@@ -1332,39 +1332,36 @@ async function renderClassHeatmapChart() {
13321332 if (! classHeatmapChartEl .value || ! classHeatmapData .value || classHeatmapData .value .matrix .length === 0 ) return
13331333 const d = classHeatmapData .value
13341334
1335- // Row-center: subtract per-feature mean so we show how each class deviates
1336- // from that feature's overall average. This makes the visualization meaningful
1337- // for all transforms (raw, log, zscore) — it reveals which class is higher/lower
1338- // for each feature, regardless of absolute scale.
1339- const rowCentered = d .matrix .map (row => {
1335+ // Row-normalize: center on per-feature mean, then scale by per-row max-abs so
1336+ // each row uses the full colorscale independently. Without per-row scaling,
1337+ // a single high-variance feature (e.g. on the log scale where most features
1338+ // are clipped near log(epsilon)) can swamp the global range and flatten all
1339+ // other rows to white. Per-row scaling guarantees every row is visible.
1340+ const rowNormalized = d .matrix .map (row => {
13401341 const valid = row .filter (v => Number .isFinite (v))
1341- if (valid .length === 0 ) return row
1342+ if (valid .length === 0 ) return row . map (() => 0 )
13421343 const mean = valid .reduce ((a , b ) => a + b, 0 ) / valid .length
1343- return row .map (v => Number .isFinite (v) ? v - mean : 0 )
1344+ const centered = row .map (v => Number .isFinite (v) ? v - mean : 0 )
1345+ let rowMax = 0
1346+ for (const v of centered) if (Math .abs (v) > rowMax) rowMax = Math .abs (v)
1347+ if (rowMax === 0 ) return centered
1348+ return centered .map (v => v / rowMax)
13441349 })
1345-
1346- // Symmetric scale around 0
1347- let maxAbs = 0
1348- for (const row of rowCentered) {
1349- for (const v of row) {
1350- if (Number .isFinite (v) && Math .abs (v) > maxAbs) maxAbs = Math .abs (v)
1351- }
1352- }
1353- if (maxAbs === 0 ) maxAbs = 1
1350+ const maxAbs = 1
13541351
13551352 // Divergent colorscale: blue (below mean) → white (mean) → red (above mean)
13561353 const colorscale = [
13571354 [0 , ' #0000b8' ], [0.25 , ' #4472ff' ], [0.5 , ' #f8f8f8' ],
13581355 [0.75 , ' #ff6060' ], [1 , ' #b80000' ],
13591356 ]
13601357
1361- // Hover: show original value + deviation
1358+ // Hover: show original value (in transform units) alongside the row-normalized display value
13621359 const customdata = d .matrix .map ((row , i ) =>
1363- row .map ((v , j ) => [v, rowCentered [i][j]])
1360+ row .map ((v , j ) => [v, rowNormalized [i][j]])
13641361 )
13651362
13661363 const trace = {
1367- z: rowCentered ,
1364+ z: rowNormalized ,
13681365 x: d .class_labels .map (cls => ` Class ${ cls} ` ),
13691366 y: d .feature_names .map (featureLabel),
13701367 type: ' heatmap' ,
@@ -1373,12 +1370,12 @@ async function renderClassHeatmapChart() {
13731370 zmax: maxAbs,
13741371 zmid: 0 ,
13751372 customdata,
1376- hovertemplate: ' Feature: %{y}<br>Class: %{x}<br>Value (' + d .transform + ' ): %{customdata[0]:.4f }<br>Δ from row mean : %{customdata[1]:.4f }<extra></extra>' ,
1377- colorbar: { title: { text: ' Δ ' + d . transform }, thickness: 12 },
1373+ hovertemplate: ' Feature: %{y}<br>Class: %{x}<br>Value (' + d .transform + ' ): %{customdata[0]:.4g }<br>Row-normalized : %{customdata[1]:.2f }<extra></extra>' ,
1374+ colorbar: { title: { text: ' Row-norm ' }, thickness: 12 , tickvals : [ - 1 , 0 , 1 ], ticktext : [ ' low ' , ' mean ' , ' high ' ] },
13781375 }
13791376
13801377 const layout = chartLayout ({
1381- title: ` Class mean heatmap (${ d .transform } , row-centered )` ,
1378+ title: ` Class mean heatmap (${ d .transform } , row-normalized )` ,
13821379 xaxis: { title: ' Class' , side: ' bottom' },
13831380 yaxis: { title: ' Feature' , autorange: ' reversed' , tickfont: { size: 10 } },
13841381 margin: { l: 220 , r: 40 , t: 50 , b: 60 },
0 commit comments