hbauzan Cursor commited on
Commit
4302cd7
·
1 Parent(s): 01967d3

fix(hud): resolve hover activation and adaptive precision

Browse files

HUD always showed 0.0000 because it ignored pointsData[index]; read the real value and format up to 32 decimals within the available slot (mobile-safe).

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

.agents/skills/dev-protocol/lessons-learned.md CHANGED
@@ -246,6 +246,11 @@ Portable findings from VectorLab 3D `v2.1.0` — apply if the older app shares A
246
 
247
  **Modules shipped (reference):** `saeFilterBridge.js`, `groupStackLayout.js`, `dimContrastSort.js`; wiring in Compare panel + Instancer layout. Diagnosis: `scripts/diagnose_group_separation.py`. Roadmap: `roadmap/compare-group-contrast-viz.md`.
248
 
 
 
 
 
 
249
  ---
250
 
251
  ## 5. Protocolo de Mantenimiento de Lecciones Aprendidas
 
246
 
247
  **Modules shipped (reference):** `saeFilterBridge.js`, `groupStackLayout.js`, `dimContrastSort.js`; wiring in Compare panel + Instancer layout. Diagnosis: `scripts/diagnose_group_separation.py`. Roadmap: `roadmap/compare-group-contrast-viz.md`.
248
 
249
+ ### 4.12. Bottom HUD hover activation (POINTS / RIBBONS)
250
+ - **Problema**: `ACTIVATION` siempre `0.0000` — `Interaction` pasaba `userData` del mesh (`{ pointsData }`), y el HUD leía `userData.val` / `data.activation` (inexistentes).
251
+ - **Solución Obligatoria**: Resolver vía `resolveHoverTelemetry` → `pointsData[index].activation` (POINTS) o `userData.activations` + face/`index` (RIBBONS / continuity). Formatear con `formatActivationValue` (hasta 32 decimales, shrink + scientific si el slot es angosto; label `ACT:` en mobile). `title` tooltip con valor completo.
252
+ - **Invariante**: telemetría de hover ≠ normalización de color; mostrar valor **crudo** del embedding/activación en ese índice.
253
+
254
  ---
255
 
256
  ## 5. Protocolo de Mantenimiento de Lecciones Aprendidas
CHANGELOG.md CHANGED
@@ -2,6 +2,11 @@
2
 
3
  All notable changes to VectorLab 3D will be documented in this file.
4
 
 
 
 
 
 
5
  ## [2.1.0] - 2026-08-02
6
 
7
  ### Added
 
2
 
3
  All notable changes to VectorLab 3D will be documented in this file.
4
 
5
+ ## [Unreleased]
6
+
7
+ ### Fixed
8
+ - **HUD ACTIVATION stuck at 0.0000**: resolve hover from `pointsData[index]` / ribbon `activations`; adaptive precision up to 32 decimals (shrink / scientific / `ACT:` on narrow mobile slots).
9
+
10
  ## [2.1.0] - 2026-08-02
11
 
12
  ### Added
src/engine/Interaction.js CHANGED
@@ -60,6 +60,7 @@ export class Interaction {
60
  object: hit.object,
61
  index: index,
62
  point: hit.point,
 
63
  userData: hit.object.userData
64
  });
65
  }
 
60
  object: hit.object,
61
  index: index,
62
  point: hit.point,
63
+ face: hit.face || null,
64
  userData: hit.object.userData
65
  });
66
  }
src/style.css CHANGED
@@ -790,12 +790,24 @@ canvas {
790
  display: flex;
791
  align-items: center;
792
  gap: 8px;
 
 
 
 
 
 
 
 
 
 
 
793
  }
794
 
795
  .hud-label {
796
  color: var(--text-muted);
797
  font-size: 0.7rem;
798
  font-weight: 700;
 
799
  }
800
 
801
  .hud-val { font-family: monospace; font-weight: 600; color: var(--text-main); }
@@ -806,10 +818,30 @@ canvas {
806
  border-radius: 4px;
807
  font-size: 0.7rem;
808
  font-weight: 700;
 
 
 
 
 
809
  }
810
 
