benchdash / assets /feature_story.js
josephsoo's picture
Simplify feature mapping interaction
c3a9029
Raw
History Blame Contribute Delete
31.6 kB
(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 };
})();