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. + 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/explorers/DocumentExplorer/DocumentExplorer.tsx b/web/src/explorers/DocumentExplorer/DocumentExplorer.tsx index 2497a5ffd..5264cd26e 100644 --- a/web/src/explorers/DocumentExplorer/DocumentExplorer.tsx +++ b/web/src/explorers/DocumentExplorer/DocumentExplorer.tsx @@ -1,550 +1,520 @@ /** * 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 * as THREE from 'three'; 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 { StatsPanel, PanelStack } from '../common'; +import { Scene } from '../ForceGraph/scene/Scene'; +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'; +import { ContextMenu, type ContextMenuItem } from '../../components/shared/ContextMenu'; +import { FileText, Eye, EyeOff, Crosshair } from 'lucide-react'; // --------------------------------------------------------------------------- -// 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; -} - -interface SimLink extends d3.SimulationLinkDatum { - type: string; - visible: boolean; -} +const NODE_CLASS_BY_TYPE: Record = { + 'document': 'document', + 'query-concept': 'concept', + 'extended-concept': 'concept', +}; // --------------------------------------------------------------------------- -// 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); - const [selectedConceptId, setSelectedConceptId] = useState(null); - const [zoomTransform, setZoomTransform] = useState({ x: 0, y: 0, k: 1 }); - const simulationRef = useRef | null>(null); - - // Physics status indicator - 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]); - +> = ({ + data, + settings, + className, + focusedDocumentId, + onFocusChange, + onViewDocument, +}) => { const { appliedTheme: theme } = useThemeStore(); - - // 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]); - - // 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[] }; - - const simNodes: SimNode[] = data.nodes.map(n => ({ + const [hoveredId, setHoveredId] = useState(null); + // 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); + + // --------------------------------------------------------------------------- + // Engine data — transform DocumentExplorer shape → EngineNode[]/EngineEdge[] + // --------------------------------------------------------------------------- + + const engineData = useMemo(() => { + 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. + 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 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, - type: l.type, - visible: l.visible, - })); + const nodeIds = new Set(nodes.map((n) => n.id)); + // 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]); - return { simNodes, simLinks }; + // 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]); - // Visible stats (exclude clustering links) - const visibleLinkCount = useMemo( - () => simLinks.filter(l => l.visible).length, - [simLinks] + const nodeType = useCallback( + (id: string): DocNodeType | null => sourceById.get(id)?.type ?? null, + [sourceById], ); - // 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})`); - }, []); + // --------------------------------------------------------------------------- + // Per-node visuals: colors, geometry classes, scales + // --------------------------------------------------------------------------- - // ----------------------------------------------------------------------- - // Main D3 rendering + force simulation + const nodeClasses = useMemo(() => { + return engineData.nodes.map((n) => { + const type = nodeType(n.id) ?? 'extended-concept'; + return NODE_CLASS_BY_TYPE[type]; + }); + }, [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: , + concept: , + }), []); + + // --------------------------------------------------------------------------- + // 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. + // --------------------------------------------------------------------------- + + // 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. // - // 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); + // 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 === id) set.add(e.to); + else if (e.to === id) set.add(e.from); + } + return set; + }, [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 }; + if (hoverActiveIds) return { activeIds: hoverActiveIds, alpha: DIM_MODEL.hover }; + 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. + // + // 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).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, 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 + // colour (the engine paints a dark stroke under the text). Dim is + // 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( + () => engineData.nodes.map(() => LABEL_COLOR), + [engineData], + ); - const g = svg.append('g').attr('class', 'main-group'); + // 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]); + + // --------------------------------------------------------------------------- + // 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. + // - 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) => { + if (!id) return; + const type = nodeType(id); + if (type === 'document') { + onViewDocument?.(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 }, + ]; + }); + }, [nodeType, nodesById, onViewDocument]); - // 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 handleDismissNodeInfo = useCallback( + (nodeId: string) => setActiveNodeInfos((prev) => prev.filter((i) => i.nodeId !== nodeId)), + [], + ); - svg.call(zoom); + const handleHover = useCallback((id: string | null) => { + if (settings?.interaction?.highlightOnHover === false) { + setHoveredId(null); + return; + } + 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], + ); - // Click background to clear focus (no physics disturbance) - svg.on('click', () => { - onFocusChangeRef.current?.(null); - setSelectedConceptId(null); - }); + 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], + ); - // 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 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!), }); - - 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; - }; - - // 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; - })); - - 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; - } + 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]); - nodeElements.call(drag); - - 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]); - - // 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(); + // 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); + 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; - }; - - // 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]); + // --------------------------------------------------------------------------- + // Render + // --------------------------------------------------------------------------- + + const projection = settings?.projection ?? '3D'; + const bgClass = theme === 'dark' ? 'bg-gray-900' : 'bg-gray-50'; return ( -
- +
+ {/* 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
- {/* NodeInfoBox for selected concept */} - {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, - }} - onDismiss={() => setSelectedConceptId(null)} - headerExtra={selectedNodeQueryBar} + {/* Right-click context menu */} + {contextMenu && contextMenuItems.length > 0 && ( + setContextMenu(null)} /> )}
); }; - -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, diff --git a/web/src/explorers/ForceGraph/ForceGraph.tsx b/web/src/explorers/ForceGraph/ForceGraph.tsx index fde344281..9eb80b7ee 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 { DIM_MODEL } from './scene/dimModel'; import type { ForceSimHandle } from './scene/useForceSim'; import { createOntologyColorScale } from '../../utils/colorScale'; import { useVocabularyStore } from '../../store/vocabularyStore'; @@ -276,10 +277,11 @@ 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. - const dimState = useMemo<{ activeIds: Set; dimAlpha: number } | undefined>(() => { + // "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; alpha: number } | undefined>(() => { const driverId = focusedNode ?? hoveredId; if (!driverId) return undefined; const active = new Set([driverId]); @@ -287,23 +289,33 @@ 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, alpha: focusedNode ? DIM_MODEL.focus : DIM_MODEL.hover }; }, [focusedNode, hoveredId, filteredData]); // Bake the dim into the per-node color array so Nodes (and endpoint- // 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.dimAlpha); + 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}`; @@ -450,7 +462,8 @@ export const ForceGraph: React.FC< pinnedIds={pinnedIds} highlightedIds={highlightedIds} activeIds={dimState?.activeIds} - dimAlpha={dimState?.dimAlpha ?? 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/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/Edges.tsx b/web/src/explorers/ForceGraph/scene/Edges.tsx index c5a152f68..0f8fb8b89 100644 --- a/web/src/explorers/ForceGraph/scene/Edges.tsx +++ b/web/src/explorers/ForceGraph/scene/Edges.tsx @@ -59,6 +59,13 @@ export interface EdgesProps { * dimmed by dimAlpha. Drives hover/focus dim. */ activeIds?: Set; 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/NodeLabels.tsx b/web/src/explorers/ForceGraph/scene/NodeLabels.tsx index f9d70c325..3f626b7ea 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,22 +91,45 @@ export interface NodeLabelsProps { projection?: Projection; /** Multiplier on the base label world-space height. Default 1. */ sizeMultiplier?: number; + /** 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; + /** 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; } -/** 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, positionsRef, colors, + labelColors, hiddenIds, visibilityRadius = 250, enabled = true, activeIds, projection = '3D', sizeMultiplier = 1, + labelOffsetY = DEFAULT_LABEL_OFFSET_Y, + nodeScales, + nodeSize = 1, + dimLabelOpacity = 1, }: NodeLabelsProps) { const camera = useThree((state) => state.camera); @@ -137,7 +170,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) { @@ -149,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) { @@ -157,7 +190,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, dimLabelOpacity]); const scratch = useMemo( () => ({ @@ -197,11 +230,23 @@ 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 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 += LABEL_OFFSET_ABOVE; + 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/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..916b97a9f 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, useMemo, type ReactElement } from 'react'; import * as THREE from 'three'; import { OrbitControls } from '@react-three/drei'; import type { EngineNode, EngineEdge, Projection } from '../types'; @@ -34,8 +34,23 @@ 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. + * 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[]; + /** 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; @@ -45,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; @@ -52,6 +72,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; @@ -80,17 +106,24 @@ export function Scene({ nodes, edges, colors, + nodeClasses, + geometryByClass, + nodeScales, edgeColors, + edgeVisible, hiddenIds, pinnedIds, highlightedIds, activeIds, dimAlpha = 1, + dimLabelOpacity = 1, nodeSize, edgeOpacity, linkWidth, nodeLabelSize = 1, edgeLabelSize = 1, + labelColors, + nodeLabelOffsetY, showArrows = true, showEdgeLabels = true, showNodeLabels = true, @@ -116,6 +149,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, @@ -144,6 +191,7 @@ export function Scene({ positionsRef={sim.positionsRef} colors={colors} edgeColors={edgeColors} + edgeVisible={edgeVisible} hiddenIds={hiddenIds} opacity={edgeOpacity} linkWidth={linkWidth} @@ -166,6 +214,9 @@ export function Scene({ nodes={nodes} positionsRef={sim.positionsRef} colors={colors} + nodeClasses={nodeClasses} + geometryByClass={geometryByClass} + nodeScales={resolvedNodeScales} hiddenIds={hiddenIds} highlightedIds={highlightedIds} nodeSize={nodeSize} @@ -188,17 +239,23 @@ export function Scene({ activeIds={activeIds} projection={projection} sizeMultiplier={edgeLabelSize} + dimLabelOpacity={dimLabelOpacity} /> {activeNodeInfos.map((info) => ( = { + hover: 0.6, + focus: 0.1, +}; 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 }), +}));