811
- .hud-highlight { font-family: monospace; font-weight: 700; }
812
- .hud-token-text { font-weight: 700; color: var(--accent-gold); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
813
 
814
  /* Camera pose overlay (VITE_SHOW_CAM_POSE=true) — readable POS/ROT for default-view capture */
815
  #camera-pose-debug {
@@ -1654,7 +1686,29 @@ canvas {
1654
  padding: 8px 12px;
1655
  font-size: 0.7rem;
1656
  flex-wrap: wrap;
1657
- gap: 4px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1658
  }
1659
 
1660
  #axis-gizmo-container {
 
790
  display: flex;
791
  align-items: center;
792
  gap: 8px;
793
+ min-width: 0;
794
+ }
795
+
796
+ .hud-center {
797
+ flex: 1 1 auto;
798
+ justify-content: center;
799
+ overflow: hidden;
800
+ }
801
+
802
+ .hud-left, .hud-right {
803
+ flex: 0 1 auto;
804
  }
805
 
806
  .hud-label {
807
  color: var(--text-muted);
808
  font-size: 0.7rem;
809
  font-weight: 700;
810
+ flex-shrink: 0;
811
  }
812
 
813
  .hud-val { font-family: monospace; font-weight: 600; color: var(--text-main); }
 
818
  border-radius: 4px;
819
  font-size: 0.7rem;
820
  font-weight: 700;
821
+ flex-shrink: 1;
822
+ overflow: hidden;
823
+ text-overflow: ellipsis;
824
+ white-space: nowrap;
825
+ max-width: 28vw;
826
  }
827
 
828
+ .hud-highlight {
829
+ font-family: monospace;
830
+ font-weight: 700;
831
+ white-space: nowrap;
832
+ overflow: hidden;
833
+ text-overflow: clip;
834
+ min-width: 0;
835
+ max-width: 100%;
836
+ }
837
+ .hud-token-text {
838
+ font-weight: 700;
839
+ color: var(--accent-gold);
840
+ overflow: hidden;
841
+ text-overflow: ellipsis;
842
+ white-space: nowrap;
843
+ max-width: 22vw;
844
+ }
845
 
846
  /* Camera pose overlay (VITE_SHOW_CAM_POSE=true) — readable POS/ROT for default-view capture */
847
  #camera-pose-debug {
 
1686
  padding: 8px 12px;
1687
  font-size: 0.7rem;
1688
  flex-wrap: wrap;
1689
+ gap: 4px 8px;
1690
+ }
1691
+
1692
+ .hud-left, .hud-right {
1693
+ flex: 1 1 40%;
1694
+ }
1695
+
1696
+ .hud-center {
1697
+ flex: 1 1 100%;
1698
+ order: 3;
1699
+ justify-content: flex-start;
1700
+ }
1701
+
1702
+ .hud-badge {
1703
+ max-width: 36vw;
1704
+ }
1705
+
1706
+ .hud-token-text {
1707
+ max-width: 40vw;
1708
+ }
1709
+
1710
+ .hud-highlight {
1711
+ font-size: 0.72rem;
1712
  }
1713
 
1714
  #axis-gizmo-container {
src/ui/HUD.js CHANGED
@@ -3,6 +3,12 @@
3
  * Displays real-time X, Y, Z coordinates, activation values, and token text.
4
  * Optional camera pose overlay (VITE_SHOW_CAM_POSE=true).
5
  */
 
 
 
 
 
 
