--- a/src/chart.js
+++ b/src/chart.js
@@ -12,8 +12,10 @@
const svg = d3.select("#incident-chart");
const chartContainer = document.getElementById("chart-container");
const legendContainer = document.getElementById("legend");
+const tooltip = d3.select("#tooltip");
let rawData = [];
+let activeSeverities = new Set(SEVERITY_ORDER);
init();
@@ -24,9 +26,11 @@
renderLegend(SEVERITY_ORDER);
renderChart(rawData);
- // TODO: add ResizeObserver for responsive redraw.
- // TODO: add legend click toggles that filter active severities.
- // TODO: add bar-level tooltip interaction.
+ // Responsive resize observer
+ const resizeObserver = new ResizeObserver(() => {
+ renderChart(rawData);
+ });
+ resizeObserver.observe(chartContainer);
}
function renderLegend(severities) {
@@ -35,14 +39,33 @@
const chip = document.createElement("button");
chip.type = "button";
chip.textContent = severity;
- chip.title = "Legend toggle not wired yet";
+ chip.className = activeSeverities.has(severity) ? "active" : "";
+ chip.title = `Toggle ${severity} severity`;
+ chip.setAttribute("aria-pressed", activeSeverities.has(severity));
chip.style.borderColor = SEVERITY_COLORS[severity];
- chip.style.boxShadow = `inset 0 0 0 1px ${SEVERITY_COLORS[severity]}33`;
+ chip.style.backgroundColor = activeSeverities.has(severity)
+ ? SEVERITY_COLORS[severity]
+ : "transparent";
+ chip.style.color = activeSeverities.has(severity) ? "#fff" : SEVERITY_COLORS[severity];
+
+ chip.addEventListener("click", () => {
+ if (activeSeverities.has(severity)) {
+ if (activeSeverities.size > 1) activeSeverities.delete(severity);
+ } else {
+ activeSeverities.add(severity);
+ }
+ renderLegend(SEVERITY_ORDER);
+ renderChart(rawData);
+ });
+
legendContainer.appendChild(chip);
});
}
function renderChart(data) {
+ const filtered = data.filter((d) => activeSeverities.has(d.severity));
+ const activeSevOrder = SEVERITY_ORDER.filter((s) => activeSeverities.has(s));
+
const width = chartContainer.clientWidth;
const height = 420;
const innerWidth = width - margin.left - margin.right;
@@ -56,20 +79,29 @@
.attr("transform", `translate(${margin.left},${margin.top})`);
const months = [...new Set(data.map((d) => d.month))];
- const grouped = d3.groups(data, (d) => d.month);
+ const grouped = d3.groups(filtered, (d) => d.month);
const xMonth = d3.scaleBand().domain(months).range([0, innerWidth]).padding(0.22);
const xSeverity = d3
.scaleBand()
- .domain(SEVERITY_ORDER)
+ .domain(activeSevOrder)
.range([0, xMonth.bandwidth()])
.padding(0.12);
const y = d3
.scaleLinear()
- .domain([0, d3.max(data, (d) => d.count)])
+ .domain([0, d3.max(filtered, (d) => d.count) || 1])
.nice()
.range([innerHeight, 0]);
+
+ // Grid lines
+ g.append("g")
+ .attr("class", "grid")
+ .call(d3.axisLeft(y).tickSize(-innerWidth).tickFormat(""))
+ .selectAll("line")
+ .attr("stroke", "#e2e8f0")
+ .attr("stroke-dasharray", "2,3");
+ g.select(".grid .domain").remove();
g.append("g")
.attr("transform", `translate(0,${innerHeight})`)
@@ -95,8 +127,17 @@
.attr("y", (d) => y(d.count))
.attr("width", xSeverity.bandwidth())
.attr("height", (d) => innerHeight - y(d.count))
+ .attr("rx", 2)
.attr("fill", (d) => SEVERITY_COLORS[d.severity] || "#64748b")
- .attr("opacity", 0.9);
+ .attr("opacity", 0.9)
+ .attr("tabindex", 0)
+ .attr("role", "img")
+ .attr("aria-label", (d) => `${d.month} ${d.severity}: ${d.count} incidents`)
+ .on("pointerenter", showTooltip)
+ .on("pointermove", moveTooltip)
+ .on("pointerleave", hideTooltip)
+ .on("focus", showTooltip)
+ .on("blur", hideTooltip);
g.append("text")
.attr("class", "axis-label")
@@ -113,3 +154,31 @@
.attr("text-anchor", "middle")
.text("Incident count");
}
+
+/* ---- Tooltip helpers ---- */
+
+function showTooltip(event, d) {
+ const datum = d.severity ? d : event.target.__data__;
+ tooltip
+ .classed("visible", true)
+ .html(
+ `${datum.month}
` +
+ ` ` +
+ `${datum.severity}: ${datum.count} incidents`
+ );
+ moveTooltip(event);
+}
+
+function moveTooltip(event) {
+ const rect = chartContainer.getBoundingClientRect();
+ let x = event.clientX - rect.left + 14;
+ let y = event.clientY - rect.top - 10;
+ // Keep tooltip inside container
+ const tw = 180;
+ if (x + tw > rect.width) x = x - tw - 28;
+ tooltip.style("left", x + "px").style("top", y + "px");
+}
+
+function hideTooltip() {
+ tooltip.classed("visible", false);
+}