chart = Generators.observe((change) => {
let svg;
const draw = () => {
const width = 800, height = 400;
const margin = {top: 50, right: 160, bottom: 40, left: 60};
svg = d3.create("svg").attr("viewBox", [0, 0, width, height]);
const filtered = parsed.filter(d => d.topic === selectedTopic);
const x = d3.scalePoint()
.domain(months)
.range([margin.left, width - margin.right]);
const y = d3.scaleLinear()
.domain([0, d3.max(filtered, d => d.emotion_word_count)]).nice()
.range([height - margin.bottom, margin.top]);
const color = d3.scaleOrdinal()
.domain(["Left", "Center", "Right"])
.range(["#e41a1c", "#377eb8", "#4daf4a"]);
const line = d3.line()
.x(d => x(d.month))
.y(d => y(d.emotion_word_count));
const biasGroups = d3.groups(filtered, d => d.bias).map(([bias, values]) => ({
bias,
values: values.sort((a, b) => a.month.localeCompare(b.month))
}));
svg.append("g")
.attr("transform", `translate(0,${height - margin.bottom})`)
.call(d3.axisBottom(x));
svg.append("g")
.attr("transform", `translate(${margin.left},0)`)
.call(d3.axisLeft(y));
svg.append("g")
.selectAll("path")
.data(biasGroups)
.join("path")
.attr("fill", "none")
.attr("stroke", d => color(d.bias))
.attr("stroke-width", 2)
.attr("d", d => line(d.values));
svg.append("g")
.selectAll("line.annotation")
.data(annotations)
.join("line")
.attr("x1", d => x(d.month))
.attr("x2", d => x(d.month))
.attr("y1", margin.top)
.attr("y2", height - margin.bottom)
.attr("stroke", "red")
.attr("stroke-dasharray", "4");
svg.append("g")
.selectAll("text.annotation")
.data(annotations)
.join("text")
.attr("x", d => x(d.month))
.attr("y", margin.top - 10)
.attr("text-anchor", "middle")
.attr("fill", "red")
.style("font-size", "12px")
.text(d => d.label);
for (const group of biasGroups) {
const thisMonth = group.values.find(d => d.month === currentMonth);
if (!thisMonth) continue;
svg.append("circle")
.attr("r", 6)
.attr("fill", color(group.bias))
.attr("stroke", "#000")
.attr("cx", x(thisMonth.month))
.attr("cy", y(thisMonth.emotion_word_count))
.on("mouseover", (event) => {
tooltip.style.display = "block";
tooltip.innerHTML = `
<strong>${group.bias}</strong><br/>
Month: ${currentMonth}<br/>
Emotion words: ${thisMonth.emotion_word_count}
`;
})
.on("mousemove", (event) => {
tooltip.style.left = event.pageX + 10 + "px";
tooltip.style.top = event.pageY - 28 + "px";
})
.on("mouseout", () => {
tooltip.style.display = "none";
});
}
svg.append("text")
.attr("x", width / 2)
.attr("y", margin.top / 2)
.attr("text-anchor", "middle")
.style("font-size", "16px")
.text(`Emotion Word Count by Bias — ${selectedTopic} (${currentMonth})`);
const legend = svg.append("g")
.attr("transform", `translate(${width - margin.right + 20}, ${margin.top})`);
const biases = ["Left", "Center", "Right"];
const colors = ["#e41a1c", "#377eb8", "#4daf4a"];
biases.forEach((bias, i) => {
legend.append("circle")
.attr("cx", 0)
.attr("cy", i * 20)
.attr("r", 6)
.attr("fill", colors[i]);
legend.append("text")
.attr("x", 10)
.attr("y", i * 20 + 5)
.text(bias)
.style("font-size", "12px")
.attr("alignment-baseline", "middle");
});
change(svg.node());
};
draw();
const interval = setInterval(() => {
draw();
}, 1000);
return () => clearInterval(interval);
})