Spaces:
Sleeping
Sleeping
feat(viz): sign filter and color anchors panel (v1.7.0)
Browse filesAdd Visualization controls under Spatial Controls: All/+ Only/− Only on
normalized activations, three hex anchors replacing the mid-stop ramp,
localStorage persistence, and POINTS shader uniforms.
Co-authored-by: Cursor <cursoragent@cursor.com>
- .agents/skills/dev-protocol/lessons-learned.md +8 -5
- CHANGELOG.md +9 -0
- CONTEXT.md +10 -1
- manifest.json +1 -1
- package.json +1 -1
- roadmap/PROMPT-visualization-controls.md +66 -0
- roadmap/README.md +2 -0
- roadmap/visualization-sign-filters-colors.md +222 -0
- src/main.js +31 -4
- src/style.css +129 -2
- src/ui/Navbar.js +1 -1
- src/ui/VisualizationControls.js +168 -0
- src/ui/visualizationControlsDefaults.js +158 -0
- src/visualizer/DivergentShading.js +118 -73
- src/visualizer/Instancer.js +26 -10
- src/visualizer/MeshFactory.js +55 -26
- src/visualizer/activationFilter.js +117 -0
- tests/DivergentShading.test.js +41 -26
- tests/visualizationControls.test.js +197 -0
.agents/skills/dev-protocol/lessons-learned.md
CHANGED
|
@@ -66,15 +66,18 @@ Este archivo registra las lecciones aprendidas, invariantes de arquitectura y pa
|
|
| 66 |
}
|
| 67 |
```
|
| 68 |
|
| 69 |
-
### 2.2. Rampas Cromáticas Divergentes
|
| 70 |
-
|
| 71 |
-
- **
|
| 72 |
-
-
|
|
|
|
|
|
|
|
|
|
| 73 |
|
| 74 |
### 2.3. Continuidad de hilo según RENDER mode (mutuamente excluyentes)
|
| 75 |
- **POINTS**: puntos + línea fina `createRibbonMesh` (continuidad 1px).
|
| 76 |
- **RIBBONS**: la continuidad **es** la cinta ancha (`createWideRibbonMesh`); sin plano base (el quad oscuro se veía a través de cintas transparentes). No usar `LineBasicMaterial.linewidth` (§1.4).
|
| 77 |
-
- Colormap RIBBONS: rampa **divergente** VectorLab para consistencia de marca.
|
| 78 |
- **Retired**: `RENDER: MESH` (surface heightfield) removed in v1.6.0 — `normalizeRenderMode('MESH')` → POINTS. Do not reintroduce without an explicit product decision.
|
| 79 |
|
| 80 |
### 2.4. Optimización de Fragment Shader para Cero Activación ($|t| < 0.01$)
|
|
|
|
| 66 |
}
|
| 67 |
```
|
| 68 |
|
| 69 |
+
### 2.2. Rampas Cromáticas Divergentes (Color Anchors)
|
| 70 |
+
User-editable hex anchors replace the former fixed dual ramp (no product mid-stops at ±0.5 orange/blue):
|
| 71 |
+
- **+1** default `#FFE600`, **0** `#000000`, **−1** `#9900E6`.
|
| 72 |
+
- Interpolation: lerp(`zero`, `positive`, t) for t≥0; lerp(`zero`, `negative`, −t) for t<0.
|
| 73 |
+
- POINTS shader: uniforms `uColorPos` / `uColorNeg` / `uColorZero` (never hardcode ramp in GLSL).
|
| 74 |
+
- Sign filter + colors are **global** (`vl3d.viz.*` in localStorage); filter runs on **normalized t** (ε=0.01), not raw dims.
|
| 75 |
+
- Continuity lines use `LineSegments` + index pairs; wide ribbons omit quad indices for filtered pairs (keeps full vertex buffers for compare reorder).
|
| 76 |
|
| 77 |
### 2.3. Continuidad de hilo según RENDER mode (mutuamente excluyentes)
|
| 78 |
- **POINTS**: puntos + línea fina `createRibbonMesh` (continuidad 1px).
|
| 79 |
- **RIBBONS**: la continuidad **es** la cinta ancha (`createWideRibbonMesh`); sin plano base (el quad oscuro se veía a través de cintas transparentes). No usar `LineBasicMaterial.linewidth` (§1.4).
|
| 80 |
+
- Colormap RIBBONS: rampa **divergente** VectorLab (anchors) para consistencia de marca.
|
| 81 |
- **Retired**: `RENDER: MESH` (surface heightfield) removed in v1.6.0 — `normalizeRenderMode('MESH')` → POINTS. Do not reintroduce without an explicit product decision.
|
| 82 |
|
| 83 |
### 2.4. Optimización de Fragment Shader para Cero Activación ($|t| < 0.01$)
|
CHANGELOG.md
CHANGED
|
@@ -2,6 +2,15 @@
|
|
| 2 |
|
| 3 |
All notable changes to VectorLab 3D will be documented in this file.
|
| 4 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
## [Unreleased]
|
| 6 |
|
| 7 |
### Changed
|
|
|
|
| 2 |
|
| 3 |
All notable changes to VectorLab 3D will be documented in this file.
|
| 4 |
|
| 5 |
+
## [1.7.0] - 2026-08-02
|
| 6 |
+
|
| 7 |
+
### Added
|
| 8 |
+
- **Visualization Controls** (`feat/visualization-sign-color-controls`): right-dock panel under 3D Spatial Controls.
|
| 9 |
+
- Sign filter: `All | + Only | − Only` on **normalized** activations (ε=0.01); hides opposite sign and near-zero for ± only.
|
| 10 |
+
- Applies to POINTS (shader discard) and RIBBONS / continuity lines (index omission) in ARITHMETIC + COMPARE.
|
| 11 |
+
- Three hex color anchors (+1 / 0 / −1) replace the fixed mid-stop divergent ramp; defaults `#FFE600` / `#000000` / `#9900E6`.
|
| 12 |
+
- POINTS shader uniforms `uColorPos` / `uColorNeg` / `uColorZero`; live update + `localStorage` (`vl3d.viz.*`) + Reset.
|
| 13 |
+
|
| 14 |
## [Unreleased]
|
| 15 |
|
| 16 |
### Changed
|
CONTEXT.md
CHANGED
|
@@ -38,5 +38,14 @@ _Avoid_: treating Sidebar/ComparePanel as independent minimize targets; unmounti
|
|
| 38 |
Soft, dismissible portrait-phone overlay that suggests rotating to landscape without locking orientation or pausing the 3D render loop.
|
| 39 |
|
| 40 |
### Wide Ribbon
|
| 41 |
-
Quad-strip mesh with real lateral width following a thread centerline, colored by activation; used by `RENDER: RIBBONS`
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
|
|
|
|
| 38 |
Soft, dismissible portrait-phone overlay that suggests rotating to landscape without locking orientation or pausing the 3D render loop.
|
| 39 |
|
| 40 |
### Wide Ribbon
|
| 41 |
+
Quad-strip mesh with real lateral width following a thread centerline, colored by activation; used by `RENDER: RIBBONS`. Distinct from 1px WebGL lines.
|
| 42 |
+
|
| 43 |
+
### Visualization Controls
|
| 44 |
+
Right-dock glass panel for global sign filter and divergent color anchors (below 3D Spatial Controls).
|
| 45 |
+
|
| 46 |
+
### Sign Filter
|
| 47 |
+
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.
|
| 48 |
+
|
| 49 |
+
### Color Anchor
|
| 50 |
+
User-editable hex for normalized activations at +1, 0, and −1; replaces the former fixed dual mid-stop ramp via linear RGB lerp.
|
| 51 |
|
manifest.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
{
|
| 2 |
"project": "vectorlab-3d",
|
| 3 |
-
"version": "1.
|
| 4 |
"bootstrap_run": false,
|
| 5 |
"state_schema": {
|
| 6 |
"model_name": "all-mpnet-base-v2",
|
|
|
|
| 1 |
{
|
| 2 |
"project": "vectorlab-3d",
|
| 3 |
+
"version": "1.7.0",
|
| 4 |
"bootstrap_run": false,
|
| 5 |
"state_schema": {
|
| 6 |
"model_name": "all-mpnet-base-v2",
|
package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
{
|
| 2 |
"name": "vectorlab-3d",
|
| 3 |
-
"version": "1.
|
| 4 |
"type": "module",
|
| 5 |
"scripts": {
|
| 6 |
"dev": "vite",
|
|
|
|
| 1 |
{
|
| 2 |
"name": "vectorlab-3d",
|
| 3 |
+
"version": "1.7.0",
|
| 4 |
"type": "module",
|
| 5 |
"scripts": {
|
| 6 |
"dev": "vite",
|
roadmap/PROMPT-visualization-controls.md
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Prompt — agente Visualization Controls (Sign filter + color anchors)
|
| 2 |
+
|
| 3 |
+
Copiá y pegá el bloque siguiente en una sesión nueva. **Todas las decisiones de producto ya están cerradas** en `roadmap/visualization-sign-filters-colors.md` (2026-08-02).
|
| 4 |
+
|
| 5 |
+
---
|
| 6 |
+
|
| 7 |
+
```text
|
| 8 |
+
Usando dev-protocol, ejecutá el roadmap Visualization Filters & Color Anchors.
|
| 9 |
+
|
| 10 |
+
## Contexto
|
| 11 |
+
Repo: VectorLab 3D (Python/uv + Vite/Three.js).
|
| 12 |
+
Base: `main` actualizado (pull first).
|
| 13 |
+
Roadmap canónico (leelo COMPLETO antes de codear):
|
| 14 |
+
`roadmap/visualization-sign-filters-colors.md`
|
| 15 |
+
Lecciones: `.agents/skills/dev-protocol/lessons-learned.md`
|
| 16 |
+
(§4.5 sliders/dock derecho, §2.2 rampa divergente, §7 SemVer, §1 WebGL).
|
| 17 |
+
|
| 18 |
+
## Decisiones YA CERRADAS (no re-preguntar)
|
| 19 |
+
- V1: Panel nuevo debajo de “3D Spatial Controls” en right dock (mismo glass).
|
| 20 |
+
- V2: Efectivo en ARITHMETIC + COMPARE.
|
| 21 |
+
- V3: POINTS + RIBBONS only.
|
| 22 |
+
- F1: + = >0, − = <0, near-zero |t|<0.01 (ε alineado a DivergentShading).
|
| 23 |
+
- F2: “+ only” oculta − y near-zero; “− only” oculta + y near-zero.
|
| 24 |
+
- F3: Radio segmentado All | + Only | − Only (default All).
|
| 25 |
+
- F4: Filtrar TODA la geometría (points + continuity lines + wide ribbons).
|
| 26 |
+
- C1: Tres hex anclas REEMPLAZAN la rampa fija (lerp 0↔+1 y 0↔−1; sin midstops de producto).
|
| 27 |
+
- C2: Hex libre; C3 defaults #FFE600 / #000000 / #9900E6; C4 localStorage.
|
| 28 |
+
- D1: Global (no per MODE|VIEW|RENDER); D2: Reset a defaults + All.
|
| 29 |
+
- X1: Sin backend; X2: sin presets con nombre / export.
|
| 30 |
+
- Filtrar sobre activación NORMALIZADA (post z-score/tanh), no raw suelto — documentalo.
|
| 31 |
+
- POINTS shader: uniforms para colores (no hardcode GLSL ramp).
|
| 32 |
+
|
| 33 |
+
## Objetivo
|
| 34 |
+
UI + pipeline de color/filtro según roadmap §0–§7. TDD en helpers puros. MINOR version bump (§7).
|
| 35 |
+
|
| 36 |
+
## Fuera de alcance
|
| 37 |
+
- roadmap/archivo/**, backend, MESH, named palettes, per-context viz overrides.
|
| 38 |
+
|
| 39 |
+
## Flujo
|
| 40 |
+
1. Branch `feat/visualization-sign-color-controls` (or staged A/B/C as in roadmap §9).
|
| 41 |
+
2. Implement defaults/persistence + pure filter/color tests (RED→GREEN).
|
| 42 |
+
3. UI panel + wire into main.js right dock below ThreadSliders.
|
| 43 |
+
4. Instancer/MeshFactory/DivergentShading: apply filter + anchors on POINTS and RIBBONS.
|
| 44 |
+
5. CHANGELOG + CONTEXT terms + short lessons note; bump version (check current manifest).
|
| 45 |
+
6. Vitest green + smoke checklist in roadmap §6–§7.
|
| 46 |
+
7. APPROVAL GATE — report how to test; wait for explicit OK before push/merge.
|
| 47 |
+
|
| 48 |
+
## Criterios de aceptación
|
| 49 |
+
Ver checklist §7 del roadmap (todos los ítems).
|
| 50 |
+
|
| 51 |
+
## Cómo probar
|
| 52 |
+
1. `npm run dev` (+ backend).
|
| 53 |
+
2. Right dock → Visualization: toggle All / + Only / − Only on POINTS then RIBBONS (Arithmetic + Compare).
|
| 54 |
+
3. Edit +1/0/−1 hex; confirm live recolor; reload → persisted; Reset → defaults.
|
| 55 |
+
4. Confirm lines/ribbons also lose filtered-sign geometry (not points-only).
|
| 56 |
+
|
| 57 |
+
## Estilo
|
| 58 |
+
No-fluff; deep modules; don’t expand scope; don’t re-ask closed IDs.
|
| 59 |
+
```
|
| 60 |
+
|
| 61 |
+
---
|
| 62 |
+
|
| 63 |
+
## Notas para el humano
|
| 64 |
+
|
| 65 |
+
- Decisiones V/F/C/D/X ya cerradas — pegá el prompt directo.
|
| 66 |
+
- Si `feat/compare-group-labels` u otros no están en `main`, mergeá antes o pedile al agente que parta de `main` limpio.
|
roadmap/README.md
CHANGED
|
@@ -6,6 +6,8 @@ Documentos de planificación **activos** viven en esta carpeta. Históricos en *
|
|
| 6 |
|---|---|---|
|
| 7 |
| [`english-ui-i18n.md`](./english-ui-i18n.md) | English-only UI / product strings (audit → glossary gate → apply) | **Activo** |
|
| 8 |
| [`PROMPT-english-ui.md`](./PROMPT-english-ui.md) | Prompt para agente que ejecuta el epic EN | **Activo** |
|
|
|
|
|
|
|
| 9 |
| [`archivo/`](./archivo/) | Epic A→E mobile/MESH/RIBBONS, specs legacy, release 1.0 | Archivado 2026-08-02 |
|
| 10 |
|
| 11 |
Fuente de verdad del código: `CHANGELOG.md`, `CONTEXT.md`, `main`.
|
|
|
|
| 6 |
|---|---|---|
|
| 7 |
| [`english-ui-i18n.md`](./english-ui-i18n.md) | English-only UI / product strings (audit → glossary gate → apply) | **Activo** |
|
| 8 |
| [`PROMPT-english-ui.md`](./PROMPT-english-ui.md) | Prompt para agente que ejecuta el epic EN | **Activo** |
|
| 9 |
+
| [`visualization-sign-filters-colors.md`](./visualization-sign-filters-colors.md) | Filtro All/+/− + anclas de color +1/0/−1 (panel Visualization) | **Activo** |
|
| 10 |
+
| [`PROMPT-visualization-controls.md`](./PROMPT-visualization-controls.md) | Prompt para agente que ejecuta viz filters/colors | **Activo** |
|
| 11 |
| [`archivo/`](./archivo/) | Epic A→E mobile/MESH/RIBBONS, specs legacy, release 1.0 | Archivado 2026-08-02 |
|
| 12 |
|
| 13 |
Fuente de verdad del código: `CHANGELOG.md`, `CONTEXT.md`, `main`.
|
roadmap/visualization-sign-filters-colors.md
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Roadmap — Visualization Filters & Color Anchors (Sign / Polarity Controls)
|
| 2 |
+
|
| 3 |
+
**Status:** Ready for implementation handoff
|
| 4 |
+
**Date:** 2026-08-02
|
| 5 |
+
**Product:** VectorLab 3D (`lsv2`)
|
| 6 |
+
**Prompt companion:** [`PROMPT-visualization-controls.md`](./PROMPT-visualization-controls.md)
|
| 7 |
+
|
| 8 |
+
> **Do not re-ask closed decisions** below. If something truly new appears, list once and ask; otherwise implement.
|
| 9 |
+
|
| 10 |
+
---
|
| 11 |
+
|
| 12 |
+
## 0. Goal (one sentence)
|
| 13 |
+
|
| 14 |
+
Add a second glass panel under **3D Spatial Controls** (right dock) that lets the user (1) filter the 3D viz to **All / positives only / negatives only**, and (2) **replace** the divergent colormap with three user-editable hex anchors for **+1**, **−1**, and **0**, globally, with persistence and reset.
|
| 15 |
+
|
| 16 |
+
---
|
| 17 |
+
|
| 18 |
+
## 0.1. Closed decisions (authoritative)
|
| 19 |
+
|
| 20 |
+
| ID | Topic | Decision |
|
| 21 |
+
| :--- | :--- | :--- |
|
| 22 |
+
| **V1** | Placement | New panel **below** existing “3D Spatial Controls” in the **right dock**, same glass / section-card style. |
|
| 23 |
+
| **V2** | Modes | Visible and effective in **ARITHMETIC and COMPARE** (all workspace modes). |
|
| 24 |
+
| **V3** | Render modes | Applies to **POINTS and RIBBONS** only (MESH already retired). |
|
| 25 |
+
| **F1** | Sign / zero | Positive: raw or display activation `> 0`. Negative: `< 0`. Near-zero: `abs(v) < ε` with **ε = 0.01** (align with existing `|t| < 0.01` short-circuit in `DivergentShading`). Treat near-zero as **neutral / zero** for filter + color anchor `0`. |
|
| 26 |
+
| **F2** | What “only + / only −” hides | **+ only:** hide negative **and** near-zero. **− only:** hide positive **and** near-zero. |
|
| 27 |
+
| **F3** | Filter UI | **Radio / segmented control:** `All` \| `+ only` \| `− only`. Exactly one active. Default: `All`. |
|
| 28 |
+
| **F4** | Geometry filtered | **Filter all drawn geometry** for POINTS and RIBBONS: point cloud **and** continuity lines / wide ribbons (omit vertices or break strips so filtered signs disappear). Not “points-only”. |
|
| 29 |
+
| **C1** | Colormap | User colors **replace** the built-in dual ramp (no fixed mid Orange/Blue stops as product truth). Interpolation: **lerp between 0↔+1 and 0↔−1** using the three anchors. Alpha/opacity curve may stay similar to today (pow on \|t\|) unless tests force a tweak — document if changed. |
|
| 30 |
+
| **C2** | Color inputs | **Free hex** (`#RRGGBB`), with validation; invalid → ignore / revert to last good. Optional native `<input type="color">` synced to hex text is OK if it stays deep/simple. |
|
| 31 |
+
| **C3** | Default colors | Match **current** palette endpoints / zero: see §0.2. |
|
| 32 |
+
| **C4** | Persistence | Persist filter mode + three hex colors in **`localStorage`**. |
|
| 33 |
+
| **D1** | Context | **Global** — not per MODE\|VIEW\|RENDER. Same values everywhere. |
|
| 34 |
+
| **D2** | Reset | Yes — explicit **Reset** control (and/or dblclick on a control restoring that field’s default, consistent with spatial sliders lesson §4.5). Reset restores filter=`All` + default hex trio. |
|
| 35 |
+
| **X1** | Backend | **No API/backend changes** unless a later discovery forces it (should not). |
|
| 36 |
+
| **X2** | Named presets / export | **Out of scope** for v1. |
|
| 37 |
+
|
| 38 |
+
### 0.2. Default color anchors (from current ramp)
|
| 39 |
+
|
| 40 |
+
Documented in `src/visualizer/DivergentShading.js` / lessons §2.2:
|
| 41 |
+
|
| 42 |
+
| Anchor | Meaning | Default hex (approx from current ramp) | Notes |
|
| 43 |
+
| :--- | :--- | :--- | :--- |
|
| 44 |
+
| **+1** | Strong positive | `#FFE600` | Incandescent yellow at +1.00 |
|
| 45 |
+
| **−1** | Strong negative | `#9900E6` | Neon violet at −1.00 |
|
| 46 |
+
| **0** | Near-zero / neutral | `#000000` | Black at 0 (today also very low alpha ~0.05) |
|
| 47 |
+
|
| 48 |
+
Mid stops (+0.5 orange `#FF8000`, −0.5 electric blue `#0040FF`) are **not** user controls in v1; they disappear as fixed brand stops once anchors replace the ramp (C1). Intermediate t values = linear RGB lerp in normalized t-space after z-score/tanh (same normalization pipeline as today).
|
| 49 |
+
|
| 50 |
+
---
|
| 51 |
+
|
| 52 |
+
## 1. Product copy (English-only UI — §4.7)
|
| 53 |
+
|
| 54 |
+
Suggested labels (adjust only if glossary conflicts; keep EN):
|
| 55 |
+
|
| 56 |
+
| UI | Copy |
|
| 57 |
+
| :--- | :--- |
|
| 58 |
+
| Panel title | `Visualization` or `Sign & Color` (pick one; prefer **`Visualization`**) |
|
| 59 |
+
| Filter group | `Show:` |
|
| 60 |
+
| Radio | `All` / `+ Only` / `− Only` |
|
| 61 |
+
| Color section | `Colors` |
|
| 62 |
+
| Fields | `+1` / `0` / `−1` (or `Positive (+1)` / `Zero (0)` / `Negative (−1)`) |
|
| 63 |
+
| Reset | `Reset` |
|
| 64 |
+
| Hex placeholders | `#FFE600`, `#000000`, `#9900E6` |
|
| 65 |
+
|
| 66 |
+
Internal keys (examples — agent may rename for clarity but keep stable once chosen):
|
| 67 |
+
|
| 68 |
+
- `vizFilterMode`: `'all' | 'positive' | 'negative'`
|
| 69 |
+
- `colorPositive`, `colorZero`, `colorNegative` (hex strings)
|
| 70 |
+
- `localStorage` key prefix e.g. `vl3d.viz.` (`vl3d.viz.filter`, `vl3d.viz.colorPositive`, …)
|
| 71 |
+
|
| 72 |
+
---
|
| 73 |
+
|
| 74 |
+
## 2. UX / layout
|
| 75 |
+
|
| 76 |
+
```
|
| 77 |
+
Right dock body (top → bottom):
|
| 78 |
+
[ 3D Spatial Controls ] ← existing ThreadSliders
|
| 79 |
+
[ Visualization ] ← NEW section-card
|
| 80 |
+
Show: ( All | + Only | − Only )
|
| 81 |
+
Colors:
|
| 82 |
+
+1 [#______] [swatch/color]
|
| 83 |
+
0 [#______] [swatch/color]
|
| 84 |
+
−1 [#______] [swatch/color]
|
| 85 |
+
[ Reset ]
|
| 86 |
+
[ AxisGizmo ]
|
| 87 |
+
```
|
| 88 |
+
|
| 89 |
+
- Same CSS patterns as `#thread-sliders-container` / `.section-card` / `.slider-group`.
|
| 90 |
+
- Mobile: lives in right dock (already collapses); no extra landscape rules.
|
| 91 |
+
- Changing filter or color → **live update** of 3D (reuse current `refreshRender` / in-situ buffer update patterns; prefer mutating colors/visibility without full scene teardown when possible).
|
| 92 |
+
|
| 93 |
+
---
|
| 94 |
+
|
| 95 |
+
## 3. Technical design (recommended seams)
|
| 96 |
+
|
| 97 |
+
### 3.1. Deep modules (preferred)
|
| 98 |
+
|
| 99 |
+
| Module | Responsibility |
|
| 100 |
+
| :--- | :--- |
|
| 101 |
+
| `src/ui/visualizationControlsDefaults.js` | Defaults, `localStorage` read/write, validate hex, `resolveVisualizationSettings()` |
|
| 102 |
+
| `src/ui/VisualizationControls.js` | Markup + wire events → mutate settings + `onChange` callback |
|
| 103 |
+
| `src/visualizer/DivergentShading.js` (extend) | Accept optional color anchors; `getDivergentColor(val, absMax, anchors?)`; shader uniforms for POINTS material if colors are GPU-side |
|
| 104 |
+
| Filter helper (pure) | `shouldShowActivation(v, mode, eps=0.01)` + `filterPointsData` / ribbon vertex mask — **unit-tested** |
|
| 105 |
+
|
| 106 |
+
### 3.2. Data flow
|
| 107 |
+
|
| 108 |
+
1. App loads → read `localStorage` → merge with defaults → hold `vizConfig` on `VectorLabApp` (or small store beside `sliderConfig`).
|
| 109 |
+
2. Mount `VisualizationControls` under sliders in right dock (`main.js` / same place as `mountThreadSlidersUI`).
|
| 110 |
+
3. On change → persist → `refreshRender()` (and/or update materials/geometries in place).
|
| 111 |
+
4. `Instancer` / `MeshFactory` / `ThreadFactory` receive `vizConfig` (or read from a single getter) when building POINTS lines/points and RIBBONS.
|
| 112 |
+
|
| 113 |
+
### 3.3. Filter semantics (F4A)
|
| 114 |
+
|
| 115 |
+
- **POINTS:** Drop points that fail the filter from the Points cloud; rebuild or rewrite buffers. Continuity `Line` (`createRibbonMesh`): only include consecutive segments where endpoints pass (or drop vertices that fail — avoid leaving misleading bridges across hidden signs). Prefer **omit failing vertices** and break the line into multiple `Line` segments / or use discontinuous indices — pick simplest robust approach; document in lessons.
|
| 116 |
+
- **RIBBONS (`createWideRibbonMesh`):** Same activation array — skip or split strips so hidden signs are not drawn. Do **not** leave full ribbon colored only on one side while filter says “+ only”.
|
| 117 |
+
- **Filter input value:** Use the **same activation** used for coloring after the project’s normalization choice. Clarify in impl: filter on **raw embedding dim value** vs **z-score/tanh normalized t**.
|
| 118 |
+
**Recommendation (lock unless blocked):** filter on **normalized t** (post `calculateZScoreNormalized`), so ε=0.01 matches the shader short-circuit and “zero color” band. State this explicitly in code comments + tests.
|
| 119 |
+
|
| 120 |
+
### 3.4. Color replacement (C1)
|
| 121 |
+
|
| 122 |
+
- `getDivergentColor` / GLSL fragment path must use anchors:
|
| 123 |
+
- `t >= 0`: lerp(`colorZero`, `colorPositive`, t) (with near-zero short-circuit → `colorZero` + low alpha)
|
| 124 |
+
- `t < 0`: lerp(`colorZero`, `colorNegative`, -t)
|
| 125 |
+
- Remove dependency on hard-coded orange/blue mid stops when custom anchors are active (always, in v1 — anchors always drive the ramp).
|
| 126 |
+
- POINTS `ShaderMaterial`: today embeds ramp in GLSL — **must** gain uniforms (or CPU attribute colors) so live hex edits update without shader string rebuild every frame. Prefer **uniforms** `uColorPos`, `uColorNeg`, `uColorZero`.
|
| 127 |
+
- CPU paths (`getDivergentColor` for lines/ribbons vertex colors) must share the **same** math as GPU (pure function + tests).
|
| 128 |
+
|
| 129 |
+
### 3.5. Persistence
|
| 130 |
+
|
| 131 |
+
- On load: invalid hex → fallback to default for that key.
|
| 132 |
+
- On Reset: write defaults back to `localStorage`.
|
| 133 |
+
- Do not key by MODE/VIEW/RENDER (D1).
|
| 134 |
+
|
| 135 |
+
### 3.6. Versioning
|
| 136 |
+
|
| 137 |
+
- Capability add → **MINOR** bump per lessons-learned **§7** (e.g. `1.7.0` → `1.8.0` if `1.7.0` already shipped with groups; check `manifest.json` / Navbar at start of work).
|
| 138 |
+
- Sync `manifest.json`, `package.json`, Navbar `version-tag`, `CHANGELOG` section.
|
| 139 |
+
- No build numbers as product version (§7.3).
|
| 140 |
+
|
| 141 |
+
---
|
| 142 |
+
|
| 143 |
+
## 4. Files likely touched
|
| 144 |
+
|
| 145 |
+
| Area | Paths |
|
| 146 |
+
| :--- | :--- |
|
| 147 |
+
| UI | `src/ui/VisualizationControls.js` (new), `src/ui/visualizationControlsDefaults.js` (new), `src/style.css`, `src/main.js` |
|
| 148 |
+
| Shading | `src/visualizer/DivergentShading.js`, `src/visualizer/MeshFactory.js`, possibly `ThreadFactory.js` |
|
| 149 |
+
| Render | `src/visualizer/Instancer.js` (pass vizConfig into mount paths) |
|
| 150 |
+
| State (optional) | `src/core/State.js` only if you want viz settings in AppState — **not required**; local app field + localStorage is enough |
|
| 151 |
+
| Tests | `tests/visualizationControls*.test.js`, extend `tests/DivergentShading.test.js`, ribbon/points filter tests |
|
| 152 |
+
| Docs | `CHANGELOG.md`, `CONTEXT.md` (new terms), `lessons-learned.md` short § |
|
| 153 |
+
|
| 154 |
+
**Do not touch:** `roadmap/archivo/**`, backend (X1), unrelated roadmaps.
|
| 155 |
+
|
| 156 |
+
---
|
| 157 |
+
|
| 158 |
+
## 5. CONTEXT glossary terms to add
|
| 159 |
+
|
| 160 |
+
- **Visualization Controls:** Right-dock panel for sign filter + divergent color anchors.
|
| 161 |
+
- **Sign Filter:** Global show mode `all | positive | negative` over normalized activations.
|
| 162 |
+
- **Color Anchor:** User hex for normalized activations at +1, 0, −1 replacing the fixed dual ramp.
|
| 163 |
+
|
| 164 |
+
---
|
| 165 |
+
|
| 166 |
+
## 6. Test plan (TDD where harness exists)
|
| 167 |
+
|
| 168 |
+
### Unit
|
| 169 |
+
- [ ] `shouldShowActivation` / filter helper: all / + / − × values `{1, 0.5, 0.005, -0.5, -1}`.
|
| 170 |
+
- [ ] Hex validate + persist round-trip (mock `localStorage`).
|
| 171 |
+
- [ ] `getDivergentColor` with custom anchors: t=1 → +1 hex; t=0 → 0 hex; t=-1 → −1 hex; t=0.5 → midpoint lerp.
|
| 172 |
+
- [ ] Defaults match §0.2.
|
| 173 |
+
- [ ] Reset restores defaults and `all`.
|
| 174 |
+
|
| 175 |
+
### Integration / visual smoke (manual)
|
| 176 |
+
- [ ] ARITHMETIC + COMPARE; POINTS + RIBBONS.
|
| 177 |
+
- [ ] `+ Only` / `− Only` visibly removes opposite and near-zero geometry on both render modes.
|
| 178 |
+
- [ ] Change +1 hex → peaks update live; reload page → hex + filter restored.
|
| 179 |
+
- [ ] Reset → back to yellow/black/violet + All.
|
| 180 |
+
- [ ] Spatial sliders + viz panel both usable in right dock; gizmo still below.
|
| 181 |
+
|
| 182 |
+
---
|
| 183 |
+
|
| 184 |
+
## 7. Acceptance criteria
|
| 185 |
+
|
| 186 |
+
- [ ] New panel under Spatial Controls; EN copy; global; POINTS+RIBBONS; Arithmetic+Compare.
|
| 187 |
+
- [ ] Segmented **All | + Only | − Only**; + hides − and |t|<ε; − hides + and |t|<ε.
|
| 188 |
+
- [ ] Filter affects points **and** lines/ribbons (F4A).
|
| 189 |
+
- [ ] Three hex anchors replace old mid-stop ramp; defaults §0.2; live update.
|
| 190 |
+
- [ ] `localStorage` persistence; Reset works.
|
| 191 |
+
- [ ] No backend changes; Vitest green; CHANGELOG + version MINOR; lesson note for filter-on-normalized-t + shader uniforms.
|
| 192 |
+
- [ ] Approval gate before push/merge (`dev-protocol`).
|
| 193 |
+
|
| 194 |
+
---
|
| 195 |
+
|
| 196 |
+
## 8. Out of scope / non-goals
|
| 197 |
+
|
| 198 |
+
- Per-context (MODE|VIEW|RENDER) viz presets.
|
| 199 |
+
- Named palettes / export / import.
|
| 200 |
+
- Filtering NAVIGATION vs ANALYSIS differently.
|
| 201 |
+
- Reintroducing MESH.
|
| 202 |
+
- Changing z-score/tanh pipeline itself (only consume its output).
|
| 203 |
+
- i18n framework.
|
| 204 |
+
|
| 205 |
+
---
|
| 206 |
+
|
| 207 |
+
## 9. Implementation stages (suggested)
|
| 208 |
+
|
| 209 |
+
| Stage | Branch sketch | Deliverable |
|
| 210 |
+
| :--- | :--- | :--- |
|
| 211 |
+
| **A** | `feat/viz-controls-settings` | Defaults module + localStorage + pure filter/color helpers + Vitest (no UI yet or stub UI). |
|
| 212 |
+
| **B** | same or `feat/viz-controls-ui` | Panel UI under sliders; wire onChange → refreshRender with CPU color path working. |
|
| 213 |
+
| **C** | same | Shader uniforms for POINTS; ribbon/line filter completeness; polish Reset; docs/version. |
|
| 214 |
+
| **Gate** | — | Human smoke → OK → merge to `main`. |
|
| 215 |
+
|
| 216 |
+
Agent may combine A–C in one branch if the diff stays reviewable.
|
| 217 |
+
|
| 218 |
+
---
|
| 219 |
+
|
| 220 |
+
## 10. Open residual (only if blocked)
|
| 221 |
+
|
| 222 |
+
None expected. If shader uniform vs CPU attribute tradeoff is ambiguous for POINTS, prefer **uniforms** and ask only if WebGL constraints break the approach.
|
src/main.js
CHANGED
|
@@ -13,6 +13,11 @@ import { CustomModal } from './ui/CustomModal.js';
|
|
| 13 |
import { ThreadLabels } from './ui/ThreadLabels.js';
|
| 14 |
import { threadSlidersMarkup, wireThreadSliders, syncThreadSlidersFromConfig } from './ui/ThreadSliders.js';
|
| 15 |
import { resolveSpatialDefaults } from './ui/spatialSliderDefaults.js';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
import { ComparePanel, COMPARE_AUTO_PRESETS } from './ui/ComparePanel.js';
|
| 18 |
import { CollapsibleDock } from './ui/CollapsibleDock.js';
|
|
@@ -113,6 +118,7 @@ class VectorLabApp {
|
|
| 113 |
});
|
| 114 |
|
| 115 |
this.mountThreadSlidersUI();
|
|
|
|
| 116 |
|
| 117 |
// 6. Interaction Callbacks
|
| 118 |
this.interaction.onHoverCallback = (hoverData) => {
|
|
@@ -145,6 +151,23 @@ class VectorLabApp {
|
|
| 145 |
});
|
| 146 |
}
|
| 147 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
/**
|
| 149 |
* Apply camera + spatial defaults for current MODE/VIEW/RENDER and sync slider UI.
|
| 150 |
* Called when context changes so each combo can keep its own sweet spot.
|
|
@@ -202,7 +225,8 @@ class VectorLabApp {
|
|
| 202 |
state.compareData,
|
| 203 |
state.renderMode,
|
| 204 |
this.sliderConfig,
|
| 205 |
-
this.viewMode
|
|
|
|
| 206 |
);
|
| 207 |
this.threadLabels.setLabels(labels);
|
| 208 |
}
|
|
@@ -212,7 +236,8 @@ class VectorLabApp {
|
|
| 212 |
state.arithmeticData,
|
| 213 |
state.renderMode,
|
| 214 |
this.sliderConfig,
|
| 215 |
-
this.viewMode
|
|
|
|
| 216 |
);
|
| 217 |
this.threadLabels.setLabels(labels);
|
| 218 |
}
|
|
@@ -257,7 +282,8 @@ class VectorLabApp {
|
|
| 257 |
data,
|
| 258 |
state.renderMode,
|
| 259 |
this.sliderConfig,
|
| 260 |
-
this.viewMode
|
|
|
|
| 261 |
);
|
| 262 |
this.threadLabels.setLabels(labels);
|
| 263 |
|
|
@@ -277,7 +303,8 @@ class VectorLabApp {
|
|
| 277 |
data,
|
| 278 |
state.renderMode,
|
| 279 |
this.sliderConfig,
|
| 280 |
-
this.viewMode
|
|
|
|
| 281 |
);
|
| 282 |
this.threadLabels.setLabels(labels);
|
| 283 |
this.comparePanel.updateCompareResults(data);
|
|
|
|
| 13 |
import { ThreadLabels } from './ui/ThreadLabels.js';
|
| 14 |
import { threadSlidersMarkup, wireThreadSliders, syncThreadSlidersFromConfig } from './ui/ThreadSliders.js';
|
| 15 |
import { resolveSpatialDefaults } from './ui/spatialSliderDefaults.js';
|
| 16 |
+
import {
|
| 17 |
+
visualizationControlsMarkup,
|
| 18 |
+
wireVisualizationControls,
|
| 19 |
+
} from './ui/VisualizationControls.js';
|
| 20 |
+
import { loadVisualizationSettings } from './ui/visualizationControlsDefaults.js';
|
| 21 |
|
| 22 |
import { ComparePanel, COMPARE_AUTO_PRESETS } from './ui/ComparePanel.js';
|
| 23 |
import { CollapsibleDock } from './ui/CollapsibleDock.js';
|
|
|
|
| 118 |
});
|
| 119 |
|
| 120 |
this.mountThreadSlidersUI();
|
| 121 |
+
this.mountVisualizationControlsUI();
|
| 122 |
|
| 123 |
// 6. Interaction Callbacks
|
| 124 |
this.interaction.onHoverCallback = (hoverData) => {
|
|
|
|
| 151 |
});
|
| 152 |
}
|
| 153 |
|
| 154 |
+
/**
|
| 155 |
+
* Visualization panel (sign filter + color anchors) below Spatial Controls.
|
| 156 |
+
*/
|
| 157 |
+
mountVisualizationControlsUI() {
|
| 158 |
+
this.vizConfig = loadVisualizationSettings();
|
| 159 |
+
const wrapper = document.createElement('div');
|
| 160 |
+
wrapper.innerHTML = visualizationControlsMarkup(this.vizConfig);
|
| 161 |
+
const vizEl = wrapper.firstElementChild;
|
| 162 |
+
this.vizEl = vizEl;
|
| 163 |
+
// Below sliders, above AxisGizmo (V1).
|
| 164 |
+
this.rightDock.body.insertBefore(vizEl, this.axisGizmo.container);
|
| 165 |
+
|
| 166 |
+
wireVisualizationControls(vizEl, this.vizConfig, () => {
|
| 167 |
+
this.refreshRender();
|
| 168 |
+
});
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
/**
|
| 172 |
* Apply camera + spatial defaults for current MODE/VIEW/RENDER and sync slider UI.
|
| 173 |
* Called when context changes so each combo can keep its own sweet spot.
|
|
|
|
| 225 |
state.compareData,
|
| 226 |
state.renderMode,
|
| 227 |
this.sliderConfig,
|
| 228 |
+
this.viewMode,
|
| 229 |
+
this.vizConfig
|
| 230 |
);
|
| 231 |
this.threadLabels.setLabels(labels);
|
| 232 |
}
|
|
|
|
| 236 |
state.arithmeticData,
|
| 237 |
state.renderMode,
|
| 238 |
this.sliderConfig,
|
| 239 |
+
this.viewMode,
|
| 240 |
+
this.vizConfig
|
| 241 |
);
|
| 242 |
this.threadLabels.setLabels(labels);
|
| 243 |
}
|
|
|
|
| 282 |
data,
|
| 283 |
state.renderMode,
|
| 284 |
this.sliderConfig,
|
| 285 |
+
this.viewMode,
|
| 286 |
+
this.vizConfig
|
| 287 |
);
|
| 288 |
this.threadLabels.setLabels(labels);
|
| 289 |
|
|
|
|
| 303 |
data,
|
| 304 |
state.renderMode,
|
| 305 |
this.sliderConfig,
|
| 306 |
+
this.viewMode,
|
| 307 |
+
this.vizConfig
|
| 308 |
);
|
| 309 |
this.threadLabels.setLabels(labels);
|
| 310 |
this.comparePanel.updateCompareResults(data);
|
src/style.css
CHANGED
|
@@ -689,7 +689,8 @@ canvas {
|
|
| 689 |
/* =============================================================================
|
| 690 |
Thread Geometry Spatial Sliders 3D Styling (inside #right-dock)
|
| 691 |
============================================================================= */
|
| 692 |
-
#thread-sliders-container
|
|
|
|
| 693 |
position: relative;
|
| 694 |
top: auto;
|
| 695 |
right: auto;
|
|
@@ -753,6 +754,131 @@ canvas {
|
|
| 753 |
cursor: pointer;
|
| 754 |
}
|
| 755 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 756 |
/* =============================================================================
|
| 757 |
Floating Thread Labels Overlay (Glassmorphic Badges)
|
| 758 |
============================================================================= */
|
|
@@ -1075,7 +1201,8 @@ canvas {
|
|
| 1075 |
padding: 8px 6px;
|
| 1076 |
}
|
| 1077 |
|
| 1078 |
-
#thread-sliders-container
|
|
|
|
| 1079 |
width: min(260px, calc(100vw - 64px));
|
| 1080 |
padding: 12px;
|
| 1081 |
gap: 10px;
|
|
|
|
| 689 |
/* =============================================================================
|
| 690 |
Thread Geometry Spatial Sliders 3D Styling (inside #right-dock)
|
| 691 |
============================================================================= */
|
| 692 |
+
#thread-sliders-container,
|
| 693 |
+
#visualization-controls-container {
|
| 694 |
position: relative;
|
| 695 |
top: auto;
|
| 696 |
right: auto;
|
|
|
|
| 754 |
cursor: pointer;
|
| 755 |
}
|
| 756 |
|
| 757 |
+
/* Visualization Controls (sign filter + color anchors) */
|
| 758 |
+
.viz-filter-group {
|
| 759 |
+
display: flex;
|
| 760 |
+
flex-direction: column;
|
| 761 |
+
gap: 8px;
|
| 762 |
+
}
|
| 763 |
+
|
| 764 |
+
.viz-filter-label {
|
| 765 |
+
font-size: 0.8rem;
|
| 766 |
+
font-weight: 600;
|
| 767 |
+
color: var(--text-muted);
|
| 768 |
+
}
|
| 769 |
+
|
| 770 |
+
.viz-segmented {
|
| 771 |
+
display: flex;
|
| 772 |
+
width: 100%;
|
| 773 |
+
border: 1px solid var(--panel-border);
|
| 774 |
+
border-radius: 8px;
|
| 775 |
+
overflow: hidden;
|
| 776 |
+
}
|
| 777 |
+
|
| 778 |
+
.viz-seg-option {
|
| 779 |
+
flex: 1;
|
| 780 |
+
position: relative;
|
| 781 |
+
text-align: center;
|
| 782 |
+
cursor: pointer;
|
| 783 |
+
margin: 0;
|
| 784 |
+
}
|
| 785 |
+
|
| 786 |
+
.viz-seg-option input {
|
| 787 |
+
position: absolute;
|
| 788 |
+
opacity: 0;
|
| 789 |
+
pointer-events: none;
|
| 790 |
+
}
|
| 791 |
+
|
| 792 |
+
.viz-seg-option span {
|
| 793 |
+
display: block;
|
| 794 |
+
padding: 6px 4px;
|
| 795 |
+
font-size: 0.72rem;
|
| 796 |
+
font-weight: 700;
|
| 797 |
+
color: var(--text-muted);
|
| 798 |
+
background: transparent;
|
| 799 |
+
border-right: 1px solid var(--panel-border);
|
| 800 |
+
transition: background 0.15s ease, color 0.15s ease;
|
| 801 |
+
}
|
| 802 |
+
|
| 803 |
+
.viz-seg-option:last-child span {
|
| 804 |
+
border-right: none;
|
| 805 |
+
}
|
| 806 |
+
|
| 807 |
+
.viz-seg-option input:checked + span {
|
| 808 |
+
background: rgba(0, 229, 255, 0.18);
|
| 809 |
+
color: var(--accent-cyan);
|
| 810 |
+
}
|
| 811 |
+
|
| 812 |
+
.viz-seg-option:hover span {
|
| 813 |
+
color: var(--text-main, #e8eef7);
|
| 814 |
+
}
|
| 815 |
+
|
| 816 |
+
.viz-colors-section {
|
| 817 |
+
display: flex;
|
| 818 |
+
flex-direction: column;
|
| 819 |
+
gap: 8px;
|
| 820 |
+
}
|
| 821 |
+
|
| 822 |
+
.viz-colors-title {
|
| 823 |
+
font-size: 0.8rem;
|
| 824 |
+
font-weight: 600;
|
| 825 |
+
color: var(--text-muted);
|
| 826 |
+
}
|
| 827 |
+
|
| 828 |
+
.viz-color-row {
|
| 829 |
+
display: grid;
|
| 830 |
+
grid-template-columns: 28px 36px 1fr;
|
| 831 |
+
align-items: center;
|
| 832 |
+
gap: 8px;
|
| 833 |
+
}
|
| 834 |
+
|
| 835 |
+
.viz-color-row label {
|
| 836 |
+
font-size: 0.78rem;
|
| 837 |
+
font-weight: 700;
|
| 838 |
+
color: var(--accent-gold);
|
| 839 |
+
font-family: monospace;
|
| 840 |
+
}
|
| 841 |
+
|
| 842 |
+
.viz-color-swatch {
|
| 843 |
+
width: 32px;
|
| 844 |
+
height: 24px;
|
| 845 |
+
padding: 0;
|
| 846 |
+
border: 1px solid var(--panel-border);
|
| 847 |
+
border-radius: 4px;
|
| 848 |
+
background: transparent;
|
| 849 |
+
cursor: pointer;
|
| 850 |
+
}
|
| 851 |
+
|
| 852 |
+
.viz-color-hex {
|
| 853 |
+
width: 100%;
|
| 854 |
+
font-family: monospace;
|
| 855 |
+
font-size: 0.78rem;
|
| 856 |
+
font-weight: 600;
|
| 857 |
+
color: var(--accent-gold);
|
| 858 |
+
background: rgba(0, 0, 0, 0.35);
|
| 859 |
+
border: 1px solid var(--panel-border);
|
| 860 |
+
border-radius: 4px;
|
| 861 |
+
padding: 4px 6px;
|
| 862 |
+
}
|
| 863 |
+
|
| 864 |
+
.viz-reset-btn {
|
| 865 |
+
align-self: flex-start;
|
| 866 |
+
margin-top: 2px;
|
| 867 |
+
padding: 6px 12px;
|
| 868 |
+
font-size: 0.75rem;
|
| 869 |
+
font-weight: 700;
|
| 870 |
+
color: var(--accent-cyan);
|
| 871 |
+
background: transparent;
|
| 872 |
+
border: 1px solid var(--panel-border);
|
| 873 |
+
border-radius: 6px;
|
| 874 |
+
cursor: pointer;
|
| 875 |
+
}
|
| 876 |
+
|
| 877 |
+
.viz-reset-btn:hover {
|
| 878 |
+
border-color: var(--accent-cyan);
|
| 879 |
+
background: rgba(0, 229, 255, 0.1);
|
| 880 |
+
}
|
| 881 |
+
|
| 882 |
/* =============================================================================
|
| 883 |
Floating Thread Labels Overlay (Glassmorphic Badges)
|
| 884 |
============================================================================= */
|
|
|
|
| 1201 |
padding: 8px 6px;
|
| 1202 |
}
|
| 1203 |
|
| 1204 |
+
#thread-sliders-container,
|
| 1205 |
+
#visualization-controls-container {
|
| 1206 |
width: min(260px, calc(100vw - 64px));
|
| 1207 |
padding: 12px;
|
| 1208 |
gap: 10px;
|
src/ui/Navbar.js
CHANGED
|
@@ -16,7 +16,7 @@ export class Navbar {
|
|
| 16 |
<div class="logo-icon">🌐</div>
|
| 17 |
<div class="title-group">
|
| 18 |
<h1>VECTORLAB <span class="accent-3d">3D</span></h1>
|
| 19 |
-
<span class="version-tag">v1.
|
| 20 |
</div>
|
| 21 |
</div>
|
| 22 |
|
|
|
|
| 16 |
<div class="logo-icon">🌐</div>
|
| 17 |
<div class="title-group">
|
| 18 |
<h1>VECTORLAB <span class="accent-3d">3D</span></h1>
|
| 19 |
+
<span class="version-tag">v1.7.0</span>
|
| 20 |
</div>
|
| 21 |
</div>
|
| 22 |
|
src/ui/VisualizationControls.js
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Visualization panel: sign filter + divergent color anchors (right dock).
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
import {
|
| 6 |
+
DEFAULT_VISUALIZATION_SETTINGS,
|
| 7 |
+
normalizeHex,
|
| 8 |
+
normalizeFilterMode,
|
| 9 |
+
resolveVisualizationSettings,
|
| 10 |
+
saveVisualizationSettings,
|
| 11 |
+
resetVisualizationSettings,
|
| 12 |
+
} from './visualizationControlsDefaults.js';
|
| 13 |
+
|
| 14 |
+
/**
|
| 15 |
+
* @param {import('./visualizationControlsDefaults.js').VisualizationSettings} [config]
|
| 16 |
+
* @returns {string}
|
| 17 |
+
*/
|
| 18 |
+
export function visualizationControlsMarkup(config = DEFAULT_VISUALIZATION_SETTINGS) {
|
| 19 |
+
const s = resolveVisualizationSettings(config);
|
| 20 |
+
const checked = (mode) => (s.vizFilterMode === mode ? 'checked' : '');
|
| 21 |
+
|
| 22 |
+
return `
|
| 23 |
+
<div id="visualization-controls-container" class="section-card">
|
| 24 |
+
<h3 class="sliders-title">Visualization</h3>
|
| 25 |
+
|
| 26 |
+
<div class="viz-filter-group">
|
| 27 |
+
<span class="viz-filter-label">Show:</span>
|
| 28 |
+
<div class="viz-segmented" role="radiogroup" aria-label="Sign filter">
|
| 29 |
+
<label class="viz-seg-option">
|
| 30 |
+
<input type="radio" name="viz-filter-mode" value="all" ${checked('all')}>
|
| 31 |
+
<span>All</span>
|
| 32 |
+
</label>
|
| 33 |
+
<label class="viz-seg-option">
|
| 34 |
+
<input type="radio" name="viz-filter-mode" value="positive" ${checked('positive')}>
|
| 35 |
+
<span>+ Only</span>
|
| 36 |
+
</label>
|
| 37 |
+
<label class="viz-seg-option">
|
| 38 |
+
<input type="radio" name="viz-filter-mode" value="negative" ${checked('negative')}>
|
| 39 |
+
<span>− Only</span>
|
| 40 |
+
</label>
|
| 41 |
+
</div>
|
| 42 |
+
</div>
|
| 43 |
+
|
| 44 |
+
<div class="viz-colors-section">
|
| 45 |
+
<div class="viz-colors-title">Colors</div>
|
| 46 |
+
<div class="viz-color-row">
|
| 47 |
+
<label for="viz-color-positive-hex">+1</label>
|
| 48 |
+
<input type="color" id="viz-color-positive-swatch" class="viz-color-swatch" value="${s.colorPositive}" title="Positive (+1)" aria-label="Positive color swatch">
|
| 49 |
+
<input type="text" id="viz-color-positive-hex" class="viz-color-hex" value="${s.colorPositive}" maxlength="7" spellcheck="false" placeholder="#FFE600" title="Positive (+1) hex">
|
| 50 |
+
</div>
|
| 51 |
+
<div class="viz-color-row">
|
| 52 |
+
<label for="viz-color-zero-hex">0</label>
|
| 53 |
+
<input type="color" id="viz-color-zero-swatch" class="viz-color-swatch" value="${s.colorZero}" title="Zero (0)" aria-label="Zero color swatch">
|
| 54 |
+
<input type="text" id="viz-color-zero-hex" class="viz-color-hex" value="${s.colorZero}" maxlength="7" spellcheck="false" placeholder="#000000" title="Zero (0) hex">
|
| 55 |
+
</div>
|
| 56 |
+
<div class="viz-color-row">
|
| 57 |
+
<label for="viz-color-negative-hex">−1</label>
|
| 58 |
+
<input type="color" id="viz-color-negative-swatch" class="viz-color-swatch" value="${s.colorNegative}" title="Negative (−1)" aria-label="Negative color swatch">
|
| 59 |
+
<input type="text" id="viz-color-negative-hex" class="viz-color-hex" value="${s.colorNegative}" maxlength="7" spellcheck="false" placeholder="#9900E6" title="Negative (−1) hex">
|
| 60 |
+
</div>
|
| 61 |
+
</div>
|
| 62 |
+
|
| 63 |
+
<button type="button" id="viz-reset-btn" class="viz-reset-btn">Reset</button>
|
| 64 |
+
</div>
|
| 65 |
+
`;
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
/**
|
| 69 |
+
* Sync form controls from config object.
|
| 70 |
+
* @param {HTMLElement} container
|
| 71 |
+
* @param {import('./visualizationControlsDefaults.js').VisualizationSettings} config
|
| 72 |
+
*/
|
| 73 |
+
export function syncVisualizationControlsFromConfig(container, config) {
|
| 74 |
+
if (!container || !config) return;
|
| 75 |
+
const s = resolveVisualizationSettings(config);
|
| 76 |
+
for (const radio of container.querySelectorAll('input[name="viz-filter-mode"]')) {
|
| 77 |
+
radio.checked = radio.value === s.vizFilterMode;
|
| 78 |
+
}
|
| 79 |
+
const pairs = [
|
| 80 |
+
['positive', s.colorPositive],
|
| 81 |
+
['zero', s.colorZero],
|
| 82 |
+
['negative', s.colorNegative],
|
| 83 |
+
];
|
| 84 |
+
for (const [key, hex] of pairs) {
|
| 85 |
+
const swatch = container.querySelector(`#viz-color-${key}-swatch`);
|
| 86 |
+
const text = container.querySelector(`#viz-color-${key}-hex`);
|
| 87 |
+
if (swatch) swatch.value = hex;
|
| 88 |
+
if (text) text.value = hex;
|
| 89 |
+
}
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
/**
|
| 93 |
+
* Wire filter radios, hex/swatch inputs, and Reset.
|
| 94 |
+
* Mutates `config` in place; persists to localStorage; calls onChange.
|
| 95 |
+
*
|
| 96 |
+
* @param {HTMLElement} container
|
| 97 |
+
* @param {import('./visualizationControlsDefaults.js').VisualizationSettings} config
|
| 98 |
+
* @param {Function} [onChangeCallback]
|
| 99 |
+
* @param {{ storage?: Storage|null }} [options]
|
| 100 |
+
*/
|
| 101 |
+
export function wireVisualizationControls(container, config, onChangeCallback = null, options = {}) {
|
| 102 |
+
if (!container || !config) return;
|
| 103 |
+
|
| 104 |
+
const storage = options.storage !== undefined
|
| 105 |
+
? options.storage
|
| 106 |
+
: (typeof localStorage !== 'undefined' ? localStorage : null);
|
| 107 |
+
|
| 108 |
+
const emit = () => {
|
| 109 |
+
saveVisualizationSettings(config, storage);
|
| 110 |
+
if (typeof onChangeCallback === 'function') onChangeCallback(config);
|
| 111 |
+
};
|
| 112 |
+
|
| 113 |
+
for (const radio of container.querySelectorAll('input[name="viz-filter-mode"]')) {
|
| 114 |
+
radio.addEventListener('change', () => {
|
| 115 |
+
if (!radio.checked) return;
|
| 116 |
+
config.vizFilterMode = normalizeFilterMode(radio.value);
|
| 117 |
+
emit();
|
| 118 |
+
});
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
const bindColor = (key, configKey) => {
|
| 122 |
+
const swatch = container.querySelector(`#viz-color-${key}-swatch`);
|
| 123 |
+
const text = container.querySelector(`#viz-color-${key}-hex`);
|
| 124 |
+
if (!swatch || !text) return;
|
| 125 |
+
|
| 126 |
+
swatch.addEventListener('input', () => {
|
| 127 |
+
const hex = normalizeHex(swatch.value);
|
| 128 |
+
if (!hex) return;
|
| 129 |
+
config[configKey] = hex;
|
| 130 |
+
text.value = hex;
|
| 131 |
+
emit();
|
| 132 |
+
});
|
| 133 |
+
|
| 134 |
+
const commitHex = () => {
|
| 135 |
+
const hex = normalizeHex(text.value);
|
| 136 |
+
if (!hex) {
|
| 137 |
+
text.value = config[configKey];
|
| 138 |
+
return;
|
| 139 |
+
}
|
| 140 |
+
config[configKey] = hex;
|
| 141 |
+
swatch.value = hex;
|
| 142 |
+
text.value = hex;
|
| 143 |
+
emit();
|
| 144 |
+
};
|
| 145 |
+
|
| 146 |
+
text.addEventListener('change', commitHex);
|
| 147 |
+
text.addEventListener('keydown', (e) => {
|
| 148 |
+
if (e.key === 'Enter') {
|
| 149 |
+
e.preventDefault();
|
| 150 |
+
commitHex();
|
| 151 |
+
}
|
| 152 |
+
});
|
| 153 |
+
};
|
| 154 |
+
|
| 155 |
+
bindColor('positive', 'colorPositive');
|
| 156 |
+
bindColor('zero', 'colorZero');
|
| 157 |
+
bindColor('negative', 'colorNegative');
|
| 158 |
+
|
| 159 |
+
const resetBtn = container.querySelector('#viz-reset-btn');
|
| 160 |
+
if (resetBtn) {
|
| 161 |
+
resetBtn.addEventListener('click', () => {
|
| 162 |
+
const defaults = resetVisualizationSettings(storage);
|
| 163 |
+
Object.assign(config, defaults);
|
| 164 |
+
syncVisualizationControlsFromConfig(container, config);
|
| 165 |
+
if (typeof onChangeCallback === 'function') onChangeCallback(config);
|
| 166 |
+
});
|
| 167 |
+
}
|
| 168 |
+
}
|
src/ui/visualizationControlsDefaults.js
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Visualization Controls defaults, hex validation, and localStorage persistence.
|
| 3 |
+
* Global (not per MODE|VIEW|RENDER). Filter applies to normalized activations (post z-score/tanh).
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
/** @typedef {'all' | 'positive' | 'negative'} VizFilterMode */
|
| 7 |
+
|
| 8 |
+
/** @typedef {{
|
| 9 |
+
* vizFilterMode: VizFilterMode,
|
| 10 |
+
* colorPositive: string,
|
| 11 |
+
* colorZero: string,
|
| 12 |
+
* colorNegative: string,
|
| 13 |
+
* }} VisualizationSettings */
|
| 14 |
+
|
| 15 |
+
export const VIZ_STORAGE_PREFIX = 'vl3d.viz.';
|
| 16 |
+
|
| 17 |
+
export const VIZ_STORAGE_KEYS = Object.freeze({
|
| 18 |
+
filter: `${VIZ_STORAGE_PREFIX}filter`,
|
| 19 |
+
colorPositive: `${VIZ_STORAGE_PREFIX}colorPositive`,
|
| 20 |
+
colorZero: `${VIZ_STORAGE_PREFIX}colorZero`,
|
| 21 |
+
colorNegative: `${VIZ_STORAGE_PREFIX}colorNegative`,
|
| 22 |
+
});
|
| 23 |
+
|
| 24 |
+
/** Default anchors match former ramp endpoints (§0.2). */
|
| 25 |
+
export const DEFAULT_VIZ_COLORS = Object.freeze({
|
| 26 |
+
colorPositive: '#FFE600',
|
| 27 |
+
colorZero: '#000000',
|
| 28 |
+
colorNegative: '#9900E6',
|
| 29 |
+
});
|
| 30 |
+
|
| 31 |
+
export const DEFAULT_VIZ_FILTER = /** @type {VizFilterMode} */ ('all');
|
| 32 |
+
|
| 33 |
+
/** @type {VisualizationSettings} */
|
| 34 |
+
export const DEFAULT_VISUALIZATION_SETTINGS = Object.freeze({
|
| 35 |
+
vizFilterMode: DEFAULT_VIZ_FILTER,
|
| 36 |
+
...DEFAULT_VIZ_COLORS,
|
| 37 |
+
});
|
| 38 |
+
|
| 39 |
+
const HEX_RE = /^#([0-9A-Fa-f]{6})$/;
|
| 40 |
+
|
| 41 |
+
/**
|
| 42 |
+
* @param {unknown} value
|
| 43 |
+
* @returns {boolean}
|
| 44 |
+
*/
|
| 45 |
+
export function isValidHex(value) {
|
| 46 |
+
return typeof value === 'string' && HEX_RE.test(value);
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
/**
|
| 50 |
+
* Normalize hex to uppercase #RRGGBB, or null if invalid.
|
| 51 |
+
* @param {unknown} value
|
| 52 |
+
* @returns {string|null}
|
| 53 |
+
*/
|
| 54 |
+
export function normalizeHex(value) {
|
| 55 |
+
if (typeof value !== 'string') return null;
|
| 56 |
+
const trimmed = value.trim();
|
| 57 |
+
const withHash = trimmed.startsWith('#') ? trimmed : `#${trimmed}`;
|
| 58 |
+
if (!isValidHex(withHash)) return null;
|
| 59 |
+
return withHash.toUpperCase();
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
/**
|
| 63 |
+
* Parse #RRGGBB → RGB in [0, 1].
|
| 64 |
+
* @param {string} hex
|
| 65 |
+
* @returns {{ r: number, g: number, b: number }|null}
|
| 66 |
+
*/
|
| 67 |
+
export function hexToRgb01(hex) {
|
| 68 |
+
const normalized = normalizeHex(hex);
|
| 69 |
+
if (!normalized) return null;
|
| 70 |
+
const n = parseInt(normalized.slice(1), 16);
|
| 71 |
+
return {
|
| 72 |
+
r: ((n >> 16) & 0xff) / 255,
|
| 73 |
+
g: ((n >> 8) & 0xff) / 255,
|
| 74 |
+
b: (n & 0xff) / 255,
|
| 75 |
+
};
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
/**
|
| 79 |
+
* @param {unknown} mode
|
| 80 |
+
* @returns {VizFilterMode}
|
| 81 |
+
*/
|
| 82 |
+
export function normalizeFilterMode(mode) {
|
| 83 |
+
if (mode === 'positive' || mode === 'negative' || mode === 'all') return mode;
|
| 84 |
+
return DEFAULT_VIZ_FILTER;
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
/**
|
| 88 |
+
* @param {Partial<VisualizationSettings>|null|undefined} partial
|
| 89 |
+
* @returns {VisualizationSettings}
|
| 90 |
+
*/
|
| 91 |
+
export function resolveVisualizationSettings(partial = null) {
|
| 92 |
+
const src = partial && typeof partial === 'object' ? partial : {};
|
| 93 |
+
return {
|
| 94 |
+
vizFilterMode: normalizeFilterMode(src.vizFilterMode),
|
| 95 |
+
colorPositive: normalizeHex(src.colorPositive) || DEFAULT_VIZ_COLORS.colorPositive,
|
| 96 |
+
colorZero: normalizeHex(src.colorZero) || DEFAULT_VIZ_COLORS.colorZero,
|
| 97 |
+
colorNegative: normalizeHex(src.colorNegative) || DEFAULT_VIZ_COLORS.colorNegative,
|
| 98 |
+
};
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
/**
|
| 102 |
+
* @param {Storage|null|undefined} storage
|
| 103 |
+
* @returns {VisualizationSettings}
|
| 104 |
+
*/
|
| 105 |
+
export function loadVisualizationSettings(storage = typeof localStorage !== 'undefined' ? localStorage : null) {
|
| 106 |
+
if (!storage) return { ...DEFAULT_VISUALIZATION_SETTINGS };
|
| 107 |
+
try {
|
| 108 |
+
return resolveVisualizationSettings({
|
| 109 |
+
vizFilterMode: storage.getItem(VIZ_STORAGE_KEYS.filter),
|
| 110 |
+
colorPositive: storage.getItem(VIZ_STORAGE_KEYS.colorPositive),
|
| 111 |
+
colorZero: storage.getItem(VIZ_STORAGE_KEYS.colorZero),
|
| 112 |
+
colorNegative: storage.getItem(VIZ_STORAGE_KEYS.colorNegative),
|
| 113 |
+
});
|
| 114 |
+
} catch {
|
| 115 |
+
return { ...DEFAULT_VISUALIZATION_SETTINGS };
|
| 116 |
+
}
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
/**
|
| 120 |
+
* @param {VisualizationSettings} settings
|
| 121 |
+
* @param {Storage|null|undefined} storage
|
| 122 |
+
*/
|
| 123 |
+
export function saveVisualizationSettings(settings, storage = typeof localStorage !== 'undefined' ? localStorage : null) {
|
| 124 |
+
if (!storage) return;
|
| 125 |
+
const resolved = resolveVisualizationSettings(settings);
|
| 126 |
+
try {
|
| 127 |
+
storage.setItem(VIZ_STORAGE_KEYS.filter, resolved.vizFilterMode);
|
| 128 |
+
storage.setItem(VIZ_STORAGE_KEYS.colorPositive, resolved.colorPositive);
|
| 129 |
+
storage.setItem(VIZ_STORAGE_KEYS.colorZero, resolved.colorZero);
|
| 130 |
+
storage.setItem(VIZ_STORAGE_KEYS.colorNegative, resolved.colorNegative);
|
| 131 |
+
} catch {
|
| 132 |
+
// Quota / private mode — ignore
|
| 133 |
+
}
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
/**
|
| 137 |
+
* @param {Storage|null|undefined} storage
|
| 138 |
+
* @returns {VisualizationSettings}
|
| 139 |
+
*/
|
| 140 |
+
export function resetVisualizationSettings(storage = typeof localStorage !== 'undefined' ? localStorage : null) {
|
| 141 |
+
const defaults = { ...DEFAULT_VISUALIZATION_SETTINGS };
|
| 142 |
+
saveVisualizationSettings(defaults, storage);
|
| 143 |
+
return defaults;
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
/**
|
| 147 |
+
* RGB01 anchors for DivergentShading from settings hex trio.
|
| 148 |
+
* @param {VisualizationSettings} settings
|
| 149 |
+
* @returns {{ positive: {r:number,g:number,b:number}, zero: {r:number,g:number,b:number}, negative: {r:number,g:number,b:number} }}
|
| 150 |
+
*/
|
| 151 |
+
export function anchorsFromSettings(settings) {
|
| 152 |
+
const resolved = resolveVisualizationSettings(settings);
|
| 153 |
+
return {
|
| 154 |
+
positive: hexToRgb01(resolved.colorPositive),
|
| 155 |
+
zero: hexToRgb01(resolved.colorZero),
|
| 156 |
+
negative: hexToRgb01(resolved.colorNegative),
|
| 157 |
+
};
|
| 158 |
+
}
|
src/visualizer/DivergentShading.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
| 1 |
import * as THREE from 'three';
|
|
|
|
|
|
|
| 2 |
|
| 3 |
/**
|
| 4 |
* Calculates Z-score standardized and Tanh normalized activations across a dataset.
|
|
@@ -31,60 +33,70 @@ export function calculateZScoreNormalized(values, scaleFactor = 1.2) {
|
|
| 31 |
}
|
| 32 |
|
| 33 |
/**
|
| 34 |
-
*
|
| 35 |
-
*
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
*
|
| 40 |
* @param {number} val - Activation value (v)
|
| 41 |
* @param {number} [absMax=1.0] - Maximum absolute value for normalization
|
| 42 |
-
* @
|
|
|
|
| 43 |
*/
|
| 44 |
-
export function getDivergentColor(val, absMax = 1.0) {
|
| 45 |
const denom = absMax > 1e-9 ? absMax : 1.0;
|
| 46 |
let t = Math.max(-1.0, Math.min(1.0, val / denom));
|
| 47 |
const absT = Math.abs(t);
|
|
|
|
| 48 |
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
return { r: 0.0, g: 0.0, b: 0.0, alpha: 0.05 };
|
| 52 |
}
|
| 53 |
|
| 54 |
const alpha = Math.min(Math.max(Math.pow(absT, 1.2), 0.05), 1.0);
|
|
|
|
|
|
|
|
|
|
| 55 |
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
if (t > 0) {
|
| 59 |
-
// Positive (0 to +1): Black (0.0) -> Orange (+0.50) -> Incandescent Yellow (+1.00)
|
| 60 |
-
if (t < 0.50) {
|
| 61 |
-
const k = t / 0.50;
|
| 62 |
-
r = 1.0 * k;
|
| 63 |
-
g = 0.5 * k;
|
| 64 |
-
b = 0.0;
|
| 65 |
-
} else {
|
| 66 |
-
const k = (t - 0.50) / 0.50;
|
| 67 |
-
r = 1.0;
|
| 68 |
-
g = 0.5 + 0.45 * k;
|
| 69 |
-
b = 0.0;
|
| 70 |
-
}
|
| 71 |
-
} else {
|
| 72 |
-
// Negative (0 to -1): Black (0.0) -> Electric Blue (-0.50) -> Neon Violet (-1.00)
|
| 73 |
-
const f = -t;
|
| 74 |
-
if (f < 0.50) {
|
| 75 |
-
const k = f / 0.50;
|
| 76 |
-
r = 0.0;
|
| 77 |
-
g = 0.25 * k;
|
| 78 |
-
b = 1.0 * k;
|
| 79 |
-
} else {
|
| 80 |
-
const k = (f - 0.50) / 0.50;
|
| 81 |
-
r = 0.6 * k;
|
| 82 |
-
g = 0.25 * (1.0 - k);
|
| 83 |
-
b = 1.0 * (1.0 - k) + 0.9 * k;
|
| 84 |
-
}
|
| 85 |
-
}
|
| 86 |
-
|
| 87 |
-
return { r, g, b, alpha };
|
| 88 |
}
|
| 89 |
|
| 90 |
/**
|
|
@@ -105,13 +117,17 @@ void main() {
|
|
| 105 |
`;
|
| 106 |
|
| 107 |
/**
|
| 108 |
-
* GLSL Fragment Shader
|
| 109 |
-
*
|
| 110 |
-
* Negative (0 to -1): Black (0.0) -> Electric Blue (-0.50) -> Neon Violet (-1.00)
|
| 111 |
*/
|
| 112 |
export const divergentFragmentShader = `
|
| 113 |
varying float vIntensity;
|
| 114 |
uniform float baseOpacity;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
|
| 116 |
void main() {
|
| 117 |
vec2 coord = abs(gl_PointCoord - vec2(0.5));
|
|
@@ -123,36 +139,24 @@ void main() {
|
|
| 123 |
float t = clamp(vIntensity, -1.0, 1.0);
|
| 124 |
float absT = abs(t);
|
| 125 |
|
| 126 |
-
//
|
| 127 |
-
if (
|
| 128 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
return;
|
| 130 |
}
|
| 131 |
|
| 132 |
float dynamicAlpha = clamp(pow(absT, 1.1), 0.05, 1.0) * baseOpacity;
|
| 133 |
|
| 134 |
-
vec3 color =
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
// Positive (0 to +1): Black -> Orange (#FF8000) -> Yellow (#FFE600)
|
| 138 |
-
if (t < 0.50) {
|
| 139 |
-
float k = t / 0.50;
|
| 140 |
-
color = mix(vec3(0.0), vec3(1.0, 0.5, 0.0), k);
|
| 141 |
-
} else {
|
| 142 |
-
float k = (t - 0.50) / 0.50;
|
| 143 |
-
color = mix(vec3(1.0, 0.5, 0.0), vec3(1.0, 0.95, 0.0), k);
|
| 144 |
-
}
|
| 145 |
-
} else {
|
| 146 |
-
// Negative (0 to -1): Black -> Electric Blue (#0040FF) -> Neon Violet (#9900E6)
|
| 147 |
-
float f = -t;
|
| 148 |
-
if (f < 0.50) {
|
| 149 |
-
float k = f / 0.50;
|
| 150 |
-
color = mix(vec3(0.0), vec3(0.0, 0.25, 1.0), k);
|
| 151 |
-
} else {
|
| 152 |
-
float k = (f - 0.50) / 0.50;
|
| 153 |
-
color = mix(vec3(0.0, 0.25, 1.0), vec3(0.6, 0.0, 0.9), k);
|
| 154 |
-
}
|
| 155 |
-
}
|
| 156 |
|
| 157 |
vec3 finalColor = color * solidEdge;
|
| 158 |
float alpha = dynamicAlpha * solidEdge;
|
|
@@ -161,23 +165,64 @@ void main() {
|
|
| 161 |
}
|
| 162 |
`;
|
| 163 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
/**
|
| 165 |
* Factory function creating a customized THREE.ShaderMaterial for GPU divergent shading.
|
| 166 |
*
|
| 167 |
* @param {number} [pointSize=10.0] - Base size of points
|
| 168 |
* @param {number} [baseOpacity=0.7] - Overall opacity scalar
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
* @returns {THREE.ShaderMaterial}
|
| 170 |
*/
|
| 171 |
-
export function createDivergentMaterial(pointSize = 10.0, baseOpacity = 0.7) {
|
|
|
|
| 172 |
return new THREE.ShaderMaterial({
|
| 173 |
uniforms: {
|
| 174 |
pointSize: { value: pointSize },
|
| 175 |
-
baseOpacity: { value: baseOpacity }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
},
|
| 177 |
vertexShader: divergentVertexShader,
|
| 178 |
fragmentShader: divergentFragmentShader,
|
| 179 |
transparent: true,
|
| 180 |
depthWrite: false,
|
| 181 |
-
blending: THREE.NormalBlending
|
| 182 |
});
|
| 183 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import * as THREE from 'three';
|
| 2 |
+
import { DEFAULT_VISUALIZATION_SETTINGS, anchorsFromSettings, hexToRgb01 } from '../ui/visualizationControlsDefaults.js';
|
| 3 |
+
import { NEAR_ZERO_EPS } from './activationFilter.js';
|
| 4 |
|
| 5 |
/**
|
| 6 |
* Calculates Z-score standardized and Tanh normalized activations across a dataset.
|
|
|
|
| 33 |
}
|
| 34 |
|
| 35 |
/**
|
| 36 |
+
* Default RGB01 anchors (product defaults). Mid-stop orange/blue ramps are retired (C1).
|
| 37 |
+
*/
|
| 38 |
+
export const DEFAULT_COLOR_ANCHORS = anchorsFromSettings(DEFAULT_VISUALIZATION_SETTINGS);
|
| 39 |
+
|
| 40 |
+
/**
|
| 41 |
+
* Linear RGB lerp.
|
| 42 |
+
* @param {{r:number,g:number,b:number}} a
|
| 43 |
+
* @param {{r:number,g:number,b:number}} b
|
| 44 |
+
* @param {number} k
|
| 45 |
+
*/
|
| 46 |
+
function lerpRgb(a, b, k) {
|
| 47 |
+
return {
|
| 48 |
+
r: a.r + (b.r - a.r) * k,
|
| 49 |
+
g: a.g + (b.g - a.g) * k,
|
| 50 |
+
b: a.b + (b.b - a.b) * k,
|
| 51 |
+
};
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
/**
|
| 55 |
+
* Resolve anchors object from optional override (RGB01 or settings-like).
|
| 56 |
+
* @param {object|null|undefined} anchors
|
| 57 |
+
*/
|
| 58 |
+
export function resolveColorAnchors(anchors) {
|
| 59 |
+
if (!anchors) return DEFAULT_COLOR_ANCHORS;
|
| 60 |
+
if (anchors.positive && anchors.zero && anchors.negative) {
|
| 61 |
+
return {
|
| 62 |
+
positive: anchors.positive,
|
| 63 |
+
zero: anchors.zero,
|
| 64 |
+
negative: anchors.negative,
|
| 65 |
+
};
|
| 66 |
+
}
|
| 67 |
+
// Hex settings shape
|
| 68 |
+
if (anchors.colorPositive || anchors.colorZero || anchors.colorNegative) {
|
| 69 |
+
return anchorsFromSettings(anchors);
|
| 70 |
+
}
|
| 71 |
+
return DEFAULT_COLOR_ANCHORS;
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
/**
|
| 75 |
+
* CPU color and dynamic opacity from normalized-ish activation.
|
| 76 |
+
* Interpolation: lerp(zero, positive, t) for t≥0; lerp(zero, negative, −t) for t<0.
|
| 77 |
+
* Near-zero |t| < 0.01 → zero anchor + alpha ≈ 0.05.
|
| 78 |
*
|
| 79 |
* @param {number} val - Activation value (v)
|
| 80 |
* @param {number} [absMax=1.0] - Maximum absolute value for normalization
|
| 81 |
+
* @param {object|null} [anchors] - RGB01 trio or hex settings; defaults to product anchors
|
| 82 |
+
* @returns {{r: number, g: number, b: number, alpha: number}}
|
| 83 |
*/
|
| 84 |
+
export function getDivergentColor(val, absMax = 1.0, anchors = null) {
|
| 85 |
const denom = absMax > 1e-9 ? absMax : 1.0;
|
| 86 |
let t = Math.max(-1.0, Math.min(1.0, val / denom));
|
| 87 |
const absT = Math.abs(t);
|
| 88 |
+
const A = resolveColorAnchors(anchors);
|
| 89 |
|
| 90 |
+
if (absT < NEAR_ZERO_EPS) {
|
| 91 |
+
return { r: A.zero.r, g: A.zero.g, b: A.zero.b, alpha: 0.05 };
|
|
|
|
| 92 |
}
|
| 93 |
|
| 94 |
const alpha = Math.min(Math.max(Math.pow(absT, 1.2), 0.05), 1.0);
|
| 95 |
+
const rgb = t >= 0
|
| 96 |
+
? lerpRgb(A.zero, A.positive, t)
|
| 97 |
+
: lerpRgb(A.zero, A.negative, -t);
|
| 98 |
|
| 99 |
+
return { r: rgb.r, g: rgb.g, b: rgb.b, alpha };
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
}
|
| 101 |
|
| 102 |
/**
|
|
|
|
| 117 |
`;
|
| 118 |
|
| 119 |
/**
|
| 120 |
+
* GLSL Fragment Shader: three color anchors via uniforms + optional sign filter.
|
| 121 |
+
* Filter modes: 0=all, 1=positive only, 2=negative only (ε = uNearZeroEps).
|
|
|
|
| 122 |
*/
|
| 123 |
export const divergentFragmentShader = `
|
| 124 |
varying float vIntensity;
|
| 125 |
uniform float baseOpacity;
|
| 126 |
+
uniform vec3 uColorPos;
|
| 127 |
+
uniform vec3 uColorNeg;
|
| 128 |
+
uniform vec3 uColorZero;
|
| 129 |
+
uniform int uFilterMode;
|
| 130 |
+
uniform float uNearZeroEps;
|
| 131 |
|
| 132 |
void main() {
|
| 133 |
vec2 coord = abs(gl_PointCoord - vec2(0.5));
|
|
|
|
| 139 |
float t = clamp(vIntensity, -1.0, 1.0);
|
| 140 |
float absT = abs(t);
|
| 141 |
|
| 142 |
+
// Sign filter on normalized t (same semantics as activationFilter.js)
|
| 143 |
+
if (uFilterMode == 1) {
|
| 144 |
+
if (absT < uNearZeroEps || t <= 0.0) discard;
|
| 145 |
+
} else if (uFilterMode == 2) {
|
| 146 |
+
if (absT < uNearZeroEps || t >= 0.0) discard;
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
// Near-zero short-circuit → zero anchor + low alpha
|
| 150 |
+
if (absT < uNearZeroEps) {
|
| 151 |
+
gl_FragColor = vec4(uColorZero, 0.05 * baseOpacity * solidEdge);
|
| 152 |
return;
|
| 153 |
}
|
| 154 |
|
| 155 |
float dynamicAlpha = clamp(pow(absT, 1.1), 0.05, 1.0) * baseOpacity;
|
| 156 |
|
| 157 |
+
vec3 color = t > 0.0
|
| 158 |
+
? mix(uColorZero, uColorPos, t)
|
| 159 |
+
: mix(uColorZero, uColorNeg, -t);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
|
| 161 |
vec3 finalColor = color * solidEdge;
|
| 162 |
float alpha = dynamicAlpha * solidEdge;
|
|
|
|
| 165 |
}
|
| 166 |
`;
|
| 167 |
|
| 168 |
+
/**
|
| 169 |
+
* Map viz filter mode → shader int.
|
| 170 |
+
* @param {'all'|'positive'|'negative'|undefined} mode
|
| 171 |
+
* @returns {number}
|
| 172 |
+
*/
|
| 173 |
+
export function filterModeToUniform(mode) {
|
| 174 |
+
if (mode === 'positive') return 1;
|
| 175 |
+
if (mode === 'negative') return 2;
|
| 176 |
+
return 0;
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
/**
|
| 180 |
* Factory function creating a customized THREE.ShaderMaterial for GPU divergent shading.
|
| 181 |
*
|
| 182 |
* @param {number} [pointSize=10.0] - Base size of points
|
| 183 |
* @param {number} [baseOpacity=0.7] - Overall opacity scalar
|
| 184 |
+
* @param {{
|
| 185 |
+
* anchors?: object,
|
| 186 |
+
* filterMode?: 'all'|'positive'|'negative',
|
| 187 |
+
* }} [options]
|
| 188 |
* @returns {THREE.ShaderMaterial}
|
| 189 |
*/
|
| 190 |
+
export function createDivergentMaterial(pointSize = 10.0, baseOpacity = 0.7, options = {}) {
|
| 191 |
+
const A = resolveColorAnchors(options.anchors ?? null);
|
| 192 |
return new THREE.ShaderMaterial({
|
| 193 |
uniforms: {
|
| 194 |
pointSize: { value: pointSize },
|
| 195 |
+
baseOpacity: { value: baseOpacity },
|
| 196 |
+
uColorPos: { value: new THREE.Vector3(A.positive.r, A.positive.g, A.positive.b) },
|
| 197 |
+
uColorNeg: { value: new THREE.Vector3(A.negative.r, A.negative.g, A.negative.b) },
|
| 198 |
+
uColorZero: { value: new THREE.Vector3(A.zero.r, A.zero.g, A.zero.b) },
|
| 199 |
+
uFilterMode: { value: filterModeToUniform(options.filterMode) },
|
| 200 |
+
uNearZeroEps: { value: NEAR_ZERO_EPS },
|
| 201 |
},
|
| 202 |
vertexShader: divergentVertexShader,
|
| 203 |
fragmentShader: divergentFragmentShader,
|
| 204 |
transparent: true,
|
| 205 |
depthWrite: false,
|
| 206 |
+
blending: THREE.NormalBlending,
|
| 207 |
});
|
| 208 |
}
|
| 209 |
+
|
| 210 |
+
/**
|
| 211 |
+
* Update live color/filter uniforms without rebuilding the shader string.
|
| 212 |
+
* @param {THREE.ShaderMaterial} material
|
| 213 |
+
* @param {{ anchors?: object, filterMode?: string }} options
|
| 214 |
+
*/
|
| 215 |
+
export function updateDivergentMaterialUniforms(material, options = {}) {
|
| 216 |
+
if (!material?.uniforms) return;
|
| 217 |
+
if (options.anchors) {
|
| 218 |
+
const A = resolveColorAnchors(options.anchors);
|
| 219 |
+
material.uniforms.uColorPos.value.set(A.positive.r, A.positive.g, A.positive.b);
|
| 220 |
+
material.uniforms.uColorNeg.value.set(A.negative.r, A.negative.g, A.negative.b);
|
| 221 |
+
material.uniforms.uColorZero.value.set(A.zero.r, A.zero.g, A.zero.b);
|
| 222 |
+
}
|
| 223 |
+
if (options.filterMode !== undefined) {
|
| 224 |
+
material.uniforms.uFilterMode.value = filterModeToUniform(options.filterMode);
|
| 225 |
+
}
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
export { hexToRgb01 };
|
src/visualizer/Instancer.js
CHANGED
|
@@ -56,9 +56,10 @@ export class Instancer {
|
|
| 56 |
* @param {string} renderMode - "POINTS" | "RIBBONS" (unknown/retired modes → POINTS)
|
| 57 |
* @param {Object} [spatialConfig] - Real-time spatial slider configuration { threadSpacing, threadWidth, threadThickness }
|
| 58 |
* @param {string} [viewMode="NAVIGATION"] - "NAVIGATION" | "ANALYSIS"
|
|
|
|
| 59 |
* @returns {Array<{ id: string, text: string, type: string, origin3D: THREE.Vector3 }>} Label metadata for origins
|
| 60 |
*/
|
| 61 |
-
renderArithmeticData(arithmeticResponse, renderMode = "POINTS", spatialConfig = null, viewMode = "NAVIGATION") {
|
| 62 |
this.clear();
|
| 63 |
renderMode = normalizeRenderMode(renderMode);
|
| 64 |
this.renderMode = renderMode;
|
|
@@ -147,7 +148,7 @@ export class Instancer {
|
|
| 147 |
if (baselineMesh) this.activeGroup.add(baselineMesh);
|
| 148 |
}
|
| 149 |
|
| 150 |
-
this._mountRenderModeGeometry(renderMode, surfaceRows, pointsData, thicknessFactor);
|
| 151 |
return threadLabelItems;
|
| 152 |
}
|
| 153 |
|
|
@@ -155,12 +156,16 @@ export class Instancer {
|
|
| 155 |
* Mount POINTS | RIBBONS geometry (mutually exclusive).
|
| 156 |
* @private
|
| 157 |
*/
|
| 158 |
-
_mountRenderModeGeometry(renderMode, surfaceRows, pointsData, thicknessFactor) {
|
|
|
|
| 159 |
if (renderMode === "RIBBONS") {
|
| 160 |
// No base plane: translucent dark quad showed through ribbons as a hard dark rectangle.
|
| 161 |
const ribbonWidth = Math.max(2.0, 14.0 * (thicknessFactor || 0.3));
|
| 162 |
surfaceRows.forEach((row) => {
|
| 163 |
-
const wide = MeshFactory.createWideRibbonMesh(row.points, row.activations, {
|
|
|
|
|
|
|
|
|
|
| 164 |
if (wide) this.activeGroup.add(wide);
|
| 165 |
});
|
| 166 |
return;
|
|
@@ -168,11 +173,14 @@ export class Instancer {
|
|
| 168 |
|
| 169 |
// POINTS: thin continuity lines + point cloud
|
| 170 |
surfaceRows.forEach((row) => {
|
| 171 |
-
const ribbon = MeshFactory.createRibbonMesh(row.points, row.activations);
|
| 172 |
this.activeGroup.add(ribbon);
|
| 173 |
});
|
| 174 |
if (pointsData.length) {
|
| 175 |
-
const pointsMesh = MeshFactory.createPointsMesh(pointsData, {
|
|
|
|
|
|
|
|
|
|
| 176 |
pointsMesh.userData = { pointsData };
|
| 177 |
this.activeGroup.add(pointsMesh);
|
| 178 |
}
|
|
@@ -184,9 +192,10 @@ export class Instancer {
|
|
| 184 |
* @param {string} renderMode - "POINTS" | "RIBBONS" (unknown/retired modes → POINTS)
|
| 185 |
* @param {Object} [spatialConfig] - Real-time spatial slider configuration
|
| 186 |
* @param {string} [viewMode="NAVIGATION"] - "NAVIGATION" | "ANALYSIS"
|
|
|
|
| 187 |
* @returns {Array<{ id: string, text: string, type: string, origin3D: THREE.Vector3 }>} Label metadata
|
| 188 |
*/
|
| 189 |
-
renderCompareData(compareResponse, renderMode = "POINTS", spatialConfig = null, viewMode = "NAVIGATION") {
|
| 190 |
this.clear();
|
| 191 |
renderMode = normalizeRenderMode(renderMode);
|
| 192 |
this.renderMode = renderMode;
|
|
@@ -244,12 +253,16 @@ export class Instancer {
|
|
| 244 |
|
| 245 |
surfaceRows.push({ points: vec3D, activations });
|
| 246 |
|
|
|
|
| 247 |
let ribbonMesh = null;
|
| 248 |
if (renderMode === "RIBBONS") {
|
| 249 |
const ribbonWidth = Math.max(2.0, 14.0 * thicknessFactor);
|
| 250 |
-
ribbonMesh = MeshFactory.createWideRibbonMesh(vec3D, activations, {
|
|
|
|
|
|
|
|
|
|
| 251 |
} else {
|
| 252 |
-
ribbonMesh = MeshFactory.createRibbonMesh(vec3D, activations);
|
| 253 |
}
|
| 254 |
if (ribbonMesh) {
|
| 255 |
ribbonMesh.userData.threadId = threadId;
|
|
@@ -290,7 +303,10 @@ export class Instancer {
|
|
| 290 |
let pointsMesh = null;
|
| 291 |
if (renderMode === "POINTS" && pointsData.length) {
|
| 292 |
// RIBBONS must not mount the POINTS cloud (square Chebyshev dots on top of strips).
|
| 293 |
-
pointsMesh = MeshFactory.createPointsMesh(pointsData, {
|
|
|
|
|
|
|
|
|
|
| 294 |
pointsMesh.userData = { pointsData };
|
| 295 |
this.activeGroup.add(pointsMesh);
|
| 296 |
}
|
|
|
|
| 56 |
* @param {string} renderMode - "POINTS" | "RIBBONS" (unknown/retired modes → POINTS)
|
| 57 |
* @param {Object} [spatialConfig] - Real-time spatial slider configuration { threadSpacing, threadWidth, threadThickness }
|
| 58 |
* @param {string} [viewMode="NAVIGATION"] - "NAVIGATION" | "ANALYSIS"
|
| 59 |
+
* @param {Object} [vizConfig] - Global sign filter + color anchors
|
| 60 |
* @returns {Array<{ id: string, text: string, type: string, origin3D: THREE.Vector3 }>} Label metadata for origins
|
| 61 |
*/
|
| 62 |
+
renderArithmeticData(arithmeticResponse, renderMode = "POINTS", spatialConfig = null, viewMode = "NAVIGATION", vizConfig = null) {
|
| 63 |
this.clear();
|
| 64 |
renderMode = normalizeRenderMode(renderMode);
|
| 65 |
this.renderMode = renderMode;
|
|
|
|
| 148 |
if (baselineMesh) this.activeGroup.add(baselineMesh);
|
| 149 |
}
|
| 150 |
|
| 151 |
+
this._mountRenderModeGeometry(renderMode, surfaceRows, pointsData, thicknessFactor, vizConfig);
|
| 152 |
return threadLabelItems;
|
| 153 |
}
|
| 154 |
|
|
|
|
| 156 |
* Mount POINTS | RIBBONS geometry (mutually exclusive).
|
| 157 |
* @private
|
| 158 |
*/
|
| 159 |
+
_mountRenderModeGeometry(renderMode, surfaceRows, pointsData, thicknessFactor, vizConfig = null) {
|
| 160 |
+
const vizOpts = vizConfig ? { vizConfig } : {};
|
| 161 |
if (renderMode === "RIBBONS") {
|
| 162 |
// No base plane: translucent dark quad showed through ribbons as a hard dark rectangle.
|
| 163 |
const ribbonWidth = Math.max(2.0, 14.0 * (thicknessFactor || 0.3));
|
| 164 |
surfaceRows.forEach((row) => {
|
| 165 |
+
const wide = MeshFactory.createWideRibbonMesh(row.points, row.activations, {
|
| 166 |
+
width: ribbonWidth,
|
| 167 |
+
...vizOpts,
|
| 168 |
+
});
|
| 169 |
if (wide) this.activeGroup.add(wide);
|
| 170 |
});
|
| 171 |
return;
|
|
|
|
| 173 |
|
| 174 |
// POINTS: thin continuity lines + point cloud
|
| 175 |
surfaceRows.forEach((row) => {
|
| 176 |
+
const ribbon = MeshFactory.createRibbonMesh(row.points, row.activations, vizOpts);
|
| 177 |
this.activeGroup.add(ribbon);
|
| 178 |
});
|
| 179 |
if (pointsData.length) {
|
| 180 |
+
const pointsMesh = MeshFactory.createPointsMesh(pointsData, {
|
| 181 |
+
pointSize: 15.0 * thicknessFactor,
|
| 182 |
+
...vizOpts,
|
| 183 |
+
});
|
| 184 |
pointsMesh.userData = { pointsData };
|
| 185 |
this.activeGroup.add(pointsMesh);
|
| 186 |
}
|
|
|
|
| 192 |
* @param {string} renderMode - "POINTS" | "RIBBONS" (unknown/retired modes → POINTS)
|
| 193 |
* @param {Object} [spatialConfig] - Real-time spatial slider configuration
|
| 194 |
* @param {string} [viewMode="NAVIGATION"] - "NAVIGATION" | "ANALYSIS"
|
| 195 |
+
* @param {Object} [vizConfig] - Global sign filter + color anchors
|
| 196 |
* @returns {Array<{ id: string, text: string, type: string, origin3D: THREE.Vector3 }>} Label metadata
|
| 197 |
*/
|
| 198 |
+
renderCompareData(compareResponse, renderMode = "POINTS", spatialConfig = null, viewMode = "NAVIGATION", vizConfig = null) {
|
| 199 |
this.clear();
|
| 200 |
renderMode = normalizeRenderMode(renderMode);
|
| 201 |
this.renderMode = renderMode;
|
|
|
|
| 253 |
|
| 254 |
surfaceRows.push({ points: vec3D, activations });
|
| 255 |
|
| 256 |
+
const vizOpts = vizConfig ? { vizConfig } : {};
|
| 257 |
let ribbonMesh = null;
|
| 258 |
if (renderMode === "RIBBONS") {
|
| 259 |
const ribbonWidth = Math.max(2.0, 14.0 * thicknessFactor);
|
| 260 |
+
ribbonMesh = MeshFactory.createWideRibbonMesh(vec3D, activations, {
|
| 261 |
+
width: ribbonWidth,
|
| 262 |
+
...vizOpts,
|
| 263 |
+
});
|
| 264 |
} else {
|
| 265 |
+
ribbonMesh = MeshFactory.createRibbonMesh(vec3D, activations, vizOpts);
|
| 266 |
}
|
| 267 |
if (ribbonMesh) {
|
| 268 |
ribbonMesh.userData.threadId = threadId;
|
|
|
|
| 303 |
let pointsMesh = null;
|
| 304 |
if (renderMode === "POINTS" && pointsData.length) {
|
| 305 |
// RIBBONS must not mount the POINTS cloud (square Chebyshev dots on top of strips).
|
| 306 |
+
pointsMesh = MeshFactory.createPointsMesh(pointsData, {
|
| 307 |
+
pointSize: 15.0 * thicknessFactor,
|
| 308 |
+
...(vizConfig ? { vizConfig } : {}),
|
| 309 |
+
});
|
| 310 |
pointsMesh.userData = { pointsData };
|
| 311 |
this.activeGroup.add(pointsMesh);
|
| 312 |
}
|
src/visualizer/MeshFactory.js
CHANGED
|
@@ -1,5 +1,22 @@
|
|
| 1 |
import * as THREE from 'three';
|
| 2 |
import { createDivergentMaterial, getDivergentColor, calculateZScoreNormalized } from './DivergentShading.js';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
|
| 4 |
/**
|
| 5 |
* Factory for creating 3D Vector Point Cloud Geometries and Ribbon Lines using Divergent Activation Shading.
|
|
@@ -7,12 +24,14 @@ import { createDivergentMaterial, getDivergentColor, calculateZScoreNormalized }
|
|
| 7 |
export class MeshFactory {
|
| 8 |
/**
|
| 9 |
* Creates a Points Cloud Mesh with GPU Divergent Activation GLSL Shader.
|
|
|
|
| 10 |
* Enforces frustumCulled = false.
|
| 11 |
*/
|
| 12 |
static createPointsMesh(pointsData, options = {}) {
|
| 13 |
const geometry = new THREE.BufferGeometry();
|
| 14 |
const positions = [];
|
| 15 |
const rawActivations = [];
|
|
|
|
| 16 |
|
| 17 |
pointsData.forEach((item) => {
|
| 18 |
const pos = item.position;
|
|
@@ -26,7 +45,10 @@ export class MeshFactory {
|
|
| 26 |
geometry.setAttribute('intensity', new THREE.Float32BufferAttribute(normIntensities, 1));
|
| 27 |
|
| 28 |
const pointSize = options.pointSize || 14.0;
|
| 29 |
-
const material = createDivergentMaterial(pointSize, 1.0
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
const pointsMesh = new THREE.Points(geometry, material);
|
| 32 |
|
|
@@ -37,23 +59,37 @@ export class MeshFactory {
|
|
| 37 |
}
|
| 38 |
|
| 39 |
/**
|
| 40 |
-
*
|
|
|
|
|
|
|
| 41 |
*/
|
| 42 |
-
static createRibbonMesh(points, activations = null) {
|
|
|
|
| 43 |
const geometry = new THREE.BufferGeometry().setFromPoints(points);
|
| 44 |
|
|
|
|
| 45 |
if (activations && activations.length === points.length) {
|
| 46 |
-
|
| 47 |
const colors = new Float32Array(points.length * 3);
|
| 48 |
-
|
| 49 |
-
const col = getDivergentColor(
|
| 50 |
colors[idx * 3 + 0] = col.r;
|
| 51 |
colors[idx * 3 + 1] = col.g;
|
| 52 |
colors[idx * 3 + 2] = col.b;
|
| 53 |
-
}
|
| 54 |
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
|
| 55 |
}
|
| 56 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
const material = new THREE.LineBasicMaterial({
|
| 58 |
vertexColors: !!activations,
|
| 59 |
color: activations ? 0xffffff : 0x00ffaa,
|
|
@@ -62,10 +98,12 @@ export class MeshFactory {
|
|
| 62 |
opacity: 0.85
|
| 63 |
});
|
| 64 |
|
| 65 |
-
const lineMesh = new THREE.
|
| 66 |
-
|
| 67 |
-
// CRITICAL INVARIANT: frustumCulled = false
|
| 68 |
lineMesh.frustumCulled = false;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
|
| 70 |
return lineMesh;
|
| 71 |
}
|
|
@@ -96,14 +134,16 @@ export class MeshFactory {
|
|
| 96 |
}
|
| 97 |
|
| 98 |
/**
|
| 99 |
-
* Wide ribbon strip mesh (
|
|
|
|
| 100 |
* @param {THREE.Vector3[]} points
|
| 101 |
* @param {number[]|null} activations
|
| 102 |
-
* @param {{ width?: number }} [options]
|
| 103 |
* @returns {THREE.Mesh|null}
|
| 104 |
*/
|
| 105 |
static createWideRibbonMesh(points, activations = null, options = {}) {
|
| 106 |
if (!points || points.length < 2) return null;
|
|
|
|
| 107 |
const width = options.width ?? 3.0;
|
| 108 |
const half = width * 0.5;
|
| 109 |
const n = points.length;
|
|
@@ -139,7 +179,7 @@ export class MeshFactory {
|
|
| 139 |
positions[iR * 3 + 1] = p.y + side.y;
|
| 140 |
positions[iR * 3 + 2] = p.z + side.z;
|
| 141 |
|
| 142 |
-
const col = getDivergentColor(norm[i], 1.0);
|
| 143 |
for (const vi of [iL, iR]) {
|
| 144 |
colors[vi * 3] = col.r;
|
| 145 |
colors[vi * 3 + 1] = col.g;
|
|
@@ -147,15 +187,7 @@ export class MeshFactory {
|
|
| 147 |
}
|
| 148 |
}
|
| 149 |
|
| 150 |
-
const indices =
|
| 151 |
-
for (let i = 0; i < n - 1; i++) {
|
| 152 |
-
const a = i * 2;
|
| 153 |
-
const b = a + 1;
|
| 154 |
-
const c = a + 2;
|
| 155 |
-
const d = a + 3;
|
| 156 |
-
indices.push(a, c, b);
|
| 157 |
-
indices.push(b, c, d);
|
| 158 |
-
}
|
| 159 |
|
| 160 |
const geometry = new THREE.BufferGeometry();
|
| 161 |
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
|
@@ -215,7 +247,7 @@ export class MeshFactory {
|
|
| 215 |
}
|
| 216 |
|
| 217 |
/**
|
| 218 |
-
* Semi-transparent ground plane under ribbons (
|
| 219 |
* @param {{ width?: number, depth?: number, y?: number, color?: number, opacity?: number }} [options]
|
| 220 |
* @returns {THREE.Mesh}
|
| 221 |
*/
|
|
@@ -264,7 +296,4 @@ export class MeshFactory {
|
|
| 264 |
plane.position.z = center.z;
|
| 265 |
return plane;
|
| 266 |
}
|
| 267 |
-
|
| 268 |
}
|
| 269 |
-
|
| 270 |
-
|
|
|
|
| 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.
|
| 8 |
+
* @param {object} [options]
|
| 9 |
+
*/
|
| 10 |
+
function resolveVizOptions(options = {}) {
|
| 11 |
+
const viz = options.vizConfig
|
| 12 |
+
? resolveVisualizationSettings(options.vizConfig)
|
| 13 |
+
: resolveVisualizationSettings(null);
|
| 14 |
+
return {
|
| 15 |
+
filterMode: viz.vizFilterMode,
|
| 16 |
+
anchors: anchorsFromSettings(viz),
|
| 17 |
+
viz,
|
| 18 |
+
};
|
| 19 |
+
}
|
| 20 |
|
| 21 |
/**
|
| 22 |
* Factory for creating 3D Vector Point Cloud Geometries and Ribbon Lines using Divergent Activation Shading.
|
|
|
|
| 24 |
export class MeshFactory {
|
| 25 |
/**
|
| 26 |
* Creates a Points Cloud Mesh with GPU Divergent Activation GLSL Shader.
|
| 27 |
+
* Sign filter is applied in the fragment shader (keeps full buffers for compare reorder).
|
| 28 |
* Enforces frustumCulled = false.
|
| 29 |
*/
|
| 30 |
static createPointsMesh(pointsData, options = {}) {
|
| 31 |
const geometry = new THREE.BufferGeometry();
|
| 32 |
const positions = [];
|
| 33 |
const rawActivations = [];
|
| 34 |
+
const { filterMode, anchors } = resolveVizOptions(options);
|
| 35 |
|
| 36 |
pointsData.forEach((item) => {
|
| 37 |
const pos = item.position;
|
|
|
|
| 45 |
geometry.setAttribute('intensity', new THREE.Float32BufferAttribute(normIntensities, 1));
|
| 46 |
|
| 47 |
const pointSize = options.pointSize || 14.0;
|
| 48 |
+
const material = createDivergentMaterial(pointSize, 1.0, {
|
| 49 |
+
anchors,
|
| 50 |
+
filterMode,
|
| 51 |
+
});
|
| 52 |
|
| 53 |
const pointsMesh = new THREE.Points(geometry, material);
|
| 54 |
|
|
|
|
| 59 |
}
|
| 60 |
|
| 61 |
/**
|
| 62 |
+
* Continuity line for POINTS mode.
|
| 63 |
+
* Uses LineSegments + index pairs so filtered signs break continuity (F4).
|
| 64 |
+
* Full vertex buffer retained for in-situ compare reorder position updates.
|
| 65 |
*/
|
| 66 |
+
static createRibbonMesh(points, activations = null, options = {}) {
|
| 67 |
+
const { filterMode, anchors } = resolveVizOptions(options);
|
| 68 |
const geometry = new THREE.BufferGeometry().setFromPoints(points);
|
| 69 |
|
| 70 |
+
let normActivations = null;
|
| 71 |
if (activations && activations.length === points.length) {
|
| 72 |
+
normActivations = calculateZScoreNormalized(activations, 0.85);
|
| 73 |
const colors = new Float32Array(points.length * 3);
|
| 74 |
+
for (let idx = 0; idx < normActivations.length; idx++) {
|
| 75 |
+
const col = getDivergentColor(normActivations[idx], 1.0, anchors);
|
| 76 |
colors[idx * 3 + 0] = col.r;
|
| 77 |
colors[idx * 3 + 1] = col.g;
|
| 78 |
colors[idx * 3 + 2] = col.b;
|
| 79 |
+
}
|
| 80 |
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
|
| 81 |
}
|
| 82 |
|
| 83 |
+
if (normActivations) {
|
| 84 |
+
const indices = lineSegmentIndices(normActivations, filterMode);
|
| 85 |
+
if (indices.length) {
|
| 86 |
+
geometry.setIndex(indices);
|
| 87 |
+
} else {
|
| 88 |
+
// No visible segments — empty draw (keep vertices for reorder).
|
| 89 |
+
geometry.setIndex([]);
|
| 90 |
+
}
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
const material = new THREE.LineBasicMaterial({
|
| 94 |
vertexColors: !!activations,
|
| 95 |
color: activations ? 0xffffff : 0x00ffaa,
|
|
|
|
| 98 |
opacity: 0.85
|
| 99 |
});
|
| 100 |
|
| 101 |
+
const lineMesh = new THREE.LineSegments(geometry, material);
|
|
|
|
|
|
|
| 102 |
lineMesh.frustumCulled = false;
|
| 103 |
+
lineMesh.userData = {
|
| 104 |
+
kind: 'continuityLine',
|
| 105 |
+
pointCount: points.length,
|
| 106 |
+
};
|
| 107 |
|
| 108 |
return lineMesh;
|
| 109 |
}
|
|
|
|
| 134 |
}
|
| 135 |
|
| 136 |
/**
|
| 137 |
+
* Wide ribbon strip mesh (RIBBONS) — real width via quads, not Line linewidth.
|
| 138 |
+
* Quad indices omit strips whose endpoints fail the sign filter (F4).
|
| 139 |
* @param {THREE.Vector3[]} points
|
| 140 |
* @param {number[]|null} activations
|
| 141 |
+
* @param {{ width?: number, vizConfig?: object }} [options]
|
| 142 |
* @returns {THREE.Mesh|null}
|
| 143 |
*/
|
| 144 |
static createWideRibbonMesh(points, activations = null, options = {}) {
|
| 145 |
if (!points || points.length < 2) return null;
|
| 146 |
+
const { filterMode, anchors } = resolveVizOptions(options);
|
| 147 |
const width = options.width ?? 3.0;
|
| 148 |
const half = width * 0.5;
|
| 149 |
const n = points.length;
|
|
|
|
| 179 |
positions[iR * 3 + 1] = p.y + side.y;
|
| 180 |
positions[iR * 3 + 2] = p.z + side.z;
|
| 181 |
|
| 182 |
+
const col = getDivergentColor(norm[i], 1.0, anchors);
|
| 183 |
for (const vi of [iL, iR]) {
|
| 184 |
colors[vi * 3] = col.r;
|
| 185 |
colors[vi * 3 + 1] = col.g;
|
|
|
|
| 187 |
}
|
| 188 |
}
|
| 189 |
|
| 190 |
+
const indices = wideRibbonQuadIndices(norm, filterMode);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
|
| 192 |
const geometry = new THREE.BufferGeometry();
|
| 193 |
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
|
|
|
| 247 |
}
|
| 248 |
|
| 249 |
/**
|
| 250 |
+
* Semi-transparent ground plane under ribbons (legacy helper; Instancer does not mount it).
|
| 251 |
* @param {{ width?: number, depth?: number, y?: number, color?: number, opacity?: number }} [options]
|
| 252 |
* @returns {THREE.Mesh}
|
| 253 |
*/
|
|
|
|
| 296 |
plane.position.z = center.z;
|
| 297 |
return plane;
|
| 298 |
}
|
|
|
|
| 299 |
}
|
|
|
|
|
|
src/visualizer/activationFilter.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Sign filter over **normalized** activations (post z-score/tanh), not raw embedding dims.
|
| 3 |
+
* ε aligns with DivergentShading near-zero short-circuit (|t| < 0.01).
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
/** @typedef {'all' | 'positive' | 'negative'} VizFilterMode */
|
| 7 |
+
|
| 8 |
+
export const NEAR_ZERO_EPS = 0.01;
|
| 9 |
+
|
| 10 |
+
/**
|
| 11 |
+
* Whether a normalized activation t should be drawn for the given filter mode.
|
| 12 |
+
* + only: hide negatives and near-zero. − only: hide positives and near-zero.
|
| 13 |
+
*
|
| 14 |
+
* @param {number} t - Normalized activation in roughly [-1, 1]
|
| 15 |
+
* @param {VizFilterMode} mode
|
| 16 |
+
* @param {number} [eps=NEAR_ZERO_EPS]
|
| 17 |
+
* @returns {boolean}
|
| 18 |
+
*/
|
| 19 |
+
export function shouldShowActivation(t, mode = 'all', eps = NEAR_ZERO_EPS) {
|
| 20 |
+
if (mode === 'all') return true;
|
| 21 |
+
const absT = Math.abs(t);
|
| 22 |
+
if (absT < eps) return false;
|
| 23 |
+
if (mode === 'positive') return t > 0;
|
| 24 |
+
if (mode === 'negative') return t < 0;
|
| 25 |
+
return true;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
/**
|
| 29 |
+
* Boolean mask parallel to `normalized`.
|
| 30 |
+
* @param {ArrayLike<number>} normalized
|
| 31 |
+
* @param {VizFilterMode} mode
|
| 32 |
+
* @param {number} [eps]
|
| 33 |
+
* @returns {boolean[]}
|
| 34 |
+
*/
|
| 35 |
+
export function activationVisibilityMask(normalized, mode = 'all', eps = NEAR_ZERO_EPS) {
|
| 36 |
+
const n = normalized.length;
|
| 37 |
+
const mask = new Array(n);
|
| 38 |
+
for (let i = 0; i < n; i++) {
|
| 39 |
+
mask[i] = shouldShowActivation(normalized[i], mode, eps);
|
| 40 |
+
}
|
| 41 |
+
return mask;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
/**
|
| 45 |
+
* Flat index pairs for THREE.LineSegments: only consecutive endpoints that both pass.
|
| 46 |
+
* Keeps full vertex buffer intact for in-situ position updates.
|
| 47 |
+
*
|
| 48 |
+
* @param {ArrayLike<number>} normalized
|
| 49 |
+
* @param {VizFilterMode} mode
|
| 50 |
+
* @param {number} [eps]
|
| 51 |
+
* @returns {number[]}
|
| 52 |
+
*/
|
| 53 |
+
export function lineSegmentIndices(normalized, mode = 'all', eps = NEAR_ZERO_EPS) {
|
| 54 |
+
const n = normalized.length;
|
| 55 |
+
if (n < 2) return [];
|
| 56 |
+
const indices = [];
|
| 57 |
+
for (let i = 0; i < n - 1; i++) {
|
| 58 |
+
if (
|
| 59 |
+
shouldShowActivation(normalized[i], mode, eps)
|
| 60 |
+
&& shouldShowActivation(normalized[i + 1], mode, eps)
|
| 61 |
+
) {
|
| 62 |
+
indices.push(i, i + 1);
|
| 63 |
+
}
|
| 64 |
+
}
|
| 65 |
+
return indices;
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
/**
|
| 69 |
+
* Quad strip triangle indices (same winding as MeshFactory wide ribbon) for
|
| 70 |
+
* consecutive centerline samples that both pass the filter.
|
| 71 |
+
*
|
| 72 |
+
* @param {ArrayLike<number>} normalized - one value per centerline sample
|
| 73 |
+
* @param {VizFilterMode} mode
|
| 74 |
+
* @param {number} [eps]
|
| 75 |
+
* @returns {number[]}
|
| 76 |
+
*/
|
| 77 |
+
export function wideRibbonQuadIndices(normalized, mode = 'all', eps = NEAR_ZERO_EPS) {
|
| 78 |
+
const n = normalized.length;
|
| 79 |
+
if (n < 2) return [];
|
| 80 |
+
const indices = [];
|
| 81 |
+
for (let i = 0; i < n - 1; i++) {
|
| 82 |
+
if (
|
| 83 |
+
!shouldShowActivation(normalized[i], mode, eps)
|
| 84 |
+
|| !shouldShowActivation(normalized[i + 1], mode, eps)
|
| 85 |
+
) {
|
| 86 |
+
continue;
|
| 87 |
+
}
|
| 88 |
+
const a = i * 2;
|
| 89 |
+
const b = a + 1;
|
| 90 |
+
const c = a + 2;
|
| 91 |
+
const d = a + 3;
|
| 92 |
+
indices.push(a, c, b);
|
| 93 |
+
indices.push(b, c, d);
|
| 94 |
+
}
|
| 95 |
+
return indices;
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
/**
|
| 99 |
+
* Filter pointsData entries by normalized activations of the whole set.
|
| 100 |
+
* Caller must pass already-normalized values aligned 1:1 with pointsData.
|
| 101 |
+
*
|
| 102 |
+
* @template T
|
| 103 |
+
* @param {T[]} pointsData
|
| 104 |
+
* @param {ArrayLike<number>} normalized
|
| 105 |
+
* @param {VizFilterMode} mode
|
| 106 |
+
* @param {number} [eps]
|
| 107 |
+
* @returns {T[]}
|
| 108 |
+
*/
|
| 109 |
+
export function filterPointsData(pointsData, normalized, mode = 'all', eps = NEAR_ZERO_EPS) {
|
| 110 |
+
if (!pointsData?.length) return [];
|
| 111 |
+
if (mode === 'all') return pointsData.slice();
|
| 112 |
+
const out = [];
|
| 113 |
+
for (let i = 0; i < pointsData.length; i++) {
|
| 114 |
+
if (shouldShowActivation(normalized[i], mode, eps)) out.push(pointsData[i]);
|
| 115 |
+
}
|
| 116 |
+
return out;
|
| 117 |
+
}
|
tests/DivergentShading.test.js
CHANGED
|
@@ -1,50 +1,65 @@
|
|
| 1 |
import { describe, it, expect } from 'vitest';
|
| 2 |
-
import { getDivergentColor } from '../src/visualizer/DivergentShading.js';
|
| 3 |
|
| 4 |
-
describe('Divergent Shading CPU Algorithm', () => {
|
| 5 |
-
|
|
|
|
|
|
|
| 6 |
const { r, g, b, alpha } = getDivergentColor(1.0, 1.0);
|
| 7 |
-
expect(r).toBeCloseTo(
|
| 8 |
-
expect(g).toBeCloseTo(
|
| 9 |
-
expect(b).
|
| 10 |
expect(alpha).toBeCloseTo(1.0, 5);
|
| 11 |
});
|
| 12 |
|
| 13 |
-
it('maps mid positive (+0.5) to
|
| 14 |
const { r, g, b } = getDivergentColor(0.5, 1.0);
|
| 15 |
-
expect(r).toBeCloseTo(
|
| 16 |
-
expect(g).toBeCloseTo(
|
| 17 |
-
expect(b).
|
| 18 |
});
|
| 19 |
|
| 20 |
-
it('maps zero to
|
| 21 |
const { r, g, b, alpha } = getDivergentColor(0.0, 1.0);
|
| 22 |
-
expect(r).
|
| 23 |
-
expect(g).
|
| 24 |
-
expect(b).
|
| 25 |
expect(alpha).toBeCloseTo(0.05, 5);
|
| 26 |
});
|
| 27 |
|
| 28 |
-
it('short-circuits |t| < 0.01 to
|
| 29 |
const { r, g, b, alpha } = getDivergentColor(0.005, 1.0);
|
| 30 |
-
expect(r).
|
| 31 |
-
expect(g).
|
| 32 |
-
expect(b).
|
| 33 |
expect(alpha).toBeCloseTo(0.05, 5);
|
| 34 |
});
|
| 35 |
|
| 36 |
-
it('maps mid negative (
|
| 37 |
const { r, g, b } = getDivergentColor(-0.5, 1.0);
|
| 38 |
-
expect(r).toBeCloseTo(
|
| 39 |
-
expect(g).toBeCloseTo(
|
| 40 |
-
expect(b).toBeCloseTo(
|
| 41 |
});
|
| 42 |
|
| 43 |
-
it('maps peak negative (
|
| 44 |
const { r, g, b, alpha } = getDivergentColor(-1.0, 1.0);
|
| 45 |
-
expect(r).toBeCloseTo(
|
| 46 |
-
expect(g).toBeCloseTo(
|
| 47 |
-
expect(b).toBeCloseTo(
|
| 48 |
expect(alpha).toBeCloseTo(1.0, 5);
|
| 49 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
});
|
|
|
|
| 1 |
import { describe, it, expect } from 'vitest';
|
| 2 |
+
import { getDivergentColor, DEFAULT_COLOR_ANCHORS } from '../src/visualizer/DivergentShading.js';
|
| 3 |
|
| 4 |
+
describe('Divergent Shading CPU Algorithm (anchor lerp)', () => {
|
| 5 |
+
const A = DEFAULT_COLOR_ANCHORS;
|
| 6 |
+
|
| 7 |
+
it('maps peak positive (+1.0) to +1 anchor', () => {
|
| 8 |
const { r, g, b, alpha } = getDivergentColor(1.0, 1.0);
|
| 9 |
+
expect(r).toBeCloseTo(A.positive.r, 5);
|
| 10 |
+
expect(g).toBeCloseTo(A.positive.g, 5);
|
| 11 |
+
expect(b).toBeCloseTo(A.positive.b, 5);
|
| 12 |
expect(alpha).toBeCloseTo(1.0, 5);
|
| 13 |
});
|
| 14 |
|
| 15 |
+
it('maps mid positive (+0.5) to midpoint lerp 0↔+1', () => {
|
| 16 |
const { r, g, b } = getDivergentColor(0.5, 1.0);
|
| 17 |
+
expect(r).toBeCloseTo((A.zero.r + A.positive.r) / 2, 5);
|
| 18 |
+
expect(g).toBeCloseTo((A.zero.g + A.positive.g) / 2, 5);
|
| 19 |
+
expect(b).toBeCloseTo((A.zero.b + A.positive.b) / 2, 5);
|
| 20 |
});
|
| 21 |
|
| 22 |
+
it('maps zero to zero anchor with minimum opacity (~0.05)', () => {
|
| 23 |
const { r, g, b, alpha } = getDivergentColor(0.0, 1.0);
|
| 24 |
+
expect(r).toBeCloseTo(A.zero.r, 5);
|
| 25 |
+
expect(g).toBeCloseTo(A.zero.g, 5);
|
| 26 |
+
expect(b).toBeCloseTo(A.zero.b, 5);
|
| 27 |
expect(alpha).toBeCloseTo(0.05, 5);
|
| 28 |
});
|
| 29 |
|
| 30 |
+
it('short-circuits |t| < 0.01 to zero anchor with minimum opacity (~0.05)', () => {
|
| 31 |
const { r, g, b, alpha } = getDivergentColor(0.005, 1.0);
|
| 32 |
+
expect(r).toBeCloseTo(A.zero.r, 5);
|
| 33 |
+
expect(g).toBeCloseTo(A.zero.g, 5);
|
| 34 |
+
expect(b).toBeCloseTo(A.zero.b, 5);
|
| 35 |
expect(alpha).toBeCloseTo(0.05, 5);
|
| 36 |
});
|
| 37 |
|
| 38 |
+
it('maps mid negative (−0.5) to midpoint lerp 0↔−1', () => {
|
| 39 |
const { r, g, b } = getDivergentColor(-0.5, 1.0);
|
| 40 |
+
expect(r).toBeCloseTo((A.zero.r + A.negative.r) / 2, 5);
|
| 41 |
+
expect(g).toBeCloseTo((A.zero.g + A.negative.g) / 2, 5);
|
| 42 |
+
expect(b).toBeCloseTo((A.zero.b + A.negative.b) / 2, 5);
|
| 43 |
});
|
| 44 |
|
| 45 |
+
it('maps peak negative (−1.0) to −1 anchor', () => {
|
| 46 |
const { r, g, b, alpha } = getDivergentColor(-1.0, 1.0);
|
| 47 |
+
expect(r).toBeCloseTo(A.negative.r, 5);
|
| 48 |
+
expect(g).toBeCloseTo(A.negative.g, 5);
|
| 49 |
+
expect(b).toBeCloseTo(A.negative.b, 5);
|
| 50 |
expect(alpha).toBeCloseTo(1.0, 5);
|
| 51 |
});
|
| 52 |
+
|
| 53 |
+
it('accepts custom anchors', () => {
|
| 54 |
+
const custom = {
|
| 55 |
+
positive: { r: 1, g: 0, b: 0 },
|
| 56 |
+
zero: { r: 0, g: 1, b: 0 },
|
| 57 |
+
negative: { r: 0, g: 0, b: 1 },
|
| 58 |
+
};
|
| 59 |
+
const pos = getDivergentColor(1, 1, custom);
|
| 60 |
+
expect(pos.r).toBeCloseTo(1, 5);
|
| 61 |
+
expect(pos.g).toBeCloseTo(0, 5);
|
| 62 |
+
const neg = getDivergentColor(-1, 1, custom);
|
| 63 |
+
expect(neg.b).toBeCloseTo(1, 5);
|
| 64 |
+
});
|
| 65 |
});
|
tests/visualizationControls.test.js
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, it, expect, beforeEach } from 'vitest';
|
| 2 |
+
import {
|
| 3 |
+
DEFAULT_VISUALIZATION_SETTINGS,
|
| 4 |
+
DEFAULT_VIZ_COLORS,
|
| 5 |
+
DEFAULT_VIZ_FILTER,
|
| 6 |
+
VIZ_STORAGE_KEYS,
|
| 7 |
+
isValidHex,
|
| 8 |
+
normalizeHex,
|
| 9 |
+
hexToRgb01,
|
| 10 |
+
resolveVisualizationSettings,
|
| 11 |
+
loadVisualizationSettings,
|
| 12 |
+
saveVisualizationSettings,
|
| 13 |
+
resetVisualizationSettings,
|
| 14 |
+
anchorsFromSettings,
|
| 15 |
+
} from '../src/ui/visualizationControlsDefaults.js';
|
| 16 |
+
import {
|
| 17 |
+
NEAR_ZERO_EPS,
|
| 18 |
+
shouldShowActivation,
|
| 19 |
+
filterPointsData,
|
| 20 |
+
lineSegmentIndices,
|
| 21 |
+
wideRibbonQuadIndices,
|
| 22 |
+
} from '../src/visualizer/activationFilter.js';
|
| 23 |
+
import { getDivergentColor } from '../src/visualizer/DivergentShading.js';
|
| 24 |
+
|
| 25 |
+
function mockStorage(initial = {}) {
|
| 26 |
+
const map = new Map(Object.entries(initial));
|
| 27 |
+
return {
|
| 28 |
+
getItem: (k) => (map.has(k) ? map.get(k) : null),
|
| 29 |
+
setItem: (k, v) => { map.set(k, String(v)); },
|
| 30 |
+
removeItem: (k) => { map.delete(k); },
|
| 31 |
+
_map: map,
|
| 32 |
+
};
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
describe('visualizationControlsDefaults', () => {
|
| 36 |
+
it('defaults match §0.2 anchors and filter All', () => {
|
| 37 |
+
expect(DEFAULT_VIZ_FILTER).toBe('all');
|
| 38 |
+
expect(DEFAULT_VIZ_COLORS.colorPositive).toBe('#FFE600');
|
| 39 |
+
expect(DEFAULT_VIZ_COLORS.colorZero).toBe('#000000');
|
| 40 |
+
expect(DEFAULT_VIZ_COLORS.colorNegative).toBe('#9900E6');
|
| 41 |
+
expect(DEFAULT_VISUALIZATION_SETTINGS).toEqual({
|
| 42 |
+
vizFilterMode: 'all',
|
| 43 |
+
colorPositive: '#FFE600',
|
| 44 |
+
colorZero: '#000000',
|
| 45 |
+
colorNegative: '#9900E6',
|
| 46 |
+
});
|
| 47 |
+
});
|
| 48 |
+
|
| 49 |
+
it('validates and normalizes hex', () => {
|
| 50 |
+
expect(isValidHex('#FFE600')).toBe(true);
|
| 51 |
+
expect(isValidHex('#ff00')).toBe(false);
|
| 52 |
+
expect(isValidHex('not-a-color')).toBe(false);
|
| 53 |
+
expect(normalizeHex('ffe600')).toBe('#FFE600');
|
| 54 |
+
expect(normalizeHex('#abc')).toBeNull();
|
| 55 |
+
});
|
| 56 |
+
|
| 57 |
+
it('hexToRgb01 parses endpoints', () => {
|
| 58 |
+
expect(hexToRgb01('#FFE600')).toEqual({
|
| 59 |
+
r: 1,
|
| 60 |
+
g: 230 / 255,
|
| 61 |
+
b: 0,
|
| 62 |
+
});
|
| 63 |
+
expect(hexToRgb01('#000000')).toEqual({ r: 0, g: 0, b: 0 });
|
| 64 |
+
expect(hexToRgb01('#9900E6')).toEqual({
|
| 65 |
+
r: 153 / 255,
|
| 66 |
+
g: 0,
|
| 67 |
+
b: 230 / 255,
|
| 68 |
+
});
|
| 69 |
+
});
|
| 70 |
+
|
| 71 |
+
it('resolveVisualizationSettings falls back invalid hex and mode', () => {
|
| 72 |
+
const resolved = resolveVisualizationSettings({
|
| 73 |
+
vizFilterMode: 'nope',
|
| 74 |
+
colorPositive: 'bad',
|
| 75 |
+
colorZero: '#112233',
|
| 76 |
+
colorNegative: '#9900E6',
|
| 77 |
+
});
|
| 78 |
+
expect(resolved.vizFilterMode).toBe('all');
|
| 79 |
+
expect(resolved.colorPositive).toBe('#FFE600');
|
| 80 |
+
expect(resolved.colorZero).toBe('#112233');
|
| 81 |
+
expect(resolved.colorNegative).toBe('#9900E6');
|
| 82 |
+
});
|
| 83 |
+
|
| 84 |
+
it('persist round-trip via mock localStorage', () => {
|
| 85 |
+
const storage = mockStorage();
|
| 86 |
+
const settings = resolveVisualizationSettings({
|
| 87 |
+
vizFilterMode: 'positive',
|
| 88 |
+
colorPositive: '#FF0000',
|
| 89 |
+
colorZero: '#111111',
|
| 90 |
+
colorNegative: '#0000FF',
|
| 91 |
+
});
|
| 92 |
+
saveVisualizationSettings(settings, storage);
|
| 93 |
+
expect(storage.getItem(VIZ_STORAGE_KEYS.filter)).toBe('positive');
|
| 94 |
+
expect(loadVisualizationSettings(storage)).toEqual(settings);
|
| 95 |
+
});
|
| 96 |
+
|
| 97 |
+
it('reset restores defaults and All', () => {
|
| 98 |
+
const storage = mockStorage({
|
| 99 |
+
[VIZ_STORAGE_KEYS.filter]: 'negative',
|
| 100 |
+
[VIZ_STORAGE_KEYS.colorPositive]: '#FF0000',
|
| 101 |
+
});
|
| 102 |
+
const reset = resetVisualizationSettings(storage);
|
| 103 |
+
expect(reset).toEqual(DEFAULT_VISUALIZATION_SETTINGS);
|
| 104 |
+
expect(loadVisualizationSettings(storage)).toEqual(DEFAULT_VISUALIZATION_SETTINGS);
|
| 105 |
+
});
|
| 106 |
+
|
| 107 |
+
it('anchorsFromSettings yields RGB01 trio', () => {
|
| 108 |
+
const a = anchorsFromSettings(DEFAULT_VISUALIZATION_SETTINGS);
|
| 109 |
+
expect(a.positive.r).toBeCloseTo(1, 5);
|
| 110 |
+
expect(a.zero).toEqual({ r: 0, g: 0, b: 0 });
|
| 111 |
+
expect(a.negative.b).toBeCloseTo(230 / 255, 5);
|
| 112 |
+
});
|
| 113 |
+
});
|
| 114 |
+
|
| 115 |
+
describe('shouldShowActivation / filter helpers', () => {
|
| 116 |
+
const samples = [1, 0.5, 0.005, -0.5, -1];
|
| 117 |
+
|
| 118 |
+
it('all mode shows every sample', () => {
|
| 119 |
+
for (const v of samples) {
|
| 120 |
+
expect(shouldShowActivation(v, 'all')).toBe(true);
|
| 121 |
+
}
|
| 122 |
+
});
|
| 123 |
+
|
| 124 |
+
it('+ only hides negatives and near-zero', () => {
|
| 125 |
+
expect(shouldShowActivation(1, 'positive')).toBe(true);
|
| 126 |
+
expect(shouldShowActivation(0.5, 'positive')).toBe(true);
|
| 127 |
+
expect(shouldShowActivation(0.005, 'positive')).toBe(false);
|
| 128 |
+
expect(shouldShowActivation(-0.5, 'positive')).toBe(false);
|
| 129 |
+
expect(shouldShowActivation(-1, 'positive')).toBe(false);
|
| 130 |
+
});
|
| 131 |
+
|
| 132 |
+
it('− only hides positives and near-zero', () => {
|
| 133 |
+
expect(shouldShowActivation(1, 'negative')).toBe(false);
|
| 134 |
+
expect(shouldShowActivation(0.5, 'negative')).toBe(false);
|
| 135 |
+
expect(shouldShowActivation(0.005, 'negative')).toBe(false);
|
| 136 |
+
expect(shouldShowActivation(-0.5, 'negative')).toBe(true);
|
| 137 |
+
expect(shouldShowActivation(-1, 'negative')).toBe(true);
|
| 138 |
+
});
|
| 139 |
+
|
| 140 |
+
it('uses ε = 0.01 by default', () => {
|
| 141 |
+
expect(NEAR_ZERO_EPS).toBe(0.01);
|
| 142 |
+
expect(shouldShowActivation(0.009, 'positive')).toBe(false);
|
| 143 |
+
expect(shouldShowActivation(0.01, 'positive')).toBe(true);
|
| 144 |
+
});
|
| 145 |
+
|
| 146 |
+
it('filterPointsData keeps aligned entries', () => {
|
| 147 |
+
const points = samples.map((activation, i) => ({ i, activation }));
|
| 148 |
+
const kept = filterPointsData(points, samples, 'positive');
|
| 149 |
+
expect(kept.map((p) => p.i)).toEqual([0, 1]);
|
| 150 |
+
});
|
| 151 |
+
|
| 152 |
+
it('lineSegmentIndices breaks across hidden signs', () => {
|
| 153 |
+
// visible, visible, near-zero, visible-neg → only first segment
|
| 154 |
+
const norm = [0.5, 0.8, 0.0, -0.5];
|
| 155 |
+
expect(lineSegmentIndices(norm, 'positive')).toEqual([0, 1]);
|
| 156 |
+
expect(lineSegmentIndices(norm, 'all')).toEqual([0, 1, 1, 2, 2, 3]);
|
| 157 |
+
});
|
| 158 |
+
|
| 159 |
+
it('wideRibbonQuadIndices omits quads for filtered pairs', () => {
|
| 160 |
+
const norm = [0.5, 0.8, -0.5];
|
| 161 |
+
expect(wideRibbonQuadIndices(norm, 'positive')).toEqual([0, 2, 1, 1, 2, 3]);
|
| 162 |
+
expect(wideRibbonQuadIndices(norm, 'negative')).toEqual([]);
|
| 163 |
+
});
|
| 164 |
+
});
|
| 165 |
+
|
| 166 |
+
describe('getDivergentColor with color anchors', () => {
|
| 167 |
+
const anchors = anchorsFromSettings(DEFAULT_VISUALIZATION_SETTINGS);
|
| 168 |
+
|
| 169 |
+
it('t=1 → +1 hex', () => {
|
| 170 |
+
const c = getDivergentColor(1, 1, anchors);
|
| 171 |
+
expect(c.r).toBeCloseTo(anchors.positive.r, 5);
|
| 172 |
+
expect(c.g).toBeCloseTo(anchors.positive.g, 5);
|
| 173 |
+
expect(c.b).toBeCloseTo(anchors.positive.b, 5);
|
| 174 |
+
});
|
| 175 |
+
|
| 176 |
+
it('t=0 → 0 hex with low alpha', () => {
|
| 177 |
+
const c = getDivergentColor(0, 1, anchors);
|
| 178 |
+
expect(c.r).toBeCloseTo(anchors.zero.r, 5);
|
| 179 |
+
expect(c.g).toBeCloseTo(anchors.zero.g, 5);
|
| 180 |
+
expect(c.b).toBeCloseTo(anchors.zero.b, 5);
|
| 181 |
+
expect(c.alpha).toBeCloseTo(0.05, 5);
|
| 182 |
+
});
|
| 183 |
+
|
| 184 |
+
it('t=-1 → −1 hex', () => {
|
| 185 |
+
const c = getDivergentColor(-1, 1, anchors);
|
| 186 |
+
expect(c.r).toBeCloseTo(anchors.negative.r, 5);
|
| 187 |
+
expect(c.g).toBeCloseTo(anchors.negative.g, 5);
|
| 188 |
+
expect(c.b).toBeCloseTo(anchors.negative.b, 5);
|
| 189 |
+
});
|
| 190 |
+
|
| 191 |
+
it('t=0.5 → midpoint lerp 0↔+1', () => {
|
| 192 |
+
const c = getDivergentColor(0.5, 1, anchors);
|
| 193 |
+
expect(c.r).toBeCloseTo((anchors.zero.r + anchors.positive.r) / 2, 5);
|
| 194 |
+
expect(c.g).toBeCloseTo((anchors.zero.g + anchors.positive.g) / 2, 5);
|
| 195 |
+
expect(c.b).toBeCloseTo((anchors.zero.b + anchors.positive.b) / 2, 5);
|
| 196 |
+
});
|
| 197 |
+
});
|