viewof plot = (async () => {
const data = await FileAttachment("Car Dataset 1945-2020.csv").csv({ typed: true });
const expanded = [];
let maxOverallRatio = 0;
let carWithMaxRatio = null;
let maxRatioCarYear = null;
for (const car of data) {
const hp = +car.engine_hp;
const weight = +car.curb_weight_kg;
const yearFrom = +car.Year_from;
const yearTo = car.Year_to ? +car.Year_to : yearFrom;
if (
isNaN(hp) || hp <= 0 ||
isNaN(weight) || weight <= 0 ||
isNaN(yearFrom) || yearFrom <= 1900 ||
isNaN(yearTo) || yearTo < yearFrom
) continue;
const ratio = hp / weight;
if (ratio > maxOverallRatio) {
maxOverallRatio = ratio;
carWithMaxRatio = car;
maxRatioCarYear = yearFrom;
}
for (let y = yearFrom; y <= yearTo; y++) {
expanded.push({ year: y, ratio });
}
}
if (expanded.length === 0) {
return html`<div>No valid data found. Check 'engine_hp', 'curb_weight_kg', and 'Year_from' in your file.</div>`;
}
// Bin years by 5-year intervals
const binSize = 5;
const bucket = y => Math.floor(y / binSize) * binSize;
// Group by year bin, collect ratios per bin
const groupedMap = new Map();
for (const d of expanded) {
const b = bucket(d.year);
if (!groupedMap.has(b)) groupedMap.set(b, []);
groupedMap.get(b).push(d.ratio);
}
// Sort bins ascending by year and create array with start years as labels
const grouped = Array.from(groupedMap.entries())
.map(([binStart, ratios]) => ({ year: binStart, ratios }))
.sort((a, b) => a.year - b.year);
// Setup SVG dimensions and margins
const chartWidth = width;
const chartHeight = chartWidth;
const marginTop = chartHeight * 0.15;
const marginBottom = chartHeight * 0.05;
const margin = chartHeight * 0.1;
const innerRadius = chartHeight * 0.065;
const outerRadius = (Math.min(chartWidth, chartHeight) / 2) - Math.max(marginTop, marginBottom, margin);
// Gap configuration
const gapDegrees = 20;
const gapRadians = gapDegrees * Math.PI / 180;
// Available angle for data
const availableAngle = 2 * Math.PI - gapRadians;
// Angle per bin
const anglePerBin = availableAngle / grouped.length;
// Starting angle
const startAngle = gapRadians / 2;
// Scale radius from power-to-weight ratio, inverted
const radiusScale = d3.scaleSqrt()
.domain([0, 1])
.range([outerRadius, innerRadius])
.clamp(true);
// Color Scale for Years with custom green-yellow-red
const minYear = d3.min(grouped, d => d.year);
const maxYear = d3.max(grouped, d => d.year + binSize);
const colorDomain = [minYear, (minYear + maxYear) / 2, maxYear];
const colorRange = ["green", "yellow", "red"];
const colorScale = d3.scaleLinear()
.domain(colorDomain)
.range(colorRange)
.clamp(true);
// Create SVG container
const svg = d3.select(document.createElementNS("http://www.w3.org/2000/svg", "svg"))
.attr("width", chartWidth)
.attr("height", chartHeight)
.attr("viewBox", [0, 0, chartWidth, chartHeight])
.style("background", "#1a1a1a");
// Add automotive bezel styling
const defs = svg.append("defs");
// Create gradient for bezel
const bezelGradient = defs.append("radialGradient")
.attr("id", "bezelGradient");
bezelGradient.append("stop")
.attr("offset", "0%")
.attr("stop-color", "#555");
bezelGradient.append("stop")
.attr("offset", "85%")
.attr("stop-color", "#333");
bezelGradient.append("stop")
.attr("offset", "100%")
.attr("stop-color", "#111");
// Function to get angle for a given bin index
const getAngle = (index) => startAngle + index * anglePerBin;
// Group element centered in SVG
const g = svg.append("g")
.attr("transform", `translate(${chartWidth / 2},${chartHeight / 2 + (marginTop - marginBottom) / 2})`);
// Add outer bezel ring
g.append("circle")
.attr("r", outerRadius + chartWidth * 0.02)
.attr("fill", "url(#bezelGradient)")
.attr("stroke", "#666")
.attr("stroke-width", 3);
// Add tick marks based on actual year data
const tickGroup = g.append("g").attr("class", "ticks");
// Major ticks at the start of each year bin
grouped.forEach((group, i) => {
const angle = getAngle(i);
tickGroup.append("line")
.attr("x1", Math.sin(angle) * (outerRadius + chartWidth * 0.005))
.attr("y1", -Math.cos(angle) * (outerRadius + chartWidth * 0.005))
.attr("x2", Math.sin(angle) * (outerRadius + chartWidth * 0.015))
.attr("y2", -Math.cos(angle) * (outerRadius + chartWidth * 0.015))
.attr("stroke", "#ffa500")
.attr("stroke-width", 3);
});
// Add major tick for 2025 at the end
const finalAngle = startAngle + grouped.length * anglePerBin;
tickGroup.append("line")
.attr("x1", Math.sin(finalAngle) * (outerRadius + chartWidth * 0.005))
.attr("y1", -Math.cos(finalAngle) * (outerRadius + chartWidth * 0.005))
.attr("x2", Math.sin(finalAngle) * (outerRadius + chartWidth * 0.015))
.attr("y2", -Math.cos(finalAngle) * (outerRadius + chartWidth * 0.015))
.attr("stroke", "#ffa500")
.attr("stroke-width", 3);
// Minor ticks within each year bin
grouped.forEach((group, i) => {
const binStartAngle = getAngle(i);
const binEndAngle = i < grouped.length - 1 ? getAngle(i + 1) : startAngle + availableAngle;
const anglePerYear = (binEndAngle - binStartAngle) / binSize;
for (let j = 1; j < binSize; j++) {
const angle = binStartAngle + j * anglePerYear;
tickGroup.append("line")
.attr("x1", Math.sin(angle) * (outerRadius + chartWidth * 0.005))
.attr("y1", -Math.cos(angle) * (outerRadius + chartWidth * 0.005))
.attr("x2", Math.sin(angle) * (outerRadius + chartWidth * 0.01))
.attr("y2", -Math.cos(angle) * (outerRadius + chartWidth * 0.01))
.attr("stroke", "#888")
.attr("stroke-width", 1);
}
});
// Draw circular grid rings for reference
const rings = [0, 0.1, 0.3, 0.5, 0.7, 1];
const ringGroup = g.append("g").attr("class", "rings");
ringGroup.selectAll("circle")
.data(rings)
.join("circle")
.attr("r", d => radiusScale(d))
.attr("fill", "none")
.attr("stroke", "#666")
.attr("stroke-width", 1)
.attr("stroke-dasharray", "4 4");
// Add labels for radial rings with "Ratio (hp/kg)"
ringGroup.selectAll("text")
.data(rings)
.join("text")
.attr("x", 0)
.attr("y", d => -radiusScale(d))
.attr("dy", "-0.5em")
.attr("text-anchor", "middle")
.attr("fill", "#FFF")
.style("font-size", "12px")
.style("font-weight", "bold")
.text(d => d.toFixed(1));
// Add a single "Ratio (hp/kg)" label
ringGroup.append("text")
.attr("x", 0)
.attr("y", -outerRadius - 40)
.attr("text-anchor", "middle")
.attr("fill", "#FFF")
.style("font-size", "14px")
.style("font-weight", "bold")
.text("Ratio (hp/kg)");
// Function to get middle angle for a bin (for labels)
const getMiddleAngle = (index) => startAngle + (index) * anglePerBin;
// Draw radial lines for each bin
const yearGroup = g.append("g").attr("class", "years");
// Draw lines at the start of each bin
yearGroup.selectAll("line")
.data(grouped)
.join("line")
.attr("x1", (d, i) => Math.sin(getAngle(i)) * innerRadius)
.attr("y1", (d, i) => -Math.cos(getAngle(i)) * innerRadius)
.attr("x2", (d, i) => Math.sin(getAngle(i)) * outerRadius)
.attr("y2", (d, i) => -Math.cos(getAngle(i)) * outerRadius)
.attr("stroke", (d, i) => i === 0 ? "#666" : colorScale(d.year))
.attr("stroke-width", (d, i) => i === 0 ? 3 : 1)
.attr("stroke-opacity", (d, i) => i === 0 ? 1 : 0.7);
// Add year labels at middle of each bin
yearGroup.selectAll("text")
.data(grouped)
.join("text")
.attr("x", (d, i) => {
const angle = getMiddleAngle(i);
return Math.sin(angle) * (outerRadius + chartWidth * 0.04);
})
.attr("y", (d, i) => {
const angle = getMiddleAngle(i);
return -Math.cos(angle) * (outerRadius + chartWidth * 0.04);
})
.attr("text-anchor", (d, i) => {
const angle = getMiddleAngle(i);
if (angle > Math.PI * 0.75 && angle < Math.PI * 1.25) return "middle";
return (angle > Math.PI) ? "end" : "start";
})
.attr("alignment-baseline", "middle")
.style("font-size", "16px")
.style("font-weight", "bold")
.attr("fill", "#ffa500")
.text(d => d.year);
// Draw data points with automotive styling
const jitterRadius = 0.04;
const jitterAngle = anglePerBin * 0.75;
const pointsGroup = g.append("g").attr("class", "points");
grouped.forEach((group, i) => {
const binStartAngle = getAngle(i) + (anglePerBin - jitterAngle) / 2;
const binColor = colorScale(group.year + binSize / 2);
group.ratios.forEach(ratio => {
const clampedRatio = Math.min(Math.max(ratio, 0), 1);
const angleJitter = Math.random() * jitterAngle;
const radiusJitter = (Math.random() - 0.5) * jitterRadius;
const angle = binStartAngle + angleJitter;
const radius = radiusScale(clampedRatio + radiusJitter);
pointsGroup.append("circle")
.attr("cx", Math.sin(angle) * radius)
.attr("cy", -Math.cos(angle) * radius)
.attr("r", 3)
.attr("fill", binColor)
.attr("fill-opacity", 0.5)
.attr("stroke", "none");
});
});
// --- Title and Description ---
svg.append("text")
.attr("x", margin / 4)
.attr("y", margin / 2)
.attr("text-anchor", "start")
.style("font-size", "20px")
.style("font-weight", "bold")
.style("fill", "#ff6b35")
.text("Car Power-to-Weight Ratio (hp/kg) by Year");
const descriptionLines = [
"The Power-to-weight ratio of a car, or horsepower/kilograms",
"is a measure to how quick that car is.",
"I have decided to compare power-to-weight ratios io nearly",
"all car models from 1935 to 2025 to see whether cars have",
"gotten quicker over time, enjoy!",
"",
"By Ostap Trush."
];
const descriptionYStart = margin / 2 + 30;
const lineHeight = 12;
descriptionLines.forEach((line, index) => {
svg.append("text")
.attr("x", margin / 4)
.attr("y", descriptionYStart + index * lineHeight)
.attr("text-anchor", "start")
.style("font-size", "12px")
.style("fill", "#888")
.text(line);
});
// --- End Title and Description ---
// Add legend explanation
svg.append("text")
.attr("x", chartWidth / 2)
.attr("y", chartHeight - margin / 4)
.attr("text-anchor", "middle")
.style("font-size", "14px")
.attr("fill", "#ffa500")
.text("Data: https://www.kaggle.com/datasets/jahaidulislam/car-specification-dataset-1945-2020");
// Draw gap boundary lines with automotive styling
const leftGapAngle = 0;
// Add final line at the end of the last bin (red)
const finalLineAngle = startAngle + grouped.length * anglePerBin;
g.append("line")
.attr("x1", Math.sin(finalLineAngle) * innerRadius)
.attr("y1", -Math.cos(finalLineAngle) * innerRadius)
.attr("x2", Math.sin(finalLineAngle) * outerRadius)
.attr("y2", -Math.cos(finalLineAngle) * outerRadius)
.attr("stroke", "#666")
.attr("stroke-width", 3);
// Add 2025 label
g.append("text")
.attr("x", Math.sin(finalLineAngle) * (outerRadius + chartWidth * 0.04))
.attr("y", -Math.cos(finalLineAngle) * (outerRadius + chartWidth * 0.04))
.attr("text-anchor", finalLineAngle > Math.PI ? "end" : "start")
.attr("alignment-baseline", "middle")
.style("font-size", "16px")
.style("font-weight", "bold")
.attr("fill", "#ffa500")
.text("2025");
// --- Odometer Needle for Max Ratio Car Year ---
if (maxRatioCarYear !== null) {
const needleGroup = g.append("g").attr("class", "odometer-needle");
// Find the relevant bin for maxRatioCarYear
const needleBinStartYear = bucket(maxRatioCarYear);
const needleBinIndex = grouped.findIndex(d => d.year === needleBinStartYear);
let needleAngle;
if (needleBinIndex !== -1) {
const binStartAngle = getAngle(needleBinIndex);
const binEndAngle = (needleBinIndex < grouped.length - 1) ? getAngle(needleBinIndex + 1) : startAngle + availableAngle;
const anglePerYear = (binEndAngle - binStartAngle) / binSize;
const yearOffset = maxRatioCarYear - needleBinStartYear;
needleAngle = binStartAngle + yearOffset * anglePerYear + (anglePerYear / 2);
} else {
needleAngle = startAngle;
}
// Needle dimensions
const needleOuterLength = outerRadius;
const needleInnerOffset = innerRadius * 0.5;
const triangleBaseWidth = 20;
// Calculate vertices for the isosceles triangle
const cosAngle = Math.cos(needleAngle - Math.PI / 2);
const sinAngle = Math.sin(needleAngle - Math.PI / 2);
const x1 = needleInnerOffset * cosAngle - (-triangleBaseWidth / 2) * sinAngle;
const y1 = needleInnerOffset * sinAngle + (-triangleBaseWidth / 2) * cosAngle;
const x2 = needleInnerOffset * cosAngle - (triangleBaseWidth / 2) * sinAngle;
const y2 = needleInnerOffset * sinAngle + (triangleBaseWidth / 2) * cosAngle;
const x3 = needleOuterLength * cosAngle;
const y3 = needleOuterLength * sinAngle;
needleGroup.append("path")
.attr("d", `M ${x1},${y1} L ${x3},${y3} L ${x2},${y2} Z`)
.attr("fill", "#bdbdbd")
.attr("fill-opacity", 0.7)
.attr("stroke", "#525252")
.attr("stroke-width", 0.5);
}
// --- End Odometer Needle ---
// --- Central Content (Max Ratio with Car Info) ---
if (carWithMaxRatio) {
const centralContentGroup = g.append("g").attr("class", "central-info");
centralContentGroup.append("circle")
.attr("r", innerRadius * 0.97)
.attr("fill", "url(#bezelGradient)")
.attr("stroke", "#ffa500")
.attr("stroke-width", 2);
centralContentGroup.append("text")
.attr("x", 0)
.attr("y", -20)
.attr("text-anchor", "middle")
.attr("alignment-baseline", "middle")
.style("font-size", "14px")
.style("font-weight", "bold")
.attr("fill", "#FFD700")
.text(`${carWithMaxRatio.Make} ${carWithMaxRatio.Modle}`);
centralContentGroup.append("text")
.attr("x", 0)
.attr("y", 5)
.attr("text-anchor", "middle")
.attr("alignment-baseline", "middle")
.style("font-size", "24px")
.style("font-weight", "bold")
.attr("fill", "#FFF")
.text(`${maxOverallRatio.toFixed(2)}`);
centralContentGroup.append("text")
.attr("x", 0)
.attr("y", 30)
.attr("text-anchor", "middle")
.attr("alignment-baseline", "middle")
.style("font-size", "12px")
.attr("fill", "#FFF")
.text("Max HP/kg");
} else {
g.append("text")
.attr("x", 0)
.attr("y", 0)
.attr("text-anchor", "middle")
.attr("alignment-baseline", "middle")
.style("font-size", "16px")
.attr("fill", "#ffa500")
.text("No Data");
}
// --- End Central Content ---
// --- Color Legend (Years) ---
const legendWidth = 200;
const legendHeight = 20;
const legendX = chartWidth - margin / 4 - legendWidth;
const legendY = margin / 2;
const legendGroup = svg.append("g")
.attr("transform", `translate(${legendX}, ${legendY})`);
// Create linear gradient for the legend
const legendGradient = defs.append("linearGradient")
.attr("id", "legendGradient")
.attr("x1", "0%")
.attr("y1", "0%")
.attr("x2", "100%")
.attr("y2", "0%");
legendGradient.append("stop")
.attr("offset", "0%")
.attr("stop-color", colorRange[0]);
legendGradient.append("stop")
.attr("offset", "50%")
.attr("stop-color", colorRange[1]);
legendGradient.append("stop")
.attr("offset", "100%")
.attr("stop-color", colorRange[2]);
// Draw the legend rectangle
legendGroup.append("rect")
.attr("width", legendWidth)
.attr("height", legendHeight)
.attr("fill", "url(#legendGradient)");
// Add legend labels
legendGroup.append("text")
.attr("x", 0)
.attr("y", legendHeight + 20)
.attr("text-anchor", "start")
.attr("fill", "#ffa500")
.style("font-size", "14px")
.style("font-weight", "bold")
.text(minYear);
legendGroup.append("text")
.attr("x", legendWidth / 2)
.attr("y", legendHeight - 30)
.attr("text-anchor", "middle")
.attr("fill", "#ffa500")
.style("font-size", "16px")
.style("font-weight", "bold")
.text("Years");
legendGroup.append("text")
.attr("x", legendWidth)
.attr("y", legendHeight + 20)
.attr("text-anchor", "end")
.attr("fill", "#ffa500")
.style("font-size", "14px")
.style("font-weight", "bold")
.text(maxYear);
// --- End Color Legend ---
return svg.node();
})()