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