--- 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); +}