hbauzan Cursor commited on
Commit
af73265
·
1 Parent(s): 3278890

feat(viz): group contrast paint, high-range zero coverage, HUD dim

Browse files

Add Shared noise / Sign conflict controls for Compare groups, Zero coverage
On/Off with 30–99.9999% range, and DIM readout in hover telemetry.

Co-authored-by: Cursor <cursoragent@cursor.com>

.agents/skills/dev-protocol/lessons-learned.md CHANGED
@@ -255,7 +255,10 @@ Portable findings from VHectorLab 3D `v2.1.0` — apply if the older app shares
255
  | Expectation vs math | RAW G1↔G2 centroid cosine ~0.55 (not antipodal); SAE can improve (~0.22) but paint still hides it | Fix paint/order first; wider SAE train is L3, not required for readability |
256
  | Layout axes (ANALYSIS) | X=dim, Y=thread stack + val×amp, Z=0 | Soft Y gap between `GROUP_*` blocks (+1× spacing) aids domain reading |
257
 
258
- **Modules shipped (reference):** `saeFilterBridge.js`, `groupStackLayout.js`, `dimContrastSort.js`; wiring in Compare panel + Instancer layout. Diagnosis: `scripts/diagnose_group_separation.py`. Roadmap: `roadmap/compare-group-contrast-viz.md`.
 
 
 
259
 
260
  ### 4.12. Bottom HUD hover activation (POINTS / RIBBONS)
261
  - **Problema**: `ACTIVATION` siempre `0.0000` — `Interaction` pasaba `userData` del mesh (`{ pointsData }`), y el HUD leía `userData.val` / `data.activation` (inexistentes).
 
255
  | Expectation vs math | RAW G1↔G2 centroid cosine ~0.55 (not antipodal); SAE can improve (~0.22) but paint still hides it | Fix paint/order first; wider SAE train is L3, not required for readability |
256
  | Layout axes (ANALYSIS) | X=dim, Y=thread stack + val×amp, Z=0 | Soft Y gap between `GROUP_*` blocks (+1× spacing) aids domain reading |
257
 
258
+ ### 4.11b. Group contrast paint (shared noise / sign conflict)
259
+ - **Problema**: En Compare con ≥2 `GROUP_*`, dims del mismo signo en ambos grupos son ruido compartido; dims de signo opuesto son señal — sin controles de paint dedicados.
260
+ - **Solución**: Deep module `groupDimContrast.js` — means raw G1↔G2; `sim = 1−|Δ|/(|a|+|b|)`; cancel ZC-style sobre metric alta → negro; opposite → highlight color × strength×diff + conflict coverage independiente. Solo paint (Y intacto). UI gated (`setGroupContrastControlsEnabled`) hasta ≥2 grupos.
261
+ - **Invariante**: Zero coverage global y Group contrast son independientes; On habilita sliders (Off = grisado); shader POINTS usa `aCancel`/`aHighlight` + `uColorHighlight`.
262
 
263
  ### 4.12. Bottom HUD hover activation (POINTS / RIBBONS)
264
  - **Problema**: `ACTIVATION` siempre `0.0000` — `Interaction` pasaba `userData` del mesh (`{ pointsData }`), y el HUD leía `userData.val` / `data.activation` (inexistentes).
CHANGELOG.md CHANGED
@@ -5,6 +5,7 @@ All notable changes to VHectorLab 3D will be documented in this file.
5
  ## [Unreleased]
6
 
7
  ### Added
 
8
  - **Public lab release hygiene**: MIT `LICENSE` (© 2026 Hector Bauzan); root README frames VHectorLab as a study/laboratory tool (not SaaS), links the live HF Space demo, and documents shared cpu-basic limits; `backend/README.md` aligned in English.
9
 
10
  ### Fixed
 
5
  ## [Unreleased]
6
 
7
  ### Added
8
+ - **Group contrast (Visualization panel)**: Compare ≥2 groups — Shared noise (same-sign → black by similarity %, ZC-style) + Sign conflict (opposite-sign highlight color × |Δ|, plus conflict coverage to black). Paint-only; gated until groups exist. Panel densified (~300px).
9
  - **Public lab release hygiene**: MIT `LICENSE` (© 2026 Hector Bauzan); root README frames VHectorLab as a study/laboratory tool (not SaaS), links the live HF Space demo, and documents shared cpu-basic limits; `backend/README.md` aligned in English.
10
 
11
  ### Fixed
CONTEXT.md CHANGED
@@ -47,7 +47,7 @@ Compact tap “i” control next to an editable field; shows a short English pop
47
  Quad-strip mesh with real lateral width following a thread centerline, colored by activation; used by `RENDER: RIBBONS`. Distinct from 1px WebGL lines.
48
 
49
  ### Visualization Controls
50
- Right-dock glass panel for global sign filter and divergent color anchors (below 3D Spatial Controls).
51
 
52
  ### Sign Filter
53
  Global show mode `all | positive | negative` over **normalized** activations (post z-score/tanh); near-zero `|t| < 0.01` is treated as neutral and hidden by +/− only.
@@ -58,6 +58,12 @@ User-editable hex for normalized activations at +1, 0, and −1; replaces the fo
58
  ### Zero Coverage
59
  Percent of the |t| range held at the zero color (default black) before blending toward ±1 anchors; capped at 90%.
60
 
 
 
 
 
 
 
61
  ### Top‑K SAE
62
  Trained sparse autoencoder with exactly K active latents per input (ReLU + Top‑K, no L1 shrinkage). Trained on the **current workspace scope** (Compare/Arithmetic batch); ephemeral in-RAM session model. Default caps 768 → 8192 with K=32 (auto-scaled for small N).
63
 
 
47
  Quad-strip mesh with real lateral width following a thread centerline, colored by activation; used by `RENDER: RIBBONS`. Distinct from 1px WebGL lines.
48
 
49
  ### Visualization Controls
50
+ Bottom-HUD glass panel for global sign filter, divergent color anchors, Zero Coverage, and Group contrast (Compare ≥2 groups).
51
 
52
  ### Sign Filter
53
  Global show mode `all | positive | negative` over **normalized** activations (post z-score/tanh); near-zero `|t| < 0.01` is treated as neutral and hidden by +/− only.
 
58
  ### Zero Coverage
59
  Percent of the |t| range held at the zero color (default black) before blending toward ±1 anchors; capped at 90%.
60
 
61
+ ### Group Contrast
62
+ Visualization paint for Compare with ≥2 groups: **Shared noise** blackens same-sign dims by similarity; **Sign conflict** highlights opposite-sign dims (custom color × |Δ|) and can blacken them by difference. Uses group means on raw embeddings; geometry Y unchanged.
63
+
64
+ ### Shared Noise Similarity
65
+ `1 − |mean_G1 − mean_G2| / (|mean_G1| + |mean_G2|)` per dimension — high values mean shared-sign “noise” between groups.
66
+
67
  ### Top‑K SAE
68
  Trained sparse autoencoder with exactly K active latents per input (ReLU + Top‑K, no L1 shrinkage). Trained on the **current workspace scope** (Compare/Arithmetic batch); ephemeral in-RAM session model. Default caps 768 → 8192 with K=32 (auto-scaled for small N).
69
 
src/main.js CHANGED
@@ -32,11 +32,13 @@ import {
32
  vizPanelLayoutForViewport,
33
  resolveVisualizationMountParent,
34
  setVisualizationPanelLayout,
 
35
  } from './ui/VisualizationControls.js';
36
  import {
37
  loadVisualizationSettings,
38
  saveVisualizationSettings,
39
  } from './ui/visualizationControlsDefaults.js';
 
40
  import {
41
  snapshotFilterForSae,
42
  filterModeForSaeOn,
@@ -249,6 +251,7 @@ class VHectorLabApp {
249
  this.refreshRender();
250
  });
251
  this.threadLabels.setVisible(this.vizConfig.labelsVisible);
 
252
 
253
  if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') {
254
  this._vizLayoutMq = window.matchMedia(MOBILE_MQ);
@@ -385,6 +388,18 @@ class VHectorLabApp {
385
  this.threadLabels.setLabels(labels);
386
  }
387
  }
 
 
 
 
 
 
 
 
 
 
 
 
388
  }
389
 
390
  /**
 
32
  vizPanelLayoutForViewport,
33
  resolveVisualizationMountParent,
34
  setVisualizationPanelLayout,
35
+ setGroupContrastControlsEnabled,
36
  } from './ui/VisualizationControls.js';
37
  import {
38
  loadVisualizationSettings,
39
  saveVisualizationSettings,
40
  } from './ui/visualizationControlsDefaults.js';
41
+ import { hasGroupsForDimContrast } from './visualizer/groupDimContrast.js';
42
  import {
43
  snapshotFilterForSae,
44
  filterModeForSaeOn,
 
251
  this.refreshRender();
252
  });
253
  this.threadLabels.setVisible(this.vizConfig.labelsVisible);
254
+ this.syncGroupContrastGate();
255
 
256
  if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') {
257
  this._vizLayoutMq = window.matchMedia(MOBILE_MQ);
 
388
  this.threadLabels.setLabels(labels);
389
  }
390
  }
391
+ this.syncGroupContrastGate();
392
+ }
393
+
394
+ /**
395
+ * Enable Group contrast viz controls only for compare ≥2 groups.
396
+ */
397
+ syncGroupContrastGate() {
398
+ if (!this.vizEl || !this.vizConfig) return;
399
+ const items = state.workspaceMode === 'COMPARE'
400
+ ? (this.rawCompareData?.items || state.compareData?.items || [])
401
+ : [];
402
+ setGroupContrastControlsEnabled(this.vizEl, hasGroupsForDimContrast(items), this.vizConfig);
403
  }
404
 
405
  /**
src/style.css CHANGED
@@ -985,6 +985,11 @@ canvas {
985
  }
986
 
987
  .hud-val { font-family: monospace; font-weight: 600; color: var(--text-main); }
 
 
 
 
 
988
  .hud-badge {
989
  background: rgba(0, 255, 170, 0.15);
990
  color: var(--accent-cyan);
@@ -1229,9 +1234,9 @@ canvas {
1229
  Collapse only hides the body — the short left dock-tab stays parked on the HUD.
1230
  */
1231
  #visualization-controls-container.viz-panel {
1232
- --viz-width: 260px;
1233
- --viz-tab-size: 28px;
1234
- --viz-tab-h: 72px;
1235
  position: absolute;
1236
  right: var(--hud-pad);
1237
  bottom: calc(var(--hud-bottom) + var(--hud-height) - 1px);
@@ -1276,11 +1281,11 @@ canvas {
1276
  #visualization-controls-container .viz-panel-body {
1277
  display: flex;
1278
  flex-direction: column;
1279
- gap: 8px;
1280
- padding: 10px 12px 12px;
1281
  width: var(--viz-width);
1282
  max-width: var(--viz-width);
1283
- max-height: min(48vh, 420px);
1284
  overflow-x: hidden;
1285
  overflow-y: auto;
1286
  box-sizing: border-box;
@@ -1333,49 +1338,111 @@ canvas {
1333
 
1334
  /* Compact Visualization internals */
1335
  #visualization-controls-container .sliders-title {
1336
- font-size: 0.85rem;
1337
- padding-bottom: 6px;
1338
  margin: 0;
1339
  }
1340
 
1341
  #visualization-controls-container .viz-filter-group {
1342
- gap: 6px;
1343
  }
1344
 
1345
  #visualization-controls-container .viz-filter-label {
1346
- font-size: 0.72rem;
1347
  }
1348
 
1349
  #visualization-controls-container .viz-colors-section {
1350
- gap: 6px;
1351
  }
1352
 
1353
  #visualization-controls-container .viz-colors-title {
1354
- font-size: 0.72rem;
1355
  }
1356
 
1357
  #visualization-controls-container .viz-color-row {
1358
- gap: 6px;
1359
  }
1360
 
1361
  #visualization-controls-container .viz-color-row label {
1362
- font-size: 0.72rem;
1363
- min-width: 2.4rem;
1364
  }
1365
 
1366
  #visualization-controls-container .viz-color-hex {
1367
- font-size: 0.72rem;
1368
- padding: 4px 6px;
1369
  }
1370
 
1371
  #visualization-controls-container .viz-coverage-row {
1372
- gap: 4px;
1373
  }
1374
 
1375
  #visualization-controls-container .viz-labels-toggle,
1376
  #visualization-controls-container .viz-reset-btn {
1377
- padding: 6px 10px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1378
  font-size: 0.72rem;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1379
  }
1380
 
1381
  #axis-gizmo-container {
@@ -2032,9 +2099,9 @@ canvas {
2032
  Collapsed tab stays on top of the HUD (not a side orphan).
2033
  */
2034
  #visualization-controls-container.viz-panel {
2035
- --viz-width: min(260px, calc(100vw - 64px));
2036
- --viz-tab-size: 28px;
2037
- --viz-tab-h: 56px;
2038
  position: fixed;
2039
  left: auto;
2040
  right: var(--hud-pad);
@@ -2056,9 +2123,9 @@ canvas {
2056
  #visualization-controls-container .viz-panel-body {
2057
  width: var(--viz-width);
2058
  max-width: var(--viz-width);
2059
- padding: 8px 10px 10px;
2060
- gap: 7px;
2061
- max-height: min(38dvh, 260px);
2062
  overflow-y: auto;
2063
  border-radius: 0 10px 0 0;
2064
  }
 
985
  }
986
 
987
  .hud-val { font-family: monospace; font-weight: 600; color: var(--text-main); }
988
+ #hud-dim {
989
+ flex-shrink: 0;
990
+ font-variant-numeric: tabular-nums;
991
+ min-width: 4.5ch;
992
+ }
993
  .hud-badge {
994
  background: rgba(0, 255, 170, 0.15);
995
  color: var(--accent-cyan);
 
1234
  Collapse only hides the body — the short left dock-tab stays parked on the HUD.
1235
  */
1236
  #visualization-controls-container.viz-panel {
1237
+ --viz-width: 300px;
1238
+ --viz-tab-size: 26px;
1239
+ --viz-tab-h: 64px;
1240
  position: absolute;
1241
  right: var(--hud-pad);
1242
  bottom: calc(var(--hud-bottom) + var(--hud-height) - 1px);
 
1281
  #visualization-controls-container .viz-panel-body {
1282
  display: flex;
1283
  flex-direction: column;
1284
+ gap: 6px;
1285
+ padding: 8px 10px 10px;
1286
  width: var(--viz-width);
1287
  max-width: var(--viz-width);
1288
+ max-height: min(56vh, 520px);
1289
  overflow-x: hidden;
1290
  overflow-y: auto;
1291
  box-sizing: border-box;
 
1338
 
1339
  /* Compact Visualization internals */
1340
  #visualization-controls-container .sliders-title {
1341
+ font-size: 0.8rem;
1342
+ padding-bottom: 4px;
1343
  margin: 0;
1344
  }
1345
 
1346
  #visualization-controls-container .viz-filter-group {
1347
+ gap: 4px;
1348
  }
1349
 
1350
  #visualization-controls-container .viz-filter-label {
1351
+ font-size: 0.68rem;
1352
  }
1353
 
1354
  #visualization-controls-container .viz-colors-section {
1355
+ gap: 4px;
1356
  }
1357
 
1358
  #visualization-controls-container .viz-colors-title {
1359
+ font-size: 0.68rem;
1360
  }
1361
 
1362
  #visualization-controls-container .viz-color-row {
1363
+ gap: 4px;
1364
  }
1365
 
1366
  #visualization-controls-container .viz-color-row label {
1367
+ font-size: 0.68rem;
1368
+ min-width: 2.1rem;
1369
  }
1370
 
1371
  #visualization-controls-container .viz-color-hex {
1372
+ font-size: 0.68rem;
1373
+ padding: 3px 5px;
1374
  }
1375
 
1376
  #visualization-controls-container .viz-coverage-row {
1377
+ gap: 2px;
1378
  }
1379
 
1380
  #visualization-controls-container .viz-labels-toggle,
1381
  #visualization-controls-container .viz-reset-btn {
1382
+ padding: 4px 8px;
1383
+ font-size: 0.68rem;
1384
+ }
1385
+
1386
+ #visualization-controls-container .viz-group-contrast {
1387
+ display: flex;
1388
+ flex-direction: column;
1389
+ gap: 5px;
1390
+ padding-top: 4px;
1391
+ border-top: 1px solid var(--panel-border);
1392
+ }
1393
+
1394
+ #visualization-controls-container .viz-group-contrast-title {
1395
+ display: flex;
1396
+ align-items: center;
1397
+ gap: 4px;
1398
  font-size: 0.72rem;
