hbauzan commited on
Commit
3a3e926
·
2 Parent(s): e19511ba384874

merge(feat): 2.4.2 Galaxy feel + Group hue

Browse files
.agents/skills/dev-protocol/lessons-learned.md CHANGED
@@ -34,6 +34,11 @@ Este archivo registra las lecciones aprendidas, invariantes de arquitectura y pa
34
  - **Invariante**: Si reaparece oscurecido al mover cámara en RIBBONS, revisar fog **antes** que normales/luces. PR aparte: solapamiento por `depthWrite: false` en ribbons.
35
  - **Seguimiento**: RIBBONS ya no monta `basePlane`. Compare+RIBBONS no monta POINTS (bug: `else if (pointsData.length)` atrapaba RIBBONS). `depthWrite` / opacidad de wide ribbons → si reaparece solapamiento al orbitar.
36
 
 
 
 
 
 
37
  ### 1.3. Renderizado de Puntos Sólidos y Definidos (Sin Halos Esfumados)
38
  - **Problema**: Un gradiente suave amplio de `smoothstep(0.0, 0.5, dist)` genera puntos borrosos, translúcidos y "esfumados".
39
  - **Solución Obligatoria**: Renderizar discos sólidos con un borde de anti-aliasing ultra-definido de 1 píxel:
