lineChart = {
const margin = {top: 40, right: 150, bottom: 50, left: 60};
const width = 800 - margin.left - margin.right;
const height = 500 - margin.top - margin.bottom;
const counts = d3.rollup(
cleaned_health_data,
v => v.length,
d => d.country,
d => d.year
);
const flat = Array.from(counts, ([country, yearMap]) =>
Array.from(yearMap, ([year, count]) => ({country, year: +year, count}))
).flat();
const [minYear, maxYear] = d3.extent(flat, d => d.year);
const lineDuration = 2000;
const dataByCountry = countries.map(country => {
const values = flat
.filter(d => d.country === country)
.sort((a, b) => d3.ascending(a.year, b.year));
return [country, values];
});
const x = d3.scaleLinear()
.domain(d3.extent(flat, d => d.year))
.range([0, width]);
const y = d3.scaleLinear()
.domain([0, d3.max(flat, d => d.count)]).nice()
.range([height, 0]);
// create lines that connect data points
const line = d3.line()
.x(d => x(d.year))
.y(d => y(d.count));
// create svg
const svg = d3.create("svg")
.attr("width", width + margin.left + margin.right)
.attr("height", height + margin.top + margin.bottom);
svg.append("text")
.attr("x", (width + margin.left + margin.right) / 2)
.attr("y", margin.top / 2)
.attr("text-anchor", "middle")
.style("font", "bold 16px sans-serif")
.text("Health Worker Incidents Over Time by Country (2017–2024)");
const g = svg.append("g")
.attr("transform", `translate(${margin.left},${margin.top})`);
// axes
g.append("g")
.attr("class", "grid")
.call(d3.axisLeft(y).tickSize(-width).tickFormat(""))
.selectAll("line")
.attr("stroke", "#ccc")
.attr("stroke-opacity", 0.7)
.select(".domain").remove();
g.append("g")
.call(d3.axisLeft(y));
g.append("g")
.attr("transform", `translate(0,${height})`)
.call(d3.axisBottom(x).tickFormat(d3.format("d")));
// From ChatGPT: draw and animate each country’s line
const countryG = g.selectAll(".country")
.data(dataByCountry)
.join("g")
.attr("class", "country");
countryG.append("path")
.attr("d", ([, values]) => line(values))
.attr("fill", "none")
.attr("stroke", ([country]) => colorScale(country))
.attr("stroke-width", 2)
.attr("stroke-dasharray", function() {
const L = this.getTotalLength();
return `${L} ${L}`;
})
.attr("stroke-dashoffset", function() {
return this.getTotalLength();
})
.transition()
.delay(0)
.duration(2000)
.ease(d3.easeLinear)
.attr("stroke-dashoffset", 0);
g.append("text")
.attr("x", width / 2)
.attr("y", height + margin.bottom - 10)
.attr("text-anchor", "middle")
.style("font", "12px sans-serif")
.text("Year");
g.append("text")
.attr("transform", "rotate(-90)")
.attr("x", -height / 2)
.attr("y", -margin.left + 15)
.attr("text-anchor", "middle")
.style("font", "12px sans-serif")
.text("Number of Incidents");
// From ChatGPT: circles fade in after each line draws
countryG.selectAll("circle")
.data(([, values]) => values)
.join("circle")
.attr("cx", d => x(d.year))
.attr("cy", d => y(d.count))
.attr("r", 0)
.attr("fill", d => colorScale(d.country))
.transition()
.delay(d => {
const t = (d.year - minYear) / (maxYear - minYear);
return t * lineDuration;
})
.duration(200)
.attr("r", 4)
.attr("opacity", 0.8);
// legend using the same global variables
const legend = svg.append("g")
.attr("transform", `translate(${width + margin.left + 20},${margin.top})`);
countries.forEach((c, i) => {
const row = legend.append("g")
.attr("transform", `translate(0, ${i * 20})`);
row.append("rect")
.attr("width", 12)
.attr("height", 12)
.attr("fill", colorScale(c));
row.append("text")
.attr("x", 16)
.attr("y", 6)
.attr("dy", "0.35em")
.text(c);
});
return svg.node();
}