6
  export class HUD {
7
  /**
8
  * @param {HTMLElement} [containerElement]
@@ -37,6 +43,19 @@ export class HUD {
37
  this.segmentEl = this.element.querySelector('#hud-segment');
38
  this.activationEl = this.element.querySelector('#hud-activation');
39
  this.tokenEl = this.element.querySelector('#hud-token');
 
 
 
 
 
 
 
 
 
 
 
 
 
40
 
41
  this.cameraDebugEl = null;
42
  this.camPosEl = null;
@@ -86,27 +105,64 @@ export class HUD {
86
  }
87
 
88
  updateTelemetry(data) {
89
- if (!data) {
 
 
90
  this.coordsEl.textContent = 'X: -- | Y: -- | Z: --';
91
  this.segmentEl.textContent = 'NEUTRAL SPACE';
92
- this.activationEl.textContent = 'ACTIVATION: 0.000';
93
  this.activationEl.style.color = '#888888';
 
94
  this.tokenEl.textContent = 'NONE';
95
  return;
96
  }
97
 
98
- const pos = data.point || { x: 0, y: 0, z: 0 };
99
  this.coordsEl.textContent = `X: ${Math.round(pos.x)} | Y: ${Math.round(pos.y)} | Z: ${Math.round(pos.z)}`;
100
 
101
- const val = data.userData?.val !== undefined ? data.userData.val : (data.activation || 0);
102
- const formattedVal = val.toFixed(4);
103
- this.activationEl.textContent = `ACTIVATION: ${formattedVal}`;
104
- this.activationEl.style.color = val > 0.3 ? '#ffe600' : '#00ffaa';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
 
106
- const typeStr = (data.userData?.type || 'VECTOR').toUpperCase();
107
- this.segmentEl.textContent = typeStr;
 
 
 
 
 
 
 
 
 
 
 
 
108
 
109
- const tokenText = data.userData?.word || data.userData?.token || `DIM #${data.index || 0}`;
110
- this.tokenEl.textContent = tokenText;
 
 
 
 
111
  }
112
  }
 
3
  * Displays real-time X, Y, Z coordinates, activation values, and token text.
4
  * Optional camera pose overlay (VITE_SHOW_CAM_POSE=true).
5
  */
6
+ import {
7
+ resolveHoverTelemetry,
8
+ formatActivationValue,
9
+ activationDisplayBudget,
10
+ } from './hoverTelemetry.js';
11
+
12
  export class HUD {
13
  /**
14
  * @param {HTMLElement} [containerElement]
 
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) {
52
+ this._resizeObserver = new ResizeObserver(() => {
53
+ if (this._lastActivation != null) {
54
+ this._renderActivation(this._lastActivation);
55
+ }
56
+ });
57
+ this._resizeObserver.observe(this.centerEl);
58
+ }
59
 
60
  this.cameraDebugEl = null;
61
  this.camPosEl = null;
 
105
  }
106
 
107
  updateTelemetry(data) {
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');
116
  this.tokenEl.textContent = 'NONE';
117
  return;
118
  }
119
 
120
+ const pos = t.point || { x: 0, y: 0, z: 0 };
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
133
+ */
134
+ _renderActivation(val) {
135
+ const slotPx = this._activationSlotWidth();
136
+ const fontSize = this._activationFontSize();
137
+ const { maxChars, compactLabel } = activationDisplayBudget(slotPx, 'ACTIVATION: ', fontSize);
138
+ const label = compactLabel ? 'ACT: ' : 'ACTIVATION: ';
139
+ const formatted = formatActivationValue(val, { maxDecimals: 32, maxChars });
140
+
141
+ this.activationEl.textContent = `${label}${formatted}`;
142
+ this.activationEl.title = `ACTIVATION: ${formatActivationValue(val, { maxDecimals: 32, maxChars: 48 })}`;
143
+ this.activationEl.style.color = Math.abs(val) > 0.3 ? '#ffe600' : '#00ffaa';
144
+ }
145
 
146
+ /** @private */
147
+ _activationSlotWidth() {
148
+ if (!this.centerEl) return 160;
149
+ const centerW = this.centerEl.clientWidth || 160;
150
+ const labelW = this.centerEl.querySelector('.hud-label')?.offsetWidth || 0;
151
+ const badgeW = this.segmentEl?.offsetWidth || 0;
152
+ const gap = 16;
153
+ const available = centerW - labelW - badgeW - gap;
154
+ // Mobile wrap: if center is tight, allow activation to use most of the bar
155
+ if (available < 64 && this.element?.clientWidth) {
156
+ return Math.max(72, Math.floor(this.element.clientWidth * 0.45));
157
+ }
158
+ return Math.max(64, available);
159
+ }
160
 
161
+ /** @private */
162
+ _activationFontSize() {
163
+ if (typeof window === 'undefined' || !this.activationEl) return 12;
164
+ const cs = window.getComputedStyle?.(this.activationEl);
165
+ const px = cs ? parseFloat(cs.fontSize) : 12;
166
+ return Number.isFinite(px) && px > 0 ? px : 12;
167
  }
168
  }
src/ui/hoverTelemetry.js ADDED
@@ -0,0 +1,183 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Resolve hover raycast payload → HUD telemetry fields.
3
+ * Points meshes store per-vertex data in userData.pointsData[index].
4
+ */
5
+
6
+ export const MAX_ACTIVATION_DECIMALS = 32;
7
+
8
+ /**
9
+ * Map a Three.js intersect payload to display fields.
10
+ * @param {object|null|undefined} data
11
+ * @returns {{
12
+ * point: { x: number, y: number, z: number } | null,
13
+ * index: number | null,
14
+ * activation: number,
15
+ * type: string,
16
+ * token: string,
17
+ * dim: number | null,
18
+ * } | null}
19
+ */
20
+ export function resolveHoverTelemetry(data) {
21
+ if (!data) return null;
22
+
23
+ const index = data.index != null ? data.index : null;
24
+ const ud = data.userData || {};
25
+ const pointsData = ud.pointsData;
26
+
27
+ let activation = null;
28
+ /** @type {Record<string, unknown>} */
29
+ let meta = {};
30
+
31
+ if (Array.isArray(pointsData) && index != null && pointsData[index]) {
32
+ const pt = pointsData[index];
33
+ activation = typeof pt.activation === 'number' ? pt.activation : pt.meta?.val;
34
+ meta = pt.meta && typeof pt.meta === 'object' ? pt.meta : {};
35
+ } else if (Array.isArray(ud.activations)) {
36
+ const actIdx = resolveActivationIndex(data, ud);
37
+ if (actIdx != null && actIdx >= 0 && actIdx < ud.activations.length) {
38
+ activation = ud.activations[actIdx];
39
+ meta = {
40
+ dim: actIdx,
41
+ token: ud.token,
42
+ type: ud.type,
43
+ val: activation,
44
+ };
45
+ }
46
+ } else if (typeof data.activation === 'number') {
47
+ activation = data.activation;
48
+ } else if (typeof ud.val === 'number') {
49
+ activation = ud.val;
50
+ }
51
+
52
+ if (typeof activation !== 'number' || !Number.isFinite(activation)) {
53
+ activation = 0;
54
+ }
55
+
56
+ const dim = meta.dim != null ? Number(meta.dim) : index;
57
+ const token =
58
+ (typeof meta.token === 'string' && meta.token)
59
+ || (typeof meta.word === 'string' && meta.word)
60
+ || (typeof ud.token === 'string' && ud.token)
61
+ || (typeof ud.word === 'string' && ud.word)
62
+ || (dim != null ? `DIM #${dim}` : '—');
63
+
64
+ const typeRaw = meta.type || ud.type || 'VECTOR';
65
+
66
+ return {
67
+ point: data.point || null,
68
+ index,
69
+ activation,
70
+ type: String(typeRaw).toUpperCase(),
71
+ token: String(token),
72
+ dim: dim != null && Number.isFinite(dim) ? dim : null,
73
+ };
74
+ }
75
+
76
+ /**
77
+ * @param {object} data
78
+ * @param {object} ud
79
+ * @returns {number|null}
80
+ */
81
+ function resolveActivationIndex(data, ud) {
82
+ if (ud.kind === 'wideRibbon' && data.face) {
83
+ const a = data.face.a;
84
+ if (typeof a === 'number') return Math.floor(a / 2);
85
+ }
86
+ if (data.index != null && Number.isFinite(data.index)) {
87
+ return data.index;
88
+ }
89
+ return null;
90
+ }
91
+
92
+ /**
93
+ * Format an activation for the bottom HUD.
94
+ * Uses enough decimals to show significance (cap 32), then shrinks to fit maxChars.
95
+ * Tiny values that still overflow fall back to scientific notation.
96
+ *
97
+ * @param {number} value
98
+ * @param {{ maxDecimals?: number, maxChars?: number }} [options]
99
+ * @returns {string}
100
+ */
101
+ export function formatActivationValue(value, options = {}) {
102
+ const maxDecimals = Math.min(
103
+ Math.max(0, options.maxDecimals ?? MAX_ACTIVATION_DECIMALS),
104
+ MAX_ACTIVATION_DECIMALS
105
+ );
106
+ const maxChars = Math.max(3, options.maxChars ?? 20);
107
+
108
+ if (!Number.isFinite(value)) return '--';
109
+ if (value === 0) return '0';
110
+
111
+ const abs = Math.abs(value);
112
+ let idealDecimals;
113
+ if (abs >= 1) {
114
+ idealDecimals = Math.min(maxDecimals, 6);
115
+ } else {
116
+ // Digits after decimal until first significant + a few more for nuance
117
+ const firstSig = Math.ceil(-Math.log10(abs));
118
+ idealDecimals = Math.min(maxDecimals, Math.max(4, firstSig + 4));
119
+ }
120
+
121
+ for (let d = idealDecimals; d >= 0; d--) {
122
+ const candidate = trimFixed(value, d);
123
+ if (candidate.length <= maxChars) {
124
+ // Avoid collapsing tiny non-zero to "0" — try scientific instead
125
+ if (Number(candidate) === 0 && value !== 0) break;
126
+ return candidate;
127
+ }
128
+ }
129
+
130
+ return formatScientific(value, maxChars);
131
+ }
132
+
133
+ /**
134
+ * Estimate how many monospace characters fit in a pixel budget.
135
+ * @param {number} widthPx
136
+ * @param {number} [fontSizePx=12]
137
+ * @returns {number}
138
+ */
139
+ export function estimateMonospaceChars(widthPx, fontSizePx = 12) {
140
+ if (!(widthPx > 0) || !(fontSizePx > 0)) return 8;
141
+ // Typical monospace advance ≈ 0.6em
142
+ return Math.max(3, Math.floor(widthPx / (fontSizePx * 0.6)));
143
+ }
144
+
145
+ /**
146
+ * Char budget for the numeric part given total activation slot width and label.
147
+ * @param {number} slotWidthPx
148
+ * @param {string} label - e.g. "ACTIVATION: " or "ACT: "
149
+ * @param {number} [fontSizePx]
150
+ * @returns {{ maxChars: number, compactLabel: boolean }}
151
+ */
152
+ export function activationDisplayBudget(slotWidthPx, label = 'ACTIVATION: ', fontSizePx = 12) {
153
+ const totalChars = estimateMonospaceChars(slotWidthPx, fontSizePx);
154
+ const labelLen = label.length;
155
+ let maxChars = totalChars - labelLen;
156
+ let compactLabel = false;
157
+
158
+ if (maxChars < 8) {
159
+ compactLabel = true;
160
+ maxChars = totalChars - 'ACT: '.length;
161
+ }
162
+ return {
163
+ maxChars: Math.max(4, maxChars),
164
+ compactLabel,
165
+ };
166
+ }
167
+
168
+ function trimFixed(value, decimals) {
169
+ if (decimals <= 0) {
170
+ return String(Math.round(value));
171
+ }
172
+ const fixed = value.toFixed(decimals);
173
+ // Keep at least one digit after decimal when present; strip trailing zeros
174
+ return fixed.replace(/(\.\d*?[1-9])0+$/, '$1').replace(/\.0+$/, '');
175
+ }
176
+
177
+ function formatScientific(value, maxChars) {
178
+ for (let p = 6; p >= 0; p--) {
179
+ const s = value.toExponential(p);
180
+ if (s.length <= maxChars) return s;
181
+ }
182
+ return value.toExponential(0);
183
+ }
src/visualizer/Instancer.js CHANGED
@@ -326,6 +326,8 @@ export class Instancer {
326
  }
327
  if (ribbonMesh) {
328
  ribbonMesh.userData.threadId = threadId;
 
 
329
  this.activeGroup.add(ribbonMesh);
330
  }
331
 
 
326
  }
