Spaces:
Running
Running
merge(feat): 2.4.2 Galaxy feel + Group hue
Browse files- .agents/skills/dev-protocol/lessons-learned.md +17 -0
- CHANGELOG.md +10 -0
- CONTEXT.md +7 -1
- backend/server.py +1 -1
- manifest.json +1 -1
- package.json +1 -1
- roadmap/galaxy-feel-group-hue.md +1 -1
- src/engine/galaxyFlightProfile.js +4 -4
- src/main.js +9 -1
- src/style.css +1 -0
- src/theme/chrome.css +12 -4
- src/ui/Navbar.js +1 -1
- src/ui/VisualizationControls.js +113 -2
- src/ui/fieldInfo.js +2 -0
- src/ui/visualizationControlsDefaults.js +99 -1
- src/visualizer/DivergentShading.js +162 -8
- src/visualizer/Instancer.js +8 -4
- src/visualizer/MeshFactory.js +41 -11
- src/visualizer/galaxyLayout.js +16 -1
- src/visualizer/groupHuePaint.js +95 -0
- tests/fieldInfo.test.js +3 -1
- tests/galaxyFlightProfile.test.js +8 -1
- tests/galaxyInstancer.test.js +25 -0
- tests/groupHuePaint.test.js +90 -0
- tests/softStarPoints.test.js +59 -0
- tests/visualizationControls.test.js +5 -0
.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.
|
| 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.
|
| 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.
|
| 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:**
|
| 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:
|
| 17 |
-
*
|
| 18 |
*/
|
| 19 |
export const GALAXY_FLIGHT_PROFILE = Object.freeze({
|
| 20 |
-
moveSpeed:
|
| 21 |
-
turboMultiplier:
|
| 22 |
lookSensitivity: 0.0014,
|
| 23 |
});
|
| 24 |
|
|
|
|
| 13 |
});
|
| 14 |
|
| 15 |
/**
|
| 16 |
+
* Galaxy-only: cruise = former Shift speed; Shift = 2× 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 135 |
body.workbench-theme textarea,
|
| 136 |
body.workbench-theme select {
|
| 137 |
-
background:
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
}
|
| 143 |
|
| 144 |
-
/* Compare token editor — black field, muted
|
| 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.
|
| 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 = {
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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 =
|
| 204 |
-
?
|
| 205 |
-
:
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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:
|
| 268 |
-
fragmentShader:
|
| 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 |
-
|
|
|
|
| 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:
|
| 446 |
}));
|
| 447 |
|
| 448 |
const pointsMesh = MeshFactory.createPointsMesh(sized, {
|
| 449 |
-
pointSize
|
|
|
|
| 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,
|
| 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 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
}
|
| 43 |
|
| 44 |
/**
|
|
@@ -54,7 +64,8 @@ export class MeshFactory {
|
|
| 54 |
const geometry = new THREE.BufferGeometry();
|
| 55 |
const positions = [];
|
| 56 |
const rawActivations = [];
|
| 57 |
-
const
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 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).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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');
|