1399
+ font-weight: 700;
1400
+ color: var(--accent-cyan);
1401
+ }
1402
+
1403
+ #visualization-controls-container .viz-group-contrast-hint {
1404
+ margin: 0;
1405
+ font-size: 0.62rem;
1406
+ color: var(--text-muted);
1407
+ line-height: 1.25;
1408
+ }
1409
+
1410
+ #visualization-controls-container .viz-group-fx-block {
1411
+ display: flex;
1412
+ flex-direction: column;
1413
+ gap: 3px;
1414
+ }
1415
+
1416
+ #visualization-controls-container .viz-toggle-row {
1417
+ display: flex;
1418
+ align-items: center;
1419
+ gap: 6px;
1420
+ font-size: 0.68rem;
1421
+ font-weight: 600;
1422
+ color: var(--text-muted);
1423
+ cursor: pointer;
1424
+ }
1425
+
1426
+ #visualization-controls-container .viz-toggle-row input[type="checkbox"] {
1427
+ accent-color: var(--accent-cyan);
1428
+ width: 14px;
1429
+ height: 14px;
1430
+ margin: 0;
1431
+ flex: 0 0 auto;
1432
+ }
1433
+
1434
+ #visualization-controls-container .viz-fx-slider.is-inert,
1435
+ #visualization-controls-container .viz-group-contrast.is-disabled {
1436
+ opacity: 0.45;
1437
+ }
1438
+
1439
+ #visualization-controls-container .viz-group-contrast.is-disabled .viz-group-contrast-hint {
1440
+ opacity: 1;
1441
+ }
1442
+
1443
+ #visualization-controls-container .viz-fx-slider.is-inert input,
1444
+ #visualization-controls-container .viz-group-contrast.is-disabled input {
1445
+ cursor: not-allowed;
1446
  }
1447
 
1448
  #axis-gizmo-container {
 
2099
  Collapsed tab stays on top of the HUD (not a side orphan).
2100
  */
2101
  #visualization-controls-container.viz-panel {
2102
+ --viz-width: min(300px, calc(100vw - 56px));
2103
+ --viz-tab-size: 26px;
2104
+ --viz-tab-h: 52px;
2105
  position: fixed;
2106
  left: auto;
2107
  right: var(--hud-pad);
 
2123
  #visualization-controls-container .viz-panel-body {
2124
  width: var(--viz-width);
2125
  max-width: var(--viz-width);
2126
+ padding: 7px 8px 8px;
2127
+ gap: 5px;
2128
+ max-height: min(42dvh, 340px);
2129
  overflow-y: auto;
2130
  border-radius: 0 10px 0 0;
2131
  }
