From a1b49e3b6fe6ea24c57b8b9d35fabbed4bd07f88 Mon Sep 17 00:00:00 2001 From: Aaron Bockelie Date: Thu, 14 May 2026 09:54:21 -0500 Subject: [PATCH 01/18] feat(web): per-node geometry class in the unified engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the engine's `` and `` to support per-node geometry classes. When the plugin passes `nodeClasses` (a class key per node) and `geometryByClass` (a JSX geometry per class), the engine partitions nodes by class and renders one InstancedMesh per class. Pointer events are mapped class-local → global node id via per-class index maps. Also adds `nodeScales` (optional per-node base scale, replaces the degree-based default when present). Document Explorer documents need a large constant scale; concepts use the engine default — this prop lets the plugin opt out of the degree formula without forking the math. All three props are optional and backward-compatible. When omitted (Force Graph today), behaviour is identical to the previous single InstancedMesh + icosahedron + degree-based scales — same draw count, same pointer event path. Verified by typecheck and the unit suite. This is the engine surface the Document Explorer port (next commit) needs to render documents-as-glyphs alongside concepts-as-dots. --- web/src/explorers/ForceGraph/scene/Nodes.tsx | 184 ++++++++++++++----- web/src/explorers/ForceGraph/scene/Scene.tsx | 18 +- 2 files changed, 153 insertions(+), 49 deletions(-) diff --git a/web/src/explorers/ForceGraph/scene/Nodes.tsx b/web/src/explorers/ForceGraph/scene/Nodes.tsx index 3c3700126..7e524ba76 100644 --- a/web/src/explorers/ForceGraph/scene/Nodes.tsx +++ b/web/src/explorers/ForceGraph/scene/Nodes.tsx @@ -1,26 +1,29 @@ /** * Instanced node rendering. * - * One draw call for N nodes via InstancedMesh. Per-instance matrix - * (position + uniform scale from degree) and per-instance color from - * the `colors` prop (string[] of length nodes.length, indexed by node - * position). Color source is opaque to the engine — callers compute - * colors from category, degree, centrality, etc. before passing them - * in. Reads positions each frame from a shared Float32Array - * ref so physics and rendering stay on the same buffer. + * One InstancedMesh per node class. When `nodeClasses`/`geometryByClass` + * are omitted, the engine renders a single mesh with the default + * icosahedron geometry — that's the Force Graph path. When provided, the + * engine partitions nodes by class key and renders one mesh per class + * with the explorer-provided geometry — that's how Document Explorer + * gets document nodes as squared glyphs alongside concept dots. * - * Pointer events on the mesh surface instanceId, which callers resolve - * to node ids. Picking is uniform across sprite and poly modes per - * ADR-702 — sprite mode (billboarded quad) will use the same - * InstancedMesh primitive in M3. + * Per-instance state (matrix from position + scale, color) lives on each + * mesh. Positions come from the shared sim buffer each frame; colors + * arrive parallel to `nodes` from the plugin. + * + * Pointer events on a mesh surface instanceId; each mesh's handler + * resolves to the global node id via a class-local index map. */ -import { useEffect, useMemo, useRef } from 'react'; +import { useEffect, useMemo, useRef, type ReactElement } from 'react'; import { useFrame, useThree, type ThreeEvent } from '@react-three/fiber'; import type { DragHandlers } from './useDragHandler'; import * as THREE from 'three'; import type { EngineNode } from '../types'; +const DEFAULT_CLASS = '__default__'; + const tmpMat = new THREE.Matrix4(); const tmpQuat = new THREE.Quaternion(); const tmpScale = new THREE.Vector3(); @@ -32,6 +35,19 @@ export interface NodesProps { positionsRef: React.MutableRefObject; /** Per-node colors, parallel to `nodes` by index. */ colors: string[]; + /** Per-node class key, parallel to `nodes` by index. When provided, + * the engine renders one InstancedMesh per distinct class with the + * geometry from `geometryByClass`. When undefined, a single mesh + * uses the default icosahedron. */ + nodeClasses?: string[]; + /** Geometry element per class key. Required when `nodeClasses` is set + * (engine clones the JSX into the per-class mesh). Each geometry + * should be unit-sized — per-instance scale handles real-world size. */ + geometryByClass?: Record; + /** Optional per-node base scale (parallel to `nodes`). When provided, + * replaces the engine's default `0.8 + sqrt(degree) * 0.3` formula. + * `nodeSize` still multiplies the final value. */ + nodeScales?: Float32Array; hiddenIds?: Set; highlightedIds?: Set; nodeSize?: number; @@ -44,8 +60,81 @@ export interface NodesProps { onDragEnd?: DragHandlers['onDragEnd']; } -/** Instanced icosahedron node mesh — one draw call for all nodes. @verified c17bbeb9 */ -export function Nodes({ +/** Instanced node meshes — one draw call per geometry class. @verified c17bbeb9 */ +export function Nodes(props: NodesProps) { + const { + nodes, + nodeClasses, + geometryByClass, + nodeScales, + nodeSize = 1, + } = props; + + // Partition nodes by class. Single-class fallback keeps the default + // path identical to the pre-multi-class behaviour. + const partitions = useMemo(() => { + const classKeys = nodeClasses ?? null; + const buckets = new Map(); + for (let i = 0; i < nodes.length; i++) { + const key = classKeys ? classKeys[i] ?? DEFAULT_CLASS : DEFAULT_CLASS; + let bucket = buckets.get(key); + if (!bucket) { + bucket = []; + buckets.set(key, bucket); + } + bucket.push(i); + } + return Array.from(buckets.entries()).map(([key, indices]) => ({ + key, + indices: Uint32Array.from(indices), + })); + }, [nodes, nodeClasses]); + + // Pre-compute per-node base scales once per (nodes, nodeScales) change. + // The engine's degree-based default applies when the explorer doesn't + // override; the per-frame loop in each mesh multiplies by `nodeSize` and + // the optional highlight boost. + const baseScales = useMemo(() => { + const out = new Float32Array(nodes.length); + if (nodeScales) { + out.set(nodeScales); + } else { + for (let i = 0; i < nodes.length; i++) { + out[i] = 0.8 + Math.sqrt(nodes[i].degree || 1) * 0.3; + } + } + return out; + }, [nodes, nodeScales]); + + return ( + <> + {partitions.map((partition) => ( + + ))} + + ); +} + +interface NodeClassMeshProps extends NodesProps { + classKey: string; + /** Global node indices that belong to this class. */ + indices: Uint32Array; + baseScales: Float32Array; + geometry?: ReactElement; +} + +function NodeClassMesh({ + classKey, + indices, + baseScales, + geometry, nodes, positionsRef, colors, @@ -59,17 +148,10 @@ export function Nodes({ onDragStart, onDragMove, onDragEnd, -}: NodesProps) { +}: NodeClassMeshProps) { const meshRef = useRef(null); const invalidate = useThree((state) => state.invalidate); - - const scales = useMemo(() => { - const out = new Float32Array(nodes.length); - for (let i = 0; i < nodes.length; i++) { - out[i] = (0.8 + Math.sqrt(nodes[i].degree || 1) * 0.3) * nodeSize; - } - return out; - }, [nodes, nodeSize]); + const count = indices.length; // Bounding-sphere refresh cadence — three.js InstancedMesh.raycast() // early-outs against the bounding sphere before testing instances. @@ -88,7 +170,8 @@ export function Nodes({ const hasHidden = !!hiddenIds && hiddenIds.size > 0; const hasHighlight = !!highlightedIds && highlightedIds.size > 0; - for (let i = 0; i < nodes.length; i++) { + for (let local = 0; local < count; local++) { + const i = indices[local]; tmpPos.set(positions[i * 3], positions[i * 3 + 1], positions[i * 3 + 2]); if (hasHidden && hiddenIds!.has(nodes[i].id)) { // Zero scale collapses the mesh to a point — invisible and not @@ -96,10 +179,10 @@ export function Nodes({ tmpScale.setScalar(0); } else { const boost = hasHighlight && highlightedIds!.has(nodes[i].id) ? 1.8 : 1.0; - tmpScale.setScalar(scales[i] * boost); + tmpScale.setScalar(baseScales[i] * nodeSize * boost); } tmpMat.compose(tmpPos, tmpQuat, tmpScale); - mesh.setMatrixAt(i, tmpMat); + mesh.setMatrixAt(local, tmpMat); } mesh.instanceMatrix.needsUpdate = true; @@ -113,9 +196,10 @@ export function Nodes({ useEffect(() => { const mesh = meshRef.current; if (!mesh) return; - for (let i = 0; i < nodes.length; i++) { + for (let local = 0; local < count; local++) { + const i = indices[local]; tmpColor.set(colors[i] ?? '#888888'); - mesh.setColorAt(i, tmpColor); + mesh.setColorAt(local, tmpColor); } if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true; // Stale bounding sphere kills raycasts (and therefore pointer events @@ -125,17 +209,22 @@ export function Nodes({ // the user's next click. mesh.boundingSphere = null; invalidate(); - }, [nodes, colors, invalidate]); + }, [nodes, colors, indices, count, invalidate]); // Drag bookkeeping — keep pointer-down position so a tiny jitter between // down and up still resolves as a click rather than a drag. const downRef = useRef<{ id: string; x: number; y: number; moved: boolean } | null>(null); const DRAG_THRESHOLD = 4; + const localToNodeId = (local: number | null | undefined): string | null => { + if (local == null) return null; + const i = indices[local]; + return nodes[i]?.id ?? null; + }; + const handleOver = (e: ThreeEvent) => { e.stopPropagation(); - if (e.instanceId == null) return; - const id = nodes[e.instanceId]?.id; + const id = localToNodeId(e.instanceId); if (!id) return; if (hiddenIds && hiddenIds.has(id)) return; onHover?.(id); @@ -145,8 +234,7 @@ export function Nodes({ onHover?.(null); }; const handlePointerDown = (e: ThreeEvent) => { - if (e.instanceId == null) return; - const id = nodes[e.instanceId]?.id; + const id = localToNodeId(e.instanceId); if (!id) return; if (hiddenIds && hiddenIds.has(id)) return; // Only left-button starts a drag; right-click falls through to onContextMenu. @@ -185,28 +273,28 @@ export function Nodes({ const handleContextMenu = (e: ThreeEvent) => { e.stopPropagation(); e.nativeEvent.preventDefault(); - if (e.instanceId == null) return; - const id = nodes[e.instanceId]?.id; + const id = localToNodeId(e.instanceId); if (!id) return; if (hiddenIds && hiddenIds.has(id)) return; onContextMenu?.(id, e.nativeEvent as unknown as PointerEvent); }; return ( - // `key={nodes.length}` forces a full remount on node-count change. - // three.js's InstancedMesh allocates `instanceMatrix` once at construction - // sized for the initial `count`; reusing the mesh when `args[2]` grows - // leaves the backing buffer too small. `computeBoundingSphere` then reads - // past the array end, produces NaN bounds, and three.js's raycaster - // early-outs against the bad sphere — every pointer event silently - // misses, so left- and right-clicks on nodes stop responding after the - // first action that grows the graph (Add Adjacent / Follow / Load). - // The 15-frame `boundingSphere = null` refresh in useFrame can't rescue - // this because the underlying buffer is the wrong size. + // `key={`${classKey}-${count}`}` forces a full remount on per-class + // count change. three.js's InstancedMesh allocates `instanceMatrix` + // once at construction sized for the initial `count`; reusing the + // mesh when `args[2]` grows leaves the backing buffer too small. + // `computeBoundingSphere` then reads past the array end, produces + // NaN bounds, and three.js's raycaster early-outs against the bad + // sphere — every pointer event silently misses, so left- and + // right-clicks on nodes stop responding after the first action that + // grows the graph (Add Adjacent / Follow / Load). The 15-frame + // `boundingSphere = null` refresh in useFrame can't rescue this + // because the underlying buffer is the wrong size. - + {geometry ?? } {/* vertexColors=false is intentional: per-instance colors come from setColorAt/instanceColor, which three injects via the USE_INSTANCING_COLOR shader chunk independent of the vertexColors diff --git a/web/src/explorers/ForceGraph/scene/Scene.tsx b/web/src/explorers/ForceGraph/scene/Scene.tsx index ff2e498b5..dbcde21fd 100644 --- a/web/src/explorers/ForceGraph/scene/Scene.tsx +++ b/web/src/explorers/ForceGraph/scene/Scene.tsx @@ -7,7 +7,7 @@ * (pan + zoom, no rotation). */ -import { useEffect, useImperativeHandle } from 'react'; +import { useEffect, useImperativeHandle, type ReactElement } from 'react'; import * as THREE from 'three'; import { OrbitControls } from '@react-three/drei'; import type { EngineNode, EngineEdge, Projection } from '../types'; @@ -34,6 +34,16 @@ export interface SceneProps { /** Per-node colors, parallel to `nodes` by index. Caller computes from * whatever dimension (ontology/degree/centrality/...) they choose. */ colors: string[]; + /** Optional per-node class key (parallel to `nodes`). When provided + * with `geometryByClass`, the engine renders one InstancedMesh per + * class — Document Explorer uses this to render document nodes as + * larger squared glyphs alongside concept dots. */ + nodeClasses?: string[]; + /** Geometry element per class key, used when `nodeClasses` is set. */ + geometryByClass?: Record; + /** Optional per-node base scale override (parallel to `nodes`). When + * provided, replaces the engine's degree-based default scale. */ + nodeScales?: Float32Array; /** Optional edge-type palette; when provided, edges and arrows color by type. */ edgeColors?: string[]; hiddenIds?: Set; @@ -80,6 +90,9 @@ export function Scene({ nodes, edges, colors, + nodeClasses, + geometryByClass, + nodeScales, edgeColors, hiddenIds, pinnedIds, @@ -166,6 +179,9 @@ export function Scene({ nodes={nodes} positionsRef={sim.positionsRef} colors={colors} + nodeClasses={nodeClasses} + geometryByClass={geometryByClass} + nodeScales={nodeScales} hiddenIds={hiddenIds} highlightedIds={highlightedIds} nodeSize={nodeSize} From ede92ecbdbdddb42545fac760c3ffa0ea0eed070 Mon Sep 17 00:00:00 2001 From: Aaron Bockelie Date: Thu, 14 May 2026 09:57:55 -0500 Subject: [PATCH 02/18] feat(web): port Document Explorer to the unified engine (ADR-702 phase 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the 604-line d3+SVG implementation with a ~340-line thin wrapper around the engine's ``. Document Explorer keeps its visual experience — amber document glyphs, query-concept dots, extended-concept dots, focus dimming, info legend, NodeInfoBox — but inherits physics, drag, hover/select, projection (2D/3D), and labels from the engine. Engine surface used: - `nodeClasses` + `geometryByClass` distinguish documents (boxGeometry) from concepts (icosahedronGeometry) — one InstancedMesh per class. - `nodeScales` gives documents a large constant scale relative to concepts; the engine no longer needs to know which is which. - `activeIds` + `dimAlpha` drive focus-mode dimming. - `simHandleRef` exposes reheat to the overlay button. - `projection` setting toggles 2D/3D via the engine's camera dispatch. What carries over unchanged: `DocumentExplorerWorkspace` keeps its direct-mount pattern (workspace builds data, passes `focusedDocumentId`, `onFocusChange`, `onViewDocument` as extra props). Settings shape adds `projection` but keeps all existing fields. Click model: - Single-click on document → toggle focus - Double-click on document → onViewDocument (manual dblclick detection in the plugin since the engine fires one onSelect per click) - Single-click on concept → toggle NodeInfoBox - Background click → clear selection (engine default) Deferred (carried as props through the component but not yet rendered; follow-up issues will land them): - Passage rings (concentric arcs around concept hits) - NodeInfoBox positioned over the node (currently corner-pinned) - Per-edge visibility for document→concept clustering hints - Type-specific force tuning (engine uses unified defaults) Force Graph behaviour is unaffected — it doesn't pass `nodeClasses`, `geometryByClass`, or `nodeScales`, so the engine renders the single icosahedron mesh exactly as before. --- .../DocumentExplorer/DocumentExplorer.tsx | 780 +++++++----------- .../DocumentExplorer/ProfilePanel.tsx | 20 + web/src/explorers/DocumentExplorer/types.ts | 10 +- 3 files changed, 320 insertions(+), 490 deletions(-) diff --git a/web/src/explorers/DocumentExplorer/DocumentExplorer.tsx b/web/src/explorers/DocumentExplorer/DocumentExplorer.tsx index 2497a5ffd..dae617bd7 100644 --- a/web/src/explorers/DocumentExplorer/DocumentExplorer.tsx +++ b/web/src/explorers/DocumentExplorer/DocumentExplorer.tsx @@ -1,550 +1,360 @@ /** * Document Explorer — Multi-Document Concept Graph * - * Force-directed graph showing concepts from multiple documents. - * Documents are "celebrity" hub nodes; concepts cluster around them. + * Renders on the unified r3f engine (ADR-702) — same `` as the + * Force Graph explorer, but with two visual classes (documents as larger + * boxed glyphs, concepts as smaller dots) and Document Explorer's own + * palette + focus model. The engine handles physics, projection (2D/3D), + * drag, hover/select, and labels; this plugin owns colors, scales, + * geometry-per-class, focus-state, and the legend/info overlays. * - * Physics model: - * - Initial load → simulation runs → settles → stops. Done. - * - Drag moves a node manually (no physics). Connected links follow. - * - "Reheat" button → one-shot simulation restart → settles → stops. - * - Pan, zoom, click, focus, settings changes — never restart physics. - * - * Callback refs prevent the simulation from restarting when parent re-renders. + * Workspace-only props (`focusedDocumentId`, `onFocusChange`, + * `onViewDocument`, plus the deferred passage rings) preserve the + * existing mount contract — DocumentExplorerWorkspace bypasses the + * registry and constructs this component directly, so the plugin entry + * point in `index.ts` is informational only. */ -import React, { useEffect, useRef, useState, useMemo, useCallback } from 'react'; -import * as d3 from 'd3'; +import React, { useCallback, useMemo, useRef, useState } from 'react'; +import { Canvas } from '@react-three/fiber'; import { RotateCcw } from 'lucide-react'; import type { ExplorerProps } from '../../types/explorer'; import type { DocumentExplorerSettings, DocumentExplorerData, DocNodeType, + PassageQuery, } from './types'; import { useThemeStore } from '../../store/themeStore'; -import { - NodeInfoBox, - StatsPanel, - PanelStack, - LABEL_FONTS, -} from '../common'; +import { NodeInfoBox, StatsPanel, PanelStack } from '../common'; +import { Scene } from '../ForceGraph/scene/Scene'; +import type { EngineNode, EngineEdge } from '../ForceGraph/types'; +import type { ForceSimHandle } from '../ForceGraph/scene/useForceSim'; // --------------------------------------------------------------------------- -// Colors +// Visual constants // --------------------------------------------------------------------------- -const COLORS: Record = { - 'document': { fill: '#f59e0b', stroke: '#fbbf24' }, - 'query-concept': { fill: '#d97706', stroke: '#fcd34d' }, - 'extended-concept': { fill: '#6366f1', stroke: '#818cf8' }, +/** Fill colors per node type — same hues as the d3 implementation that + * preceded this port so saved screenshots and muscle memory carry over. */ +const COLORS: Record = { + 'document': '#f59e0b', + 'query-concept': '#d97706', + 'extended-concept': '#6366f1', }; -const DIMMED_OPACITY = 0.08; - -// --------------------------------------------------------------------------- -// Force simulation types -// --------------------------------------------------------------------------- - -interface SimNode extends d3.SimulationNodeDatum { - id: string; - label: string; - type: DocNodeType; - documentIds: string[]; - size: number; -} +const NODE_CLASS_BY_TYPE: Record = { + 'document': 'document', + 'query-concept': 'concept', + 'extended-concept': 'concept', +}; -interface SimLink extends d3.SimulationLinkDatum { - type: string; - visible: boolean; -} +const DOUBLE_CLICK_MS = 300; // --------------------------------------------------------------------------- -// Extended props (workspace passes focus state) +// Workspace-only props (passed by DocumentExplorerWorkspace, not by the +// generic ExplorerView mount path) // --------------------------------------------------------------------------- interface DocumentExplorerExtraProps { focusedDocumentId?: string | null; onFocusChange?: (docId: string | null) => void; onViewDocument?: (docId: string) => void; - /** Passage search rings — Map> */ + /** Passage search rings. Carried through for API stability; rendering + * is deferred to a follow-up after the engine port lands. */ passageRings?: Map>; - /** Color → query text lookup for labeling rings in info dialogs. */ + /** Color → query text lookup. Carried through alongside `passageRings`. */ queryColorLabels?: Map; + /** Active passage queries — carried through with `passageRings`. */ + passageQueries?: PassageQuery[]; } +/** Document Explorer entry — engine-backed multi-document concept graph. @verified */ export const DocumentExplorer: React.FC< ExplorerProps & DocumentExplorerExtraProps -> = ({ data, settings, onNodeClick, className, focusedDocumentId, onFocusChange, onViewDocument, passageRings, queryColorLabels }) => { - const svgRef = useRef(null); - const [dimensions, setDimensions] = useState({ width: 800, height: 600 }); - const [hoveredNode, setHoveredNode] = useState(null); +> = ({ + data, + settings, + className, + focusedDocumentId, + onFocusChange, + onViewDocument, +}) => { + const { appliedTheme: theme } = useThemeStore(); const [selectedConceptId, setSelectedConceptId] = useState(null); - const [zoomTransform, setZoomTransform] = useState({ x: 0, y: 0, k: 1 }); - const simulationRef = useRef | null>(null); - - // Physics status indicator + const [hoveredId, setHoveredId] = useState(null); const [physicsActive, setPhysicsActive] = useState(true); - // Refs for SVG selections (used by drag handler and settings effects) - const linkElementsRef = useRef | null>(null); - const nodeElementsRef = useRef | null>(null); - - // ----------------------------------------------------------------------- - // Callback refs — keep current without causing simulation restarts. - // The simulation effect reads these via .current, so it doesn't depend - // on the callback identity and won't restart when parent re-renders. - // ----------------------------------------------------------------------- - const onNodeClickRef = useRef(onNodeClick); - const onFocusChangeRef = useRef(onFocusChange); - const onViewDocumentRef = useRef(onViewDocument); - const settingsRef = useRef(settings); - - useEffect(() => { onNodeClickRef.current = onNodeClick; }, [onNodeClick]); - useEffect(() => { onFocusChangeRef.current = onFocusChange; }, [onFocusChange]); - useEffect(() => { onViewDocumentRef.current = onViewDocument; }, [onViewDocument]); - useEffect(() => { settingsRef.current = settings; }, [settings]); + // Sim handle bridges the inside-Canvas hook to the Reheat button outside + // the Canvas tree. + const simHandleRef = useRef(null); - const { appliedTheme: theme } = useThemeStore(); + // Last-click bookkeeping for manual dblclick detection. The engine's + // pointer handler fires once per pointer-up; double-click semantics are + // local concern (we use it to open the document viewer on documents). + const lastClickRef = useRef<{ id: string; at: number } | null>(null); - // Focused document's concept set (for dimming) - const focusedConceptSet = useMemo(() => { - if (!focusedDocumentId || !data) return null; - const doc = data.documents.find(d => d.id === focusedDocumentId); - if (!doc) return null; - return new Set(doc.conceptIds); - }, [focusedDocumentId, data]); + // --------------------------------------------------------------------------- + // Engine data — transform DocumentExplorer shape → EngineNode[]/EngineEdge[] + // --------------------------------------------------------------------------- + + const engineData = useMemo(() => { + if (!data) return { nodes: [] as EngineNode[], edges: [] as EngineEdge[] }; - // Build simulation data — depends only on data, NOT settings. - // Settings-driven sizes are computed at render time via settingsRef so - // changing a slider never restarts the simulation. - const { simNodes, simLinks } = useMemo(() => { - if (!data) return { simNodes: [] as SimNode[], simLinks: [] as SimLink[] }; + // Pre-compute degree from visible edges so concept nodes scale subtly + // by connectivity. Document nodes use a constant scale set below. + const degree = new Map(); + for (const link of data.links) { + if (!link.visible) continue; + degree.set(link.source, (degree.get(link.source) ?? 0) + 1); + degree.set(link.target, (degree.get(link.target) ?? 0) + 1); + } - const simNodes: SimNode[] = data.nodes.map(n => ({ + const nodes: EngineNode[] = data.nodes.map((n) => ({ id: n.id, label: n.label, - type: n.type, - documentIds: n.documentIds, - size: n.size, // base size — render-time scaling applied separately + category: n.type, + degree: degree.get(n.id) ?? 0, })); - const nodeIds = new Set(simNodes.map(n => n.id)); - - const simLinks: SimLink[] = data.links - .filter(l => nodeIds.has(l.source) && nodeIds.has(l.target)) - .map(l => ({ - source: l.source, - target: l.target, + const nodeIds = new Set(nodes.map((n) => n.id)); + // Only visible edges land in the engine. Document→concept clustering + // hints from the d3 implementation are dropped on first cut — the + // engine's center gravity + concept-to-concept links produce a + // workable layout. If clustering proves loose in practice, the engine + // can grow per-edge visibility (render-skip while sim still uses them). + const edges: EngineEdge[] = data.links + .filter((l) => l.visible && nodeIds.has(l.source) && nodeIds.has(l.target)) + .map((l) => ({ + from: l.source, + to: l.target, type: l.type, - visible: l.visible, })); - return { simNodes, simLinks }; + return { nodes, edges }; }, [data]); - // Visible stats (exclude clustering links) - const visibleLinkCount = useMemo( - () => simLinks.filter(l => l.visible).length, - [simLinks] - ); - - // Track container dimensions - useEffect(() => { - if (!svgRef.current) return; - const container = svgRef.current.parentElement; - if (!container) return; - - const updateDimensions = () => { - const rect = container.getBoundingClientRect(); - setDimensions({ width: rect.width, height: rect.height }); - }; - - updateDimensions(); - const observer = new ResizeObserver(updateDimensions); - observer.observe(container); - return () => observer.disconnect(); - }, []); - - // Is a node visible in focus mode? - const isNodeInFocus = useCallback((node: SimNode): boolean => { - if (!focusedConceptSet) return true; - if (node.id === focusedDocumentId) return true; - if (node.type === 'document') return false; - return focusedConceptSet.has(node.id); - }, [focusedConceptSet, focusedDocumentId]); - - // Is a link visible in focus mode? - const isLinkInFocus = useCallback((link: SimLink): boolean => { - if (!focusedConceptSet) return true; - const sourceId = (link.source as SimNode).id ?? link.source; - const targetId = (link.target as SimNode).id ?? link.target; - const sInFocus = sourceId === focusedDocumentId || focusedConceptSet.has(sourceId as string); - const tInFocus = targetId === focusedDocumentId || focusedConceptSet.has(targetId as string); - return sInFocus && tInFocus; - }, [focusedConceptSet, focusedDocumentId]); - - // Shared tick renderer — updates SVG positions from node data - const renderPositions = useCallback(() => { - linkElementsRef.current - ?.attr('x1', d => (d.source as SimNode).x || 0) - .attr('y1', d => (d.source as SimNode).y || 0) - .attr('x2', d => (d.target as SimNode).x || 0) - .attr('y2', d => (d.target as SimNode).y || 0); - - nodeElementsRef.current - ?.attr('transform', d => `translate(${d.x || 0},${d.y || 0})`); - }, []); - - // ----------------------------------------------------------------------- - // Main D3 rendering + force simulation - // - // Dependencies: simNodes, simLinks, dimensions, theme. - // NOT: settings (ref), callbacks (refs). This prevents restarts from - // settings changes, focus changes, or parent re-renders. - // ----------------------------------------------------------------------- - useEffect(() => { - if (!svgRef.current || simNodes.length === 0) return; - - const svg = d3.select(svgRef.current); - const { width, height } = dimensions; - - svg.selectAll('*').remove(); - setPhysicsActive(true); - - const g = svg.append('g').attr('class', 'main-group'); + // Index nodes by id for fast lookups during click / NodeInfoBox prep. + const nodesById = useMemo(() => { + const map = new Map(); + for (const n of engineData.nodes) map.set(n.id, n); + return map; + }, [engineData]); + + // Map id → DocGraphNode (the source row carries `type` and `documentIds`). + const sourceById = useMemo(() => { + const map = new Map(); + for (const n of data?.nodes ?? []) map.set(n.id, n); + return map; + }, [data]); - // Zoom/pan — never touches physics - const zoom = d3.zoom() - .scaleExtent([0.05, 4]) - .on('zoom', (event) => { - g.attr('transform', event.transform.toString()); - setZoomTransform({ x: event.transform.x, y: event.transform.y, k: event.transform.k }); - }); + const nodeType = useCallback( + (id: string): DocNodeType | null => sourceById.get(id)?.type ?? null, + [sourceById], + ); - svg.call(zoom); + // --------------------------------------------------------------------------- + // Per-node visuals: colors, geometry classes, scales + // --------------------------------------------------------------------------- - // Click background to clear focus (no physics disturbance) - svg.on('click', () => { - onFocusChangeRef.current?.(null); - setSelectedConceptId(null); + const nodeClasses = useMemo(() => { + return engineData.nodes.map((n) => { + const type = nodeType(n.id) ?? 'extended-concept'; + return NODE_CLASS_BY_TYPE[type]; }); + }, [engineData, nodeType]); - // Concept edges (only visible ones rendered) - const linkGroup = g.append('g').attr('class', 'links'); - const linkElements = linkGroup.selectAll('line') - .data(simLinks.filter(l => l.visible)) - .join('line') - .attr('stroke', theme === 'dark' ? 'rgba(107, 114, 128, 0.35)' : 'rgba(85, 85, 85, 0.25)') - .attr('stroke-width', 0.8) - .attr('display', settingsRef.current.visual.showEdges ? null : 'none'); - - linkElementsRef.current = linkElements; - - // Nodes - const nodeGroup = g.append('g').attr('class', 'nodes'); - const nodeElements = nodeGroup.selectAll('g') - .data(simNodes) - .join('g') - .style('cursor', 'pointer') - .on('mouseenter', (_event: MouseEvent, d: SimNode) => { - if (settingsRef.current.interaction.highlightOnHover) setHoveredNode(d.id); - }) - .on('mouseleave', () => setHoveredNode(null)) - .on('click', (event: MouseEvent, d: SimNode) => { - event.stopPropagation(); - if (d.type === 'document') { - onFocusChangeRef.current?.(d.id); - onNodeClickRef.current?.(d.id); - } else { - setSelectedConceptId(d.id); - } - }) - .on('dblclick', (event: MouseEvent, d: SimNode) => { - event.stopPropagation(); - if (d.type === 'document') { - onViewDocumentRef.current?.(d.id); - } - }); + const geometryByClass = useMemo(() => ({ + document: , + concept: , + }), []); - nodeElementsRef.current = nodeElements; - - // Render-time size helper — reads settings from ref, never triggers simulation restart - const renderSize = (d: SimNode) => { - const s = settingsRef.current; - return d.type === 'document' ? s.layout.documentSize : d.size * s.visual.nodeSize; - }; + const nodeColors = useMemo(() => { + return engineData.nodes.map((n) => { + const type = nodeType(n.id) ?? 'extended-concept'; + return COLORS[type]; + }); + }, [engineData, nodeType]); + + // Per-node base scale. Documents use a large constant (documentSize + // setting is in pixel-space in the original; here it controls relative + // scale — documents land at ~6-12x a concept dot). Concept scales follow + // the engine's degree-based default; we replicate it explicitly so + // documents/concepts use one consistent formula. + const nodeScales = useMemo(() => { + const out = new Float32Array(engineData.nodes.length); + const docScale = (settings?.layout?.documentSize ?? 24) / 4; // pixel → world unit + for (let i = 0; i < engineData.nodes.length; i++) { + const n = engineData.nodes[i]; + const type = nodeType(n.id) ?? 'extended-concept'; + if (type === 'document') { + out[i] = docScale; + } else { + // Concept dots — mirror the engine default so concepts read at + // their natural size regardless of how documentSize is set. + out[i] = 0.8 + Math.sqrt(n.degree || 1) * 0.3; + } + } + return out; + }, [engineData, settings?.layout?.documentSize, nodeType]); + + // --------------------------------------------------------------------------- + // Focus mode → engine highlight + dim + // --------------------------------------------------------------------------- - // Node circles - nodeElements.append('circle') - .attr('r', (d: SimNode) => renderSize(d)) - .attr('fill', (d: SimNode) => COLORS[d.type].fill) - .attr('stroke', (d: SimNode) => COLORS[d.type].stroke) - .attr('stroke-width', (d: SimNode) => d.type === 'document' ? 3 : 1.5) - .attr('opacity', (d: SimNode) => d.type === 'document' ? 0.95 : 0.8); - - // Document icon (file shape) - nodeElements.filter((d: SimNode) => d.type === 'document') - .append('path') - .attr('d', (d: SimNode) => { - const s = renderSize(d) * 0.45; - return `M${-s / 2},${-s / 2} L${s / 3},${-s / 2} L${s / 2},${-s / 3} L${s / 2},${s / 2} L${-s / 2},${s / 2} Z`; - }) - .attr('fill', '#fff') - .attr('opacity', 0.9); - - // Labels - nodeElements.append('text') - .text((d: SimNode) => d.label) - .attr('dy', (d: SimNode) => renderSize(d) + 12) - .attr('text-anchor', 'middle') - .attr('font-family', LABEL_FONTS.family) - .attr('font-size', (d: SimNode) => { - if (d.type === 'document') return '11px'; - return d.type === 'query-concept' ? '9px' : '8px'; - }) - .attr('font-weight', (d: SimNode) => d.type === 'document' ? '600' : '400') - .attr('fill', (d: SimNode) => { - if (d.type === 'document') return theme === 'dark' ? '#fbbf24' : '#d97706'; - return theme === 'dark' ? '#d1d5db' : '#4b5563'; - }) - .attr('pointer-events', 'none') - .attr('display', settingsRef.current.visual.showLabels ? null : 'none') - .style('text-shadow', theme === 'dark' - ? '0 1px 0 #000, 0 -1px 0 #000, 1px 0 0 #000, -1px 0 0 #000' - : '0 1px 0 #fff, 0 -1px 0 #fff, 1px 0 0 #fff, -1px 0 0 #fff' - ); - - // ----------------------------------------------------------------------- - // Force simulation — runs once on load, then stops. - // ----------------------------------------------------------------------- - const simulation = d3.forceSimulation(simNodes) - .alpha(1) - .alphaDecay(0.015) - .alphaMin(0.008) - .alphaTarget(0) - .force('link', - d3.forceLink(simLinks) - .id(d => d.id) - .distance((l) => (l as SimLink).visible ? 100 : 25) - .strength((l) => (l as SimLink).visible ? 0.2 : 0.15) - ) - .force('charge', d3.forceManyBody().strength((d) => { - if (d.type === 'document') return -500; - if (d.type === 'query-concept') return -120; - return -80; - })) - .force('center', d3.forceCenter(width / 2, height / 2).strength(0.05)) - .force('collision', d3.forceCollide().radius((d) => { - const sz = renderSize(d); - if (d.type === 'document') return sz + 20; - return sz + 4; - })); + const focusedConceptSet = useMemo(() => { + if (!focusedDocumentId || !data) return null; + const doc = data.documents.find((d) => d.id === focusedDocumentId); + if (!doc) return null; + return new Set(doc.conceptIds); + }, [focusedDocumentId, data]); - simulationRef.current = simulation; - - simulation.on('tick', renderPositions); - simulation.on('end', () => setPhysicsActive(false)); - - // ----------------------------------------------------------------------- - // Drag — purely manual. No physics restart. Ever. - // ----------------------------------------------------------------------- - const drag = d3.drag() - .on('start', (_event, d) => { - d.fx = d.x; - d.fy = d.y; - }) - .on('drag', (event, d) => { - d.x = d.fx = event.x; - d.y = d.fy = event.y; - renderPositions(); - }) - .on('end', (_event, d) => { - d.x = d.fx!; - d.y = d.fy!; - // Only release fixed position if simulation is stopped. - // If sim is still running (e.g. after reheat), keep pinned to avoid drift. - const sim = simulationRef.current; - if (!sim || sim.alpha() < sim.alphaMin()) { - d.fx = null; - d.fy = null; - } - }); + // activeIds drives the engine's dim — when set, items not in the set + // render at `dimAlpha`. Focus on a document narrows to the document + // plus its concept set; nothing focused leaves the whole graph active. + const activeIds = useMemo(() => { + if (!focusedDocumentId || !focusedConceptSet) return undefined; + const set = new Set(focusedConceptSet); + set.add(focusedDocumentId); + return set; + }, [focusedDocumentId, focusedConceptSet]); + + // --------------------------------------------------------------------------- + // Interaction handlers — bridge engine onSelect to DocumentExplorer's + // focus / view-document model. Single-click on a document focuses it; + // double-click opens the viewer. Single-click on a concept shows the + // NodeInfoBox; clicking the same concept again dismisses it (engine's + // toggle behavior is preserved). + // --------------------------------------------------------------------------- + + const handleSelect = useCallback((id: string | null) => { + if (!id) { + setSelectedConceptId(null); + return; + } + const type = nodeType(id); + + // Manual dblclick detection — engine fires onSelect once per click. + const now = performance.now(); + const isDouble = lastClickRef.current?.id === id + && (now - lastClickRef.current.at) < DOUBLE_CLICK_MS; + lastClickRef.current = { id, at: now }; + + if (type === 'document') { + if (isDouble) { + onViewDocument?.(id); + return; + } + // Single-click on a document toggles focus. + if (focusedDocumentId === id) { + onFocusChange?.(null); + } else { + onFocusChange?.(id); + } + setSelectedConceptId(null); + return; + } - nodeElements.call(drag); + // Concept click — toggle the NodeInfoBox selection. + setSelectedConceptId((prev) => (prev === id ? null : id)); + }, [focusedDocumentId, nodeType, onFocusChange, onViewDocument]); - return () => { - simulation.stop(); - linkElementsRef.current = null; - nodeElementsRef.current = null; - }; - // Only restart simulation when data or dimensions actually change. - // Callbacks and settings are accessed via refs — never trigger restarts. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [simNodes, simLinks, dimensions, theme, renderPositions]); + const handleHover = useCallback((id: string | null) => { + if (settings?.interaction?.highlightOnHover === false) { + setHoveredId(null); + return; + } + setHoveredId(id); + }, [settings?.interaction?.highlightOnHover]); - // Reheat — one-shot energy injection from current positions const handleReheat = useCallback(() => { - const sim = simulationRef.current; - if (!sim) return; - sim.alpha(0.5).restart(); + simHandleRef.current?.reheat(); setPhysicsActive(true); + // Engine's reheat doesn't drive a settled callback yet; reflect the + // active state for a short window so the spinner UX matches the d3 + // version. The engine settles much faster than the old d3 sim so a + // brief indicator is more honest than waiting for a real-end event. + window.setTimeout(() => setPhysicsActive(false), 2500); }, []); - // ----------------------------------------------------------------------- - // Settings-driven visual updates (no simulation restart) - // ----------------------------------------------------------------------- - useEffect(() => { - linkElementsRef.current - ?.attr('display', settings.visual.showEdges ? null : 'none'); - }, [settings.visual.showEdges]); - - useEffect(() => { - if (!svgRef.current) return; - d3.select(svgRef.current).selectAll('g.nodes g text') - .attr('display', settings.visual.showLabels ? null : 'none'); - }, [settings.visual.showLabels]); - - // Update node sizes when settings change (no simulation restart) - useEffect(() => { - if (!svgRef.current) return; - const renderSize = (d: SimNode) => - d.type === 'document' ? settings.layout.documentSize : d.size * settings.visual.nodeSize; - - d3.select(svgRef.current).selectAll('g.nodes g circle:not(.query-ring)') - .attr('r', renderSize); - d3.select(svgRef.current).selectAll('g.nodes g text') - .attr('dy', (d: SimNode) => renderSize(d) + 12); - d3.select(svgRef.current).selectAll('g.nodes g path') - .attr('d', (d: SimNode) => { - const s = renderSize(d) * 0.45; - return `M${-s / 2},${-s / 2} L${s / 3},${-s / 2} L${s / 2},${-s / 3} L${s / 2},${s / 2} L${-s / 2},${s / 2} Z`; - }); - }, [settings.layout.documentSize, settings.visual.nodeSize]); - - // ----------------------------------------------------------------------- - // Passage search rings — concentric colored rings around matching nodes. - // Rings are children of node groups so they inherit transform position. - // ----------------------------------------------------------------------- - useEffect(() => { - if (!svgRef.current) return; - const svg = d3.select(svgRef.current); - - // Clear existing rings - svg.selectAll('.query-ring').remove(); - - if (!passageRings || passageRings.size === 0) return; - - const renderSize = (d: SimNode) => { - const s = settingsRef.current; - return d.type === 'document' ? s.layout.documentSize : d.size * s.visual.nodeSize; - }; + // --------------------------------------------------------------------------- + // Selected concept → NodeInfoBox data + // --------------------------------------------------------------------------- - // Scale ring spacing proportionally to the node size multiplier - const sizeScale = settingsRef.current.visual.nodeSize; - - svg.selectAll('g.nodes g').each(function(d) { - const rings = passageRings.get(d.id); - if (!rings) return; - - const g = d3.select(this); - const baseR = renderSize(d); - const ringWidth = 3 * sizeScale; - const gap = 2 * sizeScale; - - rings.forEach((ring, i) => { - // Thickness encodes hit frequency: min 1.5px (1 hit) → max 5px (max hits) - const MIN_WIDTH = 1.5 * sizeScale; - const MAX_WIDTH = 5 * sizeScale; - const t = ring.maxHitCount > 1 - ? (ring.hitCount - 1) / (ring.maxHitCount - 1) // 0..1 normalized - : 0; - const strokeWidth = MIN_WIDTH + t * (MAX_WIDTH - MIN_WIDTH); - - const r = baseR + gap + (i * (ringWidth + 1)); - g.insert('circle', ':first-child') - .attr('class', 'query-ring') - .attr('r', r) - .attr('fill', 'none') - .attr('stroke', ring.color) - .attr('stroke-width', strokeWidth) - .attr('stroke-opacity', 0.75); - }); - }); - }, [passageRings, settings]); - - // Focus mode: update opacity when focusedDocumentId changes - useEffect(() => { - if (!svgRef.current) return; - const svg = d3.select(svgRef.current); - - svg.selectAll('g.nodes g') - .attr('opacity', (d: SimNode) => isNodeInFocus(d) ? 1 : DIMMED_OPACITY); - - svg.selectAll('g.links line') - .attr('opacity', (d: SimLink) => isLinkInFocus(d) ? 0.6 : DIMMED_OPACITY); - }, [focusedDocumentId, focusedConceptSet, isNodeInFocus, isLinkInFocus]); - - // Hover highlighting - useEffect(() => { - if (!svgRef.current) return; - const svg = d3.select(svgRef.current); - svg.selectAll('g.nodes g circle:not(.query-ring)') - .attr('stroke-width', (d: SimNode) => { - if (d.id === hoveredNode || d.id === selectedConceptId) return 3; - return d.type === 'document' ? 3 : 1.5; - }) - .attr('stroke', (d: SimNode) => { - if (d.id === hoveredNode || d.id === selectedConceptId) return '#fff'; - return COLORS[d.type].stroke; - }); - }, [hoveredNode, selectedConceptId]); - - // Selected concept data for NodeInfoBox const selectedNodeData = useMemo(() => { if (!selectedConceptId) return null; - return simNodes.find(n => n.id === selectedConceptId); - }, [selectedConceptId, simNodes]); - - // Query hit bar for the selected concept's NodeInfoBox - const selectedNodeQueryBar = useMemo(() => { - if (!selectedConceptId || !passageRings || !queryColorLabels) return undefined; - const rings = passageRings.get(selectedConceptId); - if (!rings || rings.length === 0) return undefined; - - return ( -
- {rings.map((ring) => ( -
- {queryColorLabels.get(ring.color) || '?'} - {ring.hitCount} -
- ))} -
- ); - }, [selectedConceptId, passageRings, queryColorLabels]); + const src = sourceById.get(selectedConceptId); + const eng = nodesById.get(selectedConceptId); + if (!src || !eng) return null; + const degree = engineData.edges.filter( + (e) => e.from === selectedConceptId || e.to === selectedConceptId, + ).length; + return { + id: selectedConceptId, + label: src.label || 'Unknown', + type: src.type, + degree, + }; + }, [selectedConceptId, sourceById, nodesById, engineData]); + + // --------------------------------------------------------------------------- + // Render + // --------------------------------------------------------------------------- + + const projection = settings?.projection ?? '3D'; + const bgClass = theme === 'dark' ? 'bg-gray-900' : 'bg-gray-50'; return ( -
- +
e.preventDefault()} + > + {/* Canvas keys on projection so the camera dispatch in Scene gets a + fresh r3f tree (perspective vs orthographic can't be swapped + mid-mount without state confusion). */} + + + + {/* Stats — top right */} - + - {/* Reheat button */} + {/* Reheat — top left */}
- {/* Legend */} + {/* Legend — bottom left */}
- + Document
- + Query concept
- + Extended concept
@@ -581,24 +391,16 @@ export const DocumentExplorer: React.FC< {selectedNodeData && selectedNodeData.type !== 'document' && ( - l.visible && ( - ((l.source as SimNode).id === selectedConceptId) || - ((l.target as SimNode).id === selectedConceptId) - ) - ).length, - x: (selectedNodeData.x || 0) * zoomTransform.k + zoomTransform.x, - y: (selectedNodeData.y || 0) * zoomTransform.k + zoomTransform.y, + degree: selectedNodeData.degree, + x: 16, + y: 16, }} onDismiss={() => setSelectedConceptId(null)} - headerExtra={selectedNodeQueryBar} /> )}
); }; - -export default DocumentExplorer; diff --git a/web/src/explorers/DocumentExplorer/ProfilePanel.tsx b/web/src/explorers/DocumentExplorer/ProfilePanel.tsx index e57b72867..6b1e99d92 100644 --- a/web/src/explorers/DocumentExplorer/ProfilePanel.tsx +++ b/web/src/explorers/DocumentExplorer/ProfilePanel.tsx @@ -36,6 +36,26 @@ export const ProfilePanel: React.FC return (
+ {/* Projection */} +
+

+ Projection +

+ +
+ {/* Visual */}

diff --git a/web/src/explorers/DocumentExplorer/types.ts b/web/src/explorers/DocumentExplorer/types.ts index e4314edde..22e2a8e91 100644 --- a/web/src/explorers/DocumentExplorer/types.ts +++ b/web/src/explorers/DocumentExplorer/types.ts @@ -9,14 +9,21 @@ // Settings // --------------------------------------------------------------------------- +import type { Projection } from '../ForceGraph/types'; +export type { Projection }; + export interface DocumentExplorerSettings { + /** Camera + sim projection. Same toggle as the Force Graph plugin — + * the unified r3f engine (ADR-702) dispatches camera, drag plane, and + * sim axis count from this value. */ + projection: Projection; visual: { showLabels: boolean; showEdges: boolean; nodeSize: number; // Base size multiplier }; layout: { - documentSize: number; // Document node size (20-60) + documentSize: number; // Document node base scale (relative to concept dots) }; interaction: { enableZoom: boolean; @@ -26,6 +33,7 @@ export interface DocumentExplorerSettings { } export const DEFAULT_SETTINGS: DocumentExplorerSettings = { + projection: '3D', visual: { showLabels: true, showEdges: true, From e93ad5ea9c47d817d8891a4392dd662947266a57 Mon Sep 17 00:00:00 2001 From: Aaron Bockelie Date: Thu, 14 May 2026 10:02:21 -0500 Subject: [PATCH 03/18] fix(web): preserve document-concept clustering and bake focus dim into colors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues the advisor flagged from a review of the port: 1. **Concepts drift from their documents**: the d3 implementation relied on invisible document→concept links to pull concept dots toward their parent document (concepts of one doc rarely link directly to each other in the data). The first cut of the port dropped those links, which left clustering to the engine's center gravity alone — not enough to keep documents and their concepts visibly grouped. Engine fix: `` and `` accept `edgeVisible?: boolean[]` parallel to `edges`. When false, the edge stays in the physics sim (which consumes the same `edges` array) but renders collapsed to a point. Plugin fix: Document Explorer now passes ALL links to the engine, marking `l.visible === false` clustering hints with `edgeVisible[i] = false`. 2. **Focus dim didn't dim node meshes**: engine `` doesn't read `activeIds`/`dimAlpha` (only `` and labels do). With the first-cut port, focus mode dimmed edges and labels but left documents and concepts at full brightness — visible regression from the d3 version. Fix: Document Explorer now bakes the dim multiplier into the per-node color array, matching the pattern Force Graph uses for hover focus. Also: honest comment on `physicsActive` setTimeout — the engine doesn't expose a settle-end callback yet, so the spinner is a fixed-duration visual hint, not a real signal. `frameloop="demand"` is fine — both engine `` and the new dim path in `nodeColors` flow into `useEffect`s that call `invalidate()`, so prop-change-driven renders pump a frame. --- .../DocumentExplorer/DocumentExplorer.tsx | 99 +++++++++++-------- web/src/explorers/ForceGraph/scene/Edges.tsx | 23 ++++- web/src/explorers/ForceGraph/scene/Scene.tsx | 6 ++ 3 files changed, 85 insertions(+), 43 deletions(-) diff --git a/web/src/explorers/DocumentExplorer/DocumentExplorer.tsx b/web/src/explorers/DocumentExplorer/DocumentExplorer.tsx index dae617bd7..8dffd3861 100644 --- a/web/src/explorers/DocumentExplorer/DocumentExplorer.tsx +++ b/web/src/explorers/DocumentExplorer/DocumentExplorer.tsx @@ -17,6 +17,7 @@ import React, { useCallback, useMemo, useRef, useState } from 'react'; import { Canvas } from '@react-three/fiber'; +import * as THREE from 'three'; import { RotateCcw } from 'lucide-react'; import type { ExplorerProps } from '../../types/explorer'; import type { @@ -118,20 +119,21 @@ export const DocumentExplorer: React.FC< })); const nodeIds = new Set(nodes.map((n) => n.id)); - // Only visible edges land in the engine. Document→concept clustering - // hints from the d3 implementation are dropped on first cut — the - // engine's center gravity + concept-to-concept links produce a - // workable layout. If clustering proves loose in practice, the engine - // can grow per-edge visibility (render-skip while sim still uses them). - const edges: EngineEdge[] = data.links - .filter((l) => l.visible && nodeIds.has(l.source) && nodeIds.has(l.target)) - .map((l) => ({ - from: l.source, - to: l.target, - type: l.type, - })); - - return { nodes, edges }; + // All links land in the engine — both visible relationship edges + // (concept↔concept) and invisible clustering hints (document→concept). + // The engine sim uses the full set for force computation; rendering + // checks `edgeVisible` and collapses invisible edges to a point. + // Without this, concept dots drift away from their parent documents + // (concepts of one doc rarely link directly to each other in data). + const edges: EngineEdge[] = []; + const edgeVisible: boolean[] = []; + for (const l of data.links) { + if (!nodeIds.has(l.source) || !nodeIds.has(l.target)) continue; + edges.push({ from: l.source, to: l.target, type: l.type }); + edgeVisible.push(l.visible); + } + + return { nodes, edges, edgeVisible }; }, [data]); // Index nodes by id for fast lookups during click / NodeInfoBox prep. @@ -169,12 +171,44 @@ export const DocumentExplorer: React.FC< concept: , }), []); + // --------------------------------------------------------------------------- + // Focus mode → activeIds (drives the engine's dim and the per-node + // color dim baked below) + // --------------------------------------------------------------------------- + + const focusedConceptSet = useMemo(() => { + if (!focusedDocumentId || !data) return null; + const doc = data.documents.find((d) => d.id === focusedDocumentId); + if (!doc) return null; + return new Set(doc.conceptIds); + }, [focusedDocumentId, data]); + + // When set, items not in this set render at FOCUS_DIM_ALPHA on the + // engine's edges/labels and via the baked color dim on the nodes. + // Nothing focused → undefined, full opacity for everything. + const activeIds = useMemo(() => { + if (!focusedDocumentId || !focusedConceptSet) return undefined; + const set = new Set(focusedConceptSet); + set.add(focusedDocumentId); + return set; + }, [focusedDocumentId, focusedConceptSet]); + + // Engine `` doesn't read activeIds/dimAlpha (only Edges/Labels + // do), so bake the dim into the color array — that's how the document + // and concept meshes themselves visually recede when a different doc + // is focused. + const FOCUS_DIM_ALPHA = 0.08; const nodeColors = useMemo(() => { + const tmp = new THREE.Color(); + const hasFocus = !!activeIds && activeIds.size > 0; return engineData.nodes.map((n) => { const type = nodeType(n.id) ?? 'extended-concept'; - return COLORS[type]; + const base = COLORS[type]; + if (!hasFocus || activeIds!.has(n.id)) return base; + tmp.set(base).multiplyScalar(FOCUS_DIM_ALPHA); + return `rgb(${Math.round(tmp.r * 255)},${Math.round(tmp.g * 255)},${Math.round(tmp.b * 255)})`; }); - }, [engineData, nodeType]); + }, [engineData, nodeType, activeIds]); // Per-node base scale. Documents use a large constant (documentSize // setting is in pixel-space in the original; here it controls relative @@ -198,27 +232,6 @@ export const DocumentExplorer: React.FC< return out; }, [engineData, settings?.layout?.documentSize, nodeType]); - // --------------------------------------------------------------------------- - // Focus mode → engine highlight + dim - // --------------------------------------------------------------------------- - - const focusedConceptSet = useMemo(() => { - if (!focusedDocumentId || !data) return null; - const doc = data.documents.find((d) => d.id === focusedDocumentId); - if (!doc) return null; - return new Set(doc.conceptIds); - }, [focusedDocumentId, data]); - - // activeIds drives the engine's dim — when set, items not in the set - // render at `dimAlpha`. Focus on a document narrows to the document - // plus its concept set; nothing focused leaves the whole graph active. - const activeIds = useMemo(() => { - if (!focusedDocumentId || !focusedConceptSet) return undefined; - const set = new Set(focusedConceptSet); - set.add(focusedDocumentId); - return set; - }, [focusedDocumentId, focusedConceptSet]); - // --------------------------------------------------------------------------- // Interaction handlers — bridge engine onSelect to DocumentExplorer's // focus / view-document model. Single-click on a document focuses it; @@ -269,11 +282,12 @@ export const DocumentExplorer: React.FC< const handleReheat = useCallback(() => { simHandleRef.current?.reheat(); + // The engine doesn't yet expose a settle-end callback, so the spinner + // is a fixed-duration visual hint rather than a real signal that the + // layout has actually quieted. ~2.5s matches typical convergence in + // practice on the engine's GPU sim; the rendering is correct + // regardless of whether the spinner is on or off. setPhysicsActive(true); - // Engine's reheat doesn't drive a settled callback yet; reflect the - // active state for a short window so the spinner UX matches the d3 - // version. The engine settles much faster than the old d3 sim so a - // brief indicator is more honest than waiting for a real-end event. window.setTimeout(() => setPhysicsActive(false), 2500); }, []); @@ -325,12 +339,13 @@ export const DocumentExplorer: React.FC< ; dimAlpha?: number; + /** Optional per-edge visibility flag, parallel to `edges` by index. + * When `false`, the edge is kept in the physics sim (the engine's sim + * consumes the same `edges` array) but is render-collapsed to a single + * point. Document Explorer uses this for document→concept clustering + * hints — they pull concepts toward their parent document without + * drawing a visible line. Undefined = all visible. */ + edgeVisible?: boolean[]; } /** Indexed-line edge mesh — straight when no parallels, bezier otherwise. @verified c17bbeb9 */ @@ -73,6 +80,7 @@ export function Edges({ linkWidth = 1, activeIds, dimAlpha = 1, + edgeVisible, }: EdgesProps) { const lineRef = useRef(null); const invalidate = useThree((state) => state.invalidate); @@ -200,7 +208,7 @@ export function Edges({ } colAttr.needsUpdate = true; invalidate(); - }, [geometry, indexPairs, nodes, colors, edgeColors, originalIndices, segments, activeIds, dimAlpha, invalidate]); + }, [geometry, indexPairs, nodes, colors, edgeColors, originalIndices, segments, activeIds, dimAlpha, edgeVisible, invalidate]); useFrame(() => { const line = lineRef.current; @@ -229,6 +237,19 @@ export function Edges({ const ti = indexPairs[i * 2 + 1]; const base = i * vertsPerEdge * 3; + // Edge marked invisible by the plugin — collapse to a point so it + // disappears visually while staying in the sim (for clustering). + if (edgeVisible && edgeVisible[originalIndices[i]] === false) { + const k3 = si * 3; + for (let v = 0; v < vertsPerEdge; v++) { + const off = base + v * 3; + arr[off] = positions[k3]; + arr[off + 1] = positions[k3 + 1]; + arr[off + 2] = positions[k3 + 2]; + } + continue; + } + if (hasHidden && (hiddenIds!.has(nodes[si].id) || hiddenIds!.has(nodes[ti].id))) { const keepIdx = hiddenIds!.has(nodes[si].id) ? ti : si; const k3 = keepIdx * 3; diff --git a/web/src/explorers/ForceGraph/scene/Scene.tsx b/web/src/explorers/ForceGraph/scene/Scene.tsx index dbcde21fd..08774cb4d 100644 --- a/web/src/explorers/ForceGraph/scene/Scene.tsx +++ b/web/src/explorers/ForceGraph/scene/Scene.tsx @@ -46,6 +46,10 @@ export interface SceneProps { nodeScales?: Float32Array; /** Optional edge-type palette; when provided, edges and arrows color by type. */ edgeColors?: string[]; + /** Optional per-edge render visibility (parallel to `edges`). When + * `false`, the edge is kept in the physics sim but rendered collapsed + * to a point. Used by Document Explorer for invisible clustering hints. */ + edgeVisible?: boolean[]; hiddenIds?: Set; pinnedIds?: Set; highlightedIds?: Set; @@ -94,6 +98,7 @@ export function Scene({ geometryByClass, nodeScales, edgeColors, + edgeVisible, hiddenIds, pinnedIds, highlightedIds, @@ -157,6 +162,7 @@ export function Scene({ positionsRef={sim.positionsRef} colors={colors} edgeColors={edgeColors} + edgeVisible={edgeVisible} hiddenIds={hiddenIds} opacity={edgeOpacity} linkWidth={linkWidth} From 5990cd4e74d5e313d744729b1b09475e884f883d Mon Sep 17 00:00:00 2001 From: Aaron Bockelie Date: Thu, 14 May 2026 10:24:29 -0500 Subject: [PATCH 04/18] =?UTF-8?q?fix(web):=20Document=20Explorer=20visual?= =?UTF-8?q?=20fidelity=20=E2=80=94=20circles,=20label=20color,=20label=20o?= =?UTF-8?q?ffset?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three regressions from the engine port that the user flagged against the d3 reference screenshot: 1. **Document shape**: documents rendered as cubes / squares (boxGeometry). Switched to the same icosahedron geometry as concepts, distinguished only by scale and color — the d3 original drew them as larger circles too. Legend swatch follows (rounded-sm → rounded-full). 2. **Label color**: labels painted in the node's own color, so text on top of an amber node visually merged into the disc and the indigo extended-concept dots became unreadable — making them appear "missing" at a glance. Engine extension: NodeLabels accepts an optional `labelColors?: string[]` override (parallel to nodes), plumbed through Scene. Document Explorer paints all labels in `#e5e7eb` so they read against any node colour. Focus dim is baked into label colors the same way it is for node colors. 3. **Label position**: labels sat above nodes, hidden behind the larger document glyphs. Engine extension: NodeLabels accepts a signed `labelOffsetY` (plumbed via Scene as `nodeLabelOffsetY`); Document Explorer passes -2.2 to place labels below. Force Graph inherits the previous default (+1.4, above). Both engine props are optional; Force Graph behaviour is unchanged. The "missing extended-concept nodes" report should now resolve — they were there but their indigo labels visually merged into their indigo discs, making them look like a single coloured smear at concept-dot scale. Whitish labels + below-offset makes both the dots and their text readable. --- .../DocumentExplorer/DocumentExplorer.tsx | 27 +++++++++++++- .../explorers/ForceGraph/scene/NodeLabels.tsx | 37 ++++++++++++++----- web/src/explorers/ForceGraph/scene/Scene.tsx | 10 +++++ 3 files changed, 63 insertions(+), 11 deletions(-) diff --git a/web/src/explorers/DocumentExplorer/DocumentExplorer.tsx b/web/src/explorers/DocumentExplorer/DocumentExplorer.tsx index 8dffd3861..7360b20b9 100644 --- a/web/src/explorers/DocumentExplorer/DocumentExplorer.tsx +++ b/web/src/explorers/DocumentExplorer/DocumentExplorer.tsx @@ -166,8 +166,14 @@ export const DocumentExplorer: React.FC< }); }, [engineData, nodeType]); + // Both documents and concepts render as the same icosahedron geometry — + // the d3 implementation drew documents as larger circles too (with a + // small white file-glyph overlay; that overlay is the only detail + // dropped on the engine port). Size + color do the visual distinction. + // `geometryByClass` is wired so this stays single-source if we want to + // re-add a glyph (textured plane / instanced sprite) on documents later. const geometryByClass = useMemo(() => ({ - document: , + document: , concept: , }), []); @@ -210,6 +216,21 @@ export const DocumentExplorer: React.FC< }); }, [engineData, nodeType, activeIds]); + // Labels render in a colour distinct from the node mesh so text isn't + // masked by the disc it sits next to. White-ish reads against any node + // colour (the engine's label canvas paints a dark stroke underneath + // for legibility on light themes). Focus dim is baked in here too. + const LABEL_COLOR = '#e5e7eb'; + const labelColors = useMemo(() => { + const tmp = new THREE.Color(); + const hasFocus = !!activeIds && activeIds.size > 0; + return engineData.nodes.map((n) => { + if (!hasFocus || activeIds!.has(n.id)) return LABEL_COLOR; + tmp.set(LABEL_COLOR).multiplyScalar(FOCUS_DIM_ALPHA); + return `rgb(${Math.round(tmp.r * 255)},${Math.round(tmp.g * 255)},${Math.round(tmp.b * 255)})`; + }); + }, [engineData, activeIds]); + // Per-node base scale. Documents use a large constant (documentSize // setting is in pixel-space in the original; here it controls relative // scale — documents land at ~6-12x a concept dot). Concept scales follow @@ -341,6 +362,8 @@ export const DocumentExplorer: React.FC< edges={engineData.edges} edgeVisible={engineData.edgeVisible} colors={nodeColors} + labelColors={labelColors} + nodeLabelOffsetY={-2.2} nodeClasses={nodeClasses} geometryByClass={geometryByClass} nodeScales={nodeScales} @@ -389,7 +412,7 @@ export const DocumentExplorer: React.FC< {/* Legend — bottom left */}
- + Document
diff --git a/web/src/explorers/ForceGraph/scene/NodeLabels.tsx b/web/src/explorers/ForceGraph/scene/NodeLabels.tsx index f9d70c325..7dce51483 100644 --- a/web/src/explorers/ForceGraph/scene/NodeLabels.tsx +++ b/web/src/explorers/ForceGraph/scene/NodeLabels.tsx @@ -25,8 +25,12 @@ const MAX_LABELS = 120; /** Base label height in world units. Multiplied by the caller's * sizeMultiplier prop to compute the actual mesh scale. */ const BASE_LABEL_HEIGHT_WORLD = 1.0; -/** Vertical offset above the node (world up before billboard rotation). */ -const LABEL_OFFSET_ABOVE = 1.4; +/** Default vertical offset, in world units before billboard rotation. + * Positive lifts the label above the node, negative drops it below. + * The plugin can override via `labelOffsetY` — Document Explorer + * places labels below so they don't visually merge with the larger + * document glyphs. */ +const DEFAULT_LABEL_OFFSET_Y = 1.4; /** Canvas font size for text rendering (high-res for crisp scaling). */ const TEXT_FONT_PX = 32; const TEXT_PADDING_PX = 8; @@ -67,8 +71,14 @@ function makeLabelTexture(text: string, color: string): CachedTexture { export interface NodeLabelsProps { nodes: EngineNode[]; positionsRef: React.MutableRefObject; - /** Per-node colors, parallel to `nodes` by index. */ + /** Per-node colors, parallel to `nodes` by index. Used when + * `labelColors` is not provided. */ colors: string[]; + /** Optional per-node label color override (parallel to `nodes`). + * Lets the plugin paint labels in a palette distinct from the node + * mesh — Document Explorer needs whitish labels against amber nodes + * so the text isn't masked by the mesh colour underneath. */ + labelColors?: string[]; hiddenIds?: Set; /** Labels past this world-space distance from the camera are unmounted. * In 2D the distance is computed in the XY plane only — the camera's @@ -81,6 +91,10 @@ export interface NodeLabelsProps { projection?: Projection; /** Multiplier on the base label world-space height. Default 1. */ sizeMultiplier?: number; + /** Signed offset in world units along the billboard's local Y axis. + * Positive lifts the label above the node, negative drops it below. + * Default 1.4 (above). */ + labelOffsetY?: number; } /** Dim opacity applied to labels for nodes outside activeIds. */ @@ -91,12 +105,14 @@ export function NodeLabels({ nodes, positionsRef, colors, + labelColors, hiddenIds, visibilityRadius = 250, enabled = true, activeIds, projection = '3D', sizeMultiplier = 1, + labelOffsetY = DEFAULT_LABEL_OFFSET_Y, }: NodeLabelsProps) { const camera = useThree((state) => state.camera); @@ -137,7 +153,7 @@ export function NodeLabels({ const idx = visibleIndices[slot]; const node = nodes[idx]; if (!node) continue; - const color = colors[idx] ?? '#d7d7e0'; + const color = labelColors?.[idx] ?? colors[idx] ?? '#d7d7e0'; const key = `${node.label}|${color}`; let entry = textureCache.current.get(key); if (!entry) { @@ -157,7 +173,7 @@ export function NodeLabels({ mesh.scale.set(entry.aspect * h, h, 1); } } - }, [visibleIndices, nodes, colors, enabled, activeIds, sizeMultiplier]); + }, [visibleIndices, nodes, colors, labelColors, enabled, activeIds, sizeMultiplier]); const scratch = useMemo( () => ({ @@ -197,11 +213,14 @@ export function NodeLabels({ const a = idx * 3; scratch.pos.set(positions[a], positions[a + 1], positions[a + 2]); - // Offset above the node along world-up. Once the mesh faces the - // camera (via lookAt) this stays "above" from the viewer's POV - // because lookAt rotates around the node, not around world-up. + // Offset along world-up before billboard rotation. Once the mesh + // faces the camera (via lookAt-like quaternion) this stays + // "above"/"below" from the viewer's POV because the rotation is + // around the node, not around world-up. Negative labelOffsetY + // drops the label below the node — used by Document Explorer so + // labels don't sit on top of the larger document glyphs. scratch.labelPos.copy(scratch.pos); - scratch.labelPos.y += LABEL_OFFSET_ABOVE; + scratch.labelPos.y += labelOffsetY; // Screen-aligned billboard: normal points at the camera, "up" locked // to the camera's world-up so text stays horizontal in the viewport diff --git a/web/src/explorers/ForceGraph/scene/Scene.tsx b/web/src/explorers/ForceGraph/scene/Scene.tsx index 08774cb4d..ba7bbd6cd 100644 --- a/web/src/explorers/ForceGraph/scene/Scene.tsx +++ b/web/src/explorers/ForceGraph/scene/Scene.tsx @@ -66,6 +66,12 @@ export interface SceneProps { nodeLabelSize?: number; /** Multiplier on the base world-space edge-label height. Default 1. */ edgeLabelSize?: number; + /** Optional per-node label color override (parallel to `nodes`). When + * omitted, labels use the node's mesh color (Force Graph default). */ + labelColors?: string[]; + /** Signed Y offset for node labels in world units. Default +1.4 + * (above); negative drops below — used by Document Explorer. */ + nodeLabelOffsetY?: number; showArrows?: boolean; showEdgeLabels?: boolean; showNodeLabels?: boolean; @@ -109,6 +115,8 @@ export function Scene({ linkWidth, nodeLabelSize = 1, edgeLabelSize = 1, + labelColors, + nodeLabelOffsetY, showArrows = true, showEdgeLabels = true, showNodeLabels = true, @@ -215,12 +223,14 @@ export function Scene({ nodes={nodes} positionsRef={sim.positionsRef} colors={colors} + labelColors={labelColors} hiddenIds={hiddenIds} enabled={showNodeLabels} visibilityRadius={labelVisibilityRadius} activeIds={activeIds} projection={projection} sizeMultiplier={nodeLabelSize} + labelOffsetY={nodeLabelOffsetY} /> {activeNodeInfos.map((info) => ( Date: Thu, 14 May 2026 10:27:52 -0500 Subject: [PATCH 05/18] refactor(web): unify scale resolution for nodes and labels Hoist the per-node scale resolution into `` so node meshes and node labels consume the same `Float32Array`. The plugin override (`nodeScales`) still wins when provided; otherwise Scene derives the default from degree using the previously-internal formula. Effect: scale-aware label offsets now apply to Force Graph too, not just Document Explorer. Labels for high-degree hub nodes float past the node surface instead of sometimes sitting on top of it. The default `labelOffsetY = 1.4` is now interpreted as padding past radius rather than absolute world-units, so smaller nodes stay close and bigger nodes still clear cleanly. `` keeps its fallback path for the undefined-scales case so it remains usable as a leaf component if someone bypasses Scene, but in practice Scene always passes the resolved array now. --- .../explorers/ForceGraph/scene/NodeLabels.tsx | 41 +++++++++++++++---- web/src/explorers/ForceGraph/scene/Scene.tsx | 23 +++++++++-- 2 files changed, 52 insertions(+), 12 deletions(-) diff --git a/web/src/explorers/ForceGraph/scene/NodeLabels.tsx b/web/src/explorers/ForceGraph/scene/NodeLabels.tsx index 7dce51483..b47e26d49 100644 --- a/web/src/explorers/ForceGraph/scene/NodeLabels.tsx +++ b/web/src/explorers/ForceGraph/scene/NodeLabels.tsx @@ -91,10 +91,22 @@ export interface NodeLabelsProps { projection?: Projection; /** Multiplier on the base label world-space height. Default 1. */ sizeMultiplier?: number; - /** Signed offset in world units along the billboard's local Y axis. - * Positive lifts the label above the node, negative drops it below. - * Default 1.4 (above). */ + /** Signed Y offset in world units. The sign controls direction + * (positive = above, negative = below). When `nodeScales` is also + * provided, the offset becomes scale-aware: the label sits at + * `sign(labelOffsetY) * radius + labelOffsetY` so it always clears + * the node's surface by that constant padding regardless of how the + * node is scaled. When `nodeScales` is omitted (current Force Graph + * default), it falls back to a flat absolute offset. Default 1.4. */ labelOffsetY?: number; + /** Per-node base scale (parallel to `nodes`). When provided, label + * positioning becomes radius-aware — see `labelOffsetY`. The plugin + * should pass the same array it gives `` so labels track the + * actual rendered node radius. */ + nodeScales?: Float32Array; + /** Global node-size multiplier — same prop `` consumes. Used + * alongside `nodeScales` when computing scale-aware label offsets. */ + nodeSize?: number; } /** Dim opacity applied to labels for nodes outside activeIds. */ @@ -113,6 +125,8 @@ export function NodeLabels({ projection = '3D', sizeMultiplier = 1, labelOffsetY = DEFAULT_LABEL_OFFSET_Y, + nodeScales, + nodeSize = 1, }: NodeLabelsProps) { const camera = useThree((state) => state.camera); @@ -214,13 +228,22 @@ export function NodeLabels({ scratch.pos.set(positions[a], positions[a + 1], positions[a + 2]); // Offset along world-up before billboard rotation. Once the mesh - // faces the camera (via lookAt-like quaternion) this stays - // "above"/"below" from the viewer's POV because the rotation is - // around the node, not around world-up. Negative labelOffsetY - // drops the label below the node — used by Document Explorer so - // labels don't sit on top of the larger document glyphs. + // faces the camera this stays "above"/"below" from the viewer's + // POV because the rotation is around the node, not around world-up. + // + // When the plugin passes `nodeScales`, the offset clears the node's + // surface by `|labelOffsetY|` regardless of how the node is scaled + // (e.g. Document Explorer's documents are ~6× concept radius — a + // fixed offset would sit inside them). When `nodeScales` is absent + // the engine falls back to the flat absolute offset (Force Graph). scratch.labelPos.copy(scratch.pos); - scratch.labelPos.y += labelOffsetY; + if (nodeScales) { + const radius = (nodeScales[idx] ?? 1) * nodeSize; + const sign = labelOffsetY >= 0 ? 1 : -1; + scratch.labelPos.y += sign * radius + labelOffsetY; + } else { + scratch.labelPos.y += labelOffsetY; + } // Screen-aligned billboard: normal points at the camera, "up" locked // to the camera's world-up so text stays horizontal in the viewport diff --git a/web/src/explorers/ForceGraph/scene/Scene.tsx b/web/src/explorers/ForceGraph/scene/Scene.tsx index ba7bbd6cd..6f2196a35 100644 --- a/web/src/explorers/ForceGraph/scene/Scene.tsx +++ b/web/src/explorers/ForceGraph/scene/Scene.tsx @@ -7,7 +7,7 @@ * (pan + zoom, no rotation). */ -import { useEffect, useImperativeHandle, type ReactElement } from 'react'; +import { useEffect, useImperativeHandle, useMemo, type ReactElement } from 'react'; import * as THREE from 'three'; import { OrbitControls } from '@react-three/drei'; import type { EngineNode, EngineEdge, Projection } from '../types'; @@ -42,7 +42,8 @@ export interface SceneProps { /** Geometry element per class key, used when `nodeClasses` is set. */ geometryByClass?: Record; /** Optional per-node base scale override (parallel to `nodes`). When - * provided, replaces the engine's degree-based default scale. */ + * provided, replaces the engine's degree-based default scale. + * Drives node mesh size AND scale-aware label offsets uniformly. */ nodeScales?: Float32Array; /** Optional edge-type palette; when provided, edges and arrows color by type. */ edgeColors?: string[]; @@ -142,6 +143,20 @@ export function Scene({ pinnedIds, dimensions: projection === '2D' ? 2 : 3, }); + + // Resolve per-node base scales here so Nodes and NodeLabels see the + // same numbers — the plugin's override wins if provided, otherwise + // the engine derives from degree. Sharing one resolved array is what + // lets label positioning be scale-aware for every explorer (the + // alternative is each consumer re-deriving and silently disagreeing). + const resolvedNodeScales = useMemo(() => { + if (nodeScales) return nodeScales; + const out = new Float32Array(nodes.length); + for (let i = 0; i < nodes.length; i++) { + out[i] = 0.8 + Math.sqrt(nodes[i].degree || 1) * 0.3; + } + return out; + }, [nodes, nodeScales]); const drag = useDragHandler({ nodes, positionsRef: sim.positionsRef, @@ -195,7 +210,7 @@ export function Scene({ colors={colors} nodeClasses={nodeClasses} geometryByClass={geometryByClass} - nodeScales={nodeScales} + nodeScales={resolvedNodeScales} hiddenIds={hiddenIds} highlightedIds={highlightedIds} nodeSize={nodeSize} @@ -231,6 +246,8 @@ export function Scene({ projection={projection} sizeMultiplier={nodeLabelSize} labelOffsetY={nodeLabelOffsetY} + nodeScales={resolvedNodeScales} + nodeSize={nodeSize} /> {activeNodeInfos.map((info) => ( Date: Thu, 14 May 2026 10:31:07 -0500 Subject: [PATCH 06/18] feat(web): Document Explorer state survives navigation within a session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The workspace held its loaded graph (`explorerData`, `sidebarDocs`, `focusedDocId`) in local React state, so navigating away and back unmounted the workspace and reset to "Load a saved exploration query" empty state. Move those three slots into a small `documentExplorerStore`. The store lives in memory for the session and is not persisted to localStorage — mirrors Force Graph's policy for `rawGraphData` (stale snapshots are worse than no snapshot: a re-ingested DB or a different deploy makes node ids unreachable and the user clicks into a dead view). If we want cross-session persistence later, the saved exploration query is the durable record and the pipeline can replay it on next load, same as Force Graph's autosave. Pipeline state (`isLoading`, `error`, `loadingMessage`), settings, and the document viewer modal stay local — they're either truly ephemeral or workspace-mount-scoped UI. --- .../documents/DocumentExplorerWorkspace.tsx | 53 ++++++++-------- web/src/store/documentExplorerStore.ts | 60 +++++++++++++++++++ 2 files changed, 86 insertions(+), 27 deletions(-) create mode 100644 web/src/store/documentExplorerStore.ts diff --git a/web/src/components/documents/DocumentExplorerWorkspace.tsx b/web/src/components/documents/DocumentExplorerWorkspace.tsx index 0fa5a06c6..06e91d197 100644 --- a/web/src/components/documents/DocumentExplorerWorkspace.tsx +++ b/web/src/components/documents/DocumentExplorerWorkspace.tsx @@ -26,45 +26,42 @@ import { SavedQueriesPanel } from '../shared/SavedQueriesPanel'; import { useQueryReplay, type ReplayableDefinition } from '../../hooks/useQueryReplay'; import { usePassageSearch } from '../../hooks/usePassageSearch'; import { useGraphStore } from '../../store/graphStore'; +import { useDocumentExplorerStore, type SidebarDocument } from '../../store/documentExplorerStore'; import { mapWorkingGraphToRawGraph } from '../../utils/cypherResultMapper'; import { cypherToStatement } from '../../utils/programBuilder'; -/** Sidebar document entry (from findDocumentsByConcepts). */ -interface SidebarDocument { - document_id: string; - filename: string; - ontology: string; - content_type: string; - concept_ids: string[]; // concepts overlapping with query - totalConceptCount: number; // ALL concepts for this doc (after hydration) -} - export const DocumentExplorerWorkspace: React.FC = () => { const { replayQuery } = useQueryReplay(); const [activeRailTab, setActiveRailTab] = useState('savedQueries'); - // Pipeline state + // Pipeline state — kept local; these reset on remount intentionally + // (a stale "Loading..." indicator on a re-entry would be wrong). const [isLoading, setIsLoading] = useState(false); const [loadingMessage, setLoadingMessage] = useState(''); const [error, setError] = useState(null); - // Document list (populated from query) - const [sidebarDocs, setSidebarDocs] = useState([]); - - // Graph data - const [explorerData, setExplorerData] = useState(null); - - // Focus state — which document is focused in the graph - const [focusedDocId, setFocusedDocId] = useState(null); - - // Document viewer + // Session state — lives in a Zustand store so it survives navigating + // away and back. Mirrors how Force Graph keeps `rawGraphData` in + // `graphStore`. See `documentExplorerStore` for why we don't persist + // these to localStorage. + const sidebarDocs = useDocumentExplorerStore((s) => s.sidebarDocs); + const explorerData = useDocumentExplorerStore((s) => s.explorerData); + const focusedDocId = useDocumentExplorerStore((s) => s.focusedDocId); + const setSidebarDocs = useDocumentExplorerStore((s) => s.setSidebarDocs); + const setExplorerData = useDocumentExplorerStore((s) => s.setExplorerData); + const setFocusedDocId = useDocumentExplorerStore((s) => s.setFocusedDocId); + const resetSession = useDocumentExplorerStore((s) => s.reset); + + // Document viewer is per-mount UI — modal-style state that doesn't + // need to survive navigation. const [viewingDocument, setViewingDocument] = useState<{ document_id: string; filename: string; content_type: string; } | null>(null); - // Settings + // Settings — local, matching Force Graph's pattern where settings + // reset on remount. const [settings, setSettings] = useState(DEFAULT_SETTINGS); // Multi-query passage search (extracted hook) @@ -89,9 +86,7 @@ export const DocumentExplorerWorkspace: React.FC = () => { const handleLoadExplorationQuery = useCallback(async (query: ReplayableDefinition) => { setIsLoading(true); setError(null); - setFocusedDocId(null); - setExplorerData(null); - setSidebarDocs([]); + resetSession(); resetQueries(); try { @@ -271,8 +266,12 @@ export const DocumentExplorerWorkspace: React.FC = () => { // --------------------------------------------------------------------------- const handleFocusDocument = useCallback((docId: string) => { - setFocusedDocId(prev => prev === docId ? null : docId); - }, []); + // Read current focus from the store directly so this callback can stay + // dependency-free — the Zustand setter doesn't accept an updater fn + // the way `useState` does. + const current = useDocumentExplorerStore.getState().focusedDocId; + setFocusedDocId(current === docId ? null : docId); + }, [setFocusedDocId]); const handleViewDocument = useCallback((doc: SidebarDocument) => { setViewingDocument({ diff --git a/web/src/store/documentExplorerStore.ts b/web/src/store/documentExplorerStore.ts new file mode 100644 index 000000000..5f8c5413d --- /dev/null +++ b/web/src/store/documentExplorerStore.ts @@ -0,0 +1,60 @@ +/** + * Document Explorer Session Store + * + * Holds in-memory state for the Document Explorer workspace so the + * loaded graph survives navigation away and back. Mirrors the Force + * Graph pattern: the data lives in a Zustand store (survives mount / + * unmount within the session) but is NOT persisted to localStorage — + * a stale snapshot of document-keyed concept graphs goes wrong the + * moment the database is re-ingested or the user moves to a different + * deploy. If we want cross-session persistence later, the saved + * exploration query is the durable record and the pipeline can replay + * it on next load (same as Force Graph's autosave). + * + * Scope is deliberately narrow: only the state that defines "what was + * I looking at" lives here. Ephemeral pipeline state (`isLoading`, + * `error`) and per-mount UI (open document viewer) stay local to the + * workspace component. + */ + +import { create } from 'zustand'; +import type { DocumentExplorerData } from '../explorers/DocumentExplorer/types'; + +export interface SidebarDocument { + document_id: string; + filename: string; + ontology: string; + content_type: string; + /** Concepts overlapping with the active query. */ + concept_ids: string[]; + /** All concepts for this document (after hydration). */ + totalConceptCount: number; +} + +interface DocumentExplorerStore { + /** The multi-document concept graph the user loaded. Null until a + * saved query is replayed. */ + explorerData: DocumentExplorerData | null; + /** Document list shown in the sidebar. */ + sidebarDocs: SidebarDocument[]; + /** Currently-focused document id (drives the dim-everything-else mode + * in the graph). Null when nothing is focused. */ + focusedDocId: string | null; + + setExplorerData: (data: DocumentExplorerData | null) => void; + setSidebarDocs: (docs: SidebarDocument[]) => void; + setFocusedDocId: (id: string | null) => void; + /** Atomic reset, used at the start of a new query load so we don't + * flash partial old state before the new data arrives. */ + reset: () => void; +} + +export const useDocumentExplorerStore = create((set) => ({ + explorerData: null, + sidebarDocs: [], + focusedDocId: null, + setExplorerData: (explorerData) => set({ explorerData }), + setSidebarDocs: (sidebarDocs) => set({ sidebarDocs }), + setFocusedDocId: (focusedDocId) => set({ focusedDocId }), + reset: () => set({ explorerData: null, sidebarDocs: [], focusedDocId: null }), +})); From 8f4f067dd8410eaa6d6a6fb933aba90c404ac5a4 Mon Sep 17 00:00:00 2001 From: Aaron Bockelie Date: Thu, 14 May 2026 10:37:19 -0500 Subject: [PATCH 07/18] =?UTF-8?q?fix(web):=20Document=20Explorer=20interac?= =?UTF-8?q?tion=20parity=20=E2=80=94=20click,=20info=20card,=20settle,=20c?= =?UTF-8?q?ontext=20menu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four UX regressions from the engine port, all in one commit since they share the click-handler flow: 1. **Constant "Settling..." indicator**: `physicsActive` initialized to `true` and only flipped off via the Reheat timeout — so the spinner ran forever on a fresh mount. The engine sim has no "settled" event (GPU loop keeps running by design), so the spinner is really just a Reheat acknowledgement. Default off; Reheat briefly turns it on. 2. **Info card invisible on left-click**: the port used the 2D corner `NodeInfoBox` instead of the engine's in-scene `NodeInfoOverlay` that Force Graph uses. Switched to the same `activeNodeInfos` / `onDismissNodeInfo` pattern — left-click on a concept pins an in-scene info card next to the node; clicking again dismisses it. 3. **Click on document didn't open the viewer**: the port required a double-click to open the viewer. Single-click now opens the viewer AND toggles focus (clicking the focused document again clears focus). The dblclick branch and timestamp bookkeeping are gone. 4. **Right-click context menu missing**: the wrapper div was `preventDefault`-ing all right-clicks, suppressing both the browser's native menu and anything else. Dropped the handler — the d3 original didn't override right-click either, so right-click falls through to the browser menu like before. A custom Document-Explorer context menu (e.g. "View document" / "Focus") is left for a follow-up if needed. Selected-concept state and the dblclick timestamp ref are gone — the info-overlay set is now the source of truth for "what concepts the user has pinned open". --- .../DocumentExplorer/DocumentExplorer.tsx | 115 +++++++----------- 1 file changed, 42 insertions(+), 73 deletions(-) diff --git a/web/src/explorers/DocumentExplorer/DocumentExplorer.tsx b/web/src/explorers/DocumentExplorer/DocumentExplorer.tsx index 7360b20b9..7dd0ed732 100644 --- a/web/src/explorers/DocumentExplorer/DocumentExplorer.tsx +++ b/web/src/explorers/DocumentExplorer/DocumentExplorer.tsx @@ -27,10 +27,11 @@ import type { PassageQuery, } from './types'; import { useThemeStore } from '../../store/themeStore'; -import { NodeInfoBox, StatsPanel, PanelStack } from '../common'; +import { StatsPanel, PanelStack } from '../common'; import { Scene } from '../ForceGraph/scene/Scene'; import type { EngineNode, EngineEdge } from '../ForceGraph/types'; import type { ForceSimHandle } from '../ForceGraph/scene/useForceSim'; +import type { NodeInfoData } from '../ForceGraph/scene/NodeInfoOverlay'; // --------------------------------------------------------------------------- // Visual constants @@ -82,19 +83,21 @@ export const DocumentExplorer: React.FC< onViewDocument, }) => { const { appliedTheme: theme } = useThemeStore(); - const [selectedConceptId, setSelectedConceptId] = useState(null); const [hoveredId, setHoveredId] = useState(null); - const [physicsActive, setPhysicsActive] = useState(true); + // Spinner reflects an explicit Reheat — the engine sim is always + // running (GPU/CPU), so a "settled" state isn't a real signal. Default + // off; Reheat flips it on for a short window. + const [physicsActive, setPhysicsActive] = useState(false); + + // Engine-style info cards: one per pinned concept, rendered in-scene + // via the same `NodeInfoOverlay` Force Graph uses. Documents bypass + // this — clicking a document opens the viewer instead. + const [activeNodeInfos, setActiveNodeInfos] = useState([]); // Sim handle bridges the inside-Canvas hook to the Reheat button outside // the Canvas tree. const simHandleRef = useRef(null); - // Last-click bookkeeping for manual dblclick detection. The engine's - // pointer handler fires once per pointer-up; double-click semantics are - // local concern (we use it to open the document viewer on documents). - const lastClickRef = useRef<{ id: string; at: number } | null>(null); - // --------------------------------------------------------------------------- // Engine data — transform DocumentExplorer shape → EngineNode[]/EngineEdge[] // --------------------------------------------------------------------------- @@ -255,43 +258,43 @@ export const DocumentExplorer: React.FC< // --------------------------------------------------------------------------- // Interaction handlers — bridge engine onSelect to DocumentExplorer's - // focus / view-document model. Single-click on a document focuses it; - // double-click opens the viewer. Single-click on a concept shows the - // NodeInfoBox; clicking the same concept again dismisses it (engine's - // toggle behavior is preserved). + // focus / view-document model. + // + // - Click on a document: opens the document viewer AND focuses (dims + // non-focused). Clicking the focused document again clears focus and + // the open viewer is left in place — closing the viewer is its own + // dismiss control. + // - Click on a concept: pins an in-scene `NodeInfoOverlay` (the same + // info card Force Graph uses). Click the same concept again to + // dismiss it. // --------------------------------------------------------------------------- const handleSelect = useCallback((id: string | null) => { - if (!id) { - setSelectedConceptId(null); - return; - } + if (!id) return; const type = nodeType(id); - - // Manual dblclick detection — engine fires onSelect once per click. - const now = performance.now(); - const isDouble = lastClickRef.current?.id === id - && (now - lastClickRef.current.at) < DOUBLE_CLICK_MS; - lastClickRef.current = { id, at: now }; - if (type === 'document') { - if (isDouble) { - onViewDocument?.(id); - return; - } - // Single-click on a document toggles focus. - if (focusedDocumentId === id) { - onFocusChange?.(null); - } else { - onFocusChange?.(id); - } - setSelectedConceptId(null); + onViewDocument?.(id); + onFocusChange?.(focusedDocumentId === id ? null : id); return; } + // Concept — toggle the in-scene info overlay. + const node = nodesById.get(id); + if (!node) return; + setActiveNodeInfos((prev) => { + if (prev.some((i) => i.nodeId === id)) { + return prev.filter((i) => i.nodeId !== id); + } + return [ + ...prev, + { nodeId: id, label: node.label, group: type ?? undefined, degree: node.degree }, + ]; + }); + }, [focusedDocumentId, nodeType, nodesById, onFocusChange, onViewDocument]); - // Concept click — toggle the NodeInfoBox selection. - setSelectedConceptId((prev) => (prev === id ? null : id)); - }, [focusedDocumentId, nodeType, onFocusChange, onViewDocument]); + const handleDismissNodeInfo = useCallback( + (nodeId: string) => setActiveNodeInfos((prev) => prev.filter((i) => i.nodeId !== nodeId)), + [], + ); const handleHover = useCallback((id: string | null) => { if (settings?.interaction?.highlightOnHover === false) { @@ -312,26 +315,6 @@ export const DocumentExplorer: React.FC< window.setTimeout(() => setPhysicsActive(false), 2500); }, []); - // --------------------------------------------------------------------------- - // Selected concept → NodeInfoBox data - // --------------------------------------------------------------------------- - - const selectedNodeData = useMemo(() => { - if (!selectedConceptId) return null; - const src = sourceById.get(selectedConceptId); - const eng = nodesById.get(selectedConceptId); - if (!src || !eng) return null; - const degree = engineData.edges.filter( - (e) => e.from === selectedConceptId || e.to === selectedConceptId, - ).length; - return { - id: selectedConceptId, - label: src.label || 'Unknown', - type: src.type, - degree, - }; - }, [selectedConceptId, sourceById, nodesById, engineData]); - // --------------------------------------------------------------------------- // Render // --------------------------------------------------------------------------- @@ -342,7 +325,6 @@ export const DocumentExplorer: React.FC< return (
e.preventDefault()} > {/* Canvas keys on projection so the camera dispatch in Scene gets a fresh r3f tree (perspective vs orthographic can't be swapped @@ -378,10 +360,11 @@ export const DocumentExplorer: React.FC< enableDrag enableZoom={settings?.interaction?.enableZoom !== false} enablePan={settings?.interaction?.enablePan !== false} - selectedId={selectedConceptId} hoveredId={hoveredId} onSelect={handleSelect} onHover={handleHover} + activeNodeInfos={activeNodeInfos} + onDismissNodeInfo={handleDismissNodeInfo} simHandleRef={simHandleRef} projection={projection} /> @@ -425,20 +408,6 @@ export const DocumentExplorer: React.FC<
- {/* NodeInfoBox for selected concept */} - {selectedNodeData && selectedNodeData.type !== 'document' && ( - setSelectedConceptId(null)} - /> - )}
); }; From 97df7e3fd0fea2b86035a22ccfa49662778bdede Mon Sep 17 00:00:00 2001 From: Aaron Bockelie Date: Thu, 14 May 2026 10:43:36 -0500 Subject: [PATCH 08/18] =?UTF-8?q?fix(web):=20Document=20Explorer=20interac?= =?UTF-8?q?tion=20model=20=E2=80=94=20hover=20dim=20+=20right-click=20menu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Match Force Graph's two-tier dim model and click semantics so muscle memory carries between the two explorers. **Hover** (transient, 0.25 dim): hovering any node activates the engine's `activeIds` to the node plus its graph neighbours. Edges and labels fade for everything else, baked into node colours the same way Force Graph does. Local-topology inspection without clicks. **Focus** (persistent, 0.05 dim): toggled via right-click → context menu. Documents focus to "document + all its concepts" (the previous behaviour); concepts can't be focused (no useful concept-level dim model yet — flagged for follow-up if needed). Focus wins over hover when both are active. **Left-click** is now pure inspection: - Click a document → opens the viewer (no longer toggles focus too) - Click a concept → toggles its in-scene `NodeInfoOverlay` **Right-click**: - On a document → "View document" / "Focus on document" / "Unfocus" - On a concept → "Unfocus" (when something is focused) - On background (when something is focused) → "Unfocus" `nodeContextConsumedRef` guards the wrapper's `onContextMenu` from opening a second menu after the node-mesh handler has already done so (same pattern Force Graph uses). The previous click-also-focuses behaviour was a double-event: clicking a document opened the viewer AND dimmed the rest of the graph, which the user reported as confusing. --- .../DocumentExplorer/DocumentExplorer.tsx | 201 ++++++++++++++---- 1 file changed, 159 insertions(+), 42 deletions(-) diff --git a/web/src/explorers/DocumentExplorer/DocumentExplorer.tsx b/web/src/explorers/DocumentExplorer/DocumentExplorer.tsx index 7dd0ed732..0f8cfa37b 100644 --- a/web/src/explorers/DocumentExplorer/DocumentExplorer.tsx +++ b/web/src/explorers/DocumentExplorer/DocumentExplorer.tsx @@ -32,6 +32,8 @@ import { Scene } from '../ForceGraph/scene/Scene'; import type { EngineNode, EngineEdge } from '../ForceGraph/types'; import type { ForceSimHandle } from '../ForceGraph/scene/useForceSim'; import type { NodeInfoData } from '../ForceGraph/scene/NodeInfoOverlay'; +import { ContextMenu, type ContextMenuItem } from '../../components/shared/ContextMenu'; +import { FileText, Eye, EyeOff, Crosshair } from 'lucide-react'; // --------------------------------------------------------------------------- // Visual constants @@ -181,58 +183,74 @@ export const DocumentExplorer: React.FC< }), []); // --------------------------------------------------------------------------- - // Focus mode → activeIds (drives the engine's dim and the per-node - // color dim baked below) + // Dim state — hover and focus drive the same engine `activeIds` / + // `dimAlpha` machinery the Force Graph uses. Focus is persistent + // (right-click → "Focus on document") and dims more aggressively; + // hover is transient and dims subtly. When both are active, focus + // wins — same convention as Force Graph. // --------------------------------------------------------------------------- - const focusedConceptSet = useMemo(() => { + const FOCUS_DIM_ALPHA = 0.05; + const HOVER_DIM_ALPHA = 0.25; + + // Concept set for the currently-focused document (document plus all + // its concepts). Only documents can be focused; concepts use hover + // for inspection. + const focusActiveIds = useMemo | null>(() => { if (!focusedDocumentId || !data) return null; const doc = data.documents.find((d) => d.id === focusedDocumentId); if (!doc) return null; - return new Set(doc.conceptIds); - }, [focusedDocumentId, data]); - - // When set, items not in this set render at FOCUS_DIM_ALPHA on the - // engine's edges/labels and via the baked color dim on the nodes. - // Nothing focused → undefined, full opacity for everything. - const activeIds = useMemo(() => { - if (!focusedDocumentId || !focusedConceptSet) return undefined; - const set = new Set(focusedConceptSet); + const set = new Set(doc.conceptIds); set.add(focusedDocumentId); return set; - }, [focusedDocumentId, focusedConceptSet]); + }, [focusedDocumentId, data]); - // Engine `` doesn't read activeIds/dimAlpha (only Edges/Labels - // do), so bake the dim into the color array — that's how the document - // and concept meshes themselves visually recede when a different doc - // is focused. - const FOCUS_DIM_ALPHA = 0.08; + // Hovered node + its immediate edge neighbors. Matches Force Graph's + // hover-dim behaviour so muscle memory carries. + const hoverActiveIds = useMemo | null>(() => { + if (!hoveredId) return null; + const set = new Set([hoveredId]); + for (const e of engineData.edges) { + if (e.from === hoveredId) set.add(e.to); + else if (e.to === hoveredId) set.add(e.from); + } + return set; + }, [hoveredId, engineData]); + + const dimState = useMemo<{ activeIds: Set; dimAlpha: number } | undefined>(() => { + if (focusActiveIds) return { activeIds: focusActiveIds, dimAlpha: FOCUS_DIM_ALPHA }; + if (hoverActiveIds) return { activeIds: hoverActiveIds, dimAlpha: HOVER_DIM_ALPHA }; + return undefined; + }, [focusActiveIds, hoverActiveIds]); + + // Engine `` doesn't read activeIds/dimAlpha directly (only + // Edges/Labels do), so bake the dim into the per-node color array — + // the document and concept meshes themselves recede along with the + // edges. Same pattern Force Graph uses. const nodeColors = useMemo(() => { const tmp = new THREE.Color(); - const hasFocus = !!activeIds && activeIds.size > 0; return engineData.nodes.map((n) => { const type = nodeType(n.id) ?? 'extended-concept'; const base = COLORS[type]; - if (!hasFocus || activeIds!.has(n.id)) return base; - tmp.set(base).multiplyScalar(FOCUS_DIM_ALPHA); + if (!dimState || dimState.activeIds.has(n.id)) return base; + tmp.set(base).multiplyScalar(dimState.dimAlpha); return `rgb(${Math.round(tmp.r * 255)},${Math.round(tmp.g * 255)},${Math.round(tmp.b * 255)})`; }); - }, [engineData, nodeType, activeIds]); + }, [engineData, nodeType, dimState]); // Labels render in a colour distinct from the node mesh so text isn't - // masked by the disc it sits next to. White-ish reads against any node - // colour (the engine's label canvas paints a dark stroke underneath - // for legibility on light themes). Focus dim is baked in here too. + // masked by the disc next to it. White-ish reads against any node + // colour (the engine paints a dark stroke under the text). Dim is + // baked in here too. const LABEL_COLOR = '#e5e7eb'; const labelColors = useMemo(() => { const tmp = new THREE.Color(); - const hasFocus = !!activeIds && activeIds.size > 0; return engineData.nodes.map((n) => { - if (!hasFocus || activeIds!.has(n.id)) return LABEL_COLOR; - tmp.set(LABEL_COLOR).multiplyScalar(FOCUS_DIM_ALPHA); + if (!dimState || dimState.activeIds.has(n.id)) return LABEL_COLOR; + tmp.set(LABEL_COLOR).multiplyScalar(dimState.dimAlpha); return `rgb(${Math.round(tmp.r * 255)},${Math.round(tmp.g * 255)},${Math.round(tmp.b * 255)})`; }); - }, [engineData, activeIds]); + }, [engineData, dimState]); // Per-node base scale. Documents use a large constant (documentSize // setting is in pixel-space in the original; here it controls relative @@ -257,16 +275,18 @@ export const DocumentExplorer: React.FC< }, [engineData, settings?.layout?.documentSize, nodeType]); // --------------------------------------------------------------------------- - // Interaction handlers — bridge engine onSelect to DocumentExplorer's - // focus / view-document model. + // Interaction handlers — left-click is pure inspection (no graph + // mutation, no focus toggle); right-click drives the context menu + // where focus and graph-mutating actions live. Mirrors Force Graph. // - // - Click on a document: opens the document viewer AND focuses (dims - // non-focused). Clicking the focused document again clears focus and - // the open viewer is left in place — closing the viewer is its own - // dismiss control. - // - Click on a concept: pins an in-scene `NodeInfoOverlay` (the same - // info card Force Graph uses). Click the same concept again to - // dismiss it. + // - Click on a document: opens the document viewer. + // - Click on a concept: toggles its in-scene `NodeInfoOverlay`. + // - Hover: dims non-neighbours (subtle, transient). + // - Right-click: opens the context menu. Document menu offers + // View / Focus / Unfocus; concept menu offers Unfocus (when a + // document is focused) — graph mutations don't fit this explorer + // since the dataset is built from a saved query plus document + // hydration, not a generic exploration. // --------------------------------------------------------------------------- const handleSelect = useCallback((id: string | null) => { @@ -274,7 +294,6 @@ export const DocumentExplorer: React.FC< const type = nodeType(id); if (type === 'document') { onViewDocument?.(id); - onFocusChange?.(focusedDocumentId === id ? null : id); return; } // Concept — toggle the in-scene info overlay. @@ -289,7 +308,7 @@ export const DocumentExplorer: React.FC< { nodeId: id, label: node.label, group: type ?? undefined, degree: node.degree }, ]; }); - }, [focusedDocumentId, nodeType, nodesById, onFocusChange, onViewDocument]); + }, [nodeType, nodesById, onViewDocument]); const handleDismissNodeInfo = useCallback( (nodeId: string) => setActiveNodeInfos((prev) => prev.filter((i) => i.nodeId !== nodeId)), @@ -304,6 +323,93 @@ export const DocumentExplorer: React.FC< setHoveredId(id); }, [settings?.interaction?.highlightOnHover]); + // --------------------------------------------------------------------------- + // Right-click context menu + // --------------------------------------------------------------------------- + + const [contextMenu, setContextMenu] = useState<{ + x: number; + y: number; + nodeId: string | null; + nodeLabel: string | null; + } | null>(null); + + // Set when a node-mesh right-click consumes the event so the + // background div's onContextMenu doesn't ALSO open a menu (mirrors + // Force Graph's pattern). + const nodeContextConsumedRef = useRef(false); + + const handleNodeContextMenu = useCallback( + (id: string, event: PointerEvent) => { + nodeContextConsumedRef.current = true; + const label = nodesById.get(id)?.label ?? id; + setContextMenu({ x: event.clientX, y: event.clientY, nodeId: id, nodeLabel: label }); + }, + [nodesById], + ); + + const handleBackgroundContextMenu = useCallback( + (event: React.MouseEvent) => { + event.preventDefault(); + if (nodeContextConsumedRef.current) { + nodeContextConsumedRef.current = false; + return; + } + // Background menu — only useful when something is focused, to + // offer Unfocus. Otherwise dismiss silently so right-click on + // empty canvas doesn't surprise the user. + if (!focusedDocumentId) return; + setContextMenu({ x: event.clientX, y: event.clientY, nodeId: null, nodeLabel: null }); + }, + [focusedDocumentId], + ); + + const contextMenuItems = useMemo(() => { + if (!contextMenu) return []; + const items: ContextMenuItem[] = []; + const type = contextMenu.nodeId ? nodeType(contextMenu.nodeId) : null; + if (type === 'document') { + items.push({ + label: 'View document', + icon: FileText, + onClick: () => onViewDocument?.(contextMenu.nodeId!), + }); + if (focusedDocumentId === contextMenu.nodeId) { + items.push({ + label: 'Unfocus', + icon: EyeOff, + onClick: () => onFocusChange?.(null), + }); + } else { + items.push({ + label: 'Focus on document', + icon: Crosshair, + onClick: () => onFocusChange?.(contextMenu.nodeId!), + }); + } + } else if (type === 'query-concept' || type === 'extended-concept') { + // Concepts — the only menu entry that makes sense right now is + // unfocus when something is focused. A "Focus on concept" item + // would need a concept-level dim model we don't have yet. + if (focusedDocumentId) { + items.push({ + label: 'Unfocus', + icon: EyeOff, + onClick: () => onFocusChange?.(null), + }); + } + } else { + // Background — only opens when something is focused (see + // handleBackgroundContextMenu). + items.push({ + label: 'Unfocus', + icon: Eye, + onClick: () => onFocusChange?.(null), + }); + } + return items; + }, [contextMenu, nodeType, focusedDocumentId, onFocusChange, onViewDocument]); + const handleReheat = useCallback(() => { simHandleRef.current?.reheat(); // The engine doesn't yet expose a settle-end callback, so the spinner @@ -325,6 +431,7 @@ export const DocumentExplorer: React.FC< return (
{/* Canvas keys on projection so the camera dispatch in Scene gets a fresh r3f tree (perspective vs orthographic can't be swapped @@ -349,8 +456,8 @@ export const DocumentExplorer: React.FC< nodeClasses={nodeClasses} geometryByClass={geometryByClass} nodeScales={nodeScales} - activeIds={activeIds} - dimAlpha={FOCUS_DIM_ALPHA} + activeIds={dimState?.activeIds} + dimAlpha={dimState?.dimAlpha ?? 1} showArrows={false} showEdgeLabels={false} showNodeLabels={settings?.visual?.showLabels !== false} @@ -363,6 +470,7 @@ export const DocumentExplorer: React.FC< hoveredId={hoveredId} onSelect={handleSelect} onHover={handleHover} + onContextMenu={handleNodeContextMenu} activeNodeInfos={activeNodeInfos} onDismissNodeInfo={handleDismissNodeInfo} simHandleRef={simHandleRef} @@ -408,6 +516,15 @@ export const DocumentExplorer: React.FC<

+ {/* Right-click context menu */} + {contextMenu && contextMenuItems.length > 0 && ( + setContextMenu(null)} + /> + )}
); }; From 41e29ff5a02a4edec60f13ef575b9cf5287bb565 Mon Sep 17 00:00:00 2001 From: Aaron Bockelie Date: Fri, 15 May 2026 17:54:46 -0500 Subject: [PATCH 09/18] fix(web): harmonize hover/focus dim across engine consumers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document Explorer's hover dim read much harsher than Force Graph's. Two causes, both fixed: 1. Double-dim on labels: Document Explorer pre-baked dimAlpha into labelColors AND NodeLabels also drops out-of-set label opacity to DIM_LABEL_OPACITY. Stop baking dim into labelColors — labels now dim via the engine's single activeIds/opacity path, same as Force Graph. 2. Per-file magic numbers: hover alpha was 0.2 in Force Graph but 0.25 in Document Explorer. Hoisted HOVER_DIM_ALPHA / FOCUS_DIM_ALPHA / DIM_LABEL_OPACITY into a shared scene/dimModel.ts so every engine consumer recedes by the same amount. Single seam if these become user-configurable later. Typecheck clean, 10 web tests pass. --- .../DocumentExplorer/DocumentExplorer.tsx | 21 ++++++------ web/src/explorers/ForceGraph/ForceGraph.tsx | 10 +++--- .../explorers/ForceGraph/scene/NodeLabels.tsx | 4 +-- .../explorers/ForceGraph/scene/dimModel.ts | 32 +++++++++++++++++++ 4 files changed, 48 insertions(+), 19 deletions(-) create mode 100644 web/src/explorers/ForceGraph/scene/dimModel.ts diff --git a/web/src/explorers/DocumentExplorer/DocumentExplorer.tsx b/web/src/explorers/DocumentExplorer/DocumentExplorer.tsx index 0f8cfa37b..99327f49b 100644 --- a/web/src/explorers/DocumentExplorer/DocumentExplorer.tsx +++ b/web/src/explorers/DocumentExplorer/DocumentExplorer.tsx @@ -29,6 +29,7 @@ import type { import { useThemeStore } from '../../store/themeStore'; import { StatsPanel, PanelStack } from '../common'; import { Scene } from '../ForceGraph/scene/Scene'; +import { HOVER_DIM_ALPHA, FOCUS_DIM_ALPHA } from '../ForceGraph/scene/dimModel'; import type { EngineNode, EngineEdge } from '../ForceGraph/types'; import type { ForceSimHandle } from '../ForceGraph/scene/useForceSim'; import type { NodeInfoData } from '../ForceGraph/scene/NodeInfoOverlay'; @@ -190,9 +191,6 @@ export const DocumentExplorer: React.FC< // wins — same convention as Force Graph. // --------------------------------------------------------------------------- - const FOCUS_DIM_ALPHA = 0.05; - const HOVER_DIM_ALPHA = 0.25; - // Concept set for the currently-focused document (document plus all // its concepts). Only documents can be focused; concepts use hover // for inspection. @@ -241,16 +239,15 @@ export const DocumentExplorer: React.FC< // Labels render in a colour distinct from the node mesh so text isn't // masked by the disc next to it. White-ish reads against any node // colour (the engine paints a dark stroke under the text). Dim is - // baked in here too. + // intentionally NOT baked here — the engine dims out-of-set labels via + // opacity on its single activeIds path (same as Force Graph). Baking + // the alpha in too would double-dim and is what made Document Explorer + // read harsher than Force Graph on hover. const LABEL_COLOR = '#e5e7eb'; - const labelColors = useMemo(() => { - const tmp = new THREE.Color(); - return engineData.nodes.map((n) => { - if (!dimState || dimState.activeIds.has(n.id)) return LABEL_COLOR; - tmp.set(LABEL_COLOR).multiplyScalar(dimState.dimAlpha); - return `rgb(${Math.round(tmp.r * 255)},${Math.round(tmp.g * 255)},${Math.round(tmp.b * 255)})`; - }); - }, [engineData, dimState]); + const labelColors = useMemo( + () => engineData.nodes.map(() => LABEL_COLOR), + [engineData], + ); // Per-node base scale. Documents use a large constant (documentSize // setting is in pixel-space in the original; here it controls relative diff --git a/web/src/explorers/ForceGraph/ForceGraph.tsx b/web/src/explorers/ForceGraph/ForceGraph.tsx index fde344281..83b8c17d7 100644 --- a/web/src/explorers/ForceGraph/ForceGraph.tsx +++ b/web/src/explorers/ForceGraph/ForceGraph.tsx @@ -22,6 +22,7 @@ import type { ForceGraphData, ForceGraphSettings } from './types'; import { Scene } from './scene/Scene'; import type { NodeInfoData } from './scene/NodeInfoOverlay'; import { simBackend } from './scene/useSim'; +import { HOVER_DIM_ALPHA, FOCUS_DIM_ALPHA } from './scene/dimModel'; import type { ForceSimHandle } from './scene/useForceSim'; import { createOntologyColorScale } from '../../utils/colorScale'; import { useVocabularyStore } from '../../store/vocabularyStore'; @@ -276,9 +277,10 @@ export const ForceGraph: React.FC< }, [selectedId, filteredData, settings?.interaction?.highlightNeighbors]); // Active set + dim alpha for hover/focus dimming. Focus (right-click - // "Focus on node") wins over hover and dims more aggressively (0.05 vs. - // 0.2). When neither is active the set is undefined and the engine - // renders at full opacity for everything. + // "Focus on node") wins over hover and dims more aggressively. Alphas + // come from the shared dim model so every engine consumer recedes by + // the same amount. When neither is active the set is undefined and the + // engine renders at full opacity for everything. const dimState = useMemo<{ activeIds: Set; dimAlpha: number } | undefined>(() => { const driverId = focusedNode ?? hoveredId; if (!driverId) return undefined; @@ -287,7 +289,7 @@ export const ForceGraph: React.FC< if (e.from === driverId) active.add(e.to); else if (e.to === driverId) active.add(e.from); } - return { activeIds: active, dimAlpha: focusedNode ? 0.05 : 0.2 }; + return { activeIds: active, dimAlpha: focusedNode ? FOCUS_DIM_ALPHA : HOVER_DIM_ALPHA }; }, [focusedNode, hoveredId, filteredData]); // Bake the dim into the per-node color array so Nodes (and endpoint- diff --git a/web/src/explorers/ForceGraph/scene/NodeLabels.tsx b/web/src/explorers/ForceGraph/scene/NodeLabels.tsx index b47e26d49..f8c72f310 100644 --- a/web/src/explorers/ForceGraph/scene/NodeLabels.tsx +++ b/web/src/explorers/ForceGraph/scene/NodeLabels.tsx @@ -17,6 +17,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { useFrame, useThree } from '@react-three/fiber'; import * as THREE from 'three'; import type { EngineNode, Projection } from '../types'; +import { DIM_LABEL_OPACITY } from './dimModel'; /** Throttle for re-scanning which nodes qualify; ~5 Hz is imperceptible. */ const RESCAN_MS = 200; @@ -109,9 +110,6 @@ export interface NodeLabelsProps { nodeSize?: number; } -/** Dim opacity applied to labels for nodes outside activeIds. */ -const DIM_LABEL_OPACITY = 0.15; - /** Persistent billboarded node labels with distance culling. @verified e05014ea */ export function NodeLabels({ nodes, diff --git a/web/src/explorers/ForceGraph/scene/dimModel.ts b/web/src/explorers/ForceGraph/scene/dimModel.ts new file mode 100644 index 000000000..9f19f5c23 --- /dev/null +++ b/web/src/explorers/ForceGraph/scene/dimModel.ts @@ -0,0 +1,32 @@ +/** + * Shared dim model — one source of truth for hover/focus dimming so + * every explorer that consumes the engine recedes by the same amount. + * + * Two tiers, matching the interaction model both Force Graph and + * Document Explorer expose: + * + * - HOVER — transient, subtle. Pointer is over a node; its local + * topology stays lit while the rest steps back enough to + * read the neighborhood but not so far it reads as a mode + * change. + * - FOCUS — persistent, aggressive. Set via right-click "Focus on + * node/document". Wins over hover when both are active. + * + * `*_DIM_ALPHA` multiplies node mesh colors (consumers bake it into the + * per-node color array). `DIM_LABEL_OPACITY` is the label-plane opacity + * for out-of-set nodes — labels dim via opacity, not color, so the + * engine owns it on the single `activeIds` path. Consumers should NOT + * pre-bake the alpha into label colors as well; that double-dims and is + * what made Document Explorer look harsher than Force Graph. + * + * Fixed for now. If these become user-configurable, this is the seam. + */ + +/** Node-color multiplier for out-of-set nodes under transient hover. */ +export const HOVER_DIM_ALPHA = 0.2; + +/** Node-color multiplier for out-of-set nodes under persistent focus. */ +export const FOCUS_DIM_ALPHA = 0.05; + +/** Label-plane opacity for out-of-set nodes (engine-applied, single path). */ +export const DIM_LABEL_OPACITY = 0.15; From d58094b1b141e744bb6bcbc80538feec3c3f5b5d Mon Sep 17 00:00:00 2001 From: Aaron Bockelie Date: Fri, 15 May 2026 18:02:37 -0500 Subject: [PATCH 10/18] fix(web): soften hover dim to ~10% fade across both explorers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hover-dim at 0.2 still read as a mode change. Drop the fade to ~10% (HOVER_DIM_ALPHA 0.2 -> 0.9): non-active nodes step back subtly while the hovered neighborhood stays the visual focus. One constant in the shared dim model — lands in Force Graph and Document Explorer together. Focus tier (right-click) unchanged at 0.05. --- web/src/explorers/ForceGraph/scene/dimModel.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/web/src/explorers/ForceGraph/scene/dimModel.ts b/web/src/explorers/ForceGraph/scene/dimModel.ts index 9f19f5c23..7beae7e63 100644 --- a/web/src/explorers/ForceGraph/scene/dimModel.ts +++ b/web/src/explorers/ForceGraph/scene/dimModel.ts @@ -22,8 +22,10 @@ * Fixed for now. If these become user-configurable, this is the seam. */ -/** Node-color multiplier for out-of-set nodes under transient hover. */ -export const HOVER_DIM_ALPHA = 0.2; +/** Node-color multiplier for out-of-set nodes under transient hover. + * Deliberately subtle — hover only steps the rest back ~10% so the + * local topology reads without the view feeling like it changed mode. */ +export const HOVER_DIM_ALPHA = 0.9; /** Node-color multiplier for out-of-set nodes under persistent focus. */ export const FOCUS_DIM_ALPHA = 0.05; From a771d21dc5ade500d5adf1c8443b0121245bd65d Mon Sep 17 00:00:00 2001 From: Aaron Bockelie Date: Fri, 15 May 2026 18:11:31 -0500 Subject: [PATCH 11/18] refactor(web): dim model as a tier-keyed variant map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace three independent constants (HOVER_DIM_ALPHA, FOCUS_DIM_ALPHA, DIM_LABEL_OPACITY) with DIM_MODEL: Record where each tier is ONE node carrying every property it affects ({nodeAlpha, labelOpacity}). A tier's dot and its label can no longer drift apart — tune one entry, both move. This is what dissolves the hover-vs-focus label asymmetry structurally instead of by a second hand-tuned knob. Engine stays primitive: Scene/NodeLabels/EdgeLabels gain a dimLabelOpacity number prop (default 1 — a caller that wires activeIds but forgets it fails visibly, not silently on an old magic number). DIM_MODEL lives only in the consumers; a future consumer with a different interaction vocabulary isn't forced to adopt ours. Net simplification: the duplicated 'focused ? FOCUS : HOVER' ternary in both explorers collapses to a tier lookup; NodeLabels/EdgeLabels no longer carry an opaque module constant; EdgeLabels' separate 0.15 is gone (folded into the same pass so node + edge labels dim together). Typecheck clean, 10 web tests pass. --- .../DocumentExplorer/DocumentExplorer.tsx | 13 ++--- web/src/explorers/ForceGraph/ForceGraph.tsx | 11 +++-- .../explorers/ForceGraph/scene/EdgeLabels.tsx | 12 +++-- .../explorers/ForceGraph/scene/NodeLabels.tsx | 11 +++-- web/src/explorers/ForceGraph/scene/Scene.tsx | 8 ++++ .../explorers/ForceGraph/scene/dimModel.ts | 48 +++++++++++-------- 6 files changed, 65 insertions(+), 38 deletions(-) diff --git a/web/src/explorers/DocumentExplorer/DocumentExplorer.tsx b/web/src/explorers/DocumentExplorer/DocumentExplorer.tsx index 99327f49b..887f906bb 100644 --- a/web/src/explorers/DocumentExplorer/DocumentExplorer.tsx +++ b/web/src/explorers/DocumentExplorer/DocumentExplorer.tsx @@ -29,7 +29,7 @@ import type { import { useThemeStore } from '../../store/themeStore'; import { StatsPanel, PanelStack } from '../common'; import { Scene } from '../ForceGraph/scene/Scene'; -import { HOVER_DIM_ALPHA, FOCUS_DIM_ALPHA } from '../ForceGraph/scene/dimModel'; +import { DIM_MODEL, type DimSpec } from '../ForceGraph/scene/dimModel'; import type { EngineNode, EngineEdge } from '../ForceGraph/types'; import type { ForceSimHandle } from '../ForceGraph/scene/useForceSim'; import type { NodeInfoData } from '../ForceGraph/scene/NodeInfoOverlay'; @@ -215,9 +215,9 @@ export const DocumentExplorer: React.FC< return set; }, [hoveredId, engineData]); - const dimState = useMemo<{ activeIds: Set; dimAlpha: number } | undefined>(() => { - if (focusActiveIds) return { activeIds: focusActiveIds, dimAlpha: FOCUS_DIM_ALPHA }; - if (hoverActiveIds) return { activeIds: hoverActiveIds, dimAlpha: HOVER_DIM_ALPHA }; + const dimState = useMemo<{ activeIds: Set; spec: DimSpec } | undefined>(() => { + if (focusActiveIds) return { activeIds: focusActiveIds, spec: DIM_MODEL.focus }; + if (hoverActiveIds) return { activeIds: hoverActiveIds, spec: DIM_MODEL.hover }; return undefined; }, [focusActiveIds, hoverActiveIds]); @@ -231,7 +231,7 @@ export const DocumentExplorer: React.FC< const type = nodeType(n.id) ?? 'extended-concept'; const base = COLORS[type]; if (!dimState || dimState.activeIds.has(n.id)) return base; - tmp.set(base).multiplyScalar(dimState.dimAlpha); + tmp.set(base).multiplyScalar(dimState.spec.nodeAlpha); return `rgb(${Math.round(tmp.r * 255)},${Math.round(tmp.g * 255)},${Math.round(tmp.b * 255)})`; }); }, [engineData, nodeType, dimState]); @@ -454,7 +454,8 @@ export const DocumentExplorer: React.FC< geometryByClass={geometryByClass} nodeScales={nodeScales} activeIds={dimState?.activeIds} - dimAlpha={dimState?.dimAlpha ?? 1} + dimAlpha={dimState?.spec.nodeAlpha ?? 1} + dimLabelOpacity={dimState?.spec.labelOpacity ?? 1} showArrows={false} showEdgeLabels={false} showNodeLabels={settings?.visual?.showLabels !== false} diff --git a/web/src/explorers/ForceGraph/ForceGraph.tsx b/web/src/explorers/ForceGraph/ForceGraph.tsx index 83b8c17d7..3a2b95c17 100644 --- a/web/src/explorers/ForceGraph/ForceGraph.tsx +++ b/web/src/explorers/ForceGraph/ForceGraph.tsx @@ -22,7 +22,7 @@ import type { ForceGraphData, ForceGraphSettings } from './types'; import { Scene } from './scene/Scene'; import type { NodeInfoData } from './scene/NodeInfoOverlay'; import { simBackend } from './scene/useSim'; -import { HOVER_DIM_ALPHA, FOCUS_DIM_ALPHA } from './scene/dimModel'; +import { DIM_MODEL, type DimSpec } from './scene/dimModel'; import type { ForceSimHandle } from './scene/useForceSim'; import { createOntologyColorScale } from '../../utils/colorScale'; import { useVocabularyStore } from '../../store/vocabularyStore'; @@ -281,7 +281,7 @@ export const ForceGraph: React.FC< // come from the shared dim model so every engine consumer recedes by // the same amount. When neither is active the set is undefined and the // engine renders at full opacity for everything. - const dimState = useMemo<{ activeIds: Set; dimAlpha: number } | undefined>(() => { + const dimState = useMemo<{ activeIds: Set; spec: DimSpec } | undefined>(() => { const driverId = focusedNode ?? hoveredId; if (!driverId) return undefined; const active = new Set([driverId]); @@ -289,7 +289,7 @@ export const ForceGraph: React.FC< if (e.from === driverId) active.add(e.to); else if (e.to === driverId) active.add(e.from); } - return { activeIds: active, dimAlpha: focusedNode ? FOCUS_DIM_ALPHA : HOVER_DIM_ALPHA }; + return { activeIds: active, spec: focusedNode ? DIM_MODEL.focus : DIM_MODEL.hover }; }, [focusedNode, hoveredId, filteredData]); // Bake the dim into the per-node color array so Nodes (and endpoint- @@ -302,7 +302,7 @@ export const ForceGraph: React.FC< return baseNodeColors.map((c, i) => { const id = filteredData?.nodes?.[i]?.id; if (id && dimState.activeIds.has(id)) return c; - tmp.set(c).multiplyScalar(dimState.dimAlpha); + tmp.set(c).multiplyScalar(dimState.spec.nodeAlpha); return `#${tmp.getHexString()}`; }); }, [baseNodeColors, dimState, filteredData]); @@ -452,7 +452,8 @@ export const ForceGraph: React.FC< pinnedIds={pinnedIds} highlightedIds={highlightedIds} activeIds={dimState?.activeIds} - dimAlpha={dimState?.dimAlpha ?? 1} + dimAlpha={dimState?.spec.nodeAlpha ?? 1} + dimLabelOpacity={dimState?.spec.labelOpacity ?? 1} enableDrag={settings?.interaction?.enableDrag ?? true} enableZoom={settings?.interaction?.enableZoom ?? true} enablePan={settings?.interaction?.enablePan ?? true} diff --git a/web/src/explorers/ForceGraph/scene/EdgeLabels.tsx b/web/src/explorers/ForceGraph/scene/EdgeLabels.tsx index 396bda297..3d4f374fd 100644 --- a/web/src/explorers/ForceGraph/scene/EdgeLabels.tsx +++ b/web/src/explorers/ForceGraph/scene/EdgeLabels.tsx @@ -86,6 +86,10 @@ export interface EdgeLabelsProps { projection?: Projection; /** Multiplier on the base label world-space height. Default 1. */ sizeMultiplier?: number; + /** Plane opacity for labels whose endpoints aren't both in `activeIds`. + * Resolved from the active dim tier by the consumer (see dimModel). + * Default 1 (no dim). */ + dimLabelOpacity?: number; } interface EdgeMeta { @@ -100,9 +104,6 @@ interface EdgeMeta { } /** 3D plane-mesh edge labels with camera-facing roll. @verified e05014ea */ -/** Dim opacity applied to labels whose endpoints aren't in activeIds. */ -const DIM_LABEL_OPACITY = 0.15; - export function EdgeLabels({ nodes, edges, @@ -114,6 +115,7 @@ export function EdgeLabels({ activeIds, projection = '3D', sizeMultiplier = 1, + dimLabelOpacity = 1, }: EdgeLabelsProps) { const camera = useThree((state) => state.camera); @@ -197,7 +199,7 @@ export function EdgeLabels({ (!activeIds!.has(nodes[meta.si].id) || !activeIds!.has(nodes[meta.ti].id)); if (mat) { mat.map = entry.texture; - mat.opacity = dimmed ? DIM_LABEL_OPACITY : 1; + mat.opacity = dimmed ? dimLabelOpacity : 1; mat.needsUpdate = true; } if (mesh) { @@ -205,7 +207,7 @@ export function EdgeLabels({ mesh.scale.set(entry.aspect * h, h, 1); } } - }, [visibleIndices, edgeMeta, edgeColors, enabled, activeIds, nodes, sizeMultiplier]); + }, [visibleIndices, edgeMeta, edgeColors, enabled, activeIds, nodes, sizeMultiplier, dimLabelOpacity]); // Scratch vectors reused across the frame loop. const scratch = useMemo( diff --git a/web/src/explorers/ForceGraph/scene/NodeLabels.tsx b/web/src/explorers/ForceGraph/scene/NodeLabels.tsx index f8c72f310..3f626b7ea 100644 --- a/web/src/explorers/ForceGraph/scene/NodeLabels.tsx +++ b/web/src/explorers/ForceGraph/scene/NodeLabels.tsx @@ -17,7 +17,6 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { useFrame, useThree } from '@react-three/fiber'; import * as THREE from 'three'; import type { EngineNode, Projection } from '../types'; -import { DIM_LABEL_OPACITY } from './dimModel'; /** Throttle for re-scanning which nodes qualify; ~5 Hz is imperceptible. */ const RESCAN_MS = 200; @@ -108,6 +107,11 @@ export interface NodeLabelsProps { /** Global node-size multiplier — same prop `` consumes. Used * alongside `nodeScales` when computing scale-aware label offsets. */ nodeSize?: number; + /** Plane opacity for labels of nodes outside `activeIds`. Resolved + * from the active dim tier by the consumer (see dimModel). Default 1 + * (no dim) — a caller that wires `activeIds` but forgets this should + * fail visibly, not silently land on an old magic number. */ + dimLabelOpacity?: number; } /** Persistent billboarded node labels with distance culling. @verified e05014ea */ @@ -125,6 +129,7 @@ export function NodeLabels({ labelOffsetY = DEFAULT_LABEL_OFFSET_Y, nodeScales, nodeSize = 1, + dimLabelOpacity = 1, }: NodeLabelsProps) { const camera = useThree((state) => state.camera); @@ -177,7 +182,7 @@ export function NodeLabels({ const dimmed = hasActive && !activeIds!.has(node.id); if (mat) { mat.map = entry.texture; - mat.opacity = dimmed ? DIM_LABEL_OPACITY : 1; + mat.opacity = dimmed ? dimLabelOpacity : 1; mat.needsUpdate = true; } if (mesh) { @@ -185,7 +190,7 @@ export function NodeLabels({ mesh.scale.set(entry.aspect * h, h, 1); } } - }, [visibleIndices, nodes, colors, labelColors, enabled, activeIds, sizeMultiplier]); + }, [visibleIndices, nodes, colors, labelColors, enabled, activeIds, sizeMultiplier, dimLabelOpacity]); const scratch = useMemo( () => ({ diff --git a/web/src/explorers/ForceGraph/scene/Scene.tsx b/web/src/explorers/ForceGraph/scene/Scene.tsx index 6f2196a35..916b97a9f 100644 --- a/web/src/explorers/ForceGraph/scene/Scene.tsx +++ b/web/src/explorers/ForceGraph/scene/Scene.tsx @@ -60,6 +60,11 @@ export interface SceneProps { /** Dim multiplier applied to non-active edges/arrows in edge-type mode. * Node colors arrive pre-dimmed via the `colors` prop. */ dimAlpha?: number; + /** Plane opacity for out-of-set node/edge labels. Resolved from the + * active dim tier by the consumer (see dimModel). Default 1 (no dim). + * Pairs with `dimAlpha`: alpha dims the figures, this dims their + * text, and the dim model keeps the two coupled per tier. */ + dimLabelOpacity?: number; nodeSize?: number; edgeOpacity?: number; linkWidth?: number; @@ -111,6 +116,7 @@ export function Scene({ highlightedIds, activeIds, dimAlpha = 1, + dimLabelOpacity = 1, nodeSize, edgeOpacity, linkWidth, @@ -233,6 +239,7 @@ export function Scene({ activeIds={activeIds} projection={projection} sizeMultiplier={edgeLabelSize} + dimLabelOpacity={dimLabelOpacity} /> {activeNodeInfos.map((info) => ( = { + hover: { nodeAlpha: 0.9, labelOpacity: 0.9 }, + focus: { nodeAlpha: 0.05, labelOpacity: 0.15 }, +}; From 9fc9fbe214bc08bc0396fbd300c3f544b627c9fa Mon Sep 17 00:00:00 2001 From: Aaron Bockelie Date: Fri, 15 May 2026 19:12:04 -0500 Subject: [PATCH 12/18] =?UTF-8?q?refactor(web):=20one=20dim=20value=20per?= =?UTF-8?q?=20tier=20=E2=80=94=20nodes,=20edges,=20labels=20recede=20toget?= =?UTF-8?q?her?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The {nodeAlpha, labelOpacity} split let dots and labels drift to different strengths (focus was 0.05 vs 0.15). Collapse DimSpec to a single number per tier: DIM_MODEL: Record = { hover: 0.9, focus: 0.1 }. One value drives the node/edge color multiply and the node/edge label opacity alike. On the dark scene background, color-multiply toward black and opacity toward background are perceptually the same, so a single alpha reads as one consistent recede across nodes, edges, and both label kinds. focus is 0.1 (not 0.05) so focused-out label text stays barely legible while the active set still pops hard. If a tier ever needs to be gentler because labels vanish before dots, raise the one number — that softens everything together by design. Don't re-split the fields. DimTier (the tiers themselves) stays. Typecheck clean, 10 web tests pass. --- .../DocumentExplorer/DocumentExplorer.tsx | 14 ++++---- web/src/explorers/ForceGraph/ForceGraph.tsx | 12 +++---- .../explorers/ForceGraph/scene/dimModel.ts | 35 +++++++++---------- 3 files changed, 30 insertions(+), 31 deletions(-) diff --git a/web/src/explorers/DocumentExplorer/DocumentExplorer.tsx b/web/src/explorers/DocumentExplorer/DocumentExplorer.tsx index 887f906bb..ea5ec8a7c 100644 --- a/web/src/explorers/DocumentExplorer/DocumentExplorer.tsx +++ b/web/src/explorers/DocumentExplorer/DocumentExplorer.tsx @@ -29,7 +29,7 @@ import type { import { useThemeStore } from '../../store/themeStore'; import { StatsPanel, PanelStack } from '../common'; import { Scene } from '../ForceGraph/scene/Scene'; -import { DIM_MODEL, type DimSpec } from '../ForceGraph/scene/dimModel'; +import { DIM_MODEL } from '../ForceGraph/scene/dimModel'; import type { EngineNode, EngineEdge } from '../ForceGraph/types'; import type { ForceSimHandle } from '../ForceGraph/scene/useForceSim'; import type { NodeInfoData } from '../ForceGraph/scene/NodeInfoOverlay'; @@ -215,9 +215,9 @@ export const DocumentExplorer: React.FC< return set; }, [hoveredId, engineData]); - const dimState = useMemo<{ activeIds: Set; spec: DimSpec } | undefined>(() => { - if (focusActiveIds) return { activeIds: focusActiveIds, spec: DIM_MODEL.focus }; - if (hoverActiveIds) return { activeIds: hoverActiveIds, spec: DIM_MODEL.hover }; + const dimState = useMemo<{ activeIds: Set; alpha: number } | undefined>(() => { + if (focusActiveIds) return { activeIds: focusActiveIds, alpha: DIM_MODEL.focus }; + if (hoverActiveIds) return { activeIds: hoverActiveIds, alpha: DIM_MODEL.hover }; return undefined; }, [focusActiveIds, hoverActiveIds]); @@ -231,7 +231,7 @@ export const DocumentExplorer: React.FC< const type = nodeType(n.id) ?? 'extended-concept'; const base = COLORS[type]; if (!dimState || dimState.activeIds.has(n.id)) return base; - tmp.set(base).multiplyScalar(dimState.spec.nodeAlpha); + tmp.set(base).multiplyScalar(dimState.alpha); return `rgb(${Math.round(tmp.r * 255)},${Math.round(tmp.g * 255)},${Math.round(tmp.b * 255)})`; }); }, [engineData, nodeType, dimState]); @@ -454,8 +454,8 @@ export const DocumentExplorer: React.FC< geometryByClass={geometryByClass} nodeScales={nodeScales} activeIds={dimState?.activeIds} - dimAlpha={dimState?.spec.nodeAlpha ?? 1} - dimLabelOpacity={dimState?.spec.labelOpacity ?? 1} + dimAlpha={dimState?.alpha ?? 1} + dimLabelOpacity={dimState?.alpha ?? 1} showArrows={false} showEdgeLabels={false} showNodeLabels={settings?.visual?.showLabels !== false} diff --git a/web/src/explorers/ForceGraph/ForceGraph.tsx b/web/src/explorers/ForceGraph/ForceGraph.tsx index 3a2b95c17..78ed403de 100644 --- a/web/src/explorers/ForceGraph/ForceGraph.tsx +++ b/web/src/explorers/ForceGraph/ForceGraph.tsx @@ -22,7 +22,7 @@ import type { ForceGraphData, ForceGraphSettings } from './types'; import { Scene } from './scene/Scene'; import type { NodeInfoData } from './scene/NodeInfoOverlay'; import { simBackend } from './scene/useSim'; -import { DIM_MODEL, type DimSpec } from './scene/dimModel'; +import { DIM_MODEL } from './scene/dimModel'; import type { ForceSimHandle } from './scene/useForceSim'; import { createOntologyColorScale } from '../../utils/colorScale'; import { useVocabularyStore } from '../../store/vocabularyStore'; @@ -281,7 +281,7 @@ export const ForceGraph: React.FC< // come from the shared dim model so every engine consumer recedes by // the same amount. When neither is active the set is undefined and the // engine renders at full opacity for everything. - const dimState = useMemo<{ activeIds: Set; spec: DimSpec } | undefined>(() => { + const dimState = useMemo<{ activeIds: Set; alpha: number } | undefined>(() => { const driverId = focusedNode ?? hoveredId; if (!driverId) return undefined; const active = new Set([driverId]); @@ -289,7 +289,7 @@ export const ForceGraph: React.FC< if (e.from === driverId) active.add(e.to); else if (e.to === driverId) active.add(e.from); } - return { activeIds: active, spec: focusedNode ? DIM_MODEL.focus : DIM_MODEL.hover }; + return { activeIds: active, alpha: focusedNode ? DIM_MODEL.focus : DIM_MODEL.hover }; }, [focusedNode, hoveredId, filteredData]); // Bake the dim into the per-node color array so Nodes (and endpoint- @@ -302,7 +302,7 @@ export const ForceGraph: React.FC< return baseNodeColors.map((c, i) => { const id = filteredData?.nodes?.[i]?.id; if (id && dimState.activeIds.has(id)) return c; - tmp.set(c).multiplyScalar(dimState.spec.nodeAlpha); + tmp.set(c).multiplyScalar(dimState.alpha); return `#${tmp.getHexString()}`; }); }, [baseNodeColors, dimState, filteredData]); @@ -452,8 +452,8 @@ export const ForceGraph: React.FC< pinnedIds={pinnedIds} highlightedIds={highlightedIds} activeIds={dimState?.activeIds} - dimAlpha={dimState?.spec.nodeAlpha ?? 1} - dimLabelOpacity={dimState?.spec.labelOpacity ?? 1} + dimAlpha={dimState?.alpha ?? 1} + dimLabelOpacity={dimState?.alpha ?? 1} enableDrag={settings?.interaction?.enableDrag ?? true} enableZoom={settings?.interaction?.enableZoom ?? true} enablePan={settings?.interaction?.enablePan ?? true} diff --git a/web/src/explorers/ForceGraph/scene/dimModel.ts b/web/src/explorers/ForceGraph/scene/dimModel.ts index 9407a36c9..0621e1c64 100644 --- a/web/src/explorers/ForceGraph/scene/dimModel.ts +++ b/web/src/explorers/ForceGraph/scene/dimModel.ts @@ -12,15 +12,20 @@ * - focus — persistent, aggressive. Set via right-click "Focus on * node/document". Wins over hover when both are active. * - * Each tier is ONE node carrying every property it affects, so the - * node mesh and its label can never drift apart: tune a tier in one - * place and the dot and its text move together. (The previous flat - * constants let label opacity and node alpha be set independently — - * that's what made hover read harsher on labels than on dots.) + * One number per tier, applied uniformly to nodes, edges, AND their + * labels. Nodes/edges dim by color-multiply, labels by plane opacity — + * but on the dark scene background color-multiply toward black and + * opacity toward background are perceptually the same, so a single + * value reads as one consistent recede across all four element kinds. + * + * Deliberately NOT split per element kind: a per-kind knob is what let + * labels and dots drift to different strengths. If a tier needs to be + * gentler because labels vanish before dots do, raise the one number — + * that softens everything together, which is the point. * * The engine itself stays primitive — Scene/Nodes/Edges/labels take * plain numbers. This map is a consumer-side convenience; explorers - * resolve a tier to its spec and feed the numbers in. A future + * resolve a tier to its alpha and feed the number in. A future * consumer with a different interaction vocabulary isn't forced to * adopt ours. * @@ -29,16 +34,10 @@ export type DimTier = 'hover' | 'focus'; -export interface DimSpec { - /** Color multiplier for out-of-set node meshes, edges, and arrows - * (edges visually couple to their endpoints, so they share the - * node figure rather than carrying a separate alpha). */ - nodeAlpha: number; - /** Plane opacity for out-of-set node and edge labels. */ - labelOpacity: number; -} - -export const DIM_MODEL: Record = { - hover: { nodeAlpha: 0.9, labelOpacity: 0.9 }, - focus: { nodeAlpha: 0.05, labelOpacity: 0.15 }, +/** Out-of-set alpha per tier: color multiplier for node meshes / edges / + * arrows, and plane opacity for node / edge labels. One value drives + * all of them so the dim reads consistently. */ +export const DIM_MODEL: Record = { + hover: 0.9, + focus: 0.1, }; From 5d5f71e0b296b12a60d72ae78a66c51b7fa4eb33 Mon Sep 17 00:00:00 2001 From: Aaron Bockelie Date: Fri, 15 May 2026 19:15:39 -0500 Subject: [PATCH 13/18] fix(web): Document Explorer hover follows visible edges only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hoverActiveIds traversed engineData.edges, which carries the invisible document->concept clustering scaffold (kept for the force sim, collapsed in render) alongside visible relationship edges. Hovering any node lit up its whole clustering hairball — nearly the entire graph — so the shared 0.9 hover dim landed on almost nothing and Document Explorer looked un-dimmed next to Force Graph. Walk only visible edges (engineData.edgeVisible[i]) so the lit neighborhood matches what the user sees connected. Same dim model, same values as Force Graph — now the same visible behaviour. Also return edgeVisible:[] from the no-data branch for a uniform engineData type. Typecheck clean, 10 web tests pass. --- .../DocumentExplorer/DocumentExplorer.tsx | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/web/src/explorers/DocumentExplorer/DocumentExplorer.tsx b/web/src/explorers/DocumentExplorer/DocumentExplorer.tsx index ea5ec8a7c..29b05d623 100644 --- a/web/src/explorers/DocumentExplorer/DocumentExplorer.tsx +++ b/web/src/explorers/DocumentExplorer/DocumentExplorer.tsx @@ -106,7 +106,7 @@ export const DocumentExplorer: React.FC< // --------------------------------------------------------------------------- const engineData = useMemo(() => { - if (!data) return { nodes: [] as EngineNode[], edges: [] as EngineEdge[] }; + if (!data) return { nodes: [] as EngineNode[], edges: [] as EngineEdge[], edgeVisible: [] as boolean[] }; // Pre-compute degree from visible edges so concept nodes scale subtly // by connectivity. Document nodes use a constant scale set below. @@ -203,12 +203,20 @@ export const DocumentExplorer: React.FC< return set; }, [focusedDocumentId, data]); - // Hovered node + its immediate edge neighbors. Matches Force Graph's - // hover-dim behaviour so muscle memory carries. + // Hovered node + its immediate neighbors over the *visible* edges only. + // engineData.edges also carries the invisible document→concept + // clustering scaffold (kept for the force sim, collapsed in render). + // Traversing that would light up a node's entire clustering hairball + // — nearly the whole graph — so the dim would land on almost nothing. + // Following only visible edges makes the lit neighborhood match what + // the user can actually see connected, matching Force Graph's feel. const hoverActiveIds = useMemo | null>(() => { if (!hoveredId) return null; const set = new Set([hoveredId]); - for (const e of engineData.edges) { + const { edges, edgeVisible } = engineData; + for (let i = 0; i < edges.length; i++) { + if (!edgeVisible[i]) continue; + const e = edges[i]; if (e.from === hoveredId) set.add(e.to); else if (e.to === hoveredId) set.add(e.from); } From 9415c4098d74b23a73b9cffd3a1dc78e4cbaa819 Mon Sep 17 00:00:00 2001 From: Aaron Bockelie Date: Fri, 15 May 2026 19:19:31 -0500 Subject: [PATCH 14/18] fix(web): Document Explorer neighborhood reads structural fields, not edge list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 'Dims a lot more' was a count problem, not a strength one — same DIM_MODEL, but the active set was wrong. Edge-list traversal fails at both extremes of Document Explorer's two overlaid graphs: a document's edges are all the invisible clustering scaffold (visible-only → zero neighbors → entire graph dims on document hover), and concept↔concept relationship edges are sparse by design. Compute the neighborhood from the typed structural fields instead, so membership isn't muddled by the render-time visibility flag. Asymmetric by node type: - document → document + all its concepts (the visual cluster made literal) - concept → concept + its documents (via documentIds) + visible concept relationship neighbors Factor neighborhoodOf(); focus and hover now light the SAME set and differ only in dim strength — matching Force Graph, where focus/hover also share the 1-hop neighborhood. Typecheck clean, 10 web tests pass. --- .../DocumentExplorer/DocumentExplorer.tsx | 69 ++++++++++++------- 1 file changed, 44 insertions(+), 25 deletions(-) diff --git a/web/src/explorers/DocumentExplorer/DocumentExplorer.tsx b/web/src/explorers/DocumentExplorer/DocumentExplorer.tsx index 29b05d623..bbbb5625b 100644 --- a/web/src/explorers/DocumentExplorer/DocumentExplorer.tsx +++ b/web/src/explorers/DocumentExplorer/DocumentExplorer.tsx @@ -191,37 +191,56 @@ export const DocumentExplorer: React.FC< // wins — same convention as Force Graph. // --------------------------------------------------------------------------- - // Concept set for the currently-focused document (document plus all - // its concepts). Only documents can be focused; concepts use hover - // for inspection. - const focusActiveIds = useMemo | null>(() => { - if (!focusedDocumentId || !data) return null; - const doc = data.documents.find((d) => d.id === focusedDocumentId); - if (!doc) return null; - const set = new Set(doc.conceptIds); - set.add(focusedDocumentId); - return set; - }, [focusedDocumentId, data]); - - // Hovered node + its immediate neighbors over the *visible* edges only. - // engineData.edges also carries the invisible document→concept - // clustering scaffold (kept for the force sim, collapsed in render). - // Traversing that would light up a node's entire clustering hairball - // — nearly the whole graph — so the dim would land on almost nothing. - // Following only visible edges makes the lit neighborhood match what - // the user can actually see connected, matching Force Graph's feel. - const hoverActiveIds = useMemo | null>(() => { - if (!hoveredId) return null; - const set = new Set([hoveredId]); + // Document Explorer overlays two graphs: concept↔concept relationship + // edges (visible) and document↔concept membership (the invisible + // clustering scaffold). A node's meaningful neighborhood reads BOTH, + // via the typed structural fields rather than the edge list — the + // visibility flag is a render concern and muddles membership. Edge- + // list traversal alone fails at the extremes: a document's edges are + // all invisible (visible-only → zero neighbors → whole graph dims), + // and concept↔concept links are sparse by design. + // + // Asymmetric by type: + // - document → the document and all its concepts (a document *is* + // its concepts; this is the visual cluster made literal) + // - concept → the concept, the documents it belongs to (one hop up + // the scaffold via `documentIds`), and any visible concept + // relationship neighbors + // + // Focus and hover light the SAME neighborhood; only the dim strength + // differs (focus aggressive, hover subtle) — same convention as + // Force Graph, where focus and hover also share the 1-hop set. + const neighborhoodOf = useCallback((id: string): Set => { + const set = new Set([id]); + if (!data) return set; + if (nodeType(id) === 'document') { + const doc = data.documents.find((d) => d.id === id); + if (doc) for (const cid of doc.conceptIds) set.add(cid); + return set; + } + const src = sourceById.get(id); + if (src) for (const did of src.documentIds) set.add(did); const { edges, edgeVisible } = engineData; for (let i = 0; i < edges.length; i++) { if (!edgeVisible[i]) continue; const e = edges[i]; - if (e.from === hoveredId) set.add(e.to); - else if (e.to === hoveredId) set.add(e.from); + if (e.from === id) set.add(e.to); + else if (e.to === id) set.add(e.from); } return set; - }, [hoveredId, engineData]); + }, [data, nodeType, sourceById, engineData]); + + // Only documents can be focused (via right-click → "Focus on + // document"); concepts use hover for inspection. + const focusActiveIds = useMemo | null>(() => { + if (!focusedDocumentId || !data) return null; + return neighborhoodOf(focusedDocumentId); + }, [focusedDocumentId, data, neighborhoodOf]); + + const hoverActiveIds = useMemo | null>(() => { + if (!hoveredId) return null; + return neighborhoodOf(hoveredId); + }, [hoveredId, neighborhoodOf]); const dimState = useMemo<{ activeIds: Set; alpha: number } | undefined>(() => { if (focusActiveIds) return { activeIds: focusActiveIds, alpha: DIM_MODEL.focus }; From 7844b6361268be9d91e291da3d5e98c3df06605f Mon Sep 17 00:00:00 2001 From: Aaron Bockelie Date: Fri, 15 May 2026 20:54:13 -0500 Subject: [PATCH 15/18] =?UTF-8?q?fix(web):=20hover=20dim=20to=200.6=20?= =?UTF-8?q?=E2=80=94=20clear=20but=20soft=20step-back?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0.9 read as 'barely dims at all'. Settle at 0.6 (non-active elements fade to 60%): definite focus pull toward the hovered neighborhood, rest still legible, not harsh. One number, both explorers. --- web/src/explorers/ForceGraph/scene/dimModel.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/explorers/ForceGraph/scene/dimModel.ts b/web/src/explorers/ForceGraph/scene/dimModel.ts index 0621e1c64..3bdc3c2d1 100644 --- a/web/src/explorers/ForceGraph/scene/dimModel.ts +++ b/web/src/explorers/ForceGraph/scene/dimModel.ts @@ -38,6 +38,6 @@ export type DimTier = 'hover' | 'focus'; * arrows, and plane opacity for node / edge labels. One value drives * all of them so the dim reads consistently. */ export const DIM_MODEL: Record = { - hover: 0.9, + hover: 0.6, focus: 0.1, }; From 01c01d4a51587e8b111da741e0b2c9c5ceeab871 Mon Sep 17 00:00:00 2001 From: Aaron Bockelie Date: Fri, 15 May 2026 21:05:34 -0500 Subject: [PATCH 16/18] fix(web): dim by fading toward scene background, not scaling toward black MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Screenshots showed the real divergence: identical multiplyScalar(0.6) on Force Graph's bright lime palette barely changed it, while Document Explorer's indigo/amber collapsed into the dark bg and vanished. Linear color-multiply toward black is luminance-dependent — it dims bright and dark palettes by visibly different perceived amounts. Replace with lerp toward THIS scene's background: - Force Graph -> canvasBg (explorerTheme.canvas3D, #1f1b19/#ede8e4) - Doc Explorer -> wrapper bg (Canvas is transparent over bg-gray-900/50: #111827/#f9fafb) Every colour now loses the same fraction of its contrast against the background, so the perceived recede is uniform across palettes and explorers under the one shared DIM_MODEL value. The alpha's meaning is now 'fraction of contrast retained vs background', not 'scale toward black' — same knob, palette-independent, honest semantics. Labels unchanged: opacity-toward-rendered-bg is already the equivalent of lerp-to-bg, so that path was always correct. Edge/arrow color dim (engine-side multiplyScalar) still scales toward black — separate follow-up; nodes dominate the visual and were the reported issue. Typecheck clean, 10 web tests pass. --- .../DocumentExplorer/DocumentExplorer.tsx | 12 ++++++++++-- web/src/explorers/ForceGraph/ForceGraph.tsx | 14 ++++++++++++-- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/web/src/explorers/DocumentExplorer/DocumentExplorer.tsx b/web/src/explorers/DocumentExplorer/DocumentExplorer.tsx index bbbb5625b..e68941f42 100644 --- a/web/src/explorers/DocumentExplorer/DocumentExplorer.tsx +++ b/web/src/explorers/DocumentExplorer/DocumentExplorer.tsx @@ -252,16 +252,24 @@ export const DocumentExplorer: React.FC< // Edges/Labels do), so bake the dim into the per-node color array — // the document and concept meshes themselves recede along with the // edges. Same pattern Force Graph uses. + // + // Dim = fade toward THIS scene's background (see the dim note in + // ForceGraph). The Doc Explorer Canvas is transparent over the + // wrapper's bg-gray-900/50, so that wrapper colour IS the scene + // background — fade toward it, not toward black. This is what makes + // the indigo/amber palette recede by the same *perceived* amount as + // Force Graph's lime palette under the shared DIM_MODEL value. const nodeColors = useMemo(() => { const tmp = new THREE.Color(); + const bg = new THREE.Color(theme === 'dark' ? '#111827' : '#f9fafb'); return engineData.nodes.map((n) => { const type = nodeType(n.id) ?? 'extended-concept'; const base = COLORS[type]; if (!dimState || dimState.activeIds.has(n.id)) return base; - tmp.set(base).multiplyScalar(dimState.alpha); + tmp.set(base).lerp(bg, 1 - dimState.alpha); return `rgb(${Math.round(tmp.r * 255)},${Math.round(tmp.g * 255)},${Math.round(tmp.b * 255)})`; }); - }, [engineData, nodeType, dimState]); + }, [engineData, nodeType, dimState, theme]); // Labels render in a colour distinct from the node mesh so text isn't // masked by the disc next to it. White-ish reads against any node diff --git a/web/src/explorers/ForceGraph/ForceGraph.tsx b/web/src/explorers/ForceGraph/ForceGraph.tsx index 78ed403de..9eb80b7ee 100644 --- a/web/src/explorers/ForceGraph/ForceGraph.tsx +++ b/web/src/explorers/ForceGraph/ForceGraph.tsx @@ -296,16 +296,26 @@ export const ForceGraph: React.FC< // gradient Edges) automatically dim without engine-level changes. Edges/ // Arrows in edge-type mode and the labels handle their own dimming via // the activeIds prop. + // + // Dim = fade toward THIS scene's background, not scale toward black. + // Linear multiply is luminance-dependent: a bright palette (lime) + // barely changes at 0.6 while a dark one (indigo/amber) collapses + // into the bg and vanishes — that's why the same alpha looked weak + // here and brutal in Document Explorer. Lerp-to-bg is hue/luminance- + // independent: every color loses the same fraction of its contrast + // against the background, so the perceived recede is uniform across + // palettes and explorers. const nodeColors = useMemo(() => { if (!dimState) return baseNodeColors; const tmp = new THREE.Color(); + const bg = new THREE.Color(canvasBg); return baseNodeColors.map((c, i) => { const id = filteredData?.nodes?.[i]?.id; if (id && dimState.activeIds.has(id)) return c; - tmp.set(c).multiplyScalar(dimState.alpha); + tmp.set(c).lerp(bg, 1 - dimState.alpha); return `#${tmp.getHexString()}`; }); - }, [baseNodeColors, dimState, filteredData]); + }, [baseNodeColors, dimState, filteredData, canvasBg]); // Synthesize the GraphData shape the shared Legend component expects. // Legend reads `nodes[*].{group,color}` and `links[*].{category,color}`; From cc338ac6223e9c3c6e97678aaa64fc1a2b6f68a0 Mon Sep 17 00:00:00 2001 From: Aaron Bockelie Date: Fri, 15 May 2026 22:20:22 -0500 Subject: [PATCH 17/18] chore(web): drop dead DOUBLE_CLICK_MS constant Leftover from the d3 Document Explorer's double-click path, not ported to the engine version. Fails npm run lint (no-unused-vars). Flagged by pre-merge code review of PR #368. --- web/src/explorers/DocumentExplorer/DocumentExplorer.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/web/src/explorers/DocumentExplorer/DocumentExplorer.tsx b/web/src/explorers/DocumentExplorer/DocumentExplorer.tsx index e68941f42..5264cd26e 100644 --- a/web/src/explorers/DocumentExplorer/DocumentExplorer.tsx +++ b/web/src/explorers/DocumentExplorer/DocumentExplorer.tsx @@ -54,8 +54,6 @@ const NODE_CLASS_BY_TYPE: Record = { 'extended-concept': 'concept', }; -const DOUBLE_CLICK_MS = 300; - // --------------------------------------------------------------------------- // Workspace-only props (passed by DocumentExplorerWorkspace, not by the // generic ExplorerView mount path) From cfb7433bebb48ad452e6b9f6fac0ac1c000007e0 Mon Sep 17 00:00:00 2001 From: Aaron Bockelie Date: Fri, 15 May 2026 22:23:36 -0500 Subject: [PATCH 18/18] =?UTF-8?q?docs(adr):=20amend=20ADR-702=20=E2=80=94?= =?UTF-8?q?=20phase=204=20executed=20as=20full=20adoption?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-merge code review of PR #368 flagged a documented-decision deviation: ADR-702 scoped phase 4 as optional, à-la-carte trait adoption with 'no commitment to complete migration', but #368 retired Document Explorer's d3 composition entirely and put it on the full engine path. Record the deviation rather than carry it in memory: a forward-pointer in the Phase 4 section (original decision text preserved) plus a tail amendment with rationale — the engine boundary held cleanly, shared rendering compounds (one dimModel.ts change fixed both explorers), and full adoption gave the 2D/3D toggle for free. ADR-085's 'keep it distinct' intent is preserved at the UX layer, not the rendering layer. Bumped frontmatter updated: 2026-05-15. adr lint: 0 errors. --- .../ADR-702-unified-graph-rendering-engine.md | 62 ++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/docs/architecture/user-interfaces/ADR-702-unified-graph-rendering-engine.md b/docs/architecture/user-interfaces/ADR-702-unified-graph-rendering-engine.md index 7034c11b7..72edb0d0e 100644 --- a/docs/architecture/user-interfaces/ADR-702-unified-graph-rendering-engine.md +++ b/docs/architecture/user-interfaces/ADR-702-unified-graph-rendering-engine.md @@ -1,7 +1,7 @@ --- status: Proposed date: 2026-04-20 -updated: 2026-04-20 +updated: 2026-05-15 deciders: - aaronsb - claude @@ -387,6 +387,15 @@ for widgets, the `CaretMarker` pattern, the distance-culled `` label approach — while keeping its custom scene composition. This phase is optional and opportunistic; no commitment to complete migration. +> **Amendment (2026-05-15): executed as full adoption.** Phase 4 was +> implemented more aggressively than scoped above — Document Explorer +> now consumes the full engine `` path, and its custom d3 scene +> composition was retired, rather than cherry-picking traits à la +> carte. The decision's *intent* (preserve Document Explorer's distinct +> experience) is honored at the UX layer, not the rendering layer. See +> "Amendment: Phase 4 executed as full adoption" at the end of this +> document for rationale. + --- ## Alternatives Considered @@ -586,3 +595,54 @@ edges. Phase 1 implementation will need to cover all four from day one because kg's existing 3D explorer already exhibits all four properties — losing them in V2 would block cutover. +--- + +## Amendment: Phase 4 executed as full adoption (2026-05-15) + +Phase 4 was scoped above as *optional, opportunistic, à-la-carte*: the +Document Explorer would borrow individual engine traits while keeping +its own d3 scene composition, with "no commitment to complete +migration." It was instead implemented as **full engine adoption** +(PR #368): the Document Explorer's standalone d3 force implementation +was retired and the component now renders through the same engine +`` path as the Force Graph plugin. + +### Why the scope changed + +Once Phases 1–3 landed, the cost/benefit inverted relative to what was +assumed when this ADR was written: + +- **The engine boundary held cleanly.** Every trait the Document + Explorer needed (per-node geometry classes, render-collapsed + clustering edges, label color/offset overrides, the shared dim + model) was expressible as additive, opt-in engine props with + Force-Graph-preserving defaults. The "keep custom composition" + hedge existed to avoid contorting the engine; that risk did not + materialize. +- **Unification compounds.** With both explorers on one ``, + engine work lands in both at once. Concrete instance from this + cycle: harmonizing hover/focus dimming (and the later + lerp-toward-background fix) was a single shared `dimModel.ts` change + consumed identically by both — impossible without shared rendering. +- **2D/3D projection for free.** Full adoption gave the Document + Explorer the projection toggle with no bespoke work, which the + à-la-carte path would not have. + +### What the original intent still buys + +ADR-085's concern — the Document Explorer has "a specific radial +layout and its own interaction model" — is preserved, but at the +**UX layer rather than the rendering layer**: distinct node glyphs +(document vs. concept), its own color scheme, document viewer, sidebar, +and a structural (not edge-derived) hover/focus neighborhood model. +The engine renders; the Document Explorer still decides what its +experience is. "Keep it distinct" was honored; "keep it on a separate +rendering stack" was not, and that is the deliberate change recorded +here. + +### Status of the à-la-carte hedge + +Withdrawn. Phase 4 is no longer "optional and opportunistic" — it is +done, as full adoption. No engine-divergent Document Explorer stack +remains to migrate later. +