josephsoo commited on
Commit
0d4f1be
·
1 Parent(s): fce839b

Redesign linked feature attribution explorer

Browse files
Files changed (3) hide show
  1. app.py +276 -610
  2. assets/feature_story.js +849 -0
  3. assets/styles.css +242 -34
app.py CHANGED
@@ -680,7 +680,12 @@ def figure_layout(
680
  plot_bgcolor="#FFFFFF",
681
  margin=dict(l=54, r=28, t=58, b=58 if not legend_below else 105),
682
  font=dict(family="Arial, Helvetica, sans-serif", size=13, color=TEXT_COLOR),
683
- title=dict(font=dict(size=16, color=TEXT_COLOR), x=0.01, xanchor="left"),
 
 
 
 
 
684
  legend=legend,
685
  hoverlabel=dict(
686
  align="left",
@@ -916,20 +921,32 @@ def dataset_example_figures(dataset: str) -> tuple[go.Figure, go.Figure, str]:
916
  .to_numpy(dtype=int)
917
  )
918
  customdata = np.repeat(feature_ids[:, None], values.shape[1], axis=1)
919
- upper = max(float(np.nanpercentile(values.to_numpy(dtype=float), 99.5)), 1.0)
 
 
 
 
 
 
920
  neural_figure = go.Figure(
921
  go.Heatmap(
922
  z=values.to_numpy(dtype=float),
923
  x=time_values,
924
  y=np.arange(len(feature_ids)),
925
  customdata=customdata,
926
- colorscale=[[0.0, "#F7FAFC"], [1.0, "#164E63"]],
927
  zmin=0,
928
  zmax=upper,
929
- colorbar=dict(title="Count", thickness=13),
 
 
 
 
 
 
930
  hovertemplate=(
931
  "Feature=%{customdata}<br>Time=%{x:.0f} ms<br>"
932
- "Neural value=%{z:.3f}<extra></extra>"
933
  ),
934
  )
935
  )
@@ -1348,7 +1365,7 @@ def compute_figures(
1348
  title="Training and inference time",
1349
  barmode="group",
1350
  )
1351
- runtime.update_xaxes(title="Elapsed time (seconds, log scale)", type="log")
1352
  runtime.update_yaxes(title="", showgrid=False)
1353
  figure_layout(runtime, height=max(470, 27 * len(frame) + 155), legend_below=True)
1354
 
@@ -1519,15 +1536,14 @@ def feature_attribution_frame(dataset: str, model: str | None) -> pd.DataFrame:
1519
  return frame.sort_values("attribution_rank", kind="stable")
1520
 
1521
 
1522
- def feature_raster_figure(dataset: str) -> go.Figure:
1523
  frame = feature_example_raster[
1524
  feature_example_raster["dataset"].astype(str).eq(dataset)
1525
  ].copy()
1526
  if frame.empty:
1527
- return empty_figure("No example raster is available.", height=560)
1528
 
1529
  numeric_columns = [
1530
- "trial_index",
1531
  "display_index",
1532
  "feature_index",
1533
  "validation_value",
@@ -1547,487 +1563,67 @@ def feature_raster_figure(dataset: str) -> go.Figure:
1547
 
1548
  n_time = int(frame["n_time"].iloc[0])
1549
  value_columns = [f"value_{index:03d}" for index in range(n_time)]
1550
- raw = frame[value_columns].apply(pd.to_numeric, errors="coerce").to_numpy(dtype=float)
1551
- vmax = max(float(np.nanpercentile(raw, 99.5)), 1.0)
1552
- display = np.sqrt(np.clip(raw, 0.0, vmax) / vmax)
 
 
 
 
 
1553
  time_ms = (
1554
  np.arange(n_time) - int(frame["t0_index"].iloc[0])
1555
  ) * float(frame["bin_ms"].iloc[0])
1556
- feature_ids = frame["feature_index"].to_numpy(dtype=int)
1557
- customdata = np.empty((len(frame), n_time, 2), dtype=object)
1558
- customdata[:, :, 0] = feature_ids[:, None]
1559
- customdata[:, :, 1] = raw
1560
-
1561
- figure = make_subplots(
1562
- rows=1,
1563
- cols=2,
1564
- shared_yaxes=True,
1565
- column_widths=[0.035, 0.965],
1566
- horizontal_spacing=0.012,
1567
- )
1568
- if dataset == "allen_neuropixels":
1569
- strip_values = frame["validation_value"].to_numpy(dtype=float)[:, None]
1570
- strip_custom = np.empty((len(frame), 1, 2), dtype=object)
1571
- strip_custom[:, :, 0] = feature_ids[:, None]
1572
- strip_custom[:, :, 1] = strip_values
1573
- strip = go.Heatmap(
1574
- z=strip_values,
1575
- y=np.arange(len(frame)),
1576
- customdata=strip_custom,
1577
- colorscale="Cividis",
1578
- zmin=float(np.nanmin(strip_values)),
1579
- zmax=float(np.nanmax(strip_values)),
1580
- showscale=False,
1581
- hovertemplate=(
1582
- "Feature=%{customdata[0]:.0f}<br>"
1583
- "gOSI=%{customdata[1]:.4f}<extra></extra>"
1584
- ),
1585
- )
1586
- strip_title = "gOSI"
1587
- else:
1588
- group_colors = FEATURE_GROUP_COLORS[dataset]
1589
- groups = [group for group in group_colors if group in set(frame["feature_group"])]
1590
- codes = frame["feature_group"].map({group: index for index, group in enumerate(groups)})
1591
- scale: list[list[float | str]] = []
1592
- for index, group in enumerate(groups):
1593
- lower = index / len(groups)
1594
- upper = (index + 1) / len(groups)
1595
- scale.extend([[lower, group_colors[group]], [upper, group_colors[group]]])
1596
- strip_values = codes.to_numpy(dtype=float)[:, None]
1597
- strip_custom = np.asarray(
1598
- [[[int(feature), str(group)]] for feature, group in zip(feature_ids, frame["feature_group"])],
1599
- dtype=object,
1600
- )
1601
- strip = go.Heatmap(
1602
- z=strip_values,
1603
- y=np.arange(len(frame)),
1604
- customdata=strip_custom,
1605
- colorscale=scale,
1606
- zmin=0,
1607
- zmax=max(len(groups), 1),
1608
- showscale=False,
1609
- hovertemplate=(
1610
- "Feature=%{customdata[0]:.0f}<br>"
1611
- "Group=%{customdata[1]}<extra></extra>"
1612
  ),
1613
- )
1614
- strip_title = "Group"
1615
- figure.add_trace(strip, row=1, col=1)
1616
-
1617
- count_ticks = np.linspace(0.0, vmax, 5)
1618
- figure.add_trace(
1619
- go.Heatmap(
1620
- z=display,
1621
- x=time_ms,
1622
- y=np.arange(len(frame)),
1623
- customdata=customdata,
1624
- colorscale=[[0.0, "#FFFFFF"], [1.0, "#263238"]],
1625
- zmin=0,
1626
- zmax=1,
1627
- colorbar=dict(
1628
- title="Count",
1629
- thickness=11,
1630
- tickvals=np.sqrt(count_ticks / vmax),
1631
- ticktext=[f"{value:g}" for value in count_ticks],
1632
- ),
1633
- hovertemplate=(
1634
- "Feature=%{customdata[0]:.0f}<br>Time=%{x:.0f} ms<br>"
1635
- "Count=%{customdata[1]:.0f}<extra></extra>"
1636
- ),
1637
- ),
1638
- row=1,
1639
- col=2,
1640
- )
1641
- figure.add_vline(x=0, line_color="#D55E00", line_dash="dash", row=1, col=2)
1642
- if dataset != "allen_neuropixels":
1643
- for group in frame["feature_group"].drop_duplicates().astype(str):
1644
- indices = np.flatnonzero(frame["feature_group"].astype(str).eq(group))
1645
- if len(indices):
1646
- figure.add_annotation(
1647
- x=0.01,
1648
- xref="paper",
1649
- y=float(indices.mean()),
1650
- yref="y2",
1651
- text=group,
1652
- showarrow=False,
1653
- xanchor="left",
1654
- bgcolor="rgba(255,255,255,0.82)",
1655
- font=dict(size=10, color="#334957"),
1656
- )
1657
- figure.add_hline(
1658
- y=float(indices[-1]) + 0.5,
1659
- line_color="#FFFFFF",
1660
- line_width=1.2,
1661
- row=1,
1662
- col=2,
1663
- )
1664
- figure_layout(figure, height=560)
1665
- figure.update_layout(
1666
- title="Example neural activity",
1667
- margin=dict(l=18, r=24, t=66, b=58),
1668
- )
1669
- figure.add_annotation(
1670
- x=0.013,
1671
- y=1.035,
1672
- xref="paper",
1673
- yref="paper",
1674
- text=strip_title,
1675
- showarrow=False,
1676
- font=dict(size=10, color=MUTED_COLOR),
1677
- )
1678
- figure.update_xaxes(visible=False, row=1, col=1)
1679
- figure.update_yaxes(visible=False, autorange="reversed", row=1, col=1)
1680
- figure.update_xaxes(title="Time from scoring onset (ms)", row=1, col=2)
1681
- figure.update_yaxes(visible=False, autorange="reversed", row=1, col=2)
1682
- return figure
1683
 
1684
 
1685
- def feature_sorter_figure(
1686
  dataset: str,
1687
  model: str | None,
1688
- arrangement: str = "ranked",
1689
- ) -> go.Figure:
1690
- frame = feature_attribution_frame(dataset, model).reset_index(drop=True)
1691
  if frame.empty:
1692
- return empty_figure("Select an available method to inspect feature attributions.", height=560)
1693
- ranked = arrangement == "ranked"
1694
- has_ranking = frame["signed_attribution"].nunique(dropna=True) > 1
1695
- if ranked and not has_ranking:
1696
- return empty_figure(
1697
- "Signed feature-attribution values are tied for this method and dataset.",
1698
- height=560,
1699
- )
1700
-
1701
- n_features = len(frame)
1702
- values = frame["signed_attribution"].to_numpy(dtype=float)
1703
- ranks = frame["attribution_rank"].to_numpy(dtype=float)
1704
- feature_ids = frame["feature_index"].to_numpy(dtype=int)
1705
- y_low = min(float(np.nanmin(values)), 0.0)
1706
- y_high = max(float(np.nanmax(values)), 0.0)
1707
- y_span = max(y_high - y_low, 1e-6)
1708
- final_x = (ranks - 1.0) / max(n_features - 1, 1)
1709
- final_y = (values - y_low) / y_span
1710
- display_range = [-0.035, 1.055]
1711
-
1712
- group_colors = FEATURE_GROUP_COLORS.get(dataset, {})
1713
- if dataset == "allen_neuropixels":
1714
- validation = frame["validation_value"].to_numpy(dtype=float)
1715
- vmin = float(np.nanmin(validation))
1716
- vmax = float(np.nanmax(validation))
1717
- scale = max(vmax - vmin, 1e-9)
1718
- start_x = (validation - vmin) / scale
1719
- start_y = 0.5 + (
1720
- ((feature_ids * 37) % 101) / 100.0 - 0.5
1721
- ) * 0.42
1722
- trace_groups = ["gOSI"]
1723
- trace_indices = [np.arange(n_features)]
1724
- else:
1725
- trace_groups = [
1726
- group for group in group_colors if group in set(frame["feature_group"])
1727
- ]
1728
- trace_indices = [
1729
- np.flatnonzero(frame["feature_group"].astype(str).eq(group))
1730
- for group in trace_groups
1731
- ]
1732
- centers = np.linspace(0.72, 0.28, len(trace_groups))
1733
- start_x = np.zeros(n_features, dtype=float)
1734
- start_y = np.zeros(n_features, dtype=float)
1735
- for center, indices in zip(centers, trace_indices):
1736
- ordered = indices[
1737
- np.argsort(frame.loc[indices, "feature_index"].to_numpy(), kind="stable")
1738
- ]
1739
- n_columns = max(1, int(np.ceil(np.sqrt(len(ordered) * 4.0))))
1740
- n_rows = max(1, int(np.ceil(len(ordered) / n_columns)))
1741
- row_y = (
1742
- np.asarray([center])
1743
- if n_rows == 1
1744
- else center + np.linspace(-0.075, 0.075, n_rows)
1745
- )
1746
- for row_index in range(n_rows):
1747
- row = ordered[row_index * n_columns : (row_index + 1) * n_columns]
1748
- if not len(row):
1749
- continue
1750
- if n_columns == 1:
1751
- row_x = np.asarray([0.5])
1752
- else:
1753
- offset = (n_columns - len(row)) / 2.0
1754
- row_x = (np.arange(len(row)) + offset) / (n_columns - 1)
1755
- start_x[row] = 0.04 + 0.92 * row_x
1756
- start_y[row] = row_y[row_index]
1757
-
1758
- marker_size = 6 if n_features > 300 else (8 if n_features > 120 else 10)
1759
-
1760
- def trace_for(indices: np.ndarray, group: str, x: np.ndarray, y: np.ndarray) -> go.Scatter:
1761
- subset = frame.iloc[indices]
1762
- customdata = np.asarray(
1763
- [
1764
- [
1765
- int(row.feature_index),
1766
- int(row.attribution_rank),
1767
- float(row.signed_attribution),
1768
- float(row.validation_value),
1769
- str(row.feature_group),
1770
- str(row.attribution_bin),
1771
- ]
1772
- for row in subset.itertuples(index=False)
1773
- ],
1774
- dtype=object,
1775
- )
1776
- validation_hover = (
1777
- "<br>gOSI=%{customdata[3]:.4f}"
1778
- if dataset == "allen_neuropixels"
1779
- else ""
1780
- )
1781
- group_hover = (
1782
- "" if dataset == "allen_neuropixels" else "<br>Group=%{customdata[4]}"
1783
- )
1784
- marker: dict = dict(
1785
- size=marker_size,
1786
- opacity=0.82,
1787
- line=dict(color="#FFFFFF", width=0.45),
1788
- )
1789
- if dataset == "allen_neuropixels":
1790
- marker.update(
1791
- color=subset["validation_value"],
1792
- colorscale="Cividis",
1793
- cmin=float(frame["validation_value"].min()),
1794
- cmax=float(frame["validation_value"].max()),
1795
- colorbar=dict(title="gOSI", thickness=12),
1796
- )
1797
- else:
1798
- marker["color"] = group_colors[group]
1799
- rank_hover = "<br>Rank=%{customdata[1]:.0f}" if ranked and has_ranking else ""
1800
- return go.Scatter(
1801
- x=x[indices],
1802
- y=y[indices],
1803
- ids=[f"{dataset}:{int(value)}" for value in subset["feature_index"]],
1804
- mode="markers",
1805
- name=group,
1806
- showlegend=ranked and dataset != "allen_neuropixels",
1807
- marker=marker,
1808
- customdata=customdata,
1809
- hovertemplate=(
1810
- "Feature=%{customdata[0]:.0f}"
1811
- + group_hover
1812
- + rank_hover
1813
- + "<br>Signed Kernel SHAP=%{customdata[2]:+.5f}"
1814
- + validation_hover
1815
- + "<extra></extra>"
1816
- ),
1817
- )
1818
-
1819
- x = final_x if ranked else start_x
1820
- y = final_y if ranked else start_y
1821
- figure = go.Figure(
1822
- data=[
1823
- trace_for(indices, group, x, y)
1824
- for group, indices in zip(trace_groups, trace_indices)
1825
- ]
1826
- )
1827
- shapes: list[dict] = []
1828
- if ranked:
1829
- for rank_bin in ATTRIBUTION_BIN_ORDER:
1830
- bin_ranks = frame.loc[
1831
- frame["attribution_bin"].astype(str).eq(rank_bin), "attribution_rank"
1832
- ].to_numpy(dtype=float)
1833
- if not len(bin_ranks):
1834
- continue
1835
- bin_x = (bin_ranks - 1.0) / max(n_features - 1, 1)
1836
- shapes.append(
1837
- dict(
1838
- type="rect",
1839
- x0=float(bin_x.min()) - 0.5 / max(n_features - 1, 1),
1840
- x1=float(bin_x.max()) + 0.5 / max(n_features - 1, 1),
1841
- y0=display_range[0],
1842
- y1=display_range[1],
1843
- fillcolor=ATTRIBUTION_BIN_COLORS[rank_bin],
1844
- opacity=0.045,
1845
- line_width=0,
1846
- layer="below",
1847
- )
1848
- )
1849
- shapes.append(
1850
- dict(
1851
- type="line",
1852
- x0=display_range[0],
1853
- x1=display_range[1],
1854
- y0=(0.0 - y_low) / y_span,
1855
- y1=(0.0 - y_low) / y_span,
1856
- line=dict(color="#71808D", dash="dash", width=1.3),
1857
- )
1858
- )
1859
- elif dataset != "allen_neuropixels":
1860
- centers = np.linspace(0.7, 0.3, len(trace_groups))
1861
- for center, group in zip(centers, trace_groups):
1862
- shapes.append(
1863
- dict(
1864
- type="rect",
1865
- x0=display_range[0],
1866
- x1=display_range[1],
1867
- y0=center - 0.095,
1868
- y1=center + 0.095,
1869
- fillcolor=group_colors[group],
1870
- opacity=0.08,
1871
- line_width=0,
1872
- layer="below",
1873
- )
1874
- )
1875
-
1876
- title = "Signed Kernel SHAP vs. rank" if ranked else None
1877
- figure_layout(figure, height=560)
1878
- if ranked:
1879
- rank_ticks = np.unique(
1880
- np.rint(np.linspace(1, n_features, 5)).astype(int)
1881
- )
1882
- rank_tickvals = (rank_ticks - 1.0) / max(n_features - 1, 1)
1883
- value_ticks = np.linspace(y_low, y_high, 5)
1884
- value_tickvals = (value_ticks - y_low) / y_span
1885
- figure.update_xaxes(
1886
- visible=True,
1887
- range=display_range,
1888
- tickmode="array",
1889
- tickvals=rank_tickvals,
1890
- ticktext=[str(value) for value in rank_ticks],
1891
- title="Attribution rank",
1892
- )
1893
- figure.update_yaxes(
1894
- visible=True,
1895
- range=display_range,
1896
- tickmode="array",
1897
- tickvals=value_tickvals,
1898
- ticktext=[f"{value:.3g}" for value in value_ticks],
1899
- title="Signed Kernel SHAP value",
1900
- )
1901
- elif dataset == "allen_neuropixels":
1902
- ticks = np.linspace(vmin, vmax, 5)
1903
- tickvals = (ticks - vmin) / max(vmax - vmin, 1e-9)
1904
- figure.update_xaxes(
1905
- visible=True,
1906
- range=display_range,
1907
- tickmode="array",
1908
- tickvals=tickvals,
1909
- ticktext=[f"{value:.2f}" for value in ticks],
1910
- title="gOSI",
1911
- showgrid=False,
1912
- )
1913
- figure.update_yaxes(
1914
- visible=True,
1915
- range=display_range,
1916
- showticklabels=False,
1917
- ticks="",
1918
- title=" ",
1919
- showgrid=False,
1920
- zeroline=False,
1921
- )
1922
- else:
1923
- figure.update_xaxes(
1924
- visible=True,
1925
- range=display_range,
1926
- showticklabels=False,
1927
- ticks="",
1928
- title="",
1929
- showgrid=False,
1930
- zeroline=False,
1931
- )
1932
- figure.update_yaxes(
1933
- visible=True,
1934
- range=display_range,
1935
- tickmode="array",
1936
- tickvals=centers,
1937
- ticktext=trace_groups,
1938
- tickfont=dict(size=10),
1939
- ticks="",
1940
- title="",
1941
- showgrid=False,
1942
- zeroline=False,
1943
- )
1944
- figure.update_xaxes(automargin=False)
1945
- figure.update_yaxes(automargin=False)
1946
- animation_controls: list[dict] = []
1947
- if ranked:
1948
- animated_traces = list(range(len(trace_indices)))
1949
-
1950
- def coordinate_frame(
1951
- name: str,
1952
- x_values: np.ndarray,
1953
- y_values: np.ndarray,
1954
- ) -> go.Frame:
1955
- return go.Frame(
1956
- name=name,
1957
- traces=animated_traces,
1958
- data=[
1959
- go.Scatter(x=x_values[indices], y=y_values[indices])
1960
- for indices in trace_indices
1961
- ],
1962
- )
1963
-
1964
- figure.frames = [
1965
- coordinate_frame("feature-reference", start_x, start_y),
1966
- coordinate_frame("feature-ranked", final_x, final_y),
1967
- ]
1968
- animation_controls = [
1969
- dict(
1970
- type="buttons",
1971
- direction="left",
1972
- showactive=False,
1973
- x=1.0,
1974
- xanchor="right",
1975
- y=-0.38,
1976
- yanchor="top",
1977
- pad=dict(t=4, r=0),
1978
- bgcolor="#F3EFF8",
1979
- bordercolor="#C8BBDD",
1980
- borderwidth=1,
1981
- font=dict(size=11, color="#4D3880"),
1982
- buttons=[
1983
- dict(
1984
- label="↻ Replay sorting",
1985
- method="animate",
1986
- args=[
1987
- ["feature-reference", "feature-ranked"],
1988
- {
1989
- "mode": "immediate",
1990
- "fromcurrent": False,
1991
- "frame": [
1992
- {"duration": 120, "redraw": False},
1993
- {"duration": 900, "redraw": False},
1994
- ],
1995
- "transition": [
1996
- {"duration": 0},
1997
- {
1998
- "duration": 900,
1999
- "easing": "cubic-in-out",
2000
- },
2001
- ],
2002
- },
2003
- ],
2004
- )
2005
- ],
2006
- )
2007
- ]
2008
- if ranked:
2009
- margin = dict(l=58, r=28, t=66, b=165)
2010
- elif dataset == "allen_neuropixels":
2011
- margin = dict(l=46, r=54, t=26, b=76)
2012
- else:
2013
- margin = dict(l=112, r=24, t=26, b=76)
2014
- figure.update_layout(
2015
- title=title,
2016
- shapes=shapes,
2017
- annotations=[],
2018
- updatemenus=animation_controls,
2019
- margin=margin,
2020
- uirevision=f"feature-sorter:{dataset}:{model}:{arrangement}",
2021
- legend=dict(
2022
- orientation="h",
2023
- yanchor="top",
2024
- y=-0.17,
2025
- xanchor="left",
2026
- x=0,
2027
- font=dict(size=10),
2028
- ),
2029
- )
2030
- return figure
2031
 
2032
 
2033
  def trial_frame(dataset: str, models: Sequence[str] | None) -> pd.DataFrame:
@@ -2067,7 +1663,7 @@ def trial_detection_figure(dataset: str, models: Sequence[str] | None) -> go.Fig
2067
  fig.update_layout(
2068
  title="Corrupted-trial detection"
2069
  )
2070
- fig.update_xaxes(title="ROC-AUC from negative trial value")
2071
  fig.update_yaxes(title="", showgrid=False)
2072
  return figure_layout(fig, height=max(430, 25 * len(frame) + 145))
2073
 
@@ -2300,7 +1896,7 @@ def trial_retrain_figures() -> tuple[go.Figure, go.Figure, go.Figure, pd.DataFra
2300
  return removal, relation, historical_fig, round_numeric(table)
2301
 
2302
 
2303
- def historical_trajectory_figure() -> go.Figure:
2304
  frame = trial_historical_trajectories.copy()
2305
  current_r2 = float(frame["current_only_r2"].iloc[0])
2306
  historical_r2 = float(frame["historical_selected_r2"].iloc[0])
@@ -2321,12 +1917,20 @@ def historical_trajectory_figure() -> go.Figure:
2321
  2.5,
2322
  ),
2323
  ]
2324
- fig = make_subplots(
2325
- rows=1,
2326
- cols=3,
2327
- horizontal_spacing=0.045,
2328
- subplot_titles=[panel[2] for panel in panels],
2329
- )
 
 
 
 
 
 
 
 
2330
 
2331
  direction_labels = dict(zip(DIRECTION_LEGEND_ORDER, DIRECTION_LEGEND_LABELS))
2332
  for direction_rank, direction_index in enumerate(DIRECTION_LEGEND_ORDER):
@@ -2335,6 +1939,8 @@ def historical_trajectory_figure() -> go.Figure:
2335
  for panel_index, (x_column, y_column, _title, opacity, width) in enumerate(
2336
  panels, start=1
2337
  ):
 
 
2338
  x_values: list[object] = []
2339
  y_values: list[object] = []
2340
  hover_values: list[list[object]] = []
@@ -2363,7 +1969,7 @@ def historical_trajectory_figure() -> go.Figure:
2363
  name=direction_label,
2364
  legendgroup=f"direction-{direction_index}",
2365
  legendrank=direction_rank,
2366
- showlegend=panel_index == 1,
2367
  opacity=opacity,
2368
  line=dict(color=DIRECTION_PALETTE[direction_index], width=width),
2369
  customdata=hover_values,
@@ -2373,8 +1979,8 @@ def historical_trajectory_figure() -> go.Figure:
2373
  "Time bin=%{customdata[2]}<br>x=%{x:.3f}<br>y=%{y:.3f}<extra></extra>"
2374
  ),