src/ui/HUD.js CHANGED
@@ -29,6 +29,7 @@ export class HUD {
29
  <div class="hud-center">
30
  <span class="hud-label">HOVER TELEMETRY:</span>
31
  <span id="hud-segment" class="hud-badge">RESULT VECTOR</span>
 
32
  <span id="hud-activation" class="hud-highlight">ACTIVATION: --</span>
33
  </div>
34
  <div class="hud-right">
@@ -41,11 +42,13 @@ export class HUD {
41
 
42
  this.coordsEl = this.element.querySelector('#hud-coords');
43
  this.segmentEl = this.element.querySelector('#hud-segment');
 
44
  this.activationEl = this.element.querySelector('#hud-activation');
45
  this.tokenEl = this.element.querySelector('#hud-token');
46
  this.centerEl = this.element.querySelector('.hud-center');
47
 
48
  this._lastActivation = null;
 
49
  this._resizeObserver = null;
50
 
51
  if (typeof ResizeObserver !== 'undefined' && this.centerEl) {
@@ -53,6 +56,7 @@ export class HUD {
53
  if (this._lastActivation != null) {
54
  this._renderActivation(this._lastActivation);
55
  }
 
56
  });
57
  this._resizeObserver.observe(this.centerEl);
58
  }
@@ -108,8 +112,10 @@ export class HUD {
108
  const t = resolveHoverTelemetry(data);
109
  if (!t) {
110
  this._lastActivation = null;
 
111
  this.coordsEl.textContent = 'X: -- | Y: -- | Z: --';
112
  this.segmentEl.textContent = 'NEUTRAL SPACE';
 
113
  this.activationEl.textContent = 'ACTIVATION: --';
114
  this.activationEl.style.color = '#888888';
115
  this.activationEl.removeAttribute('title');
@@ -121,12 +127,30 @@ export class HUD {
121
  this.coordsEl.textContent = `X: ${Math.round(pos.x)} | Y: ${Math.round(pos.y)} | Z: ${Math.round(pos.z)}`;
122
 
123
  this._lastActivation = t.activation;
 
 
124
  this._renderActivation(t.activation);
125
 
126
  this.segmentEl.textContent = t.type;
127
  this.tokenEl.textContent = t.token;
128
  }
129
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
130
  /**
131
  * @param {number} val
132
  * @private
@@ -149,11 +173,12 @@ export class HUD {
149
  const centerW = this.centerEl.clientWidth || 160;
150
  const labelW = this.centerEl.querySelector('.hud-label')?.offsetWidth || 0;
151
  const badgeW = this.segmentEl?.offsetWidth || 0;
152
- const gap = 16;
153
- const available = centerW - labelW - badgeW - gap;
 
154
  // Mobile wrap: if center is tight, allow activation to use most of the bar
155
  if (available < 64 && this.element?.clientWidth) {
156
- return Math.max(72, Math.floor(this.element.clientWidth * 0.45));
157
  }
158
  return Math.max(64, available);
159
  }
 
29
  <div class="hud-center">
30
  <span class="hud-label">HOVER TELEMETRY:</span>
31
  <span id="hud-segment" class="hud-badge">RESULT VECTOR</span>
32
+ <span id="hud-dim" class="hud-val">DIM: --</span>
33
  <span id="hud-activation" class="hud-highlight">ACTIVATION: --</span>
34
  </div>
35
  <div class="hud-right">
 
42
 
43
  this.coordsEl = this.element.querySelector('#hud-coords');
44
  this.segmentEl = this.element.querySelector('#hud-segment');
45
+ this.dimEl = this.element.querySelector('#hud-dim');
46
  this.activationEl = this.element.querySelector('#hud-activation');
47
  this.tokenEl = this.element.querySelector('#hud-token');
48
  this.centerEl = this.element.querySelector('.hud-center');
49
 
50
  this._lastActivation = null;
51
+ this._lastDim = null;
52
  this._resizeObserver = null;
53
 
54
  if (typeof ResizeObserver !== 'undefined' && this.centerEl) {
 
56
  if (this._lastActivation != null) {
57
  this._renderActivation(this._lastActivation);
58
  }
59
+ this._renderDim(this._lastDim);
60
  });
61
  this._resizeObserver.observe(this.centerEl);
62
  }
 
112
  const t = resolveHoverTelemetry(data);
113
  if (!t) {
114
  this._lastActivation = null;
115
+ this._lastDim = null;
116
  this.coordsEl.textContent = 'X: -- | Y: -- | Z: --';
117
  this.segmentEl.textContent = 'NEUTRAL SPACE';
118
+ this._renderDim(null);
119
  this.activationEl.textContent = 'ACTIVATION: --';
120
  this.activationEl.style.color = '#888888';
121
  this.activationEl.removeAttribute('title');
 
127
  this.coordsEl.textContent = `X: ${Math.round(pos.x)} | Y: ${Math.round(pos.y)} | Z: ${Math.round(pos.z)}`;
128
 
129
  this._lastActivation = t.activation;
130
+ this._lastDim = t.dim;
131
+ this._renderDim(t.dim);
132
  this._renderActivation(t.activation);
133
 
134
  this.segmentEl.textContent = t.type;
135
  this.tokenEl.textContent = t.token;
136
  }
137
 
138
+ /**
139
+ * @param {number|null|undefined} dim
140
+ * @private
141
+ */
142
+ _renderDim(dim) {
143
+ if (!this.dimEl) return;
144
+ if (dim == null || !Number.isFinite(dim)) {
145
+ this.dimEl.textContent = 'DIM: --';
146
+ this.dimEl.removeAttribute?.('title');
147
+ return;
148
+ }
149
+ const n = Math.trunc(dim);
150
+ this.dimEl.textContent = `DIM: ${n}`;
151
+ this.dimEl.title = `dim: ${n}`;
152
+ }
153
+
154
  /**
155
  * @param {number} val
156
  * @private
 
173
  const centerW = this.centerEl.clientWidth || 160;
174
  const labelW = this.centerEl.querySelector('.hud-label')?.offsetWidth || 0;
175
  const badgeW = this.segmentEl?.offsetWidth || 0;
176
+ const dimW = this.dimEl?.offsetWidth || 0;
177
+ const gap = 20;
178
+ const available = centerW - labelW - badgeW - dimW - gap;
179
  // Mobile wrap: if center is tight, allow activation to use most of the bar
180
  if (available < 64 && this.element?.clientWidth) {
181
+ return Math.max(72, Math.floor(this.element.clientWidth * 0.4));
182
  }
183
  return Math.max(64, available);
184
  }
src/ui/VisualizationControls.js CHANGED
@@ -8,13 +8,20 @@ import {
8
  DEFAULT_VISUALIZATION_SETTINGS,
9
  normalizeHex,
10
  normalizeFilterMode,
11
- normalizeZeroCoverage,
 
12
  resolveVisualizationSettings,
13
  saveVisualizationSettings,
14
  resetVisualizationSettings,
15
  VIZ_STORAGE_PREFIX,
16
- ZERO_COVERAGE_MIN,
17
- ZERO_COVERAGE_MAX,
 
 
 
 
 
 
18
  } from './visualizationControlsDefaults.js';
19
  import {
20
  readCollapsedPreference,
@@ -150,12 +157,65 @@ export function visualizationControlsMarkup(config = DEFAULT_VISUALIZATION_SETTI
150
  </div>
151
  </div>
152
 
153
- <div class="viz-coverage-row slider-group">
154
- <div class="slider-header">
155
- <label for="viz-zero-coverage-slider"><span class="field-label-text">Zero coverage:</span>${infoTipMarkup(FIELD_INFO.zeroCoverage)}</label>
156
- <span id="viz-zero-coverage-val" class="slider-val">${s.zeroCoverage}%</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
157
  </div>
158
- <input type="range" id="viz-zero-coverage-slider" min="${ZERO_COVERAGE_MIN}" max="${ZERO_COVERAGE_MAX}" step="1" value="${s.zeroCoverage}" title="How much of ± range stays at the zero color before blending to +1/−1">
159
  </div>
160
 
161
  <div class="viz-labels-row">
@@ -193,10 +253,36 @@ export function syncVisualizationControlsFromConfig(container, config) {
193
  if (swatch) swatch.value = hex;
194
  if (text) text.value = hex;
195
  }
 
 
196
  const covInput = container.querySelector('#viz-zero-coverage-slider');
197
  const covLabel = container.querySelector('#viz-zero-coverage-val');
198
- if (covInput) covInput.value = String(s.zeroCoverage);
199
- if (covLabel) covLabel.textContent = `${s.zeroCoverage}%`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
200
 
201
  const labelsBtn = container.querySelector('#viz-labels-toggle');
202
  if (labelsBtn) {
@@ -205,6 +291,66 @@ export function syncVisualizationControlsFromConfig(container, config) {
205
  }
206
  }
207
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
208
  /**
209
  * Apply collapsed visual state to the Visualization panel host.
210
  * @param {HTMLElement} container
@@ -308,16 +454,103 @@ export function wireVisualizationControls(container, config, onChangeCallback =
308
  bindColor('zero', 'colorZero');
309
  bindColor('negative', 'colorNegative');
310
 
 
 
 
 
 
 
 
 
311
  const covInput = container.querySelector('#viz-zero-coverage-slider');
312
  const covLabel = container.querySelector('#viz-zero-coverage-val');
313
  if (covInput) {
314
  covInput.addEventListener('input', () => {
315
- const next = normalizeZeroCoverage(covInput.value);
316
  config.zeroCoverage = next;
317
- if (covLabel) covLabel.textContent = `${next}%`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
318
  emit();
319
  });
320
  }
 
 
 
 
 
 
 
 
 
 
 
 
321
 
322
  const labelsBtn = container.querySelector('#viz-labels-toggle');
323
  if (labelsBtn) {
 
8
  DEFAULT_VISUALIZATION_SETTINGS,
9
  normalizeHex,
10
  normalizeFilterMode,
11
+ normalizeConflictCover,
12
+ normalizeHighlightStrength,
13
  resolveVisualizationSettings,
14
  saveVisualizationSettings,
15
  resetVisualizationSettings,
16
  VIZ_STORAGE_PREFIX,
17
+ CONFLICT_COVER_MIN,
18
+ CONFLICT_COVER_MAX,
19
+ HIGH_COVERAGE_SLIDER_MAX,
20
+ HIGHLIGHT_STRENGTH_MIN,
21
+ HIGHLIGHT_STRENGTH_MAX,
22
+ highCoverageFromSlider,
23
+ highCoverageToSlider,
24
+ formatHighCoverage,
25
  } from './visualizationControlsDefaults.js';
26
  import {
27
  readCollapsedPreference,
 
157
  </div>
158
  </div>
159
 
160
+ <div class="viz-group-fx-block viz-zero-coverage-block">
161
+ <label class="viz-toggle-row">
162
+ <input type="checkbox" id="viz-zero-coverage-enabled" ${s.zeroCoverageEnabled ? 'checked' : ''}>
163
+ <span class="field-label-text">Zero coverage</span>${infoTipMarkup(FIELD_INFO.zeroCoverage)}
164
+ </label>
165
+ <div class="viz-coverage-row slider-group viz-fx-slider" data-requires="zero-coverage">
166
+ <div class="slider-header">
167
+ <label for="viz-zero-coverage-slider"><span class="field-label-text">Coverage:</span>${infoTipMarkup(FIELD_INFO.zeroCoverageAmount)}</label>
168
+ <span id="viz-zero-coverage-val" class="slider-val">${formatHighCoverage(s.zeroCoverage)}</span>
169
+ </div>
170
+ <input type="range" id="viz-zero-coverage-slider" min="0" max="${HIGH_COVERAGE_SLIDER_MAX}" step="1" value="${highCoverageToSlider(s.zeroCoverage)}" ${s.zeroCoverageEnabled ? '' : 'disabled'} title="30% … 99.9999% held at zero color">
171
+ </div>
172
+ </div>
173
+
174
+ <div id="viz-group-contrast" class="viz-group-contrast is-disabled" aria-disabled="true">
175
+ <div class="viz-group-contrast-title">
176
+ <span class="field-label-text">Group contrast</span>${infoTipMarkup(FIELD_INFO.groupContrast)}
177
+ </div>
178
+ <p class="viz-group-contrast-hint">Requires ≥2 compare groups.</p>
179
+
180
+ <div class="viz-group-fx-block" data-fx="same-sign">
181
+ <label class="viz-toggle-row">
182
+ <input type="checkbox" id="viz-same-sign-enabled" ${s.sameSignCancelEnabled ? 'checked' : ''}>
183
+ <span class="field-label-text">Shared noise</span>${infoTipMarkup(FIELD_INFO.sameSignCancel)}
184
+ </label>
185
+ <div class="viz-coverage-row slider-group viz-fx-slider" data-requires="same-sign">
186
+ <div class="slider-header">
187
+ <label for="viz-same-sign-coverage"><span class="field-label-text">Similarity:</span>${infoTipMarkup(FIELD_INFO.sameSignCoverage)}</label>
188
+ <span id="viz-same-sign-coverage-val" class="slider-val">${formatHighCoverage(s.sameSignCancelCoverage)}</span>
189
+ </div>
190
+ <input type="range" id="viz-same-sign-coverage" min="0" max="${HIGH_COVERAGE_SLIDER_MAX}" step="1" value="${highCoverageToSlider(s.sameSignCancelCoverage)}" ${s.sameSignCancelEnabled ? '' : 'disabled'}>
191
+ </div>
192
+ </div>
193
+
194
+ <div class="viz-group-fx-block" data-fx="opposite">
195
+ <label class="viz-toggle-row">
196
+ <input type="checkbox" id="viz-opposite-enabled" ${s.oppositeHighlightEnabled ? 'checked' : ''}>
197
+ <span class="field-label-text">Sign conflict</span>${infoTipMarkup(FIELD_INFO.oppositeHighlight)}
198
+ </label>
199
+ <div class="viz-color-row viz-fx-slider" data-requires="opposite">
200
+ <label for="viz-opposite-hex"><span class="field-label-text">Hi</span>${infoTipMarkup(FIELD_INFO.oppositeColor)}</label>
201
+ <input type="color" id="viz-opposite-swatch" class="viz-color-swatch" value="${s.oppositeHighlightColor}" ${s.oppositeHighlightEnabled ? '' : 'disabled'} title="Opposite-sign highlight" aria-label="Opposite highlight swatch">
202
+ <input type="text" id="viz-opposite-hex" class="viz-color-hex" value="${s.oppositeHighlightColor}" maxlength="7" spellcheck="false" ${s.oppositeHighlightEnabled ? '' : 'disabled'} placeholder="#00E5FF">
203
+ </div>
204
+ <div class="viz-coverage-row slider-group viz-fx-slider" data-requires="opposite">
205
+ <div class="slider-header">
206
+ <label for="viz-opposite-strength"><span class="field-label-text">Highlight:</span>${infoTipMarkup(FIELD_INFO.oppositeStrength)}</label>
207
+ <span id="viz-opposite-strength-val" class="slider-val">${s.oppositeHighlightStrength}%</span>
208
+ </div>
209
+ <input type="range" id="viz-opposite-strength" min="${HIGHLIGHT_STRENGTH_MIN}" max="${HIGHLIGHT_STRENGTH_MAX}" step="1" value="${s.oppositeHighlightStrength}" ${s.oppositeHighlightEnabled ? '' : 'disabled'}>
210
+ </div>
211
+ <div class="viz-coverage-row slider-group viz-fx-slider" data-requires="opposite">
212
+ <div class="slider-header">
213
+ <label for="viz-opposite-coverage"><span class="field-label-text">Conflict cover:</span>${infoTipMarkup(FIELD_INFO.oppositeCancel)}</label>
214
+ <span id="viz-opposite-coverage-val" class="slider-val">${s.oppositeCancelCoverage}%</span>
215
+ </div>
216
+ <input type="range" id="viz-opposite-coverage" min="${CONFLICT_COVER_MIN}" max="${CONFLICT_COVER_MAX}" step="1" value="${s.oppositeCancelCoverage}" ${s.oppositeHighlightEnabled ? '' : 'disabled'}>
217
+ </div>
218
  </div>
 
219
  </div>
220
 
221
  <div class="viz-labels-row">
 
253
  if (swatch) swatch.value = hex;
254
  if (text) text.value = hex;
255
  }
256
+ const zeroOn = container.querySelector('#viz-zero-coverage-enabled');
257
+ if (zeroOn) zeroOn.checked = s.zeroCoverageEnabled;
258
  const covInput = container.querySelector('#viz-zero-coverage-slider');
259
  const covLabel = container.querySelector('#viz-zero-coverage-val');
260
+ if (covInput) covInput.value = String(highCoverageToSlider(s.zeroCoverage));
261
+ if (covLabel) covLabel.textContent = formatHighCoverage(s.zeroCoverage);
262
+
263
+ const sameOn = container.querySelector('#viz-same-sign-enabled');
264
+ if (sameOn) sameOn.checked = s.sameSignCancelEnabled;
265
+ const sameCov = container.querySelector('#viz-same-sign-coverage');
266
+ const sameCovVal = container.querySelector('#viz-same-sign-coverage-val');
267
+ if (sameCov) sameCov.value = String(highCoverageToSlider(s.sameSignCancelCoverage));
268
+ if (sameCovVal) sameCovVal.textContent = formatHighCoverage(s.sameSignCancelCoverage);
269
+
270
+ const oppOn = container.querySelector('#viz-opposite-enabled');
271
+ if (oppOn) oppOn.checked = s.oppositeHighlightEnabled;
272
+ const oppSwatch = container.querySelector('#viz-opposite-swatch');
273
+ const oppHex = container.querySelector('#viz-opposite-hex');
274
+ if (oppSwatch) oppSwatch.value = s.oppositeHighlightColor;
275
+ if (oppHex) oppHex.value = s.oppositeHighlightColor;
276
+ const oppStr = container.querySelector('#viz-opposite-strength');
277
+ const oppStrVal = container.querySelector('#viz-opposite-strength-val');
278
+ if (oppStr) oppStr.value = String(s.oppositeHighlightStrength);
279
+ if (oppStrVal) oppStrVal.textContent = `${s.oppositeHighlightStrength}%`;
280
+ const oppCov = container.querySelector('#viz-opposite-coverage');
281
+ const oppCovVal = container.querySelector('#viz-opposite-coverage-val');
282
+ if (oppCov) oppCov.value = String(s.oppositeCancelCoverage);
283
+ if (oppCovVal) oppCovVal.textContent = `${s.oppositeCancelCoverage}%`;
284
+
285
+ syncGroupFxSliderEnabled(container, s);
286
 
287
  const labelsBtn = container.querySelector('#viz-labels-toggle');
288
  if (labelsBtn) {
 
291
  }
292
  }
293
 
294
+ /**
295
+ * Enable/disable dependent sliders when parent toggles change (and when groups gate is off).
296
+ * @param {HTMLElement} container
297
+ * @param {import('./visualizationControlsDefaults.js').VisualizationSettings} settings
298
+ */
299
+ export function syncGroupFxSliderEnabled(container, settings) {
300
+ if (!container) return;
301
+ const section = container.querySelector('#viz-group-contrast');
302
+ const groupsOk = section ? !section.classList.contains('is-disabled') : false;
303
+ const s = resolveVisualizationSettings(settings);
304
+
305
+ const setDisabled = (el, disabled) => {
306
+ if (!el) return;
307
+ el.disabled = disabled;
308
+ };
309
+
310
+ setDisabled(container.querySelector('#viz-zero-coverage-enabled'), false);
311
+ const zeroSlidersOn = s.zeroCoverageEnabled;
312
+ setDisabled(container.querySelector('#viz-zero-coverage-slider'), !zeroSlidersOn);
313
+ for (const row of container.querySelectorAll('.viz-fx-slider[data-requires="zero-coverage"]')) {
314
+ row.classList.toggle('is-inert', !zeroSlidersOn);
315
+ }
316
+
317
+ setDisabled(container.querySelector('#viz-same-sign-enabled'), !groupsOk);
318
+ setDisabled(container.querySelector('#viz-opposite-enabled'), !groupsOk);
319
+
320
+ const sameSlidersOn = groupsOk && s.sameSignCancelEnabled;
321
+ setDisabled(container.querySelector('#viz-same-sign-coverage'), !sameSlidersOn);
322
+
323
+ const oppSlidersOn = groupsOk && s.oppositeHighlightEnabled;
324
+ setDisabled(container.querySelector('#viz-opposite-swatch'), !oppSlidersOn);
325
+ setDisabled(container.querySelector('#viz-opposite-hex'), !oppSlidersOn);
326
+ setDisabled(container.querySelector('#viz-opposite-strength'), !oppSlidersOn);
327
+ setDisabled(container.querySelector('#viz-opposite-coverage'), !oppSlidersOn);
328
+
329
+ for (const row of container.querySelectorAll('.viz-fx-slider[data-requires="same-sign"]')) {
330
+ row.classList.toggle('is-inert', !sameSlidersOn);
331
+ }
332
+ for (const row of container.querySelectorAll('.viz-fx-slider[data-requires="opposite"]')) {
333
+ row.classList.toggle('is-inert', !oppSlidersOn);
334
+ }
335
+ }
336
+
337
+ /**
338
+ * Gate Group contrast controls: usable only with ≥2 compare groups.
339
+ * @param {HTMLElement|null|undefined} container
340
+ * @param {boolean} enabled
341
+ * @param {import('./visualizationControlsDefaults.js').VisualizationSettings} [config]
342
+ */
343
+ export function setGroupContrastControlsEnabled(container, enabled, config = null) {
344
+ if (!container) return;
345
+ const section = container.querySelector('#viz-group-contrast');
346
+ if (!section) return;
347
+ const on = Boolean(enabled);
348
+ section.classList.toggle('is-disabled', !on);
349
+ section.setAttribute('aria-disabled', on ? 'false' : 'true');
350
+ const settings = config || resolveVisualizationSettings(null);
351
+ syncGroupFxSliderEnabled(container, settings);
352
+ }
353
+
354
  /**
355
  * Apply collapsed visual state to the Visualization panel host.
356
  * @param {HTMLElement} container
 
454
  bindColor('zero', 'colorZero');
455
  bindColor('negative', 'colorNegative');
456
 
457
+ const zeroOn = container.querySelector('#viz-zero-coverage-enabled');
458
+ if (zeroOn) {
459
+ zeroOn.addEventListener('change', () => {
460
+ config.zeroCoverageEnabled = Boolean(zeroOn.checked);
461
+ syncGroupFxSliderEnabled(container, config);
462
+ emit();
463
+ });
464
+ }
465
  const covInput = container.querySelector('#viz-zero-coverage-slider');
466
  const covLabel = container.querySelector('#viz-zero-coverage-val');
467
  if (covInput) {
468
  covInput.addEventListener('input', () => {
469
+ const next = highCoverageFromSlider(covInput.value);
470
  config.zeroCoverage = next;
471
+ if (covLabel) covLabel.textContent = formatHighCoverage(next);
472
+ emit();
473
+ });
474
+ }
475
+
476
+ const sameOn = container.querySelector('#viz-same-sign-enabled');
477
+ if (sameOn) {
478
+ sameOn.addEventListener('change', () => {
479
+ config.sameSignCancelEnabled = Boolean(sameOn.checked);
480
+ syncGroupFxSliderEnabled(container, config);
481
+ emit();
482
+ });
483
+ }
484
+ const sameCov = container.querySelector('#viz-same-sign-coverage');
485
+ const sameCovVal = container.querySelector('#viz-same-sign-coverage-val');
486
+ if (sameCov) {
487
+ sameCov.addEventListener('input', () => {
488
+ const next = highCoverageFromSlider(sameCov.value);
489
+ config.sameSignCancelCoverage = next;
490
+ if (sameCovVal) sameCovVal.textContent = formatHighCoverage(next);
491
+ emit();
492
+ });
493
+ }
494
+
495
+ const oppOn = container.querySelector('#viz-opposite-enabled');
496
+ if (oppOn) {
497
+ oppOn.addEventListener('change', () => {
498
+ config.oppositeHighlightEnabled = Boolean(oppOn.checked);
499
+ syncGroupFxSliderEnabled(container, config);
500
+ emit();
501
+ });
502
+ }
503
+ const oppSwatch = container.querySelector('#viz-opposite-swatch');
504
+ const oppHex = container.querySelector('#viz-opposite-hex');
505
+ if (oppSwatch && oppHex) {
506
+ oppSwatch.addEventListener('input', () => {
507
+ const hex = normalizeHex(oppSwatch.value);
508
+ if (!hex) return;
509
+ config.oppositeHighlightColor = hex;
510
+ oppHex.value = hex;
511
+ emit();
512
+ });
513
+ const commitOppHex = () => {
514
+ const hex = normalizeHex(oppHex.value);
515
+ if (!hex) {
516
+ oppHex.value = config.oppositeHighlightColor;
517
+ return;
518
+ }
519
+ config.oppositeHighlightColor = hex;
520
+ oppSwatch.value = hex;
521
+ oppHex.value = hex;
522
+ emit();
523
+ };
524
+ oppHex.addEventListener('change', commitOppHex);
525
+ oppHex.addEventListener('keydown', (e) => {
526
+ if (e.key === 'Enter') {
527
+ e.preventDefault();
528
+ commitOppHex();
529
+ }
530
+ });
531
+ }
532
+ const oppStr = container.querySelector('#viz-opposite-strength');
533
+ const oppStrVal = container.querySelector('#viz-opposite-strength-val');
534
+ if (oppStr) {
535
+ oppStr.addEventListener('input', () => {
536
+ const next = normalizeHighlightStrength(oppStr.value);
537
+ config.oppositeHighlightStrength = next;
538
+ if (oppStrVal) oppStrVal.textContent = `${next}%`;
539
  emit();
540
  });
541
  }
542
+ const oppCov = container.querySelector('#viz-opposite-coverage');
543
+ const oppCovVal = container.querySelector('#viz-opposite-coverage-val');
544
+ if (oppCov) {
545
+ oppCov.addEventListener('input', () => {
546
+ const next = normalizeConflictCover(oppCov.value);
547
+ config.oppositeCancelCoverage = next;
548
+ if (oppCovVal) oppCovVal.textContent = `${next}%`;
549
+ emit();
550
+ });
551
+ }
552
+
553
+ syncGroupFxSliderEnabled(container, config);
554
 
555
  const labelsBtn = container.querySelector('#viz-labels-toggle');
556
  if (labelsBtn) {
src/ui/fieldInfo.js CHANGED
@@ -31,8 +31,16 @@ export const FIELD_INFO = Object.freeze({
31
  colorPos: 'Color at +1.',
32
  colorZero: 'Color at 0.',
33
  colorNeg: 'Color at −1.',
34
- zeroCoverage: '% held at zero.',
 
35
  labelsToggle: 'Floating names.',
 
 
 
 
 
 
 
36
  });
37
 
38
  /**
 
31
  colorPos: 'Color at +1.',
32
  colorZero: 'Color at 0.',
33
  colorNeg: 'Color at −1.',
34
+ zeroCoverage: 'Hold range at zero.',
35
+ zeroCoverageAmount: '30%…99.9999% band.',
36
  labelsToggle: 'Floating names.',
37
+ groupContrast: 'Groups only (G1↔G2).',
38
+ sameSignCancel: 'Blacken same-sign dims.',
39
+ sameSignCoverage: '30%…99.9999% sim.',
40
+ oppositeHighlight: 'Mark opposite signs.',
41
+ oppositeColor: 'Conflict color.',
42
+ oppositeStrength: 'Highlight × |Δ|.',
43
+ oppositeCancel: 'Fade conflicts black.',
44
  });
45
 
46
  /**
src/ui/hoverTelemetry.js CHANGED
@@ -36,8 +36,11 @@ export function resolveHoverTelemetry(data) {
36
  const actIdx = resolveActivationIndex(data, ud);
37
  if (actIdx != null && actIdx >= 0 && actIdx < ud.activations.length) {
38
  activation = ud.activations[actIdx];
 
 
 
39
  meta = {
40
- dim: actIdx,
41
  token: ud.token,
42
  type: ud.type,
43
  val: activation,
 
36
  const actIdx = resolveActivationIndex(data, ud);
37
  if (actIdx != null && actIdx >= 0 && actIdx < ud.activations.length) {
38
  activation = ud.activations[actIdx];
39
+ const sourceDim = Array.isArray(ud.sourceDims) && ud.sourceDims[actIdx] != null
40
+ ? Number(ud.sourceDims[actIdx])
41
+ : actIdx;
42
  meta = {
43
+ dim: sourceDim,
44
  token: ud.token,
45
  type: ud.type,
46
  val: activation,
src/ui/visualizationControlsDefaults.js CHANGED
@@ -10,8 +10,15 @@
10
  * colorPositive: string,
11
  * colorZero: string,
12
  * colorNegative: string,
 
13
  * zeroCoverage: number,
14
  * labelsVisible: boolean,
 
 
 
 
 
 
15
  * }} VisualizationSettings */
16
 
17
  export const VIZ_STORAGE_PREFIX = 'vl3d.viz.';
@@ -21,8 +28,15 @@ export const VIZ_STORAGE_KEYS = Object.freeze({
21
  colorPositive: `${VIZ_STORAGE_PREFIX}colorPositive`,
22
  colorZero: `${VIZ_STORAGE_PREFIX}colorZero`,
23
  colorNegative: `${VIZ_STORAGE_PREFIX}colorNegative`,
 
24
  zeroCoverage: `${VIZ_STORAGE_PREFIX}zeroCoverage`,
25
  labelsVisible: `${VIZ_STORAGE_PREFIX}labelsVisible`,
 
 
 
 
 
 
26
  });
27
 
28
  /** Default anchors match former ramp endpoints (§0.2). */
@@ -32,25 +46,54 @@ export const DEFAULT_VIZ_COLORS = Object.freeze({
32
  colorNegative: '#9900E6',
33
  });
34
 
35
- export const DEFAULT_VIZ_FILTER = /** @type {VizFilterMode} */ ('all');
 
36
 
37
- /** Zero-color coverage % of |t| before lerp toward ±1 (0 = linear). */
38
- export const DEFAULT_ZERO_COVERAGE = 0;
39
 
40
  /** Thread / group floating labels visible by default. */
41
  export const DEFAULT_LABELS_VISIBLE = true;
42
 
43
- export const ZERO_COVERAGE_MIN = 0;
44
- export const ZERO_COVERAGE_MAX = 90;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
 
46
  /** @type {VisualizationSettings} */
47
  export const DEFAULT_VISUALIZATION_SETTINGS = Object.freeze({
48
  vizFilterMode: DEFAULT_VIZ_FILTER,
49
  ...DEFAULT_VIZ_COLORS,
50
- zeroCoverage: DEFAULT_ZERO_COVERAGE,
 
51
  labelsVisible: DEFAULT_LABELS_VISIBLE,
 
 
 
 
 
 
52
  });
53
-
54
  const HEX_RE = /^#([0-9A-Fa-f]{6})$/;
55
 
56
  /**
@@ -111,23 +154,136 @@ export function normalizeLabelsVisible(value) {
111
  }
112
 
113
  /**
114
- * Clamp zero-coverage percent to [0, 90]. Invalid → default.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
  * @param {unknown} value
116
  * @returns {number}
117
  */
118
  export function normalizeZeroCoverage(value) {
 
 
 
 
 
 
 
 
 
119
  const n = typeof value === 'number' ? value : Number(value);
120
- if (!Number.isFinite(n)) return DEFAULT_ZERO_COVERAGE;
121
- return Math.max(ZERO_COVERAGE_MIN, Math.min(ZERO_COVERAGE_MAX, Math.round(n)));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
  }
123
 
124
  /**
125
- * Coverage % → unit fraction in [0, 0.9] for ramp remapping.
126
  * @param {number} percent
127
  * @returns {number}
128
  */
129
  export function zeroCoverageToUnit(percent) {
130
- return normalizeZeroCoverage(percent) / 100;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
131
  }
132
 
133
  /**
@@ -135,15 +291,15 @@ export function zeroCoverageToUnit(percent) {
135
  * coverage=0 → identity; coverage=0.5 → |t|≤0.5 stays zero, then stretches 0.5→1 onto 0→1.
136
  *
137
  * @param {number} absT - Absolute normalized activation in [0, 1]
138
- * @param {number} coverage01 - Fraction in [0, 0.9]
139
  * @returns {number} Blend factor in [0, 1]
140
  */
141
  export function remapAbsTWithZeroCoverage(absT, coverage01 = 0) {
142
  const a = Math.max(0, Math.min(1, absT));
143
- const c = Math.max(0, Math.min(0.9, coverage01));
144
- if (c <= 1e-9) return a;
145
  if (a <= c) return 0;
146
- return (a - c) / (1 - c);
147
  }
148
 
149
  /**
@@ -157,19 +313,47 @@ export function resolveVisualizationSettings(partial = null) {
157
  colorPositive: normalizeHex(src.colorPositive) || DEFAULT_VIZ_COLORS.colorPositive,
158
  colorZero: normalizeHex(src.colorZero) || DEFAULT_VIZ_COLORS.colorZero,
159
  colorNegative: normalizeHex(src.colorNegative) || DEFAULT_VIZ_COLORS.colorNegative,
160
- zeroCoverage: normalizeZeroCoverage(
 
 
 
 
161
  src.zeroCoverage !== undefined && src.zeroCoverage !== null
162
  ? src.zeroCoverage
163
- : DEFAULT_ZERO_COVERAGE
164
  ),
165
  labelsVisible: normalizeLabelsVisible(
166
  src.labelsVisible !== undefined && src.labelsVisible !== null
167
  ? src.labelsVisible
168
  : DEFAULT_LABELS_VISIBLE
169
  ),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
170
  };
171
  }
172
-
173
  /**
174
  * @param {Storage|null|undefined} storage
175
  * @returns {VisualizationSettings}
@@ -182,14 +366,20 @@ export function loadVisualizationSettings(storage = typeof localStorage !== 'und
182
  colorPositive: storage.getItem(VIZ_STORAGE_KEYS.colorPositive),
183
  colorZero: storage.getItem(VIZ_STORAGE_KEYS.colorZero),
184
  colorNegative: storage.getItem(VIZ_STORAGE_KEYS.colorNegative),
 
185
  zeroCoverage: storage.getItem(VIZ_STORAGE_KEYS.zeroCoverage),
186
  labelsVisible: storage.getItem(VIZ_STORAGE_KEYS.labelsVisible),
 
 
 
 
 
 
187
  });
188
  } catch {
189
  return { ...DEFAULT_VISUALIZATION_SETTINGS };
190
  }
191
  }
192
-
193
  /**
194
  * @param {VisualizationSettings} settings
195
  * @param {Storage|null|undefined} storage
@@ -202,8 +392,15 @@ export function saveVisualizationSettings(settings, storage = typeof localStorag
202
  storage.setItem(VIZ_STORAGE_KEYS.colorPositive, resolved.colorPositive);
203
  storage.setItem(VIZ_STORAGE_KEYS.colorZero, resolved.colorZero);
204
  storage.setItem(VIZ_STORAGE_KEYS.colorNegative, resolved.colorNegative);
 
205
  storage.setItem(VIZ_STORAGE_KEYS.zeroCoverage, String(resolved.zeroCoverage));
206
  storage.setItem(VIZ_STORAGE_KEYS.labelsVisible, String(resolved.labelsVisible));
 
 
 
 
 
 
207
  } catch {
208
  // Quota / private mode — ignore
209
  }
 
10
  * colorPositive: string,
11
  * colorZero: string,
12
  * colorNegative: string,
13
+ * zeroCoverageEnabled: boolean,
14
  * zeroCoverage: number,
15
  * labelsVisible: boolean,
16
+ * sameSignCancelEnabled: boolean,
17
+ * sameSignCancelCoverage: number,
18
+ * oppositeHighlightEnabled: boolean,
19
+ * oppositeHighlightColor: string,
20
+ * oppositeHighlightStrength: number,
21
+ * oppositeCancelCoverage: number,
22
  * }} VisualizationSettings */
23
 
24
  export const VIZ_STORAGE_PREFIX = 'vl3d.viz.';
 
28
  colorPositive: `${VIZ_STORAGE_PREFIX}colorPositive`,
29
  colorZero: `${VIZ_STORAGE_PREFIX}colorZero`,
30
  colorNegative: `${VIZ_STORAGE_PREFIX}colorNegative`,
31
+ zeroCoverageEnabled: `${VIZ_STORAGE_PREFIX}zeroCoverageEnabled`,
32
  zeroCoverage: `${VIZ_STORAGE_PREFIX}zeroCoverage`,
33
  labelsVisible: `${VIZ_STORAGE_PREFIX}labelsVisible`,
34
+ sameSignCancelEnabled: `${VIZ_STORAGE_PREFIX}sameSignCancelEnabled`,
35
+ sameSignCancelCoverage: `${VIZ_STORAGE_PREFIX}sameSignCancelCoverage`,
36
+ oppositeHighlightEnabled: `${VIZ_STORAGE_PREFIX}oppositeHighlightEnabled`,
37
+ oppositeHighlightColor: `${VIZ_STORAGE_PREFIX}oppositeHighlightColor`,
38
+ oppositeHighlightStrength: `${VIZ_STORAGE_PREFIX}oppositeHighlightStrength`,
39
+ oppositeCancelCoverage: `${VIZ_STORAGE_PREFIX}oppositeCancelCoverage`,
40
  });
41
 
42
  /** Default anchors match former ramp endpoints (§0.2). */
 
46
  colorNegative: '#9900E6',
47
  });
