(function () { "use strict"; const SVG_NS = "http://www.w3.org/2000/svg"; const GROUP_COLORS = { "Recorded": "#0072B2", "Synthetic control": "#BDBDBD", "Place": "#CC79A7", "Head direction": "#56B4E9", "Speed": "#E69F00", }; const DATASET_RECORDED_COLORS = { monkey: "#0072B2", speech: "#009E73", mc_pacman: "#D55E00", }; const TEXT = "#17202A"; const MUTED = "#607080"; const GRID = "#E7ECEF"; const DARK_GRID = "#AAB6BE"; function clamp(value, low, high) { return Math.max(low, Math.min(high, value)); } function svgElement(tag, attributes, text) { const node = document.createElementNS(SVG_NS, tag); Object.entries(attributes || {}).forEach(([name, value]) => { node.setAttribute(name, String(value)); }); if (text !== undefined) node.textContent = text; return node; } function htmlElement(tag, className, text) { const node = document.createElement(tag); if (className) node.className = className; if (text !== undefined) node.textContent = text; return node; } function mixColor(start, end, fraction) { const t = clamp(fraction, 0, 1); const parse = (hex) => [ parseInt(hex.slice(1, 3), 16), parseInt(hex.slice(3, 5), 16), parseInt(hex.slice(5, 7), 16), ]; const a = parse(start); const b = parse(end); const values = a.map((value, index) => Math.round(value + (b[index] - value) * t)); return `rgb(${values[0]}, ${values[1]}, ${values[2]})`; } function gosiColor(value, low, high) { const span = Math.max(high - low, 1e-9); return mixColor("#DCE7EC", "#245B78", (value - low) / span); } function featureColor(dataset, feature, validationDomain) { if (dataset === "allen_neuropixels") { return gosiColor(feature.validation_value || 0, validationDomain[0], validationDomain[1]); } if (feature.group === "Recorded" && DATASET_RECORDED_COLORS[dataset]) { return DATASET_RECORDED_COLORS[dataset]; } return GROUP_COLORS[feature.group] || "#6A51A3"; } function numericTicks(low, high, count) { if (!Number.isFinite(low) || !Number.isFinite(high)) return [0]; if (Math.abs(high - low) < 1e-12) return [low]; return Array.from({ length: count }, (_, index) => low + (high - low) * index / (count - 1)); } function integerTicks(maximum) { const max = Math.max(0, Math.round(maximum)); if (max <= 4) return Array.from({ length: max + 1 }, (_, index) => index); const candidates = [0, Math.round(max / 3), Math.round(2 * max / 3), max]; return [...new Set(candidates)]; } function formatScore(value) { if (Math.abs(value) >= 0.1) return value.toFixed(2).replace(/\.?0+$/, ""); if (Math.abs(value) >= 0.01) return value.toFixed(3).replace(/\.?0+$/, ""); return value.toPrecision(2).replace("e+", "e"); } function signedScore(value) { const prefix = value > 0 ? "+" : ""; return `${prefix}${Number(value).toPrecision(4).replace("e+", "e")}`; } function bezier(start, controlOne, controlTwo, end, fraction) { const t = clamp(fraction, 0, 1); const u = 1 - t; return { x: u * u * u * start.x + 3 * u * u * t * controlOne.x + 3 * u * t * t * controlTwo.x + t * t * t * end.x, y: u * u * u * start.y + 3 * u * u * t * controlOne.y + 3 * u * t * t * controlTwo.y + t * t * t * end.y, }; } function groupSpans(features) { const spans = []; features.forEach((feature, index) => { const previous = spans[spans.length - 1]; if (!previous || previous.group !== feature.group) { spans.push({ group: feature.group, first: index, last: index }); } else { previous.last = index; } }); return spans; } function buildState(container, detailNode, rasterData, attributionData) { const attribution = new Map( attributionData.features.map((feature) => [Number(feature.feature_index), feature]) ); const validationValues = rasterData.features .map((feature) => Number(feature.validation_value)) .filter(Number.isFinite); const validationDomain = validationValues.length ? [Math.min(...validationValues), Math.max(...validationValues)] : [0, 1]; const features = rasterData.features.map((feature, row) => { const featureIndex = Number(feature.feature_index); const score = attribution.get(featureIndex); if (!score) throw new Error(`Missing attribution for feature ${featureIndex}`); return { ...feature, ...score, feature_index: featureIndex, row, color: featureColor(rasterData.dataset, feature, validationDomain), }; }); if (features.length !== attribution.size) { throw new Error("Raster and attribution feature sets differ"); } container.replaceChildren(); const visual = htmlElement("div", "feature-story-visual"); const canvas = htmlElement("canvas", "feature-story-canvas"); canvas.setAttribute("aria-hidden", "true"); const svg = svgElement("svg", { class: "feature-story-overlay", role: "img", "aria-label": "Feature activity rows linked to signed Kernel SHAP ranks", }); svg.append( svgElement("title", {}, "Feature activity linked to signed Kernel SHAP ranking"), svgElement( "desc", {}, "Each feature dot moves from its row in the example activity raster to its signed attribution rank." ) ); visual.append(canvas, svg); const playback = htmlElement("div", "feature-story-playback"); const sourceLabel = htmlElement("span", "feature-story-playback-label", "Example-trial order"); sourceLabel.classList.add("source"); const slider = htmlElement("input", "feature-story-slider"); slider.type = "range"; slider.min = "0"; slider.max = "1"; slider.step = "0.01"; slider.value = "1"; slider.setAttribute("aria-label", "Feature mapping progress from example-trial order to SHAP rank"); const targetLabel = htmlElement( "span", "feature-story-playback-label", attributionData.tied ? "Feature order" : "SHAP rank" ); targetLabel.classList.add("target"); playback.append(sourceLabel, slider, targetLabel); container.append(visual, playback); const state = { signature: `${rasterData.dataset}:${attributionData.model}`, container, detailNode, rasterData, attributionData, validationDomain, features, visual, canvas, svg, slider, progress: 1, hovered: null, locked: null, keyboardRow: 0, positions: new Map(), circles: new Map(), animationFrame: null, resizeObserver: null, abortController: new AbortController(), geometry: null, }; container.__featureStory = state; return state; } function addText(svg, x, y, text, options) { const settings = options || {}; const node = svgElement("text", { x, y, fill: settings.fill || TEXT, "font-size": settings.size || 11, "font-weight": settings.weight || 400, "text-anchor": settings.anchor || "start", "dominant-baseline": settings.baseline || "alphabetic", class: settings.className || "", }, text); svg.appendChild(node); return node; } function computeGeometry(state, width) { const mobile = width < 720; if (mobile) { const rasterLabelWidth = state.rasterData.dataset === "allen_neuropixels" ? 10 : 74; const raster = { x: rasterLabelWidth, y: 54, width: Math.max(120, width - rasterLabelWidth - 18), height: 244, }; const chart = { x: 46, y: 394, width: Math.max(130, width - 92), height: 254, }; return { mobile, width, height: 708, raster, chart }; } const rasterLabelWidth = state.rasterData.dataset === "allen_neuropixels" ? 18 : 92; const chartX = Math.round(width * 0.59); const raster = { x: rasterLabelWidth, y: 54, width: Math.max(220, chartX - rasterLabelWidth - 64), height: 438, }; const chart = { x: chartX, y: 54, width: Math.max(220, width - chartX - 52), height: 438, }; return { mobile, width, height: 552, raster, chart }; } function drawRaster(state) { const { raster } = state.geometry; const canvas = state.canvas; const ratio = Math.min(window.devicePixelRatio || 1, 2); canvas.width = Math.round(state.geometry.width * ratio); canvas.height = Math.round(state.geometry.height * ratio); canvas.style.width = `${state.geometry.width}px`; canvas.style.height = `${state.geometry.height}px`; const context = canvas.getContext("2d", { alpha: false }); context.setTransform(ratio, 0, 0, ratio, 0, 0); context.fillStyle = "#FFFFFF"; context.fillRect(0, 0, state.geometry.width, state.geometry.height); const rows = state.features.length; const columns = state.rasterData.time_ms.length; const rowHeight = raster.height / Math.max(rows, 1); const columnWidth = raster.width / Math.max(columns, 1); const maximum = Math.max(Number(state.rasterData.max_count), 1); state.rasterData.counts.forEach((row, rowIndex) => { row.forEach((value, columnIndex) => { if (!value) return; context.fillStyle = mixColor("#F8FAFB", "#263238", Number(value) / maximum); const x0 = raster.x + columnIndex * columnWidth; const x1 = raster.x + (columnIndex + 1) * columnWidth; const y0 = raster.y + rowIndex * rowHeight; const y1 = raster.y + (rowIndex + 1) * rowHeight; context.fillRect( Math.floor(x0), Math.floor(y0), Math.max(1, Math.ceil(x1) - Math.floor(x0)), Math.max(1, Math.ceil(y1) - Math.floor(y0)) ); }); context.fillStyle = state.features[rowIndex].color; context.fillRect( raster.x + raster.width + 3, raster.y + rowIndex * rowHeight, 5, Math.max(1, rowHeight) ); }); context.strokeStyle = "#CFD8DE"; context.lineWidth = 1; context.strokeRect(raster.x - 0.5, raster.y - 0.5, raster.width + 1, raster.height + 1); } function drawCountLegend(state, y) { const { svg, geometry } = state; let ticks = integerTicks(state.rasterData.max_count); if (geometry.width < 300 && ticks.length > 2) { ticks = [0, Math.round(state.rasterData.max_count)]; } const maximum = Math.max(Number(state.rasterData.max_count), 1); const itemWidth = geometry.mobile ? 32 : 38; const labelWidth = geometry.width < 300 ? 50 : 58; const width = labelWidth + ticks.length * itemWidth; const start = Math.max(geometry.raster.x, geometry.raster.x + geometry.raster.width - width); addText(svg, start, y, "Count / bin", { size: 9, fill: MUTED, weight: 600 }); ticks.forEach((tick, index) => { const x = start + labelWidth + index * itemWidth; svg.appendChild(svgElement("rect", { x, y: y - 9, width: 9, height: 9, rx: 1, fill: mixColor("#F8FAFB", "#263238", tick / maximum), stroke: "#CAD3D9", "stroke-width": 0.6, })); addText(svg, x + 13, y, String(tick), { size: 9, fill: MUTED }); }); } function drawReferenceLabels(state) { const { svg, geometry, features } = state; if (state.rasterData.dataset === "allen_neuropixels") { addText(svg, geometry.raster.x + geometry.raster.width + 10, geometry.raster.y - 7, "gOSI", { size: 9, fill: MUTED, anchor: "end", }); return; } const rowHeight = geometry.raster.height / Math.max(features.length, 1); groupSpans(features).forEach((span) => { const y = geometry.raster.y + ((span.first + span.last + 1) / 2) * rowHeight; addText(svg, geometry.raster.x - 8, y, span.group, { size: geometry.mobile ? 9 : 10, fill: MUTED, anchor: "end", baseline: "middle", }); }); } function drawFeatureLegend(state, y) { const { svg, geometry, features } = state; let x = geometry.chart.x; if (state.rasterData.dataset === "allen_neuropixels") { addText(svg, x, y, "gOSI", { size: 9, fill: MUTED, weight: 600 }); x += 31; const values = numericTicks(state.validationDomain[0], state.validationDomain[1], 3); values.forEach((value) => { svg.appendChild(svgElement("circle", { cx: x + 5, cy: y - 3, r: 4, fill: gosiColor(value, state.validationDomain[0], state.validationDomain[1]), stroke: "#FFFFFF", "stroke-width": 0.6, })); addText(svg, x + 12, y, value.toFixed(2), { size: 9, fill: MUTED }); x += geometry.mobile ? 50 : 58; }); return; } const groups = [...new Map(features.map((feature) => [feature.group, feature.color])).entries()]; groups.forEach(([group, color]) => { svg.appendChild(svgElement("circle", { cx: x + 4, cy: y - 3, r: 4, fill: color, stroke: "#FFFFFF", "stroke-width": 0.6, })); addText(svg, x + 12, y, group, { size: 9, fill: MUTED }); x += 18 + group.length * 5.3; }); } function drawAxes(state) { const { svg, geometry, features } = state; const { raster, chart } = geometry; addText(svg, raster.x, 20, "Example activity · one trial", { size: 13, weight: 700 }); drawCountLegend(state, 39); drawReferenceLabels(state); const time = state.rasterData.time_ms.map(Number); const timeLow = time[0]; const timeHigh = time[time.length - 1]; const timeTicks = [timeLow]; if (timeLow < 0 && timeHigh > 0) timeTicks.push(0); if (timeHigh !== timeLow) timeTicks.push(timeHigh); svg.appendChild(svgElement("line", { x1: raster.x, x2: raster.x + raster.width, y1: raster.y + raster.height, y2: raster.y + raster.height, stroke: DARK_GRID, "stroke-width": 1, })); timeTicks.forEach((tick) => { const fraction = (tick - timeLow) / Math.max(timeHigh - timeLow, 1e-9); const x = raster.x + fraction * raster.width; svg.appendChild(svgElement("line", { x1: x, x2: x, y1: raster.y + raster.height, y2: raster.y + raster.height + 4, stroke: DARK_GRID, })); addText(svg, x, raster.y + raster.height + 16, `${Math.round(tick)}`, { size: 9, fill: MUTED, anchor: "middle", }); }); addText(svg, raster.x + raster.width / 2, raster.y + raster.height + 34, "Time from scoring onset (ms)", { size: 10, fill: MUTED, anchor: "middle", }); const chartTitleY = geometry.mobile ? 360 : 20; const legendY = geometry.mobile ? 380 : 39; addText( svg, chart.x, chartTitleY, state.attributionData.tied ? "Signed Kernel SHAP values" : "Signed Kernel SHAP ranking", { size: 13, weight: 700 } ); drawFeatureLegend(state, legendY); const scores = features.map((feature) => Number(feature.signed_attribution)); let scoreLow = Math.min(0, ...scores); let scoreHigh = Math.max(0, ...scores); let span = scoreHigh - scoreLow; if (span < 1e-12) span = Math.max(Math.abs(scoreHigh), 1) * 0.1; scoreLow -= span * 0.08; scoreHigh += span * 0.08; state.scoreDomain = [scoreLow, scoreHigh]; const xTicks = numericTicks(scoreLow, scoreHigh, geometry.mobile ? 3 : 5); xTicks.forEach((tick) => { const x = chart.x + (tick - scoreLow) / (scoreHigh - scoreLow) * chart.width; svg.appendChild(svgElement("line", { x1: x, x2: x, y1: chart.y, y2: chart.y + chart.height, stroke: Math.abs(tick) < span * 0.03 ? DARK_GRID : GRID, "stroke-width": Math.abs(tick) < span * 0.03 ? 1.2 : 0.8, })); addText(svg, x, chart.y + chart.height + 17, formatScore(tick), { size: 9, fill: MUTED, anchor: "middle", }); }); addText(svg, chart.x + chart.width / 2, chart.y + chart.height + 36, "Signed Kernel SHAP value", { size: 10, fill: MUTED, anchor: "middle", }); const rankMaximum = features.length; const rankTicks = [...new Set([1, Math.max(1, Math.round(rankMaximum / 2)), rankMaximum])]; addText(svg, chart.x + chart.width + 10, chart.y - 10, state.attributionData.tied ? "Feature" : "Rank", { size: 9, fill: MUTED, weight: 600, }); rankTicks.forEach((rank) => { const y = chart.y + (rank - 1) / Math.max(rankMaximum - 1, 1) * chart.height; svg.appendChild(svgElement("line", { x1: chart.x + chart.width, x2: chart.x + chart.width + 4, y1: y, y2: y, stroke: DARK_GRID, })); addText(svg, chart.x + chart.width + 8, y, String(rank), { size: 9, fill: MUTED, baseline: "middle", }); }); } function sourcePosition(state, feature) { const { raster } = state.geometry; return { x: raster.x + raster.width + 6, y: raster.y + (feature.row + 0.5) / state.features.length * raster.height, }; } function targetPosition(state, feature) { const { chart } = state.geometry; const [low, high] = state.scoreDomain; const x = chart.x + (Number(feature.signed_attribution) - low) / Math.max(high - low, 1e-9) * chart.width; const displayedRank = state.attributionData.tied ? feature.row + 1 : Number(feature.rank); const y = chart.y + (displayedRank - 1) / Math.max(state.features.length - 1, 1) * chart.height; return { x, y }; } function featurePosition(state, feature, progress) { const start = sourcePosition(state, feature); const end = targetPosition(state, feature); const mobile = state.geometry.mobile; const controlOne = mobile ? { x: start.x, y: start.y + (end.y - start.y) * 0.42 } : { x: start.x + (end.x - start.x) * 0.40, y: start.y }; const controlTwo = mobile ? { x: end.x, y: end.y - (end.y - start.y) * 0.34 } : { x: end.x - (end.x - start.x) * 0.32, y: end.y }; return bezier(start, controlOne, controlTwo, end, progress); } function updateConnector(state) { if (!state.connector || !state.rowHighlight) return; const selectedId = state.locked !== null ? state.locked : state.hovered; if (selectedId === null) { state.connector.setAttribute("visibility", "hidden"); state.rowHighlight.setAttribute("visibility", "hidden"); state.circles.forEach((circle) => { circle.setAttribute("opacity", "0.86"); circle.setAttribute("r", circle.dataset.baseRadius); }); return; } const feature = state.features.find((item) => item.feature_index === selectedId); if (!feature) return; const start = sourcePosition(state, feature); const current = state.positions.get(selectedId) || targetPosition(state, feature); const mid = state.geometry.mobile ? `C ${start.x} ${(start.y + current.y) / 2}, ${current.x} ${(start.y + current.y) / 2}, ${current.x} ${current.y}` : `C ${(start.x + current.x) / 2} ${start.y}, ${(start.x + current.x) / 2} ${current.y}, ${current.x} ${current.y}`; state.connector.setAttribute("d", `M ${start.x} ${start.y} ${mid}`); state.connector.setAttribute("stroke", feature.color); state.connector.setAttribute("visibility", "visible"); const rowHeight = state.geometry.raster.height / state.features.length; state.rowHighlight.setAttribute("x", state.geometry.raster.x); state.rowHighlight.setAttribute("y", state.geometry.raster.y + feature.row * rowHeight); state.rowHighlight.setAttribute("width", state.geometry.raster.width + 9); state.rowHighlight.setAttribute("height", Math.max(rowHeight, 1.5)); state.rowHighlight.setAttribute("stroke", feature.color); state.rowHighlight.setAttribute("visibility", "visible"); state.circles.forEach((circle, featureId) => { const active = featureId === selectedId; circle.setAttribute("opacity", active ? "1" : "0.16"); circle.setAttribute("r", active ? Number(circle.dataset.baseRadius) + 2.2 : circle.dataset.baseRadius); }); } function updatePositions(state, progress) { state.progress = clamp(progress, 0, 1); state.slider.value = String(state.progress); state.features.forEach((feature) => { const point = featurePosition(state, feature, state.progress); state.positions.set(feature.feature_index, point); const circle = state.circles.get(feature.feature_index); if (circle) { circle.setAttribute("cx", point.x); circle.setAttribute("cy", point.y); } }); updateConnector(state); } function updateReplayPositions(state, progress) { const globalProgress = clamp(progress, 0, 1); state.progress = globalProgress; state.slider.value = String(globalProgress); state.features.forEach((feature) => { const delay = state.features.length > 1 ? feature.row / (state.features.length - 1) * 0.12 : 0; const local = clamp((globalProgress - delay) / 0.88, 0, 1); const eased = 1 - Math.pow(1 - local, 3); const point = featurePosition(state, feature, eased); state.positions.set(feature.feature_index, point); const circle = state.circles.get(feature.feature_index); if (circle) { circle.setAttribute("cx", point.x); circle.setAttribute("cy", point.y); } }); updateConnector(state); } function detailText(state, feature, cell) { const noun = state.rasterData.dataset === "allen_neuropixels" ? "Unit" : "Feature"; const parts = [`${noun} ${feature.feature_index}`]; if (state.rasterData.dataset === "allen_neuropixels" && Number.isFinite(Number(feature.validation_value))) { parts.push(`gOSI ${Number(feature.validation_value).toFixed(3)}`); } else { parts.push(feature.group); } if (!state.attributionData.tied) parts.push(`rank ${feature.rank} of ${state.features.length}`); parts.push(`signed Kernel SHAP ${signedScore(Number(feature.signed_attribution))}`); if (cell) parts.push(`${Math.round(cell.time)} ms`, `${cell.count} count${cell.count === 1 ? "" : "s"}`); return parts.join(" · "); } function selectFeature(state, featureId, cell, lock) { const feature = state.features.find((item) => item.feature_index === featureId); if (!feature) return; if (lock !== undefined) state.locked = lock ? featureId : null; state.hovered = featureId; state.keyboardRow = feature.row; state.detailNode.textContent = detailText(state, feature, cell); updateConnector(state); } function clearSelection(state, clearLock) { if (clearLock) state.locked = null; state.hovered = null; if (state.locked === null) { state.detailNode.textContent = "Hover or tap a raster row or attribution dot to trace the same feature."; } updateConnector(state); } function rasterCellAt(state, event) { const bounds = state.visual.getBoundingClientRect(); const x = event.clientX - bounds.left; const y = event.clientY - bounds.top; const raster = state.geometry.raster; if (x < raster.x || x > raster.x + raster.width || y < raster.y || y > raster.y + raster.height) { return null; } const row = clamp(Math.floor((y - raster.y) / raster.height * state.features.length), 0, state.features.length - 1); const column = clamp(Math.floor((x - raster.x) / raster.width * state.rasterData.time_ms.length), 0, state.rasterData.time_ms.length - 1); return { row, column, time: Number(state.rasterData.time_ms[column]), count: Number(state.rasterData.counts[row][column]), }; } function replay(state) { if (state.animationFrame !== null) cancelAnimationFrame(state.animationFrame); if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { updatePositions(state, 1); return; } const pause = 130; const duration = 980; const startTime = performance.now(); updatePositions(state, 0); const step = (now) => { const elapsed = now - startTime; if (elapsed <= pause) { state.animationFrame = requestAnimationFrame(step); return; } const raw = clamp((elapsed - pause) / duration, 0, 1); updateReplayPositions(state, raw); if (raw < 1) { state.animationFrame = requestAnimationFrame(step); } else { state.animationFrame = null; } }; state.animationFrame = requestAnimationFrame(step); } function draw(state) { const width = Math.floor(state.container.clientWidth); if (width < 240) return; state.geometry = computeGeometry(state, width); state.visual.style.height = `${state.geometry.height}px`; state.svg.setAttribute("viewBox", `0 0 ${state.geometry.width} ${state.geometry.height}`); state.svg.setAttribute("width", String(state.geometry.width)); state.svg.setAttribute("height", String(state.geometry.height)); state.svg.replaceChildren( svgElement("title", {}, "Feature activity linked to signed Kernel SHAP ranking"), svgElement("desc", {}, "Hover or tap a feature to trace it from the example activity raster to its signed attribution rank.") ); drawRaster(state); drawAxes(state); state.rowHighlight = svgElement("rect", { fill: "rgba(255,255,255,0.05)", stroke: "#6A51A3", "stroke-width": 1.2, visibility: "hidden", "pointer-events": "none", }); state.svg.appendChild(state.rowHighlight); state.connector = svgElement("path", { fill: "none", stroke: "#6A51A3", "stroke-width": 1.35, opacity: 0.85, visibility: "hidden", "pointer-events": "none", }); state.svg.appendChild(state.connector); const dotGroup = svgElement("g", { class: "feature-story-dots" }); state.svg.appendChild(dotGroup); state.circles = new Map(); const baseRadius = state.features.length > 300 ? 2.7 : state.features.length > 140 ? 3.2 : 4.1; state.features.forEach((feature) => { const circle = svgElement("circle", { r: baseRadius, fill: feature.color, stroke: "#FFFFFF", "stroke-width": 0.75, opacity: 0.86, "data-feature-id": feature.feature_index, class: "feature-story-dot", }); circle.dataset.baseRadius = String(baseRadius); dotGroup.appendChild(circle); state.circles.set(feature.feature_index, circle); }); updatePositions(state, state.progress); } function bindInteractions(state) { const signal = state.abortController.signal; state.visual.addEventListener("pointermove", (event) => { if (state.locked !== null) return; const circle = event.target.closest && event.target.closest(".feature-story-dot"); if (circle) { selectFeature(state, Number(circle.dataset.featureId), null); return; } const cell = rasterCellAt(state, event); if (cell) { selectFeature(state, state.features[cell.row].feature_index, cell); } else { clearSelection(state, false); } }, { signal }); state.visual.addEventListener("pointerleave", () => { if (state.locked === null) clearSelection(state, false); }, { signal }); state.visual.addEventListener("click", (event) => { const circle = event.target.closest && event.target.closest(".feature-story-dot"); const cell = circle ? null : rasterCellAt(state, event); const featureId = circle ? Number(circle.dataset.featureId) : cell ? state.features[cell.row].feature_index : null; if (featureId === null) { clearSelection(state, true); return; } const shouldLock = state.locked !== featureId; selectFeature(state, featureId, cell, shouldLock); if (!shouldLock) clearSelection(state, true); }, { signal }); state.slider.addEventListener("input", () => { if (state.animationFrame !== null) cancelAnimationFrame(state.animationFrame); state.animationFrame = null; updatePositions(state, Number(state.slider.value)); }, { signal }); state.container.addEventListener("keydown", (event) => { if (!["ArrowDown", "ArrowUp", "Home", "End", "Enter", "Escape"].includes(event.key)) return; event.preventDefault(); if (event.key === "Escape") { clearSelection(state, true); return; } if (event.key === "Home") state.keyboardRow = 0; if (event.key === "End") state.keyboardRow = state.features.length - 1; if (event.key === "ArrowDown") state.keyboardRow = Math.min(state.features.length - 1, state.keyboardRow + 1); if (event.key === "ArrowUp") state.keyboardRow = Math.max(0, state.keyboardRow - 1); const feature = state.features[state.keyboardRow]; selectFeature(state, feature.feature_index, null, event.key === "Enter" ? true : undefined); }, { signal }); } function destroy(state) { if (!state) return; if (state.animationFrame !== null) cancelAnimationFrame(state.animationFrame); if (state.resizeObserver) state.resizeObserver.disconnect(); if (state.abortController) state.abortController.abort(); } function render(containerId, detailId, rasterData, attributionData) { const container = document.getElementById(containerId); const detailNode = document.getElementById(detailId); if (!container || !detailNode || !rasterData.features || !attributionData.features) return "waiting"; const signature = `${rasterData.dataset}:${attributionData.model}`; if (container.__featureStory && container.__featureStory.signature === signature) { return signature; } destroy(container.__featureStory); try { const state = buildState(container, detailNode, rasterData, attributionData); bindInteractions(state); state.resizeObserver = new ResizeObserver(() => { window.requestAnimationFrame(() => draw(state)); }); state.resizeObserver.observe(container); window.requestAnimationFrame(() => { draw(state); window.requestAnimationFrame(() => replay(state)); }); detailNode.textContent = "Hover or tap a raster row or attribution dot to trace the same feature."; return signature; } catch (error) { container.replaceChildren(htmlElement("div", "feature-story-error", "Feature visualization unavailable.")); detailNode.textContent = "Feature visualization unavailable."; console.error(error); return `error:${signature}`; } } let scheduleGeneration = 0; function schedule(containerId, detailId, rasterData, attributionData) { scheduleGeneration += 1; const generation = scheduleGeneration; let attempts = 0; const mount = () => { if (generation !== scheduleGeneration) return; if (document.getElementById(containerId) && document.getElementById(detailId)) { render(containerId, detailId, rasterData, attributionData); return; } attempts += 1; if (attempts < 60) window.requestAnimationFrame(mount); }; window.requestAnimationFrame(mount); } window.benchdashFeatureStory = { render, schedule }; })();