2375
  ),
2376
- row=1,
2377
- col=panel_index,
2378
  )
2379
 
2380
  first_points = (
@@ -2383,6 +1989,8 @@ def historical_trajectory_figure() -> go.Figure:
2383
  for panel_index, (x_column, y_column, _title, _opacity, _width) in enumerate(
2384
  panels, start=1
2385
  ):
 
 
2386
  fig.add_trace(
2387
  go.Scatter(
2388
  x=[float(first_points[x_column].mean())],
@@ -2392,8 +2000,8 @@ def historical_trajectory_figure() -> go.Figure:
2392
  showlegend=False,
2393
  hovertemplate="Mean trajectory origin<extra></extra>",
2394
  ),
2395
- row=1,
2396
- col=panel_index,
2397
  )
2398
 
2399
  x_values = pd.concat(
@@ -2409,6 +2017,8 @@ def historical_trajectory_figure() -> go.Figure:
2409
  x_range = [float(x_values.min() - 0.06 * x_span), float(x_values.max() + 0.06 * x_span)]
2410
  y_range = [float(y_values.min() - 0.06 * y_span), float(y_values.max() + 0.06 * y_span)]
2411
  for panel_index in range(1, 4):
 
 
2412
  x_axis_id = "x" if panel_index == 1 else f"x{panel_index}"
2413
  fig.update_xaxes(
2414
  range=x_range,
@@ -2416,8 +2026,8 @@ def historical_trajectory_figure() -> go.Figure:
2416
  zeroline=False,
2417
  showticklabels=False,
2418
  ticks="",
2419
- row=1,
2420
- col=panel_index,
2421
  )
2422
  fig.update_yaxes(
2423
  range=y_range,
@@ -2427,24 +2037,15 @@ def historical_trajectory_figure() -> go.Figure:
2427
  ticks="",
2428
  scaleanchor=x_axis_id,
2429
  scaleratio=1,
2430
- row=1,
2431
- col=panel_index,
2432
  )
2433
 
2434
- figure_layout(fig, height=510, legend_below=True)
2435
  fig.update_layout(
2436
  title="Held-out trajectories · RNN",
2437
- margin=dict(l=28, r=28, t=76, b=118),
2438
- legend=dict(
2439
- orientation="h",
2440
- yanchor="top",
2441
- y=-0.10,
2442
- xanchor="center",
2443
- x=0.5,
2444
- title="Reach direction",
2445
- traceorder="normal",
2446
- font=dict(size=11),
2447
- ),
2448
  )
2449
  fig.for_each_annotation(
2450
  lambda annotation: annotation.update(font=dict(size=12, color=TEXT_COLOR))
@@ -2452,6 +2053,29 @@ def historical_trajectory_figure() -> go.Figure:
2452
  return fig
2453
 
2454
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2455
  def condition_sort_key(value: object) -> tuple[int, float | str]:
2456
  try:
2457
  return (0, float(value))
@@ -2519,7 +2143,13 @@ def add_latent_color_columns(df: pd.DataFrame, dataset: str, color_mode: str) ->
2519
  return out
2520
 
2521
 
2522
- def latent_space_figure(dataset: str, model: str | None, color_mode: str) -> go.Figure:
 
 
 
 
 
 
2523
  if dataset == "mc_pacman":
2524
  return empty_figure(
2525
  "Cross-recording consistency is not available for this dataset.",
@@ -2550,7 +2180,7 @@ def latent_space_figure(dataset: str, model: str | None, color_mode: str) -> go.
2550
  trajectories["color_value"] = pd.Series(dtype=str)
2551
 
2552
  sessions = list(dict.fromkeys(samples["session_label"].astype(str)))
2553
- columns = 2 if len(sessions) > 1 else 1
2554
  rows = int(np.ceil(len(sessions) / columns))
2555
  fig = make_subplots(
2556
  rows=rows,
@@ -2697,7 +2327,7 @@ def latent_space_figure(dataset: str, model: str | None, color_mode: str) -> go.
2697
  title += f" · R² = {float(score.iloc[0]):.3f}"
2698
  fig.update_layout(
2699
  title=title,
2700
- height=700 if rows > 1 else 500,
2701
  paper_bgcolor="#FFFFFF",
2702
  plot_bgcolor="#FFFFFF",
2703
  margin=dict(l=8, r=8, t=72, b=88),
@@ -2711,11 +2341,63 @@ def latent_space_figure(dataset: str, model: str | None, color_mode: str) -> go.
2711
  entrywidth=46,
2712
  entrywidthmode="pixels",
2713
  ),
 
2714
  )
2715
  fig.for_each_annotation(lambda annotation: annotation.update(font=dict(size=12, color="#526171")))
2716
  return fig
2717
 
2718
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2719
  def consistency_frame(dataset: str, models: Sequence[str] | None) -> pd.DataFrame:
2720
  frame = filter_models(active_rows(consistency), models)
2721
  frame = frame[frame["dataset"].astype(str) == str(dataset)].copy()
@@ -2755,7 +2437,7 @@ def consistency_figures(
2755
  )
2756
  )
2757
  bar_fig.update_layout(title="Latent consistency")
2758
- bar_fig.update_xaxes(title="Latent-consistency R²", range=[0, 1.02])
2759
  bar_fig.update_yaxes(title="", showgrid=False)
2760
  figure_layout(bar_fig, height=max(400, 27 * len(bar) + 145))
2761
  columns = ["method", "latent_consistency_r2"]
@@ -3021,7 +2703,17 @@ app.layout = html.Div(
3021
  ],
3022
  className="inline-controls",
3023
  ),
3024
- graph_box("latent-space", "Aligned latent representations for each recording.", class_name="latent-graph"),
 
 
 
 
 
 
 
 
 
 
3025
  html.Div(
3026
  [
3027
  graph_box("consistency-bars", "Latent-consistency R-squared for the selected dataset."),
@@ -3050,51 +2742,34 @@ app.layout = html.Div(
3050
  "Feature-level attribution",
3051
  html.Div(
3052
  [
3053
- html.Div(
3054
- [
3055
- html.Label("Method", htmlFor="feature-method"),
3056
- dcc.Dropdown(id="feature-method", clearable=False),
3057
- ],
3058
- className="control",
3059
- ),
3060
- html.Div(
3061
- [
3062
- html.Label("Feature view", htmlFor="feature-arrangement"),
3063
- dcc.RadioItems(
3064
- id="feature-arrangement",
3065
- options=[
3066
- {
3067
- "label": "Reference annotation",
3068
- "value": "reference",
3069
- },
3070
- {
3071
- "label": "Signed SHAP ranking",
3072
- "value": "ranked",
3073
- },
3074
- ],
3075
- value="ranked",
3076
- inline=True,
3077
- className="arrangement-toggle",
3078
- ),
3079
- ],
3080
- className="control",
3081
- ),
3082
  ],
3083
- className="inline-controls",
3084
  ),
3085
- html.Div(id="feature-selection-detail", className="feature-selection-detail"),
 
3086
  html.Div(
3087
- [
3088
- graph_box(
3089
- "feature-raster",
3090
- "Example neural activity raster ordered by reference annotation.",
3091
- ),
3092
- graph_box(
3093
- "feature-rank-plot",
3094
- "Interactive input-feature views showing reference annotations and signed Kernel SHAP score versus rank.",
3095
- ),
3096
- ],
3097
- className="chart-grid feature-story-grid",
 
 
 
 
 
 
 
 
 
3098
  ),
3099
  html.Div(
3100
  [
@@ -3103,7 +2778,7 @@ app.layout = html.Div(
3103
  ],
3104
  className="download-grid panel-downloads",
3105
  ),
3106
- subtitle="Explore each input feature in its reference context, then replay how the features sort into the signed Kernel SHAP ranking.",
3107
  class_name="axis-feature",
3108
  ),
3109
  panel(
@@ -3173,8 +2848,14 @@ app.layout = html.Div(
3173
  graph_box(
3174
  "trial-historical-trajectories",
3175
  "Held-out RNN target-session trajectories for ground truth, current-session training, and nonnegative-valued historical-trial selection.",
3176
- class_name="historical-trajectory-graph",
3177
  ),
 
 
 
 
 
 
3178
  details_table("View data", dataframe_table("trial-retrain-table", page_size=17)),
3179
  html.Div(
3180
  [
@@ -3333,6 +3014,8 @@ def update_consistency_selector(dataset: str, models: list[str] | None, current:
3333
 
3334
  @app.callback(
3335
  Output("latent-space", "figure"),
 
 
3336
  Output("consistency-bars", "figure"),
3337
  Output("consistency-heatmap", "figure"),
3338
  Output("consistency-table", "columns"),
@@ -3350,8 +3033,11 @@ def update_consistency(
3350
  ):
3351
  dataset = dataset or DATASETS[0]
3352
  bars, heatmap, table = consistency_figures(dataset, models)
 
3353
  return (
3354
- latent_space_figure(dataset, method, color_mode or "condition"),
 
 
3355
  bars,
3356
  heatmap,
3357
  column_defs(table.columns),
@@ -3396,70 +3082,48 @@ def update_feature_selector(
3396
 
3397
 
3398
  @app.callback(
3399
- Output("feature-raster", "figure"),
3400
  Input("dataset-filter", "value"),
3401
  )
3402
- def update_feature_raster(dataset: str):
3403
- return feature_raster_figure(dataset or DATASETS[0])
3404
 
3405
 
3406
  @app.callback(
3407
- Output("feature-rank-plot", "figure"),
3408
- Output("feature-rank-plot", "clickData"),
3409
  Input("dataset-filter", "value"),
3410
  Input("feature-method", "value"),
3411
- Input("feature-arrangement", "value"),
3412
  )
3413
- def update_feature_attributions(
3414
  dataset: str,
3415
  method: str | None,
3416
- arrangement: str,
3417
  ):
3418
- sorter = feature_sorter_figure(
3419
- dataset or DATASETS[0], method, arrangement or "ranked"
3420
- )
3421
- return sorter, None
3422
 
3423
 
3424
- @app.callback(
3425
- Output("feature-selection-detail", "children"),
3426
- Input("feature-rank-plot", "clickData"),
3427
- Input("dataset-filter", "value"),
3428
- Input("feature-method", "value"),
3429
- Input("feature-arrangement", "value"),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3430
  )
3431
- def update_feature_selection_detail(
3432
- click_data: dict | None,
3433
- dataset: str,
3434
- method: str | None,
3435
- arrangement: str,
3436
- ):
3437
- instruction = (
3438
- "Each dot is one input feature. In the ranked view, use ↻ Replay sorting "
3439
- "to follow every feature into its signed Kernel SHAP rank."
3440
- )
3441
- if not click_data or not click_data.get("points"):
3442
- return instruction
3443
- custom = click_data["points"][0].get("customdata")
3444
- if not custom or len(custom) < 6:
3445
- return instruction
3446
- frame = feature_attribution_frame(dataset or DATASETS[0], method)
3447
- feature_index = int(float(custom[0]))
3448
- selected = frame[
3449
- frame["feature_index"].astype(int).eq(feature_index)
3450
- ]
3451
- if selected.empty:
3452
- return instruction
3453
- row = selected.iloc[0]
3454
- detail = f"Feature index {feature_index}"
3455
- if arrangement == "ranked" and frame["signed_attribution"].nunique(dropna=True) > 1:
3456
- detail += f" · rank {int(row['attribution_rank'])} of {len(frame)}"
3457
- detail += f" · signed Kernel SHAP {float(row['signed_attribution']):+.5f}"
3458
- if dataset == "allen_neuropixels" and pd.notna(row["validation_value"]):
3459
- detail += f" · gOSI {float(row['validation_value']):.4f}"
3460
- else:
3461
- detail += f" · {row['feature_group']}"
3462
- return detail
3463
 
3464
 
3465
  @app.callback(
@@ -3512,6 +3176,7 @@ def update_feature(dataset: str, models: list[str] | None):
3512
  Output("trial-recovery", "figure"),
3513
  Output("trial-historical", "figure"),
3514
  Output("trial-historical-trajectories", "figure"),
 
3515
  Output("trial-retrain-table", "columns"),
3516
  Output("trial-retrain-table", "data"),
3517
  Input("dataset-filter", "value"),
@@ -3534,6 +3199,7 @@ def update_trial(dataset: str, models: list[str] | None):
3534
  relation,
3535
  historical,
3536
  historical_trajectory_figure(),
 
3537
  column_defs(retrain_table.columns),
3538
  records(retrain_table),
3539
  )
 
680
  plot_bgcolor="#FFFFFF",
681
  margin=dict(l=54, r=28, t=58, b=58 if not legend_below else 105),
682
  font=dict(family="Arial, Helvetica, sans-serif", size=13, color=TEXT_COLOR),
683
+ title=dict(
684
+ font=dict(size=16, color=TEXT_COLOR),
685
+ x=0.02,
686
+ xanchor="left",
687
+ pad=dict(l=6, b=8),
688
+ ),
689
  legend=legend,
690
  hoverlabel=dict(
691
  align="left",
 
921
  .to_numpy(dtype=int)
922
  )
923
  customdata = np.repeat(feature_ids[:, None], values.shape[1], axis=1)
924
+ upper = max(int(np.ceil(np.nanmax(values.to_numpy(dtype=float)))), 1)
925
+ if upper <= 4:
926
+ count_ticks = list(range(upper + 1))
927
+ else:
928
+ count_ticks = np.unique(
929
+ np.rint(np.linspace(0, upper, 4)).astype(int)
930
+ ).tolist()
931
  neural_figure = go.Figure(
932
  go.Heatmap(
933
  z=values.to_numpy(dtype=float),
934
  x=time_values,
935
  y=np.arange(len(feature_ids)),
936
  customdata=customdata,
937
+ colorscale=[[0.0, "#F8FAFB"], [1.0, "#263238"]],
938
  zmin=0,
939
  zmax=upper,
940
+ colorbar=dict(
941
+ title="Count / bin",
942
+ thickness=13,
943
+ tickmode="array",
944
+ tickvals=count_ticks,
945
+ ticktext=[str(value) for value in count_ticks],
946
+ ),
947
  hovertemplate=(
948
  "Feature=%{customdata}<br>Time=%{x:.0f} ms<br>"
949
+ "Count=%{z:.0f}<extra></extra>"
950
  ),
951
  )
952
  )
 
1365
  title="Training and inference time",
1366
  barmode="group",
1367
  )
1368
+ runtime.update_xaxes(title="Elapsed time (s, log)", type="log")
1369
  runtime.update_yaxes(title="", showgrid=False)
1370
  figure_layout(runtime, height=max(470, 27 * len(frame) + 155), legend_below=True)
1371
 
 
1536
  return frame.sort_values("attribution_rank", kind="stable")
1537
 
1538
 
1539
+ def feature_story_raster_payload(dataset: str) -> dict:
1540
  frame = feature_example_raster[
1541
  feature_example_raster["dataset"].astype(str).eq(dataset)
1542
  ].copy()
1543
  if frame.empty:
1544
+ return {"dataset": dataset, "features": [], "counts": []}
1545
 
1546
  numeric_columns = [
 
1547
  "display_index",
1548
  "feature_index",
1549
  "validation_value",
 
1563
 
1564
  n_time = int(frame["n_time"].iloc[0])
1565
  value_columns = [f"value_{index:03d}" for index in range(n_time)]
1566
+ counts = (
1567
+ frame[value_columns]
1568
+ .apply(pd.to_numeric, errors="coerce")
1569
+ .to_numpy(dtype=float)
1570
+ )
1571
+ if not np.allclose(counts, np.rint(counts)):
1572
+ raise ValueError(f"Feature raster contains noninteger counts for {dataset}")
1573
+ counts = np.rint(counts).astype(int)
1574
  time_ms = (
1575
  np.arange(n_time) - int(frame["t0_index"].iloc[0])
1576
  ) * float(frame["bin_ms"].iloc[0])
1577
+ features = [
1578
+ {
1579
+ "feature_index": int(row.feature_index),
1580
+ "group": str(row.feature_group),
1581
+ "validation_value": (
1582
+ None
1583
+ if pd.isna(row.validation_value)
1584
+ else float(row.validation_value)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1585
  ),
1586
+ }
1587
+ for row in frame.itertuples(index=False)
1588
+ ]
1589
+ return {
1590
+ "dataset": dataset,
1591
+ "dataset_label": DATASET_LABELS[dataset],
1592
+ "features": features,
1593
+ "counts": counts.tolist(),
1594
+ "time_ms": time_ms.tolist(),
1595
+ "max_count": int(counts.max(initial=0)),
1596
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1597
 
1598
 
1599
+ def feature_story_attribution_payload(
1600
  dataset: str,
1601
  model: str | None,
1602
+ ) -> dict:
1603
+ frame = feature_attribution_frame(dataset, model)
 
1604
  if frame.empty:
1605
+ return {
1606
+ "dataset": dataset,
1607
+ "model": model,
1608
+ "method": "",
1609
+ "features": [],
1610
+ "tied": False,
1611
+ }
1612
+ values = pd.to_numeric(frame["signed_attribution"], errors="coerce")
1613
+ return {
1614
+ "dataset": dataset,
1615
+ "model": model,
1616
+ "method": dataset_model_label(str(model), dataset),
1617
+ "tied": values.nunique(dropna=True) <= 1,
1618
+ "features": [
1619
+ {
1620
+ "feature_index": int(row.feature_index),
1621
+ "rank": int(row.attribution_rank),
1622
+ "signed_attribution": float(row.signed_attribution),
1623
+ }
1624
+ for row in frame.itertuples(index=False)
1625
+ ],
1626
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1627
 
1628
 
1629
  def trial_frame(dataset: str, models: Sequence[str] | None) -> pd.DataFrame:
 
1663
  fig.update_layout(
1664
  title="Corrupted-trial detection"
1665
  )
1666
+ fig.update_xaxes(title="Detection ROC-AUC")
1667
  fig.update_yaxes(title="", showgrid=False)
1668
  return figure_layout(fig, height=max(430, 25 * len(frame) + 145))
1669
 
 
1896
  return removal, relation, historical_fig, round_numeric(table)
1897
 
1898
 
1899
+ def historical_trajectory_figure(*, stacked: bool = False) -> go.Figure:
1900
  frame = trial_historical_trajectories.copy()
1901
  current_r2 = float(frame["current_only_r2"].iloc[0])
1902
  historical_r2 = float(frame["historical_selected_r2"].iloc[0])
 
1917
  2.5,
1918
  ),
1919
  ]
1920
+ if stacked:
1921
+ fig = make_subplots(
1922
+ rows=3,
1923
+ cols=1,
1924
+ vertical_spacing=0.075,
1925
+ subplot_titles=[panel[2] for panel in panels],
1926
+ )
1927
+ else:
1928
+ fig = make_subplots(
1929
+ rows=1,
1930
+ cols=3,
1931
+ horizontal_spacing=0.045,
1932
+ subplot_titles=[panel[2] for panel in panels],
1933
+ )
1934
 
1935
  direction_labels = dict(zip(DIRECTION_LEGEND_ORDER, DIRECTION_LEGEND_LABELS))
1936
  for direction_rank, direction_index in enumerate(DIRECTION_LEGEND_ORDER):
 
1939
  for panel_index, (x_column, y_column, _title, opacity, width) in enumerate(
1940
  panels, start=1
1941
  ):
1942
+ subplot_row = panel_index if stacked else 1
1943
+ subplot_col = 1 if stacked else panel_index
1944
  x_values: list[object] = []
1945
  y_values: list[object] = []
1946
  hover_values: list[list[object]] = []
 
1969
  name=direction_label,
1970
  legendgroup=f"direction-{direction_index}",
1971
  legendrank=direction_rank,
1972
+ showlegend=False,
1973
  opacity=opacity,
1974
  line=dict(color=DIRECTION_PALETTE[direction_index], width=width),
1975
  customdata=hover_values,
 
1979
  "Time bin=%{customdata[2]}<br>x=%{x:.3f}<br>y=%{y:.3f}<extra></extra>"
1980
  ),
1981
  ),
1982
+ row=subplot_row,
1983
+ col=subplot_col,
1984
  )
1985
 
1986
  first_points = (
 
1989
  for panel_index, (x_column, y_column, _title, _opacity, _width) in enumerate(
1990
  panels, start=1
1991
  ):
1992
+ subplot_row = panel_index if stacked else 1
1993
+ subplot_col = 1 if stacked else panel_index
1994
  fig.add_trace(
1995
  go.Scatter(
1996
  x=[float(first_points[x_column].mean())],
 
2000
  showlegend=False,
2001
  hovertemplate="Mean trajectory origin<extra></extra>",
2002
  ),
2003
+ row=subplot_row,
2004
+ col=subplot_col,
2005
  )
2006
 
2007
  x_values = pd.concat(
 
2017
  x_range = [float(x_values.min() - 0.06 * x_span), float(x_values.max() + 0.06 * x_span)]
2018
  y_range = [float(y_values.min() - 0.06 * y_span), float(y_values.max() + 0.06 * y_span)]
2019
  for panel_index in range(1, 4):
2020
+ subplot_row = panel_index if stacked else 1
2021
+ subplot_col = 1 if stacked else panel_index
2022
  x_axis_id = "x" if panel_index == 1 else f"x{panel_index}"
2023
  fig.update_xaxes(
2024
  range=x_range,
 
2026
  zeroline=False,
2027
  showticklabels=False,
2028
  ticks="",
2029
+ row=subplot_row,
2030
+ col=subplot_col,
2031
  )
2032
  fig.update_yaxes(
2033
  range=y_range,
 
2037
  ticks="",
2038
  scaleanchor=x_axis_id,
2039
  scaleratio=1,
2040
+ row=subplot_row,
2041
+ col=subplot_col,
2042
  )
2043
 
2044
+ figure_layout(fig, height=930 if stacked else 480)
2045
  fig.update_layout(
2046
  title="Held-out trajectories · RNN",
2047
+ margin=dict(l=24, r=24, t=76 if not stacked else 68, b=24),
2048
+ showlegend=False,
 
 
 
 
 
 
 
 
 
2049
  )
2050
  fig.for_each_annotation(
2051
  lambda annotation: annotation.update(font=dict(size=12, color=TEXT_COLOR))
 
2053
  return fig
2054
 
2055
 
2056
+ def historical_direction_legend() -> html.Div:
2057
+ labels = dict(zip(DIRECTION_LEGEND_ORDER, DIRECTION_LEGEND_LABELS))
2058
+ return html.Div(
2059
+ [
2060
+ html.Span("Reach direction", className="trajectory-legend-title"),
2061
+ *[
2062
+ html.Span(
2063
+ [
2064
+ html.Span(
2065
+ className="trajectory-legend-swatch",
2066
+ style={"backgroundColor": DIRECTION_PALETTE[index]},
2067
+ ),
2068
+ labels[index],
2069
+ ],
2070
+ className="trajectory-legend-item",
2071
+ )
2072
+ for index in DIRECTION_LEGEND_ORDER
2073
+ ],
2074
+ ],
2075
+ className="trajectory-legend",
2076
+ )
2077
+
2078
+
2079
  def condition_sort_key(value: object) -> tuple[int, float | str]:
2080
  try:
2081
  return (0, float(value))
 
2143
  return out
2144
 
2145
 
2146
+ def latent_space_figure(
2147
+ dataset: str,
2148
+ model: str | None,
2149
+ color_mode: str,
2150
+ *,
2151
+ stacked: bool = False,
2152
+ ) -> go.Figure:
2153
  if dataset == "mc_pacman":
2154
  return empty_figure(
2155
  "Cross-recording consistency is not available for this dataset.",
 
2180
  trajectories["color_value"] = pd.Series(dtype=str)
2181
 
2182
  sessions = list(dict.fromkeys(samples["session_label"].astype(str)))
2183
+ columns = 1 if stacked else (2 if len(sessions) > 1 else 1)
2184
  rows = int(np.ceil(len(sessions) / columns))
2185
  fig = make_subplots(
2186
  rows=rows,
 
2327
  title += f" · R² = {float(score.iloc[0]):.3f}"
2328
  fig.update_layout(
2329
  title=title,
2330
+ height=(max(520, 300 * rows + 110) if stacked else (700 if rows > 1 else 500)),
2331
  paper_bgcolor="#FFFFFF",
2332
  plot_bgcolor="#FFFFFF",
2333
  margin=dict(l=8, r=8, t=72, b=88),
 
2341
  entrywidth=46,
2342
  entrywidthmode="pixels",
2343
  ),
2344
+ showlegend=not stacked,
2345
  )
2346
  fig.for_each_annotation(lambda annotation: annotation.update(font=dict(size=12, color="#526171")))
2347
  return fig
2348
 
2349
 
2350
+ def latent_mobile_legend(dataset: str, color_mode: str) -> html.Div:
2351
+ frame = latent_samples[latent_samples["dataset"].astype(str).eq(dataset)].copy()
2352
+ frame = add_latent_color_columns(frame, dataset, color_mode)
2353
+ label = condition_axis_label(dataset, color_mode)
2354
+ if dataset == "ratinabox":
2355
+ colors = [RATINABOX_SCALE[0][1], RATINABOX_SCALE[len(RATINABOX_SCALE) // 2][1], RATINABOX_SCALE[-1][1]]
2356
+ return html.Div(
2357
+ [
2358
+ html.Span(label, className="latent-mobile-legend-title"),
2359
+ *[
2360
+ html.Span(
2361
+ className="latent-mobile-legend-dot",
2362
+ style={"backgroundColor": color},
2363
+ )
2364
+ for color in colors
2365
+ ],
2366
+ ],
2367
+ className="latent-mobile-legend",
2368
+ )
2369
+
2370
+ values = sorted(frame["color_value"].dropna().unique(), key=condition_sort_key)
2371
+ if dataset == "monkey":
2372
+ colors = {
2373
+ value: DIRECTION_PALETTE[int(value) % len(DIRECTION_PALETTE)]
2374
+ for value in values
2375
+ }
2376
+ elif dataset == "speech":
2377
+ colors = {value: SPEECH_PALETTE.get(value, "#777777") for value in values}
2378
+ else:
2379
+ colors = {value: ALLEN_PALETTE.get(value, "#777777") for value in values}
2380
+ return html.Div(
2381
+ [
2382
+ html.Span(label, className="latent-mobile-legend-title"),
2383
+ *[
2384
+ html.Span(
2385
+ [
2386
+ html.Span(
2387
+ className="latent-mobile-legend-dot",
2388
+ style={"backgroundColor": colors[value]},
2389
+ ),
2390
+ condition_label(dataset, value, color_mode),
2391
+ ],
2392
+ className="latent-mobile-legend-item",
2393
+ )
2394
+ for value in values
2395
+ ],
2396
+ ],
2397
+ className="latent-mobile-legend",
2398
+ )
2399
+
2400
+
2401
  def consistency_frame(dataset: str, models: Sequence[str] | None) -> pd.DataFrame:
2402
  frame = filter_models(active_rows(consistency), models)
2403
  frame = frame[frame["dataset"].astype(str) == str(dataset)].copy()
 
2437
  )
2438
  )
2439
  bar_fig.update_layout(title="Latent consistency")
2440
+ bar_fig.update_xaxes(title="Consistency R²", range=[0, 1.02])
2441
  bar_fig.update_yaxes(title="", showgrid=False)
2442
  figure_layout(bar_fig, height=max(400, 27 * len(bar) + 145))
2443
  columns = ["method", "latent_consistency_r2"]
 
2703
  ],
2704
  className="inline-controls",
2705
  ),
2706
+ graph_box(
2707
+ "latent-space",
2708
+ "Aligned latent representations for each recording.",
2709
+ class_name="latent-graph latent-space-desktop",
2710
+ ),
2711
+ graph_box(
2712
+ "latent-space-mobile",
2713
+ "Aligned latent representations stacked for narrow screens.",
2714
+ class_name="latent-graph latent-space-mobile",
2715
+ ),
2716
+ html.Div(id="latent-mobile-legend"),
2717
  html.Div(
2718
  [
2719
  graph_box("consistency-bars", "Latent-consistency R-squared for the selected dataset."),
 
2742
  "Feature-level attribution",
2743
  html.Div(
2744
  [
2745
+ html.Label("Method", htmlFor="feature-method"),
2746
+ dcc.Dropdown(id="feature-method", clearable=False),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2747
  ],
2748
+ className="control feature-method-control",
2749
  ),
2750
+ dcc.Store(id="feature-story-raster-data"),
2751
+ dcc.Store(id="feature-story-attribution-data"),
2752
  html.Div(
2753
+ id="feature-story-viz",
2754
+ className="feature-story-viz",
2755
+ role="group",
2756
+ tabIndex=0,
2757
+ **{
2758
+ "aria-label": (
2759
+ "Linked example neural activity and signed "
2760
+ "Kernel SHAP feature ranking."
2761
+ )
2762
+ },
2763
+ ),
2764
+ html.Span(
2765
+ id="feature-story-render-token",
2766
+ className="feature-story-render-token",
2767
+ ),
2768
+ html.Div(
2769
+ "Hover or tap a raster row or attribution dot to trace the same feature.",
2770
+ id="feature-selection-detail",
2771
+ className="feature-selection-detail",
2772
+ **{"aria-live": "polite"},
2773
  ),
2774
  html.Div(
2775
  [
 
2778
  ],
2779
  className="download-grid panel-downloads",
2780
  ),
2781
+ subtitle="Follow each input feature from an example trial to its signed Kernel SHAP rank.",
2782
  class_name="axis-feature",
2783
  ),
2784
  panel(
 
2848
  graph_box(
2849
  "trial-historical-trajectories",
2850
  "Held-out RNN target-session trajectories for ground truth, current-session training, and nonnegative-valued historical-trial selection.",
2851
+ class_name="historical-trajectory-graph historical-trajectory-desktop",
2852
  ),
2853
+ graph_box(
2854
+ "trial-historical-trajectories-mobile",
2855
+ "Held-out RNN target-session trajectories, stacked for narrow screens.",
2856
+ class_name="historical-trajectory-graph historical-trajectory-mobile",
2857
+ ),
2858
+ historical_direction_legend(),
2859
  details_table("View data", dataframe_table("trial-retrain-table", page_size=17)),
2860
  html.Div(
2861
  [
 
3014
 
3015
  @app.callback(
3016
  Output("latent-space", "figure"),
3017
+ Output("latent-space-mobile", "figure"),
3018
+ Output("latent-mobile-legend", "children"),
3019
  Output("consistency-bars", "figure"),
3020
  Output("consistency-heatmap", "figure"),
3021
  Output("consistency-table", "columns"),
 
3033
  ):
3034
  dataset = dataset or DATASETS[0]
3035
  bars, heatmap, table = consistency_figures(dataset, models)
3036
+ resolved_color_mode = color_mode or "condition"
3037
  return (
3038
+ latent_space_figure(dataset, method, resolved_color_mode),
3039
+ latent_space_figure(dataset, method, resolved_color_mode, stacked=True),
3040
+ latent_mobile_legend(dataset, resolved_color_mode),
3041
  bars,
3042
  heatmap,
3043
  column_defs(table.columns),
 
3082
 
3083
 
3084
  @app.callback(
3085
+ Output("feature-story-raster-data", "data"),
3086
  Input("dataset-filter", "value"),
3087
  )
3088
+ def update_feature_story_raster(dataset: str):
3089
+ return feature_story_raster_payload(dataset or DATASETS[0])
3090
 
3091
 
3092
  @app.callback(
3093
+ Output("feature-story-attribution-data", "data"),
 
3094
  Input("dataset-filter", "value"),
3095
  Input("feature-method", "value"),
 
3096
  )
3097
+ def update_feature_story_attribution(
3098
  dataset: str,
3099
  method: str | None,
 
3100
  ):
3101
+ return feature_story_attribution_payload(dataset or DATASETS[0], method)
 
 
 
3102
 
3103
 
3104
+ app.clientside_callback(
3105
+ """
3106
+ function(rasterData, attributionData, activeTab) {
3107
+ if (!rasterData || !attributionData ||
3108
+ activeTab !== "feature" ||
3109
+ rasterData.dataset !== attributionData.dataset ||
3110
+ !window.benchdashFeatureStory) {
3111
+ return window.dash_clientside.no_update;
3112
+ }
3113
+ window.benchdashFeatureStory.schedule(
3114
+ "feature-story-viz",
3115
+ "feature-selection-detail",
3116
+ rasterData,
3117
+ attributionData
3118
+ );
3119
+ return `${rasterData.dataset}:${attributionData.model}:feature`;
3120
+ }
3121
+ """,
3122
+ Output("feature-story-render-token", "children"),
3123
+ Input("feature-story-raster-data", "data"),
3124
+ Input("feature-story-attribution-data", "data"),
3125
+ Input("tabs", "value"),
3126
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3127
 
3128
 
3129
  @app.callback(
 
3176
  Output("trial-recovery", "figure"),
3177
  Output("trial-historical", "figure"),
3178
  Output("trial-historical-trajectories", "figure"),
3179
+ Output("trial-historical-trajectories-mobile", "figure"),
3180
  Output("trial-retrain-table", "columns"),
3181
  Output("trial-retrain-table", "data"),
3182
  Input("dataset-filter", "value"),
 
3199
  relation,
3200
  historical,
3201
  historical_trajectory_figure(),
3202
+ historical_trajectory_figure(stacked=True),
3203
  column_defs(retrain_table.columns),
3204
  records(retrain_table),
3205
  )
assets/feature_story.js ADDED
@@ -0,0 +1,849 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ (function () {
2
+ "use strict";
3
+
4
+ const SVG_NS = "http://www.w3.org/2000/svg";
5
+ const GROUP_COLORS = {
6
+ "Recorded": "#0072B2",
7
+ "Synthetic control": "#BDBDBD",
8
+ "Place": "#CC79A7",
9
+ "Head direction": "#56B4E9",
10
+ "Speed": "#E69F00",
11
+ };
12
+ const DATASET_RECORDED_COLORS = {
13
+ monkey: "#0072B2",
14
+ speech: "#009E73",
15
+ mc_pacman: "#D55E00",
16
+ };
17
+ const TEXT = "#17202A";
18
+ const MUTED = "#607080";
19
+ const GRID = "#E7ECEF";
20
+ const DARK_GRID = "#AAB6BE";
21
+
22
+ function clamp(value, low, high) {
23
+ return Math.max(low, Math.min(high, value));
24
+ }
25
+
26
+ function svgElement(tag, attributes, text) {
27
+ const node = document.createElementNS(SVG_NS, tag);
28
+ Object.entries(attributes || {}).forEach(([name, value]) => {
29
+ node.setAttribute(name, String(value));
30
+ });
31
+ if (text !== undefined) node.textContent = text;
32
+ return node;
33
+ }
34
+
35
+ function htmlElement(tag, className, text) {
36
+ const node = document.createElement(tag);
37
+ if (className) node.className = className;
38
+ if (text !== undefined) node.textContent = text;
39
+ return node;
40
+ }
41
+
42
+ function mixColor(start, end, fraction) {
43
+ const t = clamp(fraction, 0, 1);
44
+ const parse = (hex) => [
45
+ parseInt(hex.slice(1, 3), 16),
46
+ parseInt(hex.slice(3, 5), 16),
47
+ parseInt(hex.slice(5, 7), 16),
48
+ ];
49
+ const a = parse(start);
50
+ const b = parse(end);
51
+ const values = a.map((value, index) => Math.round(value + (b[index] - value) * t));
52
+ return `rgb(${values[0]}, ${values[1]}, ${values[2]})`;
53
+ }
54
+
55
+ function gosiColor(value, low, high) {
56
+ const span = Math.max(high - low, 1e-9);
57
+ return mixColor("#DCE7EC", "#245B78", (value - low) / span);
58
+ }
59
+
60
+ function featureColor(dataset, feature, validationDomain) {
61
+ if (dataset === "allen_neuropixels") {
62
+ return gosiColor(feature.validation_value || 0, validationDomain[0], validationDomain[1]);
63
+ }
64
+ if (feature.group === "Recorded" && DATASET_RECORDED_COLORS[dataset]) {
65
+ return DATASET_RECORDED_COLORS[dataset];
66
+ }
67
+ return GROUP_COLORS[feature.group] || "#6A51A3";
68
+ }
69
+
70
+ function numericTicks(low, high, count) {
71
+ if (!Number.isFinite(low) || !Number.isFinite(high)) return [0];
72
+ if (Math.abs(high - low) < 1e-12) return [low];
73
+ return Array.from({ length: count }, (_, index) => low + (high - low) * index / (count - 1));
74
+ }
75
+
76
+ function integerTicks(maximum) {
77
+ const max = Math.max(0, Math.round(maximum));
78
+ if (max <= 4) return Array.from({ length: max + 1 }, (_, index) => index);
79
+ const candidates = [0, Math.round(max / 3), Math.round(2 * max / 3), max];
80
+ return [...new Set(candidates)];
81
+ }
82
+
83
+ function formatScore(value) {
84
+ if (Math.abs(value) >= 0.1) return value.toFixed(2).replace(/\.?0+$/, "");
85
+ if (Math.abs(value) >= 0.01) return value.toFixed(3).replace(/\.?0+$/, "");
86
+ return value.toPrecision(2).replace("e+", "e");
87
+ }
88
+
89
+ function signedScore(value) {
90
+ const prefix = value > 0 ? "+" : "";
91
+ return `${prefix}${Number(value).toPrecision(4).replace("e+", "e")}`;
92
+ }
93
+
94
+ function bezier(start, controlOne, controlTwo, end, fraction) {
95
+ const t = clamp(fraction, 0, 1);
96
+ const u = 1 - t;
97
+ return {
98
+ x: u * u * u * start.x + 3 * u * u * t * controlOne.x +
99
+ 3 * u * t * t * controlTwo.x + t * t * t * end.x,
100
+ y: u * u * u * start.y + 3 * u * u * t * controlOne.y +
101
+ 3 * u * t * t * controlTwo.y + t * t * t * end.y,
102
+ };
103
+ }
104
+
105
+ function groupSpans(features) {
106
+ const spans = [];
107
+ features.forEach((feature, index) => {
108
+ const previous = spans[spans.length - 1];
109
+ if (!previous || previous.group !== feature.group) {
110
+ spans.push({ group: feature.group, first: index, last: index });
111
+ } else {
112
+ previous.last = index;
113
+ }
114
+ });
115
+ return spans;
116
+ }
117
+
118
+ function buildState(container, detailNode, rasterData, attributionData) {
119
+ const attribution = new Map(
120
+ attributionData.features.map((feature) => [Number(feature.feature_index), feature])
121
+ );
122
+ const validationValues = rasterData.features
123
+ .map((feature) => Number(feature.validation_value))
124
+ .filter(Number.isFinite);
125
+ const validationDomain = validationValues.length
126
+ ? [Math.min(...validationValues), Math.max(...validationValues)]
127
+ : [0, 1];
128
+ const features = rasterData.features.map((feature, row) => {
129
+ const featureIndex = Number(feature.feature_index);
130
+ const score = attribution.get(featureIndex);
131
+ if (!score) throw new Error(`Missing attribution for feature ${featureIndex}`);
132
+ return {
133
+ ...feature,
134
+ ...score,
135
+ feature_index: featureIndex,
136
+ row,
137
+ color: featureColor(rasterData.dataset, feature, validationDomain),
138
+ };
139
+ });
140
+ if (features.length !== attribution.size) {
141
+ throw new Error("Raster and attribution feature sets differ");
142
+ }
143
+
144
+ container.replaceChildren();
145
+ const visual = htmlElement("div", "feature-story-visual");
146
+ const canvas = htmlElement("canvas", "feature-story-canvas");
147
+ canvas.setAttribute("aria-hidden", "true");
148
+ const svg = svgElement("svg", {
149
+ class: "feature-story-overlay",
150
+ role: "img",
151
+ "aria-label": "Feature activity rows linked to signed Kernel SHAP ranks",
152
+ });
153
+ svg.append(
154
+ svgElement("title", {}, "Feature activity linked to signed Kernel SHAP ranking"),
155
+ svgElement(
156
+ "desc",
157
+ {},
158
+ "Each feature dot moves from its row in the example activity raster to its signed attribution rank."
159
+ )
160
+ );
161
+ visual.append(canvas, svg);
162
+
163
+ const playback = htmlElement("div", "feature-story-playback");
164
+ const sourceLabel = htmlElement("span", "feature-story-playback-label", "Example-trial order");
165
+ sourceLabel.classList.add("source");
166
+ const slider = htmlElement("input", "feature-story-slider");
167
+ slider.type = "range";
168
+ slider.min = "0";
169
+ slider.max = "1";
170
+ slider.step = "0.01";
171
+ slider.value = "1";
172
+ slider.setAttribute("aria-label", "Feature mapping progress from example-trial order to SHAP rank");
173
+ const targetLabel = htmlElement(
174
+ "span",
175
+ "feature-story-playback-label",
176
+ attributionData.tied ? "Feature order" : "SHAP rank"
177
+ );
178
+ targetLabel.classList.add("target");
179
+ const replay = htmlElement("button", "feature-story-replay", "Replay mapping");
180
+ replay.type = "button";
181
+ playback.append(sourceLabel, slider, targetLabel, replay);
182
+ container.append(visual, playback);
183
+
184
+ const state = {
185
+ signature: `${rasterData.dataset}:${attributionData.model}`,
186
+ container,
187
+ detailNode,
188
+ rasterData,
189
+ attributionData,
190
+ validationDomain,
191
+ features,
192
+ visual,
193
+ canvas,
194
+ svg,
195
+ slider,
196
+ replay,
197
+ progress: 1,
198
+ hovered: null,
199
+ locked: null,
200
+ keyboardRow: 0,
201
+ positions: new Map(),
202
+ circles: new Map(),
203
+ animationFrame: null,
204
+ resizeObserver: null,
205
+ abortController: new AbortController(),
206
+ geometry: null,
207
+ };
208
+ container.__featureStory = state;
209
+ return state;
210
+ }
211
+
212
+ function addText(svg, x, y, text, options) {
213
+ const settings = options || {};
214
+ const node = svgElement("text", {
215
+ x,
216
+ y,
217
+ fill: settings.fill || TEXT,
218
+ "font-size": settings.size || 11,
219
+ "font-weight": settings.weight || 400,
220
+ "text-anchor": settings.anchor || "start",
221
+ "dominant-baseline": settings.baseline || "alphabetic",
222
+ class: settings.className || "",
223
+ }, text);
224
+ svg.appendChild(node);
225
+ return node;
226
+ }
227
+
228
+ function computeGeometry(state, width) {
229
+ const mobile = width < 720;
230
+ if (mobile) {
231
+ const rasterLabelWidth = state.rasterData.dataset === "allen_neuropixels" ? 10 : 74;
232
+ const raster = {
233
+ x: rasterLabelWidth,
234
+ y: 54,
235
+ width: Math.max(120, width - rasterLabelWidth - 18),
236
+ height: 244,
237
+ };
238
+ const chart = {
239
+ x: 46,
240
+ y: 394,
241
+ width: Math.max(130, width - 92),
242
+ height: 254,
243
+ };
244
+ return { mobile, width, height: 708, raster, chart };
245
+ }
246
+ const rasterLabelWidth = state.rasterData.dataset === "allen_neuropixels" ? 18 : 92;
247
+ const chartX = Math.round(width * 0.59);
248
+ const raster = {
249
+ x: rasterLabelWidth,
250
+ y: 54,
251
+ width: Math.max(220, chartX - rasterLabelWidth - 64),
252
+ height: 438,
253
+ };
254
+ const chart = {
255
+ x: chartX,
256
+ y: 54,
257
+ width: Math.max(220, width - chartX - 52),
258
+ height: 438,
259
+ };
260
+ return { mobile, width, height: 552, raster, chart };
261
+ }
262
+
263
+ function drawRaster(state) {
264
+ const { raster } = state.geometry;
265
+ const canvas = state.canvas;
266
+ const ratio = Math.min(window.devicePixelRatio || 1, 2);
267
+ canvas.width = Math.round(state.geometry.width * ratio);
268
+ canvas.height = Math.round(state.geometry.height * ratio);
269
+ canvas.style.width = `${state.geometry.width}px`;
270
+ canvas.style.height = `${state.geometry.height}px`;
271
+ const context = canvas.getContext("2d", { alpha: false });
272
+ context.setTransform(ratio, 0, 0, ratio, 0, 0);
273
+ context.fillStyle = "#FFFFFF";
274
+ context.fillRect(0, 0, state.geometry.width, state.geometry.height);
275
+
276
+ const rows = state.features.length;
277
+ const columns = state.rasterData.time_ms.length;
278
+ const rowHeight = raster.height / Math.max(rows, 1);
279
+ const columnWidth = raster.width / Math.max(columns, 1);
280
+ const maximum = Math.max(Number(state.rasterData.max_count), 1);
281
+ state.rasterData.counts.forEach((row, rowIndex) => {
282
+ row.forEach((value, columnIndex) => {
283
+ if (!value) return;
284
+ context.fillStyle = mixColor("#F8FAFB", "#263238", Number(value) / maximum);
285
+ const x0 = raster.x + columnIndex * columnWidth;
286
+ const x1 = raster.x + (columnIndex + 1) * columnWidth;
287
+ const y0 = raster.y + rowIndex * rowHeight;
288
+ const y1 = raster.y + (rowIndex + 1) * rowHeight;
289
+ context.fillRect(
290
+ Math.floor(x0),
291
+ Math.floor(y0),
292
+ Math.max(1, Math.ceil(x1) - Math.floor(x0)),
293
+ Math.max(1, Math.ceil(y1) - Math.floor(y0))
294
+ );
295
+ });
296
+ context.fillStyle = state.features[rowIndex].color;
297
+ context.fillRect(
298
+ raster.x + raster.width + 3,
299
+ raster.y + rowIndex * rowHeight,
300
+ 5,
301
+ Math.max(1, rowHeight)
302
+ );
303
+ });
304
+ context.strokeStyle = "#CFD8DE";
305
+ context.lineWidth = 1;
306
+ context.strokeRect(raster.x - 0.5, raster.y - 0.5, raster.width + 1, raster.height + 1);
307
+ }
308
+
309
+ function drawCountLegend(state, y) {
310
+ const { svg, geometry } = state;
311
+ let ticks = integerTicks(state.rasterData.max_count);
312
+ if (geometry.width < 300 && ticks.length > 2) {
313
+ ticks = [0, Math.round(state.rasterData.max_count)];
314
+ }
315
+ const maximum = Math.max(Number(state.rasterData.max_count), 1);
316
+ const itemWidth = geometry.mobile ? 32 : 38;
317
+ const labelWidth = geometry.width < 300 ? 50 : 58;
318
+ const width = labelWidth + ticks.length * itemWidth;
319
+ const start = Math.max(geometry.raster.x, geometry.raster.x + geometry.raster.width - width);
320
+ addText(svg, start, y, "Count / bin", { size: 9, fill: MUTED, weight: 600 });
321
+ ticks.forEach((tick, index) => {
322
+ const x = start + labelWidth + index * itemWidth;
323
+ svg.appendChild(svgElement("rect", {
324
+ x,
325
+ y: y - 9,
326
+ width: 9,
327
+ height: 9,
328
+ rx: 1,
329
+ fill: mixColor("#F8FAFB", "#263238", tick / maximum),
330
+ stroke: "#CAD3D9",
331
+ "stroke-width": 0.6,
332
+ }));
333
+ addText(svg, x + 13, y, String(tick), { size: 9, fill: MUTED });
334
+ });
335
+ }
336
+
337
+ function drawReferenceLabels(state) {
338
+ const { svg, geometry, features } = state;
339
+ if (state.rasterData.dataset === "allen_neuropixels") {
340
+ addText(svg, geometry.raster.x + geometry.raster.width + 10, geometry.raster.y - 7, "gOSI", {
341
+ size: 9,
342
+ fill: MUTED,
343
+ anchor: "end",
344
+ });
345
+ return;
346
+ }
347
+ const rowHeight = geometry.raster.height / Math.max(features.length, 1);
348
+ groupSpans(features).forEach((span) => {
349
+ const y = geometry.raster.y + ((span.first + span.last + 1) / 2) * rowHeight;
350
+ addText(svg, geometry.raster.x - 8, y, span.group, {
351
+ size: geometry.mobile ? 9 : 10,
352
+ fill: MUTED,
353
+ anchor: "end",
354
+ baseline: "middle",
355
+ });
356
+ });
357
+ }
358
+
359
+ function drawFeatureLegend(state, y) {
360
+ const { svg, geometry, features } = state;
361
+ let x = geometry.chart.x;
362
+ if (state.rasterData.dataset === "allen_neuropixels") {
363
+ addText(svg, x, y, "gOSI", { size: 9, fill: MUTED, weight: 600 });
364
+ x += 31;
365
+ const values = numericTicks(state.validationDomain[0], state.validationDomain[1], 3);
366
+ values.forEach((value) => {
367
+ svg.appendChild(svgElement("circle", {
368
+ cx: x + 5,
369
+ cy: y - 3,
370
+ r: 4,
371
+ fill: gosiColor(value, state.validationDomain[0], state.validationDomain[1]),
372
+ stroke: "#FFFFFF",
373
+ "stroke-width": 0.6,
374
+ }));
375
+ addText(svg, x + 12, y, value.toFixed(2), { size: 9, fill: MUTED });
376
+ x += geometry.mobile ? 50 : 58;
377
+ });
378
+ return;
379
+ }
380
+ const groups = [...new Map(features.map((feature) => [feature.group, feature.color])).entries()];
381
+ groups.forEach(([group, color]) => {
382
+ svg.appendChild(svgElement("circle", {
383
+ cx: x + 4,
384
+ cy: y - 3,
385
+ r: 4,
386
+ fill: color,
387
+ stroke: "#FFFFFF",
388
+ "stroke-width": 0.6,
389
+ }));
390
+ addText(svg, x + 12, y, group, { size: 9, fill: MUTED });
391
+ x += 18 + group.length * 5.3;
392
+ });
393
+ }
394
+
395
+ function drawAxes(state) {
396
+ const { svg, geometry, features } = state;
397
+ const { raster, chart } = geometry;
398
+ addText(svg, raster.x, 20, "Example activity · one trial", { size: 13, weight: 700 });
399
+ drawCountLegend(state, 39);
400
+ drawReferenceLabels(state);
401
+
402
+ const time = state.rasterData.time_ms.map(Number);
403
+ const timeLow = time[0];
404
+ const timeHigh = time[time.length - 1];
405
+ const timeTicks = [timeLow];
406
+ if (timeLow < 0 && timeHigh > 0) timeTicks.push(0);
407
+ if (timeHigh !== timeLow) timeTicks.push(timeHigh);
408
+ svg.appendChild(svgElement("line", {
409
+ x1: raster.x,
410
+ x2: raster.x + raster.width,
411
+ y1: raster.y + raster.height,
412
+ y2: raster.y + raster.height,
413
+ stroke: DARK_GRID,
414
+ "stroke-width": 1,
415
+ }));
416
+ timeTicks.forEach((tick) => {
417
+ const fraction = (tick - timeLow) / Math.max(timeHigh - timeLow, 1e-9);
418
+ const x = raster.x + fraction * raster.width;
419
+ svg.appendChild(svgElement("line", {
420
+ x1: x,
421
+ x2: x,
422
+ y1: raster.y + raster.height,
423
+ y2: raster.y + raster.height + 4,
424
+ stroke: DARK_GRID,
425
+ }));
426
+ addText(svg, x, raster.y + raster.height + 16, `${Math.round(tick)}`, {
427
+ size: 9,
428
+ fill: MUTED,
429
+ anchor: "middle",
430
+ });
431
+ });
432
+ addText(svg, raster.x + raster.width / 2, raster.y + raster.height + 34, "Time from scoring onset (ms)", {
433
+ size: 10,
434
+ fill: MUTED,
435
+ anchor: "middle",
436
+ });
437
+
438
+ const chartTitleY = geometry.mobile ? 360 : 20;
439
+ const legendY = geometry.mobile ? 380 : 39;
440
+ addText(
441
+ svg,
442
+ chart.x,
443
+ chartTitleY,
444
+ state.attributionData.tied ? "Signed Kernel SHAP values" : "Signed Kernel SHAP ranking",
445
+ { size: 13, weight: 700 }
446
+ );
447
+ drawFeatureLegend(state, legendY);
448
+
449
+ const scores = features.map((feature) => Number(feature.signed_attribution));
450
+ let scoreLow = Math.min(0, ...scores);
451
+ let scoreHigh = Math.max(0, ...scores);
452
+ let span = scoreHigh - scoreLow;
453
+ if (span < 1e-12) span = Math.max(Math.abs(scoreHigh), 1) * 0.1;
454
+ scoreLow -= span * 0.08;
455
+ scoreHigh += span * 0.08;
456
+ state.scoreDomain = [scoreLow, scoreHigh];
457
+
458
+ const xTicks = numericTicks(scoreLow, scoreHigh, geometry.mobile ? 3 : 5);
459
+ xTicks.forEach((tick) => {
460
+ const x = chart.x + (tick - scoreLow) / (scoreHigh - scoreLow) * chart.width;
461
+ svg.appendChild(svgElement("line", {
462
+ x1: x,
463
+ x2: x,
464
+ y1: chart.y,
465
+ y2: chart.y + chart.height,
466
+ stroke: Math.abs(tick) < span * 0.03 ? DARK_GRID : GRID,
467
+ "stroke-width": Math.abs(tick) < span * 0.03 ? 1.2 : 0.8,
468
+ }));
469
+ addText(svg, x, chart.y + chart.height + 17, formatScore(tick), {
470
+ size: 9,
471
+ fill: MUTED,
472
+ anchor: "middle",
473
+ });
474
+ });
475
+ addText(svg, chart.x + chart.width / 2, chart.y + chart.height + 36, "Signed Kernel SHAP value", {
476
+ size: 10,
477
+ fill: MUTED,
478
+ anchor: "middle",
479
+ });
480
+
481
+ const rankMaximum = features.length;
482
+ const rankTicks = [...new Set([1, Math.max(1, Math.round(rankMaximum / 2)), rankMaximum])];
483
+ addText(svg, chart.x + chart.width + 10, chart.y - 10, state.attributionData.tied ? "Feature" : "Rank", {
484
+ size: 9,
485
+ fill: MUTED,
486
+ weight: 600,
487
+ });
488
+ rankTicks.forEach((rank) => {
489
+ const y = chart.y + (rank - 1) / Math.max(rankMaximum - 1, 1) * chart.height;
490
+ svg.appendChild(svgElement("line", {
491
+ x1: chart.x + chart.width,
492
+ x2: chart.x + chart.width + 4,
493
+ y1: y,
494
+ y2: y,
495
+ stroke: DARK_GRID,
496
+ }));
497
+ addText(svg, chart.x + chart.width + 8, y, String(rank), {
498
+ size: 9,
499
+ fill: MUTED,
500
+ baseline: "middle",
501
+ });
502
+ });
503
+ }
504
+
505
+ function sourcePosition(state, feature) {
506
+ const { raster } = state.geometry;
507
+ return {
508
+ x: raster.x + raster.width + 6,
509
+ y: raster.y + (feature.row + 0.5) / state.features.length * raster.height,
510
+ };
511
+ }
512
+
513
+ function targetPosition(state, feature) {
514
+ const { chart } = state.geometry;
515
+ const [low, high] = state.scoreDomain;
516
+ const x = chart.x + (Number(feature.signed_attribution) - low) / Math.max(high - low, 1e-9) * chart.width;
517
+ const displayedRank = state.attributionData.tied ? feature.row + 1 : Number(feature.rank);
518
+ const y = chart.y + (displayedRank - 1) / Math.max(state.features.length - 1, 1) * chart.height;
519
+ return { x, y };
520
+ }
521
+
522
+ function featurePosition(state, feature, progress) {
523
+ const start = sourcePosition(state, feature);
524
+ const end = targetPosition(state, feature);
525
+ const mobile = state.geometry.mobile;
526
+ const controlOne = mobile
527
+ ? { x: start.x, y: start.y + (end.y - start.y) * 0.42 }
528
+ : { x: start.x + (end.x - start.x) * 0.40, y: start.y };
529
+ const controlTwo = mobile
530
+ ? { x: end.x, y: end.y - (end.y - start.y) * 0.34 }
531
+ : { x: end.x - (end.x - start.x) * 0.32, y: end.y };
532
+ return bezier(start, controlOne, controlTwo, end, progress);
533
+ }
534
+
535
+ function updateConnector(state) {
536
+ if (!state.connector || !state.rowHighlight) return;
537
+ const selectedId = state.locked !== null ? state.locked : state.hovered;
538
+ if (selectedId === null) {
539
+ state.connector.setAttribute("visibility", "hidden");
540
+ state.rowHighlight.setAttribute("visibility", "hidden");
541
+ state.circles.forEach((circle) => {
542
+ circle.setAttribute("opacity", "0.86");
543
+ circle.setAttribute("r", circle.dataset.baseRadius);
544
+ });
545
+ return;
546
+ }
547
+ const feature = state.features.find((item) => item.feature_index === selectedId);
548
+ if (!feature) return;
549
+ const start = sourcePosition(state, feature);
550
+ const current = state.positions.get(selectedId) || targetPosition(state, feature);
551
+ const mid = state.geometry.mobile
552
+ ? `C ${start.x} ${(start.y + current.y) / 2}, ${current.x} ${(start.y + current.y) / 2}, ${current.x} ${current.y}`
553
+ : `C ${(start.x + current.x) / 2} ${start.y}, ${(start.x + current.x) / 2} ${current.y}, ${current.x} ${current.y}`;
554
+ state.connector.setAttribute("d", `M ${start.x} ${start.y} ${mid}`);
555
+ state.connector.setAttribute("stroke", feature.color);
556
+ state.connector.setAttribute("visibility", "visible");
557
+ const rowHeight = state.geometry.raster.height / state.features.length;
558
+ state.rowHighlight.setAttribute("x", state.geometry.raster.x);
559
+ state.rowHighlight.setAttribute("y", state.geometry.raster.y + feature.row * rowHeight);
560
+ state.rowHighlight.setAttribute("width", state.geometry.raster.width + 9);
561
+ state.rowHighlight.setAttribute("height", Math.max(rowHeight, 1.5));
562
+ state.rowHighlight.setAttribute("stroke", feature.color);
563
+ state.rowHighlight.setAttribute("visibility", "visible");
564
+ state.circles.forEach((circle, featureId) => {
565
+ const active = featureId === selectedId;
566
+ circle.setAttribute("opacity", active ? "1" : "0.16");
567
+ circle.setAttribute("r", active ? Number(circle.dataset.baseRadius) + 2.2 : circle.dataset.baseRadius);
568
+ });
569
+ }
570
+
571
+ function updatePositions(state, progress) {
572
+ state.progress = clamp(progress, 0, 1);
573
+ state.slider.value = String(state.progress);
574
+ state.features.forEach((feature) => {
575
+ const point = featurePosition(state, feature, state.progress);
576
+ state.positions.set(feature.feature_index, point);
577
+ const circle = state.circles.get(feature.feature_index);
578
+ if (circle) {
579
+ circle.setAttribute("cx", point.x);
580
+ circle.setAttribute("cy", point.y);
581
+ }
582
+ });
583
+ updateConnector(state);
584
+ }
585
+
586
+ function updateReplayPositions(state, progress) {
587
+ const globalProgress = clamp(progress, 0, 1);
588
+ state.progress = globalProgress;
589
+ state.slider.value = String(globalProgress);
590
+ state.features.forEach((feature) => {
591
+ const delay = state.features.length > 1
592
+ ? feature.row / (state.features.length - 1) * 0.12
593
+ : 0;
594
+ const local = clamp((globalProgress - delay) / 0.88, 0, 1);
595
+ const eased = 1 - Math.pow(1 - local, 3);
596
+ const point = featurePosition(state, feature, eased);
597
+ state.positions.set(feature.feature_index, point);
598
+ const circle = state.circles.get(feature.feature_index);
599
+ if (circle) {
600
+ circle.setAttribute("cx", point.x);
601
+ circle.setAttribute("cy", point.y);
602
+ }
603
+ });
604
+ updateConnector(state);
605
+ }
606
+
607
+ function detailText(state, feature, cell) {
608
+ const noun = state.rasterData.dataset === "allen_neuropixels" ? "Unit" : "Feature";
609
+ const parts = [`${noun} ${feature.feature_index}`];
610
+ if (state.rasterData.dataset === "allen_neuropixels" && Number.isFinite(Number(feature.validation_value))) {
611
+ parts.push(`gOSI ${Number(feature.validation_value).toFixed(3)}`);
612
+ } else {
613
+ parts.push(feature.group);
614
+ }
615
+ if (!state.attributionData.tied) parts.push(`rank ${feature.rank} of ${state.features.length}`);
616
+ parts.push(`signed Kernel SHAP ${signedScore(Number(feature.signed_attribution))}`);
617
+ if (cell) parts.push(`${Math.round(cell.time)} ms`, `${cell.count} count${cell.count === 1 ? "" : "s"}`);
618
+ return parts.join(" · ");
619
+ }
620
+
621
+ function selectFeature(state, featureId, cell, lock) {
622
+ const feature = state.features.find((item) => item.feature_index === featureId);
623
+ if (!feature) return;
624
+ if (lock !== undefined) state.locked = lock ? featureId : null;
625
+ state.hovered = featureId;
626
+ state.keyboardRow = feature.row;
627
+ state.detailNode.textContent = detailText(state, feature, cell);
628
+ updateConnector(state);
629
+ }
630
+
631
+ function clearSelection(state, clearLock) {
632
+ if (clearLock) state.locked = null;
633
+ state.hovered = null;
634
+ if (state.locked === null) {
635
+ state.detailNode.textContent = "Hover or tap a raster row or attribution dot to trace the same feature.";
636
+ }
637
+ updateConnector(state);
638
+ }
639
+
640
+ function rasterCellAt(state, event) {
641
+ const bounds = state.visual.getBoundingClientRect();
642
+ const x = event.clientX - bounds.left;
643
+ const y = event.clientY - bounds.top;
644
+ const raster = state.geometry.raster;
645
+ if (x < raster.x || x > raster.x + raster.width || y < raster.y || y > raster.y + raster.height) {
646
+ return null;
647
+ }
648
+ const row = clamp(Math.floor((y - raster.y) / raster.height * state.features.length), 0, state.features.length - 1);
649
+ const column = clamp(Math.floor((x - raster.x) / raster.width * state.rasterData.time_ms.length), 0, state.rasterData.time_ms.length - 1);
650
+ return {
651
+ row,
652
+ column,
653
+ time: Number(state.rasterData.time_ms[column]),
654
+ count: Number(state.rasterData.counts[row][column]),
655
+ };
656
+ }
657
+
658
+ function replay(state) {
659
+ if (state.animationFrame !== null) cancelAnimationFrame(state.animationFrame);
660
+ if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
661
+ updatePositions(state, 1);
662
+ return;
663
+ }
664
+ const pause = 130;
665
+ const duration = 980;
666
+ const startTime = performance.now();
667
+ updatePositions(state, 0);
668
+ const step = (now) => {
669
+ const elapsed = now - startTime;
670
+ if (elapsed <= pause) {
671
+ state.animationFrame = requestAnimationFrame(step);
672
+ return;
673
+ }
674
+ const raw = clamp((elapsed - pause) / duration, 0, 1);
675
+ updateReplayPositions(state, raw);
676
+ if (raw < 1) {
677
+ state.animationFrame = requestAnimationFrame(step);
678
+ } else {
679
+ state.animationFrame = null;
680
+ }
681
+ };
682
+ state.animationFrame = requestAnimationFrame(step);
683
+ }
684
+
685
+ function draw(state) {
686
+ const width = Math.floor(state.container.clientWidth);
687
+ if (width < 240) return;
688
+ state.geometry = computeGeometry(state, width);
689
+ state.visual.style.height = `${state.geometry.height}px`;
690
+ state.svg.setAttribute("viewBox", `0 0 ${state.geometry.width} ${state.geometry.height}`);
691
+ state.svg.setAttribute("width", String(state.geometry.width));
692
+ state.svg.setAttribute("height", String(state.geometry.height));
693
+ state.svg.replaceChildren(
694
+ svgElement("title", {}, "Feature activity linked to signed Kernel SHAP ranking"),
695
+ svgElement("desc", {}, "Hover or tap a feature to trace it from the example activity raster to its signed attribution rank.")
696
+ );
697
+ drawRaster(state);
698
+ drawAxes(state);
699
+
700
+ state.rowHighlight = svgElement("rect", {
701
+ fill: "rgba(255,255,255,0.05)",
702
+ stroke: "#6A51A3",
703
+ "stroke-width": 1.2,
704
+ visibility: "hidden",
705
+ "pointer-events": "none",
706
+ });
707
+ state.svg.appendChild(state.rowHighlight);
708
+ state.connector = svgElement("path", {
709
+ fill: "none",
710
+ stroke: "#6A51A3",
711
+ "stroke-width": 1.35,
712
+ opacity: 0.85,
713
+ visibility: "hidden",
714
+ "pointer-events": "none",
715
+ });
716
+ state.svg.appendChild(state.connector);
717
+
718
+ const dotGroup = svgElement("g", { class: "feature-story-dots" });
719
+ state.svg.appendChild(dotGroup);
720
+ state.circles = new Map();
721
+ const baseRadius = state.features.length > 300 ? 2.7 : state.features.length > 140 ? 3.2 : 4.1;
722
+ state.features.forEach((feature) => {
723
+ const circle = svgElement("circle", {
724
+ r: baseRadius,
725
+ fill: feature.color,
726
+ stroke: "#FFFFFF",
727
+ "stroke-width": 0.75,
728
+ opacity: 0.86,
729
+ "data-feature-id": feature.feature_index,
730
+ class: "feature-story-dot",
731
+ });
732
+ circle.dataset.baseRadius = String(baseRadius);
733
+ dotGroup.appendChild(circle);
734
+ state.circles.set(feature.feature_index, circle);
735
+ });
736
+ updatePositions(state, state.progress);
737
+ }
738
+
739
+ function bindInteractions(state) {
740
+ const signal = state.abortController.signal;
741
+ state.visual.addEventListener("pointermove", (event) => {
742
+ if (state.locked !== null) return;
743
+ const circle = event.target.closest && event.target.closest(".feature-story-dot");
744
+ if (circle) {
745
+ selectFeature(state, Number(circle.dataset.featureId), null);
746
+ return;
747
+ }
748
+ const cell = rasterCellAt(state, event);
749
+ if (cell) {
750
+ selectFeature(state, state.features[cell.row].feature_index, cell);
751
+ } else {
752
+ clearSelection(state, false);
753
+ }
754
+ }, { signal });
755
+ state.visual.addEventListener("pointerleave", () => {
756
+ if (state.locked === null) clearSelection(state, false);
757
+ }, { signal });
758
+ state.visual.addEventListener("click", (event) => {
759
+ const circle = event.target.closest && event.target.closest(".feature-story-dot");
760
+ const cell = circle ? null : rasterCellAt(state, event);
761
+ const featureId = circle
762
+ ? Number(circle.dataset.featureId)
763
+ : cell
764
+ ? state.features[cell.row].feature_index
765
+ : null;
766
+ if (featureId === null) {
767
+ clearSelection(state, true);
768
+ return;
769
+ }
770
+ const shouldLock = state.locked !== featureId;
771
+ selectFeature(state, featureId, cell, shouldLock);
772
+ if (!shouldLock) clearSelection(state, true);
773
+ }, { signal });
774
+ state.slider.addEventListener("input", () => {
775
+ if (state.animationFrame !== null) cancelAnimationFrame(state.animationFrame);
776
+ state.animationFrame = null;
777
+ updatePositions(state, Number(state.slider.value));
778
+ }, { signal });
779
+ state.replay.addEventListener("click", () => replay(state), { signal });
780
+ state.container.addEventListener("keydown", (event) => {
781
+ if (!["ArrowDown", "ArrowUp", "Home", "End", "Enter", "Escape"].includes(event.key)) return;
782
+ event.preventDefault();
783
+ if (event.key === "Escape") {
784
+ clearSelection(state, true);
785
+ return;
786
+ }
787
+ if (event.key === "Home") state.keyboardRow = 0;
788
+ if (event.key === "End") state.keyboardRow = state.features.length - 1;
789
+ if (event.key === "ArrowDown") state.keyboardRow = Math.min(state.features.length - 1, state.keyboardRow + 1);
790
+ if (event.key === "ArrowUp") state.keyboardRow = Math.max(0, state.keyboardRow - 1);
791
+ const feature = state.features[state.keyboardRow];
792
+ selectFeature(state, feature.feature_index, null, event.key === "Enter" ? true : undefined);
793
+ }, { signal });
794
+ }
795
+
796
+ function destroy(state) {
797
+ if (!state) return;
798
+ if (state.animationFrame !== null) cancelAnimationFrame(state.animationFrame);
799
+ if (state.resizeObserver) state.resizeObserver.disconnect();
800
+ if (state.abortController) state.abortController.abort();
801
+ }
802
+
803
+ function render(containerId, detailId, rasterData, attributionData) {
804
+ const container = document.getElementById(containerId);
805
+ const detailNode = document.getElementById(detailId);
806
+ if (!container || !detailNode || !rasterData.features || !attributionData.features) return "waiting";
807
+ const signature = `${rasterData.dataset}:${attributionData.model}`;
808
+ if (container.__featureStory && container.__featureStory.signature === signature) {
809
+ return signature;
810
+ }
811
+ destroy(container.__featureStory);
812
+ try {
813
+ const state = buildState(container, detailNode, rasterData, attributionData);
814
+ bindInteractions(state);
815
+ state.resizeObserver = new ResizeObserver(() => {
816
+ window.requestAnimationFrame(() => draw(state));
817
+ });
818
+ state.resizeObserver.observe(container);
819
+ window.requestAnimationFrame(() => draw(state));
820
+ detailNode.textContent = "Hover or tap a raster row or attribution dot to trace the same feature.";
821
+ return signature;
822
+ } catch (error) {
823
+ container.replaceChildren(htmlElement("div", "feature-story-error", "Feature visualization unavailable."));
824
+ detailNode.textContent = "Feature visualization unavailable.";
825
+ console.error(error);
826
+ return `error:${signature}`;
827
+ }
828
+ }
829
+
830
+ let scheduleGeneration = 0;
831
+
832
+ function schedule(containerId, detailId, rasterData, attributionData) {
833
+ scheduleGeneration += 1;
834
+ const generation = scheduleGeneration;
835
+ let attempts = 0;
836
+ const mount = () => {
837
+ if (generation !== scheduleGeneration) return;
838
+ if (document.getElementById(containerId) && document.getElementById(detailId)) {
839
+ render(containerId, detailId, rasterData, attributionData);
840
+ return;
841
+ }
842
+ attempts += 1;
843
+ if (attempts < 60) window.requestAnimationFrame(mount);
844
+ };
845
+ window.requestAnimationFrame(mount);
846
+ }
847
+
848
+ window.benchdashFeatureStory = { render, schedule };
849
+ })();
assets/styles.css CHANGED
@@ -607,6 +607,76 @@ h2 {
607
  margin-top: 14px !important;
608
  }
609
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
610
  .inline-controls {
611
  display: grid;
612
  grid-template-columns: minmax(240px, 420px) minmax(260px, 380px);
@@ -623,55 +693,122 @@ h2 {
623
  grid-template-columns: minmax(240px, 420px);
624
  }
625
 
626
- .arrangement-toggle {
627
- display: flex;
 
 
 
 
 
 
 
 
628
  width: 100%;
629
- max-width: 380px;
630
  overflow: hidden;
631
- border: 1px solid #cbd5dd;
632
- border-radius: 7px;
633
  background: #ffffff;
634
  }
635
 
636
- .arrangement-toggle label {
 
 
 
 
637
  position: relative;
638
- flex: 1 1 0;
639
- min-width: 0;
640
- margin: 0;
641
- padding: 9px 13px;
642
- border-right: 1px solid #dce3e8;
643
- color: #5a6c79;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
644
  cursor: pointer;
645
- font-size: 12px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
646
  font-weight: 700;
647
- letter-spacing: 0;
648
- line-height: 1.55;
649
- text-transform: none;
650
- text-align: center;
651
- white-space: normal;
652
  }
653
 
654
- .arrangement-toggle label:last-child {
655
- border-right: 0;
656
  }
657
 
658
- .arrangement-toggle input {
659
- position: absolute;
660
- opacity: 0;
 
 
 
 
 
 
661
  }
662
 
663
- .arrangement-toggle label:has(input:checked) {
664
- background: #eee9f6;
 
 
 
 
 
665
  color: #4d3880;
 
 
 
 
 
 
 
 
 
 
 
 
 
666
  }
667
 
668
- .feature-story-grid {
669
- grid-template-columns: minmax(320px, 0.78fr) minmax(0, 1.42fr);
 
 
670
  }
671
 
672
  .feature-selection-detail {
673
  min-height: 36px;
674
- margin: 0 0 12px;
675
  padding: 9px 11px;
676
  border-left: 3px solid var(--feature);
677
  border-radius: 6px;
@@ -859,9 +996,6 @@ h2 {
859
  grid-template-columns: 1fr;
860
  }
861
 
862
- .feature-story-grid {
863
- grid-template-columns: 1fr;
864
- }
865
  }
866
 
867
  @media (max-width: 820px) {
@@ -909,22 +1043,96 @@ h2 {
909
  grid-template-columns: 1fr;
910
  }
911
 
 
 
 
 
912
  .modebar-container {
913
  display: none !important;
914
  }
915
 
916
- .heatmap-graph,
 
 
 
 
 
 
 
 
917
  .latent-graph {
918
  overflow-x: auto;
 
 
 
919
  }
920
 
921
- .heatmap-graph > div,
922
- .latent-graph > div {
923
  min-width: 660px;
924
  }
925
  }
926
 
927
  @media (max-width: 560px) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
928
  .site-nav {
929
  align-items: flex-start;
930
  padding: 15px 0;
 
607
  margin-top: 14px !important;
608
  }
609
 
610
+ .latent-space-mobile,
611
+ .latent-mobile-legend {
612
+ display: none;
613
+ }
614
+
615
+ .latent-mobile-legend {
616
+ gap: 7px 12px;
617
+ align-items: center;
618
+ margin: 8px 0 14px;
619
+ padding: 9px 12px;
620
+ border: 1px solid #e2e8ec;
621
+ border-radius: 7px;
622
+ background: #fafbfc;
623
+ color: #536672;
624
+ font-size: 10px;
625
+ }
626
+
627
+ .latent-mobile-legend-title {
628
+ grid-column: 1 / -1;
629
+ font-weight: 750;
630
+ }
631
+
632
+ .latent-mobile-legend-item {
633
+ display: inline-flex;
634
+ gap: 5px;
635
+ align-items: center;
636
+ }
637
+
638
+ .latent-mobile-legend-dot {
639
+ display: inline-block;
640
+ width: 8px;
641
+ height: 8px;
642
+ border-radius: 50%;
643
+ }
644
+
645
+ .historical-trajectory-mobile {
646
+ display: none;
647
+ }
648
+
649
+ .trajectory-legend {
650
+ display: grid;
651
+ grid-template-columns: auto repeat(8, minmax(0, auto));
652
+ gap: 8px 13px;
653
+ align-items: center;
654
+ margin: 8px 0 14px;
655
+ padding: 9px 12px;
656
+ border: 1px solid #e2e8ec;
657
+ border-radius: 7px;
658
+ background: #fafbfc;
659
+ color: #536672;
660
+ font-size: 10px;
661
+ }
662
+
663
+ .trajectory-legend-title {
664
+ font-weight: 750;
665
+ }
666
+
667
+ .trajectory-legend-item {
668
+ display: inline-flex;
669
+ gap: 5px;
670
+ align-items: center;
671
+ white-space: nowrap;
672
+ }
673
+
674
+ .trajectory-legend-swatch {
675
+ width: 13px;
676
+ height: 3px;
677
+ border-radius: 3px;
678
+ }
679
+
680
  .inline-controls {
681
  display: grid;
682
  grid-template-columns: minmax(240px, 420px) minmax(260px, 380px);
 
693
  grid-template-columns: minmax(240px, 420px);
694
  }
695
 
696
+ .feature-method-control {
697
+ max-width: 420px;
698
+ margin-bottom: 14px;
699
+ padding: 13px 14px;
700
+ border: 1px solid #e0e6ea;
701
+ border-radius: 9px;
702
+ background: #fafbfc;
703
+ }
704
+
705
+ .feature-story-viz {
706
  width: 100%;
 
707
  overflow: hidden;
708
+ border: 1px solid #dfe6ea;
709
+ border-radius: 9px;
710
  background: #ffffff;
711
  }
712
 
713
+ .feature-story-viz:focus-visible {
714
+ outline-offset: 3px !important;
715
+ }
716
+
717
+ .feature-story-visual {
718
  position: relative;
719
+ width: 100%;
720
+ overflow: hidden;
721
+ background: #ffffff;
722
+ }
723
+
724
+ .feature-story-canvas,
725
+ .feature-story-overlay {
726
+ position: absolute;
727
+ inset: 0;
728
+ display: block;
729
+ }
730
+
731
+ .feature-story-overlay {
732
+ pointer-events: none;
733
+ font-family: Arial, Helvetica, sans-serif;
734
+ }
735
+
736
+ .feature-story-overlay text {
737
+ user-select: none;
738
+ }
739
+
740
+ .feature-story-dot {
741
  cursor: pointer;
742
+ pointer-events: all;
743
+ }
744
+
745
+ .feature-story-playback {
746
+ display: grid;
747
+ grid-template-areas: "source slider target replay";
748
+ grid-template-columns: auto minmax(150px, 1fr) auto auto;
749
+ gap: 10px;
750
+ align-items: center;
751
+ min-height: 58px;
752
+ padding: 10px 14px;
753
+ border-top: 1px solid #edf1f3;
754
+ background: #fbfcfc;
755
+ }
756
+
757
+ .feature-story-playback-label {
758
+ color: #5b6d79;
759
+ font-size: 10px;
760
  font-weight: 700;
761
+ letter-spacing: 0.02em;
762
+ white-space: nowrap;
 
 
 
763
  }
764
 
765
+ .feature-story-playback-label.source {
766
+ grid-area: source;
767
  }
768
 
769
+ .feature-story-playback-label.target {
770
+ grid-area: target;
771
+ }
772
+
773
+ .feature-story-slider {
774
+ grid-area: slider;
775
+ width: 100%;
776
+ accent-color: var(--feature);
777
+ cursor: ew-resize;
778
  }
779
 
780
+ .feature-story-replay {
781
+ grid-area: replay;
782
+ min-height: 34px;
783
+ padding: 7px 12px;
784
+ border: 1px solid #b9accf;
785
+ border-radius: 6px;
786
+ background: #f4f1f8;
787
  color: #4d3880;
788
+ cursor: pointer;
789
+ font: inherit;
790
+ font-size: 11px;
791
+ font-weight: 750;
792
+ }
793
+
794
+ .feature-story-replay:hover {
795
+ border-color: #846fa6;
796
+ background: #eee9f5;
797
+ }
798
+
799
+ .feature-story-render-token {
800
+ display: none;
801
  }
802
 
803
+ .feature-story-error {
804
+ padding: 40px 20px;
805
+ color: #607080;
806
+ text-align: center;
807
  }
808
 
809
  .feature-selection-detail {
810
  min-height: 36px;
811
+ margin: 12px 0;
812
  padding: 9px 11px;
813
  border-left: 3px solid var(--feature);
814
  border-radius: 6px;
 
996
  grid-template-columns: 1fr;
997
  }
998
 
 
 
 
999
  }
1000
 
1001
  @media (max-width: 820px) {
 
1043
  grid-template-columns: 1fr;
1044
  }
1045
 
1046
+ .feature-method-control {
1047
+ max-width: none;
1048
+ }
1049
+
1050
  .modebar-container {
1051
  display: none !important;
1052
  }
1053
 
1054
+ .heatmap-graph {
1055
+ overflow: visible;
1056
+ }
1057
+
1058
+ .heatmap-graph > div {
1059
+ width: 100% !important;
1060
+ min-width: 0;
1061
+ }
1062
+
1063
  .latent-graph {
1064
  overflow-x: auto;
1065
+ padding-bottom: 7px;
1066
+ scrollbar-color: #a9b5bd #eef2f4;
1067
+ scrollbar-width: thin;
1068
  }
1069
 
1070
+ .latent-space-desktop > div {
 
1071
  min-width: 660px;
1072
  }
1073
  }
1074
 
1075
  @media (max-width: 560px) {
1076
+ .heatmap-graph {
1077
+ display: none;
1078
+ }
1079
+
1080
+ .latent-space-desktop {
1081
+ display: none;
1082
+ }
1083
+
1084
+ .latent-space-mobile {
1085
+ display: block;
1086
+ overflow: visible;
1087
+ padding-bottom: 0;
1088
+ }
1089
+
1090
+ .latent-space-mobile > div {
1091
+ width: 100% !important;
1092
+ min-width: 0;
1093
+ }
1094
+
1095
+ .latent-mobile-legend {
1096
+ display: grid;
1097
+ grid-template-columns: repeat(2, minmax(0, 1fr));
1098
+ }
1099
+
1100
+ .historical-trajectory-desktop {
1101
+ display: none;
1102
+ }
1103
+
1104
+ .historical-trajectory-mobile {
1105
+ display: block;
1106
+ }
1107
+
1108
+ .trajectory-legend {
1109
+ grid-template-columns: repeat(2, minmax(0, 1fr));
1110
+ gap: 7px 12px;
1111
+ }
1112
+
1113
+ .trajectory-legend-title {
1114
+ grid-column: 1 / -1;
1115
+ }
1116
+
1117
+ .feature-story-playback {
1118
+ grid-template-areas:
1119
+ "source target"
1120
+ "slider slider"
1121
+ "replay replay";
1122
+ grid-template-columns: 1fr 1fr;
1123
+ gap: 8px 12px;
1124
+ padding: 11px 12px 12px;
1125
+ }
1126
+
1127
+ .feature-story-playback-label.target {
1128
+ text-align: right;
1129
+ }
1130
+
1131
+ .feature-story-replay {
1132
+ width: 100%;
1133
+ min-height: 40px;
1134
+ }
1135
+
1136
  .site-nav {
1137
  align-items: flex-start;
1138
  padding: 15px 0;