Spaces:
Running
Running
fix(hud): resolve hover activation and adaptive precision
Browse filesHUD 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 +5 -0
- CHANGELOG.md +5 -0
- src/engine/Interaction.js +1 -0
- src/style.css +57 -3
- src/ui/HUD.js +67 -11
- src/ui/hoverTelemetry.js +183 -0
- src/visualizer/Instancer.js +2 -0
- src/visualizer/MeshFactory.js +7 -1
- tests/HUD.test.js +67 -0
- tests/hoverTelemetry.test.js +121 -0
.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 {
|
| 812 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 90 |
this.coordsEl.textContent = 'X: -- | Y: -- | Z: --';
|
| 91 |
this.segmentEl.textContent = 'NEUTRAL SPACE';
|
| 92 |
-
this.activationEl.textContent = 'ACTIVATION:
|
| 93 |
this.activationEl.style.color = '#888888';
|
|
|
|
| 94 |
this.tokenEl.textContent = 'NONE';
|
| 95 |
return;
|
| 96 |
}
|
| 97 |
|
| 98 |
-
const pos =
|
| 99 |
this.coordsEl.textContent = `X: ${Math.round(pos.x)} | Y: ${Math.round(pos.y)} | Z: ${Math.round(pos.z)}`;
|
| 100 |
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
this.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
|
| 106 |
-
|
| 107 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
|
| 109 |
-
|
| 110 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 = {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
});
|