48
 
49
+ /** Default highlight for opposite-sign dims (cyan). */
50
+ export const DEFAULT_OPPOSITE_HIGHLIGHT_COLOR = '#00E5FF';
51
 
52
+ export const DEFAULT_VIZ_FILTER = /** @type {VizFilterMode} */ ('all');
 
53
 
54
  /** Thread / group floating labels visible by default. */
55
  export const DEFAULT_LABELS_VISIBLE = true;
56
 
57
+ /**
58
+ * Zero coverage + Shared noise similarity: 30% … 99.9999%.
59
+ * Slider uses 0…10000 positions for usable fine control near the top.
60
+ */
61
+ export const HIGH_COVERAGE_MIN = 30;
62
+ export const HIGH_COVERAGE_MAX = 99.9999;
63
+ export const HIGH_COVERAGE_SLIDER_MAX = 10000;
64
+ export const DEFAULT_HIGH_COVERAGE = HIGH_COVERAGE_MIN;
65
+ export const COVERAGE_UNIT_MAX = HIGH_COVERAGE_MAX / 100;
66
+
67
+ /** Conflict cover only: 0–90% linear. */
68
+ export const CONFLICT_COVER_MIN = 0;
69
+ export const CONFLICT_COVER_MAX = 90;
70
+ export const DEFAULT_CONFLICT_COVER = 0;
71
+
72
+ /** @deprecated Use CONFLICT_COVER_* or HIGH_COVERAGE_* */
73
+ export const ZERO_COVERAGE_MIN = CONFLICT_COVER_MIN;
74
+ /** @deprecated */
75
+ export const ZERO_COVERAGE_MAX = CONFLICT_COVER_MAX;
76
+ /** @deprecated */
77
+ export const DEFAULT_ZERO_COVERAGE = DEFAULT_HIGH_COVERAGE;
78
+
79
+ export const HIGHLIGHT_STRENGTH_MIN = 0;
80
+ export const HIGHLIGHT_STRENGTH_MAX = 100;
81
+ export const DEFAULT_OPPOSITE_HIGHLIGHT_STRENGTH = 70;
82
 
83
  /** @type {VisualizationSettings} */
84
  export const DEFAULT_VISUALIZATION_SETTINGS = Object.freeze({
85
  vizFilterMode: DEFAULT_VIZ_FILTER,
86
  ...DEFAULT_VIZ_COLORS,
87
+ zeroCoverageEnabled: false,
88
+ zeroCoverage: DEFAULT_HIGH_COVERAGE,
89
  labelsVisible: DEFAULT_LABELS_VISIBLE,
90
+ sameSignCancelEnabled: false,
91
+ sameSignCancelCoverage: DEFAULT_HIGH_COVERAGE,
92
+ oppositeHighlightEnabled: false,
93
+ oppositeHighlightColor: DEFAULT_OPPOSITE_HIGHLIGHT_COLOR,
94
+ oppositeHighlightStrength: DEFAULT_OPPOSITE_HIGHLIGHT_STRENGTH,
95
+ oppositeCancelCoverage: DEFAULT_CONFLICT_COVER,
96
  });
 
97
  const HEX_RE = /^#([0-9A-Fa-f]{6})$/;
98
 
99
  /**
 
154
  }
155
 
156
  /**
157
+ * @param {unknown} value
158
+ * @param {boolean} [fallback=false]
159
+ * @returns {boolean}
160
+ */
161
+ export function normalizeBoolFlag(value, fallback = false) {
162
+ if (value === false || value === 'false' || value === '0') return false;
163
+ if (value === true || value === 'true' || value === '1') return true;
164
+ if (value == null || value === '') return fallback;
165
+ return fallback;
166
+ }
167
+
168
+ /**
169
+ * Clamp highlight strength % to [0, 100].
170
+ * @param {unknown} value
171
+ * @returns {number}
172
+ */
173
+ export function normalizeHighlightStrength(value) {
174
+ const n = typeof value === 'number' ? value : Number(value);
175
+ if (!Number.isFinite(n)) return DEFAULT_OPPOSITE_HIGHLIGHT_STRENGTH;
176
+ return Math.max(HIGHLIGHT_STRENGTH_MIN, Math.min(HIGHLIGHT_STRENGTH_MAX, Math.round(n)));
177
+ }
178
+
179
+ /**
180
+ * Clamp conflict-cover percent to [0, 90]. Invalid → default.
181
+ * @param {unknown} value
182
+ * @returns {number}
183
+ */
184
+ export function normalizeConflictCover(value) {
185
+ const n = typeof value === 'number' ? value : Number(value);
186
+ if (!Number.isFinite(n)) return DEFAULT_CONFLICT_COVER;
187
+ return Math.max(CONFLICT_COVER_MIN, Math.min(CONFLICT_COVER_MAX, Math.round(n)));
188
+ }
189
+
190
+ /**
191
+ * @deprecated Prefer normalizeConflictCover or normalizeHighCoverage
192
  * @param {unknown} value
193
  * @returns {number}
194
  */
195
  export function normalizeZeroCoverage(value) {
196
+ return normalizeConflictCover(value);
197
+ }
198
+
199
+ /**
200
+ * Clamp Zero coverage / Shared noise percent to [30, 99.9999].
201
+ * @param {unknown} value
202
+ * @returns {number}
203
+ */
204
+ export function normalizeHighCoverage(value) {
205
  const n = typeof value === 'number' ? value : Number(value);
206
+ if (!Number.isFinite(n)) return DEFAULT_HIGH_COVERAGE;
207
+ const clamped = Math.max(HIGH_COVERAGE_MIN, Math.min(HIGH_COVERAGE_MAX, n));
208
+ // Keep up to 4 decimal places (matches 99.9999)
209
+ return Math.round(clamped * 10000) / 10000;
210
+ }
211
+
212
+ /**
213
+ * Linear map slider position 0…10000 → high coverage %.
214
+ * @param {unknown} pos
215
+ * @returns {number}
216
+ */
217
+ export function highCoverageFromSlider(pos) {
218
+ const p = typeof pos === 'number' ? pos : Number(pos);
219
+ if (!Number.isFinite(p)) return DEFAULT_HIGH_COVERAGE;
220
+ const t = Math.max(0, Math.min(1, p / HIGH_COVERAGE_SLIDER_MAX));
221
+ return normalizeHighCoverage(
222
+ HIGH_COVERAGE_MIN + t * (HIGH_COVERAGE_MAX - HIGH_COVERAGE_MIN)
223
+ );
224
+ }
225
+
226
+ /**
227
+ * Inverse: high coverage % → slider position 0…10000.
228
+ * @param {unknown} percent
229
+ * @returns {number}
230
+ */
231
+ export function highCoverageToSlider(percent) {
232
+ const v = normalizeHighCoverage(percent);
233
+ const t = (v - HIGH_COVERAGE_MIN) / (HIGH_COVERAGE_MAX - HIGH_COVERAGE_MIN);
234
+ return Math.round(Math.max(0, Math.min(1, t)) * HIGH_COVERAGE_SLIDER_MAX);
235
+ }
236
+
237
+ /**
238
+ * Display label for high coverage (trim trailing zeros, up to 4 decimals).
239
+ * @param {unknown} percent
240
+ * @returns {string}
241
+ */
242
+ export function formatHighCoverage(percent) {
243
+ const v = normalizeHighCoverage(percent);
244
+ const rounded = Math.round(v * 10000) / 10000;
245
+ if (Math.abs(rounded - Math.round(rounded)) < 1e-9) {
246
+ return `${Math.round(rounded)}%`;
247
+ }
248
+ return `${parseFloat(rounded.toFixed(4))}%`;
249
+ }
250
+
251
+ /**
252
+ * Coverage % → unit fraction for ramp remapping (supports up to ~0.999999).
253
+ * @param {number} percent
254
+ * @returns {number}
255
+ */
256
+ export function coveragePercentToUnit(percent) {
257
+ const n = typeof percent === 'number' ? percent : Number(percent);
258
+ if (!Number.isFinite(n) || n <= 0) return 0;
259
+ return Math.max(0, Math.min(COVERAGE_UNIT_MAX, n / 100));
260
  }
261
 
262
  /**
 
263
  * @param {number} percent
264
  * @returns {number}
265
  */
266
  export function zeroCoverageToUnit(percent) {
267
+ return coveragePercentToUnit(percent);
268
+ }
269
+
270
+ /**
271
+ * High coverage unit for Zero coverage / Shared noise.
272
+ * @param {number} percent
273
+ * @returns {number}
274
+ */
275
+ export function highCoverageToUnit(percent) {
276
+ return coveragePercentToUnit(normalizeHighCoverage(percent));
277
+ }
278
+
279
+ /**
280
+ * Effective Zero coverage % (0 when toggle Off).
281
+ * @param {{ zeroCoverageEnabled?: boolean, zeroCoverage?: number }|null|undefined} settings
282
+ * @returns {number}
283
+ */
284
+ export function effectiveZeroCoveragePercent(settings) {
285
+ if (!settings || !settings.zeroCoverageEnabled) return 0;
286
+ return normalizeHighCoverage(settings.zeroCoverage);
287
  }
288
 
289
  /**
 
291
  * coverage=0 → identity; coverage=0.5 → |t|≤0.5 stays zero, then stretches 0.5→1 onto 0→1.
292
  *
293
  * @param {number} absT - Absolute normalized activation in [0, 1]
294
+ * @param {number} coverage01 - Fraction in [0, COVERAGE_UNIT_MAX]
295
  * @returns {number} Blend factor in [0, 1]
296
  */
297
  export function remapAbsTWithZeroCoverage(absT, coverage01 = 0) {
298
  const a = Math.max(0, Math.min(1, absT));
299
+ const c = Math.max(0, Math.min(COVERAGE_UNIT_MAX, coverage01));
300
+ if (c <= 1e-12) return a;
301
  if (a <= c) return 0;
302
+ return (a - c) / Math.max(1 - c, 1e-12);
303
  }
304
 
