// Cross-layer heatmap — hero element. Plotly under the hood.
// Click any cell -> onCellClick({layer, component})
const { useRef, useEffect, useMemo, useState } = React;
function Heatmap({ matrix, behaviorMatrix, behaviorLabel, onCellClick, selectedCell, palette }) {
const ref = useRef(null);
const [hover, setHover] = useState(null);
const componentLabels = AtlasData.COMPONENTS.map(c => c.label);
const layerLabels = Array.from({ length: AtlasData.N_LAYERS }, (_, i) => `L${i}`);
// Build z matrix — if a behavior overlay is active, blend
const z = useMemo(() => {
if (!behaviorMatrix) return matrix;
// emphasize behavior — multiplicative blend, then re-normalize
const out = [];
let max = 0;
for (let l = 0; l < matrix.length; l++) {
const row = [];
for (let c = 0; c < (matrix[0] || []).length; c++) {
const a = matrix[l][c]; const b = behaviorMatrix[l] ? behaviorMatrix[l][c] : 0;
if (!Number.isFinite(a) && !Number.isFinite(b)) { row.push(NaN); continue; }
const v = 0.25 * (Number.isFinite(a) ? a : 0) + 0.95 * (Number.isFinite(b) ? b : 0);
row.push(v);
if (v > max) max = v;
}
out.push(row);
}
return out.map(r => r.map(v => Number.isFinite(v) ? v / (max || 1) : NaN));
}, [matrix, behaviorMatrix]);
useEffect(() => {
if (!ref.current || !window.Plotly) return;
const colorscale = palette;
const data = [{
z, x: componentLabels, y: layerLabels,
type: 'heatmap',
colorscale,
zmin: 0, zmax: 1,
xgap: 3, ygap: 3,
hoverongaps: false,
hovertemplate: '%{y} · %{x}
F-stat %{z:.2f}',
showscale: false,
connectgaps: false,
}];
const layout = {
margin: { l: 56, r: 18, t: 8, b: 60 },
paper_bgcolor: 'rgba(0,0,0,0)',
plot_bgcolor: 'rgba(0,0,0,0)',
font: { family: 'Fraunces, Georgia, serif', color: '#5b454c', size: 12 },
xaxis: {
side: 'bottom', tickangle: -32, automargin: false,
tickfont: { family: 'JetBrains Mono, ui-monospace', size: 11, color: '#84686c' },
showgrid: false, zeroline: false, ticks: '',
},
yaxis: {
autorange: 'reversed', automargin: false,
tickfont: { family: 'JetBrains Mono, ui-monospace', size: 10, color: '#84686c' },
tickmode: 'array',
tickvals: layerLabels.filter((_, i) => i % 2 === 0),
showgrid: false, zeroline: false, ticks: '',
},
};
const config = { displayModeBar: false, responsive: true };
Plotly.react(ref.current, data, layout, config);
const node = ref.current;
const clickHandler = (ev) => {
const p = ev.points && ev.points[0];
if (!p) return;
const li = layerLabels.indexOf(p.y);
const ci = componentLabels.indexOf(p.x);
onCellClick && onCellClick({ layer: li, component: AtlasData.COMPONENTS[ci].id });
};
const hoverHandler = (ev) => {
const p = ev.points && ev.points[0]; if (!p) return;
setHover({ layer: layerLabels.indexOf(p.y), component: AtlasData.COMPONENTS[componentLabels.indexOf(p.x)].id, v: p.z });
};
const unhoverHandler = () => setHover(null);
node.on('plotly_click', clickHandler);
node.on('plotly_hover', hoverHandler);
node.on('plotly_unhover', unhoverHandler);
return () => {
try { node.removeAllListeners && node.removeAllListeners('plotly_click'); } catch {}
};
}, [z, palette]);
// Selected-cell outline overlay
const cellOverlay = useMemo(() => {
if (!selectedCell) return null;
const ci = AtlasData.COMPONENTS.findIndex(c => c.id === selectedCell.component);
if (ci < 0) return null;
const cols = AtlasData.COMPONENTS.length;
const rows = AtlasData.N_LAYERS;
// approximate cell position based on plot area
return { ci, li: selectedCell.layer, cols, rows };
}, [selectedCell]);
return (
{cellOverlay && (
)}
{hover ? (
<>
L{hover.layer} · {hover.component}
·
F-stat {Number.isFinite(hover.v) ? hover.v.toFixed(2) : '—'}
{behaviorLabel && (<>·{behaviorLabel} overlay>)}
>
) : (
Hover a cell — click to drill into a layer · component
)}
);
}
window.Heatmap = Heatmap;