hbauzan Cursor commited on
Commit
58b3a49
·
1 Parent(s): dc903dc

feat(viz): sign filter and color anchors panel (v1.7.0)

Browse files

Add 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 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 Simplificadas (Dual Color Ramps)
70
- Para lecturas de alta visibilidad y ligera carga computacional, implementar la paleta divergente simplificada (sin transiciones intermedias a rojo o verde):
71
- - **Rango Positivo ($0 \rightarrow +1$)**: Negro ($0.0$) $\rightarrow$ Naranja ($+0.50$, `#FF8000`) $\rightarrow$ Amarillo Incandescente ($+1.00$, `#FFE600`).
72
- - **Rango Negativo ($0 \rightarrow -1$)**: Negro ($0.0$) $\rightarrow$ Azul Eléctrico ($-0.50$, `#0040FF`) $\rightarrow$ Violeta Neón ($-1.00$, `#9900E6`).
 
 
 
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` together with a translucent Base Plane. Distinct from 1px WebGL lines.
 
 
 
 
 
 
 
 
 
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.6.0",
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.6.0",
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|&lt;ε; − hides + and |t|&lt;ε.
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.6.0</span>
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
- * Calculates CPU color and dynamic opacity based on activation value.
35
- *
36
- * Color Ramps:
37
- * Positive (0 to +1): Black (0.0) -> Orange (+0.50) -> Yellow (+1.00)
38
- * Negative (0 to -1): Black (0.0) -> Electric Blue (-0.50) -> Violet (-1.00)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  *
40
  * @param {number} val - Activation value (v)
41
  * @param {number} [absMax=1.0] - Maximum absolute value for normalization
42
- * @returns {{r: number, g: number, b: number, alpha: number}} Color components in [0, 1] range
 
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
- // Computational short-circuit for |t| < 0.01
50
- if (absT < 0.01) {
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
- let r = 0.0, g = 0.0, b = 0.0;
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 for Dual Divergent Color Ramps (Solid Circular Points):
109
- * Positive (0 to +1): Black (0.0) -> Orange (+0.50) -> Incandescent Yellow (+1.00)
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
- // Computational short-circuit for |t| < 0.01: early return without interpolation
127
- if (absT < 0.01) {
128
- gl_FragColor = vec4(vec3(0.0), 0.05 * baseOpacity * solidEdge);
 
 
 
 
 
 
 
129
  return;
130
  }
131
 
132
  float dynamicAlpha = clamp(pow(absT, 1.1), 0.05, 1.0) * baseOpacity;
133
 
134
- vec3 color = vec3(0.0);
135
-
136
- if (t > 0.0) {
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, { width: ribbonWidth });
 
 
 
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, { pointSize: 15.0 * thicknessFactor });
 
 
 
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, { width: ribbonWidth });
 
 
 
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, { pointSize: 15.0 * thicknessFactor });
 
 
 
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
- * Creates a Ribbon Line connecting a sequence of 3D vector points with vertex activation colors.
 
 
41
  */
42
- static createRibbonMesh(points, activations = null) {
 
43
  const geometry = new THREE.BufferGeometry().setFromPoints(points);
44
 
 
45
  if (activations && activations.length === points.length) {
46
- const normActivations = calculateZScoreNormalized(activations, 0.85);
47
  const colors = new Float32Array(points.length * 3);
48
- normActivations.forEach((val, idx) => {
49
- const col = getDivergentColor(val, 1.0);
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.Line(geometry, material);
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 (Etapa E / RIBBONS) — real width via quads, not Line linewidth.
 
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 (Etapa E).
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
- it('maps peak positive (+1.0) to incandescent yellow', () => {
 
 
6
  const { r, g, b, alpha } = getDivergentColor(1.0, 1.0);
7
- expect(r).toBeCloseTo(1.0, 5);
8
- expect(g).toBeCloseTo(0.95, 5);
9
- expect(b).toBe(0.0);
10
  expect(alpha).toBeCloseTo(1.0, 5);
11
  });
12
 
13
- it('maps mid positive (+0.5) to orange', () => {
14
  const { r, g, b } = getDivergentColor(0.5, 1.0);
15
- expect(r).toBeCloseTo(1.0, 5);
16
- expect(g).toBeCloseTo(0.5, 5);
17
- expect(b).toBe(0.0);
18
  });
19
 
20
- it('maps zero to black with minimum opacity (~0.05)', () => {
21
  const { r, g, b, alpha } = getDivergentColor(0.0, 1.0);
22
- expect(r).toBe(0.0);
23
- expect(g).toBe(0.0);
24
- expect(b).toBe(0.0);
25
  expect(alpha).toBeCloseTo(0.05, 5);
26
  });
27
 
28
- it('short-circuits |t| < 0.01 to black with minimum opacity (~0.05)', () => {
29
  const { r, g, b, alpha } = getDivergentColor(0.005, 1.0);
30
- expect(r).toBe(0.0);
31
- expect(g).toBe(0.0);
32
- expect(b).toBe(0.0);
33
  expect(alpha).toBeCloseTo(0.05, 5);
34
  });
35
 
36
- it('maps mid negative (-0.5) to electric blue', () => {
37
  const { r, g, b } = getDivergentColor(-0.5, 1.0);
38
- expect(r).toBeCloseTo(0.0, 5);
39
- expect(g).toBeCloseTo(0.25, 5);
40
- expect(b).toBeCloseTo(1.0, 5);
41
  });
42
 
43
- it('maps peak negative (-1.0) to neon violet', () => {
44
  const { r, g, b, alpha } = getDivergentColor(-1.0, 1.0);
45
- expect(r).toBeCloseTo(0.6, 5);
46
- expect(g).toBeCloseTo(0.0, 5);
47
- expect(b).toBeCloseTo(0.9, 5);
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
+ });