305
  /**
 
313
  colorPositive: normalizeHex(src.colorPositive) || DEFAULT_VIZ_COLORS.colorPositive,
314
  colorZero: normalizeHex(src.colorZero) || DEFAULT_VIZ_COLORS.colorZero,
315
  colorNegative: normalizeHex(src.colorNegative) || DEFAULT_VIZ_COLORS.colorNegative,
316
+ zeroCoverageEnabled: normalizeBoolFlag(
317
+ src.zeroCoverageEnabled,
318
+ DEFAULT_VISUALIZATION_SETTINGS.zeroCoverageEnabled
319
+ ),
320
+ zeroCoverage: normalizeHighCoverage(
321
  src.zeroCoverage !== undefined && src.zeroCoverage !== null
322
  ? src.zeroCoverage
323
+ : DEFAULT_HIGH_COVERAGE
324
  ),
325
  labelsVisible: normalizeLabelsVisible(
326
  src.labelsVisible !== undefined && src.labelsVisible !== null
327
  ? src.labelsVisible
328
  : DEFAULT_LABELS_VISIBLE
329
  ),
330
+ sameSignCancelEnabled: normalizeBoolFlag(
331
+ src.sameSignCancelEnabled,
332
+ DEFAULT_VISUALIZATION_SETTINGS.sameSignCancelEnabled
333
+ ),
334
+ sameSignCancelCoverage: normalizeHighCoverage(
335
+ src.sameSignCancelCoverage !== undefined && src.sameSignCancelCoverage !== null
336
+ ? src.sameSignCancelCoverage
337
+ : DEFAULT_HIGH_COVERAGE
338
+ ),
339
+ oppositeHighlightEnabled: normalizeBoolFlag(
340
+ src.oppositeHighlightEnabled,
341
+ DEFAULT_VISUALIZATION_SETTINGS.oppositeHighlightEnabled
342
+ ),
343
+ oppositeHighlightColor:
344
+ normalizeHex(src.oppositeHighlightColor) || DEFAULT_OPPOSITE_HIGHLIGHT_COLOR,
345
+ oppositeHighlightStrength: normalizeHighlightStrength(
346
+ src.oppositeHighlightStrength !== undefined && src.oppositeHighlightStrength !== null
347
+ ? src.oppositeHighlightStrength
348
+ : DEFAULT_OPPOSITE_HIGHLIGHT_STRENGTH
349
+ ),
350
+ oppositeCancelCoverage: normalizeConflictCover(
351
+ src.oppositeCancelCoverage !== undefined && src.oppositeCancelCoverage !== null
352
+ ? src.oppositeCancelCoverage
353
+ : DEFAULT_CONFLICT_COVER
354
+ ),
355
  };
356
  }
 
357
  /**
358
  * @param {Storage|null|undefined} storage
359
  * @returns {VisualizationSettings}
 
366
  colorPositive: storage.getItem(VIZ_STORAGE_KEYS.colorPositive),
367
  colorZero: storage.getItem(VIZ_STORAGE_KEYS.colorZero),
368
  colorNegative: storage.getItem(VIZ_STORAGE_KEYS.colorNegative),
369
+ zeroCoverageEnabled: storage.getItem(VIZ_STORAGE_KEYS.zeroCoverageEnabled),
370
  zeroCoverage: storage.getItem(VIZ_STORAGE_KEYS.zeroCoverage),
371
  labelsVisible: storage.getItem(VIZ_STORAGE_KEYS.labelsVisible),
372
+ sameSignCancelEnabled: storage.getItem(VIZ_STORAGE_KEYS.sameSignCancelEnabled),
373
+ sameSignCancelCoverage: storage.getItem(VIZ_STORAGE_KEYS.sameSignCancelCoverage),
374
+ oppositeHighlightEnabled: storage.getItem(VIZ_STORAGE_KEYS.oppositeHighlightEnabled),
375
+ oppositeHighlightColor: storage.getItem(VIZ_STORAGE_KEYS.oppositeHighlightColor),
376
+ oppositeHighlightStrength: storage.getItem(VIZ_STORAGE_KEYS.oppositeHighlightStrength),
377
+ oppositeCancelCoverage: storage.getItem(VIZ_STORAGE_KEYS.oppositeCancelCoverage),
378
  });
379
  } catch {
380
  return { ...DEFAULT_VISUALIZATION_SETTINGS };
381
  }
382
  }
 
383
  /**
384
  * @param {VisualizationSettings} settings
385
  * @param {Storage|null|undefined} storage
 
392
  storage.setItem(VIZ_STORAGE_KEYS.colorPositive, resolved.colorPositive);
393
  storage.setItem(VIZ_STORAGE_KEYS.colorZero, resolved.colorZero);
394
  storage.setItem(VIZ_STORAGE_KEYS.colorNegative, resolved.colorNegative);
395
+ storage.setItem(VIZ_STORAGE_KEYS.zeroCoverageEnabled, String(resolved.zeroCoverageEnabled));
396
  storage.setItem(VIZ_STORAGE_KEYS.zeroCoverage, String(resolved.zeroCoverage));
397
  storage.setItem(VIZ_STORAGE_KEYS.labelsVisible, String(resolved.labelsVisible));
398
+ storage.setItem(VIZ_STORAGE_KEYS.sameSignCancelEnabled, String(resolved.sameSignCancelEnabled));
399
+ storage.setItem(VIZ_STORAGE_KEYS.sameSignCancelCoverage, String(resolved.sameSignCancelCoverage));
400
+ storage.setItem(VIZ_STORAGE_KEYS.oppositeHighlightEnabled, String(resolved.oppositeHighlightEnabled));
401
+ storage.setItem(VIZ_STORAGE_KEYS.oppositeHighlightColor, resolved.oppositeHighlightColor);
402
+ storage.setItem(VIZ_STORAGE_KEYS.oppositeHighlightStrength, String(resolved.oppositeHighlightStrength));
403
+ storage.setItem(VIZ_STORAGE_KEYS.oppositeCancelCoverage, String(resolved.oppositeCancelCoverage));
404
  } catch {
405
  // Quota / private mode — ignore
406
  }
src/v25/canvasHost.js CHANGED
@@ -56,7 +56,7 @@ export function resolveCanvasSize(el) {
56
  /**
57
  * Map Interaction hover payload → footer HUD strings (minimal telemetry).
58
  * @param {object|null|undefined} hoverData
59
- * @returns {{ coords: string, segment: string, activation: string, token: string }}
60
  */