327
  if (ribbonMesh) {
328
  ribbonMesh.userData.threadId = threadId;
329
+ ribbonMesh.userData.token = item.text;
330
+ ribbonMesh.userData.type = 'compare';
331
  this.activeGroup.add(ribbonMesh);
332
  }
333
 
src/visualizer/MeshFactory.js CHANGED
@@ -105,6 +105,7 @@ export class MeshFactory {
105
  lineMesh.userData = {
106
  kind: 'continuityLine',
107
  pointCount: points.length,
 
108
  };
109
 
110
  return lineMesh;
@@ -207,7 +208,12 @@ export class MeshFactory {
207
 
208
  const mesh = new THREE.Mesh(geometry, material);
209
  mesh.frustumCulled = false;
210
- mesh.userData = { kind: 'wideRibbon', pointCount: n, width };
 
 
 
 
 
211
  return mesh;
212
  }
213
 
 
105
  lineMesh.userData = {
106
  kind: 'continuityLine',
107
  pointCount: points.length,
108
+ activations: activations ? Array.from(activations) : null,
109
  };
110
 
111
  return lineMesh;
 
208
 
209
  const mesh = new THREE.Mesh(geometry, material);
210
  mesh.frustumCulled = false;
211
+ mesh.userData = {
212
+ kind: 'wideRibbon',
213
+ pointCount: n,
214
+ width,
215
+ activations: Array.from(acts),
216
+ };
217
  return mesh;
218
  }