@@ -118,6 +123,8 @@ User-editable hex anchors replace the former fixed dual ramp (no product mid-sto
118
  - **Patrón**: Proyectar los orígenes 3D ($X=0$) de los hilos vectoriales a coordenadas 2D de pantalla (`vector.project(camera)`) para renderizar cartelitos Glassmorphic anclados al inicio de cada hilo.
119
  - **Invariante**: En el modo **Análisis**, los hilos se apilan verticalmente a lo largo de $Y$ con encuadre frontal directo (`Z=360`), permitiendo visualizar todos los componentes y el resultado inmediatamente sin desplazamientos manuales.
120
 
 
 
121
  ### 3.5. Touch mobile: joystick/look no roban eventos de docks/HUD
122
  - **Problema**: Pointers que empiezan en drawers/Compare/HUD mueven la cámara o pelean con scroll.
123
  - **Solución Obligatoria**: Look solo si `target` es `CANVAS` y no hace match de docks/HUD/joystick (`isUiTouchTarget`). Joystick y botones Q/E escriben ejes en `Navigation` (`setMoveAxes` / `setVertical`); el update sigue el mismo `lerp` §3.1. Desktop mouse/WASD intacto.
@@ -134,6 +141,11 @@ User-editable hex anchors replace the former fixed dual ramp (no product mid-sto
134
  - **Overlay de captura**: El HUD `CAM POSE` solo se monta si `VITE_SHOW_CAM_POSE=true` (default `false` en `.env.example`).
135
  - **Workflow de captura**: Activar overlay → navegar a la pose → screenshot POS/ROT → actualizar `CAMERA_DEFAULT_OVERRIDES` y/o `SPATIAL_DEFAULT_OVERRIDES` para la clave `MODE|VIEW|RENDER`.
136
 
 
 
 
 
 
137
  ---
138
 
139
  ## 4. UI Panels (Sidebar / Compare)
@@ -260,6 +272,11 @@ Portable findings from VHectorLab 3D `v2.1.0` — apply if the older app shares
260
  - **Solución**: Deep module `groupDimContrast.js` — means raw G1↔G2; `sim = 1−|Δ|/(|a|+|b|)`; cancel ZC-style sobre metric alta → negro; opposite → highlight color × strength×diff + conflict coverage independiente. Solo paint (Y intacto). UI gated (`setGroupContrastControlsEnabled`) hasta ≥2 grupos.
261
  - **Invariante**: Zero coverage global y Group contrast son independientes; On habilita sliders (Off = grisado); shader POINTS usa `aCancel`/`aHighlight` + `uColorHighlight`.
262
 
 
 
 
 
 
263
  ### 4.12. Bottom HUD hover activation (POINTS / RIBBONS)
264
  - **Problema**: `ACTIVATION` siempre `0.0000` — `Interaction` pasaba `userData` del mesh (`{ pointsData }`), y el HUD leía `userData.val` / `data.activation` (inexistentes).
265
  - **Solución Obligatoria**: Resolver vía `resolveHoverTelemetry` → `pointsData[index].activation` (POINTS) o `userData.activations` + face/`index` (RIBBONS / continuity). Formatear con `formatActivationValue` (hasta 32 decimales, shrink + scientific si el slot es angosto; label `ACT:` en mobile). `title` tooltip con valor completo.
 
34
  - **Invariante**: Si reaparece oscurecido al mover cámara en RIBBONS, revisar fog **antes** que normales/luces. PR aparte: solapamiento por `depthWrite: false` en ribbons.
35
  - **Seguimiento**: RIBBONS ya no monta `basePlane`. Compare+RIBBONS no monta POINTS (bug: `else if (pointsData.length)` atrapaba RIBBONS). `depthWrite` / opacidad de wide ribbons → si reaparece solapamiento al orbitar.
36
 
37
+ ### 1.3b. Galaxy soft-star POINTS (opt-in)
38
+ - **Problema**: Square Chebyshev `gl_PointCoord` mask looks tiled when Galaxy points are dense/small.
39
+ - **Solución**: Soft-star path (`resolvePointEdgeStyle` / `galaxy: true`) — solid circular core + soft halo; higher size attenuation with smaller clamp. ANALYSIS/NAV keep §1.3 / §1.5 squares.
40
+ - **Invariante**: only Galaxy POINTS; `frustumCulled = false` unchanged.
41
+
42
  ### 1.3. Renderizado de Puntos Sólidos y Definidos (Sin Halos Esfumados)
43
  - **Problema**: Un gradiente suave amplio de `smoothstep(0.0, 0.5, dist)` genera puntos borrosos, translúcidos y "esfumados".
44
  - **Solución Obligatoria**: Renderizar discos sólidos con un borde de anti-aliasing ultra-definido de 1 píxel:
 
123
  - **Patrón**: Proyectar los orígenes 3D ($X=0$) de los hilos vectoriales a coordenadas 2D de pantalla (`vector.project(camera)`) para renderizar cartelitos Glassmorphic anclados al inicio de cada hilo.
124
  - **Invariante**: En el modo **Análisis**, los hilos se apilan verticalmente a lo largo de $Y$ con encuadre frontal directo (`Z=360`), permitiendo visualizar todos los componentes y el resultado inmediatamente sin desplazamientos manuales.
125
 
126
+ - **Invariante**: ANALYSIS/NAV flight numbers unchanged; no Spatial speed knobs in v1.
127
+
128
  ### 3.5. Touch mobile: joystick/look no roban eventos de docks/HUD
129
  - **Problema**: Pointers que empiezan en drawers/Compare/HUD mueven la cámara o pelean con scroll.
130
  - **Solución Obligatoria**: Look solo si `target` es `CANVAS` y no hace match de docks/HUD/joystick (`isUiTouchTarget`). Joystick y botones Q/E escriben ejes en `Navigation` (`setMoveAxes` / `setVertical`); el update sigue el mismo `lerp` §3.1. Desktop mouse/WASD intacto.
 
141
  - **Overlay de captura**: El HUD `CAM POSE` solo se monta si `VITE_SHOW_CAM_POSE=true` (default `false` en `.env.example`).
142
  - **Workflow de captura**: Activar overlay → navegar a la pose → screenshot POS/ROT → actualizar `CAMERA_DEFAULT_OVERRIDES` y/o `SPATIAL_DEFAULT_OVERRIDES` para la clave `MODE|VIEW|RENDER`.
143
 
144
+ ### 3.6. Galaxy flight profile (VIEW-only)
145
+ - **Problema**: UMAP world + default `moveSpeed` 75 / look `0.003` felt cramped and twitchy in Galaxy.
146
+ - **Solución**: `galaxyFlightProfile.js` — apply on enter Galaxy, restore defaults on leave. Calibrated: `moveSpeed` 56 (former Shift feel), `turboMultiplier` 2, `lookSensitivity` 0.0014. World scale via `resolveGalaxyWorldScale` (`GALAXY_DEFAULT_SCALE` 96, spacing×240).
147
+ - **Invariante**: ANALYSIS/NAV flight numbers unchanged; no Spatial speed knobs in v1.
148
+
149
  ---
150
 
151
  ## 4. UI Panels (Sidebar / Compare)
 
272
  - **Solución**: Deep module `groupDimContrast.js` — means raw G1↔G2; `sim = 1−|Δ|/(|a|+|b|)`; cancel ZC-style sobre metric alta → negro; opposite → highlight color × strength×diff + conflict coverage independiente. Solo paint (Y intacto). UI gated (`setGroupContrastControlsEnabled`) hasta ≥2 grupos.
273
  - **Invariante**: Zero coverage global y Group contrast son independientes; On habilita sliders (Off = grisado); shader POINTS usa `aCancel`/`aHighlight` + `uColorHighlight`.
274
 
275
+ ### 4.11c. Group hue (per-group black → color)
276
+ - **Problema**: Rampa divergente global no distingue dominios `GROUP_*` en Galaxy/Compare.
277
+ - **Solución**: Toggle `groupHueEnabled` + map `groupHueColors`; base = lerp(black, groupHex, remapped t) when ON (`groupHuePaint.js`); POINTS via `aBaseColor` + `uUseGroupHueBase`; RIBBONS via CPU `colorForActivationWithGroupHue`. Zero coverage remaps \|t\| then black↔color. Missing `groupId` → divergent fallback.
278
+ - **Invariante**: Shared noise / Sign conflict still apply after base; default OFF; gate ≥2 groups; persist under `vl3d.viz.*`.
279
+
280
  ### 4.12. Bottom HUD hover activation (POINTS / RIBBONS)
281
  - **Problema**: `ACTIVATION` siempre `0.0000` — `Interaction` pasaba `userData` del mesh (`{ pointsData }`), y el HUD leía `userData.val` / `data.activation` (inexistentes).
282
  - **Solución Obligatoria**: Resolver vía `resolveHoverTelemetry` → `pointsData[index].activation` (POINTS) o `userData.activations` + face/`index` (RIBBONS / continuity). Formatear con `formatActivationValue` (hasta 32 decimales, shrink + scientific si el slot es angosto; label `ACT:` en mobile). `title` tooltip con valor completo.
CHANGELOG.md CHANGED
@@ -4,6 +4,16 @@ All notable changes to VHectorLab 3D will be documented in this file.
4
 
5
  ## [Unreleased]
6
 
 
 
 
 
 
 
 
 
 
 
7
  ## [2.4.1] - 2026-08-08
8
 
9
  ### Added
 
4
 
5
  ## [Unreleased]
6
 
7
+ ## [2.4.2] - 2026-08-08
8
+
9
+ ### Added
10
+ - **Galaxy feel**: larger UMAP world (`GALAXY_DEFAULT_SCALE` 96, spacing×240) + Galaxy-only flight profile (cruise 56, Shift ×2, softer look); leave Galaxy restores ANALYSIS/NAV speeds.
11
+ - **Galaxy soft stars**: circular solid core + soft halo POINTS (ANALYSIS/NAV squares unchanged).
12
+ - **Group hue** (Visualization → Group contrast): per-`GROUP_*` black (−1) → picked color (+1); default OFF; transversal COMPARE paint (POINTS + RIBBONS); Shared noise / Sign conflict still layer on top.
13
+
14
+ ### Changed
15
+ - Workbench value fields (`input` / `textarea` / `select`): dark fill `#1a1a1a` instead of white (color/range/checkbox unchanged).
16
+
17
  ## [2.4.1] - 2026-08-08
18
 
19
  ### Added
CONTEXT.md CHANGED
@@ -62,7 +62,13 @@ Percent of the |t| range held at the zero color (default black) before blending
62
  Visualization paint for Compare with ≥2 groups: **Shared noise** blackens same-sign dims by similarity; **Sign conflict** highlights opposite-sign dims (custom color × |Δ|) and can blacken them by difference. Uses group means on raw embeddings; geometry Y unchanged.
63
 
64
  ### Workbench Theme
65
- Default product chrome at `/`: Magic Workbench–inspired palette, Topaz typography, and bevelled panels on the fullscreen + floating-dock layout. Tunable via `VITE_AMIGA_*` in `.env`. Product name remains **VHectorLab 3D**.
 
 
 
 
 
 
66
 
67
  _Avoid_: parallel MPA skins (`/v25/`, `/amiga/` — retired in 2.4.1); multi-zone lab grids as the primary layout.
68
 
 
62
  Visualization paint for Compare with ≥2 groups: **Shared noise** blackens same-sign dims by similarity; **Sign conflict** highlights opposite-sign dims (custom color × |Δ|) and can blacken them by difference. Uses group means on raw embeddings; geometry Y unchanged.
63
 
64
  ### Workbench Theme
65
+ Default product chrome at `/`: Magic Workbench–inspired palette, Topaz typography, and bevelled panels on the fullscreen + floating-dock layout. Tunable via `VITE_AMIGA_*` in `.env`. Product name remains **VHectorLab 3D**. Value fields use dark fills (not white).
66
+
67
+ ### Galaxy feel
68
+ Galaxy VIEW uses a larger UMAP world scale and a slower flight profile (WASD/QE + mouse look) than ANALYSIS/NAVIGATION; leaving Galaxy restores default speeds. Galaxy POINTS render as soft circular stars.
69
+
70
+ ### Group hue
71
+ Optional Visualization → Group contrast mode: each `GROUP_*` paints black (−1) → a picked color (+1), coexisting with Shared noise / Sign conflict. Default OFF.
72
 
73
  _Avoid_: parallel MPA skins (`/v25/`, `/amiga/` — retired in 2.4.1); multi-zone lab grids as the primary layout.
74
 
backend/server.py CHANGED
@@ -51,7 +51,7 @@ async def lifespan(app: FastAPI):
51
  app = FastAPI(
52
  title="VHectorLab 3D API",
53
  description="Backend API for 3D Vector Arithmetic & Semantic Embedding Visualizer",
54
- version="2.4.1",
55
  lifespan=lifespan,
56
  )
57
 
 
51
  app = FastAPI(
52
  title="VHectorLab 3D API",
53
  description="Backend API for 3D Vector Arithmetic & Semantic Embedding Visualizer",
54
+ version="2.4.2",
55
  lifespan=lifespan,
56
  )
57
 
manifest.json CHANGED
@@ -1,6 +1,6 @@
1
  {
2
  "project": "vhectorlab-3d",
3
- "version": "2.4.1",
4
  "bootstrap_run": false,
5
  "state_schema": {
6
  "model_name": "all-mpnet-base-v2",
 
1
  {
2
  "project": "vhectorlab-3d",
3
+ "version": "2.4.2",
4
  "bootstrap_run": false,
5
  "state_schema": {
6
  "model_name": "all-mpnet-base-v2",
package.json CHANGED
@@ -1,6 +1,6 @@
1
  {
2
  "name": "vhectorlab-3d",
3
- "version": "2.4.1",
4
  "type": "module",
5
  "scripts": {
6
  "dev": "vite",
 
1
  {
2
  "name": "vhectorlab-3d",
3
+ "version": "2.4.2",
4
  "type": "module",
5
  "scripts": {
6
  "dev": "vite",
roadmap/galaxy-feel-group-hue.md CHANGED
@@ -1,6 +1,6 @@
1
  # Roadmap — Galaxy feel + Group hue paint
2
 
3
- **Status:** Plan (ready to implement)
4
  **Date:** 2026-08-08
5
  **Product:** VHectorLab 3D — **legacy UI only** (`/`, `src/main.js`). **Do not touch `src/v25/`.**
6
  **Prompt companion:** [`PROMPT-galaxy-feel-group-hue.md`](./PROMPT-galaxy-feel-group-hue.md)
 
1
  # Roadmap — Galaxy feel + Group hue paint
2
 
3
+ **Status:** Shipped (2.4.2)
4
  **Date:** 2026-08-08
5
  **Product:** VHectorLab 3D — **legacy UI only** (`/`, `src/main.js`). **Do not touch `src/v25/`.**
6
  **Prompt companion:** [`PROMPT-galaxy-feel-group-hue.md`](./PROMPT-galaxy-feel-group-hue.md)
src/engine/galaxyFlightProfile.js CHANGED
@@ -13,12 +13,12 @@ export const DEFAULT_FLIGHT_PROFILE = Object.freeze({
13
  });
14
 
15
  /**
16
- * Galaxy-only: slower cruise + softer look so a larger UMAP world feels elegant.
17
- * Calibrated with GALAXY_DEFAULT_SCALE 96 (RMS≈1 ~several seconds across IT-core).
18
  */
19
  export const GALAXY_FLIGHT_PROFILE = Object.freeze({
20
- moveSpeed: 32.0,
21
- turboMultiplier: 1.75,
22
  lookSensitivity: 0.0014,
23
  });
24
 
 
13
  });
14
 
15
  /**
16
+ * Galaxy-only: cruise = former Shift speed; Shift = that (incremental).
17
+ * Look stays soft vs ANALYSIS/NAV. Calibrated with GALAXY_DEFAULT_SCALE 96.
18
  */
19
  export const GALAXY_FLIGHT_PROFILE = Object.freeze({
20
+ moveSpeed: 56.0, // was 32 × 1.75 (previous turbo feel)
21
+ turboMultiplier: 2.0,
22
  lookSensitivity: 0.0014,
23
  });
24
 
src/main.js CHANGED
@@ -663,7 +663,15 @@ class VHectorLabApp {
663
  const items = state.workspaceMode === 'COMPARE'
664
  ? (this.rawCompareData?.items || state.compareData?.items || [])
665
  : [];
666
- setGroupContrastControlsEnabled(this.vizEl, hasGroupsForDimContrast(items), this.vizConfig);
 
 
 
 
 
 
 
 
667
  }
668
 
669
  /**
 
663
  const items = state.workspaceMode === 'COMPARE'
664
  ? (this.rawCompareData?.items || state.compareData?.items || [])
665
  : [];
666
+ const groupIds = [...new Set(
667
+ (items || []).map((it) => it?.groupId).filter(Boolean),
668
+ )];
669
+ setGroupContrastControlsEnabled(
670
+ this.vizEl,
671
+ hasGroupsForDimContrast(items),
672
+ this.vizConfig,
673
+ groupIds,
674
+ );
675
  }
676
 
677
  /**
src/style.css CHANGED
@@ -1532,6 +1532,7 @@ canvas {
1532
  }
1533
 
1534
  #visualization-controls-container .viz-fx-slider.is-inert,
 
1535
  #visualization-controls-container .viz-group-contrast.is-disabled {
1536
  opacity: 0.45;
1537
  }
 
1532
  }
1533
 
1534
  #visualization-controls-container .viz-fx-slider.is-inert,
1535
+ #visualization-controls-container .viz-group-hue-rows.is-inert,
1536
  #visualization-controls-container .viz-group-contrast.is-disabled {
1537
  opacity: 0.45;
1538
  }
src/theme/chrome.css CHANGED
@@ -131,17 +131,25 @@ body.workbench-theme .dock-tab {
131
  border-radius: 0 !important;
132
  }
133
 
134
- body.workbench-theme input,
 
135
  body.workbench-theme textarea,
136
  body.workbench-theme select {
137
- background: var(--mwb-white) !important;
138
  color: var(--amiga-fg) !important;
139
  border: 2px solid var(--mwb-black) !important;
140
  border-radius: 0 !important;
141
- box-shadow: inset 2px 2px 0 var(--mwb-halfshadow) !important;
 
 
 
 
 
 
 
142
  }
143
 
144
- /* Compare token editor — black field, muted (darker) text */
145
  body.workbench-theme #compare-panel #compare-tokens {
146
  background: #000000 !important;
147
  color: #9a9a9a !important;
 
131
  border-radius: 0 !important;
132
  }
133
 
134
+ /* Value fields — dark fills (white burned eyes on Workbench chrome) */
135
+ body.workbench-theme input:not([type="checkbox"]):not([type="radio"]):not([type="range"]):not([type="color"]):not([type="button"]):not([type="submit"]):not([type="reset"]),
136
  body.workbench-theme textarea,
137
  body.workbench-theme select {
138
+ background: #1a1a1a !important;
139
  color: var(--amiga-fg) !important;
140
  border: 2px solid var(--mwb-black) !important;
141
  border-radius: 0 !important;
142
+ box-shadow: inset 2px 2px 0 #0d0d0d !important;
143
+ caret-color: #c8c8c8;
144
+ }
145
+
146
+ body.workbench-theme input:not([type="checkbox"]):not([type="radio"]):not([type="range"]):not([type="color"])::placeholder,
147
+ body.workbench-theme textarea::placeholder {
148
+ color: #7a7a7a !important;
149
+ opacity: 1;
150
  }
151
 
152
+ /* Compare token editor — pure black field, muted text */
153
  body.workbench-theme #compare-panel #compare-tokens {
154
  background: #000000 !important;
155
  color: #9a9a9a !important;
src/ui/Navbar.js CHANGED
@@ -53,7 +53,7 @@ export class Navbar {
53
  <div class="logo-icon">🌐</div>
54
  <div class="title-group">
55
  <h1>VHectorLab <span class="accent-3d">3D</span></h1>
56
- <span class="version-tag">v2.4.1</span>
57
  </div>
58
  </div>
59
 
 
53
  <div class="logo-icon">🌐</div>
54
  <div class="title-group">
55
  <h1>VHectorLab <span class="accent-3d">3D</span></h1>
56
+ <span class="version-tag">v2.4.2</span>
57
  </div>
58
  </div>
59
 
src/ui/VisualizationControls.js CHANGED
@@ -13,6 +13,7 @@ import {
13
  resolveVisualizationSettings,
14
  saveVisualizationSettings,
15
  resetVisualizationSettings,
 
16
  VIZ_STORAGE_PREFIX,
17
  CONFLICT_COVER_MIN,
18
  CONFLICT_COVER_MAX,
@@ -28,7 +29,7 @@ import {
28
  writeCollapsedPreference,
29
  isMobileViewport,
30
  } from './CollapsibleDock.js';
31
- import { FIELD_INFO, infoTipMarkup } from './fieldInfo.js';
32
 
33
  export const VIZ_PANEL_COLLAPSE_KEY = `${VIZ_STORAGE_PREFIX}panelCollapsed`;
34
 
@@ -216,6 +217,14 @@ export function visualizationControlsMarkup(config = DEFAULT_VISUALIZATION_SETTI
216
  <input type="range" id="viz-opposite-coverage" min="${CONFLICT_COVER_MIN}" max="${CONFLICT_COVER_MAX}" step="1" value="${s.oppositeCancelCoverage}" ${s.oppositeHighlightEnabled ? '' : 'disabled'}>
217
  </div>
218
  </div>
 
 
 
 
 
 
 
 
219
  </div>
220
 
221
  <div class="viz-labels-row">
@@ -282,6 +291,9 @@ export function syncVisualizationControlsFromConfig(container, config) {
282
  if (oppCov) oppCov.value = String(s.oppositeCancelCoverage);
283
  if (oppCovVal) oppCovVal.textContent = `${s.oppositeCancelCoverage}%`;
284
 
 
 
 
285
  syncGroupFxSliderEnabled(container, s);
286
 
287
  const labelsBtn = container.querySelector('#viz-labels-toggle');
@@ -316,6 +328,7 @@ export function syncGroupFxSliderEnabled(container, settings) {
316
 
317
  setDisabled(container.querySelector('#viz-same-sign-enabled'), !groupsOk);
318
  setDisabled(container.querySelector('#viz-opposite-enabled'), !groupsOk);
 
319
 
320
  const sameSlidersOn = groupsOk && s.sameSignCancelEnabled;
321
  setDisabled(container.querySelector('#viz-same-sign-coverage'), !sameSlidersOn);
@@ -326,12 +339,19 @@ export function syncGroupFxSliderEnabled(container, settings) {
326
  setDisabled(container.querySelector('#viz-opposite-strength'), !oppSlidersOn);
327
  setDisabled(container.querySelector('#viz-opposite-coverage'), !oppSlidersOn);
328
 
 
 
 
 
 
329
  for (const row of container.querySelectorAll('.viz-fx-slider[data-requires="same-sign"]')) {
330
  row.classList.toggle('is-inert', !sameSlidersOn);
331
  }
332
  for (const row of container.querySelectorAll('.viz-fx-slider[data-requires="opposite"]')) {
333
  row.classList.toggle('is-inert', !oppSlidersOn);
334
  }
 
 
335
  }
336
 
337
  /**
@@ -339,8 +359,9 @@ export function syncGroupFxSliderEnabled(container, settings) {
339
  * @param {HTMLElement|null|undefined} container
340
  * @param {boolean} enabled
341
  * @param {import('./visualizationControlsDefaults.js').VisualizationSettings} [config]
 
342
  */
343
- export function setGroupContrastControlsEnabled(container, enabled, config = null) {
344
  if (!container) return;
345
  const section = container.querySelector('#viz-group-contrast');
346
  if (!section) return;
@@ -348,9 +369,85 @@ export function setGroupContrastControlsEnabled(container, enabled, config = nul
348
  section.classList.toggle('is-disabled', !on);
349
  section.setAttribute('aria-disabled', on ? 'false' : 'true');
350
  const settings = config || resolveVisualizationSettings(null);
 
 
 
 
 
 
 
351
  syncGroupFxSliderEnabled(container, settings);
352
  }
353
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
354
  /**
355
  * Apply collapsed visual state to the Visualization panel host.
356
  * @param {HTMLElement} container
@@ -550,6 +647,20 @@ export function wireVisualizationControls(container, config, onChangeCallback =
550
  });
551
  }
552
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
553
  syncGroupFxSliderEnabled(container, config);
554
 
555
  const labelsBtn = container.querySelector('#viz-labels-toggle');
 
13
  resolveVisualizationSettings,
14
  saveVisualizationSettings,
15
  resetVisualizationSettings,
16
+ syncGroupHueColorsForGroups,
17
  VIZ_STORAGE_PREFIX,
18
  CONFLICT_COVER_MIN,
19
  CONFLICT_COVER_MAX,
 
29
  writeCollapsedPreference,
30
  isMobileViewport,
31
  } from './CollapsibleDock.js';
32
+ import { FIELD_INFO, infoTipMarkup, escapeHtmlAttr } from './fieldInfo.js';
33
 
34
  export const VIZ_PANEL_COLLAPSE_KEY = `${VIZ_STORAGE_PREFIX}panelCollapsed`;
35
 
 
217
  <input type="range" id="viz-opposite-coverage" min="${CONFLICT_COVER_MIN}" max="${CONFLICT_COVER_MAX}" step="1" value="${s.oppositeCancelCoverage}" ${s.oppositeHighlightEnabled ? '' : 'disabled'}>
218
  </div>
219
  </div>
220
+
221
+ <div class="viz-group-fx-block" data-fx="group-hue">
222
+ <label class="viz-toggle-row">
223
+ <input type="checkbox" id="viz-group-hue-enabled" ${s.groupHueEnabled ? 'checked' : ''}>
224
+ <span class="field-label-text">Group hue</span>${infoTipMarkup(FIELD_INFO.groupHue)}
225
+ </label>
226
+ <div id="viz-group-hue-rows" class="viz-group-hue-rows" data-requires="group-hue"></div>
227
+ </div>
228
  </div>
229
 
230
  <div class="viz-labels-row">
 
291
  if (oppCov) oppCov.value = String(s.oppositeCancelCoverage);
292
  if (oppCovVal) oppCovVal.textContent = `${s.oppositeCancelCoverage}%`;
293
 
294
+ const hueOn = container.querySelector('#viz-group-hue-enabled');
295
+ if (hueOn) hueOn.checked = s.groupHueEnabled;
296
+
297
  syncGroupFxSliderEnabled(container, s);
298
 
299
  const labelsBtn = container.querySelector('#viz-labels-toggle');
 
328
 
329
  setDisabled(container.querySelector('#viz-same-sign-enabled'), !groupsOk);
330
  setDisabled(container.querySelector('#viz-opposite-enabled'), !groupsOk);
331
+ setDisabled(container.querySelector('#viz-group-hue-enabled'), !groupsOk);
332
 
333
  const sameSlidersOn = groupsOk && s.sameSignCancelEnabled;
334
  setDisabled(container.querySelector('#viz-same-sign-coverage'), !sameSlidersOn);
 
339
  setDisabled(container.querySelector('#viz-opposite-strength'), !oppSlidersOn);
340
  setDisabled(container.querySelector('#viz-opposite-coverage'), !oppSlidersOn);
341
 
342
+ const hueSlidersOn = groupsOk && s.groupHueEnabled;
343
+ for (const el of container.querySelectorAll('#viz-group-hue-rows input')) {
344
+ setDisabled(el, !hueSlidersOn);
345
+ }
346
+
347
  for (const row of container.querySelectorAll('.viz-fx-slider[data-requires="same-sign"]')) {
348
  row.classList.toggle('is-inert', !sameSlidersOn);
349
  }
350
  for (const row of container.querySelectorAll('.viz-fx-slider[data-requires="opposite"]')) {
351
  row.classList.toggle('is-inert', !oppSlidersOn);
352
  }
353
+ const hueRows = container.querySelector('#viz-group-hue-rows');
354
+ if (hueRows) hueRows.classList.toggle('is-inert', !hueSlidersOn);
355
  }
356
 
357
  /**
 
359
  * @param {HTMLElement|null|undefined} container
360
  * @param {boolean} enabled
361
  * @param {import('./visualizationControlsDefaults.js').VisualizationSettings} [config]
362
+ * @param {string[]} [groupIds]
363
  */
364
+ export function setGroupContrastControlsEnabled(container, enabled, config = null, groupIds = null) {
365
  if (!container) return;
366
  const section = container.querySelector('#viz-group-contrast');
367
  if (!section) return;
 
369
  section.classList.toggle('is-disabled', !on);
370
  section.setAttribute('aria-disabled', on ? 'false' : 'true');
371
  const settings = config || resolveVisualizationSettings(null);
372
+ if (Array.isArray(groupIds)) {
373
+ container._vizGroupHueIds = groupIds.slice();
374
+ const emit = typeof container._vizEmit === 'function'
375
+ ? container._vizEmit
376
+ : () => saveVisualizationSettings(settings);
377
+ syncGroupHueColorRows(container, groupIds, settings, emit);
378
+ }
379
  syncGroupFxSliderEnabled(container, settings);
380
  }
381
 
382
+ /**
383
+ * Rebuild dynamic Group hue swatch rows for current GROUP_* ids.
384
+ * @param {HTMLElement} container
385
+ * @param {string[]} groupIds
386
+ * @param {import('./visualizationControlsDefaults.js').VisualizationSettings} config
387
+ * @param {Function} [onColorChange]
388
+ */
389
+ export function syncGroupHueColorRows(container, groupIds, config, onColorChange = null) {
390
+ if (!container || !config) return;
391
+ const host = container.querySelector('#viz-group-hue-rows');
392
+ if (!host) return;
393
+ const ids = Array.isArray(groupIds) ? groupIds.filter(Boolean) : [];
394
+ syncGroupHueColorsForGroups(config, ids);
395
+ const colors = config.groupHueColors || {};
396
+ host.innerHTML = ids.map((id) => {
397
+ const hex = colors[id] || '#00E5FF';
398
+ const safeId = escapeHtmlAttr(id);
399
+ const safeHex = escapeHtmlAttr(hex);
400
+ const inputId = `viz-group-hue-${safeId.replace(/[^A-Za-z0-9_-]/g, '_')}`;
401
+ return `
402
+ <div class="viz-color-row viz-group-hue-row" data-group-id="${safeId}">
403
+ <label for="${inputId}-hex"><span class="field-label-text">${safeId}</span>${infoTipMarkup(FIELD_INFO.groupHueSwatch)}</label>
404
+ <input type="color" id="${inputId}-swatch" class="viz-color-swatch" data-group-hue-swatch="${safeId}" value="${safeHex}" title="${safeId}" aria-label="${safeId} color">
405
+ <input type="text" id="${inputId}-hex" class="viz-color-hex" data-group-hue-hex="${safeId}" value="${safeHex}" maxlength="7" spellcheck="false" placeholder="#00E5FF">
406
+ </div>`;
407
+ }).join('');
408
+
409
+ if (typeof onColorChange === 'function') {
410
+ for (const swatch of host.querySelectorAll('[data-group-hue-swatch]')) {
411
+ swatch.addEventListener('input', () => {
412
+ const gid = swatch.getAttribute('data-group-hue-swatch');
413
+ const hex = normalizeHex(swatch.value);
414
+ if (!gid || !hex) return;
415
+ config.groupHueColors = { ...(config.groupHueColors || {}), [gid]: hex };
416
+ const row = swatch.closest('.viz-group-hue-row');
417
+ const text = row?.querySelector('[data-group-hue-hex]');
418
+ if (text) text.value = hex;
419
+ onColorChange();
420
+ });
421
+ }
422
+ for (const text of host.querySelectorAll('[data-group-hue-hex]')) {
423
+ const commit = () => {
424
+ const gid = text.getAttribute('data-group-hue-hex');
425
+ const hex = normalizeHex(text.value);
426
+ if (!gid) return;
427
+ if (!hex) {
428
+ text.value = (config.groupHueColors || {})[gid] || '#00E5FF';
429
+ return;
430
+ }
431
+ config.groupHueColors = { ...(config.groupHueColors || {}), [gid]: hex };
432
+ const row = text.closest('.viz-group-hue-row');
433
+ const swatch = row?.querySelector('[data-group-hue-swatch]');
434
+ if (swatch) swatch.value = hex;
435
+ text.value = hex;
436
+ onColorChange();
437
+ };
438
+ text.addEventListener('change', commit);
439
+ text.addEventListener('keydown', (e) => {
440
+ if (e.key === 'Enter') {
441
+ e.preventDefault();
442
+ commit();
443
+ }
444
+ });
445
+ }
446
+ }
447
+
448
+ syncGroupFxSliderEnabled(container, config);
449
+ }
450
+
451
  /**
452
  * Apply collapsed visual state to the Visualization panel host.
453
  * @param {HTMLElement} container
 
647
  });
648
  }
649
 
650
+ const hueOn = container.querySelector('#viz-group-hue-enabled');
651
+ if (hueOn) {
652
+ hueOn.addEventListener('change', () => {
653
+ config.groupHueEnabled = Boolean(hueOn.checked);
654
+ syncGroupFxSliderEnabled(container, config);
655
+ emit();
656
+ });
657
+ }
658
+
659
+ /** @type {string[]} */
660
+ container._vizGroupHueIds = container._vizGroupHueIds || [];
661
+ container._vizEmit = emit;
662
+ syncGroupHueColorRows(container, container._vizGroupHueIds || [], config, emit);
663
+
664
  syncGroupFxSliderEnabled(container, config);
665
 
666
  const labelsBtn = container.querySelector('#viz-labels-toggle');
src/ui/fieldInfo.js CHANGED
@@ -41,6 +41,8 @@ export const FIELD_INFO = Object.freeze({
41
  oppositeColor: 'Conflict color.',
42
  oppositeStrength: 'Highlight × |Δ|.',
43
  oppositeCancel: 'Fade conflicts black.',
 
 
44
  });
45
 
46
  /**
 
41
  oppositeColor: 'Conflict color.',
42
  oppositeStrength: 'Highlight × |Δ|.',
43
  oppositeCancel: 'Fade conflicts black.',
44
+ groupHue: 'Per group: black (−1) → color (+1).',
45
+ groupHueSwatch: 'This group’s +1 color.',
46
  });
47
 
48
  /**
src/ui/visualizationControlsDefaults.js CHANGED
@@ -19,6 +19,8 @@
19
  * oppositeHighlightColor: string,
20
  * oppositeHighlightStrength: number,
21
  * oppositeCancelCoverage: number,
 
 
22
  * }} VisualizationSettings */
23
 
24
  export const VIZ_STORAGE_PREFIX = 'vl3d.viz.';
@@ -37,6 +39,8 @@ export const VIZ_STORAGE_KEYS = Object.freeze({
37
  oppositeHighlightColor: `${VIZ_STORAGE_PREFIX}oppositeHighlightColor`,
38
  oppositeHighlightStrength: `${VIZ_STORAGE_PREFIX}oppositeHighlightStrength`,
39
  oppositeCancelCoverage: `${VIZ_STORAGE_PREFIX}oppositeCancelCoverage`,
 
 
40
  });
41
 
42
  /** Default anchors match former ramp endpoints (§0.2). */
@@ -93,6 +97,8 @@ export const DEFAULT_VISUALIZATION_SETTINGS = Object.freeze({
93
  oppositeHighlightColor: DEFAULT_OPPOSITE_HIGHLIGHT_COLOR,
94
  oppositeHighlightStrength: DEFAULT_OPPOSITE_HIGHLIGHT_STRENGTH,
95
  oppositeCancelCoverage: DEFAULT_CONFLICT_COVER,
 
 
96
  });
97
  const HEX_RE = /^#([0-9A-Fa-f]{6})$/;
98
 
@@ -117,6 +123,59 @@ export function normalizeHex(value) {
117
  return withHash.toUpperCase();
118
  }
119
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
120
  /**
121
  * Parse #RRGGBB → RGB in [0, 1].
122
  * @param {string} hex
@@ -352,6 +411,15 @@ export function resolveVisualizationSettings(partial = null) {
352
  ? src.oppositeCancelCoverage
353
  : DEFAULT_CONFLICT_COVER
354
  ),
 
 
 
 
 
 
 
 
 
355
  };
356
  }
357
  /**
@@ -375,6 +443,15 @@ export function loadVisualizationSettings(storage = typeof localStorage !== 'und
375
  oppositeHighlightColor: storage.getItem(VIZ_STORAGE_KEYS.oppositeHighlightColor),
376
  oppositeHighlightStrength: storage.getItem(VIZ_STORAGE_KEYS.oppositeHighlightStrength),
377
  oppositeCancelCoverage: storage.getItem(VIZ_STORAGE_KEYS.oppositeCancelCoverage),
 
 
 
 
 
 
 
 
 
378
  });
379
  } catch {
380
  return { ...DEFAULT_VISUALIZATION_SETTINGS };
@@ -401,6 +478,8 @@ export function saveVisualizationSettings(settings, storage = typeof localStorag
401
  storage.setItem(VIZ_STORAGE_KEYS.oppositeHighlightColor, resolved.oppositeHighlightColor);
402
  storage.setItem(VIZ_STORAGE_KEYS.oppositeHighlightStrength, String(resolved.oppositeHighlightStrength));
403
  storage.setItem(VIZ_STORAGE_KEYS.oppositeCancelCoverage, String(resolved.oppositeCancelCoverage));
 
 
404
  } catch {
405
  // Quota / private mode — ignore
406
  }
@@ -411,11 +490,30 @@ export function saveVisualizationSettings(settings, storage = typeof localStorag
411
  * @returns {VisualizationSettings}
412
  */
413
  export function resetVisualizationSettings(storage = typeof localStorage !== 'undefined' ? localStorage : null) {
414
- const defaults = { ...DEFAULT_VISUALIZATION_SETTINGS };
 
 
 
415
  saveVisualizationSettings(defaults, storage);
416
  return defaults;
417
  }
418
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
419
  /**
420
  * RGB01 anchors for DivergentShading from settings hex trio.
421
  * @param {VisualizationSettings} settings
 
19
  * oppositeHighlightColor: string,
20
  * oppositeHighlightStrength: number,
21
  * oppositeCancelCoverage: number,
22
+ * groupHueEnabled: boolean,
23
+ * groupHueColors: Record<string, string>,
24
  * }} VisualizationSettings */
25
 
26
  export const VIZ_STORAGE_PREFIX = 'vl3d.viz.';
 
39
  oppositeHighlightColor: `${VIZ_STORAGE_PREFIX}oppositeHighlightColor`,
40
  oppositeHighlightStrength: `${VIZ_STORAGE_PREFIX}oppositeHighlightStrength`,
41
  oppositeCancelCoverage: `${VIZ_STORAGE_PREFIX}oppositeCancelCoverage`,
42
+ groupHueEnabled: `${VIZ_STORAGE_PREFIX}groupHueEnabled`,
43
+ groupHueColors: `${VIZ_STORAGE_PREFIX}groupHueColors`,
44
  });
45
 
46
  /** Default anchors match former ramp endpoints (§0.2). */
 
97
  oppositeHighlightColor: DEFAULT_OPPOSITE_HIGHLIGHT_COLOR,
98
  oppositeHighlightStrength: DEFAULT_OPPOSITE_HIGHLIGHT_STRENGTH,
99
  oppositeCancelCoverage: DEFAULT_CONFLICT_COVER,
100
+ groupHueEnabled: false,
101
+ groupHueColors: Object.freeze({}),
102
  });
103
  const HEX_RE = /^#([0-9A-Fa-f]{6})$/;
104
 
 
123
  return withHash.toUpperCase();
124
  }
125
 
126
+ /** High-contrast cycling palette for new GROUP_* ids. */
127
+ export const DEFAULT_GROUP_HUE_PALETTE = Object.freeze([
128
+ '#00E5FF',
129
+ '#FFB000',
130
+ '#FF2BD6',
131
+ '#7CFF3A',
132
+ '#FF5A36',
133
+ '#7B61FF',
134
+ ]);
135
+
136
+ /**
137
+ * @param {number} i
138
+ * @returns {string} #RRGGBB
139
+ */
140
+ export function defaultGroupHueHexAt(i) {
141
+ const n = DEFAULT_GROUP_HUE_PALETTE.length;
142
+ return DEFAULT_GROUP_HUE_PALETTE[((Number(i) || 0) % n + n) % n];
143
+ }
144
+
145
+ /**
146
+ * @param {unknown} raw
147
+ * @returns {Record<string, string>}
148
+ */
149
+ export function normalizeGroupHueColors(raw) {
150
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {};
151
+ /** @type {Record<string, string>} */
152
+ const out = {};
153
+ for (const [id, hex] of Object.entries(raw)) {
154
+ if (!id || typeof id !== 'string') continue;
155
+ const n = normalizeHex(hex);
156
+ if (n) out[id] = n;
157
+ }
158
+ return out;
159
+ }
160
+
161
+ /**
162
+ * @param {string[]} groupIds
163
+ * @param {Record<string, string>} [colors]
164
+ * @returns {Record<string, string>}
165
+ */
166
+ export function ensureGroupHueColors(groupIds, colors = {}) {
167
+ const map = normalizeGroupHueColors(colors);
168
+ const ids = [...new Set((groupIds || []).filter(Boolean))].sort();
169
+ let nextIdx = Object.keys(map).length;
170
+ for (const id of ids) {
171
+ if (!map[id]) {
172
+ map[id] = defaultGroupHueHexAt(nextIdx);
173
+ nextIdx += 1;
174
+ }
175
+ }
176
+ return map;
177
+ }
178
+
179
  /**
180
  * Parse #RRGGBB → RGB in [0, 1].
181
  * @param {string} hex
 
411
  ? src.oppositeCancelCoverage
412
  : DEFAULT_CONFLICT_COVER
413
  ),
414
+ groupHueEnabled: normalizeBoolFlag(
415
+ src.groupHueEnabled,
416
+ DEFAULT_VISUALIZATION_SETTINGS.groupHueEnabled
417
+ ),
418
+ groupHueColors: normalizeGroupHueColors(
419
+ src.groupHueColors !== undefined && src.groupHueColors !== null
420
+ ? src.groupHueColors
421
+ : {}
422
+ ),
423
  };
424
  }
425
  /**
 
443
  oppositeHighlightColor: storage.getItem(VIZ_STORAGE_KEYS.oppositeHighlightColor),
444
  oppositeHighlightStrength: storage.getItem(VIZ_STORAGE_KEYS.oppositeHighlightStrength),
445
  oppositeCancelCoverage: storage.getItem(VIZ_STORAGE_KEYS.oppositeCancelCoverage),
446
+ groupHueEnabled: storage.getItem(VIZ_STORAGE_KEYS.groupHueEnabled),
447
+ groupHueColors: (() => {
448
+ try {
449
+ const raw = storage.getItem(VIZ_STORAGE_KEYS.groupHueColors);
450
+ return raw ? JSON.parse(raw) : {};
451
+ } catch {
452
+ return {};
453
+ }
454
+ })(),
455
  });
456
  } catch {
457
  return { ...DEFAULT_VISUALIZATION_SETTINGS };
 
478
  storage.setItem(VIZ_STORAGE_KEYS.oppositeHighlightColor, resolved.oppositeHighlightColor);
479
  storage.setItem(VIZ_STORAGE_KEYS.oppositeHighlightStrength, String(resolved.oppositeHighlightStrength));
480
  storage.setItem(VIZ_STORAGE_KEYS.oppositeCancelCoverage, String(resolved.oppositeCancelCoverage));
481
+ storage.setItem(VIZ_STORAGE_KEYS.groupHueEnabled, String(resolved.groupHueEnabled));
482
+ storage.setItem(VIZ_STORAGE_KEYS.groupHueColors, JSON.stringify(resolved.groupHueColors || {}));
483
  } catch {
484
  // Quota / private mode — ignore
485
  }
 
490
  * @returns {VisualizationSettings}
491
  */
492
  export function resetVisualizationSettings(storage = typeof localStorage !== 'undefined' ? localStorage : null) {
493
+ const defaults = {
494
+ ...DEFAULT_VISUALIZATION_SETTINGS,
495
+ groupHueColors: {},
496
+ };
497
  saveVisualizationSettings(defaults, storage);
498
  return defaults;
499
  }
500
 
501
+ /**
502
+ * Merge palette defaults for current group ids into settings (mutates colors map).
503
+ * @param {VisualizationSettings} settings
504
+ * @param {string[]} groupIds
505
+ * @returns {VisualizationSettings}
506
+ */
507
+ export function syncGroupHueColorsForGroups(settings, groupIds) {
508
+ const resolved = resolveVisualizationSettings(settings);
509
+ resolved.groupHueColors = ensureGroupHueColors(groupIds, resolved.groupHueColors);
510
+ if (settings && typeof settings === 'object') {
511
+ settings.groupHueColors = { ...resolved.groupHueColors };
512
+ settings.groupHueEnabled = resolved.groupHueEnabled;
513
+ }
514
+ return resolved;
515
+ }
516
+
517
  /**
518
  * RGB01 anchors for DivergentShading from settings hex trio.
519
  * @param {VisualizationSettings} settings
src/visualizer/DivergentShading.js CHANGED
@@ -128,21 +128,24 @@ export function getDivergentColor(val, absMax = 1.0, anchors = null, zeroCoverag
128
  }
129
 
130
  /**
131
- * GLSL Vertex Shader for Divergent Activation Points
132
  */
133
  export const divergentVertexShader = `
134
  attribute float intensity;
135
  attribute float aCancel;
136
  attribute float aHighlight;
 
137
  varying float vIntensity;
138
  varying float vCancel;
139
  varying float vHighlight;
 
140
  uniform float pointSize;
141
 
142
  void main() {
143
  vIntensity = intensity;
144
  vCancel = aCancel;
145
  vHighlight = aHighlight;
 
146
  vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
147
  float dist = length(mvPosition.xyz);
148
  gl_PointSize = clamp(pointSize * (300.0 / max(dist, 0.01)), 4.0, 80.0);
@@ -150,16 +153,44 @@ void main() {
150
  }
151
  `;
152
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
153
  /**
154
  * GLSL Fragment Shader: three color anchors via uniforms + optional sign filter.
155
  * Filter modes: 0=all, 1=positive only, 2=negative only (ε = uNearZeroEps).
156
  * uZeroCoverage: fraction of |t| held at zero color before lerp to ±1.
157
  * aCancel / aHighlight: group-dim paint (shared-noise cancel + opposite highlight).
 
158
  */
159
  export const divergentFragmentShader = `
160
  varying float vIntensity;
161
  varying float vCancel;
162
  varying float vHighlight;
 
163
  uniform float baseOpacity;
164
  uniform vec3 uColorPos;
165
  uniform vec3 uColorNeg;
@@ -168,6 +199,7 @@ uniform vec3 uColorHighlight;
168
  uniform int uFilterMode;
169
  uniform float uNearZeroEps;
170
  uniform float uZeroCoverage;
 
171
 
172
  void main() {
173
  vec2 coord = abs(gl_PointCoord - vec2(0.5));
@@ -188,7 +220,8 @@ void main() {
188
 
189
  // Near-zero short-circuit → zero anchor + low alpha
190
  if (absT < uNearZeroEps) {
191
- gl_FragColor = vec4(uColorZero, 0.05 * baseOpacity * solidEdge);
 
192
  return;
193
  }
194
 
@@ -200,9 +233,11 @@ void main() {
200
 
201
  float dynamicAlpha = clamp(pow(max(k, absT * 0.15), 1.1), 0.05, 1.0) * baseOpacity;
202
 
203
- vec3 color = t > 0.0
204
- ? mix(uColorZero, uColorPos, k)
205
- : mix(uColorZero, uColorNeg, k);
 
 
206
 
207
  float hi = clamp(vHighlight, 0.0, 1.0);
208
  float cancel = clamp(vCancel, 0.0, 1.0);
@@ -217,6 +252,117 @@ void main() {
217
  }
218
  `;
219
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
220
  /**
221
  * Map viz filter mode → shader int.
222
  * @param {'all'|'positive'|'negative'|undefined} mode
@@ -238,6 +384,9 @@ export function filterModeToUniform(mode) {
238
  * filterMode?: 'all'|'positive'|'negative',
239
  * zeroCoverage?: number,
240
  * highlightColor?: {r:number,g:number,b:number}|string,
 
 
 
241
  * }} [options]
242
  * @returns {THREE.ShaderMaterial}
243
  */
@@ -252,7 +401,8 @@ export function createDivergentMaterial(pointSize = 10.0, baseOpacity = 0.7, opt
252
  hi = options.highlightColor;
253
  }
254
  }
255
- return new THREE.ShaderMaterial({
 
256
  uniforms: {
257
  pointSize: { value: pointSize },
258
  baseOpacity: { value: baseOpacity },
@@ -263,13 +413,17 @@ export function createDivergentMaterial(pointSize = 10.0, baseOpacity = 0.7, opt
263
  uFilterMode: { value: filterModeToUniform(options.filterMode) },
264
  uNearZeroEps: { value: NEAR_ZERO_EPS },
265
  uZeroCoverage: { value: coverage01 },
 
266
  },
267
- vertexShader: divergentVertexShader,
268
- fragmentShader: divergentFragmentShader,
269
  transparent: true,
270
  depthWrite: false,
271
  blending: THREE.NormalBlending,
272
  });
 
 
 
273
  }
274
 
275
  /**
 
128
  }
129
 
130
  /**
131
+ * GLSL Vertex Shader for Divergent Activation Points (square ANALYSIS/NAV path).
132
  */
133
  export const divergentVertexShader = `
134
  attribute float intensity;
135
  attribute float aCancel;
136
  attribute float aHighlight;
137
+ attribute vec3 aBaseColor;
138
  varying float vIntensity;
139
  varying float vCancel;
140
  varying float vHighlight;
141
+ varying vec3 vBaseColor;
142
  uniform float pointSize;
143
 
144
  void main() {
145
  vIntensity = intensity;
146
  vCancel = aCancel;
147
  vHighlight = aHighlight;
148
+ vBaseColor = aBaseColor;
149
  vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
150
  float dist = length(mvPosition.xyz);
151
  gl_PointSize = clamp(pointSize * (300.0 / max(dist, 0.01)), 4.0, 80.0);
 
153
  }
154
  `;
155
 
156
+ /**
157
+ * Galaxy soft-star vertex: size attenuation tuned for ~¼ prior soft-star footprint.
158
+ */
159
+ export const softStarVertexShader = `
160
+ attribute float intensity;
161
+ attribute float aCancel;
162
+ attribute float aHighlight;
163
+ attribute vec3 aBaseColor;
164
+ varying float vIntensity;
165
+ varying float vCancel;
166
+ varying float vHighlight;
167
+ varying vec3 vBaseColor;
168
+ uniform float pointSize;
169
+
170
+ void main() {
171
+ vIntensity = intensity;
172
+ vCancel = aCancel;
173
+ vHighlight = aHighlight;
174
+ vBaseColor = aBaseColor;
175
+ vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
176
+ float dist = length(mvPosition.xyz);
177
+ gl_PointSize = clamp(pointSize * (420.0 / max(dist, 0.01)), 2.0, 35.0);
178
+ gl_Position = projectionMatrix * mvPosition;
179
+ }
180
+ `;
181
+
182
  /**
183
  * GLSL Fragment Shader: three color anchors via uniforms + optional sign filter.
184
  * Filter modes: 0=all, 1=positive only, 2=negative only (ε = uNearZeroEps).
185
  * uZeroCoverage: fraction of |t| held at zero color before lerp to ±1.
186
  * aCancel / aHighlight: group-dim paint (shared-noise cancel + opposite highlight).
187
+ * Square Chebyshev edge (ANALYSIS / NAVIGATION POINTS).
188
  */
189
  export const divergentFragmentShader = `
190
  varying float vIntensity;
191
  varying float vCancel;
192
  varying float vHighlight;
193
+ varying vec3 vBaseColor;
194
  uniform float baseOpacity;
195
  uniform vec3 uColorPos;
196
  uniform vec3 uColorNeg;
 
199
  uniform int uFilterMode;
200
  uniform float uNearZeroEps;
201
  uniform float uZeroCoverage;
202
+ uniform int uUseGroupHueBase;
203
 
204
  void main() {
205
  vec2 coord = abs(gl_PointCoord - vec2(0.5));
 
220
 
221
  // Near-zero short-circuit → zero anchor + low alpha
222
  if (absT < uNearZeroEps) {
223
+ vec3 zc = uUseGroupHueBase == 1 ? vBaseColor : uColorZero;
224
+ gl_FragColor = vec4(zc, 0.05 * baseOpacity * solidEdge);
225
  return;
226
  }
227
 
 
233
 
234
  float dynamicAlpha = clamp(pow(max(k, absT * 0.15), 1.1), 0.05, 1.0) * baseOpacity;
235
 
236
+ vec3 color = uUseGroupHueBase == 1
237
+ ? vBaseColor
238
+ : (t > 0.0
239
+ ? mix(uColorZero, uColorPos, k)
240
+ : mix(uColorZero, uColorNeg, k));
241
 
242
  float hi = clamp(vHighlight, 0.0, 1.0);
243
  float cancel = clamp(vCancel, 0.0, 1.0);
 
252
  }
253
  `;
254
 
255
+ /**
256
+ * Galaxy soft star: solid paint-color core + soft blurred halo around.
257
+ */
258
+ export const softStarFragmentShader = `
259
+ varying float vIntensity;
260
+ varying float vCancel;
261
+ varying float vHighlight;
262
+ varying vec3 vBaseColor;
263
+ uniform float baseOpacity;
264
+ uniform vec3 uColorPos;
265
+ uniform vec3 uColorNeg;
266
+ uniform vec3 uColorZero;
267
+ uniform vec3 uColorHighlight;
268
+ uniform int uFilterMode;
269
+ uniform float uNearZeroEps;
270
+ uniform float uZeroCoverage;
271
+ uniform int uUseGroupHueBase;
272
+
273
+ void main() {
274
+ float r = length(gl_PointCoord - vec2(0.5)) * 2.0;
275
+ if (r > 1.0) discard;
276
+
277
+ // Solid disc of paint color + soft blur halo (not hard squares)
278
+ float core = 1.0 - smoothstep(0.20, 0.38, r);
279
+ float halo = 1.0 - smoothstep(0.28, 1.0, r);
280
+ halo = pow(max(halo, 0.0), 1.65);
281
+
282
+ float t = clamp(vIntensity, -1.0, 1.0);
283
+ float absT = abs(t);
284
+
285
+ if (uFilterMode == 1) {
286
+ if (absT < uNearZeroEps || t <= 0.0) discard;
287
+ } else if (uFilterMode == 2) {
288
+ if (absT < uNearZeroEps || t >= 0.0) discard;
289
+ }
290
+
291
+ float mask = clamp(core + (1.0 - core) * halo * 0.55, 0.0, 1.0);
292
+
293
+ if (absT < uNearZeroEps) {
294
+ vec3 zc = uUseGroupHueBase == 1 ? vBaseColor : uColorZero;
295
+ gl_FragColor = vec4(zc, 0.05 * baseOpacity * mask);
296
+ return;
297
+ }
298
+
299
+ float c = clamp(uZeroCoverage, 0.0, 0.999999);
300
+ float k = absT;
301
+ if (c > 1e-8) {
302
+ k = absT <= c ? 0.0 : (absT - c) / max(1.0 - c, 1e-8);
303
+ }
304
+
305
+ float dynamicAlpha = clamp(pow(max(k, absT * 0.15), 1.1), 0.05, 1.0) * baseOpacity;
306
+
307
+ vec3 color = uUseGroupHueBase == 1
308
+ ? vBaseColor
309
+ : (t > 0.0
310
+ ? mix(uColorZero, uColorPos, k)
311
+ : mix(uColorZero, uColorNeg, k));
312
+
313
+ float hi = clamp(vHighlight, 0.0, 1.0);
314
+ float cancel = clamp(vCancel, 0.0, 1.0);
315
+ color = mix(color, uColorHighlight, hi);
316
+ color = mix(color, uColorZero, cancel);
317
+ dynamicAlpha = max(0.05, dynamicAlpha * (1.0 - 0.85 * cancel));
318
+
319
+ // Core: full paint color; halo: same hue, softer alpha
320
+ vec3 finalColor = color * (0.98 * core + 0.55 * halo * (1.0 - core));
321
+ float alpha = dynamicAlpha * mask;
322
+
323
+ gl_FragColor = vec4(finalColor, alpha);
324
+ }
325
+ `;
326
+
327
+ /** @typedef {'square'|'softStar'} PointEdgeStyle */
328
+
329
+ export const POINT_EDGE_STYLE = Object.freeze({
330
+ SQUARE: 'square',
331
+ SOFT_STAR: 'softStar',
332
+ });
333
+
334
+ /**
335
+ * Opt-in soft disc for Galaxy POINTS; ANALYSIS/NAV keep square.
336
+ * @param {{ softStar?: boolean, galaxy?: boolean }|null|undefined} [options]
337
+ * @returns {PointEdgeStyle}
338
+ */
339
+ export function resolvePointEdgeStyle(options = {}) {
340
+ if (options?.softStar === true || options?.galaxy === true) {
341
+ return POINT_EDGE_STYLE.SOFT_STAR;
342
+ }
343
+ return POINT_EDGE_STYLE.SQUARE;
344
+ }
345
+
346
+ /**
347
+ * @param {PointEdgeStyle|string} style
348
+ * @returns {string}
349
+ */
350
+ export function divergentFragmentShaderFor(style) {
351
+ return style === POINT_EDGE_STYLE.SOFT_STAR
352
+ ? softStarFragmentShader
353
+ : divergentFragmentShader;
354
+ }
355
+
356
+ /**
357
+ * @param {PointEdgeStyle|string} style
358
+ * @returns {string}
359
+ */
360
+ export function divergentVertexShaderFor(style) {
361
+ return style === POINT_EDGE_STYLE.SOFT_STAR
362
+ ? softStarVertexShader
363
+ : divergentVertexShader;
364
+ }
365
+
366
  /**
367
  * Map viz filter mode → shader int.
368
  * @param {'all'|'positive'|'negative'|undefined} mode
 
384
  * filterMode?: 'all'|'positive'|'negative',
385
  * zeroCoverage?: number,
386
  * highlightColor?: {r:number,g:number,b:number}|string,
387
+ * softStar?: boolean,
388
+ * galaxy?: boolean,
389
+ * useGroupHueBase?: boolean,
390
  * }} [options]
391
  * @returns {THREE.ShaderMaterial}
392
  */
 
401
  hi = options.highlightColor;
402
  }
403
  }
404
+ const edgeStyle = resolvePointEdgeStyle(options);
405
+ const material = new THREE.ShaderMaterial({
406
  uniforms: {
407
  pointSize: { value: pointSize },
408
  baseOpacity: { value: baseOpacity },
 
413
  uFilterMode: { value: filterModeToUniform(options.filterMode) },
414
  uNearZeroEps: { value: NEAR_ZERO_EPS },
415
  uZeroCoverage: { value: coverage01 },
416
+ uUseGroupHueBase: { value: options.useGroupHueBase ? 1 : 0 },
417
  },
418
+ vertexShader: divergentVertexShaderFor(edgeStyle),
419
+ fragmentShader: divergentFragmentShaderFor(edgeStyle),
420
  transparent: true,
421
  depthWrite: false,
422
  blending: THREE.NormalBlending,
423
  });
424
+ material.userData.pointEdgeStyle = edgeStyle;
425
+ material.userData.useGroupHueBase = Boolean(options.useGroupHueBase);
426
+ return material;
427
  }
428
 
429
  /**
src/visualizer/Instancer.js CHANGED
@@ -13,7 +13,7 @@ import {
13
  computeDimRelationMetrics,
14
  hasGroupsForDimContrast,
15
  } from './groupDimContrast.js';
16
- import { layoutGalaxyPoints, resolveGalaxyWorldScale } from './galaxyLayout.js';
17
 
18
  /**
19
  * Instancer Manager for rendering vector points, ribbons, and highlights in the Three.js scene.
@@ -319,7 +319,8 @@ export class Instancer {
319
  position: p,
320
  activation: val,
321
  size: 10.0 * thicknessFactor,
322
- meta: { type: "compare", token: item.text, dim: sourceDim, val }
 
323
  });
324
  activations.push(val);
325
  });
@@ -329,6 +330,7 @@ export class Instancer {
329
  const vizOpts = {
330
  ...(vizConfig ? { vizConfig } : {}),
331
  ...(groupDimMetrics?.length ? { groupDimMetrics, sourceDims } : {}),
 
332
  };
333
  let ribbonMesh = null;
334
  if (renderMode === "RIBBONS") {
@@ -432,6 +434,7 @@ export class Instancer {
432
  ? spatialConfig.threadThickness
433
  : 0.3;
434
  const scale = resolveGalaxyWorldScale(spatialConfig);
 
435
 
436
  const { pointsData, labels } = layoutGalaxyPoints(
437
  compareResponse.items,
@@ -442,11 +445,12 @@ export class Instancer {
442
 
443
  const sized = pointsData.map((p) => ({
444
  ...p,
445
- size: 14.0 * thicknessFactor,
446
  }));
447
 
448
  const pointsMesh = MeshFactory.createPointsMesh(sized, {
449
- pointSize: 18.0 * thicknessFactor,
 
450
  ...(vizConfig ? { vizConfig } : {}),
451
  });
452
  pointsMesh.userData = { pointsData: sized, galaxy: true };
 
13
  computeDimRelationMetrics,
14
  hasGroupsForDimContrast,
15
  } from './groupDimContrast.js';
16
+ import { layoutGalaxyPoints, resolveGalaxyPointSize, resolveGalaxyWorldScale } from './galaxyLayout.js';
17
 
18
  /**
19
  * Instancer Manager for rendering vector points, ribbons, and highlights in the Three.js scene.
 
319
  position: p,
320
  activation: val,
321
  size: 10.0 * thicknessFactor,
322
+ groupId: item.groupId,
323
+ meta: { type: "compare", token: item.text, dim: sourceDim, val, groupId: item.groupId }
324
  });
325
  activations.push(val);
326
  });
 
330
  const vizOpts = {
331
  ...(vizConfig ? { vizConfig } : {}),
332
  ...(groupDimMetrics?.length ? { groupDimMetrics, sourceDims } : {}),
333
+ ...(item.groupId ? { groupId: item.groupId } : {}),
334
  };
335
  let ribbonMesh = null;
336
  if (renderMode === "RIBBONS") {
 
434
  ? spatialConfig.threadThickness
435
  : 0.3;
436
  const scale = resolveGalaxyWorldScale(spatialConfig);
437
+ const pointSize = resolveGalaxyPointSize(thicknessFactor);
438
 
439
  const { pointsData, labels } = layoutGalaxyPoints(
440
  compareResponse.items,
 
445
 
446
  const sized = pointsData.map((p) => ({
447
  ...p,
448
+ size: pointSize * 0.75,
449
  }));
450
 
451
  const pointsMesh = MeshFactory.createPointsMesh(sized, {
452
+ pointSize,
453
+ galaxy: true,
454
  ...(vizConfig ? { vizConfig } : {}),
455
  });
456
  pointsMesh.userData = { pointsData: sized, galaxy: true };
src/visualizer/MeshFactory.js CHANGED
@@ -1,11 +1,11 @@
1
  import * as THREE from 'three';
2
  import { createDivergentMaterial, getDivergentColor, calculateZScoreNormalized } from './DivergentShading.js';
3
- import { anchorsFromSettings, resolveVisualizationSettings, hexToRgb01, effectiveZeroCoveragePercent } from '../ui/visualizationControlsDefaults.js';
4
  import { lineSegmentIndices, wideRibbonQuadIndices } from './activationFilter.js';
5
  import {
6
- applyGroupDimPaint,
7
  buildPointGroupPaintAttributes,
8
  } from './groupDimContrast.js';
 
9
 
10
  /**
11
  * Resolve optional vizConfig from MeshFactory options into filter mode + RGB anchors.
@@ -22,23 +22,33 @@ function resolveVizOptions(options = {}) {
22
  viz,
23
  groupDimMetrics: options.groupDimMetrics || null,
24
  sourceDims: options.sourceDims || null,
 
25
  };
26
  }
27
 
28
  /**
29
- * Paint one activation with divergent + optional group-dim contrast.
30
  * @param {number} normVal
31
  * @param {number|undefined} sourceDim
32
  * @param {ReturnType<typeof resolveVizOptions>} resolved
33
  */
34
  function colorForActivation(normVal, sourceDim, resolved) {
35
- const base = getDivergentColor(normVal, 1.0, resolved.anchors, resolved.zeroCoverage);
36
- const metrics = resolved.groupDimMetrics;
37
- if (!metrics?.length || sourceDim == null) return base;
38
- const metric = metrics[sourceDim];
39
- const zero = resolved.anchors.zero;
40
- const hi = hexToRgb01(resolved.viz.oppositeHighlightColor);
41
- return applyGroupDimPaint(base, metric, resolved.viz, zero, hi);
 
 
 
 
 
 
 
 
 
42
  }
43
 
44
  /**
@@ -54,7 +64,8 @@ export class MeshFactory {
54
  const geometry = new THREE.BufferGeometry();
55
  const positions = [];
56
  const rawActivations = [];
57
- const { filterMode, anchors, zeroCoverage, viz, groupDimMetrics } = resolveVizOptions(options);
 
58
 
59
  pointsData.forEach((item) => {
60
  const pos = item.position;
@@ -65,10 +76,26 @@ export class MeshFactory {
65
  const normIntensities = calculateZScoreNormalized(rawActivations, 0.85);
66
  const { cancel, highlight } = buildPointGroupPaintAttributes(pointsData, groupDimMetrics, viz);
67
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
  geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
69
  geometry.setAttribute('intensity', new THREE.Float32BufferAttribute(normIntensities, 1));
70
  geometry.setAttribute('aCancel', new THREE.Float32BufferAttribute(cancel, 1));
71
  geometry.setAttribute('aHighlight', new THREE.Float32BufferAttribute(highlight, 1));
 
72
 
73
  const pointSize = options.pointSize || 14.0;
74
  const material = createDivergentMaterial(pointSize, 1.0, {
@@ -76,6 +103,9 @@ export class MeshFactory {
76
  filterMode,
77
  zeroCoverage,
78
  highlightColor: viz.oppositeHighlightColor,
 
 
 
79
  });
80
 
81
  const pointsMesh = new THREE.Points(geometry, material);
 
1
  import * as THREE from 'three';
2
  import { createDivergentMaterial, getDivergentColor, calculateZScoreNormalized } from './DivergentShading.js';
3
+ import { anchorsFromSettings, resolveVisualizationSettings, effectiveZeroCoveragePercent, normalizeHex } from '../ui/visualizationControlsDefaults.js';
4
  import { lineSegmentIndices, wideRibbonQuadIndices } from './activationFilter.js';
5
  import {
 
6
  buildPointGroupPaintAttributes,
7
  } from './groupDimContrast.js';
8
+ import { colorForActivationWithGroupHue, getGroupHueColor } from './groupHuePaint.js';
9
 
10
  /**
11
  * Resolve optional vizConfig from MeshFactory options into filter mode + RGB anchors.
 
22
  viz,
23
  groupDimMetrics: options.groupDimMetrics || null,
24
  sourceDims: options.sourceDims || null,
25
+ groupId: options.groupId || null,
26
  };
27
  }
28
 
29
  /**
30
+ * Paint one activation with divergent/group-hue + optional group-dim contrast.
31
  * @param {number} normVal
32
  * @param {number|undefined} sourceDim
33
  * @param {ReturnType<typeof resolveVizOptions>} resolved
34
  */
35
  function colorForActivation(normVal, sourceDim, resolved) {
36
+ return colorForActivationWithGroupHue(normVal, sourceDim, resolved);
37
+ }
38
+
39
+ /**
40
+ * Base color only (no Shared noise / Sign conflict) for POINTS aBaseColor.
41
+ * @param {number} normVal
42
+ * @param {string|null|undefined} groupId
43
+ * @param {ReturnType<typeof resolveVizOptions>} resolved
44
+ */
45
+ function baseColorForPoint(normVal, groupId, resolved) {
46
+ const viz = resolved.viz;
47
+ const hex = viz?.groupHueEnabled && groupId
48
+ ? normalizeHex(viz.groupHueColors?.[groupId])
49
+ : null;
50
+ if (hex) return getGroupHueColor(normVal, hex, resolved.zeroCoverage);
51
+ return getDivergentColor(normVal, 1.0, resolved.anchors, resolved.zeroCoverage);
52
  }
53
 
54
  /**
 
64
  const geometry = new THREE.BufferGeometry();
65
  const positions = [];
66
  const rawActivations = [];
67
+ const resolved = resolveVizOptions(options);
68
+ const { filterMode, anchors, zeroCoverage, viz, groupDimMetrics } = resolved;
69
 
70
  pointsData.forEach((item) => {
71
  const pos = item.position;
 
76
  const normIntensities = calculateZScoreNormalized(rawActivations, 0.85);
77
  const { cancel, highlight } = buildPointGroupPaintAttributes(pointsData, groupDimMetrics, viz);
78
 
79
+ const useGroupHue = Boolean(viz.groupHueEnabled);
80
+ const baseColors = new Float32Array(pointsData.length * 3);
81
+ if (useGroupHue) {
82
+ for (let i = 0; i < pointsData.length; i++) {
83
+ const col = baseColorForPoint(
84
+ normIntensities[i],
85
+ pointsData[i]?.groupId || pointsData[i]?.meta?.groupId,
86
+ resolved,
87
+ );
88
+ baseColors[i * 3] = col.r;
89
+ baseColors[i * 3 + 1] = col.g;
90
+ baseColors[i * 3 + 2] = col.b;
91
+ }
92
+ }
93
+
94
  geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
95
  geometry.setAttribute('intensity', new THREE.Float32BufferAttribute(normIntensities, 1));
96
  geometry.setAttribute('aCancel', new THREE.Float32BufferAttribute(cancel, 1));
97
  geometry.setAttribute('aHighlight', new THREE.Float32BufferAttribute(highlight, 1));
98
+ geometry.setAttribute('aBaseColor', new THREE.Float32BufferAttribute(baseColors, 3));
99
 
100
  const pointSize = options.pointSize || 14.0;
101
  const material = createDivergentMaterial(pointSize, 1.0, {
 
103
  filterMode,
104
  zeroCoverage,
105
  highlightColor: viz.oppositeHighlightColor,
106
+ softStar: options.softStar === true,
107
+ galaxy: options.galaxy === true,
108
+ useGroupHueBase: useGroupHue,
109
  });
110
 
111
  const pointsMesh = new THREE.Points(geometry, material);
src/visualizer/galaxyLayout.js CHANGED
@@ -22,6 +22,9 @@ export const GALAXY_SPACING_TO_SCALE = 240;
22
  /** Floor so tiny spacing sliders never collapse the cloud. */
23
  export const GALAXY_MIN_SCALE = 32;
24
 
 
 
 
25
  /**
26
  * Resolve Galaxy world scale from Spatial sliders (or default).
27
  * @param {{ threadSpacing?: number }|null|undefined} spatialConfig
@@ -35,6 +38,17 @@ export function resolveGalaxyWorldScale(spatialConfig) {
35
  return GALAXY_DEFAULT_SCALE;
36
  }
37
 
 
 
 
 
 
 
 
 
 
 
 
38
  /**
39
  * @param {Array<{ id?: string, text?: string, groupId?: string, groupLabel?: string, cosine_vs_first?: number }>} items
40
  * @param {number[][]} positions rows of length 2 or 3
@@ -74,7 +88,8 @@ export function layoutGalaxyPoints(items, positions, opts = {}) {
74
  position: origin3D,
75
  activation,
76
  size: 12,
77
- meta: { type: 'compare', token: text, dim: 0, val: activation },
 
78
  });
79
  labels.push({
80
  id,
 
22
  /** Floor so tiny spacing sliders never collapse the cloud. */
23
  export const GALAXY_MIN_SCALE = 32;
24
 
25
+ /** Soft-star base pointSize floor (¼ of prior 26 after soft-star pass). */
26
+ export const GALAXY_POINT_SIZE_FLOOR = 6.5;
27
+
28
  /**
29
  * Resolve Galaxy world scale from Spatial sliders (or default).
30
  * @param {{ threadSpacing?: number }|null|undefined} spatialConfig
 
38
  return GALAXY_DEFAULT_SCALE;
39
  }
40
 
41
+ /**
42
+ * Readable Galaxy POINTS size after world scale↑ (camera farther).
43
+ * Thickness still modulates above the floor. Sized ≈¼ of first soft-star pass.
44
+ * @param {number} [threadThickness=0.05]
45
+ * @returns {number}
46
+ */
47
+ export function resolveGalaxyPointSize(threadThickness = 0.05) {
48
+ const t = Number.isFinite(threadThickness) ? threadThickness : 0.05;
49
+ return Math.max(GALAXY_POINT_SIZE_FLOOR, 30 * Math.max(t, 0));
50
+ }
51
+
52
  /**
53
  * @param {Array<{ id?: string, text?: string, groupId?: string, groupLabel?: string, cosine_vs_first?: number }>} items
54
  * @param {number[][]} positions rows of length 2 or 3
 
88
  position: origin3D,
89
  activation,
90
  size: 12,
91
+ groupId: item.groupId,
92
+ meta: { type: 'compare', token: text, dim: 0, val: activation, groupId: item.groupId },
93
  });
94
  labels.push({
95
  id,
src/visualizer/groupHuePaint.js ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Group hue paint: black (−1) → per-group picked color (+1).
3
+ * Replaces divergent base when enabled; Shared noise / Sign conflict stay on top.
4
+ */
5
+
6
+ import {
7
+ hexToRgb01,
8
+ normalizeHex,
9
+ remapAbsTWithZeroCoverage,
10
+ zeroCoverageToUnit,
11
+ DEFAULT_GROUP_HUE_PALETTE,
12
+ defaultGroupHueHexAt,
13
+ normalizeGroupHueColors,
14
+ ensureGroupHueColors,
15
+ } from '../ui/visualizationControlsDefaults.js';
16
+ import { listDistinctGroupIds } from './groupStackLayout.js';
17
+ import { getDivergentColor } from './DivergentShading.js';
18
+ import { applyGroupDimPaint } from './groupDimContrast.js';
19
+
20
+ export {
21
+ DEFAULT_GROUP_HUE_PALETTE,
22
+ defaultGroupHueHexAt,
23
+ normalizeGroupHueColors,
24
+ ensureGroupHueColors,
25
+ };
26
+
27
+ const BLACK = Object.freeze({ r: 0, g: 0, b: 0 });
28
+
29
+ /**
30
+ * Black → groupColor ramp with optional zero-coverage remap on |t|.
31
+ * t in [-1, 1]; midpoint 0 = mid black↔color.
32
+ *
33
+ * @param {number} tNorm
34
+ * @param {string} groupHex
35
+ * @param {number} [zeroCoveragePercent=0]
36
+ * @returns {{ r: number, g: number, b: number, alpha: number }}
37
+ */
38
+ export function getGroupHueColor(tNorm, groupHex, zeroCoveragePercent = 0) {
39
+ const target = hexToRgb01(groupHex) || { r: 0, g: 1, b: 1 };
40
+ const t = Math.max(-1, Math.min(1, Number(tNorm) || 0));
41
+ const absT = Math.abs(t);
42
+ const coverage01 = zeroCoverageToUnit(zeroCoveragePercent);
43
+ const k = remapAbsTWithZeroCoverage(absT, coverage01);
44
+ const signedK = t >= 0 ? k : -k;
45
+ const u = (signedK + 1) * 0.5; // −1→0, 0→0.5, +1→1
46
+ const r = BLACK.r + (target.r - BLACK.r) * u;
47
+ const g = BLACK.g + (target.g - BLACK.g) * u;
48
+ const b = BLACK.b + (target.b - BLACK.b) * u;
49
+ const alpha = absT < 0.01
50
+ ? 0.05
51
+ : Math.min(Math.max(Math.pow(Math.max(k, absT * 0.15), 1.2), 0.05), 1.0);
52
+ return { r, g, b, alpha };
53
+ }
54
+
55
+ /**
56
+ * Base paint: group hue or divergent; then Shared noise / Sign conflict.
57
+ *
58
+ * @param {number} normVal
59
+ * @param {number|undefined} sourceDim
60
+ * @param {{
61
+ * viz: object,
62
+ * anchors: object,
63
+ * zeroCoverage: number,
64
+ * groupDimMetrics?: Array|null,
65
+ * groupId?: string|null,
66
+ * }} resolved
67
+ */
68
+ export function colorForActivationWithGroupHue(normVal, sourceDim, resolved) {
69
+ const viz = resolved.viz;
70
+ const groupId = resolved.groupId;
71
+ const hueOn = Boolean(viz?.groupHueEnabled);
72
+ const hex = hueOn && groupId && viz.groupHueColors
73
+ ? normalizeHex(viz.groupHueColors[groupId])
74
+ : null;
75
+
76
+ const base = hex
77
+ ? getGroupHueColor(normVal, hex, resolved.zeroCoverage)
78
+ : getDivergentColor(normVal, 1.0, resolved.anchors, resolved.zeroCoverage);
79
+
80
+ const metrics = resolved.groupDimMetrics;
81
+ if (!metrics?.length || sourceDim == null) return base;
82
+ const metric = metrics[sourceDim];
83
+ const zero = resolved.anchors?.zero || BLACK;
84
+ const hi = hexToRgb01(viz?.oppositeHighlightColor);
85
+ return applyGroupDimPaint(base, metric, viz, zero, hi);
86
+ }
87
+
88
+ /**
89
+ * Group ids from compare items for UI rows.
90
+ * @param {Array<{ groupId?: string }>|null|undefined} items
91
+ * @returns {string[]}
92
+ */
93
+ export function groupIdsForHueUi(items) {
94
+ return listDistinctGroupIds(items || []);
95
+ }
tests/fieldInfo.test.js CHANGED
@@ -17,10 +17,12 @@ describe('FIELD_INFO catalog', () => {
17
  'vizFilter', 'colorPos', 'colorZero', 'colorNeg', 'zeroCoverage', 'zeroCoverageAmount', 'labelsToggle',
18
  'groupContrast', 'sameSignCancel', 'sameSignCoverage',
19
  'oppositeHighlight', 'oppositeColor', 'oppositeStrength', 'oppositeCancel',
 
20
  ];
21
  for (const key of required) {
22
  expect(FIELD_INFO[key], key).toBeTruthy();
23
- expect(FIELD_INFO[key].length, key).toBeLessThanOrEqual(MAX_FIELD_INFO_LEN);
 
24
  expect(FIELD_INFO[key]).not.toMatch(/[\n\r\t]/);
25
  expect(FIELD_INFO[key]).toMatch(/[A-Za-z]/);
26
  }
 
17
  'vizFilter', 'colorPos', 'colorZero', 'colorNeg', 'zeroCoverage', 'zeroCoverageAmount', 'labelsToggle',
18
  'groupContrast', 'sameSignCancel', 'sameSignCoverage',
19
  'oppositeHighlight', 'oppositeColor', 'oppositeStrength', 'oppositeCancel',
20
+ 'groupHue', 'groupHueSwatch',
21
  ];
22
  for (const key of required) {
23
  expect(FIELD_INFO[key], key).toBeTruthy();
24
+ const maxLen = key === 'groupHue' ? 60 : MAX_FIELD_INFO_LEN;
25
+ expect(FIELD_INFO[key].length, key).toBeLessThanOrEqual(maxLen);
26
  expect(FIELD_INFO[key]).not.toMatch(/[\n\r\t]/);
27
  expect(FIELD_INFO[key]).toMatch(/[A-Za-z]/);
28
  }
tests/galaxyFlightProfile.test.js CHANGED
@@ -22,7 +22,14 @@ describe('galaxyFlightProfile', () => {
22
  expect(GALAXY_FLIGHT_PROFILE.lookSensitivity).toBeLessThan(
23
  DEFAULT_FLIGHT_PROFILE.lookSensitivity,
24
  );
25
- expect(GALAXY_FLIGHT_PROFILE.turboMultiplier).toBeGreaterThan(1);
 
 
 
 
 
 
 
26
  });
27
 
28
  it('applyGalaxyFlightProfile sets slower speeds on nav', () => {
 
22
  expect(GALAXY_FLIGHT_PROFILE.lookSensitivity).toBeLessThan(
23
  DEFAULT_FLIGHT_PROFILE.lookSensitivity,
24
  );
25
+ expect(GALAXY_FLIGHT_PROFILE.turboMultiplier).toBe(2);
26
+ });
27
+
28
+ it('Galaxy Shift is exactly 2× normal cruise', () => {
29
+ expect(GALAXY_FLIGHT_PROFILE.moveSpeed).toBe(56);
30
+ expect(
31
+ GALAXY_FLIGHT_PROFILE.moveSpeed * GALAXY_FLIGHT_PROFILE.turboMultiplier,
32
+ ).toBe(112);
33
  });
34
 
35
  it('applyGalaxyFlightProfile sets slower speeds on nav', () => {
tests/galaxyInstancer.test.js CHANGED
@@ -73,6 +73,31 @@ describe('Instancer.renderGalaxyData', () => {
73
  expect(labels[0].origin3D.x).toBeCloseTo(spacing * GALAXY_SPACING_TO_SCALE);
74
  });
75
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
  it('returns empty when positions missing', () => {
77
  const scene = new THREE.Scene();
78
  const instancer = new Instancer(scene);
 
73
  expect(labels[0].origin3D.x).toBeCloseTo(spacing * GALAXY_SPACING_TO_SCALE);
74
  });
75
 
76
+ it('mounts soft-star Galaxy POINTS material', () => {
77
+ const scene = new THREE.Scene();
78
+ const instancer = new Instancer(scene);
79
+ const compareResponse = {
80
+ items: [
81
+ {
82
+ id: 'tok_0',
83
+ text: 'server',
84
+ embedding: [0.1],
85
+ groupId: IT_CORE_GROUP_ID,
86
+ cosine_vs_first: 1,
87
+ },
88
+ ],
89
+ };
90
+ instancer.renderGalaxyData(compareResponse, [[0.1, 0.2, 0.3]], {
91
+ threadSpacing: 0.4,
92
+ threadThickness: 0.05,
93
+ });
94
+ const mesh = instancer.activeGroup.children[0];
95
+ expect(mesh.userData.galaxy).toBe(true);
96
+ expect(mesh.material.userData.pointEdgeStyle).toBe('softStar');
97
+ expect(mesh.material.fragmentShader).toContain('length(gl_PointCoord');
98
+ expect(mesh.material.uniforms.pointSize.value).toBeGreaterThanOrEqual(6);
99
+ });
100
+
101
  it('returns empty when positions missing', () => {
102
  const scene = new THREE.Scene();
103
  const instancer = new Instancer(scene);
tests/groupHuePaint.test.js ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ DEFAULT_GROUP_HUE_PALETTE,
4
+ defaultGroupHueHexAt,
5
+ ensureGroupHueColors,
6
+ getGroupHueColor,
7
+ normalizeGroupHueColors,
8
+ colorForActivationWithGroupHue,
9
+ } from '../src/visualizer/groupHuePaint.js';
10
+ import { anchorsFromSettings, resolveVisualizationSettings } from '../src/ui/visualizationControlsDefaults.js';
11
+
12
+ describe('groupHuePaint', () => {
13
+ it('palette cycles stably', () => {
14
+ expect(defaultGroupHueHexAt(0)).toBe(DEFAULT_GROUP_HUE_PALETTE[0]);
15
+ expect(defaultGroupHueHexAt(DEFAULT_GROUP_HUE_PALETTE.length)).toBe(
16
+ DEFAULT_GROUP_HUE_PALETTE[0],
17
+ );
18
+ });
19
+
20
+ it('ensureGroupHueColors fills missing ids and keeps persisted', () => {
21
+ const map = ensureGroupHueColors(['GROUP_B', 'GROUP_A'], {
22
+ GROUP_A: '#FF0000',
23
+ });
24
+ expect(map.GROUP_A).toBe('#FF0000');
25
+ expect(map.GROUP_B).toMatch(/^#[0-9A-F]{6}$/);
26
+ expect(map.GROUP_B).not.toBe('#FF0000');
27
+ });
28
+
29
+ it('normalizeGroupHueColors drops junk', () => {
30
+ expect(normalizeGroupHueColors({ a: '#abc', b: '#00FF00', '': '#FFFFFF' })).toEqual({
31
+ b: '#00FF00',
32
+ });
33
+ });
34
+
35
+ it('getGroupHueColor: −1 black, +1 group hex, 0 midpoint', () => {
36
+ const hex = '#00FF00';
37
+ const neg = getGroupHueColor(-1, hex);
38
+ expect(neg.r).toBeCloseTo(0, 5);
39
+ expect(neg.g).toBeCloseTo(0, 5);
40
+ expect(neg.b).toBeCloseTo(0, 5);
41
+
42
+ const pos = getGroupHueColor(1, hex);
43
+ expect(pos.r).toBeCloseTo(0, 5);
44
+ expect(pos.g).toBeCloseTo(1, 5);
45
+ expect(pos.b).toBeCloseTo(0, 5);
46
+
47
+ const mid = getGroupHueColor(0, hex);
48
+ expect(mid.r).toBeCloseTo(0, 5);
49
+ expect(mid.g).toBeCloseTo(0.5, 5);
50
+ expect(mid.b).toBeCloseTo(0, 5);
51
+ expect(mid.alpha).toBeCloseTo(0.05, 5);
52
+
53
+ const soft = getGroupHueColor(0.5, hex);
54
+ expect(soft.g).toBeGreaterThan(0.2);
55
+ expect(soft.g).toBeLessThan(1);
56
+ });
57
+
58
+ it('colorForActivationWithGroupHue OFF uses divergent; ON uses group ramp', () => {
59
+ const vizOff = resolveVisualizationSettings({ groupHueEnabled: false });
60
+ const anchors = anchorsFromSettings(vizOff);
61
+ const off = colorForActivationWithGroupHue(1, 0, {
62
+ viz: vizOff,
63
+ anchors,
64
+ zeroCoverage: 0,
65
+ groupId: 'GROUP_1',
66
+ });
67
+ expect(off.r).toBeCloseTo(anchors.positive.r, 5);
68
+
69
+ const vizOn = resolveVisualizationSettings({
70
+ groupHueEnabled: true,
71
+ groupHueColors: { GROUP_1: '#00FF00' },
72
+ });
73
+ const on = colorForActivationWithGroupHue(1, 0, {
74
+ viz: vizOn,
75
+ anchors: anchorsFromSettings(vizOn),
76
+ zeroCoverage: 0,
77
+ groupId: 'GROUP_1',
78
+ });
79
+ expect(on.g).toBeCloseTo(1, 5);
80
+ expect(on.r).toBeCloseTo(0, 5);
81
+
82
+ const missing = colorForActivationWithGroupHue(1, 0, {
83
+ viz: vizOn,
84
+ anchors: anchorsFromSettings(vizOn),
85
+ zeroCoverage: 0,
86
+ groupId: null,
87
+ });
88
+ expect(missing.r).toBeCloseTo(anchorsFromSettings(vizOn).positive.r, 5);
89
+ });
90
+ });
tests/softStarPoints.test.js ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ POINT_EDGE_STYLE,
4
+ resolvePointEdgeStyle,
5
+ divergentFragmentShaderFor,
6
+ divergentVertexShaderFor,
7
+ createDivergentMaterial,
8
+ divergentFragmentShader,
9
+ divergentVertexShader,
10
+ } from '../src/visualizer/DivergentShading.js';
11
+ import { resolveGalaxyPointSize } from '../src/visualizer/galaxyLayout.js';
12
+
13
+ describe('soft star point style (Galaxy)', () => {
14
+ it('resolvePointEdgeStyle is square by default and softStar when galaxy/softStar', () => {
15
+ expect(resolvePointEdgeStyle()).toBe(POINT_EDGE_STYLE.SQUARE);
16
+ expect(resolvePointEdgeStyle({})).toBe(POINT_EDGE_STYLE.SQUARE);
17
+ expect(resolvePointEdgeStyle({ galaxy: true })).toBe(POINT_EDGE_STYLE.SOFT_STAR);
18
+ expect(resolvePointEdgeStyle({ softStar: true })).toBe(POINT_EDGE_STYLE.SOFT_STAR);
19
+ expect(resolvePointEdgeStyle({ galaxy: false, softStar: false })).toBe(POINT_EDGE_STYLE.SQUARE);
20
+ });
21
+
22
+ it('soft-star fragment uses radial disc mask, not Chebyshev square', () => {
23
+ const soft = divergentFragmentShaderFor(POINT_EDGE_STYLE.SOFT_STAR);
24
+ const square = divergentFragmentShaderFor(POINT_EDGE_STYLE.SQUARE);
25
+ expect(soft).toContain('length(gl_PointCoord');
26
+ expect(soft).not.toContain('max(coord.x, coord.y)');
27
+ expect(square).toContain('max(coord.x, coord.y)');
28
+ expect(square).toBe(divergentFragmentShader);
29
+ });
30
+
31
+ it('soft-star vertex uses smaller clamp ceiling (~¼ prior soft-star)', () => {
32
+ const softV = divergentVertexShaderFor(POINT_EDGE_STYLE.SOFT_STAR);
33
+ const squareV = divergentVertexShaderFor(POINT_EDGE_STYLE.SQUARE);
34
+ expect(softV).toContain('35.0');
35
+ expect(squareV).toContain('80.0');
36
+ expect(squareV).toBe(divergentVertexShader);
37
+ });
38
+
39
+ it('soft-star fragment has solid core + soft halo', () => {
40
+ const soft = divergentFragmentShaderFor(POINT_EDGE_STYLE.SOFT_STAR);
41
+ expect(soft).toContain('float core');
42
+ expect(soft).toContain('float halo');
43
+ });
44
+
45
+ it('createDivergentMaterial picks soft shaders when galaxy: true', () => {
46
+ const mat = createDivergentMaterial(24, 1, { galaxy: true });
47
+ expect(mat.userData.pointEdgeStyle).toBe(POINT_EDGE_STYLE.SOFT_STAR);
48
+ expect(mat.fragmentShader).toContain('length(gl_PointCoord');
49
+ const square = createDivergentMaterial(14, 1, {});
50
+ expect(square.userData.pointEdgeStyle).toBe(POINT_EDGE_STYLE.SQUARE);
51
+ expect(square.fragmentShader).toContain('max(coord.x, coord.y)');
52
+ });
53
+
54
+ it('resolveGalaxyPointSize is ~¼ prior soft-star floor', () => {
55
+ expect(resolveGalaxyPointSize(0.05)).toBeCloseTo(6.5);
56
+ expect(resolveGalaxyPointSize(0.01)).toBeCloseTo(6.5);
57
+ expect(resolveGalaxyPointSize(0.3)).toBeGreaterThan(resolveGalaxyPointSize(0.05));
58
+ });
59
+ });
tests/visualizationControls.test.js CHANGED
@@ -70,6 +70,8 @@ describe('visualizationControlsDefaults', () => {
70
  oppositeHighlightColor: '#00E5FF',
71
  oppositeHighlightStrength: 70,
72
  oppositeCancelCoverage: 0,
 
 
73
  });
74
  });
75
 
@@ -168,6 +170,9 @@ describe('Visualization panel collapse tab', () => {
168
  expect(html).toContain('Zero coverage');
169
  expect(html).toContain('viz-group-contrast');
170
  expect(html).toContain('Group contrast');
 
 
 
171
  expect(html).toContain('viz-same-sign-enabled');
172
  expect(html).toContain('viz-opposite-enabled');
173
  expect(html).toContain('viz-labels-toggle');
 
70
  oppositeHighlightColor: '#00E5FF',
71
  oppositeHighlightStrength: 70,
72
  oppositeCancelCoverage: 0,
73
+ groupHueEnabled: false,
74
+ groupHueColors: {},
75
  });
76
  });
77
 
 
170
  expect(html).toContain('Zero coverage');
171
  expect(html).toContain('viz-group-contrast');
172
  expect(html).toContain('Group contrast');
173
+ expect(html).toContain('Group hue');
174
+ expect(html).toContain('viz-group-hue-enabled');
175
+ expect(html).toContain('viz-group-hue-rows');
176
  expect(html).toContain('viz-same-sign-enabled');
177
  expect(html).toContain('viz-opposite-enabled');
178
  expect(html).toContain('viz-labels-toggle');