61
  export function formatHoverForFooter(hoverData) {
62
  const t = resolveHoverTelemetry(hoverData);
@@ -64,14 +64,17 @@ export function formatHoverForFooter(hoverData) {
64
  return {
65
  coords: 'X: -- | Y: -- | Z: --',
66
  segment: 'NEUTRAL SPACE',
 
67
  activation: 'ACTIVATION: --',
68
  token: 'NONE',
69
  };
70
  }
71
  const pos = t.point || { x: 0, y: 0, z: 0 };
 
72
  return {
73
  coords: `X: ${Math.round(pos.x)} | Y: ${Math.round(pos.y)} | Z: ${Math.round(pos.z)}`,
74
  segment: t.type,
 
75
  activation: `ACTIVATION: ${formatActivationValue(t.activation, { maxChars: 16 })}`,
76
  token: t.token,
77
  };
 
56
  /**
57
  * Map Interaction hover payload → footer HUD strings (minimal telemetry).
58
  * @param {object|null|undefined} hoverData
59
+ * @returns {{ coords: string, segment: string, dim: string, activation: string, token: string }}
60
  */
61
  export function formatHoverForFooter(hoverData) {
62
  const t = resolveHoverTelemetry(hoverData);
 
64
  return {
65
  coords: 'X: -- | Y: -- | Z: --',
66
  segment: 'NEUTRAL SPACE',
67
+ dim: 'DIM: --',
68
  activation: 'ACTIVATION: --',
69
  token: 'NONE',
70
  };
71
  }
72
  const pos = t.point || { x: 0, y: 0, z: 0 };
73
+ const dimLabel = t.dim != null && Number.isFinite(t.dim) ? `DIM: ${Math.trunc(t.dim)}` : 'DIM: --';
74
  return {
75
  coords: `X: ${Math.round(pos.x)} | Y: ${Math.round(pos.y)} | Z: ${Math.round(pos.z)}`,
76
  segment: t.type,
77
+ dim: dimLabel,
78
  activation: `ACTIVATION: ${formatActivationValue(t.activation, { maxChars: 16 })}`,
79
  token: t.token,
80
  };
src/v25/main.js CHANGED
@@ -53,6 +53,7 @@ if (app) {
53
  footer.setCoords(payload.coords);
54
  footer.setTelemetry({
55
  segment: payload.segment,
 
56
  activation: payload.activation,
57
  token: payload.token,
58
  });
 
53
  footer.setCoords(payload.coords);
54
  footer.setTelemetry({
55
  segment: payload.segment,
56
+ dim: payload.dim,
57
  activation: payload.activation,
58
  token: payload.token,
59
  });
src/v25/ui/footerHud.js CHANGED
@@ -5,6 +5,7 @@
5
  export const HUD_PLACEHOLDERS = Object.freeze({
6
  coords: 'X: 0 | Y: 0 | Z: 0',
7
  segment: 'RESULT VECTOR',
 
8
  activation: 'ACTIVATION: --',
9
  token: '--',
10
  });
@@ -19,6 +20,7 @@ export function footerHudMarkup() {
19
  <div class="lab-hud__block lab-hud__block--center">
20
  <span class="lab-hud__label">HOVER TELEMETRY</span>
21
  <span id="lab-hud-segment" class="lab-hud__badge">${HUD_PLACEHOLDERS.segment}</span>
 
22
  <span id="lab-hud-activation" class="lab-hud__val lab-mono">${HUD_PLACEHOLDERS.activation}</span>
23
  </div>
24
  <div class="lab-hud__block">
@@ -40,11 +42,13 @@ export function mountFooterHud(container) {
40
  const el = container.querySelector('#lab-hud-coords');
41
  if (el) el.textContent = text;
42
  },
43
- setTelemetry({ segment, activation, token } = {}) {
44
  const seg = container.querySelector('#lab-hud-segment');
 
45
  const act = container.querySelector('#lab-hud-activation');
46
  const tok = container.querySelector('#lab-hud-token');
47
  if (seg && segment != null) seg.textContent = segment;
 
48
  if (act && activation != null) act.textContent = activation;
49
  if (tok && token != null) tok.textContent = token;
50
  },
 
5
  export const HUD_PLACEHOLDERS = Object.freeze({
6
  coords: 'X: 0 | Y: 0 | Z: 0',
7
  segment: 'RESULT VECTOR',
8
+ dim: 'DIM: --',
9
  activation: 'ACTIVATION: --',
10
  token: '--',
11
  });
 
20
  <div class="lab-hud__block lab-hud__block--center">
21
  <span class="lab-hud__label">HOVER TELEMETRY</span>
22
  <span id="lab-hud-segment" class="lab-hud__badge">${HUD_PLACEHOLDERS.segment}</span>
23
+ <span id="lab-hud-dim" class="lab-hud__val lab-mono">${HUD_PLACEHOLDERS.dim}</span>
24
  <span id="lab-hud-activation" class="lab-hud__val lab-mono">${HUD_PLACEHOLDERS.activation}</span>
25
  </div>
26
  <div class="lab-hud__block">
 
42
  const el = container.querySelector('#lab-hud-coords');
43
  if (el) el.textContent = text;
44
  },
45
+ setTelemetry({ segment, dim, activation, token } = {}) {
46
  const seg = container.querySelector('#lab-hud-segment');
47
+ const dimEl = container.querySelector('#lab-hud-dim');
48
  const act = container.querySelector('#lab-hud-activation');
49
  const tok = container.querySelector('#lab-hud-token');
50
  if (seg && segment != null) seg.textContent = segment;
51
+ if (dimEl && dim != null) dimEl.textContent = dim;
52
  if (act && activation != null) act.textContent = activation;
53
  if (tok && token != null) tok.textContent = token;
54
  },
src/visualizer/DivergentShading.js CHANGED
@@ -132,11 +132,17 @@ export function getDivergentColor(val, absMax = 1.0, anchors = null, zeroCoverag
132
  */
133
  export const divergentVertexShader = `
134
  attribute float intensity;
 
 
135
  varying float vIntensity;
 
 
136
  uniform float pointSize;
137
 
138
  void main() {
139
  vIntensity = intensity;
 
 
140
  vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
141
  float dist = length(mvPosition.xyz);
142
  gl_PointSize = clamp(pointSize * (300.0 / max(dist, 0.01)), 4.0, 80.0);
@@ -148,13 +154,17 @@ void main() {
148
  * GLSL Fragment Shader: three color anchors via uniforms + optional sign filter.
149
  * Filter modes: 0=all, 1=positive only, 2=negative only (ε = uNearZeroEps).
150
  * uZeroCoverage: fraction of |t| held at zero color before lerp to ±1.
 
151
  */
152
  export const divergentFragmentShader = `
153
  varying float vIntensity;
 
 
154
  uniform float baseOpacity;
155
  uniform vec3 uColorPos;
156
  uniform vec3 uColorNeg;
157
  uniform vec3 uColorZero;
 
158
  uniform int uFilterMode;
159
  uniform float uNearZeroEps;
160
  uniform float uZeroCoverage;
@@ -182,10 +192,10 @@ void main() {
182
  return;
183
  }
184
 
185
- float c = clamp(uZeroCoverage, 0.0, 0.9);
186
  float k = absT;
187
- if (c > 1e-5) {
188
- k = absT <= c ? 0.0 : (absT - c) / max(1.0 - c, 1e-5);
189
  }
190
 
191
  float dynamicAlpha = clamp(pow(max(k, absT * 0.15), 1.1), 0.05, 1.0) * baseOpacity;
@@ -194,6 +204,12 @@ void main() {
194
  ? mix(uColorZero, uColorPos, k)
195
  : mix(uColorZero, uColorNeg, k);
196
 
 
 
 
 
 
 
197
  vec3 finalColor = color * solidEdge;
198
  float alpha = dynamicAlpha * solidEdge;
199
 
@@ -221,12 +237,21 @@ export function filterModeToUniform(mode) {
221
  * anchors?: object,
222
  * filterMode?: 'all'|'positive'|'negative',
223
  * zeroCoverage?: number,
 
224
  * }} [options]
225
  * @returns {THREE.ShaderMaterial}
226
  */
227
  export function createDivergentMaterial(pointSize = 10.0, baseOpacity = 0.7, options = {}) {
228
  const A = resolveColorAnchors(options.anchors ?? null);
229
  const coverage01 = resolveZeroCoverage01(options.anchors, options.zeroCoverage);
 
 
 
 
 
 
 
 
230
  return new THREE.ShaderMaterial({
231
  uniforms: {
232
  pointSize: { value: pointSize },
@@ -234,6 +259,7 @@ export function createDivergentMaterial(pointSize = 10.0, baseOpacity = 0.7, opt
234
  uColorPos: { value: new THREE.Vector3(A.positive.r, A.positive.g, A.positive.b) },
235
  uColorNeg: { value: new THREE.Vector3(A.negative.r, A.negative.g, A.negative.b) },
236
  uColorZero: { value: new THREE.Vector3(A.zero.r, A.zero.g, A.zero.b) },
 
237
  uFilterMode: { value: filterModeToUniform(options.filterMode) },
238
  uNearZeroEps: { value: NEAR_ZERO_EPS },
239
  uZeroCoverage: { value: coverage01 },
@@ -249,7 +275,7 @@ export function createDivergentMaterial(pointSize = 10.0, baseOpacity = 0.7, opt
249
  /**
250
  * Update live color/filter uniforms without rebuilding the shader string.
251
  * @param {THREE.ShaderMaterial} material
252
- * @param {{ anchors?: object, filterMode?: string, zeroCoverage?: number }} options
253
  */
254
  export function updateDivergentMaterialUniforms(material, options = {}) {
255
  if (!material?.uniforms) return;
@@ -265,6 +291,11 @@ export function updateDivergentMaterialUniforms(material, options = {}) {
265
  if (options.zeroCoverage !== undefined || (options.anchors && options.anchors.zeroCoverage !== undefined)) {
266
  material.uniforms.uZeroCoverage.value = resolveZeroCoverage01(options.anchors, options.zeroCoverage);
267
  }
 
 
 
 
 
268
  }
269
 
270
  export { hexToRgb01, remapAbsTWithZeroCoverage };
 
132
  */
133
  export const divergentVertexShader = `
134
  attribute float intensity;
135
+ attribute float aCancel;
136
+ attribute float aHighlight;
137
  varying float vIntensity;
138
+ varying float vCancel;
139
+ varying float vHighlight;
140
  uniform float pointSize;
141
 
142
  void main() {
143
  vIntensity = intensity;
144
+ vCancel = aCancel;
145
+ vHighlight = aHighlight;
146
  vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
147
  float dist = length(mvPosition.xyz);
148
  gl_PointSize = clamp(pointSize * (300.0 / max(dist, 0.01)), 4.0, 80.0);
 
154
  * GLSL Fragment Shader: three color anchors via uniforms + optional sign filter.
155
  * Filter modes: 0=all, 1=positive only, 2=negative only (ε = uNearZeroEps).
156
  * uZeroCoverage: fraction of |t| held at zero color before lerp to ±1.
157
+ * aCancel / aHighlight: group-dim paint (shared-noise cancel + opposite highlight).
158
  */
159
  export const divergentFragmentShader = `
160
  varying float vIntensity;
161
+ varying float vCancel;
162
+ varying float vHighlight;
163
  uniform float baseOpacity;
164
  uniform vec3 uColorPos;
165
  uniform vec3 uColorNeg;
166
  uniform vec3 uColorZero;
167
+ uniform vec3 uColorHighlight;
168
  uniform int uFilterMode;
169
  uniform float uNearZeroEps;
170
  uniform float uZeroCoverage;
 
192
  return;
193
  }
194
 
195
+ float c = clamp(uZeroCoverage, 0.0, 0.999999);
196
  float k = absT;
197
+ if (c > 1e-8) {
198
+ k = absT <= c ? 0.0 : (absT - c) / max(1.0 - c, 1e-8);
199
  }
200
 
201
  float dynamicAlpha = clamp(pow(max(k, absT * 0.15), 1.1), 0.05, 1.0) * baseOpacity;
 
204
  ? mix(uColorZero, uColorPos, k)
205
  : mix(uColorZero, uColorNeg, k);
206
 
207
+ float hi = clamp(vHighlight, 0.0, 1.0);
208
+ float cancel = clamp(vCancel, 0.0, 1.0);
209
+ color = mix(color, uColorHighlight, hi);
210
+ color = mix(color, uColorZero, cancel);
211
+ dynamicAlpha = max(0.05, dynamicAlpha * (1.0 - 0.85 * cancel));
212
+
213
  vec3 finalColor = color * solidEdge;
214
  float alpha = dynamicAlpha * solidEdge;
215
 
 
237
  * anchors?: object,
238
  * filterMode?: 'all'|'positive'|'negative',
239
  * zeroCoverage?: number,
240
+ * highlightColor?: {r:number,g:number,b:number}|string,
241
  * }} [options]
242
  * @returns {THREE.ShaderMaterial}
243
  */
244
  export function createDivergentMaterial(pointSize = 10.0, baseOpacity = 0.7, options = {}) {
245
  const A = resolveColorAnchors(options.anchors ?? null);
246
  const coverage01 = resolveZeroCoverage01(options.anchors, options.zeroCoverage);
247
+ let hi = { r: 0, g: 229 / 255, b: 1 };
248
+ if (options.highlightColor) {
249
+ if (typeof options.highlightColor === 'string') {
250
+ hi = hexToRgb01(options.highlightColor) || hi;
251
+ } else if (options.highlightColor.r != null) {
252
+ hi = options.highlightColor;
253
+ }
254
+ }
255
  return new THREE.ShaderMaterial({
256
  uniforms: {
257
  pointSize: { value: pointSize },
 
259
  uColorPos: { value: new THREE.Vector3(A.positive.r, A.positive.g, A.positive.b) },
260
  uColorNeg: { value: new THREE.Vector3(A.negative.r, A.negative.g, A.negative.b) },
261
  uColorZero: { value: new THREE.Vector3(A.zero.r, A.zero.g, A.zero.b) },
262
+ uColorHighlight: { value: new THREE.Vector3(hi.r, hi.g, hi.b) },
263
  uFilterMode: { value: filterModeToUniform(options.filterMode) },
264
  uNearZeroEps: { value: NEAR_ZERO_EPS },
265
  uZeroCoverage: { value: coverage01 },
 
275
  /**
276
  * Update live color/filter uniforms without rebuilding the shader string.
277
  * @param {THREE.ShaderMaterial} material
278
+ * @param {{ anchors?: object, filterMode?: string, zeroCoverage?: number, highlightColor?: object|string }} options
279
  */
280
  export function updateDivergentMaterialUniforms(material, options = {}) {
281
  if (!material?.uniforms) return;
 
291
  if (options.zeroCoverage !== undefined || (options.anchors && options.anchors.zeroCoverage !== undefined)) {
292
  material.uniforms.uZeroCoverage.value = resolveZeroCoverage01(options.anchors, options.zeroCoverage);
293
  }
294
+ if (options.highlightColor && material.uniforms.uColorHighlight) {
295
+ let hi = options.highlightColor;
296
+ if (typeof hi === 'string') hi = hexToRgb01(hi);
297
+ if (hi) material.uniforms.uColorHighlight.value.set(hi.r, hi.g, hi.b);
298
+ }
299
  }
300
 
301
  export { hexToRgb01, remapAbsTWithZeroCoverage };
src/visualizer/Instancer.js CHANGED
@@ -9,6 +9,10 @@ import {
9
  computeDimContrastPermutation,
10
  hasEnoughGroupsForDimSort,
11
  } from './dimContrastSort.js';
 
 
 
 
12
 
13
  /**
14
  * Instancer Manager for rendering vector points, ribbons, and highlights in the Three.js scene.
@@ -273,6 +277,13 @@ export class Instancer {
273
  const { slots: ySlots, span: ySlotSpan } = computeGroupAwareYSlots(items, { gapSlots: 1 });
274
  const dimSortOn = options.dimSortByContrast === true && hasEnoughGroupsForDimSort(items);
275
  const dimPerm = dimSortOn ? computeDimContrastPermutation(items) : null;
 
 
 
 
 
 
 
276
 
277
  const pointsData = [];
278
  const threadLabelItems = [];
@@ -286,6 +297,7 @@ export class Instancer {
286
  if (!rawVec || !rawVec.length) return;
287
 
288
  const vec = dimPerm ? applyDimPermutation(rawVec, dimPerm) : rawVec;
 
289
  const threadId = item.id || `tok_${idx}`;
290
  const layoutOpts = { ySlot: ySlots[idx] ?? idx, ySlotSpan };
291
  const vec3D = this.layoutEngine.mapVectorTo3DPoints(
@@ -301,7 +313,7 @@ export class Instancer {
301
 
302
  vec3D.forEach((p, dimIdx) => {
303
  const val = vec[dimIdx];
304
- const sourceDim = dimPerm ? dimPerm[dimIdx] : dimIdx;
305
  pointsData.push({
306
  position: p,
307
  activation: val,
@@ -313,7 +325,10 @@ export class Instancer {
313
 
314
  surfaceRows.push({ points: vec3D, activations });
315
 
316
- const vizOpts = vizConfig ? { vizConfig } : {};
 
 
 
317
  let ribbonMesh = null;
318
  if (renderMode === "RIBBONS") {
319
  const ribbonWidth = Math.max(2.0, 14.0 * thicknessFactor);
@@ -328,6 +343,7 @@ export class Instancer {
328
  ribbonMesh.userData.threadId = threadId;
329
  ribbonMesh.userData.token = item.text;
330
  ribbonMesh.userData.type = 'compare';
 
331
  this.activeGroup.add(ribbonMesh);
332
  }
333
 
@@ -374,6 +390,7 @@ export class Instancer {
374
  pointsMesh = MeshFactory.createPointsMesh(pointsData, {
375
  pointSize: 15.0 * thicknessFactor,
376
  ...(vizConfig ? { vizConfig } : {}),
 
377
  });
378
  pointsMesh.userData = { pointsData };
379
  this.activeGroup.add(pointsMesh);
 
9
  computeDimContrastPermutation,
10
  hasEnoughGroupsForDimSort,
11
  } from './dimContrastSort.js';
12
+ import {
13
+ computeDimRelationMetrics,
14
+ hasGroupsForDimContrast,
15
+ } from './groupDimContrast.js';
16
 
17
  /**
18
  * Instancer Manager for rendering vector points, ribbons, and highlights in the Three.js scene.
 
277
  const { slots: ySlots, span: ySlotSpan } = computeGroupAwareYSlots(items, { gapSlots: 1 });
278
  const dimSortOn = options.dimSortByContrast === true && hasEnoughGroupsForDimSort(items);
279
  const dimPerm = dimSortOn ? computeDimContrastPermutation(items) : null;
280
+ const groupDimMetrics = hasGroupsForDimContrast(items)
281
+ ? computeDimRelationMetrics(items)
282
+ : null;
283
+ const sourceDimsForVec = (len) => {
284
+ if (dimPerm && dimPerm.length === len) return dimPerm.slice();
285
+ return Array.from({ length: len }, (_, i) => i);
286
+ };
287
 
288
  const pointsData = [];
289
  const threadLabelItems = [];
 
297
  if (!rawVec || !rawVec.length) return;
298
 
299
  const vec = dimPerm ? applyDimPermutation(rawVec, dimPerm) : rawVec;
300
+ const sourceDims = sourceDimsForVec(vec.length);
301
  const threadId = item.id || `tok_${idx}`;
302
  const layoutOpts = { ySlot: ySlots[idx] ?? idx, ySlotSpan };
303
  const vec3D = this.layoutEngine.mapVectorTo3DPoints(
 
313
 
314
  vec3D.forEach((p, dimIdx) => {
315
  const val = vec[dimIdx];
316
+ const sourceDim = sourceDims[dimIdx];
317
  pointsData.push({
318
  position: p,
319
  activation: val,
 
325
 
326
  surfaceRows.push({ points: vec3D, activations });
327
 
328
+ const vizOpts = {
329
+ ...(vizConfig ? { vizConfig } : {}),
330
+ ...(groupDimMetrics?.length ? { groupDimMetrics, sourceDims } : {}),
331
+ };
332
  let ribbonMesh = null;
333
  if (renderMode === "RIBBONS") {
334
  const ribbonWidth = Math.max(2.0, 14.0 * thicknessFactor);
 
343
  ribbonMesh.userData.threadId = threadId;
344
  ribbonMesh.userData.token = item.text;
345
  ribbonMesh.userData.type = 'compare';
346
+ ribbonMesh.userData.sourceDims = sourceDims;
347
  this.activeGroup.add(ribbonMesh);
348
  }
349
 
 
390
  pointsMesh = MeshFactory.createPointsMesh(pointsData, {
391
  pointSize: 15.0 * thicknessFactor,
392
  ...(vizConfig ? { vizConfig } : {}),
393
+ ...(groupDimMetrics?.length ? { groupDimMetrics } : {}),
394
  });
395
  pointsMesh.userData = { pointsData };
396
  this.activeGroup.add(pointsMesh);
src/visualizer/MeshFactory.js CHANGED
@@ -1,7 +1,11 @@
1
  import * as THREE from 'three';
2
  import { createDivergentMaterial, getDivergentColor, calculateZScoreNormalized } from './DivergentShading.js';
3
- import { anchorsFromSettings, resolveVisualizationSettings } from '../ui/visualizationControlsDefaults.js';
4
  import { lineSegmentIndices, wideRibbonQuadIndices } from './activationFilter.js';
 
 
 
 
5
 
6
  /**
7
  * Resolve optional vizConfig from MeshFactory options into filter mode + RGB anchors.
@@ -14,11 +18,29 @@ function resolveVizOptions(options = {}) {
14
  return {
15
  filterMode: viz.vizFilterMode,
16
  anchors: anchorsFromSettings(viz),
17
- zeroCoverage: viz.zeroCoverage,
18
  viz,
 
 
19
  };
20
  }
21
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  /**
23
  * Factory for creating 3D Vector Point Cloud Geometries and Ribbon Lines using Divergent Activation Shading.
24
  */
@@ -32,7 +54,7 @@ export class MeshFactory {
32
  const geometry = new THREE.BufferGeometry();
33
  const positions = [];
34
  const rawActivations = [];
35
- const { filterMode, anchors, zeroCoverage } = resolveVizOptions(options);
36
 
37
  pointsData.forEach((item) => {
38
  const pos = item.position;
@@ -41,15 +63,19 @@ export class MeshFactory {
41
  });
42
 
43
  const normIntensities = calculateZScoreNormalized(rawActivations, 0.85);
 
44
 
45
  geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
46
  geometry.setAttribute('intensity', new THREE.Float32BufferAttribute(normIntensities, 1));
 
 
47
 
48
  const pointSize = options.pointSize || 14.0;
49
  const material = createDivergentMaterial(pointSize, 1.0, {
50
  anchors,
51
  filterMode,
52
  zeroCoverage,
 
53
  });
54
 
55
  const pointsMesh = new THREE.Points(geometry, material);
@@ -66,7 +92,8 @@ export class MeshFactory {
66
  * Full vertex buffer retained for in-situ compare reorder position updates.
67
  */
68
  static createRibbonMesh(points, activations = null, options = {}) {
69
- const { filterMode, anchors, zeroCoverage } = resolveVizOptions(options);
 
70
  const geometry = new THREE.BufferGeometry().setFromPoints(points);
71
 
72
  let normActivations = null;
@@ -74,7 +101,8 @@ export class MeshFactory {
74
  normActivations = calculateZScoreNormalized(activations, 0.85);
75
  const colors = new Float32Array(points.length * 3);
76
  for (let idx = 0; idx < normActivations.length; idx++) {
77
- const col = getDivergentColor(normActivations[idx], 1.0, anchors, zeroCoverage);
 
78
  colors[idx * 3 + 0] = col.r;
79
  colors[idx * 3 + 1] = col.g;
80
  colors[idx * 3 + 2] = col.b;
@@ -146,7 +174,8 @@ export class MeshFactory {
146
  */
147
  static createWideRibbonMesh(points, activations = null, options = {}) {
148
  if (!points || points.length < 2) return null;
149
- const { filterMode, anchors, zeroCoverage } = resolveVizOptions(options);
 
150
  const width = options.width ?? 3.0;
151
  const half = width * 0.5;
152
  const n = points.length;
@@ -182,7 +211,8 @@ export class MeshFactory {
182
  positions[iR * 3 + 1] = p.y + side.y;
183
  positions[iR * 3 + 2] = p.z + side.z;
184
 
185
- const col = getDivergentColor(norm[i], 1.0, anchors, zeroCoverage);
 
186
  for (const vi of [iL, iR]) {
187
  colors[vi * 3] = col.r;
188
  colors[vi * 3 + 1] = col.g;
 
1
  import * as THREE from 'three';
2
  import { createDivergentMaterial, getDivergentColor, calculateZScoreNormalized } from './DivergentShading.js';
3
+ import { anchorsFromSettings, resolveVisualizationSettings, hexToRgb01, effectiveZeroCoveragePercent } from '../ui/visualizationControlsDefaults.js';
4
  import { lineSegmentIndices, wideRibbonQuadIndices } from './activationFilter.js';
5
+ import {
6
+ applyGroupDimPaint,
7
+ buildPointGroupPaintAttributes,
8
+ } from './groupDimContrast.js';
9
 
10
  /**
11
  * Resolve optional vizConfig from MeshFactory options into filter mode + RGB anchors.
 
18
  return {
19
  filterMode: viz.vizFilterMode,
20
  anchors: anchorsFromSettings(viz),
21
+ zeroCoverage: effectiveZeroCoveragePercent(viz),
22
  viz,
23
+ groupDimMetrics: options.groupDimMetrics || null,
24
+ sourceDims: options.sourceDims || null,
25
  };
26
  }
27
 
28
+ /**
29
+ * Paint one activation with divergent + optional group-dim contrast.
30
+ * @param {number} normVal
31
+ * @param {number|undefined} sourceDim
32
+ * @param {ReturnType<typeof resolveVizOptions>} resolved
33
+ */
34
+ function colorForActivation(normVal, sourceDim, resolved) {
35
+ const base = getDivergentColor(normVal, 1.0, resolved.anchors, resolved.zeroCoverage);
36
+ const metrics = resolved.groupDimMetrics;
37
+ if (!metrics?.length || sourceDim == null) return base;
38
+ const metric = metrics[sourceDim];
39
+ const zero = resolved.anchors.zero;
40
+ const hi = hexToRgb01(resolved.viz.oppositeHighlightColor);
41
+ return applyGroupDimPaint(base, metric, resolved.viz, zero, hi);
42
+ }
43
+
44
  /**
45
  * Factory for creating 3D Vector Point Cloud Geometries and Ribbon Lines using Divergent Activation Shading.
46
  */
 
54
  const geometry = new THREE.BufferGeometry();
55
  const positions = [];
56
  const rawActivations = [];
57
+ const { filterMode, anchors, zeroCoverage, viz, groupDimMetrics } = resolveVizOptions(options);
58
 
59
  pointsData.forEach((item) => {
60
  const pos = item.position;
 
63
  });
64
 
65
  const normIntensities = calculateZScoreNormalized(rawActivations, 0.85);
66
+ const { cancel, highlight } = buildPointGroupPaintAttributes(pointsData, groupDimMetrics, viz);
67
 
68
  geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
69
  geometry.setAttribute('intensity', new THREE.Float32BufferAttribute(normIntensities, 1));
70
+ geometry.setAttribute('aCancel', new THREE.Float32BufferAttribute(cancel, 1));
71
+ geometry.setAttribute('aHighlight', new THREE.Float32BufferAttribute(highlight, 1));
72
 
73
  const pointSize = options.pointSize || 14.0;
74
  const material = createDivergentMaterial(pointSize, 1.0, {
75
  anchors,
76
  filterMode,
77
  zeroCoverage,
78
+ highlightColor: viz.oppositeHighlightColor,
79
  });
80
 
81
  const pointsMesh = new THREE.Points(geometry, material);
 
92
  * Full vertex buffer retained for in-situ compare reorder position updates.
93
  */
94
  static createRibbonMesh(points, activations = null, options = {}) {
95
+ const resolved = resolveVizOptions(options);
96
+ const { filterMode, sourceDims } = resolved;
97
  const geometry = new THREE.BufferGeometry().setFromPoints(points);
98
 
99
  let normActivations = null;
 
101
  normActivations = calculateZScoreNormalized(activations, 0.85);
102
  const colors = new Float32Array(points.length * 3);
103
  for (let idx = 0; idx < normActivations.length; idx++) {
104
+ const srcDim = sourceDims ? sourceDims[idx] : idx;
105
+ const col = colorForActivation(normActivations[idx], srcDim, resolved);
106
  colors[idx * 3 + 0] = col.r;
107
  colors[idx * 3 + 1] = col.g;
108
  colors[idx * 3 + 2] = col.b;
 
174
  */
175
  static createWideRibbonMesh(points, activations = null, options = {}) {
176
  if (!points || points.length < 2) return null;
177
+ const resolved = resolveVizOptions(options);
178
+ const { filterMode, sourceDims } = resolved;
179
  const width = options.width ?? 3.0;
180
  const half = width * 0.5;
181
  const n = points.length;
 
211
  positions[iR * 3 + 1] = p.y + side.y;
212
  positions[iR * 3 + 2] = p.z + side.z;
213
 
214
+ const srcDim = sourceDims ? sourceDims[i] : i;
215
+ const col = colorForActivation(norm[i], srcDim, resolved);
216
  for (const vi of [iL, iR]) {
217
  colors[vi * 3] = col.r;
218
  colors[vi * 3 + 1] = col.g;
src/visualizer/groupDimContrast.js ADDED
@@ -0,0 +1,270 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Group-dim shared-noise cancel + opposite-sign highlight (paint only).
3
+ * Means from raw embeddings; cancel/highlight applied in color space after divergent shading.
4
+ * Active only when ≥2 groups (first two distinct groupIds = G1 vs G2).
5
+ */
6
+
7
+ import { countDistinctGroups, listDistinctGroupIds } from './groupStackLayout.js';
8
+ import { hexToRgb01, normalizeConflictCover, highCoverageToUnit } from '../ui/visualizationControlsDefaults.js';
9
+
10
+ /**
11
+ * @typedef {{
12
+ * meanA: number,
13
+ * meanB: number,
14
+ * sameSign: boolean,
15
+ * similarity: number,
16
+ * difference: number,
17
+ * conflictBalance: number,
18
+ * }} DimRelationMetric
19
+ */
20
+
21
+ /**
22
+ * @param {number} v
23
+ * @returns {number} Always ±1 (0 treated as +).
24
+ */
25
+ export function signedUnit(v) {
26
+ return v >= 0 ? 1 : -1;
27
+ }
28
+
29
+ /**
30
+ * Shared-noise similarity: 1 − |a−b|/(|a|+|b|). Exact zeros → 1.
31
+ * @param {number} a
32
+ * @param {number} b
33
+ * @returns {number} [0, 1]
34
+ */
35
+ export function sharedNoiseSimilarity(a, b) {
36
+ const aa = Math.abs(Number(a) || 0);
37
+ const bb = Math.abs(Number(b) || 0);
38
+ const den = aa + bb;
39
+ if (den <= 1e-12) return 1;
40
+ return Math.max(0, Math.min(1, 1 - Math.abs(a - b) / den));
41
+ }
42
+
43
+ /**
44
+ * Relative magnitude difference |a−b|/(|a|+|b|). Exact zeros → 0.
45
+ * Note: for opposite signs this is always ~1 — do not use for gradual cover.
46
+ * @param {number} a
47
+ * @param {number} b
48
+ * @returns {number} [0, 1]
49
+ */
50
+ export function relativeDifference(a, b) {
51
+ const aa = Math.abs(Number(a) || 0);
52
+ const bb = Math.abs(Number(b) || 0);
53
+ const den = aa + bb;
54
+ if (den <= 1e-12) return 0;
55
+ return Math.max(0, Math.min(1, Math.abs(a - b) / den));
56
+ }
57
+
58
+ /**
59
+ * How balanced an opposite-sign pair is: 2·min(|a|,|b|)/(|a|+|b|).
60
+ * 1 = equal magnitude opposition; 0 = one side near zero.
61
+ * @param {number} a
62
+ * @param {number} b
63
+ * @returns {number} [0, 1]
64
+ */
65
+ export function oppositeConflictBalance(a, b) {
66
+ const aa = Math.abs(Number(a) || 0);
67
+ const bb = Math.abs(Number(b) || 0);
68
+ const den = aa + bb;
69
+ if (den <= 1e-12) return 0;
70
+ return Math.max(0, Math.min(1, (2 * Math.min(aa, bb)) / den));
71
+ }
72
+
73
+ /**
74
+ * Linear cover toward black for opposite-sign dims (0–90% → 0–1).
75
+ * Avoids cancelAmountFromMetric(|Δ|) which is always 1 when signs differ.
76
+ * @param {number} coveragePercent - 0–90
77
+ * @returns {number} [0, 1]
78
+ */
79
+ export function oppositeCoverCancel(coveragePercent) {
80
+ const c = normalizeConflictCover(coveragePercent) / 100;
81
+ if (c <= 1e-9) return 0;
82
+ return Math.max(0, Math.min(1, c / 0.9));
83
+ }
84
+
85
+ /**
86
+ * High-metric → black, Zero-coverage style.
87
+ * coverage=0 → never; coverage=0.5 → metric≤0.5 untouched, metric→1 fully cancelled.
88
+ *
89
+ * @param {number} metric01
90
+ * @param {number} coverage01 - [0, 0.9]
91
+ * @returns {number} cancel amount [0, 1]
92
+ */
93
+ export function cancelAmountFromMetric(metric01, coverage01) {
94
+ const m = Math.max(0, Math.min(1, Number(metric01) || 0));
95
+ const c = Math.max(0, Math.min(0.999999, Number(coverage01) || 0));
96
+ if (c <= 1e-12) return 0;
97
+ const floor = 1 - c;
98
+ if (m <= floor) return 0;
99
+ return (m - floor) / Math.max(c, 1e-12);
100
+ }
101
+
102
+ /**
103
+ * @param {Array<{ groupId?: string, embedding?: number[] }|null|undefined>|null|undefined} items
104
+ * @returns {boolean}
105
+ */
106
+ export function hasGroupsForDimContrast(items) {
107
+ return countDistinctGroups(items) >= 2;
108
+ }
109
+
110
+ /**
111
+ * Per-dim G1 vs G2 means (first two distinct groupIds in encounter order).
112
+ * @param {Array<{ groupId?: string, embedding?: number[] }|null|undefined>|null|undefined} items
113
+ * @returns {DimRelationMetric[]}
114
+ */
115
+ export function computeDimRelationMetrics(items) {
116
+ const list = (items || []).filter(
117
+ (it) => it?.groupId && Array.isArray(it.embedding) && it.embedding.length
118
+ );
119
+ const groupIds = listDistinctGroupIds(list);
120
+ if (groupIds.length < 2) return [];
121
+
122
+ const idA = groupIds[0];
123
+ const idB = groupIds[1];
124
+ const dim = list[0].embedding.length;
125
+ if (!list.every((it) => it.embedding.length === dim)) return [];
126
+
127
+ const sumA = new Float64Array(dim);
128
+ const sumB = new Float64Array(dim);
129
+ let countA = 0;
130
+ let countB = 0;
131
+
132
+ for (const it of list) {
133
+ if (it.groupId === idA) {
134
+ for (let d = 0; d < dim; d++) sumA[d] += it.embedding[d];
135
+ countA += 1;
136
+ } else if (it.groupId === idB) {
137
+ for (let d = 0; d < dim; d++) sumB[d] += it.embedding[d];
138
+ countB += 1;
139
+ }
140
+ }
141
+ if (countA < 1 || countB < 1) return [];
142
+
143
+ /** @type {DimRelationMetric[]} */
144
+ const out = new Array(dim);
145
+ for (let d = 0; d < dim; d++) {
146
+ const meanA = sumA[d] / countA;
147
+ const meanB = sumB[d] / countB;
148
+ const sameSign = signedUnit(meanA) === signedUnit(meanB);
149
+ out[d] = {
150
+ meanA,
151
+ meanB,
152
+ sameSign,
153
+ similarity: sharedNoiseSimilarity(meanA, meanB),
154
+ difference: relativeDifference(meanA, meanB),
155
+ conflictBalance: oppositeConflictBalance(meanA, meanB),
156
+ };
157
+ }
158
+ return out;
159
+ }
160
+
161
+ /**
162
+ * Paint weights for one dim given resolved viz settings.
163
+ * @param {DimRelationMetric|null|undefined} metric
164
+ * @param {{
165
+ * sameSignCancelEnabled?: boolean,
166
+ * sameSignCancelCoverage?: number,
167
+ * oppositeHighlightEnabled?: boolean,
168
+ * oppositeHighlightStrength?: number,
169
+ * oppositeCancelCoverage?: number,
170
+ * }} settings
171
+ * @returns {{ cancel: number, highlight: number }}
172
+ */
173
+ export function paintWeightsForDim(metric, settings = {}) {
174
+ if (!metric) return { cancel: 0, highlight: 0 };
175
+
176
+ let cancel = 0;
177
+ let highlight = 0;
178
+
179
+ if (metric.sameSign && settings.sameSignCancelEnabled) {
180
+ const c = highCoverageToUnit(settings.sameSignCancelCoverage ?? 30);
181
+ cancel = Math.max(cancel, cancelAmountFromMetric(metric.similarity, c));
182
+ }
183
+
184
+ if (!metric.sameSign && settings.oppositeHighlightEnabled) {
185
+ const strength = Math.max(0, Math.min(100, Number(settings.oppositeHighlightStrength) || 0)) / 100;
186
+ const balance = metric.conflictBalance != null
187
+ ? metric.conflictBalance
188
+ : oppositeConflictBalance(metric.meanA, metric.meanB);
189
+ highlight = Math.max(0, Math.min(1, strength * balance));
190
+ cancel = Math.max(cancel, oppositeCoverCancel(settings.oppositeCancelCoverage ?? 0));
191
+ }
192
+
193
+ return { cancel, highlight };
194
+ }
195
+
196
+ /**
197
+ * @param {{ r: number, g: number, b: number, alpha?: number }} color
198
+ * @param {{ r: number, g: number, b: number }} target
199
+ * @param {number} k
200
+ */
201
+ function lerpRgb(color, target, k) {
202
+ const t = Math.max(0, Math.min(1, k));
203
+ return {
204
+ r: color.r + (target.r - color.r) * t,
205
+ g: color.g + (target.g - color.g) * t,
206
+ b: color.b + (target.b - color.b) * t,
207
+ alpha: color.alpha,
208
+ };
209
+ }
210
+
211
+ /**
212
+ * Apply group-dim paint on top of a divergent CPU color (Y/geometry untouched).
213
+ *
214
+ * @param {{ r: number, g: number, b: number, alpha?: number }} baseColor
215
+ * @param {DimRelationMetric|null|undefined} metric
216
+ * @param {object} settings - VisualizationSettings-like
217
+ * @param {{ r: number, g: number, b: number }|null} [zeroRgb]
218
+ * @param {{ r: number, g: number, b: number }|null} [highlightRgb]
219
+ * @returns {{ r: number, g: number, b: number, alpha?: number }}
220
+ */
221
+ export function applyGroupDimPaint(baseColor, metric, settings, zeroRgb = null, highlightRgb = null) {
222
+ const weights = paintWeightsForDim(metric, settings);
223
+ if (weights.cancel <= 1e-9 && weights.highlight <= 1e-9) {
224
+ return { ...baseColor };
225
+ }
226
+
227
+ const zero = zeroRgb || { r: 0, g: 0, b: 0 };
228
+ let out = { ...baseColor };
229
+
230
+ if (weights.highlight > 1e-9) {
231
+ const hi = highlightRgb
232
+ || hexToRgb01(settings?.oppositeHighlightColor)
233
+ || { r: 0, g: 229 / 255, b: 1 };
234
+ out = lerpRgb(out, hi, weights.highlight);
235
+ }
236
+ if (weights.cancel > 1e-9) {
237
+ out = lerpRgb(out, zero, weights.cancel);
238
+ if (typeof out.alpha === 'number') {
239
+ out.alpha = Math.max(0.05, out.alpha * (1 - 0.85 * weights.cancel));
240
+ }
241
+ }
242
+ return out;
243
+ }
244
+
245
+ /**
246
+ * Build parallel cancel/highlight attribute arrays for POINTS (length = points).
247
+ *
248
+ * @param {Array<{ meta?: { dim?: number } }>} pointsData
249
+ * @param {DimRelationMetric[]|null|undefined} metrics
250
+ * @param {object} settings
251
+ * @returns {{ cancel: Float32Array, highlight: Float32Array }}
252
+ */
253
+ export function buildPointGroupPaintAttributes(pointsData, metrics, settings) {
254
+ const n = pointsData?.length || 0;
255
+ const cancel = new Float32Array(n);
256
+ const highlight = new Float32Array(n);
257
+ if (!n || !metrics?.length) return { cancel, highlight };
258
+
259
+ const groupFxOn = settings?.sameSignCancelEnabled || settings?.oppositeHighlightEnabled;
260
+ if (!groupFxOn) return { cancel, highlight };
261
+
262
+ for (let i = 0; i < n; i++) {
263
+ const dim = pointsData[i]?.meta?.dim;
264
+ const metric = typeof dim === 'number' ? metrics[dim] : null;
265
+ const w = paintWeightsForDim(metric, settings);
266
+ cancel[i] = w.cancel;
267
+ highlight[i] = w.highlight;
268
+ }
269
+ return { cancel, highlight };
270
+ }
tests/HUD.test.js CHANGED
@@ -101,15 +101,18 @@ describe('HUD activation telemetry', () => {
101
  Object.defineProperty(segment, 'offsetWidth', { get: () => 70 });
102
 
103
  const activation = document.createElement('span');
 
104
  const coords = document.createElement('span');
105
  const token = document.createElement('span');
106
 
107
  center.appendChild(label);
108
  center.appendChild(segment);
 
109
  center.appendChild(activation);
110
 
111
  hud.centerEl = center;
112
  hud.segmentEl = segment;
 
113
  hud.activationEl = activation;
114
  hud.coordsEl = coords;
115
  hud.tokenEl = token;
@@ -127,6 +130,7 @@ describe('HUD activation telemetry', () => {
127
 
128
  expect(activation.textContent).not.toMatch(/0\.0000$/);
129
  expect(activation.textContent).toMatch(/0\.0000421|4\.21e-/i);
 
130
  expect(token.textContent).toBe('alpha');
131
  expect(segment.textContent).toBe('COMPARE');
132
  });
@@ -134,6 +138,7 @@ describe('HUD activation telemetry', () => {
134
  it('clears to -- when hover leaves', () => {
135
  const hud = new HUD(root);
136
  hud.activationEl = document.createElement('span');
 
137
  hud.coordsEl = document.createElement('span');
138
  hud.segmentEl = document.createElement('span');
139
  hud.tokenEl = document.createElement('span');
@@ -142,6 +147,7 @@ describe('HUD activation telemetry', () => {
142
 
143
  hud.updateTelemetry(null);
144
  expect(hud.activationEl.textContent).toBe('ACTIVATION: --');
 
145
  expect(hud.segmentEl.textContent).toBe('NEUTRAL SPACE');
146
  });
147
  });
 
101
  Object.defineProperty(segment, 'offsetWidth', { get: () => 70 });
102
 
103
  const activation = document.createElement('span');
104
+ const dim = document.createElement('span');
105
  const coords = document.createElement('span');
106
  const token = document.createElement('span');
107
 
108
  center.appendChild(label);
109
  center.appendChild(segment);
110
+ center.appendChild(dim);
111
  center.appendChild(activation);
112
 
113
  hud.centerEl = center;
114
  hud.segmentEl = segment;
115
+ hud.dimEl = dim;
116
  hud.activationEl = activation;
117
  hud.coordsEl = coords;
118
  hud.tokenEl = token;
 
130
 
131
  expect(activation.textContent).not.toMatch(/0\.0000$/);
132
  expect(activation.textContent).toMatch(/0\.0000421|4\.21e-/i);
133
+ expect(dim.textContent).toBe('DIM: 3');
134
  expect(token.textContent).toBe('alpha');
135
  expect(segment.textContent).toBe('COMPARE');
136
  });
 
138
  it('clears to -- when hover leaves', () => {
139
  const hud = new HUD(root);
140
  hud.activationEl = document.createElement('span');
141
+ hud.dimEl = document.createElement('span');
142
  hud.coordsEl = document.createElement('span');
143
  hud.segmentEl = document.createElement('span');
144
  hud.tokenEl = document.createElement('span');
 
147
 
148
  hud.updateTelemetry(null);
149
  expect(hud.activationEl.textContent).toBe('ACTIVATION: --');
150
+ expect(hud.dimEl.textContent).toBe('DIM: --');
151
  expect(hud.segmentEl.textContent).toBe('NEUTRAL SPACE');
152
  });
153
  });
tests/fieldInfo.test.js CHANGED
@@ -14,7 +14,9 @@ describe('FIELD_INFO catalog', () => {
14
  'compareTokens', 'dimSort',
15
  'saeToggle', 'saeHidden', 'saeK', 'saeEpochs', 'saeLr', 'saeBatch',
16
  'spacingX', 'vectorDistY', 'amplitudeY', 'lengthZ', 'thickness',
17
- 'vizFilter', 'colorPos', 'colorZero', 'colorNeg', 'zeroCoverage', 'labelsToggle',
 
 
18
  ];
19
  for (const key of required) {
20
  expect(FIELD_INFO[key], key).toBeTruthy();
 
14
  'compareTokens', 'dimSort',
15
  'saeToggle', 'saeHidden', 'saeK', 'saeEpochs', 'saeLr', 'saeBatch',
16
  'spacingX', 'vectorDistY', 'amplitudeY', 'lengthZ', 'thickness',
17
+ 'vizFilter', 'colorPos', 'colorZero', 'colorNeg', 'zeroCoverage', 'zeroCoverageAmount', 'labelsToggle',
18
+ 'groupContrast', 'sameSignCancel', 'sameSignCoverage',
19
+ 'oppositeHighlight', 'oppositeColor', 'oppositeStrength', 'oppositeCancel',
20
  ];
21
  for (const key of required) {
22
  expect(FIELD_INFO[key], key).toBeTruthy();
tests/groupDimContrast.test.js ADDED
@@ -0,0 +1,185 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ signedUnit,
4
+ sharedNoiseSimilarity,
5
+ relativeDifference,
6
+ oppositeConflictBalance,
7
+ cancelAmountFromMetric,
8
+ hasGroupsForDimContrast,
9
+ computeDimRelationMetrics,
10
+ paintWeightsForDim,
11
+ applyGroupDimPaint,
12
+ buildPointGroupPaintAttributes,
13
+ } from '../src/visualizer/groupDimContrast.js';
14
+
15
+ describe('groupDimContrast math', () => {
16
+ it('signedUnit treats 0 as positive', () => {
17
+ expect(signedUnit(0)).toBe(1);
18
+ expect(signedUnit(0.002)).toBe(1);
19
+ expect(signedUnit(-0.018)).toBe(-1);
20
+ });
21
+
22
+ it('sharedNoiseSimilarity is formula (b)', () => {
23
+ // a=0.002, b=0.018 → 1 - 0.016/0.020 = 0.2
24
+ expect(sharedNoiseSimilarity(0.002, 0.018)).toBeCloseTo(0.2, 5);
25
+ expect(sharedNoiseSimilarity(0.1, 0.1)).toBeCloseTo(1, 5);
26
+ expect(sharedNoiseSimilarity(0, 0)).toBe(1);
27
+ });
28
+
29
+ it('relativeDifference complements similarity on same magnitudes', () => {
30
+ expect(relativeDifference(0.002, 0.018)).toBeCloseTo(0.8, 5);
31
+ expect(relativeDifference(0.1, -0.1)).toBeCloseTo(1, 5);
32
+ });
33
+
34
+ it('cancelAmountFromMetric mirrors zero-coverage on high metric', () => {
35
+ expect(cancelAmountFromMetric(0.4, 0)).toBe(0);
36
+ expect(cancelAmountFromMetric(0.4, 0.5)).toBe(0); // floor=0.5
37
+ expect(cancelAmountFromMetric(0.75, 0.5)).toBeCloseTo(0.5, 5);
38
+ expect(cancelAmountFromMetric(1, 0.5)).toBeCloseTo(1, 5);
39
+ });
40
+ });
41
+
42
+ describe('computeDimRelationMetrics', () => {
43
+ it('requires ≥2 groups', () => {
44
+ expect(hasGroupsForDimContrast([{ groupId: 'G1', embedding: [1] }])).toBe(false);
45
+ expect(computeDimRelationMetrics([{ groupId: 'G1', embedding: [1] }])).toEqual([]);
46
+ });
47
+
48
+ it('uses group means and classifies same vs opposite sign', () => {
49
+ const items = [
50
+ { groupId: 'G1', embedding: [0.002, 0.1, -0.05] },
51
+ { groupId: 'G1', embedding: [0.002, 0.1, -0.05] },
52
+ { groupId: 'G2', embedding: [0.018, -0.1, -0.05] },
53
+ { groupId: 'G2', embedding: [0.018, -0.1, -0.05] },
54
+ ];
55
+ const m = computeDimRelationMetrics(items);
56
+ expect(m).toHaveLength(3);
57
+ expect(m[0].sameSign).toBe(true);
58
+ expect(m[0].similarity).toBeCloseTo(0.2, 5);
59
+ expect(m[1].sameSign).toBe(false);
60
+ expect(m[1].difference).toBeCloseTo(1, 5);
61
+ expect(m[2].sameSign).toBe(true);
62
+ expect(m[2].similarity).toBeCloseTo(1, 5);
63
+ });
64
+ });
65
+
66
+ describe('paintWeightsForDim / applyGroupDimPaint', () => {
67
+ it('same-sign cancel only when enabled', () => {
68
+ const metric = {
69
+ meanA: 0.1,
70
+ meanB: 0.1,
71
+ sameSign: true,
72
+ similarity: 1,
73
+ difference: 0,
74
+ };
75
+ expect(paintWeightsForDim(metric, { sameSignCancelEnabled: false, sameSignCancelCoverage: 90 })).toEqual({
76
+ cancel: 0,
77
+ highlight: 0,
78
+ });
79
+ const on = paintWeightsForDim(metric, { sameSignCancelEnabled: true, sameSignCancelCoverage: 90 });
80
+ expect(on.cancel).toBeGreaterThan(0.9);
81
+ expect(on.highlight).toBe(0);
82
+ });
83
+
84
+ it('opposite highlight scales by strength × conflict balance', () => {
85
+ const metric = {
86
+ meanA: 0.1,
87
+ meanB: -0.1,
88
+ sameSign: false,
89
+ similarity: 0,
90
+ difference: 1,
91
+ conflictBalance: 1,
92
+ };
93
+ const w = paintWeightsForDim(metric, {
94
+ oppositeHighlightEnabled: true,
95
+ oppositeHighlightStrength: 50,
96
+ oppositeCancelCoverage: 0,
97
+ });
98
+ expect(w.highlight).toBeCloseTo(0.5, 5);
99
+ expect(w.cancel).toBe(0);
100
+ });
101
+
102
+ it('conflict cover fades opposite dims linearly (not all-or-nothing)', () => {
103
+ const metric = {
104
+ meanA: 0.1,
105
+ meanB: -0.1,
106
+ sameSign: false,
107
+ similarity: 0,
108
+ difference: 1,
109
+ conflictBalance: 1,
110
+ };
111
+ const at0 = paintWeightsForDim(metric, {
112
+ oppositeHighlightEnabled: true,
113
+ oppositeHighlightStrength: 100,
114
+ oppositeCancelCoverage: 0,
115
+ });
116
+ expect(at0.cancel).toBe(0);
117
+ expect(at0.highlight).toBeCloseTo(1, 5);
118
+
119
+ const at45 = paintWeightsForDim(metric, {
120
+ oppositeHighlightEnabled: true,
121
+ oppositeHighlightStrength: 100,
122
+ oppositeCancelCoverage: 45,
123
+ });
124
+ expect(at45.cancel).toBeCloseTo(0.5, 5);
125
+ expect(at45.highlight).toBeCloseTo(1, 5);
126
+
127
+ const at90 = paintWeightsForDim(metric, {
128
+ oppositeHighlightEnabled: true,
129
+ oppositeHighlightStrength: 100,
130
+ oppositeCancelCoverage: 90,
131
+ });
132
+ expect(at90.cancel).toBeCloseTo(1, 5);
133
+ });
134
+
135
+ it('oppositeConflictBalance is 1 for equal mags, lower when unbalanced', () => {
136
+ expect(oppositeConflictBalance(0.1, -0.1)).toBeCloseTo(1, 5);
137
+ expect(oppositeConflictBalance(0.1, -0.01)).toBeCloseTo(2 * 0.01 / 0.11, 5);
138
+ });
139
+
140
+ it('replaces toward highlight then blackens', () => {
141
+ const base = { r: 1, g: 1, b: 0, alpha: 1 };
142
+ const metric = {
143
+ meanA: 1,
144
+ meanB: -1,
145
+ sameSign: false,
146
+ similarity: 0,
147
+ difference: 1,
148
+ };
149
+ const painted = applyGroupDimPaint(
150
+ base,
151
+ metric,
152
+ {
153
+ oppositeHighlightEnabled: true,
154
+ oppositeHighlightStrength: 100,
155
+ oppositeCancelCoverage: 0,
156
+ oppositeHighlightColor: '#00E5FF',
157
+ },
158
+ { r: 0, g: 0, b: 0 },
159
+ { r: 0, g: 229 / 255, b: 1 }
160
+ );
161
+ expect(painted.r).toBeCloseTo(0, 5);
162
+ expect(painted.b).toBeCloseTo(1, 5);
163
+ });
164
+
165
+ it('buildPointGroupPaintAttributes uses meta.dim', () => {
166
+ const metrics = computeDimRelationMetrics([
167
+ { groupId: 'G1', embedding: [0.1, -0.1] },
168
+ { groupId: 'G2', embedding: [0.1, 0.1] },
169
+ ]);
170
+ const points = [
171
+ { meta: { dim: 0 } },
172
+ { meta: { dim: 1 } },
173
+ ];
174
+ const { cancel, highlight } = buildPointGroupPaintAttributes(points, metrics, {
175
+ sameSignCancelEnabled: true,
176
+ sameSignCancelCoverage: 90,
177
+ oppositeHighlightEnabled: true,
178
+ oppositeHighlightStrength: 100,
179
+ oppositeCancelCoverage: 0,
180
+ });
181
+ expect(cancel[0]).toBeGreaterThan(0.5); // same-sign dim0
182
+ expect(highlight[0]).toBe(0);
183
+ expect(highlight[1]).toBeGreaterThan(0.5); // opposite dim1
184
+ });
185
+ });
tests/hoverTelemetry.test.js CHANGED
@@ -32,6 +32,23 @@ describe('resolveHoverTelemetry', () => {
32
  expect(t.point).toEqual({ x: 10, y: 2, z: 0 });
33
  });
34
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  it('does not fall back to 0 when pointsData has a real value (regression)', () => {
36
  // Legacy HUD read userData.val / data.activation — both missing on Points meshes
37
  const data = {
 
32
  expect(t.point).toEqual({ x: 10, y: 2, z: 0 });
33
  });
34
 
35
+ it('maps ribbon actIdx through sourceDims when present', () => {
36
+ const data = {
37
+ index: 4,
38
+ face: { a: 6, b: 7, c: 8 },
39
+ userData: {
40
+ kind: 'wideRibbon',
41
+ activations: [0.1, 0.2, 0.3, 0.4],
42
+ sourceDims: [10, 20, 30, 40],
43
+ token: 'dog',
44
+ type: 'compare',
45
+ },
46
+ };
47
+ const t = resolveHoverTelemetry(data);
48
+ expect(t.activation).toBe(0.4);
49
+ expect(t.dim).toBe(40);
50
+ });
51
+
52
  it('does not fall back to 0 when pointsData has a real value (regression)', () => {
53
  // Legacy HUD read userData.val / data.activation — both missing on Points meshes
54
  const data = {
tests/v25CanvasWire.test.js CHANGED
@@ -40,6 +40,7 @@ describe('v25 canvasHost helpers', () => {
40
  const cleared = formatHoverForFooter(null);
41
  expect(cleared.coords).toContain('--');
42
  expect(cleared.segment).toBe('NEUTRAL SPACE');
 
43
  expect(cleared.activation).toMatch(/ACTIVATION:\s*--/);
44
  expect(cleared.token).toBe('NONE');
45
  });
@@ -52,13 +53,14 @@ describe('v25 canvasHost helpers', () => {
52
  pointsData: [
53
  {
54
  activation: 0.55,
55
- meta: { type: 'res', token: 'queen', dim: 0 },
56
  },
57
  ],
58
  },
59
  });
60
  expect(formatted.coords).toBe('X: 12 | Y: -3 | Z: 91');
61
  expect(formatted.segment).toBe('RES');
 
62
  expect(formatted.token).toBe('queen');
63
  expect(formatted.activation).toMatch(/ACTIVATION:/);
64
  expect(formatted.activation).toContain('0.55');
 
40
  const cleared = formatHoverForFooter(null);
41
  expect(cleared.coords).toContain('--');
42
  expect(cleared.segment).toBe('NEUTRAL SPACE');
43
+ expect(cleared.dim).toBe('DIM: --');
44
  expect(cleared.activation).toMatch(/ACTIVATION:\s*--/);
45
  expect(cleared.token).toBe('NONE');
46
  });
 
53
  pointsData: [
54
  {
55
  activation: 0.55,
56
+ meta: { type: 'res', token: 'queen', dim: 7 },
57
  },
58
  ],
59
  },
60
  });
61
  expect(formatted.coords).toBe('X: 12 | Y: -3 | Z: 91');
62
  expect(formatted.segment).toBe('RES');
63
+ expect(formatted.dim).toBe('DIM: 7');
64
  expect(formatted.token).toBe('queen');
65
  expect(formatted.activation).toMatch(/ACTIVATION:/);
66
  expect(formatted.activation).toContain('0.55');
tests/v25RightHudChrome.test.js CHANGED
@@ -41,9 +41,11 @@ describe('v25 right + HUD chrome', () => {
41
  it('footer HUD exposes coords / telemetry / token placeholders', () => {
42
  const html = footerHudMarkup();
43
  expect(html).toContain('id="lab-hud-coords"');
 
44
  expect(html).toContain('id="lab-hud-activation"');
45
  expect(html).toContain('id="lab-hud-token"');
46
  expect(html).toContain(HUD_PLACEHOLDERS.coords);
 
47
  expect(html).toContain('COORDS');
48
  });
49
  });
 
41
  it('footer HUD exposes coords / telemetry / token placeholders', () => {
42
  const html = footerHudMarkup();
43
  expect(html).toContain('id="lab-hud-coords"');
44
+ expect(html).toContain('id="lab-hud-dim"');
45
  expect(html).toContain('id="lab-hud-activation"');
46
  expect(html).toContain('id="lab-hud-token"');
47
  expect(html).toContain(HUD_PLACEHOLDERS.coords);
48
+ expect(html).toContain(HUD_PLACEHOLDERS.dim);
49
  expect(html).toContain('COORDS');
50
  });
51
  });
tests/visualizationControls.test.js CHANGED
@@ -14,6 +14,14 @@ import {
14
  anchorsFromSettings,
15
  remapAbsTWithZeroCoverage,
16
  normalizeZeroCoverage,
 
 
 
 
 
 
 
 
17
  } from '../src/ui/visualizationControlsDefaults.js';
18
  import {
19
  visualizationControlsMarkup,
@@ -53,8 +61,15 @@ describe('visualizationControlsDefaults', () => {
53
  colorPositive: '#FFE600',
54
  colorZero: '#000000',
55
  colorNegative: '#9900E6',
56
- zeroCoverage: 0,
 
57
  labelsVisible: true,
 
 
 
 
 
 
58
  });
59
  });
60
 
@@ -149,12 +164,18 @@ describe('Visualization panel collapse tab', () => {
149
  expect(html).toContain('aria-expanded="true"');
150
  expect(html).toContain('▼');
151
  expect(html).toContain('viz-zero-coverage-slider');
 
152
  expect(html).toContain('Zero coverage');
 
 
 
 
153
  expect(html).toContain('viz-labels-toggle');
154
  expect(html).toContain('Hide labels');
155
  expect(html).toContain('field-info-btn');
156
  expect(html).toContain('data-field-info="Which signs show."');
157
- expect(html).toContain('data-field-info="% held at zero."');
 
158
  });
159
 
160
  it('collapse tab is a sibling of viz-panel-body (outside card chrome)', () => {
@@ -365,9 +386,34 @@ describe('remapAbsTWithZeroCoverage', () => {
365
  expect(remapAbsTWithZeroCoverage(1, 0.5)).toBeCloseTo(1, 5);
366
  });
367
 
368
- it('normalizeZeroCoverage clamps', () => {
 
 
 
 
 
 
369
  expect(normalizeZeroCoverage(150)).toBe(90);
370
  expect(normalizeZeroCoverage(-3)).toBe(0);
371
  expect(normalizeZeroCoverage('40')).toBe(40);
372
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
373
  });
 
14
  anchorsFromSettings,
15
  remapAbsTWithZeroCoverage,
16
  normalizeZeroCoverage,
17
+ normalizeHighCoverage,
18
+ highCoverageFromSlider,
19
+ highCoverageToSlider,
20
+ formatHighCoverage,
21
+ effectiveZeroCoveragePercent,
22
+ HIGH_COVERAGE_MIN,
23
+ HIGH_COVERAGE_MAX,
24
+ HIGH_COVERAGE_SLIDER_MAX,
25
  } from '../src/ui/visualizationControlsDefaults.js';
26
  import {
27
  visualizationControlsMarkup,
 
61
  colorPositive: '#FFE600',
62
  colorZero: '#000000',
63
  colorNegative: '#9900E6',
64
+ zeroCoverageEnabled: false,
65
+ zeroCoverage: 30,
66
  labelsVisible: true,
67
+ sameSignCancelEnabled: false,
68
+ sameSignCancelCoverage: 30,
69
+ oppositeHighlightEnabled: false,
70
+ oppositeHighlightColor: '#00E5FF',
71
+ oppositeHighlightStrength: 70,
72
+ oppositeCancelCoverage: 0,
73
  });
74
  });
75
 
 
164
  expect(html).toContain('aria-expanded="true"');
165
  expect(html).toContain('▼');
166
  expect(html).toContain('viz-zero-coverage-slider');
167
+ expect(html).toContain('viz-zero-coverage-enabled');
168
  expect(html).toContain('Zero coverage');
169
+ expect(html).toContain('viz-group-contrast');
170
+ expect(html).toContain('Group contrast');
171
+ expect(html).toContain('viz-same-sign-enabled');
172
+ expect(html).toContain('viz-opposite-enabled');
173
  expect(html).toContain('viz-labels-toggle');
174
  expect(html).toContain('Hide labels');
175
  expect(html).toContain('field-info-btn');
176
  expect(html).toContain('data-field-info="Which signs show."');
177
+ expect(html).toContain('data-field-info="Hold range at zero."');
178
+ expect(html).toContain('data-field-info="Groups only (G1↔G2)."');
179
  });
180
 
181
  it('collapse tab is a sibling of viz-panel-body (outside card chrome)', () => {
 
386
  expect(remapAbsTWithZeroCoverage(1, 0.5)).toBeCloseTo(1, 5);
387
  });
388
 
389
+ it('supports near-100% coverage', () => {
390
+ const c = HIGH_COVERAGE_MAX / 100;
391
+ expect(remapAbsTWithZeroCoverage(0.99, c)).toBe(0);
392
+ expect(remapAbsTWithZeroCoverage(1, c)).toBeCloseTo(1, 5);
393
+ });
394
+
395
+ it('normalizeZeroCoverage clamps conflict cover 0–90', () => {
396
  expect(normalizeZeroCoverage(150)).toBe(90);
397
  expect(normalizeZeroCoverage(-3)).toBe(0);
398
  expect(normalizeZeroCoverage('40')).toBe(40);
399
  });
400
+
401
+ it('normalizeHighCoverage clamps 30…99.9999', () => {
402
+ expect(normalizeHighCoverage(10)).toBe(HIGH_COVERAGE_MIN);
403
+ expect(normalizeHighCoverage(100)).toBe(HIGH_COVERAGE_MAX);
404
+ expect(normalizeHighCoverage(50.12345)).toBeCloseTo(50.1235, 4);
405
+ });
406
+
407
+ it('highCoverage slider maps endpoints', () => {
408
+ expect(highCoverageFromSlider(0)).toBe(HIGH_COVERAGE_MIN);
409
+ expect(highCoverageFromSlider(HIGH_COVERAGE_SLIDER_MAX)).toBe(HIGH_COVERAGE_MAX);
410
+ expect(highCoverageToSlider(HIGH_COVERAGE_MIN)).toBe(0);
411
+ expect(highCoverageToSlider(HIGH_COVERAGE_MAX)).toBe(HIGH_COVERAGE_SLIDER_MAX);
412
+ expect(formatHighCoverage(HIGH_COVERAGE_MAX)).toBe('99.9999%');
413
+ });
414
+
415
+ it('effectiveZeroCoveragePercent is 0 when Off', () => {
416
+ expect(effectiveZeroCoveragePercent({ zeroCoverageEnabled: false, zeroCoverage: 80 })).toBe(0);
417
+ expect(effectiveZeroCoveragePercent({ zeroCoverageEnabled: true, zeroCoverage: 80 })).toBe(80);
418
+ });
419
  });