219
 
tests/HUD.test.js CHANGED
@@ -78,3 +78,70 @@ describe('HUD camera pose overlay', () => {
78
  expect(hud.camRotEl.textContent).toBe('ROT: -5.4, -51.5, 0.0');
79
  });
80
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
  expect(hud.camRotEl.textContent).toBe('ROT: -5.4, -51.5, 0.0');
79
  });
80
  });
81
+
82
+ describe('HUD activation telemetry', () => {
83
+ let root;
84
+
85
+ beforeEach(() => {
86
+ root = document.createElement('div');
87
+ });
88
+
89
+ it('shows real activation from pointsData instead of 0.0000', () => {
90
+ const hud = new HUD(root);
91
+ // Node mock lacks innerHTML parsing — attach real-ish readout nodes
92
+ const center = document.createElement('div');
93
+ center.className = 'hud-center';
94
+ Object.defineProperty(center, 'clientWidth', { get: () => 360 });
95
+
96
+ const label = document.createElement('span');
97
+ label.className = 'hud-label';
98
+ Object.defineProperty(label, 'offsetWidth', { get: () => 100 });
99
+
100
+ const segment = document.createElement('span');
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;
116
+
117
+ hud.updateTelemetry({
118
+ index: 0,
119
+ point: { x: 5, y: 1, z: 0 },
120
+ userData: {
121
+ pointsData: [{
122
+ activation: 0.0000421,
123
+ meta: { type: 'compare', token: 'alpha', dim: 3, val: 0.0000421 },
124
+ }],
125
+ },
126
+ });
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
+ });
133
+
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');
140
+ hud.activationEl.style = {};
141
+ hud.activationEl.removeAttribute = () => {};
142
+
143
+ hud.updateTelemetry(null);
144
+ expect(hud.activationEl.textContent).toBe('ACTIVATION: --');
145
+ expect(hud.segmentEl.textContent).toBe('NEUTRAL SPACE');
146
+ });
147
+ });
tests/hoverTelemetry.test.js ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ resolveHoverTelemetry,
4
+ formatActivationValue,
5
+ estimateMonospaceChars,
6
+ activationDisplayBudget,
7
+ MAX_ACTIVATION_DECIMALS,
8
+ } from '../src/ui/hoverTelemetry.js';
9
+
10
+ describe('resolveHoverTelemetry', () => {
11
+ it('returns null for empty hover', () => {
12
+ expect(resolveHoverTelemetry(null)).toBeNull();
13
+ expect(resolveHoverTelemetry(undefined)).toBeNull();
14
+ });
15
+
16
+ it('reads activation from pointsData[index] (POINTS mesh)', () => {
17
+ const data = {
18
+ index: 1,
19
+ point: { x: 10, y: 2, z: 0 },
20
+ userData: {
21
+ pointsData: [
22
+ { activation: 0.01, meta: { type: 'compare', token: 'cat', dim: 0, val: 0.01 } },
23
+ { activation: -0.0421, meta: { type: 'compare', token: 'cat', dim: 1, val: -0.0421 } },
24
+ ],
25
+ },
26
+ };
27
+ const t = resolveHoverTelemetry(data);
28
+ expect(t.activation).toBe(-0.0421);
29
+ expect(t.token).toBe('cat');
30
+ expect(t.type).toBe('COMPARE');
31
+ expect(t.dim).toBe(1);
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 = {
38
+ index: 0,
39
+ point: { x: 1, y: 0, z: 0 },
40
+ userData: {
41
+ pointsData: [{ activation: 1.23e-7, meta: { dim: 0, val: 1.23e-7, type: 'res' } }],
42
+ },
43
+ };
44
+ expect(resolveHoverTelemetry(data).activation).toBe(1.23e-7);
45
+ });
46
+
47
+ it('reads activations[] on wideRibbon via face vertex index', () => {
48
+ const data = {
49
+ index: 4,
50
+ face: { a: 6, b: 7, c: 8 },
51
+ userData: {
52
+ kind: 'wideRibbon',
53
+ activations: [0.1, 0.2, 0.3, 0.4, 0.5],
54
+ token: 'dog',
55
+ type: 'compare',
56
+ },
57
+ };
58
+ const t = resolveHoverTelemetry(data);
59
+ expect(t.activation).toBe(0.4); // floor(6/2) = 3
60
+ expect(t.token).toBe('dog');
61
+ });
62
+
63
+ it('falls back to DIM #n when no token meta', () => {
64
+ const t = resolveHoverTelemetry({
65
+ index: 12,
66
+ userData: {
67
+ pointsData: [{ activation: 0.5, meta: { dim: 12, val: 0.5 } }],
68
+ },
69
+ });
70
+ expect(t.token).toBe('DIM #12');
71
+ });
72
+ });
73
+
74
+ describe('formatActivationValue', () => {
75
+ it('caps decimals at 32', () => {
76
+ expect(MAX_ACTIVATION_DECIMALS).toBe(32);
77
+ const tiny = 1e-30;
78
+ const s = formatActivationValue(tiny, { maxDecimals: 32, maxChars: 40 });
79
+ expect(s.includes('e') || s.length <= 40).toBe(true);
80
+ // Must not be the old HUD "0.0000"
81
+ expect(s).not.toBe('0.0000');
82
+ expect(Number(s)).not.toBe(0);
83
+ });
84
+
85
+ it('shows small magnitudes that toFixed(4) would hide', () => {
86
+ const s = formatActivationValue(0.00001234, { maxDecimals: 32, maxChars: 20 });
87
+ expect(s).not.toMatch(/^0\.0+$/);
88
+ expect(Math.abs(Number(s) - 0.00001234)).toBeLessThan(1e-8);
89
+ });
90
+
91
+ it('shrinks to fit maxChars without overflowing', () => {
92
+ const s = formatActivationValue(0.123456789012345, { maxDecimals: 32, maxChars: 8 });
93
+ expect(s.length).toBeLessThanOrEqual(8);
94
+ expect(s).not.toBe('0');
95
+ });
96
+
97
+ it('uses scientific when fixed form cannot fit', () => {
98
+ const s = formatActivationValue(1.23456789e-20, { maxDecimals: 32, maxChars: 10 });
99
+ expect(s.length).toBeLessThanOrEqual(10);
100
+ expect(s).toMatch(/e/i);
101
+ });
102
+
103
+ it('formats zero as 0', () => {
104
+ expect(formatActivationValue(0, { maxChars: 10 })).toBe('0');
105
+ });
106
+ });
107
+
108
+ describe('activationDisplayBudget', () => {
109
+ it('estimates monospace capacity', () => {
110
+ expect(estimateMonospaceChars(120, 12)).toBeGreaterThan(10);
111
+ });
112
+
113
+ it('switches to compact ACT: label when slot is narrow (mobile)', () => {
114
+ const wide = activationDisplayBudget(400, 'ACTIVATION: ', 12);
115
+ expect(wide.compactLabel).toBe(false);
116
+
117
+ const narrow = activationDisplayBudget(90, 'ACTIVATION: ', 11);
118
+ expect(narrow.compactLabel).toBe(true);
119
+ expect(narrow.maxChars).toBeGreaterThanOrEqual(4);
120
+ });
121
+ });