Spaces:
Sleeping
Sleeping
feat(viz): group contrast paint, high-range zero coverage, HUD dim
Browse filesAdd 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 +4 -1
- CHANGELOG.md +1 -0
- CONTEXT.md +7 -1
- src/main.js +15 -0
- src/style.css +92 -25
- src/ui/HUD.js +28 -3
- src/ui/VisualizationControls.js +245 -12
- src/ui/fieldInfo.js +9 -1
- src/ui/hoverTelemetry.js +4 -1
- src/ui/visualizationControlsDefaults.js +217 -20
- src/v25/canvasHost.js +4 -1
- src/v25/main.js +1 -0
- src/v25/ui/footerHud.js +5 -1
- src/visualizer/DivergentShading.js +35 -4
- src/visualizer/Instancer.js +19 -2
- src/visualizer/MeshFactory.js +37 -7
- src/visualizer/groupDimContrast.js +270 -0
- tests/HUD.test.js +6 -0
- tests/fieldInfo.test.js +3 -1
- tests/groupDimContrast.test.js +185 -0
- tests/hoverTelemetry.test.js +17 -0
- tests/v25CanvasWire.test.js +3 -1
- tests/v25RightHudChrome.test.js +2 -0
- tests/visualizationControls.test.js +49 -3
.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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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:
|
| 1233 |
-
--viz-tab-size:
|
| 1234 |
-
--viz-tab-h:
|
| 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:
|
| 1280 |
-
padding: 10px
|
| 1281 |
width: var(--viz-width);
|
| 1282 |
max-width: var(--viz-width);
|
| 1283 |
-
max-height: min(
|
| 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.
|
| 1337 |
-
padding-bottom:
|
| 1338 |
margin: 0;
|
| 1339 |
}
|
| 1340 |
|
| 1341 |
#visualization-controls-container .viz-filter-group {
|
| 1342 |
-
gap:
|
| 1343 |
}
|
| 1344 |
|
| 1345 |
#visualization-controls-container .viz-filter-label {
|
| 1346 |
-
font-size: 0.
|
| 1347 |
}
|
| 1348 |
|
| 1349 |
#visualization-controls-container .viz-colors-section {
|
| 1350 |
-
gap:
|
| 1351 |
}
|
| 1352 |
|
| 1353 |
#visualization-controls-container .viz-colors-title {
|
| 1354 |
-
font-size: 0.
|
| 1355 |
}
|
| 1356 |
|
| 1357 |
#visualization-controls-container .viz-color-row {
|
| 1358 |
-
gap:
|
| 1359 |
}
|
| 1360 |
|
| 1361 |
#visualization-controls-container .viz-color-row label {
|
| 1362 |
-
font-size: 0.
|
| 1363 |
-
min-width: 2.
|
| 1364 |
}
|
| 1365 |
|
| 1366 |
#visualization-controls-container .viz-color-hex {
|
| 1367 |
-
font-size: 0.
|
| 1368 |
-
padding:
|
| 1369 |
}
|
| 1370 |
|
| 1371 |
#visualization-controls-container .viz-coverage-row {
|
| 1372 |
-
gap:
|
| 1373 |
}
|
| 1374 |
|
| 1375 |
#visualization-controls-container .viz-labels-toggle,
|
| 1376 |
#visualization-controls-container .viz-reset-btn {
|
| 1377 |
-
padding:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 2036 |
-
--viz-tab-size:
|
| 2037 |
-
--viz-tab-h:
|
| 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
|
| 2060 |
-
gap:
|
| 2061 |
-
max-height: min(
|
| 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
|
| 153 |
-
const
|
|
|
|
| 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.
|
| 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 |
-
|
|
|
|
| 12 |
resolveVisualizationSettings,
|
| 13 |
saveVisualizationSettings,
|
| 14 |
resetVisualizationSettings,
|
| 15 |
VIZ_STORAGE_PREFIX,
|
| 16 |
-
|
| 17 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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-
|
| 154 |
-
<
|
| 155 |
-
<
|
| 156 |
-
<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 =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 =
|
| 316 |
config.zeroCoverage = next;
|
| 317 |
-
if (covLabel) covLabel.textContent =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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: '
|
|
|
|
| 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:
|
| 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 |
-
|
|
|
|
| 36 |
|
| 37 |
-
|
| 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 |
-
|
| 44 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
|
| 46 |
/** @type {VisualizationSettings} */
|
| 47 |
export const DEFAULT_VISUALIZATION_SETTINGS = Object.freeze({
|
| 48 |
vizFilterMode: DEFAULT_VIZ_FILTER,
|
| 49 |
...DEFAULT_VIZ_COLORS,
|
| 50 |
-
|
|
|
|
| 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 |
-
*
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 121 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
| 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(
|
| 144 |
-
if (c <= 1e-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
src.zeroCoverage !== undefined && src.zeroCoverage !== null
|
| 162 |
? src.zeroCoverage
|
| 163 |
-
:
|
| 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.
|
| 186 |
float k = absT;
|
| 187 |
-
if (c > 1e-
|
| 188 |
-
k = absT <= c ? 0.0 : (absT - c) / max(1.0 - c, 1e-
|
| 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 =
|
| 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 =
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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
|
|
|
|
| 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
|
|
|
|
| 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
|
|
|
|
| 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
|
|
|
|
| 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:
|
| 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 |
-
|
|
|
|
| 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="
|
|
|
|
| 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('
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
});
|