diff --git a/.gitignore b/.gitignore index be5c0ce6..44395c8c 100644 --- a/.gitignore +++ b/.gitignore @@ -115,6 +115,11 @@ cookies.txt # uv lockfile (generated tooling, not a project dependency) uv.lock +# Every-node stress fixtures (local only) +.scratch-*.db* +.seed20k*.py +.seed-graph*.py + # Schema-migration flock lives next to the DB (engraphis/config.py _migration_lock); # regenerable runtime state like *.db itself. Held live while the server runs. .*.migration.lock diff --git a/BENCHMARKS.md b/BENCHMARKS.md index 219f6ee2..4ea7c9ea 100644 --- a/BENCHMARKS.md +++ b/BENCHMARKS.md @@ -106,6 +106,14 @@ frontier-model QA score. answer, and is identified in every report; inject a real agent callable for model-specific results. Optional provider telemetry is reported separately from the deterministic token counter and is not a provider billing estimate. +- **Dashboard graph layout settle**: `eval/graph_every_bench.py` drives the Every-node + dashboard engine's real worker (`engraphis-graph-every-worker.js`) through a + `prepare → settled` round-trip over synthetic node/link loads and reports wall-clock settle + time plus the scaling ratio across sizes. It measures initial layout cost only: camera pans + and zooms never touch the worker (they are GPU-uniform updates), so no per-frame number can + come out of this harness and none should be quoted. Results are host- and Node-version + dependent local diagnostics, not registered public evidence; run the harness on the target + class of machine before quoting a figure. The context-economy and productivity tools intentionally report when a small workload does not benefit from memory, and the external loaders expose retrieval-quality tradeoffs rather than diff --git a/CHANGELOG.md b/CHANGELOG.md index ee9bcaa9..1920e79f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,23 @@ All notable changes to Engraphis are documented here. Format loosely follows ## [Unreleased] +### Added + +- The graph's "Show all nodes" toggle is replaced by a dedicated **Every node** layout built + on a new ultra-performance engine (`engraphis-graph-every.js` + + `engraphis-graph-every-worker.js`, WebGL2-only): all geometry is uploaded once and camera + moves touch only uniforms, so pan/zoom frame cost is independent of node count up to the + 20,000-node / 200,000-relation ceilings. Zoomed-out scenes read as an additive glow + density map; edges reveal progressively by weight with gold bridges; community districts + paint as tinted region hulls with hub-derived labels; hovering or highlighting a node dims + everything outside its neighbourhood, marks its relations with directional arrows and + relation names, and shows a callout card with category, connection count, and strongest + connections. Includes two-pointer pinch zoom, keyboard browsing (arrows/+/-/F/Escape), + a screen-reader live region for scene and hover announcements, and deterministic worker + layouts that stream settling passes (measured: ~320 ms settle at 2k nodes, ~1.2 s at 20k). + Entering Every-node shows every entity regardless of overview filters; leaving restores + the person's filters. + ### Changed - Direct black-hole children now receive compact, deterministic orbital lanes near the black @@ -108,6 +125,9 @@ All notable changes to Engraphis are documented here. Format loosely follows ### Fixed +- The Every node dashboard view no longer crashes on open: a declaration-order bug in the + renderer threw during construction before anything painted. The scene canvas also keeps its + accessible role/label now instead of being hidden from assistive technology. - Import previews now page the source manifest exactly like execution, so vaults whose manifest outgrew one list page (10k identities) no longer show manifest-only files as silently absent from the preview plan; beyond-boundary rows are reported as `missing` instead of dropped. diff --git a/README.md b/README.md index 00b743cd..a9708a92 100644 --- a/README.md +++ b/README.md @@ -231,7 +231,7 @@ delegates configuration, startup health, browser opening, and process lifecycle Inspect memories, supersession diffs, recall scores, timelines, links, consolidation, and audit records in the dashboard. The offline graph renderer is vendored, and the interface is keyboard- navigable with light and dark themes. Graph exploration offers a focused **High quality** view and -an explicit worker-backed **Show all nodes** view for complete entity projections up to 20,000 +an explicit worker-backed **Every node** view for complete entity projections up to 20,000 nodes and 200,000 relationships; see the [graph performance profiles](https://github.com/Coding-Dev-Tools/engraphis/blob/main/docs/GRAPH_PERFORMANCE.md). --- diff --git a/docs/GRAPH_PERFORMANCE.md b/docs/GRAPH_PERFORMANCE.md index 95e03adc..718c4c9d 100644 --- a/docs/GRAPH_PERFORMANCE.md +++ b/docs/GRAPH_PERFORMANCE.md @@ -4,30 +4,61 @@ The dashboard has two explicit graph presentations: - **High quality** requests an overview capped at 1,000 entity nodes and 2,000 relations and keeps the existing shaded renderer and interaction behavior. -- **All nodes · LOD** requests the complete entity projection up to 20,000 nodes and the - existing 200,000-relation safety ceiling. An exact repository filter can add its code overlay - within the same final-node ceiling. All relationships remain indexed in the worker; - zoomed-out views paint points only, medium zoom paints ranked/visible edges, and focused views - reveal local labels and relationships. The all-node renderer uses flat dots by design. - -All-node preparation runs in `engraphis-graph-worker.js`. WebGL2 is the supported performance -target; browsers without WebGL2 use a flatter Canvas fallback with stricter practical edge -budgets. The all-node path has no live force simulation. Layout presets and force controls run -bounded deterministic settling passes in the worker; relation-flow markers animate only a capped -visible subset and become static directional cues when reduced motion or Freeze is active. - -Every shared graph control has an All-node behavior: minimum relations and unlinked toggles filter -worker visibility, neighbourhood depth bounds a focused traversal, relation layers and history -ghosts rebuild the ranked paint set, auto-collapse reduces zoomed-out communities to representative -nodes, and colour, palette, size, labels, line width, fit, reflow, export, and focus remain live. - -The Playwright fixture `tests/e2e/graph-all-performance.spec.js` builds 20,000 nodes and 200,000 -dense relationships, verifies progressive point/relationship handoff, exercises pan/zoom/focus, -and fails if post-handoff long tasks exceed 50 ms. Run it with the normal Playwright suite on a -mid-range desktop with hardware-accelerated WebGL2 enabled. +- **Every node** (layout chip "Every node") requests the complete entity projection up to + 20,000 nodes and the existing 200,000-relation safety ceiling via the dedicated Every-node + engine (`engraphis-graph-every.js` + `engraphis-graph-every-worker.js`). An exact repository + filter can add its code overlay within the same final-node ceiling. + +## The Every-node engine + +Design contract: **all geometry is uploaded once and only re-uploaded when data, layout, +colours, or filters change; camera moves touch two uniforms.** Pan/zoom frame cost is +independent of node count - nothing on the GPU moves when you pan. + +- **Worker** (`engraphis-graph-every-worker.js`): capacity validation, typed-array + compaction, deterministic community-seeded placement (districts packed tight, centres + spread wide), and 26 bounded relaxation passes streamed as `preview → ready → progress → + layout` messages. Springs are community-aware: intra-district springs run strong, + cross-district springs weak, and district centroids repel each other so neighbourhoods + stay separated. The worker is silent once a layout settles - it never sees camera traffic. +- **Renderer**: WebGL2-only (unsupported browsers get an explicit error). Zoom-out + readability comes from additive glow density - crowded regions melt into brightness - + with continuous shader-side LOD instead of hard tiers. Edges reveal progressively by + weight as you zoom (bridges always render, tinted gold). Hovering or highlighting a node + dims everything outside its direct neighbourhood and marks its relations with directional + arrows and relation names; picking runs through a local spatial grid with no worker + round-trip. Labels are decluttered by screen-space occupancy (rank-first). Community + regions paint as tinted district hulls with hub-derived labels. +- **Interaction**: pointer drag/wheel zoom, two-pointer pinch, keyboard (arrows pan, + +/- zoom, F fit, Escape clears selection). A screen-reader live region announces scene + totals and hovered entities; the WebGL scene canvas is labelled while the underlay/label + canvases remain decorative. + +Measured worker settle times (deterministic fixture, see `python -m eval.graph_every_bench`, +run inside the dev distrobox where node is available): + +| Scale | Settle time | +|---|---| +| 2,000 nodes / 2,667 relations | ~320 ms | +| 20,000 nodes / 26,667 relations | ~1.2 s | + +Settle is a one-off cost per data/relayout; per-frame render cost does not grow with node +count. Reduced-motion preferences freeze relation-flow markers. WebGL2 is required. + +## Shared controls + +Every shared graph control has an Every-node behaviour: minimum relations and unlinked +toggles filter visibility (entering Every-node shows all nodes; leaving restores the +person's overview filters), presets re-run the seeded layout with new force settings, +colour/size/style/palette remain live, and colour, labels, fit, reflow, export, and focus +remain live. Focus-depth traversal and auto-collapse are not yet implemented in the engine; +their controls are disabled honestly while in Every-node mode rather than silently doing +nothing. + +The Playwright e2e coverage for graph routing lives in `tests/e2e/ledger.spec.js`; the +worker/renderer contract is pinned by `tests/test_graph_every_asset.py`, which executes the +real worker in Node and asserts the renderer's structural invariants. If the server has more than 20,000 final nodes or more than 200,000 raw relationships, the -all profile refuses the request with an explicit capacity response. Narrow by repository or entity -type, or reduce the workspace graph; it never silently samples the all-node projection. Time, -layer, and relation filters still shape an accepted scene, but are not advertised as ways around -the raw entity and relationship safety ceilings because those limits are enforced first. +profile refuses the request with an explicit capacity response. Narrow by repository or entity +type, or reduce the workspace graph; it never silently samples the projection. diff --git a/engraphis/classic_assets/dashboard.js b/engraphis/classic_assets/dashboard.js index 74bf3cc2..a7b599ca 100644 --- a/engraphis/classic_assets/dashboard.js +++ b/engraphis/classic_assets/dashboard.js @@ -1214,41 +1214,57 @@ function loadForceGraph(){ }); return FORCE_GRAPH_LOADING; } -let GRAPH_ENGINE_LOADING=null,GRAPH_ENGINE_RETRY=0; -function loadGraphEngine(){ +let GRAPH_ENGINE_LOADING=null,GRAPH_ENGINE_RETRY=0,ALL_GRAPH_ENGINE_LOADING=null; +function loadAllGraphEngine(){ + if(typeof EngraphisEveryGraph!=='undefined')return Promise.resolve(); + if(!ALL_GRAPH_ENGINE_LOADING){ + ALL_GRAPH_ENGINE_LOADING=new Promise((resolve,reject)=>{ + const script=document.createElement('script');script.src='/v2-assets/engraphis-graph-every.js?v=20260823-every-19'; + script.onload=()=>{typeof EngraphisEveryGraph==='undefined'?reject(new Error('Every-node graph asset loaded without registering EngraphisEveryGraph')):resolve()}; + script.onerror=()=>reject(new Error('Every-node graph asset could not load')); + document.head.appendChild(script); + }); + ALL_GRAPH_ENGINE_LOADING.catch(()=>{}); + } + return ALL_GRAPH_ENGINE_LOADING; +} +function loadGraphEngine(loadAll=false){ let engineReady; if(typeof EngraphisGraph!=='undefined'){GRAPH_ENGINE_RETRY=0;engineReady=Promise.resolve();} else{ - if(!GRAPH_ENGINE_LOADING){ - GRAPH_ENGINE_LOADING=new Promise((resolve,reject)=>{ - const script=document.createElement('script'); + if(!GRAPH_ENGINE_LOADING){ + GRAPH_ENGINE_LOADING=new Promise((resolve,reject)=>{ + const script=document.createElement('script'); const bust=GRAPH_ENGINE_RETRY>0?'&r='+GRAPH_ENGINE_RETRY:''; script.src='/v2-assets/engraphis-graph.js?v=20260815-merge-ready-1'+bust; - /* A 200 that never registers the global is a corrupt/truncated asset, not a success — - resolving there would hand graphRenderEngine() an undefined EngraphisGraph. Failed - attempts drop the script node and clear the memo so the next call retries with a - cache-buster rather than returning the same rejected promise forever. */ - const cleanup=(failed)=>{if(script.parentNode)script.parentNode.removeChild(script);if(failed){GRAPH_ENGINE_LOADING=null;GRAPH_ENGINE_RETRY=Math.min(GRAPH_ENGINE_RETRY+1,2)}else{GRAPH_ENGINE_RETRY=0}}; - script.onload=()=>{if(typeof EngraphisGraph==='undefined'){cleanup(true);reject(new Error('Graph engine asset loaded without registering EngraphisGraph'))}else{cleanup(false);resolve()}}; - script.onerror=()=>{cleanup(true);reject(new Error('Graph engine could not load'))}; - document.head.appendChild(script); - }); - GRAPH_ENGINE_LOADING.catch(()=>{}); - } - engineReady=GRAPH_ENGINE_LOADING; - } - /* Mark the memoized promise handled. graphRender() can start this fetch on a pass that - returns before attaching its own handler, and an unhandled rejection would print the exact - console error this lazy-loading exists to remove. Callers still receive the rejection. */ - return engineReady; + /* A 200 that never registers the global is a corrupt/truncated asset, not a success — + resolving there would hand graphRenderEngine() an undefined EngraphisGraph. Failed + attempts drop the script node and clear the memo so the next call retries with a + cache-buster rather than returning the same rejected promise forever. */ + const cleanup=(failed)=>{if(script.parentNode)script.parentNode.removeChild(script);if(failed){GRAPH_ENGINE_LOADING=null;GRAPH_ENGINE_RETRY=Math.min(GRAPH_ENGINE_RETRY+1,2)}else{GRAPH_ENGINE_RETRY=0}}; + script.onload=()=>{if(typeof EngraphisGraph==='undefined'){cleanup(true);reject(new Error('Graph engine asset loaded without registering EngraphisGraph'))}else{cleanup(false);resolve()}}; + script.onerror=()=>{cleanup(true);reject(new Error('Graph engine could not load'))}; + document.head.appendChild(script); + }); + GRAPH_ENGINE_LOADING.catch(()=>{}); + } + engineReady=GRAPH_ENGINE_LOADING; + } + /* Mark the memoized promise handled. graphRender() can start this fetch on a pass that + returns before attaching its own handler, and an unhandled rejection would print the exact + console error this lazy-loading exists to remove. Callers still receive the rejection. */ + return loadAll?engineReady.then(()=>loadAllGraphEngine()):engineReady; } function graphRender(fit=true,reheat=true){ - const empty=document.getElementById('graph-empty'); + const empty=document.getElementById('graph-empty'); + const graphFull=typeof GRAPH_FULL!=='undefined'&&GRAPH_FULL; /* Kick the opt-in engine off alongside the vendor bundle instead of after it, so a `?graph-engine=next` deep link costs one round trip rather than two. */ - const engineMissing=typeof EngraphisGraph==='undefined'; - const enginePending=(!GRAPH_ENGINE_FAILED&&graphEngineEnabled())&&engineMissing?loadGraphEngine():null; - if(typeof ForceGraph==='undefined'){ + const engineMissing=typeof EngraphisGraph==='undefined'||(graphFull&&typeof EngraphisEveryGraph==='undefined'); + /* All mode owns a dedicated bounded renderer and must remain available after a quality-renderer + runtime failure. The quality failure latch only authorizes the small legacy overview. */ + const enginePending=(graphFull||(!GRAPH_ENGINE_FAILED&&graphEngineEnabled()))&&engineMissing?loadGraphEngine(graphFull):null; + if(!graphFull&&typeof ForceGraph==='undefined'){ showAs(empty,true,'flex');empty.textContent='Loading graph engine…'; graphSetLayoutStatus('Loading engine',true); loadForceGraph().then(()=>graphRender(fit,reheat)).catch(error=>{ @@ -1265,7 +1281,12 @@ function graphRender(fit=true,reheat=true){ announced through graphEngineFallback() rather than silent. */ showAs(empty,true,'flex');empty.textContent='Loading graph engine…'; graphSetLayoutStatus('Loading engine',true); - enginePending.then(()=>graphRender(fit,reheat)).catch(error=>{ + enginePending.then(()=>graphRender(fit,reheat)).catch(error=>{ + if(graphFull){ + empty.textContent=error.message+'; return to High quality or reload the dashboard assets.'; + graphSetLayoutStatus('All-node engine unavailable',false); + return; + } /* Latches GRAPH_ENGINE_FAILED, so the re-entry below takes the classic path and this cannot loop. */ graphEngineFallback(error); @@ -1274,6 +1295,13 @@ function graphRender(fit=true,reheat=true){ return; } const element=document.getElementById('graph-net'),settings=window.GSET,mode=GRAPH_PRESETS[settings.mode]||GRAPH_PRESETS.compact,data=graphData(); + if(graphFull){ + if(graphRenderEngine(data,fit,reheat))return; + showAs(empty,true,'flex'); + empty.textContent='All-node renderer unavailable; return to High quality or reload the dashboard assets.'; + graphSetLayoutStatus('All-node engine unavailable',false); + return; + } if(graphEngineEnabled()&&graphRenderEngine(data,fit,reheat))return; /* Read AFTER the opt-in attempt: a failing engine resets GACTIVE_DATA precisely so the classic renderer below rebuilds from scratch instead of assuming the canvas is current. */ diff --git a/engraphis/dashboard_assets/engraphis-graph-all.js b/engraphis/dashboard_assets/engraphis-graph-all.js deleted file mode 100644 index 5efd8c49..00000000 --- a/engraphis/dashboard_assets/engraphis-graph-all.js +++ /dev/null @@ -1,595 +0,0 @@ -/* Progressive renderer for the explicit all-node profile. It intentionally has no live force - simulation: a worker prepares deterministic layouts and LOD sets, WebGL2 paints batched - geometry, and a bounded overlay communicates relation direction without moving nodes. */ -(function () { - 'use strict'; - const WORKER_URL = '/v2-assets/engraphis-graph-worker.js?v=20260815-merge-ready-1'; - const MAX_NODES = 20000; - const MAX_LINKS = 200000; - const FLOW_EDGE_LIMIT = 900; - const FLOW_FRAME_MS = 34; - const PALETTES = { - cyber: ['#4bd8df', '#9a7cff', '#ed6fc2', '#6fe6b0', '#f0c674', '#6ba8ff'], - galaxy: ['#72a8ff', '#9a87ff', '#d987ff', '#59d5e7', '#8ee3c7', '#f4c978'], - solar: ['#e8a05c', '#e17f65', '#f2c66d', '#d36d8f', '#d9d28b', '#e99767'], - classic: ['#9ab2c7', '#839db2', '#b0a4c8', '#7aa7a6', '#c0aa7b', '#8aa6c9'], - }; - const TYPE_COLORS = { person_or_concept: '#8d82e3', mention: '#5ba1a6', hashtag: '#c9a15b', email: '#8eb3e6', organization: '#d48173', location: '#7ebf8e', memory: '#5ba1a6', repo: '#c9a15b', file: '#8eb3e6' }; - const LIGHT_CLASSIC_PALETTE = ['#455d72', '#526a7d', '#625878', '#426b6a', '#75623d', '#4f6685']; - const LIGHT_CLASSIC_TYPE_COLORS = { person_or_concept: '#5146a1', mention: '#2f6f73', hashtag: '#725716', email: '#35658f', organization: '#8a4a3f', location: '#397147', memory: '#2f6f73', repo: '#725716', file: '#35658f' }; - const DARK_GRAPH_PAINT = { - canvasEdge: 'rgba(124,163,183,0.17)', canvasBridge: 'rgba(244,211,127,0.62)', - webglEdge: '#638fa6', webglBridge: '#f4d37f', webglOpacity: 0.2, - focus: '#f4d37f', label: 'rgba(224,236,241,0.86)', - flow: 'rgba(115,220,239,0.72)', flowBridge: 'rgba(255,220,132,0.88)', - flowComposite: 'lighter', - }; - const LIGHT_CLASSIC_PAINT = { - canvasEdge: '#66757e', canvasBridge: '#7a5a12', - webglEdge: '#66757e', webglBridge: '#7a5a12', webglOpacity: 1, - focus: '#5c50b7', label: '#202126', - flow: '#1f6775', flowBridge: '#7a5a12', - flowComposite: 'source-over', - }; - const PRESETS = { - galaxy: { repel: 60, link: 8, gravity: 48, font: 12, size: 3, linkw: 0.72, labelDensity: 24 }, - original: { repel: 120, link: 30, gravity: 14, font: 13, size: 3, linkw: 1, labelDensity: 40 }, - compact: { repel: 42, link: 20, gravity: 26, font: 12, size: 3, linkw: 0.7, labelDensity: 30 }, - communities: { repel: 48, link: 16, gravity: 48, font: 12, size: 3, linkw: 0.72, labelDensity: 24 }, - radial: { repel: 68, link: 26, gravity: 12, font: 13, size: 3, linkw: 0.75, labelDensity: 55 }, - constellation: { repel: 34, link: 16, gravity: 38, font: 12, size: 3, linkw: 0.65, labelDensity: 35 }, - }; - const raf = window.requestAnimationFrame || (callback => window.setTimeout(callback, 16)); - const caf = window.cancelAnimationFrame || (handle => window.clearTimeout(handle)); - const clamp = (value, low, high) => Math.max(low, Math.min(high, value)); - const color = value => /^#[0-9a-f]{6}$/i.test(String(value || '')) ? String(value) : '#86a8bf'; - const rgb = value => { const text = color(value).slice(1); return [parseInt(text.slice(0, 2), 16) / 255, parseInt(text.slice(2, 4), 16) / 255, parseInt(text.slice(4, 6), 16) / 255]; }; - function isLightColor(value) { - const match = /^#([0-9a-f]{6})$/i.exec(String(value || '')); - if (!match) return false; - const packed = parseInt(match[1], 16); - const linear = channel => { - const normalized = channel / 255; - return normalized <= 0.04045 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4; - }; - return 0.2126 * linear((packed >> 16) & 255) - + 0.7152 * linear((packed >> 8) & 255) - + 0.0722 * linear(packed & 255) > 0.5; - } - - function create(element, options) { - if (!element) throw new Error('all graph renderer requires a host element'); - const opts = options || {}, canvas = document.createElement('canvas'), labels = document.createElement('canvas'); - canvas.className = 'engraphis-all-canvas'; labels.className = 'engraphis-all-labels'; canvas.setAttribute('aria-hidden', 'true'); labels.setAttribute('aria-hidden', 'true'); element.replaceChildren(canvas, labels); - element.setAttribute('data-graph-style', opts.style || 'cyber'); - const gl = canvas.getContext('webgl2', { antialias: false, alpha: true, powerPreference: 'high-performance' }); - const labelContext = labels.getContext('2d'); - const worker = new Worker(WORKER_URL); - const state = { - ids: [], labels: [], types: [], communities: [], positions: new Float32Array(0), nodeVertexPositions: new Float32Array(0), nodeGhosts: new Uint8Array(0), nodeVisible: new Uint8Array(0), degrees: new Float32Array(0), betweenness: new Float32Array(0), evidenceMass: new Float32Array(0), - edgeSources: new Uint32Array(0), edgeTargets: new Uint32Array(0), edgeBridges: new Uint8Array(0), edgeLayers: [], topNodes: new Uint32Array(0), - visibleNodes: new Uint32Array(0), visibleEdges: new Uint32Array(0), visibleLabels: new Uint32Array(0), - edgeVertexPositions: new Float32Array(0), edgeColors: new Float32Array(0), edgeVertexCount: 0, nodeColors: new Float32Array(0), nodeSizes: new Float32Array(0), bounds: null, - camera: { x: 0, y: 0, scale: 1 }, width: 1, height: 1, dpr: 1, styleName: opts.style || 'cyber', colorBy: 'community', typeColors: {}, - settings: { labels: true, flow: false, flowSpeed: 45, frozen: false, mode: 'communities', repel: 48, link: 16, gravity: 48, font: 12, size: 3, linkw: 0.72, labelDensity: 24 }, - palette: 'theme', themeColors: {}, lightSurface: false, layers: null, sizeBy: 'degree', bridges: true, ghosts: true, - scope: { minDegree: 1, showUnlinked: true, depth: 2 }, collapse: false, collapsed: false, - focus: -1, hover: -1, ready: false, totalLinks: 0, drawnLinks: 0, visibleNodeCount: 0, - lodTier: 'medium', frame: 0, flowPaintAt: 0, layoutPending: false, - hitRequest: 0, drag: null, destroyed: false, error: null, - }; - let nodeProgram = null, edgeProgram = null, nodeBuffers = {}, edgeBuffers = {}; - let hitFrame = 0, pendingHit = null, layoutFrame = 0, pendingLayoutFit = false; - let cameraRevision = 0, cameraInFlight = 0, pendingCamera = null; - let readyResolve = null, readyReject = null, readyPromise = Promise.resolve(); - function renewReadyPromise() { - readyPromise = new Promise((resolve, reject) => { - readyResolve = resolve; - readyReject = reject; - }); - /* Lifecycle consumers may opt out; keep worker errors observable without an unhandled - rejection in embedders that only use callbacks. */ - readyPromise.catch(() => {}); - } - function settleReady(error) { - const resolve = readyResolve, reject = readyReject; - readyResolve = null; - readyReject = null; - if (error) { - if (reject) reject(error); - } else if (resolve) resolve(api); - } - const reducedMotion = () => { - if (typeof opts.reducedMotion === 'function') return opts.reducedMotion() === true; - if (opts.reducedMotion === true) return true; - return typeof window.matchMedia === 'function' - && window.matchMedia('(prefers-reduced-motion: reduce)').matches; - }; - const screen = (x, y) => [(x - state.camera.x) * state.camera.scale + state.width / 2, (y - state.camera.y) * state.camera.scale + state.height / 2]; - const world = (x, y) => [(x - state.width / 2) / state.camera.scale + state.camera.x, (y - state.height / 2) / state.camera.scale + state.camera.y]; - function drawableNodeIndices() { - const values = []; - for (let index = 0; index < state.ids.length; index += 1) { - const workerVisible = state.nodeVisible.length !== state.ids.length || state.nodeVisible[index]; - if (workerVisible && (state.ghosts || !state.nodeGhosts[index])) values.push(index); - } - return new Uint32Array(values); - } - function setVisibleNodes(values) { - state.visibleNodes = values || new Uint32Array(0); - state.nodeVisible = new Uint8Array(state.ids.length); - for (let index = 0; index < state.visibleNodes.length; index += 1) { - state.nodeVisible[state.visibleNodes[index]] = 1; - } - state.visibleNodeCount = state.visibleNodes.length; - } - function resize() { - const rect = element.getBoundingClientRect(); state.width = Math.max(1, rect.width || element.clientWidth || 1); state.height = Math.max(1, rect.height || element.clientHeight || 1); state.dpr = Math.min(2, window.devicePixelRatio || 1); - [canvas, labels].forEach(target => { target.width = Math.max(1, Math.floor(state.width * state.dpr)); target.height = Math.max(1, Math.floor(state.height * state.dpr)); }); - if (gl) gl.viewport(0, 0, canvas.width, canvas.height); - /* LOD sets are viewport-dependent. A resize must refresh the worker camera too; a repaint - alone can leave Canvas nodes, edges, and labels clipped to the previous dimensions. */ - if (state.ready) camera(); else schedule(); - } - function nodeAt(index) { return { id: state.ids[index], label: state.labels[index] || state.ids[index], type: state.types[index] || 'person_or_concept' }; } - const usesLightClassicPaint = () => state.styleName === 'classic' && state.lightSurface; - const graphPaint = () => usesLightClassicPaint() ? LIGHT_CLASSIC_PAINT : DARK_GRAPH_PAINT; - function activePalette() { - if (usesLightClassicPaint() && (state.palette === 'theme' || state.palette === 'ocean')) return LIGHT_CLASSIC_PALETTE; - if (state.palette === 'ember') return PALETTES.solar; - if (state.palette === 'ocean') return PALETTES.classic; - if (state.palette === 'contrast') return ['#ffffff', '#8fe8ff', '#ffd166', '#ff7aa2', '#b9ffb0', '#d6b3ff']; - if (state.palette === 'aurora') return PALETTES.cyber; - return PALETTES[state.styleName] || PALETTES.cyber; - } - function nodeColor(index) { - const item = nodeAt(index); - const fallback = usesLightClassicPaint() ? LIGHT_CLASSIC_TYPE_COLORS[item.type] : TYPE_COLORS[item.type]; - const themed = state.typeColors[item.type] || state.themeColors[item.type] || fallback; - if (state.colorBy === 'type' || state.palette === 'custom') return color(themed || item.color); - const palette = activePalette(); - if (state.colorBy === 'connections') return palette[Math.min(5, Math.floor(Math.log1p(state.degrees[index] || 0) * 1.5))]; - const group = String(state.communities[index] || index), hash = Array.from(group).reduce((sum, char) => ((sum * 31) + char.charCodeAt(0)) >>> 0, 7); - return palette[hash % palette.length]; - } - function metricValue(index) { - if (state.sizeBy === 'betweenness') return state.betweenness[index] || 0; - if (state.sizeBy === 'evidence_mass') return state.evidenceMass[index] || 0; - return state.degrees[index] || 0; - } - function pointSize(index = 0) { - const metric = Math.log1p(Math.max(0, metricValue(index))); - return clamp(2.4 + Number(state.settings.size || 3) * 0.62 + Math.min(4.5, metric * 0.55), 2.5, 12); - } - function shader(type, source) { const value = gl.createShader(type); gl.shaderSource(value, source); gl.compileShader(value); if (!gl.getShaderParameter(value, gl.COMPILE_STATUS)) throw new Error('all-node shader compilation failed'); return value; } - function program(vertex, fragment) { - const value = gl.createProgram(); - const vertexShader = shader(gl.VERTEX_SHADER, vertex); - const fragmentShader = shader(gl.FRAGMENT_SHADER, fragment); - gl.attachShader(value, vertexShader); gl.attachShader(value, fragmentShader); - gl.linkProgram(value); - const linked = gl.getProgramParameter(value, gl.LINK_STATUS); - if (typeof gl.detachShader === 'function') { - gl.detachShader(value, vertexShader); gl.detachShader(value, fragmentShader); - } - if (typeof gl.deleteShader === 'function') { - gl.deleteShader(vertexShader); gl.deleteShader(fragmentShader); - } - if (!linked) { - if (typeof gl.deleteProgram === 'function') gl.deleteProgram(value); - throw new Error('all-node shader link failed'); - } - return value; - } - function initWebgl() { - if (!gl) return; - try { - const vertex = `#version 300 es - in vec2 a_position; in vec3 a_color; in float a_size; uniform vec2 u_camera; uniform float u_scale; uniform vec2 u_resolution; out vec3 v_color; - void main(){vec2 px=(a_position-u_camera)*u_scale+u_resolution*0.5;vec2 clip=px/u_resolution*2.0-1.0;gl_Position=vec4(clip.x,-clip.y,0.0,1.0);gl_PointSize=max(1.0,a_size*u_scale);v_color=a_color;}`; - const fragment = `#version 300 es - precision mediump float;in vec3 v_color;out vec4 outputColor;void main(){vec2 p=gl_PointCoord-0.5;if(dot(p,p)>0.25)discard;outputColor=vec4(v_color,0.92);}`; - const edgeVertex = `#version 300 es - in vec2 a_position;in vec3 a_color;uniform vec2 u_camera;uniform float u_scale;uniform vec2 u_resolution;out vec3 v_color;void main(){vec2 px=(a_position-u_camera)*u_scale+u_resolution*0.5;vec2 clip=px/u_resolution*2.0-1.0;gl_Position=vec4(clip.x,-clip.y,0.0,1.0);v_color=a_color;}`; - const edgeFragment = `#version 300 es - precision mediump float;in vec3 v_color;uniform float u_opacity;out vec4 outputColor;void main(){outputColor=vec4(v_color,u_opacity);}`; - nodeProgram = program(vertex, fragment); edgeProgram = program(edgeVertex, edgeFragment); - nodeBuffers.position = gl.createBuffer(); nodeBuffers.color = gl.createBuffer(); nodeBuffers.size = gl.createBuffer(); edgeBuffers.position = gl.createBuffer(); edgeBuffers.color = gl.createBuffer(); - nodeBuffers.attrs = { position: gl.getAttribLocation(nodeProgram, 'a_position'), color: gl.getAttribLocation(nodeProgram, 'a_color'), size: gl.getAttribLocation(nodeProgram, 'a_size'), camera: gl.getUniformLocation(nodeProgram, 'u_camera'), scale: gl.getUniformLocation(nodeProgram, 'u_scale'), resolution: gl.getUniformLocation(nodeProgram, 'u_resolution') }; - edgeBuffers.attrs = { position: gl.getAttribLocation(edgeProgram, 'a_position'), color: gl.getAttribLocation(edgeProgram, 'a_color'), camera: gl.getUniformLocation(edgeProgram, 'u_camera'), scale: gl.getUniformLocation(edgeProgram, 'u_scale'), resolution: gl.getUniformLocation(edgeProgram, 'u_resolution'), opacity: gl.getUniformLocation(edgeProgram, 'u_opacity') }; - } catch (error) { nodeProgram = edgeProgram = null; if (window.console && console.warn) console.warn('All-node WebGL2 unavailable; using flat Canvas.', error); } - } - function updateNodes() { - if (!gl || !nodeProgram || !state.ready) return; - if (state.nodeVertexPositions.length !== state.positions.length) { - state.nodeVertexPositions = new Float32Array(state.positions.length); - } - if (state.nodeColors.length !== state.ids.length * 3) state.nodeColors = new Float32Array(state.ids.length * 3); - if (state.nodeSizes.length !== state.ids.length) state.nodeSizes = new Float32Array(state.ids.length); - for (let index = 0; index < state.ids.length; index += 1) { - const nodeRgb = rgb(nodeColor(index)), colorOffset = index * 3, positionOffset = index * 2; - const visible = (state.nodeVisible.length !== state.ids.length || state.nodeVisible[index]) - && (state.ghosts || !state.nodeGhosts[index]); - state.nodeVertexPositions[positionOffset] = visible - ? state.positions[positionOffset] : Number.NaN; - state.nodeVertexPositions[positionOffset + 1] = visible - ? state.positions[positionOffset + 1] : Number.NaN; - state.nodeColors[colorOffset] = nodeRgb[0]; state.nodeColors[colorOffset + 1] = nodeRgb[1]; state.nodeColors[colorOffset + 2] = nodeRgb[2]; - state.nodeSizes[index] = pointSize(index); - } - gl.bindBuffer(gl.ARRAY_BUFFER, nodeBuffers.position); gl.bufferData(gl.ARRAY_BUFFER, state.nodeVertexPositions, gl.DYNAMIC_DRAW); - gl.bindBuffer(gl.ARRAY_BUFFER, nodeBuffers.color); gl.bufferData(gl.ARRAY_BUFFER, state.nodeColors, gl.DYNAMIC_DRAW); - gl.bindBuffer(gl.ARRAY_BUFFER, nodeBuffers.size); gl.bufferData(gl.ARRAY_BUFFER, state.nodeSizes, gl.DYNAMIC_DRAW); - } - function drawCanvas() { - if (!labelContext) return; - const paint = graphPaint(); - labelContext.setTransform(state.dpr, 0, 0, state.dpr, 0, 0); - labelContext.clearRect(0, 0, state.width, state.height); - labelContext.strokeStyle = paint.canvasEdge; - labelContext.lineWidth = Math.max(0.35, Number(state.settings.linkw || 0.72)) * (state.camera.scale < 1 ? 0.65 : 1); - labelContext.beginPath(); - for (let index = 0; index < state.visibleEdges.length; index += 1) { const edge = state.visibleEdges[index]; if (state.bridges && state.edgeBridges[edge]) continue; const source = state.edgeSources[edge], target = state.edgeTargets[edge], a = screen(state.positions[source * 2], state.positions[source * 2 + 1]), b = screen(state.positions[target * 2], state.positions[target * 2 + 1]); labelContext.moveTo(a[0], a[1]); labelContext.lineTo(b[0], b[1]); } - labelContext.stroke(); - if (state.bridges) { labelContext.strokeStyle = paint.canvasBridge; labelContext.beginPath(); for (let index = 0; index < state.visibleEdges.length; index += 1) { const edge = state.visibleEdges[index]; if (!state.edgeBridges[edge]) continue; const source = state.edgeSources[edge], target = state.edgeTargets[edge], a = screen(state.positions[source * 2], state.positions[source * 2 + 1]), b = screen(state.positions[target * 2], state.positions[target * 2 + 1]); labelContext.moveTo(a[0], a[1]); labelContext.lineTo(b[0], b[1]); } labelContext.stroke(); } - const visible = state.visibleNodes, compact = state.camera.scale < 0.55; - for (let cursor = 0; cursor < visible.length; cursor += 1) { const index = visible[cursor], point = screen(state.positions[index * 2], state.positions[index * 2 + 1]); if (point[0] < -4 || point[0] > state.width + 4 || point[1] < -4 || point[1] > state.height + 4) continue; const radius = compact ? 1.3 : clamp(pointSize(index) * Math.min(1, state.camera.scale), 1, 7); labelContext.fillStyle = nodeColor(index); labelContext.fillRect(point[0] - radius, point[1] - radius, radius * 2, radius * 2); } - } - function updateEdges() { - if (!gl || !edgeProgram) return; - const edges = state.visibleEdges, required = edges.length * 4, paint = graphPaint(); - if (state.edgeVertexPositions.length < required) state.edgeVertexPositions = new Float32Array(required); - gl.bindBuffer(gl.ARRAY_BUFFER, edgeBuffers.position); gl.bufferData(gl.ARRAY_BUFFER, state.edgeVertexPositions.subarray(0, required), gl.STREAM_DRAW); - /* LINES consumes two vertices per relation, and an RGB attribute belongs to each vertex. */ - if (state.edgeColors.length < edges.length * 6) state.edgeColors = new Float32Array(edges.length * 6); - for (let index = 0; index < edges.length; index += 1) { - const bridge = state.bridges && state.edgeBridges[edges[index]]; - const value = rgb(bridge ? paint.webglBridge : paint.webglEdge), offset = index * 6; - state.edgeColors[offset] = value[0]; state.edgeColors[offset + 1] = value[1]; state.edgeColors[offset + 2] = value[2]; - state.edgeColors[offset + 3] = value[0]; state.edgeColors[offset + 4] = value[1]; state.edgeColors[offset + 5] = value[2]; - } - gl.bindBuffer(gl.ARRAY_BUFFER, edgeBuffers.color); gl.bufferData(gl.ARRAY_BUFFER, state.edgeColors.subarray(0, edges.length * 6), gl.DYNAMIC_DRAW); - state.edgeVertexCount = edges.length * 2; - } - function drawWebgl() { - if (!gl || !nodeProgram || !state.ready) return false; gl.clearColor(0, 0, 0, 0); gl.clear(gl.COLOR_BUFFER_BIT); gl.enable(gl.BLEND); gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); - const edges = state.visibleEdges; - if (edges.length) { gl.useProgram(edgeProgram); gl.bindBuffer(gl.ARRAY_BUFFER, edgeBuffers.position); gl.enableVertexAttribArray(edgeBuffers.attrs.position); gl.vertexAttribPointer(edgeBuffers.attrs.position, 2, gl.FLOAT, false, 0, 0); gl.bindBuffer(gl.ARRAY_BUFFER, edgeBuffers.color); gl.enableVertexAttribArray(edgeBuffers.attrs.color); gl.vertexAttribPointer(edgeBuffers.attrs.color, 3, gl.FLOAT, false, 0, 0); gl.uniform2f(edgeBuffers.attrs.camera, state.camera.x, state.camera.y); gl.uniform1f(edgeBuffers.attrs.scale, state.camera.scale * state.dpr); gl.uniform2f(edgeBuffers.attrs.resolution, canvas.width, canvas.height); gl.uniform1f(edgeBuffers.attrs.opacity, graphPaint().webglOpacity); gl.lineWidth(Math.max(1, Number(state.settings.linkw || 0.72) * state.dpr)); gl.drawArrays(gl.LINES, 0, state.edgeVertexCount); } - gl.useProgram(nodeProgram); gl.bindBuffer(gl.ARRAY_BUFFER, nodeBuffers.position); gl.enableVertexAttribArray(nodeBuffers.attrs.position); gl.vertexAttribPointer(nodeBuffers.attrs.position, 2, gl.FLOAT, false, 0, 0); gl.bindBuffer(gl.ARRAY_BUFFER, nodeBuffers.color); gl.enableVertexAttribArray(nodeBuffers.attrs.color); gl.vertexAttribPointer(nodeBuffers.attrs.color, 3, gl.FLOAT, false, 0, 0); gl.bindBuffer(gl.ARRAY_BUFFER, nodeBuffers.size); gl.enableVertexAttribArray(nodeBuffers.attrs.size); gl.vertexAttribPointer(nodeBuffers.attrs.size, 1, gl.FLOAT, false, 0, 0); gl.uniform2f(nodeBuffers.attrs.camera, state.camera.x, state.camera.y); gl.uniform1f(nodeBuffers.attrs.scale, state.camera.scale * state.dpr); gl.uniform2f(nodeBuffers.attrs.resolution, canvas.width, canvas.height); gl.drawArrays(gl.POINTS, 0, state.ids.length); return true; - } - function drawRelationFlow(now) { - if (!labelContext || !state.settings.flow || !state.visibleEdges.length) return; - const speed = clamp(Number(state.settings.flowSpeed || 0), 0, 100); - const moving = speed > 0 && !state.settings.frozen && !state.settings.orbitPaused - && !reducedMotion(); - const stride = Math.max(1, Math.ceil(state.visibleEdges.length / FLOW_EDGE_LIMIT)); - const paint = graphPaint(); - labelContext.save(); - labelContext.globalCompositeOperation = paint.flowComposite; - for (let cursor = 0; cursor < state.visibleEdges.length; cursor += stride) { - const offset = cursor * 4; - const a = screen(state.edgeVertexPositions[offset], state.edgeVertexPositions[offset + 1]); - const b = screen(state.edgeVertexPositions[offset + 2], state.edgeVertexPositions[offset + 3]); - if ((a[0] < -12 && b[0] < -12) || (a[0] > state.width + 12 && b[0] > state.width + 12) - || (a[1] < -12 && b[1] < -12) || (a[1] > state.height + 12 && b[1] > state.height + 12)) continue; - const edge = state.visibleEdges[cursor]; - const phase = moving - ? ((now * (0.00006 + speed * 0.000018) + (edge % 997) / 997) % 1) - : 0.68; - const x = a[0] + (b[0] - a[0]) * phase, y = a[1] + (b[1] - a[1]) * phase; - labelContext.fillStyle = state.bridges && state.edgeBridges[edge] - ? paint.flowBridge : paint.flow; - labelContext.beginPath(); - labelContext.arc(x, y, state.camera.scale < 0.8 ? 1.15 : 1.65, 0, Math.PI * 2); - labelContext.fill(); - } - labelContext.restore(); - } - function drawLabels(clear = false, now = 0) { - if (!labelContext) return; - const paint = graphPaint(); - labelContext.setTransform(state.dpr, 0, 0, state.dpr, 0, 0); - if (clear) labelContext.clearRect(0, 0, state.width, state.height); - drawRelationFlow(now); - labelContext.save(); - const focused = state.focus >= 0 ? state.focus : state.hover; - if (focused >= 0 && focused < state.ids.length) { const point = screen(state.positions[focused * 2], state.positions[focused * 2 + 1]); labelContext.beginPath(); labelContext.arc(point[0], point[1], clamp(7 + state.camera.scale * 2, 7, 15), 0, Math.PI * 2); labelContext.strokeStyle = paint.focus; labelContext.lineWidth = 1.5; labelContext.stroke(); } - if (state.settings.labels) { labelContext.font = `${clamp(Number(state.settings.font || 12) + state.camera.scale * 1.5, 8, 24)}px ui-sans-serif,system-ui,sans-serif`; labelContext.textBaseline = 'middle'; labelContext.fillStyle = paint.label; for (let index = 0; index < state.visibleLabels.length; index += 1) { const item = state.visibleLabels[index], point = screen(state.positions[item * 2], state.positions[item * 2 + 1]); labelContext.fillText(state.labels[item] || state.ids[item], point[0] + 6, point[1] - 6); } } - labelContext.restore(); - } - function clearHover() { - state.hitRequest += 1; - pendingHit = null; - if (hitFrame) { caf(hitFrame); hitFrame = 0; } - state.hover = -1; - element.classList.remove('engraphis-all-node-hover'); - if (gl && nodeProgram && labelContext) { - labelContext.setTransform(state.dpr, 0, 0, state.dpr, 0, 0); - labelContext.clearRect(0, 0, state.width, state.height); - } - schedule(); - } - function flowAnimating() { - return state.settings.flow && state.visibleEdges.length && Number(state.settings.flowSpeed || 0) > 0 - && !state.settings.frozen && !state.settings.orbitPaused && !reducedMotion(); - } - function draw(now = 0) { - state.frame = 0; - if (state.destroyed || state.paused || !state.ready) return; - if (flowAnimating() && state.flowPaintAt && now - state.flowPaintAt < FLOW_FRAME_MS) { - schedule(); return; - } - state.flowPaintAt = now; - const webgl = drawWebgl(); if (!webgl) drawCanvas(); drawLabels(webgl, now); - if (flowAnimating()) schedule(); - } - function schedule() { if (!state.destroyed && !state.paused && !state.frame) state.frame = raf(draw); } - function postCamera(snapshot) { - const revision = ++cameraRevision; - cameraInFlight = revision; - worker.postMessage({ type: 'camera', revision, ...snapshot }); - } - function camera() { - if (!state.ready || state.destroyed) return; - const snapshot = { - x: state.camera.x, y: state.camera.y, scale: state.camera.scale, - width: state.width, height: state.height, - }; - if (cameraInFlight) pendingCamera = snapshot; - else postCamera(snapshot); - schedule(); - } - function completeCamera(message) { - if (message.revision !== undefined && Number(message.revision) !== cameraInFlight) { - return false; - } - cameraInFlight = 0; - const next = pendingCamera; - pendingCamera = null; - if (next) postCamera(next); - return true; - } - function postSettings(relayout, fitLayout = false) { - if (!relayout) { - worker.postMessage({ type: 'settings', settings: state.settings, relayout: false }); - return; - } - pendingLayoutFit = pendingLayoutFit || fitLayout; - if (layoutFrame) return; - /* Range inputs can emit faster than a 20k/200k worker pass can settle. Keep only the - latest values per display frame so stale force calculations never build a queue. */ - layoutFrame = raf(() => { - layoutFrame = 0; - const fit = pendingLayoutFit; pendingLayoutFit = false; - state.layoutPending = true; - if (state.ready) stats({ layoutPending: true }); - worker.postMessage({ type: 'settings', settings: state.settings, relayout: true, fit }); - }); - } - function fit() { if (!state.positions.length) return; const bounds = state.bounds || { minX: state.positions[0], maxX: state.positions[0], minY: state.positions[1], maxY: state.positions[1] }; state.camera.x = (bounds.minX + bounds.maxX) / 2; state.camera.y = (bounds.minY + bounds.maxY) / 2; state.camera.scale = clamp(Math.min(state.width / Math.max(120, bounds.maxX - bounds.minX + 120), state.height / Math.max(120, bounds.maxY - bounds.minY + 120)), 0.03, 4); camera(); } - function stats(extra) { if (typeof opts.onStats === 'function') opts.onStats({ nodes: state.ids.length, visibleNodes: state.visibleNodeCount || state.visibleNodes.length, links: state.totalLinks, drawnLinks: state.drawnLinks, hiddenLinks: Math.max(0, state.totalLinks - state.drawnLinks), collapsed: state.collapsed, lodTier: state.lodTier, relationFlow: state.settings.flow === true, layoutPending: state.layoutPending, presentation: 'all', preset: 'All nodes · LOD', renderer: gl && nodeProgram ? 'webgl2' : 'canvas', ...extra }); } - /* Coalesce pointer samples to the display cadence. Otherwise a high-polling mouse can queue - hundreds of obsolete worker hit tests behind the latest camera request. */ - function requestHit(event) { - if (state.destroyed) return; - pendingHit = { x: event.clientX, y: event.clientY }; - if (hitFrame) return; - hitFrame = raf(() => { - hitFrame = 0; - const sample = pendingHit; - pendingHit = null; - if (!sample || state.destroyed) return; - const rect = element.getBoundingClientRect(); - const point = world(sample.x - rect.left, sample.y - rect.top); - worker.postMessage({ type: 'hit', request: ++state.hitRequest, - x: point[0], y: point[1], scale: state.camera.scale }); - }); - } - function focus(index) { state.focus = index; worker.postMessage({ type: 'focus', index }); camera(); } - function handleWorkerFailure(event) { - if (state.destroyed) return; - const source = event && event.error; - const error = source instanceof Error ? source - : new Error(event && event.type === 'messageerror' - ? 'All-node worker returned an unreadable response.' - : 'All-node worker failed while preparing the graph.'); - error.code = error.code || 'GRAPH_WORKER'; - state.error = { code: error.code, message: error.message }; - state.ready = false; - settleReady(error); - if (typeof opts.onError === 'function') opts.onError(error); - } - worker.addEventListener('error', handleWorkerFailure); - worker.addEventListener('messageerror', handleWorkerFailure); - function handleWorkerMessage(event) { - const message = event.data || {}; - if (message.type === 'capacity') { - const resource = message.resource === 'relations' ? 'relations' : 'nodes'; - const error = new Error(`All-node capacity exceeded: ${message.count.toLocaleString()} ${resource} (limit ${Number(message.limit || (resource === 'relations' ? MAX_LINKS : MAX_NODES)).toLocaleString()}). Filter the graph before loading all nodes.`); - error.code = 'GRAPH_CAPACITY'; - state.error = { code: error.code, message: error.message }; - settleReady(error); - if (typeof opts.onError === 'function') opts.onError(error); - return; - } - if (message.type === 'preview') { - state.ids = message.ids || []; - state.labels = message.labels || []; - state.types = message.types || state.types; - state.positions = message.positions || new Float32Array(0); - state.nodeGhosts = message.nodeGhosts || state.nodeGhosts; - state.bounds = message.bounds || null; - state.communities = message.communities || []; - state.degrees = new Float32Array(state.ids.length); - state.nodeVisible = new Uint8Array(state.ids.length); state.nodeVisible.fill(1); - setVisibleNodes(drawableNodeIndices()); - state.ready = true; - updateNodes(); fit(); stats({ progressive: true, linksPending: true }); - return; - } - if (message.type === 'ready') { - state.ids = message.ids || []; - state.labels = message.labels || []; - state.types = message.types || state.types; - state.positions = message.positions || new Float32Array(0); - state.nodeGhosts = message.nodeGhosts || state.nodeGhosts; - state.bounds = message.bounds || null; - state.degrees = message.degrees || new Float32Array(0); - state.betweenness = message.betweenness || new Float32Array(0); - state.evidenceMass = message.evidenceMass || new Float32Array(0); - state.communities = message.communities || []; - state.edgeSources = message.edgeSources || new Uint32Array(0); - state.edgeTargets = message.edgeTargets || new Uint32Array(0); - state.edgeBridges = message.edgeBridges || new Uint8Array(0); - state.edgeLayers = message.edgeLayers || []; - state.topNodes = message.topNodes || new Uint32Array(0); - state.totalLinks = Number(message.totalLinks || 0); - state.nodeVisible = new Uint8Array(state.ids.length); state.nodeVisible.fill(1); - setVisibleNodes(drawableNodeIndices()); - state.ready = true; - settleReady(); - updateNodes(); fit(); - if (typeof opts.onMetrics === 'function') opts.onMetrics(api.metrics()); - stats({ progressive: true }); - return; - } - if (message.type === 'camera-ack') { - completeCamera(message); - return; - } - if (message.type === 'visible') { - if (!completeCamera(message)) return; - setVisibleNodes(message.nodes || state.visibleNodes); - state.visibleEdges = message.edges || new Uint32Array(0); - state.visibleLabels = message.labels || new Uint32Array(0); - state.edgeVertexPositions = message.edgePositions || new Float32Array(0); - state.drawnLinks = Number(message.drawnLinks || 0); - state.lodTier = message.lodTier || state.lodTier; - state.collapsed = state.collapse === false ? false : message.collapsed === true; - updateNodes(); updateEdges(); stats(); schedule(); - return; - } - if (message.type === 'collapse') { - state.collapsed = state.collapse === false ? false : message.value === true; - if (typeof opts.onCollapseChange === 'function') opts.onCollapseChange(state.collapsed); - stats(); - return; - } - if (message.type === 'hit') { - if (message.request !== state.hitRequest) return; - const next = Number.isInteger(message.index) ? message.index : -1; - if (next === state.hover) return; - state.hover = next; - element.classList.toggle('engraphis-all-node-hover', next >= 0); - if (typeof opts.onHover === 'function') { - opts.onHover(next >= 0 ? nodeAt(next) : null); - } - schedule(); - } - } - worker.onmessage = handleWorkerMessage; - function handleWorkerLayout(event) { - const message = event.data || {}; - if (message.type !== 'layout') return; - state.positions = message.positions || state.positions; - state.bounds = message.bounds || state.bounds; - state.layoutPending = false; - if (!state.ready) return; - updateNodes(); - if (message.fit) fit(); else camera(); - stats({ layoutPending: false }); - schedule(); - } - worker.addEventListener('message', handleWorkerLayout); - canvas.addEventListener('pointerdown', event => { if (event.button !== 0) return; state.drag = { x: event.clientX, y: event.clientY, cameraX: state.camera.x, cameraY: state.camera.y, moved: false }; canvas.setPointerCapture(event.pointerId); event.preventDefault(); }); - canvas.addEventListener('pointermove', event => { if (state.drag) { const dx = event.clientX - state.drag.x, dy = event.clientY - state.drag.y; if (Math.abs(dx) + Math.abs(dy) > 3) state.drag.moved = true; state.camera.x = state.drag.cameraX - dx / state.camera.scale; state.camera.y = state.drag.cameraY - dy / state.camera.scale; camera(); } else requestHit(event); }); - canvas.addEventListener('pointerup', event => { const drag = state.drag; state.drag = null; if (!drag || drag.moved) return; if (state.hover >= 0 && typeof opts.onNodeClick === 'function') opts.onNodeClick(nodeAt(state.hover)); else if (typeof opts.onBackgroundClick === 'function') opts.onBackgroundClick(); }); - canvas.addEventListener('pointerleave', () => { if (!state.drag) clearHover(); }); - canvas.addEventListener('pointerout', event => { if (!state.drag && (!event.relatedTarget || event.relatedTarget !== canvas)) clearHover(); }); - canvas.addEventListener('wheel', event => { const rect = element.getBoundingClientRect(), before = world(event.clientX - rect.left, event.clientY - rect.top), nextScale = clamp(state.camera.scale * Math.exp(-event.deltaY * 0.0012), 0.02, 7); state.camera.scale = nextScale; const after = world(event.clientX - rect.left, event.clientY - rect.top); state.camera.x += before[0] - after[0]; state.camera.y += before[1] - after[1]; camera(); event.preventDefault(); }, { passive: false }); - const observer = typeof ResizeObserver === 'function' ? new ResizeObserver(resize) : null; if (observer) observer.observe(element); else window.addEventListener('resize', resize); initWebgl(); worker.postMessage({ type: 'renderer', canvasFallback: !(gl && nodeProgram) }); resize(); - /* WebGL's default drawing buffer is not preserved and labels live on a second canvas. Paint - once synchronously, then composite both layers while the GPU buffer is still readable. */ - function exportImageCanvas() { - if (state.destroyed || !state.ready) return null; - if (state.frame) { caf(state.frame); state.frame = 0; } - const webgl = drawWebgl(); - if (!webgl) drawCanvas(); - drawLabels(webgl, typeof performance !== 'undefined' ? performance.now() : 0); - const output = document.createElement('canvas'); - output.width = canvas.width; output.height = canvas.height; - const context = output.getContext('2d'); - if (!context) return null; - context.drawImage(canvas, 0, 0); - context.drawImage(labels, 0, 0); - return output; - } - function destroyGraph() { - if (state.destroyed) return; - state.destroyed = true; - state.paused = true; - state.hitRequest += 1; - pendingCamera = null; - cameraInFlight = 0; - const destroyedError = new Error('All-node renderer was destroyed before it became ready.'); - destroyedError.code = 'GRAPH_DESTROYED'; - settleReady(destroyedError); - pendingHit = null; - if (hitFrame) { caf(hitFrame); hitFrame = 0; } - if (layoutFrame) { caf(layoutFrame); layoutFrame = 0; } - if (state.frame) { caf(state.frame); state.frame = 0; } - worker.onmessage = null; - worker.removeEventListener('message', handleWorkerLayout); - worker.removeEventListener('error', handleWorkerFailure); - worker.removeEventListener('messageerror', handleWorkerFailure); - worker.terminate(); - if (observer) observer.disconnect(); - else window.removeEventListener('resize', resize); - if (gl) { - [nodeBuffers.position, nodeBuffers.color, nodeBuffers.size, - edgeBuffers.position, edgeBuffers.color].forEach(buffer => { - if (buffer && typeof gl.deleteBuffer === 'function') gl.deleteBuffer(buffer); - }); - [nodeProgram, edgeProgram].forEach(value => { - if (value && typeof gl.deleteProgram === 'function') gl.deleteProgram(value); - }); - const loseContext = typeof gl.getExtension === 'function' - ? gl.getExtension('WEBGL_lose_context') : null; - if (loseContext && typeof loseContext.loseContext === 'function') loseContext.loseContext(); - } - nodeProgram = edgeProgram = null; - nodeBuffers = {}; edgeBuffers = {}; - state.ids = []; state.labels = []; state.types = []; state.communities = []; - state.positions = state.nodeVertexPositions = new Float32Array(0); - state.nodeVisible = state.nodeGhosts = new Uint8Array(0); - state.nodeColors = state.nodeSizes = new Float32Array(0); - state.visibleNodes = state.visibleEdges = state.visibleLabels = new Uint32Array(0); - element.removeAttribute('data-graph-style'); - element.replaceChildren(); - } - const api = { - exportImageCanvas, - apply(fn, shouldFit) { if (typeof fn === 'function') fn(api); if (shouldFit) fit(); return api; }, - setData(data) { if (state.destroyed) return api; const nodes = Array.isArray(data && data.nodes) ? data.nodes : [], links = Array.isArray(data && data.links) ? data.links : (data && data.edges) || []; state.ready = false; state.error = null; pendingCamera = null; cameraInFlight = 0; renewReadyPromise(); worker.postMessage({ type: 'prepare', payload: { nodes, links } }); return api; }, - whenReady() { return readyPromise; }, - setRenderMode(value) { state.renderMode = value === 'full' ? 'full' : 'all'; return api; }, - setPreset(value) { const preset = PRESETS[value] ? value : 'communities'; const next = { ...state.settings, ...PRESETS[preset], mode: preset }; state.settings = next; pendingLayoutFit = true; postSettings(true, true); updateNodes(); schedule(); return { ...next }; }, - setStyle(value) { state.styleName = value || state.styleName; element.setAttribute('data-graph-style', state.styleName); updateNodes(); updateEdges(); schedule(); return api; }, - setColorBy(value) { state.colorBy = value || state.colorBy; updateNodes(); schedule(); return api; }, - setPalette(value) { state.palette = typeof value === 'string' ? value : state.palette; if (state.palette !== 'custom') state.typeColors = {}; updateNodes(); schedule(); return api; }, - setTypeColors(value) { state.typeColors = value && typeof value === 'object' ? { ...state.typeColors, ...value } : {}; updateNodes(); schedule(); return api; }, - setThemeColors(value) { state.themeColors = value && typeof value === 'object' ? { ...value } : {}; state.lightSurface = isLightColor(state.themeColors.canvas || state.themeColors.surface); updateNodes(); updateEdges(); schedule(); return api; }, - setSettings(value) { const patch = value || {}; state.settings = { ...state.settings, ...patch }; state.flowPaintAt = 0; const relayout = Object.keys(patch).some(key => ['mode', 'repel', 'link', 'gravity', 'gravitationalConstant', 'localGravitationalConstant', 'blackHoleMass', 'damping', 'springStiffness'].includes(key)); postSettings(relayout); updateNodes(); camera(); schedule(); return api; }, - setScope(value) { state.scope = value && typeof value === 'object' ? { ...state.scope, ...value } : { minDegree: 1, showUnlinked: true, depth: 2 }; worker.postMessage({ type: 'scope', scope: state.scope }); camera(); return api; }, - setRepoFilter(value) { state.repoFilter = String(value || '').slice(0, 200); return api; }, - setAsOf(value) { state.asOf = value || null; return api; }, - setSizeBy(value) { state.sizeBy = ['degree', 'betweenness', 'evidence_mass'].includes(value) ? value : 'degree'; updateNodes(); schedule(); return api; }, - setBridges(value) { state.bridges = value !== false; updateEdges(); if (typeof opts.onMetrics === 'function') opts.onMetrics(api.metrics()); schedule(); return api; }, - setCollapse(value) { state.collapse = value === true ? true : value === 'auto' ? 'auto' : false; worker.postMessage({ type: 'collapse', value: state.collapse }); camera(); return api; }, - setGhosts(value) { state.ghosts = value !== false; setVisibleNodes(drawableNodeIndices()); updateNodes(); worker.postMessage({ type: 'ghosts', value: state.ghosts }); camera(); schedule(); return api; }, - setLayers(value) { state.layers = value || null; worker.postMessage({ type: 'layers', layers: state.layers }); camera(); return api; }, setHighlight(id) { focus(state.ids.indexOf(String(id))); return api; }, clearFocus() { focus(-1); return api; }, reveal(id) { const index = state.ids.indexOf(String(id)); if (index < 0) return false; state.camera.x = state.positions[index * 2]; state.camera.y = state.positions[index * 2 + 1]; state.camera.scale = Math.max(1.2, state.camera.scale); focus(index); return true; }, focus(id) { return api.reveal(id); }, zoomToNode(id) { return api.reveal(id); }, communityMap() { const result = {}; state.ids.forEach((id, index) => { result[id] = state.communities[index] || index; }); return result; }, resize, fit, reheat() { if (state.settings.frozen) return api; state.layoutPending = true; stats({ layoutPending: true }); worker.postMessage({ type: 'reheat' }); return api; }, freeze(value = true) { state.settings.frozen = value !== false; return api.setSettings({ frozen: state.settings.frozen }); }, pause() { state.paused = true; if (state.frame) { caf(state.frame); state.frame = 0; } return api; }, resume() { state.paused = false; schedule(); return api; }, state() { return { mode: 'all', presentation: 'all', nodeCount: state.ids.length, visibleNodeCount: state.visibleNodeCount, edgeCount: state.totalLinks, drawnEdgeCount: state.drawnLinks, renderer: gl && nodeProgram ? 'webgl2' : 'canvas', collapsed: state.collapsed, collapse: state.collapse, scope: { ...state.scope }, relationFlow: state.settings.flow === true, flowSpeed: Number(state.settings.flowSpeed || 0), layoutPending: state.layoutPending, frozen: state.settings.frozen === true, paused: state.paused === true }; }, metrics() { const bridges = state.edgeBridges.reduce((count, value) => count + (value ? 1 : 0), 0); return { ...api.state(), bridges, top: Array.from(state.topNodes.slice(0, 5), node => ({ id: state.ids[node], name: state.labels[node], score: state.degrees[node] || 0 })) }; }, physicsDiagnostics() { return { mode: 'all', simulation: false, layout: 'deterministic-worker', controls: 'bounded-layout-forces', relationFlow: state.settings.flow === true, frozen: state.settings.frozen === true, paused: state.paused === true }; }, graphToScreen(x, y) { return { x: (Number(x) - state.camera.x) * state.camera.scale + state.width / 2, y: (Number(y) - state.camera.y) * state.camera.scale + state.height / 2 }; }, getPhysicsSnapshot() { const nodes = []; const limit = Math.min(128, state.topNodes.length); for (let index = 0; index < limit; index += 1) { const node = state.topNodes[index]; nodes.push({ id: state.ids[node], x: state.positions[node * 2], y: state.positions[node * 2 + 1], vx: 0, vy: 0, radius: pointSize(node), communityId: state.communities[node] }); } return { center: null, nodes, systemAnchors: [], paused: state.settings.frozen === true || state.paused === true, diagnostics: api.physicsDiagnostics() }; }, destroy: destroyGraph, - }; - return api; - } - window.EngraphisAllGraph = { create, MAX_NODES, MAX_LINKS }; -})(); diff --git a/engraphis/dashboard_assets/engraphis-graph-every-worker.js b/engraphis/dashboard_assets/engraphis-graph-every-worker.js new file mode 100644 index 00000000..7028eb13 --- /dev/null +++ b/engraphis/dashboard_assets/engraphis-graph-every-worker.js @@ -0,0 +1,429 @@ +/* Deterministic layout worker for the Every-node engine. It owns only what must leave the + main thread: capacity validation, typed-array compaction, deterministic community-seeded + placement, and bounded streamed relaxation. Nothing here is per-frame: camera moves, + picking, and LOD shading decisions belong to the renderer and its shaders, so this worker + goes silent the moment a layout settles. */ +(function () { + 'use strict'; + + const MAX_NODES = 20000; + const MAX_LINKS = 200000; + const REFINE_PASSES = 26; + const BROADCAST_EVERY = 4; + /* The scene reads as a map, not a cluster: every spacing constant is multiplied out so + communities breathe and single relations stretch into visible journeys. */ + const SPACING = 13; + const MAP_SCALE = 3; + const BRIDGE_LIMIT = 512; + /* Centroid separation is useful while communities are few, but its exact pair pass is + intentionally bounded. Once every node is its own (or a very small) community, the node + spatial hash below is the safer O(n) separation mechanism; an unbounded centroid pass would + turn an otherwise supported 20k-node payload into a quadratic stall. */ + const MAX_CENTROID_GROUPS = 512; + + let model = null; + let settings = { repel: 48, link: 16, gravity: 48 }; + let generation = 0; + + function post(message) { self.postMessage(message); } + + /* Endpoint ids may be any JSON value including falsy ones such as 0 or false; string keys + keep every id addressable while preserving insertion order of first sight. */ + function stableKey(value) { + if (typeof value === 'string') return value; + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + if (value && typeof value === 'object' && 'id' in value) return stableKey(value.id); + return ''; + } + + function hash32(text) { + let h = 2166136261 >>> 0; + for (let index = 0; index < text.length; index += 1) { + h ^= text.charCodeAt(index); + h = Math.imul(h, 16777619); + } + return h >>> 0; + } + + function mulberry32(seed) { + let state = seed >>> 0; + return function () { + state = (state + 0x6D2B79F5) | 0; + let t = Math.imul(state ^ (state >>> 15), 1 | state); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; + } + + function buildModel(payload) { + const nodes = Array.isArray(payload && payload.nodes) ? payload.nodes : []; + const rawLinks = Array.isArray(payload && payload.links) ? payload.links + : Array.isArray(payload && payload.edges) ? payload.edges : []; + if (nodes.length > MAX_NODES) { post({ type: 'capacity', resource: 'nodes', count: nodes.length, limit: MAX_NODES }); return null; } + if (rawLinks.length > MAX_LINKS) { post({ type: 'capacity', resource: 'relations', count: rawLinks.length, limit: MAX_LINKS }); return null; } + + const count = nodes.length; + const ids = new Array(count); + const indexById = new Map(); + for (let index = 0; index < count; index += 1) { + const raw = nodes[index]; + const id = stableKey(raw && raw.id !== undefined ? raw.id : index); + ids[index] = id; + if (!indexById.has(id)) indexById.set(id, index); + } + + const labels = new Array(count); + const types = new Array(count).fill(''); + const ghostFlags = new Uint8Array(count); + const communities = new Array(count); + const evidenceMass = new Float32Array(count); + const communityIndex = new Map(); + for (let index = 0; index < count; index += 1) { + const node = nodes[index] || {}; + labels[index] = String(node.name || node.label || ids[index]); + types[index] = String(node.type || ''); + ghostFlags[index] = node.ghost ? 1 : 0; + /* Untagged nodes share one implicit district so centroid separation stays + O(distinct community tags), rather than becoming an O(n^2) node pair loop. */ + const group = node.community_id !== undefined && node.community_id !== null + ? String(node.community_id) : null; + if (!communityIndex.has(group)) communityIndex.set(group, communityIndex.size); + communities[index] = communityIndex.get(group); + const mass = Number(node.evidence_mass); + evidenceMass[index] = Number.isFinite(mass) && mass > 0 ? mass : 0; + } + + const degreeCounts = new Uint32Array(count); + const sources = []; + const targets = []; + const weights = []; + const relations = []; + const edgeLayers = []; + const edgeGhosts = []; + for (let index = 0; index < rawLinks.length; index += 1) { + const link = rawLinks[index] || {}; + const source = indexById.get(stableKey(link.source)); + const target = indexById.get(stableKey(link.target)); + if (source === undefined || target === undefined) continue; + sources.push(source); + targets.push(target); + const weight = Number(link.weight); + weights.push(Number.isFinite(weight) && weight > 0 ? weight : 1); + relations.push(String(link.relation || link.label || "")); + edgeLayers.push(String(link.layer || 'semantic')); + edgeGhosts.push(link.ghost === true ? 1 : 0); + degreeCounts[source] += 1; + degreeCounts[target] += 1; + } + + /* Bridges keep distant clusters visually connected: the strongest cross-community + relations win a bounded budget so far-out zoom still reads one connected scene. */ + const linkCount = sources.length; + const edgeBridges = new Uint8Array(linkCount); + const candidates = []; + for (let index = 0; index < linkCount; index += 1) { + if (communities[sources[index]] !== communities[targets[index]]) candidates.push([index, weights[index]]); + } + candidates.sort((a, b) => b[1] - a[1]); + const bridgeBudget = Math.min(candidates.length, BRIDGE_LIMIT); + for (let index = 0; index < bridgeBudget; index += 1) edgeBridges[candidates[index][0]] = 1; + + const degrees = new Float32Array(count); + degrees.set(degreeCounts); + + const topNodes = new Uint32Array(count); + for (let index = 0; index < count; index += 1) topNodes[index] = index; + topNodes.sort((a, b) => degrees[b] - degrees[a]); + + let maxDegree = 0; + for (let index = 0; index < count; index += 1) maxDegree = Math.max(maxDegree, degrees[index]); + const betweenness = new Float32Array(count); + if (maxDegree > 0) { + const scale = Math.log1p(maxDegree); + for (let index = 0; index < count; index += 1) betweenness[index] = Math.log1p(degrees[index]) / scale; + } + + return { + count, ids, labels, types, ghostFlags, communities, evidenceMass, + degrees, betweenness, topNodes, + sources: Uint32Array.from(sources), + targets: Uint32Array.from(targets), + weights: Float32Array.from(weights), + relations, + edgeLayers, + edgeGhosts: Uint8Array.from(edgeGhosts), + edgeBridges, + totalLinks: linkCount, + positions: null, bounds: null, dx: null, dy: null, + }; + } + + function seedPositions() { + const positions = new Float32Array(model.count * 2); + const sizes = new Map(); + for (let index = 0; index < model.count; index += 1) { + const group = model.communities[index]; + sizes.set(group, (sizes.get(group) || 0) + 1); + } + const order = Array.from(sizes.keys()).sort((a, b) => sizes.get(b) - sizes.get(a)); + const centreSlot = new Map(); + order.forEach((group, slot) => centreSlot.set(group, slot)); + + /* Large communities claim the middle of a sunflower spiral; members fan out on their own + golden-angle disc so even the seed layout is readable before relaxation touches it. */ + /* Districts read tighter than their spacing: members pack into compact discs while + centres ride a much wider spiral, so communities look like distinct regions. */ + const scaledSpacing = SPACING * MAP_SCALE; + const spread = scaledSpacing * 6.5 * Math.sqrt(Math.max(1, model.count)); + const goldenAngle = 2.399963229728653; + const random = mulberry32(0x9E3779B9 ^ Math.imul(model.count + 1, 2654435761)); + const memberCursor = new Map(); + + for (let index = 0; index < model.count; index += 1) { + const group = model.communities[index]; + const slot = centreSlot.get(group); + const angle = slot * goldenAngle; + const radius = slot === 0 ? 0 : spread * Math.sqrt(slot / order.length); + const cursor = memberCursor.get(group) || 0; + memberCursor.set(group, cursor + 1); + const localAngle = cursor * goldenAngle + random() * 0.4; + const localRadius = scaledSpacing * 0.85 * Math.sqrt(cursor + 0.3); + positions[index * 2] = Math.cos(angle) * radius + Math.cos(localAngle) * localRadius; + positions[index * 2 + 1] = Math.sin(angle) * radius + Math.sin(localAngle) * localRadius; + } + model.positions = positions; + } + + function computeBounds() { + const positions = model.positions, count = model.count; + if (!count) { model.bounds = { minX: -60, maxX: 60, minY: -60, maxY: 60 }; return; } + let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; + for (let index = 0; index < count; index += 1) { + const x = positions[index * 2], y = positions[index * 2 + 1]; + if (x < minX) minX = x; if (x > maxX) maxX = x; + if (y < minY) minY = y; if (y > maxY) maxY = y; + } + model.bounds = { minX, maxX, minY, maxY }; + } + + function relaxPass() { + const count = model.count; + if (!count) return; + const pos = model.positions, dx = model.dx, dy = model.dy; + dx.fill(0); dy.fill(0); + + /* Springs pull linked pairs toward a rest length scaled by the link-distance control. + Intra-community springs run strong so districts hold their shape; cross-community + springs run weak — they are visual routes between districts, not licence to drag + the districts into one another over the settle passes. */ + const scaledSpacing = SPACING * MAP_SCALE; + const rest = Math.max(scaledSpacing * 1.9, Number(settings.link) * 1.6 * (MAP_SCALE * 0.55)); + for (let edge = 0; edge < model.totalLinks; edge += 1) { + const a = model.sources[edge], b = model.targets[edge]; + const ddx = pos[b * 2] - pos[a * 2], ddy = pos[b * 2 + 1] - pos[a * 2 + 1]; + const dist = Math.sqrt(ddx * ddx + ddy * ddy) || 0.0001; + const crossCommunity = + model.communities[a] !== model.communities[b] ? 0.02 : 0.07; + const force = (dist - rest) / dist * crossCommunity; + dx[a] -= ddx * force; dy[a] -= ddy * force; + dx[b] += ddx * force; dy[b] += ddy * force; + } + + /* Local repulsion through a spatial hash with a per-node visit cap keeps each pass O(n) + regardless of density; global repulsion is neither affordable nor readable at scale. */ + const cell = SPACING * MAP_SCALE * 2.2; + const grid = new Map(); + for (let index = 0; index < count; index += 1) { + const key = (Math.floor(pos[index * 2] / cell) + 32768) * 65536 + + (Math.floor(pos[index * 2 + 1] / cell) + 32768); + const bucket = grid.get(key); + if (bucket) bucket.push(index); else grid.set(key, [index]); + } + const minDist = SPACING * MAP_SCALE * 1.55; + const minDist2 = minDist * minDist; + const push = Number(settings.repel) / 48; + for (let index = 0; index < count; index += 1) { + const gx = Math.floor(pos[index * 2] / cell), gy = Math.floor(pos[index * 2 + 1] / cell); + let checked = 0; + for (let ox = -1; ox <= 1 && checked < 14; ox += 1) { + for (let oy = -1; oy <= 1 && checked < 14; oy += 1) { + const bucket = grid.get((gx + ox + 32768) * 65536 + (gy + oy + 32768)); + if (!bucket) continue; + for (let slot = 0; slot < bucket.length && checked < 14; slot += 1) { + const other = bucket[slot]; + if (other === index) continue; + checked += 1; + const ddx = pos[index * 2] - pos[other * 2]; + const ddy = pos[index * 2 + 1] - pos[other * 2 + 1]; + const d2 = ddx * ddx + ddy * ddy; + if (d2 > minDist2 || d2 === 0) continue; + const dist = Math.sqrt(d2) || 0.001; + const f = (minDist - dist) / dist * 0.10 * push; + dx[index] += ddx * f; dy[index] += ddy * f; + } + } + } + } + + let cx = 0, cy = 0; + for (let index = 0; index < count; index += 1) { cx += pos[index * 2]; cy += pos[index * 2 + 1]; } + cx /= count; cy /= count; + + /* District-level separation: repelling community CENTROIDS moves whole neighbourhoods + apart as units — node-level repulsion alone cannot, its range is far too short. + Centroids and member lists are recomputed per pass from live positions. */ + const stats = { list: [] }; + { + const map = new Map(); + for (let index = 0; index < count; index += 1) { + const group = model.communities[index]; + const entry = map.get(group); + if (entry) { entry.x += pos[index * 2]; entry.y += pos[index * 2 + 1]; entry.n += 1; } + else map.set(group, { x: pos[index * 2], y: pos[index * 2 + 1], n: 1 }); + } + const slots = new Map(); + let slot = 0; + for (const [group, entry] of map) { + slots.set(group, slot); + stats.list.push({ + x: entry.x / entry.n, y: entry.y / entry.n, + r: scaledSpacing * 0.95 * Math.sqrt(entry.n), members: [], + }); + slot += 1; + } + for (let index = 0; index < count; index += 1) { + stats.list[slots.get(model.communities[index])].members.push(index); + } + } + const separation = scaledSpacing * 2.6; + const pushStrength = 0.05; + if (stats.list.length <= MAX_CENTROID_GROUPS) { + for (let a = 0; a < stats.list.length; a += 1) { + for (let b = a + 1; b < stats.list.length; b += 1) { + const A = stats.list[a], B = stats.list[b]; + const ddx = B.x - A.x, ddy = B.y - A.y; + const dist = Math.sqrt(ddx * ddx + ddy * ddy) || 0.001; + const desired = separation + A.r + B.r; + if (dist >= desired) continue; + const f = (desired - dist) / dist * pushStrength; + const fx = ddx * f / A.members.length, fy = ddy * f / A.members.length; + const gx = ddx * f / B.members.length, gy = ddy * f / B.members.length; + for (const index of A.members) { dx[index] -= fx; dy[index] -= fy; } + for (const index of B.members) { dx[index] += gx; dy[index] += gy; } + } + } + } + + const gravity = Number(settings.gravity) / 48 * 0.0015; + for (let index = 0; index < count; index += 1) { + dx[index] += (cx - pos[index * 2]) * gravity; + dy[index] += (cy - pos[index * 2 + 1]) * gravity; + } + + /* A tight per-pass step cap keeps the settle from smearing district boundaries. */ + const damp = 0.8, maxStep = SPACING * MAP_SCALE * 0.7; + for (let index = 0; index < count; index += 1) { + let vx = dx[index] * damp, vy = dy[index] * damp; + const speed = Math.sqrt(vx * vx + vy * vy); + if (speed > maxStep) { vx = vx / speed * maxStep; vy = vy / speed * maxStep; } + pos[index * 2] += vx; + pos[index * 2 + 1] += vy; + } + } + + function refine(gen, fitFinal) { + let pass = 0; + const step = () => { + if (gen !== generation) return; + pass += 1; + relaxPass(); + computeBounds(); + post({ type: 'progress', pass, total: REFINE_PASSES }); + if (pass % BROADCAST_EVERY === 0 || pass === REFINE_PASSES) { + post({ + type: 'layout', + positions: model.positions.slice(), + bounds: { ...model.bounds }, + pass, + total: REFINE_PASSES, + fit: pass === REFINE_PASSES ? fitFinal === true : false, + }); + } + if (pass < REFINE_PASSES) setTimeout(step, 0); + }; + setTimeout(step, 0); + } + + self.onmessage = (event) => { + const data = event.data || {}; + if (data.type === 'prepare') { + generation += 1; + const gen = generation; + /* A rejected replacement must not leave the previous model available to a later + settings/reheat message after the renderer has entered its error state. */ + model = null; + model = buildModel(data.payload || {}); + if (!model) return; + seedPositions(); + model.dx = new Float32Array(model.count); + model.dy = new Float32Array(model.count); + computeBounds(); + post({ + type: 'preview', + ids: model.ids.slice(), + labels: model.labels.slice(), + types: model.types.slice(), + positions: model.positions.slice(), + bounds: { ...model.bounds }, + nodeGhosts: model.ghostFlags.slice(), + communities: model.communities.slice(), + }); + post({ + type: 'ready', + ids: model.ids, + labels: model.labels, + types: model.types, + positions: model.positions, + bounds: { ...model.bounds }, + nodeGhosts: model.ghostFlags, + communities: model.communities, + degrees: model.degrees, + betweenness: model.betweenness, + evidenceMass: model.evidenceMass, + edgeSources: model.sources, + edgeTargets: model.targets, + edgeBridges: model.edgeBridges, + edgeGhosts: model.edgeGhosts, + edgeWeights: model.weights, + edgeRelations: model.relations, + edgeLayers: model.edgeLayers, + topNodes: model.topNodes, + totalLinks: model.totalLinks, + }); + refine(gen, true); + return; + } + if (data.type === 'settings') { + const next = data.settings || {}; + settings = { + repel: Number.isFinite(Number(next.repel)) ? Number(next.repel) : settings.repel, + link: Number.isFinite(Number(next.link)) ? Number(next.link) : settings.link, + gravity: Number.isFinite(Number(next.gravity)) ? Number(next.gravity) : settings.gravity, + }; + if (data.relayout && model) { + generation += 1; + const gen = generation; + seedPositions(); + computeBounds(); + post({ type: 'layout', positions: model.positions.slice(), bounds: { ...model.bounds }, fit: false }); + refine(gen, data.fit === true); + } + return; + } + if (data.type === 'reheat') { + if (!model) return; + generation += 1; + refine(generation, false); + } + }; +})(); diff --git a/engraphis/dashboard_assets/engraphis-graph-every.js b/engraphis/dashboard_assets/engraphis-graph-every.js new file mode 100644 index 00000000..ed508c7c --- /dev/null +++ b/engraphis/dashboard_assets/engraphis-graph-every.js @@ -0,0 +1,1529 @@ +/* Every-node renderer: an ultra high performance WebGL2 presentation for complete graphs. + Design contract: ALL geometry is uploaded once and only re-uploaded when data, layout, + colours, or filters change; the camera lives entirely in uniforms so pan/zoom stays + frame-rate independent of node count; zoom-out readability comes from additive glow + density instead of shrinking datasets; picking is a local spatial grid with no worker + round-trip; labels are decluttered on a 2D overlay by screen-space occupancy. + WebGL2 is required — hosts show their own unsupported card via onError. */ +(function () { + 'use strict'; + + const WORKER_URL = '/v2-assets/engraphis-graph-every-worker.js?v=20260823-every-19'; + const MAX_NODES = 20000; + const MAX_LINKS = 200000; + const LABEL_MAX = 220; + const LABEL_CANDIDATE_MAX = LABEL_MAX * 8; + const FLOW_EDGE_LIMIT = 900; + const REGION_LIMIT = 512; + const FLOW_FRAME_MS = 34; + /* Zoom bands are RATIOS of the current camera scale to the fitted-scene scale, so the + LOD behaviour is identical whether the world holds 500 or 20,000 nodes: below ~half + of fit the scene melts into additive glow density; edges fade in from ~45% of fit. */ + const GLOW_END = 0.55; + const EDGE_START = 0.45; + + const PALETTES = { + cyber: ['#4bd8df', '#9a7cff', '#ed6fc2', '#6fe6b0', '#f0c674', '#6ba8ff'], + galaxy: ['#72a8ff', '#9a87ff', '#d987ff', '#59d5e7', '#8ee3c7', '#f4c978'], + solar: ['#e8a05c', '#e17f65', '#f2c66d', '#d36d8f', '#d9d28b', '#e99767'], + classic: ['#9ab2c7', '#839db2', '#b0a4c8', '#7aa7a6', '#c0aa7b', '#8aa6c9'], + }; + const TYPE_COLORS = { person_or_concept: '#8d82e3', mention: '#5ba1a6', hashtag: '#c9a15b', email: '#8eb3e6', organization: '#d48173', location: '#7ebf8e', memory: '#5ba1a6', repo: '#c9a15b', file: '#8eb3e6' }; + const PRESETS = { + galaxy: { repel: 60, link: 8, gravity: 48, font: 12, size: 3, linkw: 0.72 }, + original: { repel: 120, link: 30, gravity: 14, font: 13, size: 3, linkw: 1 }, + compact: { repel: 42, link: 20, gravity: 26, font: 12, size: 3, linkw: 0.7 }, + communities: { repel: 48, link: 16, gravity: 48, font: 12, size: 3, linkw: 0.72 }, + radial: { repel: 68, link: 26, gravity: 12, font: 13, size: 3, linkw: 0.75 }, + constellation: { repel: 34, link: 16, gravity: 38, font: 12, size: 3, linkw: 0.65 }, + every: { repel: 48, link: 16, gravity: 48, font: 12, size: 3, linkw: 0.72 }, + }; + + const raf = window.requestAnimationFrame || (callback => window.setTimeout(callback, 16)); + const caf = window.cancelAnimationFrame || (handle => window.clearTimeout(handle)); + const clamp = (value, low, high) => Math.max(low, Math.min(high, value)); + const color = value => /^#[0-9a-f]{6}$/i.test(String(value || '')) ? String(value) : '#86a8bf'; + const rgb = value => { + const text = color(value).slice(1); + return [parseInt(text.slice(0, 2), 16) / 255, parseInt(text.slice(2, 4), 16) / 255, parseInt(text.slice(4, 6), 16) / 255]; + }; + + const NODE_VS = `#version 300 es + in vec2 a_position; in vec3 a_color; in float a_size; in float a_flag; + uniform vec2 u_camera; uniform float u_scale; uniform vec2 u_resolution; uniform float u_glow; + out vec3 v_color; out float v_alpha; out float v_glow; + void main(){ + bool alive = a_flag > 0.5; + v_glow = u_glow; + vec2 px = (a_position - u_camera) * u_scale + u_resolution * 0.5; + vec2 clip = px / u_resolution * 2.0 - 1.0; + gl_Position = alive ? vec4(clip.x, -clip.y, 0.0, 1.0) : vec4(0.0, 0.0, 2.0, 1.0); + gl_PointSize = alive ? clamp(a_size * (1.0 + u_glow * 2.4) * u_scale, 1.0, 90.0) : 0.0; + v_color = a_color; + float dim = a_flag > 1.5 ? 0.10 : 1.0; + v_alpha = alive ? mix(0.92, 0.15, u_glow) * dim : 0.0; + }`; + const NODE_FS = `#version 300 es + precision mediump float; + in vec3 v_color; in float v_alpha; in float v_glow; out vec4 outputColor; + void main(){ + vec2 p = gl_PointCoord - 0.5; + float r2 = dot(p, p); + float core = 1.0 - smoothstep(0.16, 0.5, r2); + float halo = 1.0 - smoothstep(0.0, 0.5, r2); + float alpha = mix(core, halo * halo, v_glow) * v_alpha; + if (alpha < 0.004) discard; + outputColor = vec4(v_color, alpha); + }`; + const EDGE_VS = `#version 300 es + in vec2 a_position; in float a_factor; in float a_visible; + uniform vec2 u_camera; uniform float u_scale; uniform vec2 u_resolution; + uniform vec2 u_hotA; uniform float u_hotAOn; + uniform vec2 u_hotB; uniform float u_hotBOn; + out float v_factor; out float v_visible; + void main(){ + vec2 px = (a_position - u_camera) * u_scale + u_resolution * 0.5; + vec2 clip = px / u_resolution * 2.0 - 1.0; + gl_Position = vec4(clip.x, -clip.y, 0.0, 1.0); + /* An endpoint sitting exactly on the hovered or highlighted node's coordinates marks + the relation hot without touching any buffer: edge endpoints share node positions. */ + bool hot = (u_hotAOn > 0.5 && distance(a_position, u_hotA) < 0.001) + || (u_hotBOn > 0.5 && distance(a_position, u_hotB) < 0.001); + v_factor = hot ? a_factor + 10.0 : a_factor; + v_visible = a_visible; + }`; + const EDGE_FS = `#version 300 es + precision mediump float; + in float v_factor; in float v_visible; + uniform float u_edgeAlpha; uniform float u_focusFade; uniform float u_weightFloor; + out vec4 outputColor; + void main(){ + if (v_visible < 0.5) discard; + bool bridge = v_factor >= 9.5; + /* A map reveals routes progressively: far out only the strongest relations survive, + and the floor drops as you zoom until every relation is drawn. */ + if (!bridge && v_factor < u_weightFloor) discard; + vec3 steel = vec3(0.389, 0.561, 0.651); + vec3 gold = vec3(0.957, 0.827, 0.498); + vec3 tint = mix(steel, gold, step(0.5, v_factor)); + float alpha = u_edgeAlpha * mix(1.0, 2.6, step(0.5, v_factor)) * (bridge ? 2.2 : u_focusFade); + if (alpha < 0.004) discard; + outputColor = vec4(tint, min(alpha, 0.85)); + }`; + + function create(element, options) { + if (!element) throw new Error('every-node renderer requires a host element'); + const opts = options || {}; + const underlay = document.createElement('canvas'); + const canvas = document.createElement('canvas'), labels = document.createElement('canvas'); + canvas.className = 'engraphis-all-canvas'; + labels.className = 'engraphis-all-labels'; + underlay.className = 'engraphis-all-underlay'; + /* The GL canvas carries the scene's role/label (set in handleWorkerMessage), + so it must stay in the accessibility tree; only the label/underlay layers + are decorative. */ + labels.setAttribute('aria-hidden', 'true'); + underlay.setAttribute('aria-hidden', 'true'); + /* Screen-reader surface: the live region announces the scene summary and hovered + entity, and the host carries a descriptive label. Declared before first use. */ + const liveRegion = document.createElement('div'); + liveRegion.className = 'sr-only'; + liveRegion.setAttribute('aria-live', 'polite'); + /* Region hulls live UNDER the GL scene; node points and lit paths sit in the middle; + decluttered labels, flow markers, arrows, and the hover card paint on top. */ + element.replaceChildren(underlay, canvas, labels); + element.appendChild(liveRegion); + const underlayContext = underlay.getContext('2d'); + element.setAttribute('data-graph-style', opts.style || 'cyber'); + const gl = canvas.getContext('webgl2', { antialias: false, alpha: true, powerPreference: 'high-performance' }); + const labelContext = labels.getContext('2d'); + + const state = { + ids: [], idIndex: new Map(), labels: [], types: [], communities: [], + positions: new Float32Array(0), bounds: null, + nodeGhosts: new Uint8Array(0), nodeFlags: new Float32Array(0), + nodeColors: new Float32Array(0), nodeSizes: new Float32Array(0), + degrees: new Float32Array(0), betweenness: new Float32Array(0), evidenceMass: new Float32Array(0), + topNodes: new Uint32Array(0), + edgeSources: new Uint32Array(0), edgeTargets: new Uint32Array(0), + edgeBridges: new Uint8Array(0), edgeWeights: new Float32Array(0), + edgeGhosts: new Uint8Array(0), edgeRelations: [], edgeLayers: [], layers: null, + totalLinks: 0, edgeVertexCount: 0, + camera: { x: 0, y: 0, scale: 1 }, baseScale: 1, width: 1, height: 1, dpr: 1, + styleName: opts.style || 'cyber', colorBy: 'community', typeColors: {}, themeColors: {}, palette: 'theme', + settings: { labels: true, flow: false, flowSpeed: 45, frozen: false, mode: 'communities', repel: 48, link: 16, gravity: 48, font: 12, size: 3, linkw: 0.72 }, + sizeBy: 'degree', bridges: true, ghosts: true, + scope: { minDegree: 0, showUnlinked: true, depth: 2 }, + collapse: false, collapsed: false, + focus: -1, hover: -1, hoverPoint: [0, 0], focusPoint: [0, 0], + neighbors: null, incidentEdges: null, connectionHighlights: null, ready: false, visibleCount: 0, + frame: 0, labelFrame: 0, flowPaintAt: 0, layoutPending: false, lastLabelKey: '', labelLayout: [], + drag: null, pickGrid: null, pickDirty: true, + destroyed: false, paused: false, unsupported: !gl, error: null, + labelMetrics: new Map(), + }; + + let worker = null, nodeProgram = null, edgeProgram = null; + let nodeBuffers = {}, edgeBuffers = {}; + let hitFrame = 0, pendingHit = null, layoutFrame = 0, pendingLayoutFit = false; + + const reducedMotion = () => { + if (typeof opts.reducedMotion === 'function') return opts.reducedMotion() === true; + if (opts.reducedMotion === true) return true; + return typeof window.matchMedia === 'function' + && window.matchMedia('(prefers-reduced-motion: reduce)').matches; + }; + const screen = (x, y) => [(x - state.camera.x) * state.camera.scale + state.width / 2, (y - state.camera.y) * state.camera.scale + state.height / 2]; + const world = (x, y) => [(x - state.width / 2) / state.camera.scale + state.camera.x, (y - state.height / 2) / state.camera.scale + state.camera.y]; + const zoomRatio = () => { + if (!state.baseScale) return 1; + return state.camera.scale / state.baseScale; + }; + const glowAmount = () => clamp((GLOW_END - zoomRatio()) / GLOW_END, 0, 1); + + function rebuildIdIndex() { + state.idIndex = new Map(); + for (let index = 0; index < state.ids.length; index += 1) { + if (!state.idIndex.has(state.ids[index])) state.idIndex.set(state.ids[index], index); + } + } + function nodeAt(index) { + return { id: state.ids[index], label: state.labels[index] || state.ids[index], type: state.types[index] || 'person_or_concept' }; + } + function activePalette() { + if (state.palette === 'ember') return PALETTES.solar; + if (state.palette === 'ocean') return PALETTES.classic; + if (state.palette === 'contrast') return ['#ffffff', '#8fe8ff', '#ffd166', '#ff7aa2', '#b9ffb0', '#d6b3ff']; + if (state.palette === 'aurora') return PALETTES.cyber; + return PALETTES[state.styleName] || PALETTES.cyber; + } + function nodeColor(index) { + const item = nodeAt(index); + const themed = state.typeColors[item.type] || state.themeColors[item.type] || TYPE_COLORS[item.type]; + if (state.colorBy === 'type' || state.palette === 'custom') return color(themed || item.color); + const palette = activePalette(); + if (state.colorBy === 'connections') return palette[Math.min(5, Math.floor(Math.log1p(state.degrees[index] || 0) * 1.5))]; + const group = String(state.communities[index] ?? index); + const hash = Array.from(group).reduce((sum, char) => ((sum * 31) + char.charCodeAt(0)) >>> 0, 7); + return palette[hash % palette.length]; + } + function metricValue(index) { + if (state.sizeBy === 'betweenness') return state.betweenness[index] || 0; + if (state.sizeBy === 'evidence_mass') return state.evidenceMass[index] || 0; + return state.degrees[index] || 0; + } + function pointSize(index = 0) { + const metric = Math.log1p(Math.max(0, metricValue(index))); + return clamp(2.4 + Number(state.settings.size || 3) * 0.62 + Math.min(4.5, metric * 0.55), 2.5, 12); + } + + /* ── GL plumbing ─────────────────────────────────────────────────────────────── */ + function shader(type, source) { + const value = gl.createShader(type); + gl.shaderSource(value, source); + gl.compileShader(value); + if (!gl.getShaderParameter(value, gl.COMPILE_STATUS)) throw new Error('every-node shader compilation failed'); + return value; + } + function program(vertexSource, fragmentSource) { + const value = gl.createProgram(); + const vertexShader = shader(gl.VERTEX_SHADER, vertexSource); + const fragmentShader = shader(gl.FRAGMENT_SHADER, fragmentSource); + gl.attachShader(value, vertexShader); + gl.attachShader(value, fragmentShader); + gl.linkProgram(value); + gl.detachShader(value, vertexShader); + gl.detachShader(value, fragmentShader); + gl.deleteShader(vertexShader); + gl.deleteShader(fragmentShader); + if (!gl.getProgramParameter(value, gl.LINK_STATUS)) { + gl.deleteProgram(value); + throw new Error('every-node shader link failed'); + } + return value; + } + function initWebgl() { + if (!gl) return; + try { + nodeProgram = program(NODE_VS, NODE_FS); + edgeProgram = program(EDGE_VS, EDGE_FS); + nodeBuffers.position = gl.createBuffer(); + nodeBuffers.color = gl.createBuffer(); + nodeBuffers.size = gl.createBuffer(); + nodeBuffers.flag = gl.createBuffer(); + edgeBuffers.position = gl.createBuffer(); + edgeBuffers.factor = gl.createBuffer(); + edgeBuffers.visible = gl.createBuffer(); + nodeBuffers.attrs = { + position: gl.getAttribLocation(nodeProgram, 'a_position'), + color: gl.getAttribLocation(nodeProgram, 'a_color'), + size: gl.getAttribLocation(nodeProgram, 'a_size'), + flag: gl.getAttribLocation(nodeProgram, 'a_flag'), + camera: gl.getUniformLocation(nodeProgram, 'u_camera'), + scale: gl.getUniformLocation(nodeProgram, 'u_scale'), + resolution: gl.getUniformLocation(nodeProgram, 'u_resolution'), + glow: gl.getUniformLocation(nodeProgram, 'u_glow'), + }; + edgeBuffers.attrs = { + position: gl.getAttribLocation(edgeProgram, 'a_position'), + factor: gl.getAttribLocation(edgeProgram, 'a_factor'), + visible: gl.getAttribLocation(edgeProgram, 'a_visible'), + camera: gl.getUniformLocation(edgeProgram, 'u_camera'), + scale: gl.getUniformLocation(edgeProgram, 'u_scale'), + resolution: gl.getUniformLocation(edgeProgram, 'u_resolution'), + edgeAlpha: gl.getUniformLocation(edgeProgram, 'u_edgeAlpha'), + focusFade: gl.getUniformLocation(edgeProgram, 'u_focusFade'), + weightFloor: gl.getUniformLocation(edgeProgram, 'u_weightFloor'), + hotA: gl.getUniformLocation(edgeProgram, 'u_hotA'), + hotAOn: gl.getUniformLocation(edgeProgram, 'u_hotAOn'), + hotB: gl.getUniformLocation(edgeProgram, 'u_hotB'), + hotBOn: gl.getUniformLocation(edgeProgram, 'u_hotBOn'), + }; + } catch (error) { + nodeProgram = edgeProgram = null; + state.error = { code: 'WEBGL2_UNSUPPORTED', message: String(error && error.message || error) }; + if (window.console && console.warn) console.warn('Every-node engine could not initialise WebGL2.', error); + } + } + + /* ── Visibility & uploads (change-driven, never per-frame) ──────────────────── */ + function passesFilters(index) { + if (!state.ghosts && state.nodeGhosts[index]) return false; + if (!state.scope.showUnlinked && !(state.degrees[index] > 0)) return false; + if (state.scope.minDegree > 0 && !(state.degrees[index] >= state.scope.minDegree)) return false; + return true; + } + function edgePassesFilters(index) { + const source = state.edgeSources[index], target = state.edgeTargets[index]; + if (!passesFilters(source) || !passesFilters(target)) return false; + if (!state.ghosts && state.edgeGhosts[index]) return false; + const layer = state.edgeLayers[index] || 'semantic'; + return !state.layers || state.layers[layer] !== false; + } + function refreshVisibility(repaint = true) { + let visible = 0; + for (let index = 0; index < state.ids.length; index += 1) { + const pass = passesFilters(index); + state.nodeFlags[index] = pass ? 1 : 0; + if (pass) visible += 1; + } + state.visibleCount = visible; + state.pickDirty = true; + state.lastLabelKey = ''; + state.labelLayout = []; + /* Region hulls follow the same visibility contract as nodes and edges. Rebuild them + here so scope/ghost changes cannot leave a stale district overlay on the underlay. */ + computeCommunityRegions(); + uploadNodeMeta(); + uploadEdges(); + applyHoverToFlags(); + if (repaint) scheduleLabels(true); + } + /* Hover dimming overlays the filter visibility: flag 1 stays bright (the hovered node + plus its neighbours), flag 2 is a visible node pushed into the background. */ + function applyHoverToFlags() { + if (!state.ready || !gl || !nodeProgram) return; + /* The hovered node AND the last highlighted node anchor the lit neighbourhood, so a + clicked selection keeps its paths visible after the pointer moves on. */ + const anchors = []; + if (state.hover >= 0) anchors.push(state.hover); + if (state.focus >= 0 && state.focus !== state.hover) anchors.push(state.focus); + const keep = new Set(anchors); + for (const anchor of anchors) { + if (state.neighbors && state.neighbors[anchor]) { + for (const neighbor of state.neighbors[anchor]) keep.add(neighbor); + } + } + /* Flags hold filter visibility (0/1); 2 is a transient dim overlay that must clear + before each re-application so ending a hover restores the full scene. */ + for (let index = 0; index < state.nodeFlags.length; index += 1) { + if (state.nodeFlags[index] === 2) state.nodeFlags[index] = 1; + } + if (keep.size) { + for (let index = 0; index < state.nodeFlags.length; index += 1) { + if (state.nodeFlags[index] === 1 && !keep.has(index)) state.nodeFlags[index] = 2; + } + } + gl.bindBuffer(gl.ARRAY_BUFFER, nodeBuffers.flag); + gl.bufferData(gl.ARRAY_BUFFER, state.nodeFlags, gl.DYNAMIC_DRAW); + } + function uploadNodePositions() { + if (!gl || !nodeProgram) return; + gl.bindBuffer(gl.ARRAY_BUFFER, nodeBuffers.position); + gl.bufferData(gl.ARRAY_BUFFER, state.positions, gl.DYNAMIC_DRAW); + } + function uploadNodeMeta() { + if (!gl || !nodeProgram) return; + const count = state.ids.length; + if (state.nodeColors.length !== count * 3) state.nodeColors = new Float32Array(count * 3); + if (state.nodeSizes.length !== count) state.nodeSizes = new Float32Array(count); + if (state.nodeFlags.length !== count) state.nodeFlags = new Float32Array(count); + for (let index = 0; index < count; index += 1) { + const tint = rgb(nodeColor(index)); + state.nodeColors[index * 3] = tint[0]; + state.nodeColors[index * 3 + 1] = tint[1]; + state.nodeColors[index * 3 + 2] = tint[2]; + state.nodeSizes[index] = pointSize(index); + state.nodeFlags[index] = passesFilters(index) ? 1 : 0; + } + gl.bindBuffer(gl.ARRAY_BUFFER, nodeBuffers.color); + gl.bufferData(gl.ARRAY_BUFFER, state.nodeColors, gl.DYNAMIC_DRAW); + gl.bindBuffer(gl.ARRAY_BUFFER, nodeBuffers.size); + gl.bufferData(gl.ARRAY_BUFFER, state.nodeSizes, gl.DYNAMIC_DRAW); + gl.bindBuffer(gl.ARRAY_BUFFER, nodeBuffers.flag); + gl.bufferData(gl.ARRAY_BUFFER, state.nodeFlags, gl.DYNAMIC_DRAW); + applyHoverToFlags(); + } + function uploadEdges() { + if (!gl || !edgeProgram) return; + const links = state.totalLinks; + const positions = new Float32Array(links * 4); + const factors = new Float32Array(links * 2); + const visibility = new Float32Array(links * 2); + let maxWeight = 1; + for (let index = 0; index < links; index += 1) maxWeight = Math.max(maxWeight, state.edgeWeights[index]); + const norms = new Float32Array(links); + let bridgeCount = 0; + for (let index = 0; index < links; index += 1) { + const source = state.edgeSources[index], target = state.edgeTargets[index]; + positions[index * 4] = state.positions[source * 2]; + positions[index * 4 + 1] = state.positions[source * 2 + 1]; + positions[index * 4 + 2] = state.positions[target * 2]; + positions[index * 4 + 3] = state.positions[target * 2 + 1]; + const visible = edgePassesFilters(index); + visibility[index * 2] = visible ? 1 : 0; + visibility[index * 2 + 1] = visible ? 1 : 0; + if (!visible) { + norms[index] = -1; + continue; + } + if (state.bridges && state.edgeBridges[index]) { + bridgeCount += 1; + factors[index * 2] = 10; + factors[index * 2 + 1] = 10; + norms[index] = -1; /* bridges always render; excluded from the floor search */ + } else { + const norm = clamp(state.edgeWeights[index] / maxWeight, 0, 0.49); + factors[index * 2] = norm; + factors[index * 2 + 1] = norm; + norms[index] = norm; + } + } + /* Honest drawn-edge stats: sorted non-bridge weights let stats() binary-search how + many survive the current zoom's weight floor without touching GPU state. */ + state.bridgeCount = bridgeCount; + state.weightFloorSorted = Float32Array.from(norms).filter(v => v >= 0).sort(); + gl.bindBuffer(gl.ARRAY_BUFFER, edgeBuffers.position); + gl.bufferData(gl.ARRAY_BUFFER, positions, gl.STATIC_DRAW); + gl.bindBuffer(gl.ARRAY_BUFFER, edgeBuffers.factor); + gl.bufferData(gl.ARRAY_BUFFER, factors, gl.STATIC_DRAW); + gl.bindBuffer(gl.ARRAY_BUFFER, edgeBuffers.visible); + gl.bufferData(gl.ARRAY_BUFFER, visibility, gl.STATIC_DRAW); + state.edgeVertexCount = links * 2; + } + + /* ── Community regions: soft district outlines that make the scene read as a map ── */ + function computeCommunityRegions() { + const groups = new Map(); + for (let index = 0; index < state.ids.length; index += 1) { + if (!state.nodeFlags[index]) continue; + const group = String(state.communities[index] ?? index); + const bucket = groups.get(group); + if (bucket) bucket.push(index); else groups.set(group, [index]); + } + const regions = []; + for (const [group, members] of groups) { + if (members.length < 3) continue; + let cx = 0, cy = 0; + let topIndex = members[0]; + for (const index of members) { + cx += state.positions[index * 2]; cy += state.positions[index * 2 + 1]; + if ((state.degrees[index] || 0) > (state.degrees[topIndex] || 0)) topIndex = index; + } + cx /= members.length; cy /= members.length; + let radius = 0; + for (const index of members) { + const dx = state.positions[index * 2] - cx, dy = state.positions[index * 2 + 1] - cy; + radius = Math.max(radius, Math.sqrt(dx * dx + dy * dy)); + } + const hash = Array.from(group).reduce((sum, ch) => ((sum * 31) + ch.charCodeAt(0)) >>> 0, 7); + regions.push({ + x: cx, y: cy, r: radius * 1.12, + tint: rgb(activePalette()[hash % activePalette().length]), + /* Districts are named after their strongest hub: real graphs usually have a + recognisable anchor entity; synthetic ones still get something readable. */ + name: labelText(topIndex), + count: members.length, + }); + } + state.communityRegions = regions.sort((a, b) => b.count - a.count).slice(0, REGION_LIMIT); + } + function drawRegions() { + if (!underlayContext || !state.ready) return; + if (!state.communityRegions.length) { + underlayContext.setTransform(state.dpr, 0, 0, state.dpr, 0, 0); + underlayContext.clearRect(0, 0, state.width, state.height); + return; + } + const ratio = zoomRatio(); + /* Regions aid orientation at browsing distance; they vanish into the glow when far + out and get out of the way of close reading. Strong enough to survive the edge + field painted over them. */ + const strength = clamp(1 - Math.abs(ratio - 0.9) / 1.4, 0, 1) * 0.26; + if (strength <= 0.005) { underlayContext.clearRect(0, 0, state.width, state.height); return; } + underlayContext.setTransform(state.dpr, 0, 0, state.dpr, 0, 0); + underlayContext.clearRect(0, 0, state.width, state.height); + const viewRadius = Math.hypot(state.width / (2 * state.camera.scale), state.height / (2 * state.camera.scale)) + 80; + for (const region of state.communityRegions) { + const point = screen(region.x, region.y); + const radius = region.r * state.camera.scale; + if (radius < 14) continue; + if (Math.hypot(point[0] - state.width / 2, point[1] - state.height / 2) > viewRadius * state.camera.scale + radius) continue; + const tint = `rgba(${region.tint[0] * 255 | 0},${region.tint[1] * 255 | 0},${region.tint[2] * 255 | 0},`; + const gradient = underlayContext.createRadialGradient(point[0], point[1], radius * 0.3, point[0], point[1], radius); + gradient.addColorStop(0, `${tint}${strength})`); + gradient.addColorStop(0.75, `${tint}${strength * 0.55})`); + gradient.addColorStop(1, 'rgba(0,0,0,0)'); + underlayContext.fillStyle = gradient; + underlayContext.beginPath(); + underlayContext.arc(point[0], point[1], radius, 0, Math.PI * 2); + underlayContext.fill(); + /* Thin rim gives each district a defined boundary against the edge field. */ + underlayContext.strokeStyle = `${tint}${Math.min(0.5, strength * 2.2)})`; + underlayContext.lineWidth = 1.5; + underlayContext.beginPath(); + underlayContext.arc(point[0], point[1], radius * 0.98, 0, Math.PI * 2); + underlayContext.stroke(); + /* District label: hub-named, member-counted, drawn only when the district is + large enough on screen to be a place rather than a smudge. */ + if (radius >= 46) { + const name = region.name || ''; + if (name) { + const fontPx = clamp(11 + radius / state.camera.scale * 0.004, 11, 17); + underlayContext.font = `600 ${fontPx}px ui-sans-serif,system-ui,sans-serif`; + underlayContext.textAlign = 'center'; + underlayContext.textBaseline = 'middle'; + const labelAlpha = Math.min(0.85, strength * 3); + const title = name.length > 28 ? `${name.slice(0, 27)}…` : name; + underlayContext.shadowColor = 'rgba(4,8,12,0.9)'; + underlayContext.shadowBlur = 4; + underlayContext.fillStyle = `rgba(236,244,248,${labelAlpha})`; + underlayContext.fillText(title, point[0], point[1]); + underlayContext.font = `10px ui-sans-serif,system-ui,sans-serif`; + underlayContext.fillStyle = `rgba(190,208,220,${labelAlpha * 0.8})`; + underlayContext.fillText(`${region.count} nodes`, point[0], point[1] + fontPx + 3); + underlayContext.shadowColor = 'transparent'; + underlayContext.shadowBlur = 0; + underlayContext.textAlign = 'left'; + } + } + } + } + + /* ── Picking: local spatial grid, no worker latency ─────────────────────────── */ + function buildPickGrid() { + const cell = 39; + const grid = new Map(); + for (let index = 0; index < state.ids.length; index += 1) { + if (!state.nodeFlags[index]) continue; + const key = (Math.floor(state.positions[index * 2] / cell) + 32768) * 65536 + + (Math.floor(state.positions[index * 2 + 1] / cell) + 32768); + const bucket = grid.get(key); + if (bucket) bucket.push(index); else grid.set(key, [index]); + } + state.pickGrid = grid; + state.pickCell = cell; + state.pickDirty = false; + } + function pickAt(worldX, worldY) { + if (!state.ready) return -1; + if (state.pickDirty) buildPickGrid(); + const cell = state.pickCell; + const gx = Math.floor(worldX / cell), gy = Math.floor(worldY / cell); + let best = -1, bestDist = Infinity; + for (let ox = -1; ox <= 1; ox += 1) { + for (let oy = -1; oy <= 1; oy += 1) { + const bucket = state.pickGrid.get((gx + ox + 32768) * 65536 + (gy + oy + 32768)); + if (!bucket) continue; + for (let slot = 0; slot < bucket.length; slot += 1) { + const index = bucket[slot]; + const dx = state.positions[index * 2] - worldX; + const dy = state.positions[index * 2 + 1] - worldY; + const dist = dx * dx + dy * dy; + if (dist < bestDist) { bestDist = dist; best = index; } + } + } + } + if (best < 0) return -1; + /* bestDist is measured in world units while pointSize and the extra touch slop are + screen pixels. Convert the hit radius back into world units so picking remains + usable at both fit-to-view and close-reading zoom levels. */ + const reach = (pointSize(best) + 7) / Math.max(0.005, state.camera.scale); + return bestDist <= reach * reach ? best : -1; + } + + /* ── Overlay: decluttered labels + capped relation flow ─────────────────────── */ + function labelText(index) { return state.labels[index] || state.ids[index]; } + function drawOverlay(now = 0) { + state.labelFrame = 0; + if (!labelContext || state.destroyed || state.paused) return; + labelContext.setTransform(state.dpr, 0, 0, state.dpr, 0, 0); + labelContext.clearRect(0, 0, state.width, state.height); + drawRelationFlow(now); + drawFocusRing(); + drawDeclutteredLabels(); + /* Lit paths and their arrows paint above background structure... */ + drawHotEdgeDecorations(); + /* ...and the hover card paints above absolutely everything. */ + drawHoverCardLayer(); + } + function scheduleLabels(immediate = false) { + if (state.destroyed || state.paused || !labelContext) return; + if (immediate) { if (state.labelFrame) caf(state.labelFrame); state.labelFrame = raf(() => drawOverlay()); return; } + if (!state.labelFrame) state.labelFrame = raf(() => drawOverlay()); + } + /* Lit-path decorations: the hovered/highlighted node's relations get direction arrows + and their relation name, so reading a connection does not require opening anything. */ + function hotAnchors() { + const anchors = []; + if (state.hover >= 0) anchors.push(state.hover); + if (state.focus >= 0 && state.focus !== state.hover) anchors.push(state.focus); + return anchors; + } + function drawHotEdgeDecorations() { + if (!labelContext || !state.ready) return; + const anchors = hotAnchors(); + if (!anchors.length || !state.edgeVertexCount) return; + const seenEdges = new Set(); + const showRelation = zoomRatio() > 0.55; + labelContext.save(); + labelContext.strokeStyle = "rgba(244,211,127,0.9)"; + labelContext.fillStyle = "rgba(244,211,127,0.9)"; + let drawn = 0; + for (const anchor of anchors) { + const incident = state.incidentEdges && state.incidentEdges[anchor] || []; + const incidentLimit = Math.min(incident.length, FLOW_EDGE_LIMIT); + for (let slot = 0; slot < incidentLimit && drawn < FLOW_EDGE_LIMIT; slot += 1) { + const edge = incident[slot]; + if (seenEdges.has(edge)) continue; + seenEdges.add(edge); + if (!edgePassesFilters(edge)) continue; + /* A single hub can own the entire relation budget; keep the accessible focus layer + bounded even though the GPU still retains every relation in its static buffer. */ + if (drawn >= FLOW_EDGE_LIMIT) continue; + const source = state.edgeSources[edge], target = state.edgeTargets[edge]; + const a = screen(state.positions[source * 2], state.positions[source * 2 + 1]); + const b = screen(state.positions[target * 2], state.positions[target * 2 + 1]); + if ((a[0] < -20 && b[0] < -20) || (a[0] > state.width + 20 && b[0] > state.width + 20) + || (a[1] < -20 && b[1] < -20) || (a[1] > state.height + 20 && b[1] > state.height + 20)) continue; + drawn += 1; + labelContext.lineWidth = 1.6; + labelContext.beginPath(); labelContext.moveTo(a[0], a[1]); labelContext.lineTo(b[0], b[1]); labelContext.stroke(); + /* Direction cue at 62% of the run: source → target. Dark halo keeps it visible + over dense background lines; size grows as you zoom in. */ + const t = 0.62, tipX = a[0] + (b[0] - a[0]) * t, tipY = a[1] + (b[1] - a[1]) * t; + const angle = Math.atan2(b[1] - a[1], b[0] - a[0]); + const head = clamp(6 + zoomRatio() * 3, 6, 13); + labelContext.strokeStyle = 'rgba(6,10,14,0.85)'; + labelContext.lineWidth = head * 0.9; + labelContext.lineCap = 'round'; + labelContext.beginPath(); labelContext.moveTo(a[0], a[1]); labelContext.lineTo(b[0], b[1]); labelContext.stroke(); + labelContext.strokeStyle = 'rgba(244,211,127,0.95)'; + labelContext.lineWidth = 2; + labelContext.beginPath(); labelContext.moveTo(a[0], a[1]); labelContext.lineTo(b[0], b[1]); labelContext.stroke(); + labelContext.fillStyle = 'rgba(244,211,127,0.98)'; + labelContext.beginPath(); + labelContext.moveTo(tipX, tipY); + labelContext.lineTo(tipX - Math.cos(angle - 0.42) * head, tipY - Math.sin(angle - 0.42) * head); + labelContext.lineTo(tipX - Math.cos(angle + 0.42) * head, tipY - Math.sin(angle + 0.42) * head); + labelContext.closePath(); labelContext.fill(); + const relation = String(state.edgeRelations[edge] || ""); + if (showRelation && relation && relation !== "relates_to") { + const mx = (a[0] + b[0]) / 2, my = (a[1] + b[1]) / 2; + labelContext.font = "10px ui-sans-serif,system-ui,sans-serif"; + const width = labelContext.measureText(relation).width; + labelContext.fillStyle = "rgba(9,14,20,0.88)"; + labelContext.fillRect(mx - width / 2 - 4, my - 8, width + 8, 15); + labelContext.fillStyle = "rgba(244,222,168,0.95)"; + labelContext.textBaseline = "middle"; labelContext.textAlign = "left"; + labelContext.fillText(relation, mx - width / 2, my); + labelContext.fillStyle = "rgba(244,211,127,0.9)"; + } + } + } + labelContext.restore(); + } + function drawFocusRing() { + const focused = state.focus >= 0 ? state.focus : state.hover; + if (focused < 0 || focused >= state.ids.length || !state.nodeFlags[focused]) return; + const point = screen(state.positions[focused * 2], state.positions[focused * 2 + 1]); + labelContext.beginPath(); + labelContext.arc(point[0], point[1], clamp(7 + state.camera.scale * 2, 7, 15), 0, Math.PI * 2); + labelContext.strokeStyle = '#f4d37f'; + labelContext.lineWidth = 1.5; + labelContext.stroke(); + } + /* The callout is the single most important overlay: it always paints last so no + line, label, or arrow can ever cover it. */ + function drawHoverCardLayer() { + const index = state.hover; + if (index < 0 || index >= state.ids.length || !state.nodeFlags[index]) return; + drawHoverCard(index, screen(state.positions[index * 2], state.positions[index * 2 + 1])); + } + /* Hover callout: hubs are meaningless as bare dots — spell out what the entity is, + which category it belongs to, and how many relations it carries. */ + function drawHoverCard(index, point) { + const title = labelText(index); + const rawType = String(state.types[index] || '').trim(); + const category = rawType + ? rawType.replace(/[_-]+/g, ' ').replace(/\b\w/g, ch => ch.toUpperCase()) + : 'Entity'; + const meta = `${category} · ${Math.round(state.degrees[index] || 0)} relation${(state.degrees[index] || 0) === 1 ? '' : 's'}`; + const titleFont = '600 13px ui-sans-serif,system-ui,sans-serif'; + const metaFont = '11px ui-sans-serif,system-ui,sans-serif'; + /* Relation analysis at a glance: the strongest connections, strongest first. */ + const connections = state.connectionHighlights && state.connectionHighlights[index] || []; + labelContext.save(); + labelContext.font = titleFont; + const titleWidth = labelContext.measureText(title).width; + labelContext.font = metaFont; + const metaWidth = labelContext.measureText(meta).width; + let connectionWidths = []; + if (connections.length) { + labelContext.font = metaFont; + connectionWidths = connections.map(neighbor => labelContext.measureText(`↳ ${labelText(neighbor)}`).width); + } + const padX = 10, padY = 8, gap = 4; + const cardW = Math.ceil(Math.max(titleWidth, metaWidth, ...connectionWidths)) + padX * 2; + const cardH = 13 + 11 + gap + padY * 2 + connectionWidths.length * 14; + const radius = pointSize(index) * Math.min(1.6, state.camera.scale) + 4; + let x = point[0] - cardW / 2; + let y = point[1] - radius - 10 - cardH; + if (y < 4) y = point[1] + radius + 10; + x = clamp(x, 4, Math.max(4, state.width - cardW - 4)); + labelContext.font = metaFont; + if (typeof labelContext.roundRect === 'function') { + labelContext.beginPath(); + labelContext.roundRect(x, y, cardW, cardH, 7); + labelContext.fillStyle = 'rgba(9,14,20,0.94)'; + labelContext.fill(); + labelContext.strokeStyle = 'rgba(244,211,127,0.55)'; + labelContext.lineWidth = 1; + labelContext.stroke(); + } else { + labelContext.fillStyle = 'rgba(9,14,20,0.94)'; + labelContext.fillRect(x, y, cardW, cardH); + } + labelContext.textBaseline = 'alphabetic'; + labelContext.textAlign = 'left'; + labelContext.font = titleFont; + labelContext.fillStyle = '#f4d37f'; + labelContext.fillText(title, x + padX, y + padY + 12); + labelContext.font = metaFont; + labelContext.fillStyle = 'rgba(214,228,236,0.85)'; + labelContext.fillText(meta, x + padX, y + padY + 12 + gap + 11); + connections.forEach((neighbor, slot) => { + const lineY = y + padY + 12 + gap + 11 + (slot + 1) * 14; + labelContext.fillStyle = 'rgba(150,205,220,0.92)'; + labelContext.fillText(`↳ ${labelText(neighbor)}`, x + padX, lineY); + }); + labelContext.restore(); + } + function drawRelationFlow(now) { + if (!state.settings.flow || !state.totalLinks) return; + const speed = clamp(Number(state.settings.flowSpeed || 0), 0, 100); + const moving = speed > 0 && !state.settings.frozen && !reducedMotion(); + const stride = Math.max(1, Math.ceil(state.totalLinks / FLOW_EDGE_LIMIT)); + labelContext.save(); + labelContext.globalCompositeOperation = 'lighter'; + for (let cursor = 0; cursor < state.totalLinks; cursor += stride) { + if (!edgePassesFilters(cursor)) continue; + if (!state.edgeBridges[cursor] && zoomRatio() < EDGE_START) continue; + const ax = state.positions[state.edgeSources[cursor] * 2]; + const ay = state.positions[state.edgeSources[cursor] * 2 + 1]; + const bx = state.positions[state.edgeTargets[cursor] * 2]; + const by = state.positions[state.edgeTargets[cursor] * 2 + 1]; + const a = screen(ax, ay), b = screen(bx, by); + if ((a[0] < -12 && b[0] < -12) || (a[0] > state.width + 12 && b[0] > state.width + 12) + || (a[1] < -12 && b[1] < -12) || (a[1] > state.height + 12 && b[1] > state.height + 12)) continue; + const phase = moving ? ((now * (0.00006 + speed * 0.000018) + (cursor % 997) / 997) % 1) : 0.68; + const x = a[0] + (b[0] - a[0]) * phase, y = a[1] + (b[1] - a[1]) * phase; + labelContext.fillStyle = state.edgeBridges[cursor] + ? 'rgba(255,220,132,0.88)' : 'rgba(115,220,239,0.72)'; + labelContext.beginPath(); + labelContext.arc(x, y, zoomRatio() < 0.8 ? 1.15 : 1.65, 0, Math.PI * 2); + labelContext.fill(); + } + labelContext.restore(); + } + function drawDeclutteredLabels() { + if (!state.settings.labels || !state.ready) return; + /* Camera key quantises scale so tiny wheel nudges do not invalidate the occupancy + pass; candidates walk topNodes rank-first so important names always win space. */ + const key = `${Math.round(state.camera.x)}:${Math.round(state.camera.y)}:${Math.round(state.camera.scale * 24)}`; + const fontPx = clamp(Number(state.settings.font || 12) + state.camera.scale * 1.5, 8, 24); + const font = `${fontPx}px ui-sans-serif,system-ui,sans-serif`; + const cacheKey = `${font}|${key}`; + labelContext.font = font; + labelContext.textBaseline = 'middle'; + labelContext.shadowColor = 'rgba(4,8,12,0.85)'; + labelContext.shadowBlur = 3; + labelContext.fillStyle = 'rgba(224,236,241,0.86)'; + if (cacheKey === state.lastLabelKey && state.labelLayout.length) { + state.labelLayout.forEach(item => labelContext.fillText(item.text, item.x, item.y)); + labelContext.shadowColor = 'transparent'; + labelContext.shadowBlur = 0; + return; + } + state.lastLabelKey = cacheKey; + state.labelLayout = []; + const cell = 14; + const occupied = new Set(); + let drawn = 0; + const consider = index => { + if (drawn >= LABEL_MAX || !state.nodeFlags[index] || state.nodeFlags[index] === 2) return; + const point = screen(state.positions[index * 2], state.positions[index * 2 + 1]); + if (point[0] < -40 || point[0] > state.width + 40 || point[1] < -20 || point[1] > state.height + 20) return; + const text = labelText(index); + let width = state.labelMetrics.get(text); + if (width === undefined) { width = labelContext.measureText(text).width; if (state.labelMetrics.size > 8000) state.labelMetrics.clear(); state.labelMetrics.set(text, width); } + const left = Math.floor((point[0] + 6) / cell), right = Math.ceil((point[0] + 6 + width) / cell); + const top = Math.floor((point[1] - 6 - fontPx / 2) / cell), bottom = Math.ceil((point[1] - 6 + fontPx / 2) / cell); + for (let gx = left; gx <= right; gx += 1) { + for (let gy = top; gy <= bottom; gy += 1) { + if (occupied.has(`${gx}:${gy}`)) return; + } + } + for (let gx = left; gx <= right; gx += 1) { + for (let gy = top; gy <= bottom; gy += 1) occupied.add(`${gx}:${gy}`); + } + labelContext.fillText(text, point[0] + 6, point[1] - 6); + state.labelLayout.push({ text, x: point[0] + 6, y: point[1] - 6 }); + drawn += 1; + }; + const anchor = state.hover >= 0 ? state.hover + : (state.focus >= 0 && state.nodeFlags[state.focus] === 1 ? state.focus : -1); + const focusNeighborhood = anchor >= 0 && state.neighbors && state.neighbors[anchor]; + if (focusNeighborhood) { + consider(anchor); + const neighborhoodLimit = Math.min(state.neighbors[anchor].length, LABEL_CANDIDATE_MAX); + for (let slot = 0; slot < neighborhoodLimit; slot += 1) { + if (drawn >= LABEL_MAX) break; + consider(state.neighbors[anchor][slot]); + } + } + for (let rank = 0; rank < Math.min(state.topNodes.length, LABEL_CANDIDATE_MAX) && drawn < LABEL_MAX; rank += 1) { + if (!focusNeighborhood) consider(state.topNodes[rank]); + else break; + } + labelContext.shadowColor = 'transparent'; + labelContext.shadowBlur = 0; + } + + /* ── Frame loop ─────────────────────────────────────────────────────────────── */ + function flowAnimating() { + return state.settings.flow && state.totalLinks && Number(state.settings.flowSpeed || 0) > 0 + && !state.settings.frozen && !reducedMotion(); + } + function draw(now = 0) { + state.frame = 0; + if (state.destroyed || state.paused || !state.ready || !nodeProgram) return; + if (flowAnimating() && state.flowPaintAt && now - state.flowPaintAt < FLOW_FRAME_MS) { + schedule(); + return; + } + state.flowPaintAt = now; + const glow = glowAmount(); + gl.clearColor(0, 0, 0, 0); + gl.clear(gl.COLOR_BUFFER_BIT); + gl.viewport(0, 0, canvas.width, canvas.height); + gl.enable(gl.BLEND); + + const edgeAlpha = clamp((zoomRatio() - EDGE_START) * 0.8, 0, 0.16) + * clamp(Number(state.settings.linkw || 0.72), 0.2, 3); + if (edgeAlpha > 0.01 && state.edgeVertexCount) { + gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); + gl.useProgram(edgeProgram); + bindEdgeGeometry(); + gl.uniform2f(edgeBuffers.attrs.camera, state.camera.x, state.camera.y); + gl.uniform1f(edgeBuffers.attrs.scale, state.camera.scale * state.dpr); + gl.uniform2f(edgeBuffers.attrs.resolution, canvas.width, canvas.height); + gl.uniform1f(edgeBuffers.attrs.edgeAlpha, edgeAlpha); + /* Most WebGL2 implementations clamp line width to 1: the linkw control scales the + * shader alpha (see edgeAlpha above), not geometric width. */ + gl.uniform1f(edgeBuffers.attrs.focusFade, + state.hover >= 0 || state.focus >= 0 ? 0.10 : 1.0); + /* Far out only the strongest routes render; the floor empties as you zoom in. + Scaled into the [0, 0.49] weight-norm band — an unscaled floor would gate out + every normal edge until well past fit zoom, then dump them all at once. */ + const ratio = zoomRatio(); + gl.uniform1f(edgeBuffers.attrs.weightFloor, + clamp(1.15 - ratio * 0.55, 0, 1) * 0.49); + gl.uniform2f(edgeBuffers.attrs.hotA, state.hoverPoint[0], state.hoverPoint[1]); + gl.uniform1f(edgeBuffers.attrs.hotAOn, state.hover >= 0 ? 1.0 : 0.0); + gl.uniform2f(edgeBuffers.attrs.hotB, state.focusPoint[0], state.focusPoint[1]); + gl.uniform1f(edgeBuffers.attrs.hotBOn, state.focus >= 0 ? 1.0 : 0.0); + gl.lineWidth(Math.max(1, Number(state.settings.linkw || 0.72) * state.dpr)); + gl.drawArrays(gl.LINES, 0, state.edgeVertexCount); + } + + gl.useProgram(nodeProgram); + bindNodeGeometry(); + gl.uniform2f(nodeBuffers.attrs.camera, state.camera.x, state.camera.y); + gl.uniform1f(nodeBuffers.attrs.scale, state.camera.scale * state.dpr); + gl.uniform2f(nodeBuffers.attrs.resolution, canvas.width, canvas.height); + gl.uniform1f(nodeBuffers.attrs.glow, glow); + if (glow > 0.02) { + /* Density pass: additive soft sprites turn crowded regions into brightness so the + zoomed-out scene reads as structure instead of overlapping dots. */ + gl.blendFunc(gl.SRC_ALPHA, gl.ONE); + gl.drawArrays(gl.POINTS, 0, state.ids.length); + } + gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); + gl.drawArrays(gl.POINTS, 0, state.ids.length); + + drawRegions(); + + if (flowAnimating()) schedule(); + scheduleLabels(); + } + function bindNodeGeometry() { + gl.bindBuffer(gl.ARRAY_BUFFER, nodeBuffers.position); + gl.enableVertexAttribArray(nodeBuffers.attrs.position); + gl.vertexAttribPointer(nodeBuffers.attrs.position, 2, gl.FLOAT, false, 0, 0); + gl.bindBuffer(gl.ARRAY_BUFFER, nodeBuffers.color); + gl.enableVertexAttribArray(nodeBuffers.attrs.color); + gl.vertexAttribPointer(nodeBuffers.attrs.color, 3, gl.FLOAT, false, 0, 0); + gl.bindBuffer(gl.ARRAY_BUFFER, nodeBuffers.size); + gl.enableVertexAttribArray(nodeBuffers.attrs.size); + gl.vertexAttribPointer(nodeBuffers.attrs.size, 1, gl.FLOAT, false, 0, 0); + gl.bindBuffer(gl.ARRAY_BUFFER, nodeBuffers.flag); + gl.enableVertexAttribArray(nodeBuffers.attrs.flag); + gl.vertexAttribPointer(nodeBuffers.attrs.flag, 1, gl.FLOAT, false, 0, 0); + } + function bindEdgeGeometry() { + gl.bindBuffer(gl.ARRAY_BUFFER, edgeBuffers.position); + gl.enableVertexAttribArray(edgeBuffers.attrs.position); + gl.vertexAttribPointer(edgeBuffers.attrs.position, 2, gl.FLOAT, false, 0, 0); + gl.bindBuffer(gl.ARRAY_BUFFER, edgeBuffers.factor); + gl.enableVertexAttribArray(edgeBuffers.attrs.factor); + gl.vertexAttribPointer(edgeBuffers.attrs.factor, 1, gl.FLOAT, false, 0, 0); + gl.bindBuffer(gl.ARRAY_BUFFER, edgeBuffers.visible); + gl.enableVertexAttribArray(edgeBuffers.attrs.visible); + gl.vertexAttribPointer(edgeBuffers.attrs.visible, 1, gl.FLOAT, false, 0, 0); + } + function schedule() { + if (!state.destroyed && !state.paused && !state.frame) state.frame = raf(draw); + } + + /* ── Camera & sizing ────────────────────────────────────────────────────────── */ + /* The camera touches uniforms only — the worker never hears about pan/zoom again. */ + function camera() { schedule(); scheduleLabels(); } + function resize() { + const rect = element.getBoundingClientRect(); + state.width = Math.max(1, rect.width || element.clientWidth || 1); + state.height = Math.max(1, rect.height || element.clientHeight || 1); + state.dpr = Math.min(2, window.devicePixelRatio || 1); + [underlay, canvas, labels].forEach(target => { + target.width = Math.max(1, Math.floor(state.width * state.dpr)); + target.height = Math.max(1, Math.floor(state.height * state.dpr)); + }); + state.lastLabelKey = ''; + if (state.ready) { schedule(); scheduleLabels(true); } + } + function fit() { + if (!state.bounds) return; + const bounds = state.bounds; + state.camera.x = (bounds.minX + bounds.maxX) / 2; + state.camera.y = (bounds.minY + bounds.maxY) / 2; + state.camera.scale = clamp( + Math.min( + state.width / Math.max(120, bounds.maxX - bounds.minX + 120), + state.height / Math.max(120, bounds.maxY - bounds.minY + 120), + ), 0.005, 4); + /* The fitted scale is the yardstick every LOD band measures against. */ + state.baseScale = state.camera.scale || 1; + camera(); + } + function applyLayout(positions, bounds, doFit) { + state.positions = positions || state.positions; + state.bounds = bounds || state.bounds; + [[state.hover, 'hoverPoint'], [state.focus, 'focusPoint']].forEach(([index, key]) => { + if (index >= 0 && index * 2 + 1 < state.positions.length) { + state[key] = [state.positions[index * 2], state.positions[index * 2 + 1]]; + } + }); + uploadNodePositions(); + uploadEdges(); + state.pickDirty = true; + state.lastLabelKey = ''; + if (doFit) fit(); else camera(); + computeCommunityRegions(); + drawRegions(); + } + + /* ── Worker messages ────────────────────────────────────────────────────────── */ + function handleCapacity(message) { + const resource = message.resource === 'relations' ? 'relations' : 'nodes'; + const error = new Error(`Every-node capacity exceeded: ${Number(message.count).toLocaleString()} ${resource} (limit ${Number(message.limit || (resource === 'relations' ? MAX_LINKS : MAX_NODES)).toLocaleString()}). Filter the graph before loading all nodes.`); + error.code = 'GRAPH_CAPACITY'; + state.error = { code: error.code, message: error.message }; + if (typeof opts.onError === 'function') opts.onError(error); + } + function adoptCommon(message) { + state.ids = message.ids || []; + rebuildIdIndex(); + state.labels = message.labels || []; + state.types = message.types || state.types; + state.communities = message.communities || []; + state.nodeGhosts = message.nodeGhosts || new Uint8Array(state.ids.length); + /* Preview and ready both carry a seeded layout. Adopt it immediately so a reload never + uploads the previous graph's coordinates while the worker settles the next one. */ + state.positions = message.positions || state.positions; + state.bounds = message.bounds || null; + const count = state.ids.length; + if (state.nodeFlags.length !== count) state.nodeFlags = new Float32Array(count); + if (state.nodeColors.length !== count * 3) state.nodeColors = new Float32Array(count * 3); + if (state.nodeSizes.length !== count) state.nodeSizes = new Float32Array(count); + } + function handleWorkerMessage(event) { + const message = event.data || {}; + if (message.type === 'capacity') { handleCapacity(message); return; } + if (message.type === 'preview' || message.type === 'ready') { + state.error = null; + adoptCommon(message); + canvas.setAttribute('role', 'img'); + canvas.setAttribute('aria-label', + `Graph with ${state.ids.length} entities and ${Number(message.totalLinks || 0)} relations`); + if (message.type === 'ready') { + state.degrees = message.degrees || new Float32Array(state.ids.length); + state.betweenness = message.betweenness || new Float32Array(state.ids.length); + state.evidenceMass = message.evidenceMass || new Float32Array(state.ids.length); + state.edgeSources = message.edgeSources || new Uint32Array(0); + state.edgeTargets = message.edgeTargets || new Uint32Array(0); + state.edgeBridges = message.edgeBridges || new Uint8Array(0); + state.edgeGhosts = message.edgeGhosts || new Uint8Array(0); + state.edgeWeights = message.edgeWeights || new Float32Array(0); + state.edgeRelations = message.edgeRelations || []; + state.edgeLayers = message.edgeLayers || []; + state.topNodes = message.topNodes || new Uint32Array(0); + state.totalLinks = Number(message.totalLinks || 0); + /* Neighbourhood adjacency powers hover focus: hovering a node dims everything + that is not the node, its direct relations, or their connecting edges. */ + state.neighbors = state.ids.map(() => []); + state.incidentEdges = state.ids.map(() => []); + for (let edge = 0; edge < state.totalLinks; edge += 1) { + const source = state.edgeSources[edge], target = state.edgeTargets[edge]; + if (state.neighbors[source]) { + state.neighbors[source].push(target); + state.incidentEdges[source].push(edge); + } + if (state.neighbors[target]) { + state.neighbors[target].push(source); + state.incidentEdges[target].push(edge); + } + } + state.connectionHighlights = state.neighbors.map(neighbors => { + const top = []; + for (const neighbor of neighbors) { + let slot = top.length; + for (let index = 0; index < top.length; index += 1) { + if ((state.degrees[neighbor] || 0) > (state.degrees[top[index]] || 0)) { + slot = index; break; + } + } + if (slot < 3) { + top.splice(slot, 0, neighbor); + if (top.length > 3) top.pop(); + } + } + return top; + }); + } + state.ready = true; + refreshVisibility(false); + uploadNodePositions(); + uploadEdges(); + fit(); + if (message.type === 'preview') stats({ progressive: true, linksPending: true }); + else if (typeof opts.onMetrics === 'function') opts.onMetrics(api.metrics()); + schedule(); + scheduleLabels(true); + return; + } + if (message.type === 'progress') { + state.layoutPending = Number(message.pass) < Number(message.total); + stats({ layoutPending: state.layoutPending }); + return; + } + if (message.type === 'layout') { + const pass = Number(message.pass), total = Number(message.total); + state.layoutPending = Number.isFinite(pass) && Number.isFinite(total) + ? pass < total : message.fit !== true; + if (!state.ready) return; + applyLayout(message.positions, message.bounds, message.fit === true); + stats({ layoutPending: state.layoutPending }); + return; + } + } + function handleWorkerFailure(event) { + if (state.destroyed) return; + const source = event && event.error; + const error = source instanceof Error ? source + : new Error(event && event.type === 'messageerror' + ? 'Every-node worker returned an unreadable response.' + : 'Every-node worker failed while preparing the graph.'); + error.code = error.code || 'GRAPH_WORKER'; + state.error = { code: error.code, message: error.message }; + state.ready = false; + if (typeof opts.onError === 'function') opts.onError(error); + } + + function postSettings(relayout, fitLayout = false) { + if (!worker) return; + if (!relayout) { + worker.postMessage({ type: 'settings', settings: state.settings, relayout: false }); + return; + } + pendingLayoutFit = pendingLayoutFit || fitLayout; + if (layoutFrame) return; + layoutFrame = raf(() => { + layoutFrame = 0; + const fitWanted = pendingLayoutFit; pendingLayoutFit = false; + state.layoutPending = true; + stats({ layoutPending: true }); + worker.postMessage({ type: 'settings', settings: state.settings, relayout: true, fit: fitWanted }); + }); + } + function drawnEdgeEstimate() { + if (!state.totalLinks) return 0; + if (zoomRatio() <= EDGE_START) return state.bridgeCount; + /* Mirrors the shader floor exactly, including the 0.49 norm-band scaling. */ + const floor = clamp(1.15 - zoomRatio() * 0.55, 0, 1) * 0.49; + const sorted = state.weightFloorSorted; + let lo = 0, hi = sorted.length; + while (lo < hi) { const mid = (lo + hi) >> 1; if (sorted[mid] < floor) lo = mid + 1; else hi = mid; } + return state.bridgeCount + (sorted.length - lo); + } + function stats(extra) { + if (typeof opts.onStats !== 'function') return; + const drawn = drawnEdgeEstimate(); + opts.onStats({ + nodes: state.ids.length, + visibleNodes: state.visibleCount, + links: state.totalLinks, + drawnLinks: drawn, + hiddenLinks: Math.max(0, state.totalLinks - drawn), + collapsed: state.collapsed, + relationFlow: state.settings.flow === true, + layoutPending: state.layoutPending, + presentation: 'all', + preset: 'Every node · LOD', + renderer: gl && nodeProgram ? 'webgl2' : 'unsupported', + ...extra, + }); + } + function clearHover() { + state.hover = -1; + state.hoverPoint = [0, 0]; + applyHoverToFlags(); + element.classList.remove('engraphis-all-node-hover'); + state.lastLabelKey = ''; + scheduleLabels(true); + } + function requestHit(event) { + if (state.destroyed) return; + pendingHit = { x: event.clientX, y: event.clientY }; + if (hitFrame) return; + hitFrame = raf(() => { + hitFrame = 0; + const sample = pendingHit; + pendingHit = null; + if (!sample || state.destroyed) return; + const rect = element.getBoundingClientRect(); + const point = world(sample.x - rect.left, sample.y - rect.top); + const next = pickAt(point[0], point[1]); + if (next === state.hover) return; + state.hover = next; + state.hoverPoint = next >= 0 + ? [state.positions[next * 2], state.positions[next * 2 + 1]] + : [0, 0]; + applyHoverToFlags(); + state.lastLabelKey = ''; + element.classList.toggle('engraphis-all-node-hover', next >= 0); + liveRegion.textContent = next >= 0 + ? `${labelText(next)}, ${Math.round(state.degrees[next] || 0)} relations` + : ''; + if (typeof opts.onHover === 'function') opts.onHover(next >= 0 ? nodeAt(next) : null); + scheduleLabels(true); + }); + } + + /* ── Interaction ────────────────────────────────────────────────────────────── */ + /* ── Touch: two-pointer pinch zoom about the pinch midpoint ─────────────────── */ + const activePointers = new Map(); + function applyPinch() { + if (activePointers.size !== 2) return; + const [p1, p2] = [...activePointers.values()]; + const dist = Math.hypot(p2.x - p1.x, p2.y - p1.y) || 1; + const rect = element.getBoundingClientRect(); + const midX = (p1.x + p2.x) / 2 - rect.left, midY = (p1.y + p2.y) / 2 - rect.top; + const before = world(midX, midY); + state.camera.scale = clamp(state.camera.scale * (dist / (state.pinchDist || dist)), 0.005, 7); + state.pinchDist = dist; + const after = world(midX, midY); + state.camera.x += before[0] - after[0]; + state.camera.y += before[1] - after[1]; + camera(); + } + canvas.addEventListener('pointerdown', event => { + activePointers.set(event.pointerId, { x: event.clientX, y: event.clientY }); + if (activePointers.size === 2) { state.drag = null; state.pinchDist = 0; } + if (event.button !== 0 || activePointers.size >= 2) return; + state.drag = { x: event.clientX, y: event.clientY, cameraX: state.camera.x, cameraY: state.camera.y, moved: false }; + canvas.setPointerCapture(event.pointerId); + event.preventDefault(); + }); + canvas.addEventListener('pointermove', event => { + if (activePointers.has(event.pointerId)) { + activePointers.set(event.pointerId, { x: event.clientX, y: event.clientY }); + if (activePointers.size === 2) { applyPinch(); return; } + } + if (state.drag) { + const dx = event.clientX - state.drag.x, dy = event.clientY - state.drag.y; + if (Math.abs(dx) + Math.abs(dy) > 3) state.drag.moved = true; + state.camera.x = state.drag.cameraX - dx / state.camera.scale; + state.camera.y = state.drag.cameraY - dy / state.camera.scale; + camera(); + } else requestHit(event); + }); + canvas.addEventListener('pointerup', event => { + activePointers.delete(event.pointerId); + state.pinchDist = 0; + const drag = state.drag; + state.drag = null; + if (!drag || drag.moved) return; + if (state.hover >= 0 && typeof opts.onNodeClick === 'function') opts.onNodeClick(nodeAt(state.hover)); + else if (typeof opts.onBackgroundClick === 'function') opts.onBackgroundClick(); + }); + canvas.addEventListener('pointerleave', () => { + activePointers.clear(); + state.pinchDist = 0; + if (!state.drag) clearHover(); + }); + canvas.addEventListener('pointercancel', event => { + activePointers.delete(event.pointerId); + state.pinchDist = 0; + state.drag = null; + }); + canvas.addEventListener('pointerout', event => { + if (!state.drag && (!event.relatedTarget || event.relatedTarget !== canvas)) clearHover(); + }); + canvas.addEventListener('wheel', event => { + const rect = element.getBoundingClientRect(); + const before = world(event.clientX - rect.left, event.clientY - rect.top); + state.camera.scale = clamp(state.camera.scale * Math.exp(-event.deltaY * 0.0012), 0.005, 7); + const after = world(event.clientX - rect.left, event.clientY - rect.top); + state.camera.x += before[0] - after[0]; + state.camera.y += before[1] - after[1]; + camera(); + event.preventDefault(); + }, { passive: false }); + /* Keyboard browsing: arrows pan, +/- zoom, F fits, Escape drops the selection. + Named handler: the host element outlives engine instances, so destroy must remove + it or handlers accumulate across mode switches and multiply pan/zoom steps. */ + const handleKeydown = event => { + const step = 90 / Math.max(0.02, state.camera.scale); + let handled = true; + switch (event.key) { + case 'ArrowLeft': state.camera.x -= step; break; + case 'ArrowRight': state.camera.x += step; break; + case 'ArrowUp': state.camera.y -= step; break; + case 'ArrowDown': state.camera.y += step; break; + case '+': case '=': + state.camera.scale = clamp(state.camera.scale * 1.25, 0.005, 7); break; + case '-': case '_': + state.camera.scale = clamp(state.camera.scale / 1.25, 0.005, 7); break; + case 'f': case 'F': fit(); break; + case 'Escape': clearFocus(); clearHover(); break; + default: handled = false; + } + if (handled) { camera(); event.preventDefault(); } + }; + element.tabIndex = 0; + element.addEventListener('keydown', handleKeydown); + + const observer = typeof ResizeObserver === 'function' ? new ResizeObserver(resize) : null; + if (observer) observer.observe(element); else window.addEventListener('resize', resize); + + initWebgl(); + if (gl && nodeProgram) { + worker = new Worker(WORKER_URL); + worker.onmessage = handleWorkerMessage; + worker.addEventListener('error', handleWorkerFailure); + worker.addEventListener('messageerror', handleWorkerFailure); + } else { + const error = new Error('This browser does not provide WebGL2, which the Every-node view requires.'); + error.code = 'WEBGL2_UNSUPPORTED'; + state.error = { code: error.code, message: error.message }; + if (typeof opts.onError === 'function') opts.onError(error); + } + resize(); + + function exportImageCanvas() { + if (state.destroyed || !state.ready || !nodeProgram) return null; + if (state.frame) { caf(state.frame); state.frame = 0; } + if (state.labelFrame) { caf(state.labelFrame); state.labelFrame = 0; } + /* Paint both layers synchronously: draw() alone would leave the overlay on a rAF + and the composite below would read the previous camera's labels/arrows/card. */ + const now = typeof performance !== 'undefined' ? performance.now() : 0; + draw(now); + drawRegions(); + drawOverlay(now); + const output = document.createElement('canvas'); + output.width = canvas.width; + output.height = canvas.height; + const context = output.getContext('2d'); + if (!context) return null; + context.drawImage(underlay, 0, 0); + context.drawImage(canvas, 0, 0); + context.drawImage(labels, 0, 0); + return output; + } + function destroyGraph() { + if (state.destroyed) return; + state.destroyed = true; + state.paused = true; + if (hitFrame) { caf(hitFrame); hitFrame = 0; } + if (layoutFrame) { caf(layoutFrame); layoutFrame = 0; } + if (state.frame) { caf(state.frame); state.frame = 0; } + if (state.labelFrame) { caf(state.labelFrame); state.labelFrame = 0; } + if (worker) { + worker.onmessage = null; + worker.removeEventListener('error', handleWorkerFailure); + worker.removeEventListener('messageerror', handleWorkerFailure); + worker.terminate(); + worker = null; + } + if (observer) observer.disconnect(); + else window.removeEventListener('resize', resize); + element.removeEventListener('keydown', handleKeydown); + if (gl) { + [nodeBuffers.position, nodeBuffers.color, nodeBuffers.size, nodeBuffers.flag, + edgeBuffers.position, edgeBuffers.factor, edgeBuffers.visible].forEach(buffer => { + if (buffer && typeof gl.deleteBuffer === 'function') gl.deleteBuffer(buffer); + }); + [nodeProgram, edgeProgram].forEach(value => { + if (value && typeof gl.deleteProgram === 'function') gl.deleteProgram(value); + }); + const loseContext = typeof gl.getExtension === 'function' + ? gl.getExtension('WEBGL_lose_context') : null; + if (loseContext && typeof loseContext.loseContext === 'function') loseContext.loseContext(); + } + nodeProgram = edgeProgram = null; + nodeBuffers = {}; edgeBuffers = {}; + state.ids = []; state.idIndex = new Map(); state.labels = []; state.types = []; state.communities = []; + state.edgeSources = new Uint32Array(0); state.edgeTargets = new Uint32Array(0); + state.edgeBridges = new Uint8Array(0); state.edgeGhosts = new Uint8Array(0); + state.edgeWeights = new Float32Array(0); state.edgeRelations = []; state.edgeLayers = []; + state.neighbors = null; state.incidentEdges = null; state.connectionHighlights = null; + state.positions = new Float32Array(0); + state.nodeFlags = state.nodeGhosts = new Uint8Array(0); + state.nodeColors = state.nodeSizes = new Float32Array(0); + element.removeAttribute('data-graph-style'); + element.replaceChildren(); + } + + const api = { + exportImageCanvas, + apply(fn, shouldFit) { if (typeof fn === 'function') fn(api); if (shouldFit) fit(); return api; }, + setData(data) { + if (state.destroyed || !worker) return api; + const nodes = Array.isArray(data && data.nodes) ? data.nodes : []; + const links = Array.isArray(data && data.links) ? data.links : (data && data.edges) || []; + state.ready = false; state.error = null; state.lastLabelKey = ''; state.layoutPending = true; + state.hover = -1; state.focus = -1; state.hoverPoint = [0, 0]; state.focusPoint = [0, 0]; + state.neighbors = null; state.incidentEdges = null; state.connectionHighlights = null; + state.edgeSources = new Uint32Array(0); state.edgeTargets = new Uint32Array(0); + state.edgeBridges = new Uint8Array(0); state.edgeWeights = new Float32Array(0); + state.edgeGhosts = new Uint8Array(0); state.edgeRelations = []; state.edgeLayers = []; + state.labelLayout = []; state.topNodes = new Uint32Array(0); + state.totalLinks = 0; state.edgeVertexCount = 0; state.bridgeCount = 0; + state.weightFloorSorted = new Float32Array(0); state.communityRegions = []; + state.degrees = new Float32Array(0); state.betweenness = new Float32Array(0); + state.evidenceMass = new Float32Array(0); state.visibleCount = 0; + state.pickGrid = null; state.pickDirty = true; + worker.postMessage({ type: 'prepare', payload: { nodes, links } }); + return api; + }, + setRenderMode(value) { state.renderMode = value === 'full' ? 'full' : 'all'; return api; }, + setPreset(value) { + const preset = PRESETS[value] ? value : 'communities'; + state.settings = { ...state.settings, ...PRESETS[preset], mode: preset }; + pendingLayoutFit = true; + postSettings(true, true); + uploadNodeMeta(); + schedule(); + scheduleLabels(true); + return { ...state.settings }; + }, + setStyle(value) { state.styleName = value || state.styleName; element.setAttribute('data-graph-style', state.styleName); uploadNodeMeta(); schedule(); scheduleLabels(true); return api; }, + setColorBy(value) { state.colorBy = value || state.colorBy; uploadNodeMeta(); schedule(); return api; }, + setPalette(value) { state.palette = typeof value === 'string' ? value : state.palette; if (state.palette !== 'custom') state.typeColors = {}; uploadNodeMeta(); schedule(); return api; }, + setTypeColors(value) { state.typeColors = value && typeof value === 'object' ? { ...state.typeColors, ...value } : {}; uploadNodeMeta(); schedule(); return api; }, + setThemeColors(value) { state.themeColors = value && typeof value === 'object' ? { ...value } : {}; uploadNodeMeta(); schedule(); return api; }, + setSettings(value) { + const patch = value || {}; + state.settings = { ...state.settings, ...patch }; + state.flowPaintAt = 0; + const relayout = Object.keys(patch).some(key => ['mode', 'repel', 'link', 'gravity'].includes(key)); + postSettings(relayout); + uploadNodeMeta(); + camera(); + scheduleLabels(true); + return api; + }, + setScope(value) { + state.scope = value && typeof value === 'object' + ? { ...state.scope, ...value } + : { minDegree: 0, showUnlinked: true, depth: 2 }; + refreshVisibility(); + camera(); + return api; + }, + setLayers(value) { + state.layers = value && typeof value === 'object' ? { ...value } : null; + uploadEdges(); + state.lastLabelKey = ''; + state.labelLayout = []; + schedule(); + scheduleLabels(true); + if (typeof opts.onMetrics === 'function') opts.onMetrics(api.metrics()); + return api; + }, + setRepoFilter(value) { state.repoFilter = String(value || '').slice(0, 200); return api; }, + setAsOf(value) { state.asOf = value || null; return api; }, + setSizeBy(value) { state.sizeBy = ['degree', 'betweenness', 'evidence_mass'].includes(value) ? value : 'degree'; uploadNodeMeta(); schedule(); return api; }, + setBridges(value) { state.bridges = value !== false; uploadEdges(); schedule(); if (typeof opts.onMetrics === 'function') opts.onMetrics(api.metrics()); return api; }, + setCollapse(value) { + state.collapse = value === true ? true : value === 'auto' ? 'auto' : false; + state.collapsed = value === true; + if (typeof opts.onCollapseChange === 'function') opts.onCollapseChange(state.collapsed); + stats(); + return api; + }, + setGhosts(value) { state.ghosts = value !== false; refreshVisibility(); camera(); return api; }, + setHighlight(id) { + const index = state.idIndex.get(String(id)); + state.focus = index === undefined ? -1 : index; + state.focusPoint = index === undefined ? [0, 0] : [state.positions[index * 2], state.positions[index * 2 + 1]]; + applyHoverToFlags(); + state.lastLabelKey = ''; + scheduleLabels(true); + return api; + }, + clearFocus() { + state.focus = -1; + state.focusPoint = [0, 0]; + applyHoverToFlags(); + state.lastLabelKey = ''; + scheduleLabels(true); + return api; + }, + reveal(id) { + const index = state.idIndex.get(String(id)); + if (index === undefined || !state.nodeFlags[index]) return false; + state.camera.x = state.positions[index * 2]; + state.camera.y = state.positions[index * 2 + 1]; + state.camera.scale = Math.max(1.2, state.camera.scale); + state.focus = index; + state.focusPoint = [state.positions[index * 2], state.positions[index * 2 + 1]]; + applyHoverToFlags(); + camera(); + return true; + }, + focus(id) { return api.reveal(id); }, + zoomToNode(id) { return api.reveal(id); }, + communityMap() { + const result = {}; + state.ids.forEach((id, index) => { result[id] = state.communities[index] ?? index; }); + return result; + }, + resize, fit, + reheat() { + if (state.settings.frozen || !worker) return api; + state.layoutPending = true; + stats({ layoutPending: true }); + worker.postMessage({ type: 'reheat' }); + return api; + }, + freeze(value = true) { state.settings.frozen = value !== false; return api.setSettings({ frozen: state.settings.frozen }); }, + pause() { state.paused = true; if (state.frame) { caf(state.frame); state.frame = 0; } return api; }, + resume() { state.paused = false; schedule(); scheduleLabels(); return api; }, + state() { + return { + mode: 'all', presentation: 'all', + nodeCount: state.ids.length, + visibleNodeCount: state.visibleCount, + edgeCount: state.totalLinks, + drawnEdgeCount: drawnEdgeEstimate(), + renderer: gl && nodeProgram ? 'webgl2' : 'unsupported', + collapsed: state.collapsed, collapse: state.collapse, + scope: { ...state.scope }, + relationFlow: state.settings.flow === true, + flowSpeed: Number(state.settings.flowSpeed || 0), + layoutPending: state.layoutPending, + frozen: state.settings.frozen === true, + paused: state.paused === true, + }; + }, + metrics() { + const bridges = state.edgeBridges.reduce((count, value) => count + (value ? 1 : 0), 0); + return { + ...api.state(), bridges, + top: Array.from(state.topNodes.slice(0, 5), node => ({ + id: state.ids[node], name: state.labels[node], score: state.degrees[node] || 0, + })), + }; + }, + physicsDiagnostics() { + return { + mode: 'all', simulation: false, layout: 'deterministic-worker', + controls: 'bounded-layout-forces', + relationFlow: state.settings.flow === true, + frozen: state.settings.frozen === true, + paused: state.paused === true, + }; + }, + graphToScreen(x, y) { + return { + x: (Number(x) - state.camera.x) * state.camera.scale + state.width / 2, + y: (Number(y) - state.camera.y) * state.camera.scale + state.height / 2, + }; + }, + getPhysicsSnapshot() { + const nodes = []; + const limit = Math.min(128, state.topNodes.length); + for (let index = 0; index < limit; index += 1) { + const node = state.topNodes[index]; + nodes.push({ + id: state.ids[node], x: state.positions[node * 2], y: state.positions[node * 2 + 1], + vx: 0, vy: 0, radius: pointSize(node), communityId: state.communities[node], + }); + } + return { + center: null, nodes, systemAnchors: [], + paused: state.settings.frozen === true || state.paused === true, + diagnostics: api.physicsDiagnostics(), + }; + }, + destroy: destroyGraph, + }; + return api; + } + + window.EngraphisEveryGraph = { create, MAX_NODES, MAX_LINKS }; + /* Compatibility alias: the legacy classic/static dashboards still address the all-node + engine by its historical global; they get the Every-node implementation. */ + window.EngraphisAllGraph = window.EngraphisEveryGraph; +})(); diff --git a/engraphis/dashboard_assets/engraphis-graph-worker.js b/engraphis/dashboard_assets/engraphis-graph-worker.js deleted file mode 100644 index 735d101a..00000000 --- a/engraphis/dashboard_assets/engraphis-graph-worker.js +++ /dev/null @@ -1,507 +0,0 @@ -/* Worker for the explicit 20k-node profile. Object-heavy preparation, deterministic placement, - ranked edge selection and spatial hit testing stay off the UI thread. */ -(function () { - 'use strict'; - const MAX_NODES = 20000; - const MAX_LINKS = 200000; - const LOW_ZOOM_EDGE_LIMIT = 0; - const MEDIUM_ZOOM_EDGE_LIMIT = 25000; - const HIGH_ZOOM_EDGE_LIMIT = 75000; - const CANVAS_MEDIUM_ZOOM_EDGE_LIMIT = 8000; - const CANVAS_HIGH_ZOOM_EDGE_LIMIT = 25000; - const LABEL_LIMIT = 220; - const CELL_SIZE = 48; - const FAR_ENTER = 0.35, FAR_EXIT = 0.45; - const MEDIUM_ENTER = 0.9, MEDIUM_EXIT = 1.2; - const FAR_NODE_BUDGET = 500, MEDIUM_NODE_BUDGET = 3000; - const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5)); - const state = { - ids: [], labels: [], types: [], positions: new Float32Array(0), basePositions: new Float32Array(0), degrees: new Float32Array(0), betweenness: new Float32Array(0), evidenceMass: new Float32Array(0), nodeGhosts: new Uint8Array(0), - communities: [], topNodes: new Uint32Array(0), edgeSources: new Uint32Array(0), - edgeTargets: new Uint32Array(0), edgeStrength: new Float32Array(0), edgeLayers: [], edgeBridges: new Uint8Array(0), edgeGhosts: new Uint8Array(0), - edgeOrder: new Uint32Array(0), edgeRank: new Uint32Array(0), adjacencyOffsets: new Uint32Array(0), - adjacencyEdges: new Uint32Array(0), edgeSeen: new Uint32Array(0), edgeStamp: 0, - nodeSeen: new Uint32Array(0), nodeStamp: 0, - allNodes: new Uint32Array(0), grid: new Map(), layers: null, focusIndex: -1, - lastCameraKey: '', lastVisibleNodes: new Uint32Array(0), lastVisibleEdges: new Uint32Array(0), - lastVisibleLabels: new Uint32Array(0), canvasFallback: false, showBridges: true, showGhosts: true, paintOrder: new Uint32Array(0), - layoutSettings: {}, labelDensity: 24, - scope: { minDegree: 1, showUnlinked: true, depth: 2 }, collapseMode: false, - collapsed: false, lodTier: 'medium', lastVisibleMask: new Uint8Array(0), - layoutRevision: 0, - }; - const finite = (value, fallback) => Number.isFinite(Number(value)) ? Number(value) : fallback; - const clamp = (value, low, high) => Math.max(low, Math.min(high, value)); - const key = value => String(value == null ? '' : value); - /* Preserve valid falsy ids such as 0 and false. A boolean fallback chain drops them and can - stringify endpoint objects as "[object Object]" instead of reading their stable id. */ - function endpoint(link, side) { - if (!link || typeof link !== 'object') return ''; - const alternate = side === 'source' ? 'from' : 'to'; - const value = link[side] !== undefined ? link[side] : link[alternate]; - return key(value && typeof value === 'object' ? value.id : value); - } - const cellKey = (x, y) => `${Math.floor(x / CELL_SIZE)},${Math.floor(y / CELL_SIZE)}`; - function rebuildGrid() { - state.grid = new Map(); - for (let index = 0; index < state.ids.length; index += 1) { - const bucket = cellKey(state.positions[index * 2], state.positions[index * 2 + 1]); - if (!state.grid.has(bucket)) state.grid.set(bucket, []); - state.grid.get(bucket).push(index); - } - } - function cameraKey(camera) { - return [ - finite(camera && camera.x, 0), finite(camera && camera.y, 0), - finite(camera && camera.scale, 1), finite(camera && camera.width, 1), - finite(camera && camera.height, 1), state.focusIndex, - state.layers ? JSON.stringify(state.layers) : '', - state.scope.minDegree, state.scope.showUnlinked, state.scope.depth, - state.collapseMode || '', state.showGhosts, - state.lodTier, - ].join('|'); - } - function makePositions(nodes, groups) { - const result = new Float32Array(nodes.length * 2); - const order = [...groups.keys()].sort((a, b) => groups.get(b).length - groups.get(a).length || a.localeCompare(b)); - const groupIndex = new Map(order.map((value, index) => [value, index])); - const radius = Math.max(280, Math.sqrt(nodes.length) * 18), offsets = new Map(); - nodes.forEach((node, index) => { - const group = key(node && (node.community_id != null ? node.community_id : node.community)); - const ordinal = offsets.get(group) || 0; offsets.set(group, ordinal + 1); - const groupNumber = groupIndex.get(group) || 0, count = Math.max(1, order.length); - const angle = groupNumber * GOLDEN_ANGLE * 7; - const groupRadius = count === 1 ? 0 : radius * (0.35 + 0.65 * Math.sqrt((groupNumber + 1) / count)); - const localRadius = Math.max(16, Math.sqrt((groups.get(group) || []).length) * 13); - const localAngle = ordinal * GOLDEN_ANGLE, distance = Math.min(Math.sqrt(ordinal + 1) * 5.5, localRadius); - const x = finite(node && node.x, NaN), y = finite(node && node.y, NaN); - result[index * 2] = Number.isFinite(x) ? x : Math.cos(angle) * groupRadius + Math.cos(localAngle) * distance; - result[index * 2 + 1] = Number.isFinite(y) ? y : Math.sin(angle) * groupRadius * 0.72 + Math.sin(localAngle) * distance * 0.8; - }); - return result; - } - function makeBounds(positions) { - let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; - for (let index = 0; index < positions.length; index += 2) { - minX = Math.min(minX, positions[index]); maxX = Math.max(maxX, positions[index]); - minY = Math.min(minY, positions[index + 1]); maxY = Math.max(maxY, positions[index + 1]); - } - return { minX: Number.isFinite(minX) ? minX : 0, maxX: Number.isFinite(maxX) ? maxX : 0, minY: Number.isFinite(minY) ? minY : 0, maxY: Number.isFinite(maxY) ? maxY : 0 }; - } - function applyLayout(notify = false, fit = false) { - if (!state.basePositions.length) return; - const settings = state.layoutSettings || {}, mode = key(settings.mode || 'communities'); - const galaxyMode = mode === 'galaxy'; - /* Galaxy coordinates and hierarchy are server-authored. Each bounded refinement starts - from those coordinates, preserving the authored scene while allowing the shared force - controls to make a deterministic, hierarchy-preserving adjustment. */ - const repel = Math.max(0, finite(settings.repel, galaxyMode ? 60 : 48)); - const link = Math.max(1, finite(settings.link, galaxyMode ? 8 : 16)); - const gravity = Math.max(0, finite(settings.gravity, 48)); - const galacticGravity = Math.max(0, finite(settings.gravitationalConstant, 1)); - const blackHoleMass = Math.max(0.1, finite(settings.blackHoleMass, 1)); - const localGravity = Math.max(0, finite(settings.localGravitationalConstant, 1)); - const damping = Math.max(0, finite(settings.damping, 1)); - const spring = Math.max(0, finite(settings.springStiffness, 1)); - const modeScale = { original: 1.32, compact: 0.76, communities: 1, radial: 1.08, constellation: 1.18, galaxy: 1.04 }[mode] || 1; - /* The All profile stays deterministic and worker-only, but its controls are real forces: - repel expands the initial envelope, link is the spring target below, and gravity pulls - the settled result toward the global centre. The bounded passes are O(nodes + links). */ - /* Galaxy's server coordinates are the neutral point for the full-node profile. Relative - controls keep the authored scene unchanged at the default preset while making every - exposed force a bounded refinement around that hierarchy. */ - const repelSpread = galaxyMode - ? (0.58 + repel / 72) / (0.58 + 60 / 72) - : 0.58 + repel / 72; - const gravityTightening = galaxyMode - ? (0.72 + 48 / 128 + 0.05) - / (0.72 + gravity / 128 + galacticGravity * blackHoleMass * 0.05) - : 1 / (0.72 + gravity / 128 + galacticGravity * blackHoleMass * 0.05); - const spaceSpread = galaxyMode - ? (0.86 + localGravity * 0.07 - Math.min(2, damping) * 0.035) - / (0.86 + 0.07 - 0.035) - : 0.86 + localGravity * 0.07 - Math.min(2, damping) * 0.035; - const linkSpread = galaxyMode ? 1 + (link - 8) * 0.01 : 1; - const spread = (galaxyMode ? 1 : modeScale) - * clamp(repelSpread * gravityTightening * spaceSpread * linkSpread, 0.42, 3.2); - const baseBounds = makeBounds(state.basePositions), centerX = (baseBounds.minX + baseBounds.maxX) / 2, centerY = (baseBounds.minY + baseBounds.maxY) / 2; - state.positions = new Float32Array(state.basePositions.length); - for (let index = 0; index < state.basePositions.length; index += 2) { - let x = state.basePositions[index] - centerX, y = state.basePositions[index + 1] - centerY; - if (mode === 'radial') { const angle = Math.atan2(y, x), radius = Math.hypot(x, y) * spread; x = Math.cos(angle) * radius; y = Math.sin(angle) * radius; } - else if (mode === 'constellation') { x *= spread; y = y * spread * 0.72 + Math.sin(index * GOLDEN_ANGLE + state.layoutRevision) * 8; } - else if (mode === 'galaxy') { x *= spread; y *= spread; } - else { x *= spread; y *= spread; } - if (state.layoutRevision && !galaxyMode) { - const phase = (index / 2 + 1) * GOLDEN_ANGLE + state.layoutRevision * 0.73; - const jitter = Math.min(10, 1.5 + link * 0.08); - x += Math.cos(phase) * jitter; y += Math.sin(phase) * jitter; - } - state.positions[index] = x + centerX; state.positions[index + 1] = y + centerY; - } - if (state.edgeSources.length) { - const nodeCount = state.positions.length / 2; - const desired = clamp((galaxyMode ? 20 : 10) + link * 1.25 - - (galaxyMode ? 8 * 1.25 : 0), 14, 112); - const springForce = clamp((galaxyMode ? Math.max(0, spring - 1) * 0.018 : 0.025 + spring * 0.018), - galaxyMode ? 0 : 0.025, 0.2) - * (mode === 'compact' ? 1.22 : mode === 'original' ? 0.72 : 1); - const settle = 1 / (1 + Math.min(12, damping) * 0.18); - const passes = nodeCount > 12000 ? 1 : 2; - const delta = new Float32Array(state.positions.length); - const counts = new Float32Array(nodeCount); - for (let pass = 0; pass < passes; pass += 1) { - delta.fill(0); counts.fill(0); - for (let edge = 0; edge < state.edgeSources.length; edge += 1) { - const source = state.edgeSources[edge], target = state.edgeTargets[edge]; - const sx = state.positions[source * 2], sy = state.positions[source * 2 + 1]; - const dx = state.positions[target * 2] - sx, dy = state.positions[target * 2 + 1] - sy; - const distance = Math.max(0.001, Math.hypot(dx, dy)); - const strength = clamp(Math.log1p(Math.max(0, state.edgeStrength[edge])) / 3, 0.3, 1.4); - const pull = clamp((distance - desired) / distance, -1.5, 1.5) * springForce * strength; - delta[source * 2] += dx * pull; delta[source * 2 + 1] += dy * pull; - delta[target * 2] -= dx * pull; delta[target * 2 + 1] -= dy * pull; - counts[source] += 1; counts[target] += 1; - } - const centrePull = clamp((galaxyMode - ? (Math.abs(gravity - 48) / 400 + Math.abs(galacticGravity * blackHoleMass - 1) * 0.035) - : gravity / 400 + galacticGravity * blackHoleMass * 0.035) * 0.05, 0, 0.075); - for (let index = 0; index < nodeCount; index += 1) { - const offset = index * 2, divisor = Math.max(1, counts[index]); - const x = state.positions[offset], y = state.positions[offset + 1]; - state.positions[offset] = x + delta[offset] / divisor * settle + (centerX - x) * centrePull; - state.positions[offset + 1] = y + delta[offset + 1] / divisor * settle + (centerY - y) * centrePull; - } - } - } - rebuildGrid(); state.lastCameraKey = ''; - if (notify) { const positions = state.positions.slice(); self.postMessage({ type: 'layout', positions, bounds: makeBounds(state.positions), fit }, [positions.buffer]); } - } - function edgeAllowed(edge) { - if (state.layers && state.layers[state.edgeLayers[edge]] === false) return false; - if (!state.showGhosts && state.edgeGhosts[edge]) return false; - return true; - } - function rebuildPaintOrder() { - const values = []; - for (let index = 0; index < state.edgeOrder.length; index += 1) { - const edge = state.edgeOrder[index]; - if (edgeAllowed(edge)) values.push(edge); - } - state.paintOrder = new Uint32Array(values); - } - function prepare(payload) { - const input = (Array.isArray(payload && payload.nodes) ? payload.nodes : []).slice().sort((a, b) => key(a && a.id).localeCompare(key(b && b.id))); - const inputLinks = Array.isArray(payload && (payload.links || payload.edges)) ? (payload.links || payload.edges) : []; - if (input.length > MAX_NODES) { self.postMessage({ type: 'capacity', resource: 'nodes', count: input.length, limit: MAX_NODES }); return; } - if (inputLinks.length > MAX_LINKS) { self.postMessage({ type: 'capacity', resource: 'relations', count: inputLinks.length, limit: MAX_LINKS }); return; } - const nodes = [], ids = [], labels = [], nodeIndex = new Map(), groups = new Map(); - input.forEach(node => { - const id = key(node && node.id); if (!id || nodeIndex.has(id)) return; - nodeIndex.set(id, ids.length); nodes.push(node || {}); ids.push(id); - labels.push(key(node && (node.label || node.name || id))); - const group = key(node && (node.community_id != null ? node.community_id : node.community)); - if (!groups.has(group)) groups.set(group, []); groups.get(group).push(ids.length - 1); - }); - const positions = makePositions(nodes, groups); - const nodeGhosts = new Uint8Array(nodes.map(node => node && node.ghost === true ? 1 : 0)); - state.basePositions = positions.slice(); - state.positions = positions.slice(); - state.layoutRevision = 0; - state.lastVisibleMask = new Uint8Array(ids.length); - const communities = nodes.map(node => key( - node && (node.community_id != null ? node.community_id : node.community))); - const types = nodes.map(node => key( - node && (node.etype || node.type || 'person_or_concept'))); - const previewPositions = state.positions.slice(); - const previewGhosts = nodeGhosts.slice(); - self.postMessage({ type: 'preview', ids, labels, types, positions: previewPositions, communities, nodeGhosts: previewGhosts, bounds: makeBounds(state.positions), totalNodes: ids.length }, [previewPositions.buffer, previewGhosts.buffer]); - const degrees = new Float32Array(ids.length), edges = []; - inputLinks.forEach((link, ordinal) => { - const source = endpoint(link, 'source'); - const target = endpoint(link, 'target'); - const sourceIndex = nodeIndex.get(source), targetIndex = nodeIndex.get(target); - if (sourceIndex == null || targetIndex == null || sourceIndex === targetIndex) return; - degrees[sourceIndex] += 1; degrees[targetIndex] += 1; - edges.push({ source: sourceIndex, target: targetIndex, strength: finite(link && (link.strength || link.weight), 1), layer: key(link && link.layer || 'semantic'), bridge: link && link.bridge === true, ghost: link && link.ghost === true, ordinal }); - }); - const order = edges.map((_value, index) => index).sort((a, b) => edges[b].strength - edges[a].strength || edges[a].ordinal - edges[b].ordinal); - const edgeRank = new Uint32Array(edges.length); - order.forEach((edge, rank) => { edgeRank[edge] = rank; }); - const betweenness = new Float32Array(ids.length), evidenceMass = new Float32Array(ids.length); - nodes.forEach((node, index) => { - betweenness[index] = Math.max(0, finite( - node && (node.betweenness || node.bridge_score || node.pagerank), 0)); - evidenceMass[index] = Math.max(0, finite( - node && (node.evidence_mass || node.evidenceMass || node.gravity_mass - || node.mass_score || node.mass), degrees[index] || 0)); - }); - state.ids = ids; state.labels = labels; state.types = types; state.degrees = degrees; state.betweenness = betweenness; state.evidenceMass = evidenceMass; state.nodeGhosts = nodeGhosts; state.communities = communities; - state.edgeSources = new Uint32Array(edges.map(edge => edge.source)); state.edgeTargets = new Uint32Array(edges.map(edge => edge.target)); - state.edgeStrength = new Float32Array(edges.map(edge => edge.strength)); state.edgeLayers = edges.map(edge => edge.layer); state.edgeBridges = new Uint8Array(edges.map(edge => edge.bridge ? 1 : 0)); state.edgeGhosts = new Uint8Array(edges.map(edge => edge.ghost ? 1 : 0)); state.edgeOrder = new Uint32Array(order); state.edgeRank = edgeRank; - applyLayout(false); - const incidence = new Uint32Array(ids.length); - edges.forEach(edge => { incidence[edge.source] += 1; incidence[edge.target] += 1; }); - const adjacencyOffsets = new Uint32Array(ids.length + 1); - for (let index = 0; index < ids.length; index += 1) adjacencyOffsets[index + 1] = adjacencyOffsets[index] + incidence[index]; - const adjacencyEdges = new Uint32Array(adjacencyOffsets[ids.length]), cursor = adjacencyOffsets.slice(0, -1); - edges.forEach((_edge, edgeIndex) => { const source = state.edgeSources[edgeIndex], target = state.edgeTargets[edgeIndex]; adjacencyEdges[cursor[source]++] = edgeIndex; adjacencyEdges[cursor[target]++] = edgeIndex; }); - for (let index = 0; index < ids.length; index += 1) { - const start = adjacencyOffsets[index], end = adjacencyOffsets[index + 1], segment = Array.from(adjacencyEdges.slice(start, end)); - segment.sort((a, b) => edgeRank[a] - edgeRank[b]); - adjacencyEdges.set(segment, start); - } - state.adjacencyOffsets = adjacencyOffsets; state.adjacencyEdges = adjacencyEdges; state.edgeSeen = new Uint32Array(edges.length); state.edgeStamp = 0; - state.nodeSeen = new Uint32Array(ids.length); state.nodeStamp = 0; - state.topNodes = new Uint32Array(Array.from({ length: ids.length }, (_v, index) => index).sort((a, b) => degrees[b] - degrees[a] || a - b)); - state.allNodes = new Uint32Array(ids.length); for (let index = 0; index < ids.length; index += 1) state.allNodes[index] = index; - rebuildPaintOrder(); - rebuildGrid(); - state.lastCameraKey = ''; - const positionsOut = state.positions.slice(), degreesOut = degrees.slice(), betweennessOut = betweenness.slice(), evidenceMassOut = evidenceMass.slice(), nodeGhostsOut = nodeGhosts.slice(), edgeSourcesOut = state.edgeSources.slice(), edgeTargetsOut = state.edgeTargets.slice(), edgeStrengthOut = state.edgeStrength.slice(), edgeBridgesOut = state.edgeBridges.slice(), topNodesOut = state.topNodes.slice(); - self.postMessage({ type: 'ready', ids, labels, types, positions: positionsOut, degrees: degreesOut, betweenness: betweennessOut, evidenceMass: evidenceMassOut, nodeGhosts: nodeGhostsOut, communities, bounds: makeBounds(state.positions), edgeSources: edgeSourcesOut, edgeTargets: edgeTargetsOut, edgeStrength: edgeStrengthOut, edgeBridges: edgeBridgesOut, edgeLayers: state.edgeLayers, topNodes: topNodesOut, totalNodes: ids.length, totalLinks: edges.length }, [positionsOut.buffer, degreesOut.buffer, betweennessOut.buffer, evidenceMassOut.buffer, nodeGhostsOut.buffer, edgeSourcesOut.buffer, edgeTargetsOut.buffer, edgeStrengthOut.buffer, edgeBridgesOut.buffer, topNodesOut.buffer]); - } - function inViewport(index, camera, padding = 1) { - const scale = Math.max(0.01, finite(camera && camera.scale, 1)), width = Math.max(1, finite(camera && camera.width, 1)), height = Math.max(1, finite(camera && camera.height, 1)); - const halfWidth = width / scale / 2 * padding, halfHeight = height / scale / 2 * padding, x = state.positions[index * 2], y = state.positions[index * 2 + 1]; - return x >= finite(camera && camera.x, 0) - halfWidth && x <= finite(camera && camera.x, 0) + halfWidth && y >= finite(camera && camera.y, 0) - halfHeight && y <= finite(camera && camera.y, 0) + halfHeight; - } - function focusMask() { - if (state.focusIndex < 0 || state.focusIndex >= state.ids.length) return null; - const mask = new Uint8Array(state.ids.length), depth = clamp(Math.round(finite(state.scope.depth, 2)), 1, 4); - mask[state.focusIndex] = 1; - let frontier = [state.focusIndex]; - for (let hop = 0; hop < depth && frontier.length; hop += 1) { - const next = []; - for (let cursor = 0; cursor < frontier.length; cursor += 1) { - const node = frontier[cursor], start = state.adjacencyOffsets[node] || 0; - const end = state.adjacencyOffsets[node + 1] || 0; - for (let edgeCursor = start; edgeCursor < end; edgeCursor += 1) { - const edge = state.adjacencyEdges[edgeCursor]; - const neighbour = state.edgeSources[edge] === node ? state.edgeTargets[edge] : state.edgeSources[edge]; - if (!mask[neighbour]) { mask[neighbour] = 1; next.push(neighbour); } - } - } - frontier = next; - } - return mask; - } - function nodeAllowed(index, focused) { - if (index < 0 || index >= state.ids.length) return false; - if (!state.showGhosts && state.nodeGhosts[index]) return false; - if (focused && !focused[index]) return false; - const degree = state.degrees[index] || 0; - return (degree > 0 && degree >= state.scope.minDegree) - || (degree === 0 && state.scope.showUnlinked); - } - function setCollapsed(value) { - const next = value === true; - if (next === state.collapsed) return; - state.collapsed = next; - self.postMessage({ type: 'collapse', value: next }); - } - function collapseRepresentatives(values) { - const representatives = new Map(); - for (let cursor = 0; cursor < values.length; cursor += 1) { - const index = values[cursor]; - const community = state.communities[index] || `__node:${index}`; - const previous = representatives.get(community); - if (previous == null || state.degrees[index] > state.degrees[previous] - || (state.degrees[index] === state.degrees[previous] && index < previous)) { - representatives.set(community, index); - } - } - return new Uint32Array([...representatives.values()].sort((a, b) => a - b)); - } - function resolveLodTier(scale) { - const current = state.lodTier; - if (current === 'far') { - if (scale < FAR_EXIT) return 'far'; - return scale >= MEDIUM_EXIT ? 'near' : 'medium'; - } - if (current === 'near') { - if (scale >= MEDIUM_ENTER) return 'near'; - return scale < FAR_ENTER ? 'far' : 'medium'; - } - if (scale < FAR_ENTER) return 'far'; - if (scale >= MEDIUM_EXIT) return 'near'; - return 'medium'; - } - function boundedNodes(values, limit) { - if (values.length <= limit) return values; - state.nodeStamp = (state.nodeStamp + 1) >>> 0; - if (!state.nodeStamp) { state.nodeSeen.fill(0); state.nodeStamp = 1; } - for (let index = 0; index < values.length; index += 1) { - state.nodeSeen[values[index]] = state.nodeStamp; - } - const retained = []; - const focused = state.focusIndex; - if (focused >= 0 && state.nodeSeen[focused] === state.nodeStamp) retained.push(focused); - for (let index = 0; index < state.topNodes.length && retained.length < limit; index += 1) { - const node = state.topNodes[index]; - if (node !== focused && state.nodeSeen[node] === state.nodeStamp) retained.push(node); - } - retained.sort((left, right) => left - right); - return new Uint32Array(retained); - } - function visibleNodes(camera) { - const scale = Math.max(0.01, finite(camera && camera.scale, 1)); - const focused = focusMask(); - const allVisibleNodes = () => { - const result = []; - for (let index = 0; index < state.ids.length; index += 1) { - if (nodeAllowed(index, focused)) result.push(index); - } - return new Uint32Array(result); - }; - const shouldCollapse = state.focusIndex < 0 && ( - state.collapseMode === true - || (state.collapseMode === 'auto' && state.lodTier === 'far') - ); - if (state.lodTier === 'far') { - const values = allVisibleNodes(); - setCollapsed(shouldCollapse); - const representatives = shouldCollapse ? collapseRepresentatives(values) : values; - return boundedNodes(representatives, FAR_NODE_BUDGET); - } - const width = Math.max(1, finite(camera && camera.width, 1)); - const height = Math.max(1, finite(camera && camera.height, 1)); - const halfWidth = width / scale / 2 * 1.05, halfHeight = height / scale / 2 * 1.05; - const minCellX = Math.floor((finite(camera && camera.x, 0) - halfWidth) / CELL_SIZE); - const maxCellX = Math.floor((finite(camera && camera.x, 0) + halfWidth) / CELL_SIZE); - const minCellY = Math.floor((finite(camera && camera.y, 0) - halfHeight) / CELL_SIZE); - const maxCellY = Math.floor((finite(camera && camera.y, 0) + halfHeight) / CELL_SIZE); - let values; - if (maxCellX - minCellX > 256 || maxCellY - minCellY > 256) { - values = allVisibleNodes(); - } else { - const result = []; - for (let cellY = minCellY; cellY <= maxCellY; cellY += 1) { - for (let cellX = minCellX; cellX <= maxCellX; cellX += 1) { - const bucket = state.grid.get(`${cellX},${cellY}`); - if (bucket) bucket.forEach(index => { - if (nodeAllowed(index, focused)) result.push(index); - }); - } - } - values = new Uint32Array(result); - } - setCollapsed(shouldCollapse); - if (shouldCollapse) values = collapseRepresentatives(values); - return state.lodTier === 'medium' - ? boundedNodes(values, MEDIUM_NODE_BUDGET) : values; - } - function visibleEdges(camera, nodes) { - const tier = state.lodTier; - const limit = tier === 'far' ? LOW_ZOOM_EDGE_LIMIT - : tier === 'medium' - ? (state.canvasFallback ? CANVAS_MEDIUM_ZOOM_EDGE_LIMIT : MEDIUM_ZOOM_EDGE_LIMIT) - : (state.canvasFallback ? CANVAS_HIGH_ZOOM_EDGE_LIMIT : HIGH_ZOOM_EDGE_LIMIT); - if (!limit) return new Uint32Array(0); - const visible = new Uint8Array(state.ids.length); - for (let index = 0; index < nodes.length; index += 1) visible[nodes[index]] = 1; - if (state.focusIndex < 0 && nodes.length > state.ids.length * 0.55) { - const result = []; - for (let index = 0; index < state.paintOrder.length && result.length < limit; index += 1) { - const edge = state.paintOrder[index]; - if (visible[state.edgeSources[edge]] && visible[state.edgeTargets[edge]]) result.push(edge); - } - return new Uint32Array(result); - } - const selected = state.focusIndex, candidates = [], result = []; - state.edgeStamp = (state.edgeStamp + 1) >>> 0; if (!state.edgeStamp) { state.edgeSeen.fill(0); state.edgeStamp = 1; } - const addNode = index => { const start = state.adjacencyOffsets[index] || 0, end = state.adjacencyOffsets[index + 1] || 0; for (let cursor = start; cursor < end; cursor += 1) { const edge = state.adjacencyEdges[cursor]; if (state.edgeSeen[edge] !== state.edgeStamp) { state.edgeSeen[edge] = state.edgeStamp; candidates.push(edge); } } }; - if (selected >= 0) addNode(selected); - for (let index = 0; index < nodes.length; index += 1) addNode(nodes[index]); - candidates.sort((a, b) => state.edgeRank[a] - state.edgeRank[b]); - for (let index = 0; index < candidates.length && result.length < limit; index += 1) { - const edge = candidates[index]; - if (!edgeAllowed(edge)) continue; - if (!visible[state.edgeSources[edge]] || !visible[state.edgeTargets[edge]]) continue; - result.push(edge); - } - return new Uint32Array(result); - } - function visibleLabels(camera, nodes) { - if (state.lodTier === 'far') return new Uint32Array(0); - const density = Math.max(0.25, Math.min(3, finite(state.labelDensity, 24) / 24)); - const limit = Math.min(LABEL_LIMIT, Math.max(12, Math.floor(80 * finite(camera && camera.scale, 1) * density))), result = [], visible = new Uint8Array(state.ids.length); - for (let index = 0; index < nodes.length; index += 1) visible[nodes[index]] = 1; - for (let index = 0; index < state.topNodes.length && result.length < limit; index += 1) if (visible[state.topNodes[index]]) result.push(state.topNodes[index]); - return new Uint32Array(result); - } - function camera(message) { - const scale = Math.max(0.01, finite(message && message.scale, 1)); - state.lodTier = resolveLodTier(scale); - const nextKey = cameraKey(message); - if (nextKey === state.lastCameraKey) { - self.postMessage({ type: 'camera-ack', revision: message && message.revision }); - return; - } - state.lastCameraKey = nextKey; - const nodes = visibleNodes(message), edges = visibleEdges(message, nodes), labels = visibleLabels(message, nodes); - const visibleMask = new Uint8Array(state.ids.length); - for (let index = 0; index < nodes.length; index += 1) visibleMask[nodes[index]] = 1; - const edgePositions = new Float32Array(edges.length * 4); - for (let index = 0; index < edges.length; index += 1) { const edge = edges[index], source = state.edgeSources[edge], target = state.edgeTargets[edge], offset = index * 4; edgePositions[offset] = state.positions[source * 2]; edgePositions[offset + 1] = state.positions[source * 2 + 1]; edgePositions[offset + 2] = state.positions[target * 2]; edgePositions[offset + 3] = state.positions[target * 2 + 1]; } - state.lastVisibleNodes = nodes; state.lastVisibleEdges = edges; state.lastVisibleLabels = labels; - state.lastVisibleMask = visibleMask; - self.postMessage({ type: 'visible', revision: message && message.revision, - nodes, edges, labels, edgePositions, totalLinks: state.edgeSources.length, - drawnLinks: edges.length, visibleNodeCount: nodes.length, - collapsed: state.collapsed, lodTier: state.lodTier }, - [nodes.buffer, edges.buffer, labels.buffer, edgePositions.buffer]); - } - function hit(message) { - const x = finite(message && message.x, 0), y = finite(message && message.y, 0), cellX = Math.floor(x / CELL_SIZE), cellY = Math.floor(y / CELL_SIZE), maxDistance = Math.max(8, 12 / Math.max(0.01, finite(message && message.scale, 1))), maxSquared = maxDistance * maxDistance; - let best = -1, distance = maxSquared; - const cellRadius = Math.max(1, Math.ceil(maxDistance / CELL_SIZE)); - for (let dx = -cellRadius; dx <= cellRadius; dx += 1) for (let dy = -cellRadius; dy <= cellRadius; dy += 1) (state.grid.get(`${cellX + dx},${cellY + dy}`) || []).forEach(index => { - const deltaX = state.positions[index * 2] - x, deltaY = state.positions[index * 2 + 1] - y, next = deltaX * deltaX + deltaY * deltaY; - if ((!state.showGhosts && state.nodeGhosts[index]) - || (state.lastVisibleMask.length && !state.lastVisibleMask[index])) return; - if (next < distance) { best = index; distance = next; } - }); - self.postMessage({ type: 'hit', request: message && message.request, index: best }); - } - self.onmessage = event => { - const message = event.data || {}; - if (message.type === 'prepare') prepare(message.payload || {}); - else if (message.type === 'camera') camera(message); - else if (message.type === 'hit') hit(message); - else if (message.type === 'focus') { - state.focusIndex = Number.isInteger(message.index) ? message.index : -1; - state.lastCameraKey = ''; - } else if (message.type === 'layers') { - state.layers = message.layers || null; rebuildPaintOrder(); state.lastCameraKey = ''; - } else if (message.type === 'renderer') { - state.canvasFallback = message.canvasFallback === true; state.lastCameraKey = ''; - } else if (message.type === 'settings') { - state.layoutSettings = message.settings && typeof message.settings === 'object' - ? { ...state.layoutSettings, ...message.settings } : state.layoutSettings; - state.labelDensity = finite(state.layoutSettings.labelDensity, state.labelDensity); - if (message.relayout === true) applyLayout(true, message.fit === true); - state.lastCameraKey = ''; - } else if (message.type === 'scope') { - const scope = message.scope && typeof message.scope === 'object' ? message.scope : {}; - state.scope = { - minDegree: clamp(Math.round(finite(scope.minDegree, state.scope.minDegree)), 0, 12), - showUnlinked: scope.showUnlinked !== false, - depth: clamp(Math.round(finite(scope.depth, state.scope.depth)), 1, 4), - }; - state.lastCameraKey = ''; - } else if (message.type === 'collapse') { - state.collapseMode = message.value === true ? true : message.value === 'auto' ? 'auto' : false; - if (!state.collapseMode) setCollapsed(false); - state.lastCameraKey = ''; - } else if (message.type === 'reheat') { - state.layoutRevision += 1; - applyLayout(true, false); - state.lastCameraKey = ''; - } else if (message.type === 'bridges') { - state.showBridges = message.value !== false; state.lastCameraKey = ''; - } else if (message.type === 'ghosts') { - state.showGhosts = message.value !== false; rebuildPaintOrder(); state.lastCameraKey = ''; - } - }; -})(); diff --git a/engraphis/dashboard_assets/index.html b/engraphis/dashboard_assets/index.html index ee541347..c5c10925 100644 --- a/engraphis/dashboard_assets/index.html +++ b/engraphis/dashboard_assets/index.html @@ -273,8 +273,7 @@

How this workspace connects

- - +
@@ -306,6 +305,7 @@

Layout

+
@@ -664,6 +664,7 @@

Connect to this Engraphis deployment

Graph connections

Connected nodes

+

diff --git a/engraphis/dashboard_assets/ledger.css b/engraphis/dashboard_assets/ledger.css index 2e8f0993..86876a62 100644 --- a/engraphis/dashboard_assets/ledger.css +++ b/engraphis/dashboard_assets/ledger.css @@ -610,8 +610,12 @@ body[data-theme="paper"] .graph-header { pointer-events: none; } .graph-canvas .engraphis-all-canvas, -.graph-canvas .engraphis-all-labels { position: absolute; inset: 0; width: 100%; height: 100%; display: block; } -.graph-canvas .engraphis-all-labels { pointer-events: none; } +.graph-canvas .engraphis-all-labels, +.graph-canvas .engraphis-all-underlay { + position: absolute; inset: 0; width: 100%; height: 100%; display: block; +} +.graph-canvas .engraphis-all-labels, +.graph-canvas .engraphis-all-underlay { pointer-events: none; } .graph-canvas.engraphis-all-node-hover { cursor: pointer; } .force-graph-container canvas { display: block; @@ -1307,3 +1311,12 @@ body[data-theme="paper"] .graph-header { .metrics { grid-template-columns: 1fr 1fr; } .graph-actions { flex-wrap: wrap; } } +/* Avoid a pointer-focus ring around the canvas while retaining a visible keyboard + indicator for the tab-focusable graph host (static CSS, CSP-safe). */ +div[data-graph-style]:focus { + outline: none; +} +div[data-graph-style]:focus-visible { + outline: 2px solid var(--c-acc); + outline-offset: -2px; +} diff --git a/engraphis/dashboard_assets/ledger.js b/engraphis/dashboard_assets/ledger.js index ab6f2ea5..008da727 100644 --- a/engraphis/dashboard_assets/ledger.js +++ b/engraphis/dashboard_assets/ledger.js @@ -18,6 +18,7 @@ graphWorkspace: '', graphData: null, graphDataMode: 'overview', + graphDataPreset: 'galaxy', graphDataIncludeCode: false, graphDataShowUnlinked: false, graphDataAsOf: null, @@ -175,6 +176,7 @@ radial: 'Radial', constellation: 'Constellation', galaxy: 'Galaxy gravity', + every: 'Every node', }; const GRAPH_STYLE_NOTES = { cyber: 'Iridescent PVD over graphite — cyan, violet, and magenta across each node.', @@ -418,12 +420,12 @@ } function ensureGraphAllAsset() { - if (window.EngraphisAllGraph) return Promise.resolve(); + if (window.EngraphisEveryGraph) return Promise.resolve(); if (!graphAllAssetsPromise) { const controller = new AbortController(); const attempt = loadScript( - graphAssetSource('/v2-assets/engraphis-graph-all.js?v=20260815-merge-ready-1'), - 'EngraphisAllGraph', controller.signal, + graphAssetSource('/v2-assets/engraphis-graph-every.js?v=20260823-every-19'), + 'EngraphisEveryGraph', controller.signal, ); graphAllAssetsPromise = attempt; graphAllAssetsController = controller; @@ -435,10 +437,16 @@ } function ensureGraphAssets(loadAll = false) { - /* Complete scenes always use the worker/WebGL renderer. Galaxy hierarchy is already - encoded in canonical server coordinates; loading ForceGraph here would restore the - duplicate live simulation that Show all is specifically designed to avoid. */ - if (loadAll) return ensureGraphAllAsset(); + /* The complete profile is an independent worker/WebGL renderer. Galaxy is the exception: + its solar-system view needs the authoritative hierarchical orbit integrator, so a full + Galaxy request uses the quality engine with the complete payload instead of the static + all-node worker. The factory decision is data-sensitive, not toolbar-sensitive: the + Every-node chip changes the preset to "every" before the scene arrives, and a fast click + can race the overview load. Keep both candidates available so an authored star/planet + scene cannot select an engine whose asset is still in flight. */ + if (loadAll) { + return Promise.all([ensureGraphAllAsset(), ensureGraphAssets(false)]); + } const coreReady = window.ForceGraph && window.EngraphisGraph && window.EngraphisSpacetime; if (!coreReady && !graphAssetsPromise) { const controller = new AbortController(); @@ -1135,6 +1143,7 @@ state.workspace = name; state.graphWorkspace = ''; state.graphData = null; + state.graphDataPreset = 'galaxy'; state.graphDataIncludeCode = false; state.graphDataShowUnlinked = false; state.graphDataRepo = ''; @@ -2031,13 +2040,18 @@ })); } + function graphEndpoint(value) { + if (value && typeof value === 'object') return value.id ?? value; + return value; + } + function graphLinks(payload) { const source = payload.edges || payload.links || []; return source.map((item, index) => ({ ...item, id: item.id || `edge-${index}`, - source: item.from || (item.source && (item.source.id || item.source)), - target: item.to || (item.target && (item.target.id || item.target)), + source: item.from ?? graphEndpoint(item.source), + target: item.to ?? graphEndpoint(item.target), label: item.label || item.relation || 'related', layer: item.layer || 'semantic', valid_from: item.valid_from, @@ -2049,7 +2063,8 @@ ghost: item.ghost === true, bridge: item.bridge === true, visible_by_default: item.visible_by_default !== false, - })).filter(item => item.source && item.target); + })).filter(item => item.source !== undefined && item.source !== null + && item.target !== undefined && item.target !== null); } function revealGraphNode(id, label = 'Selected entity') { @@ -2159,7 +2174,7 @@ } async function showGraphConnectionMemories(item, includeHistory = false) { - if (!item || !item.id || !state.workspace) return; + if (!item || item.id === undefined || item.id === null || !state.workspace) return; cancelGraphConnectionMemoryLoad(); const request = ++state.graphConnectionsRequest; const workspace = state.workspace; @@ -2235,8 +2250,12 @@ } function openGraphConnections(item) { - if (!item || !item.id) return; + if (!item || item.id === undefined || item.id === null) return; cancelGraphConnectionMemoryLoad(); + state.graphConnectionsFocusId = String(item.id); + state.graphConnectionsFocusLabel = item.name || item.label || item.id; + const focusButton = byId('graph-connections-focus'); + if (focusButton) focusButton.hidden = !state.graphEngine; const dialog = byId('graph-connections-dialog'); const entries = graphConnectionEntries(item); const title = item.name || item.label || item.id; @@ -2295,7 +2314,7 @@ ['graph-min-degree', 'graph-tune-min-degree', 'graph-collapse', 'graph-depth', 'graph-show-unlinked', 'graph-flow', 'graph-flow-speed'].forEach(id => { const control = byId(id); - if (control) control.disabled = false; + if (control) { control.disabled = false; control.title = ''; } }); all('[data-graph-layer="code"]').forEach(control => { control.disabled = false; @@ -2303,6 +2322,16 @@ ? 'Choose an exact repository first, then add its code overlay within the All Nodes capacity.' : ''; }); + /* Focus-depth and auto-collapse are not implemented in the Every-node engine yet. + Disable them honestly instead of leaving controls that silently do nothing. */ + if (full && byId('graph-preset').value === 'every') { + ['graph-collapse', 'graph-depth'].forEach(id => { + const control = byId(id); + if (!control) return; + control.disabled = true; + control.title = 'Not yet available in the Every-node view.'; + }); + } const lodNote = byId('graph-lod-note'); if (lodNote) lodNote.hidden = !full; byId('graph-reheat').textContent = full ? 'Reflow layout' : 'Reheat layout'; @@ -2316,12 +2345,6 @@ updateGraphGalaxyControls(); const preset = GRAPH_PRESET_LABELS[byId('graph-preset').value] || 'Galaxy gravity'; byId('graph-mode').textContent = `${full ? 'All nodes · LOD' : 'High quality'} · ${preset}`; - const toggle = byId('graph-show-all'); - if (toggle) { - toggle.textContent = 'All nodes'; - toggle.setAttribute('aria-pressed', String(full)); - toggle.title = full ? 'Return to the High quality graph' : `Load up to ${GRAPH_ALL_NODE_LIMIT.toLocaleString()} entities and ${GRAPH_ALL_EDGE_LIMIT.toLocaleString()} relationships with progressive LOD rendering`; - } } function graphIsGalaxy() { @@ -3154,11 +3177,13 @@ } function setGraphLoadControlsBusy(busy, disableRetry = true) { - const controls = disableRetry ? ['graph-show-all', 'graph-retry'] : ['graph-show-all']; + const controls = disableRetry ? ['graph-retry'] : []; controls.forEach(id => { const control = byId(id); if (control) control.disabled = busy; }); + const every = document.querySelector('[data-graph-preset-choice="every"]'); + if (every) every.disabled = busy; const retry = byId('graph-retry'); if (retry && ((busy && disableRetry) || !busy)) { retry.textContent = busy ? 'Reloading graph…' : 'Reload data'; @@ -3290,10 +3315,10 @@ candidateHost = null; }; const timeout = window.setTimeout(() => { - if (!fullGraph && (!window.ForceGraph || !window.EngraphisGraph || !window.EngraphisSpacetime)) { + if (!window.ForceGraph || !window.EngraphisGraph || !window.EngraphisSpacetime) { releaseGraphAssetsAttempt(graphAssetsPromise); } - if (fullGraph && !window.EngraphisAllGraph) { + if (fullGraph && !window.EngraphisEveryGraph) { releaseGraphAllAssetsAttempt(graphAllAssetsPromise); } if (!controller.signal.aborted) controller.abort(); @@ -3361,7 +3386,15 @@ candidateHost.classList.add('graph-canvas-candidate'); candidateHost.setAttribute('aria-hidden', 'true'); oldHost.insertAdjacentElement('afterend', candidateHost); - const graphFactory = fullGraph ? window.EngraphisAllGraph : window.EngraphisGraph; + /* Authored-Galaxy detection must key off scene markers, not the toolbar preset: + entering Every via its chip sets the preset to 'every', but a complete scene + with system anchors still needs the hierarchical orbit engine and overlay. */ + const galaxyQuality = fullGraph + && data.nodes.some(node => node.anchor_role === 'community' + && (node.system_anchor_id !== undefined + || Number.isFinite(Number(node.galactic_radius)))); + const graphFactory = galaxyQuality ? window.EngraphisGraph + : fullGraph ? window.EngraphisEveryGraph : window.EngraphisGraph; if (!graphFactory || typeof graphFactory.create !== 'function') { throw new Error(fullGraph ? 'all-node graph engine asset is unavailable' @@ -3433,12 +3466,12 @@ }, false, false); candidateEngine.setData(data); candidateEngine.freeze(fullGraph ? false : state.graphFrozen); - if (!fullGraph && window.EngraphisSpacetime + if ((!fullGraph || galaxyQuality) && window.EngraphisSpacetime && window.EngraphisSpacetime.create) { candidateOverlay = window.EngraphisSpacetime.create( candidateHost, candidateEngine ); - candidateOverlay.setEnabled(graphIsGalaxy()); + candidateOverlay.setEnabled(galaxyQuality || graphIsGalaxy()); } if (typeof candidateEngine.whenReady === 'function') { await Promise.race([candidateEngine.whenReady(), timeoutPromise]); @@ -3459,6 +3492,7 @@ state.graphData = data; state.graphWorkspace = targetWorkspace; state.graphDataMode = targetMode; + state.graphDataPreset = byId('graph-preset').value; state.graphDataIncludeCode = responseIncludeCode; state.graphDataShowUnlinked = targetShowUnlinked; state.graphDataAsOf = targetAsOf; @@ -3500,9 +3534,14 @@ : fullGraph && (error.status === 413 || error.code === 'GRAPH_CAPACITY') ? `All nodes exceed the server capacity. Enter an exact repository filter or reduce the workspace graph. (${error.message})` : `Graph unavailable: ${error.message}. Choose Reload data to try again.`; - if (state.graphData && state.graphDataMode !== targetMode) { - state.graphMode = state.graphDataMode; + if (state.graphData) { + if (state.graphDataMode !== targetMode) state.graphMode = state.graphDataMode; + /* The toolbar preset changes before a replacement request begins. Restore the + committed preset together with the committed renderer so a failed Every-node + transition cannot leave aria-pressed and the active engine disagreeing. */ + byId('graph-preset').value = state.graphDataPreset || 'galaxy'; updateGraphModeControls(); + syncGraphChoices(); } // Restore the committed renderer's freeze/overlay state. The old engine survived // because we never mutated state.graphEngine on the failure path. @@ -4663,6 +4702,67 @@ }); all('[data-graph-preset-choice]').forEach(control => control.addEventListener('click', () => { const preset = control.dataset.graphPresetChoice; + /* Every node is its own presentation: selecting the Every node chip loads the + complete LOD scene; the Show-all toggle remains the canonical exit that + restores overview filters. Other layout presets while in Every-node re-run + the seeded Every-node layout without leaving the presentation. */ + if (preset === 'every') { + if (state.graphMode !== 'full') { + byId('graph-preset').value = preset; + clearGraphSavedView(); + syncGraphChoices(); + saveGraphPreferences(); + /* Every node means every node: entering the map clears the unlinked/degree + filters that the overview uses, but remembers them so leaving restores the + person's overview exactly as they had configured it. */ + if (!state.everyPriorFilters) { + state.everyPriorFilters = { + minDegree: Number(byId('graph-min-degree').value) || 0, + unlinked: byId('graph-show-unlinked').getAttribute('aria-pressed') === 'true', + }; + } + setGraphMinDegree(0, false); + setGraphShowUnlinked(true, false); + cancelGraphRepositoryReload(); + state.graphMode = 'full'; + updateGraphModeControls(); + loadGraph({ force: true }); + } else { + // Clicking Every node while already in Every-node exits back to overview, + // mirroring the old Show-all toggle but now via the layout chip. + if (state.everyPriorFilters) { + const prior = state.everyPriorFilters; + state.everyPriorFilters = null; + setGraphMinDegree(prior.minDegree, false); + setGraphShowUnlinked(prior.unlinked, false); + } + byId('graph-preset').value = 'galaxy'; + cancelGraphRepositoryReload(); + state.graphMode = 'overview'; + clearGraphSavedView(); + syncGraphChoices(); + saveGraphPreferences(); + updateGraphModeControls(); + loadGraph({ force: true }); + } + return; + } + if (state.graphMode === 'full') { + byId('graph-preset').value = preset; + clearGraphSavedView(); + syncGraphChoices(); + saveGraphPreferences(); + if (state.graphEngine && state.graphEngine.setPreset) { + const result = state.graphEngine.setPreset(preset); + if (result && typeof result === 'object') syncGraphTuning(result); + } else { + syncGraphTuning(graphPresetTuning(preset)); + } + updateGraphModeControls(); + if (state.graphEngine) state.graphEngine.setSizeBy(graphSizeBy()); + if (state.graphSpacetimeOverlay) state.graphSpacetimeOverlay.setEnabled(graphIsGalaxy()); + return; + } const resumeLayout = state.graphFrozen; byId('graph-preset').value = preset; if (state.graphEngine && resumeLayout) { @@ -4718,11 +4818,6 @@ saveGraphPreferences(); if (state.graphMode !== 'full') loadGraph({ force: true }); }); - byId('graph-show-all').addEventListener('click', () => { - cancelGraphRepositoryReload(); - state.graphMode = state.graphMode === 'full' ? 'overview' : 'full'; - loadGraph({ force: true }); - }); byId('graph-tune-min-degree').addEventListener('input', event => { setGraphMinDegree(event.target.value); clearGraphSavedView(); @@ -4834,6 +4929,12 @@ exportGraphJson(); }); byId('graph-connections-close').addEventListener('click', closeGraphConnections); + byId('graph-connections-focus').addEventListener('click', () => { + const id = state.graphConnectionsFocusId; + if (!id) return; + closeGraphConnections(); + revealGraphNode(id, state.graphConnectionsFocusLabel || 'Selected entity'); + }); byId('graph-connections-dialog').addEventListener('click', event => { if (event.target === event.currentTarget) closeGraphConnections(); }); diff --git a/engraphis/static/dashboard.js b/engraphis/static/dashboard.js index 74bf3cc2..a7b599ca 100644 --- a/engraphis/static/dashboard.js +++ b/engraphis/static/dashboard.js @@ -1214,41 +1214,57 @@ function loadForceGraph(){ }); return FORCE_GRAPH_LOADING; } -let GRAPH_ENGINE_LOADING=null,GRAPH_ENGINE_RETRY=0; -function loadGraphEngine(){ +let GRAPH_ENGINE_LOADING=null,GRAPH_ENGINE_RETRY=0,ALL_GRAPH_ENGINE_LOADING=null; +function loadAllGraphEngine(){ + if(typeof EngraphisEveryGraph!=='undefined')return Promise.resolve(); + if(!ALL_GRAPH_ENGINE_LOADING){ + ALL_GRAPH_ENGINE_LOADING=new Promise((resolve,reject)=>{ + const script=document.createElement('script');script.src='/v2-assets/engraphis-graph-every.js?v=20260823-every-19'; + script.onload=()=>{typeof EngraphisEveryGraph==='undefined'?reject(new Error('Every-node graph asset loaded without registering EngraphisEveryGraph')):resolve()}; + script.onerror=()=>reject(new Error('Every-node graph asset could not load')); + document.head.appendChild(script); + }); + ALL_GRAPH_ENGINE_LOADING.catch(()=>{}); + } + return ALL_GRAPH_ENGINE_LOADING; +} +function loadGraphEngine(loadAll=false){ let engineReady; if(typeof EngraphisGraph!=='undefined'){GRAPH_ENGINE_RETRY=0;engineReady=Promise.resolve();} else{ - if(!GRAPH_ENGINE_LOADING){ - GRAPH_ENGINE_LOADING=new Promise((resolve,reject)=>{ - const script=document.createElement('script'); + if(!GRAPH_ENGINE_LOADING){ + GRAPH_ENGINE_LOADING=new Promise((resolve,reject)=>{ + const script=document.createElement('script'); const bust=GRAPH_ENGINE_RETRY>0?'&r='+GRAPH_ENGINE_RETRY:''; script.src='/v2-assets/engraphis-graph.js?v=20260815-merge-ready-1'+bust; - /* A 200 that never registers the global is a corrupt/truncated asset, not a success — - resolving there would hand graphRenderEngine() an undefined EngraphisGraph. Failed - attempts drop the script node and clear the memo so the next call retries with a - cache-buster rather than returning the same rejected promise forever. */ - const cleanup=(failed)=>{if(script.parentNode)script.parentNode.removeChild(script);if(failed){GRAPH_ENGINE_LOADING=null;GRAPH_ENGINE_RETRY=Math.min(GRAPH_ENGINE_RETRY+1,2)}else{GRAPH_ENGINE_RETRY=0}}; - script.onload=()=>{if(typeof EngraphisGraph==='undefined'){cleanup(true);reject(new Error('Graph engine asset loaded without registering EngraphisGraph'))}else{cleanup(false);resolve()}}; - script.onerror=()=>{cleanup(true);reject(new Error('Graph engine could not load'))}; - document.head.appendChild(script); - }); - GRAPH_ENGINE_LOADING.catch(()=>{}); - } - engineReady=GRAPH_ENGINE_LOADING; - } - /* Mark the memoized promise handled. graphRender() can start this fetch on a pass that - returns before attaching its own handler, and an unhandled rejection would print the exact - console error this lazy-loading exists to remove. Callers still receive the rejection. */ - return engineReady; + /* A 200 that never registers the global is a corrupt/truncated asset, not a success — + resolving there would hand graphRenderEngine() an undefined EngraphisGraph. Failed + attempts drop the script node and clear the memo so the next call retries with a + cache-buster rather than returning the same rejected promise forever. */ + const cleanup=(failed)=>{if(script.parentNode)script.parentNode.removeChild(script);if(failed){GRAPH_ENGINE_LOADING=null;GRAPH_ENGINE_RETRY=Math.min(GRAPH_ENGINE_RETRY+1,2)}else{GRAPH_ENGINE_RETRY=0}}; + script.onload=()=>{if(typeof EngraphisGraph==='undefined'){cleanup(true);reject(new Error('Graph engine asset loaded without registering EngraphisGraph'))}else{cleanup(false);resolve()}}; + script.onerror=()=>{cleanup(true);reject(new Error('Graph engine could not load'))}; + document.head.appendChild(script); + }); + GRAPH_ENGINE_LOADING.catch(()=>{}); + } + engineReady=GRAPH_ENGINE_LOADING; + } + /* Mark the memoized promise handled. graphRender() can start this fetch on a pass that + returns before attaching its own handler, and an unhandled rejection would print the exact + console error this lazy-loading exists to remove. Callers still receive the rejection. */ + return loadAll?engineReady.then(()=>loadAllGraphEngine()):engineReady; } function graphRender(fit=true,reheat=true){ - const empty=document.getElementById('graph-empty'); + const empty=document.getElementById('graph-empty'); + const graphFull=typeof GRAPH_FULL!=='undefined'&&GRAPH_FULL; /* Kick the opt-in engine off alongside the vendor bundle instead of after it, so a `?graph-engine=next` deep link costs one round trip rather than two. */ - const engineMissing=typeof EngraphisGraph==='undefined'; - const enginePending=(!GRAPH_ENGINE_FAILED&&graphEngineEnabled())&&engineMissing?loadGraphEngine():null; - if(typeof ForceGraph==='undefined'){ + const engineMissing=typeof EngraphisGraph==='undefined'||(graphFull&&typeof EngraphisEveryGraph==='undefined'); + /* All mode owns a dedicated bounded renderer and must remain available after a quality-renderer + runtime failure. The quality failure latch only authorizes the small legacy overview. */ + const enginePending=(graphFull||(!GRAPH_ENGINE_FAILED&&graphEngineEnabled()))&&engineMissing?loadGraphEngine(graphFull):null; + if(!graphFull&&typeof ForceGraph==='undefined'){ showAs(empty,true,'flex');empty.textContent='Loading graph engine…'; graphSetLayoutStatus('Loading engine',true); loadForceGraph().then(()=>graphRender(fit,reheat)).catch(error=>{ @@ -1265,7 +1281,12 @@ function graphRender(fit=true,reheat=true){ announced through graphEngineFallback() rather than silent. */ showAs(empty,true,'flex');empty.textContent='Loading graph engine…'; graphSetLayoutStatus('Loading engine',true); - enginePending.then(()=>graphRender(fit,reheat)).catch(error=>{ + enginePending.then(()=>graphRender(fit,reheat)).catch(error=>{ + if(graphFull){ + empty.textContent=error.message+'; return to High quality or reload the dashboard assets.'; + graphSetLayoutStatus('All-node engine unavailable',false); + return; + } /* Latches GRAPH_ENGINE_FAILED, so the re-entry below takes the classic path and this cannot loop. */ graphEngineFallback(error); @@ -1274,6 +1295,13 @@ function graphRender(fit=true,reheat=true){ return; } const element=document.getElementById('graph-net'),settings=window.GSET,mode=GRAPH_PRESETS[settings.mode]||GRAPH_PRESETS.compact,data=graphData(); + if(graphFull){ + if(graphRenderEngine(data,fit,reheat))return; + showAs(empty,true,'flex'); + empty.textContent='All-node renderer unavailable; return to High quality or reload the dashboard assets.'; + graphSetLayoutStatus('All-node engine unavailable',false); + return; + } if(graphEngineEnabled()&&graphRenderEngine(data,fit,reheat))return; /* Read AFTER the opt-in attempt: a failing engine resets GACTIVE_DATA precisely so the classic renderer below rebuilds from scratch instead of assuming the canvas is current. */ diff --git a/eval/graph_every_bench.py b/eval/graph_every_bench.py new file mode 100644 index 00000000..1b8e9c14 --- /dev/null +++ b/eval/graph_every_bench.py @@ -0,0 +1,67 @@ +"""Deterministic Every-node worker benchmark: measures prepare->settled-layout wall time +at the dashboard's two real scales. Run inside the dev distrobox (needs node): + + distrobox enter dev -- bash -lc 'cd && python -m eval.graph_every_bench' + +This is the number behind the performance claim in docs/GRAPH_PERFORMANCE.md: the worker +settle is the only O(n)-ish phase; per-frame render cost is node-count independent.""" +import json +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +WORKER = ROOT / "engraphis" / "dashboard_assets" / "engraphis-graph-every-worker.js" + +SCALES = (2000, 20000) + +HARNESS = """ +const vm = require('vm'); const fs = require('fs'); const messages = []; +const src = fs.readFileSync(process.argv[1], 'utf8'); +const ctx = { self: { postMessage: m => messages.push(m) }, + setTimeout: f => setTimeout(f, 0), clearTimeout: t => clearTimeout(t) }; +vm.runInNewContext(src, ctx); +const n = Number(process.argv[2]), clusters = 40; +const nodes = [], links = []; +for (let i = 0; i < n; i += 1) { + nodes.push({ id: `n${i}`, name: `Entity ${i}`, community_id: `c${i % clusters}` }); + links.push({ source: `n${i}`, target: `n${(i + 1) % n}` }); + if (i % 3 === 0) links.push({ source: `n${i}`, target: `n${(i * 13 + 7) % n}`, weight: 2 }); +} +const t0 = Date.now(); +ctx.self.onmessage({ data: { type: 'prepare', payload: { nodes, links } } }); +const waitQuiet = cb => { + let count = messages.length; + setTimeout(function tick() { + if (messages.length === count) return cb(Date.now() - t0); + count = messages.length; + setTimeout(tick, 100); + }, 200); +}; +waitQuiet(ms => { + const ready = messages.find(m => m.type === 'ready'); + console.log(JSON.stringify({ + nodes: ready.ids.length, links: ready.totalLinks, + settle_ms: ms, layouts_streamed: messages.filter(m => m.type === 'layout').length, + })); +}); +""" + + +def main() -> None: + results = [] + for scale in SCALES: + out = subprocess.run( + ["node", "-e", HARNESS, str(WORKER), str(scale)], + cwd=ROOT, check=True, capture_output=True, text=True, timeout=600, + ) + report = json.loads(out.stdout.strip().splitlines()[-1]) + results.append(report) + print(f"{scale:>6} nodes / {report['links']:>6} links: " + f"settle {report['settle_ms']:>5} ms " + f"({report['layouts_streamed']} streamed layout passes)") + ratio = results[-1]["settle_ms"] / max(1, results[0]["settle_ms"]) + print(f"20k/2k settle-time ratio: {ratio:.1f}x (one-off cost; frame rate is scale-independent)") + + +if __name__ == "__main__": + main() diff --git a/scripts/externalize_dashboard_assets.py b/scripts/externalize_dashboard_assets.py index 44680d24..80d5c514 100644 --- a/scripts/externalize_dashboard_assets.py +++ b/scripts/externalize_dashboard_assets.py @@ -27,8 +27,8 @@ V2_ASSETS = ROOT / "engraphis" / "dashboard_assets" EXTRA_SCRIPTS = ( V2_ASSETS / "engraphis-graph.js", - V2_ASSETS / "engraphis-graph-all.js", - V2_ASSETS / "engraphis-graph-worker.js", + V2_ASSETS / "engraphis-graph-every.js", + V2_ASSETS / "engraphis-graph-every-worker.js", V2_ASSETS / "engraphis-spacetime.js", ) LAZY_LOADER_SCRIPTS = (V2_ASSETS / "ledger.js",) @@ -42,7 +42,7 @@ DEFERRED_SCRIPTS = ( "/static/vendor/force-graph.min.js", "/v2-assets/engraphis-graph.js", - "/v2-assets/engraphis-graph-all.js", + "/v2-assets/engraphis-graph-every.js", "/v2-assets/engraphis-spacetime.js", ) diff --git a/tests/e2e/graph-all-performance.spec.js b/tests/e2e/graph-all-performance.spec.js deleted file mode 100644 index 7cc6532f..00000000 --- a/tests/e2e/graph-all-performance.spec.js +++ /dev/null @@ -1,126 +0,0 @@ -const { test, expect } = require('@playwright/test'); - -test('All-node controls filter, collapse, reflow, freeze, and expose directional flow', async ({ page }) => { - await page.goto('/'); - await page.addScriptTag({ url: '/v2-assets/engraphis-graph-all.js?v=20260815-merge-ready-1' }); - const result = await page.evaluate(async () => { - const host = document.createElement('div'); - host.style.cssText = 'position:fixed;inset:20px;width:900px;height:600px'; - document.body.append(host); - const nodes = [ - { id: 'a', community_id: 'one' }, { id: 'b', community_id: 'one' }, - { id: 'c', community_id: 'one' }, { id: 'd', community_id: 'two' }, - { id: 'e', community_id: 'two' }, { id: 'lonely' }, - ]; - const links = [ - { source: 'a', target: 'b', weight: 3 }, - { source: 'b', target: 'c', weight: 2 }, - { source: 'd', target: 'e', weight: 1 }, - ]; - const engine = window.EngraphisAllGraph.create(host, { reducedMotion: () => true }); - const waitFor = async predicate => { - const deadline = Date.now() + 6000; - while (!predicate() && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, 25)); - if (!predicate()) throw new Error(`All-node state did not settle: ${JSON.stringify(engine.state())}`); - }; - engine.setPreset('radial'); - engine.setColorBy('type'); - engine.setSettings({ flow: true, flowSpeed: 73, repel: 82, link: 34, gravity: 27 }); - engine.setScope({ minDegree: 2, showUnlinked: false, depth: 1 }); - engine.setCollapse(false); - engine.setData({ nodes, links }); - await waitFor(() => engine.state().nodeCount === 6 && engine.state().visibleNodeCount === 1); - const filtered = engine.state(); - engine.setScope({ minDegree: 0, showUnlinked: true, depth: 2 }); - await waitFor(() => engine.state().visibleNodeCount === 6); - const before = engine.getPhysicsSnapshot().nodes.map(node => [node.id, node.x, node.y]); - engine.reheat(); - await waitFor(() => engine.getPhysicsSnapshot().nodes.some((node, index) => - node.x !== before[index][1] || node.y !== before[index][2])); - const canvas = host.querySelector('.engraphis-all-canvas'); - engine.setCollapse('auto'); - for (let index = 0; index < 12; index += 1) { - canvas.dispatchEvent(new WheelEvent('wheel', { - deltaY: 450, clientX: 450, clientY: 300, bubbles: true, cancelable: true, - })); - } - await waitFor(() => engine.state().collapsed === true); - const collapsed = engine.state(); - engine.setCollapse(false); - await waitFor(() => engine.state().collapsed === false - && engine.state().visibleNodeCount === nodes.length); - engine.freeze(true); - const frozenBefore = engine.getPhysicsSnapshot().nodes.map(node => [node.x, node.y]); - engine.reheat(); - await new Promise(resolve => setTimeout(resolve, 80)); - const frozenAfter = engine.getPhysicsSnapshot().nodes.map(node => [node.x, node.y]); - const final = engine.state(); - engine.destroy(); host.remove(); - return { filtered, collapsed, final, frozenBefore, frozenAfter }; - }); - expect(result.filtered.visibleNodeCount).toBe(1); - expect(result.filtered.relationFlow).toBe(true); - expect(result.filtered.flowSpeed).toBe(73); - expect(result.collapsed.visibleNodeCount).toBe(3); - expect(result.final.collapsed).toBe(false); - expect(result.final.visibleNodeCount).toBe(6); - expect(result.final.frozen).toBe(true); - expect(result.frozenAfter).toEqual(result.frozenBefore); -}); - -/* Release-style browser fixture for the supported mid-range WebGL2 target. It is intentionally - synthetic: the API contract tests own server capacity, while this test isolates handoff, - worker preparation, LOD painting, and interaction without needing a 20k-row database fixture. */ -test('20k-node all profile paints progressively and stays responsive after handoff', async ({ page }) => { - await page.goto('/'); - const gpu = await page.evaluate(() => { - const gl = document.createElement('canvas').getContext('webgl2'); - if (!gl) return { supported: false, renderer: '' }; - const debug = gl.getExtension('WEBGL_debug_renderer_info'); - return { supported: true, renderer: debug ? String(gl.getParameter(debug.UNMASKED_RENDERER_WEBGL) || '') : '' }; - }); - test.skip(!gpu.supported || /swiftshader|llvmpipe|software renderer/i.test(gpu.renderer), 'All-node performance target requires hardware-accelerated WebGL2'); - await page.addScriptTag({ url: '/v2-assets/engraphis-graph-all.js?v=20260815-merge-ready-1' }); - const result = await page.evaluate(async () => { - const host = document.createElement('div'); - host.className = 'graph-network'; - host.setAttribute('aria-label', '20k-node performance fixture'); - document.body.append(host); - const nodes = Array.from({ length: 20000 }, (_value, index) => ({ - id: `n-${index}`, label: `Node ${index}`, community_id: `c-${index % 32}`, - })); - const links = Array.from({ length: 200000 }, (_value, index) => ({ - source: `n-${index % 20000}`, target: `n-${(index * 17 + 1) % 20000}`, weight: (index % 100) + 1, - })); - const progressive = []; - const engine = window.EngraphisAllGraph.create(host, { onStats: stats => progressive.push({ nodes: stats.nodes, links: stats.links, pending: stats.linksPending, drawn: stats.drawnLinks }) }); - engine.setData({ nodes, links }); - /* The object-to-worker postMessage is the explicit initial handoff. Measure the - interaction/rendering phase after that handoff, not the one-time structured clone - of the server-shaped fixture payload. */ - const longTasks = []; - const observer = typeof PerformanceObserver === 'function' ? new PerformanceObserver(list => { - list.getEntries().forEach(entry => longTasks.push(entry.duration)); - }) : null; - if (observer) { try { observer.observe({ type: 'longtask', buffered: false }); } catch (_error) {} } - const deadline = Date.now() + 30000; - while ((!progressive.some(item => item.pending) || !progressive.some(item => item.links === 200000)) && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, 25)); - const canvas = host.querySelector('.engraphis-all-canvas'); - for (let index = 0; index < 18; index += 1) { - canvas.dispatchEvent(new WheelEvent('wheel', { deltaY: index % 2 ? 180 : -180, clientX: 400 + index, clientY: 240, bubbles: true, cancelable: true })); - canvas.dispatchEvent(new PointerEvent('pointermove', { clientX: 400 + index * 2, clientY: 240 + index, bubbles: true })); - } - engine.reveal('n-10000'); - await new Promise(resolve => setTimeout(resolve, 700)); - if (observer) observer.disconnect(); - const settled = progressive.at(-1) || {}; - engine.destroy(); host.remove(); - return { progressive, settled, longTasks, webgl: true }; - }); - expect(result.progressive.some(item => item.pending)).toBeTruthy(); - expect(result.progressive.some(item => item.nodes === 20000 && item.links === 200000)).toBeTruthy(); - expect(result.settled.nodes).toBe(20000); - expect(result.settled.links).toBe(200000); - expect(result.settled.drawn).toBeLessThanOrEqual(75000); - expect(result.longTasks.filter(duration => duration > 50)).toEqual([]); -}); diff --git a/tests/e2e/ledger.spec.js b/tests/e2e/ledger.spec.js index e0f00047..1a600542 100644 --- a/tests/e2e/ledger.spec.js +++ b/tests/e2e/ledger.spec.js @@ -404,11 +404,11 @@ test('Ledger retries a failed lazy graph load and opens search evidence by keybo await expect(dialog.locator('#graph-connection-memory-list')).toContainText('Database choice'); }); -test('Ledger enters All nodes from a loaded overview without losing its scope', async ({ page }) => { +test('Ledger enters Every node from a loaded overview without losing its scope', async ({ page }) => { const allAssetRequests = []; page.on('request', request => { const pathname = new URL(request.url()).pathname; - if (pathname.endsWith('/v2-assets/engraphis-graph-all.js')) allAssetRequests.push(request.url()); + if (pathname.endsWith('/v2-assets/engraphis-graph-every.js')) allAssetRequests.push(request.url()); }); const requests = await mockApi(page); await page.goto('/'); @@ -424,13 +424,14 @@ test('Ledger enters All nodes from a loaded overview without losing its scope', await page.locator('#graph-as-of').fill('2026-08-14'); await page.getByRole('tab', { name: 'Explore' }).click(); await page.locator('[data-graph-layer="code"]').click(); - await page.locator('#graph-show-all').click(); + await page.locator('[data-graph-preset-choice="every"]').click(); - await expect(page.locator('#graph-show-all')).toHaveText('All nodes'); - await expect(page.locator('#graph-show-all')).toHaveAttribute('aria-pressed', 'true'); + await expect(page.locator('[data-graph-preset-choice="every"]')).toHaveAttribute('aria-pressed', 'true'); + await expect(page.locator('#graph-mode')).toContainText('All nodes'); await expect(page.locator('#graph-repo-filter')).toHaveAttribute('placeholder', 'Filter by exact repository name…'); await expect(page.locator('#graph-show-unlinked')).toBeEnabled(); - await expect(page.locator('#graph-depth')).toBeEnabled(); + await expect(page.locator('#graph-depth')).toBeDisabled(); + await expect(page.locator('#graph-depth')).toHaveAttribute('title', 'Not yet available in the Every-node view.'); await expect(page.locator('#graph-flow')).toBeEnabled(); await expect(page.locator('[data-graph-layer="code"]')).toBeEnabled(); await expect(page.locator('#graph-canvas')).toHaveAttribute('aria-busy', 'false'); @@ -441,7 +442,7 @@ test('Ledger enters All nodes from a loaded overview without losing its scope', expect(allQuery.repo).toBe('agent-memory'); expect(allQuery.include_code).toBe('true'); expect(allQuery.as_of).toBe(String(Date.parse('2026-08-14T23:59:59.999Z') / 1000)); - await expect(page.locator('#graph-count')).toContainText('2 visible of 3 entities'); + await expect(page.locator('#graph-count')).toContainText('3 entities'); await page.locator('[data-graph-preset-choice="compact"]').click(); await expect(page.locator('[data-graph-preset-choice="compact"]')).toHaveAttribute('aria-pressed', 'true'); await page.locator('[data-graph-color-choice="type"]').click(); @@ -463,15 +464,15 @@ test('Ledger enters All nodes from a loaded overview without losing its scope', const persistedInAllMode = await page.evaluate(() => JSON.parse( localStorage.getItem('engraphis-ledger-graph-preferences-v1') || '{}', )); - expect(persistedInAllMode.showUnlinked).toBe(false); + expect(persistedInAllMode.showUnlinked).toBe(true); expect(persistedInAllMode.layers.code).toBe(true); expect(persistedInAllMode.includeCode).toBe(true); expect(persistedInAllMode.flow).toBe(true); const allAccessibility = await new AxeBuilder({ page }).analyze(); expect(allAccessibility.violations).toEqual([]); - await page.locator('#graph-show-all').click(); - await expect(page.locator('#graph-show-all')).toHaveText('All nodes'); + await page.locator('[data-graph-preset-choice="every"]').click(); + await expect(page.locator('[data-graph-preset-choice="every"]')).toHaveAttribute('aria-pressed', 'false'); await expect(page.locator('#graph-repo-filter')).toHaveAttribute('placeholder', 'Filter to a repository or topic…'); await expect(page.locator('#graph-show-unlinked')).toBeEnabled(); await expect(page.locator('#graph-show-unlinked')).toHaveAttribute('aria-pressed', 'false'); @@ -482,7 +483,7 @@ test('Ledger enters All nodes from a loaded overview without losing its scope', expect(allAssetRequests).toHaveLength(1); }); -test('Ledger keeps authored Galaxy coordinates in the All-node renderer', async ({ page }) => { +test('Ledger keeps authored Galaxy coordinates on the orbit renderer in Every-node view', async ({ page }) => { await mockApi(page, { graphScene: { nodes: [ @@ -515,13 +516,15 @@ test('Ledger keeps authored Galaxy coordinates in the All-node renderer', async await page.locator('.nav-item[data-view="relations"]').click(); await expect(page.locator('#graph-count')).toContainText('3 entities'); - await page.locator('#graph-show-all').click(); + await page.locator('[data-graph-preset-choice="every"]').click(); await expect(page.locator('#graph-canvas')).toHaveAttribute('aria-busy', 'false'); - await expect(page.locator('.engraphis-all-canvas')).toHaveCount(1); - await expect(page.locator('.graph-spacetime-overlay')).toHaveCount(0); + // Authored Galaxy scenes intentionally stay on the quality/orbit renderer; the dedicated + // Every-node WebGL canvas is reserved for non-Galaxy complete scenes. + await expect(page.locator('.engraphis-all-canvas')).toHaveCount(0); + await expect(page.locator('.graph-spacetime-overlay')).toHaveCount(1); }); -test('Ledger keeps the committed All-node renderer visible when a superseded load resolves', async ({ page }) => { +test('Ledger keeps the committed Every-node renderer visible when a superseded load resolves', async ({ page }) => { let releaseFirstScene; let deferredAllScenes = 0; await mockApi(page, { @@ -536,7 +539,7 @@ test('Ledger keeps the committed All-node renderer visible when a superseded loa }); await page.goto('/'); await page.locator('.nav-item[data-view="relations"]').click(); - await page.locator('#graph-show-all').click(); + await page.locator('[data-graph-preset-choice="every"]').click(); await expect(page.locator('#graph-canvas')).toHaveAttribute('aria-busy', 'true'); await expect.poll(() => deferredAllScenes).toBe(1); @@ -1302,7 +1305,7 @@ test('Graph & Relationships uses the visual explorer controls and applies their await expect(page.locator('#graph-flow-speed')).toHaveValue('45'); await expect(page.locator('#graph-layer-temporal-count')).toHaveText('15'); - await expect(page.getByRole('button', { name: 'All nodes' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Every node' })).toBeVisible(); await expect(page.getByRole('button', { name: 'Unlinked nodes' })).toHaveAttribute('aria-pressed', 'true'); await expect(page.locator('#graph-count')).toContainText('3 entities · 1 relations'); const paletteNotice = page.locator('#notice-banner'); @@ -1970,7 +1973,7 @@ test('billing cadence selects the exact Pro and Team checkout target', async ({ ); }); -test('overview→all readiness failure preserves the committed overview renderer and re-enables controls', async ({ page }) => { +test('overview→Every-node readiness failure preserves the committed overview renderer and re-enables controls', async ({ page }) => { const pageErrors = []; page.on('pageerror', error => pageErrors.push(String(error))); await page.addInitScript(() => { @@ -1981,7 +1984,7 @@ test('overview→all readiness failure preserves the committed overview renderer }; }); await mockApi(page); - await page.route('**/v2-assets/engraphis-graph-all.js*', async route => { + await page.route('**/v2-assets/engraphis-graph-every.js*', async route => { return route.fulfill({ status: 200, contentType: 'application/javascript', @@ -1991,15 +1994,15 @@ test('overview→all readiness failure preserves the committed overview renderer await page.goto('/'); await page.locator('.nav-item[data-view="relations"]').click(); await expect(page.locator('#graph-count')).toContainText('entities'); - await expect(page.locator('#graph-show-all')).toHaveAttribute('aria-pressed', 'false'); + await expect(page.locator('[data-graph-preset-choice="every"]')).toHaveAttribute('aria-pressed', 'false'); - await page.locator('#graph-show-all').click(); + await page.locator('[data-graph-preset-choice="every"]').click(); await expect(page.locator('#graph-empty')).toContainText('did not register', { timeout: 10_000 }); // Committed mode reverted to overview: aria-pressed survives - await expect(page.locator('#graph-show-all')).toHaveAttribute('aria-pressed', 'false'); + await expect(page.locator('[data-graph-preset-choice="every"]')).toHaveAttribute('aria-pressed', 'false'); // Controls re-enabled - await expect(page.locator('#graph-show-all')).toBeEnabled(); + await expect(page.locator('[data-graph-preset-choice="every"]')).toBeEnabled(); await expect(page.locator('#graph-canvas')).toHaveAttribute('aria-busy', 'false'); // Candidate alone is destroyed — no orphan host in the DOM await expect(page.locator('.graph-canvas-candidate')).toHaveCount(0); @@ -2008,15 +2011,15 @@ test('overview→all readiness failure preserves the committed overview renderer expect(pageErrors).toEqual([]); }); -test('all→quality readiness failure preserves the committed all-node renderer and re-enables controls', async ({ page }) => { +test('Every-node→quality readiness failure preserves the committed renderer and re-enables controls', async ({ page }) => { await mockApi(page); await page.goto('/'); await page.locator('.nav-item[data-view="relations"]').click(); await expect(page.locator('#graph-count')).toContainText('entities'); // Enter all mode successfully first - await page.locator('#graph-show-all').click(); - await expect(page.locator('#graph-show-all')).toHaveAttribute('aria-pressed', 'true'); + await page.locator('[data-graph-preset-choice="every"]').click(); + await expect(page.locator('[data-graph-preset-choice="every"]')).toHaveAttribute('aria-pressed', 'true'); await expect(page.locator('#graph-count')).toContainText('entities'); // Shorten the quality timeout after page load, before the failing transition @@ -2037,13 +2040,13 @@ test('all→quality readiness failure preserves the committed all-node renderer return route.fallback(); }); - await page.locator('#graph-show-all').click(); + await page.locator('[data-graph-preset-choice="every"]').click(); await expect(page.locator('#graph-empty')).toContainText('timed out', { timeout: 10_000 }); // Committed mode stays at full (all-node): aria-pressed survives - await expect(page.locator('#graph-show-all')).toHaveAttribute('aria-pressed', 'true'); + await expect(page.locator('[data-graph-preset-choice="every"]')).toHaveAttribute('aria-pressed', 'true'); // Controls re-enabled - await expect(page.locator('#graph-show-all')).toBeEnabled(); + await expect(page.locator('[data-graph-preset-choice="every"]')).toBeEnabled(); await expect(page.locator('#graph-canvas')).toHaveAttribute('aria-busy', 'false'); // Candidate alone is destroyed await expect(page.locator('.graph-canvas-candidate')).toHaveCount(0); @@ -2061,7 +2064,7 @@ test('successful retry after readiness failure commits exactly one new renderer' }); await mockApi(page); let allAssetAttempts = 0; - await page.route('**/v2-assets/engraphis-graph-all.js*', async route => { + await page.route('**/v2-assets/engraphis-graph-every.js*', async route => { allAssetAttempts += 1; if (allAssetAttempts === 1) { return route.fulfill({ @@ -2077,13 +2080,13 @@ test('successful retry after readiness failure commits exactly one new renderer' await expect(page.locator('#graph-count')).toContainText('entities'); // First attempt fails; mode reverts to overview - await page.locator('#graph-show-all').click(); + await page.locator('[data-graph-preset-choice="every"]').click(); await expect(page.locator('#graph-empty')).toContainText('did not register', { timeout: 10_000 }); - await expect(page.locator('#graph-show-all')).toHaveAttribute('aria-pressed', 'false'); + await expect(page.locator('[data-graph-preset-choice="every"]')).toHaveAttribute('aria-pressed', 'false'); // Retry by toggling again — second asset load succeeds and commits - await page.locator('#graph-show-all').click(); - await expect(page.locator('#graph-show-all')).toHaveAttribute('aria-pressed', 'true', { timeout: 15_000 }); + await page.locator('[data-graph-preset-choice="every"]').click(); + await expect(page.locator('[data-graph-preset-choice="every"]')).toHaveAttribute('aria-pressed', 'true', { timeout: 15_000 }); await expect(page.locator('#graph-canvas')).toHaveAttribute('aria-busy', 'false'); await expect(page.locator('.graph-canvas-candidate')).toHaveCount(0); await expect(page.locator('#graph-count')).toContainText('entities'); diff --git a/tests/test_dashboard_v2.py b/tests/test_dashboard_v2.py index 8ceb8133..150bd038 100644 --- a/tests/test_dashboard_v2.py +++ b/tests/test_dashboard_v2.py @@ -97,7 +97,9 @@ def test_dashboard_serves_and_bootstraps_local_core(monkeypatch, tmp_path): r"/v2-assets/engraphis-graph\.js\?v=[A-Za-z0-9._-]+", classic_js.text ) assert "presentation=all" not in classic_js.text - assert "GRAPH_FULL" not in classic_js.text + assert "GRAPH_FULL" in classic_js.text + assert "EngraphisEveryGraph" in classic_js.text + assert "engraphis-graph-all.js" not in classic_js.text assert "EngraphisAllGraph" not in classic_js.text bootstrap = client.get("/api/bootstrap") assert bootstrap.status_code == 200 @@ -841,8 +843,8 @@ def test_graph_load_is_bounded_single_flight_and_retryable(monkeypatch, tmp_path script = client.get("/v2-assets/ledger.js") assert 'id="graph-retry"' in page.text assert 'id="graph-full"' not in page.text - assert 'id="graph-show-all"' in page.text - assert "All nodes" in page.text + assert 'id="graph-show-all"' not in page.text + assert 'data-graph-preset-choice="every"' in page.text assert 'id="graph-show-unlinked"' in page.text assert 'id="graph-show-unlinked" class="graph-action" type="button" aria-pressed="true"' in page.text assert 'id="graph-unlinked"' not in page.text @@ -883,7 +885,7 @@ def test_graph_load_is_bounded_single_flight_and_retryable(monkeypatch, tmp_path assert "style: 'cyber'" in script.text assert "renderMode: fullGraph ? 'all' : 'overview'" in script.text assert "loadGraph({ force: true })" in script.text - assert "if (!fullGraph && window.EngraphisSpacetime" in script.text + assert "if ((!fullGraph || galaxyQuality) && window.EngraphisSpacetime" in script.text assert "setAttribute('aria-busy', 'true')" in script.text assert "setAttribute('aria-busy', 'false')" in script.text @@ -935,8 +937,9 @@ def test_all_nodes_mode_preserves_scope_preferences_and_bounds_heavy_work(monkey assert "showUnlinked: state.graphShowUnlinked" in script.text assert "includeCode: state.graphIncludeCode" in script.text assert "minDegree: number(byId('graph-min-degree').value)" in script.text - assert "if (loadAll) return ensureGraphAllAsset();" in script.text - assert "const graphFactory = fullGraph ? window.EngraphisAllGraph" in script.text + assert "if (loadAll) {" in script.text + assert "return Promise.all([ensureGraphAllAsset(), ensureGraphAssets(false)]);" in script.text + assert "const graphFactory = galaxyQuality ? window.EngraphisGraph" in script.text assert "scopeControl.disabled = full" not in script.text assert "graph.setCollapse(byId('graph-collapse').checked ? 'auto' : false)" in script.text assert "const includeCode = targetIncludeCode ? '&include_code=true' : '';" in script.text @@ -1997,14 +2000,15 @@ def test_every_managed_cloud_error_message_is_fixed_local_copy(): ) def test_graph_toggle_labels_are_fixed_and_use_aria_pressed(monkeypatch, tmp_path): - """Toggle labels must stay fixed (not swap) and reflect state via aria-pressed.""" + """Every-node is a fixed graph preset and remaining toggles use aria state.""" with _client(monkeypatch, tmp_path) as client: page = client.get("/") script = client.get("/v2-assets/ledger.js") - # The All-nodes toggle keeps a fixed label; aria-pressed carries the state. - assert 'id="graph-show-all"' in page.text - assert 'toggle.textContent = \'All nodes\'' in script.text - assert "toggle.setAttribute('aria-pressed', String(full))" in script.text + # Every-node is selected as a preset, rather than a second All-nodes toggle. + assert 'data-graph-preset-choice="every"' in page.text + assert "Every node" in page.text + assert 'id="graph-show-all"' not in page.text + assert "toggle.textContent = 'All nodes'" not in script.text # Unlinked toggle also uses a fixed label + aria-pressed, not swapped text. assert "control.textContent = 'Unlinked nodes'" in script.text assert "control.setAttribute('aria-pressed', String(next))" in script.text @@ -2031,9 +2035,11 @@ def test_graph_load_busy_disables_controls_and_updates_recovery_copy(monkeypatch """During load, retry/show-all are disabled and recovery copy names Reload data.""" with _client(monkeypatch, tmp_path) as client: script = client.get("/v2-assets/ledger.js") - # Busy state disables the two primary load controls. + # Busy state disables reload and the Every-node preset. assert "function setGraphLoadControlsBusy(busy, disableRetry = true)" in script.text - assert "'graph-show-all', 'graph-retry'" in script.text + assert "const controls = disableRetry ? ['graph-retry'] : []" in script.text + assert "[data-graph-preset-choice=\"every\"]" in script.text + assert "every.disabled = busy" in script.text assert "control.disabled = busy" in script.text # Recovery copy uses actionable "Reload data", not vague retry language. assert "retry.textContent = busy ? 'Reloading graph…' : 'Reload data'" in script.text diff --git a/tests/test_graph_all_asset.py b/tests/test_graph_all_asset.py deleted file mode 100644 index b2ffdc96..00000000 --- a/tests/test_graph_all_asset.py +++ /dev/null @@ -1,509 +0,0 @@ -"""Focused contract tests for the worker-backed all-node graph profile.""" -from __future__ import annotations - -import json -import subprocess -import re -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[1] -WORKER = ROOT / "engraphis" / "dashboard_assets" / "engraphis-graph-worker.js" -RENDERER = ROOT / "engraphis" / "dashboard_assets" / "engraphis-graph-all.js" -LEDGER = ROOT / "engraphis" / "dashboard_assets" / "ledger.js" -MARKUP = ROOT / "engraphis" / "dashboard_assets" / "index.html" -STYLES = ROOT / "engraphis" / "dashboard_assets" / "ledger.css" - - - -def _srgb_to_linear(c): - return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4 - - -def _luminance(r, g, b): - return 0.2126 * _srgb_to_linear(r) + 0.7152 * _srgb_to_linear(g) + 0.0722 * _srgb_to_linear(b) - - -def _hex_luminance(hex_color): - h = hex_color.lstrip("#") - return _luminance(int(h[0:2], 16) / 255, int(h[2:4], 16) / 255, int(h[4:6], 16) / 255) - - -def _contrast_ratio(l1, l2): - lighter, darker = max(l1, l2), min(l1, l2) - return (lighter + 0.05) / (darker + 0.05) - - -def _composite_rgba_luminance(r, g, b, a, bg_hex): - h = bg_hex.lstrip("#") - bg_r = int(h[0:2], 16) / 255 - bg_g = int(h[2:4], 16) / 255 - bg_b = int(h[4:6], 16) / 255 - return _luminance( - r / 255 * a + bg_r * (1 - a), - g / 255 * a + bg_g * (1 - a), - b / 255 * a + bg_b * (1 - a), - ) - -def _run_worker(nodes, links): - source = json.dumps(WORKER.read_text(encoding="utf-8")) - payload = json.dumps({"nodes": nodes, "links": links}) - script = f""" -const vm = require('vm'); const messages = []; -const context = {{ self: {{ postMessage: (message) => messages.push(message) }} }}; -vm.runInNewContext({source}, context); -context.self.onmessage({{ data: {{ type: 'prepare', payload: {payload} }} }}); -const ready = messages.find(message => message.type === 'ready'); -context.self.onmessage({{ data: {{ type: 'camera', x: 0, y: 0, scale: 0.2, width: 1200, height: 800 }} }}); -const low = messages.filter(message => message.type === 'visible').at(-1); -context.self.onmessage({{ data: {{ type: 'camera', x: 0, y: 0, scale: 0.8, width: 1200, height: 800 }} }}); -const medium = messages.filter(message => message.type === 'visible').at(-1); -context.self.onmessage({{ data: {{ type: 'camera', x: 0, y: 0, scale: 1.5, width: 1200, height: 800 }} }}); -const high = messages.filter(message => message.type === 'visible').at(-1); -context.self.onmessage({{ data: {{ type: 'hit', request: 9, x: 0, y: 0, scale: 1 }} }}); -const hit = messages.filter(message => message.type === 'hit').at(-1); -console.log(JSON.stringify({{ready: {{nodes: ready.totalNodes, links: ready.totalLinks, ids: ready.ids, positions: ready.positions.constructor.name, edges: ready.edgeSources.constructor.name}}, lod: {{low: low.drawnLinks, medium: medium.drawnLinks, high: high.drawnLinks, lowNodes: low.nodes.length, mediumNodes: medium.nodes.length, highNodes: high.nodes.length}}, hit: hit.index}})); -""" - result = subprocess.run( - ["node", "-"], cwd=ROOT, check=True, capture_output=True, text=True, input=script, - ) - return json.loads(result.stdout) - - -def test_all_worker_compacts_identity_builds_typed_arrays_and_hits_spatial_index(): - nodes = [{"id": f"n-{index}", "name": f"Node {index}"} for index in range(8)] - links = [{"source": "n-0", "target": f"n-{index}", "weight": index + 1} for index in range(1, 8)] - result = _run_worker(nodes, links) - assert result["ready"] == {"nodes": 8, "links": 7, "ids": [f"n-{index}" for index in range(8)], "positions": "Float32Array", "edges": "Uint32Array"} - assert result["lod"]["low"] == 0 - assert result["lod"]["medium"] <= 7 and result["lod"]["high"] <= 7 - assert result["hit"] >= 0 - - -def test_all_worker_enforces_hysteretic_progressive_node_budgets(): - nodes = [ - {"id": f"n-{index}", "community_id": f"community-{index}"} - for index in range(5_000) - ] - result = _run_worker(nodes, []) - assert result["lod"]["lowNodes"] <= 500 - assert result["lod"]["mediumNodes"] <= 3_000 - assert result["lod"]["highNodes"] <= 5_000 - - -def test_all_worker_reserves_a_focused_node_when_the_lod_budget_is_full(): - source = json.dumps(WORKER.read_text(encoding="utf-8")) - nodes = [{"id": f"leaf-{index}"} for index in range(6_000)] - nodes.extend([{"id": "hub"}, {"id": "focused"}]) - links = [{"source": "focused", "target": "hub"}] - links.extend({"source": "hub", "target": f"leaf-{index}"} for index in range(6_000)) - payload = json.dumps({"nodes": nodes, "links": links}) - script = f""" -const vm = require('vm'); const messages = []; -const context = {{ self: {{ postMessage: message => messages.push(message) }} }}; -vm.runInNewContext({source}, context); -const send = data => context.self.onmessage({{ data }}); -const latest = type => messages.filter(message => message.type === type).at(-1); -send({{ type: 'prepare', payload: {payload} }}); -const ready = latest('ready'); -const focused = ready.ids.indexOf('focused'); -send({{ type: 'focus', index: focused }}); -send({{ type: 'camera', x: 0, y: 0, scale: 0.8, width: 100000, height: 100000 }}); -const visible = latest('visible'); -console.log(JSON.stringify({{ count: visible.nodes.length, focused, - retained: Array.from(visible.nodes).includes(focused) }})); -""" - result = subprocess.run( - ["node", "-"], cwd=ROOT, check=True, capture_output=True, text=True, input=script, - ) - report = json.loads(result.stdout) - assert report["count"] <= 3_000 - assert report["retained"] is True - - -def test_all_renderer_is_flat_worker_webgl_and_not_a_live_force_simulation(): - worker = WORKER.read_text(encoding="utf-8") - renderer = RENDERER.read_text(encoding="utf-8") - assert "new Worker" in renderer and "webgl2" in renderer - assert "Uint32Array" in renderer and "Float32Array" in renderer - assert "forceSimulation" not in renderer and "force-graph" not in renderer - assert "createRadialGradient" not in renderer and "shadowBlur" not in renderer - assert "new Map" in worker and "state.grid" in worker - assert "MEDIUM_ZOOM_EDGE_LIMIT" in worker and "HIGH_ZOOM_EDGE_LIMIT" in worker - assert "CANVAS_MEDIUM_ZOOM_EDGE_LIMIT" in worker and "CANVAS_HIGH_ZOOM_EDGE_LIMIT" in worker - assert "adjacencyOffsets" in worker and "edgePositions" in worker - assert "lastCameraKey" in worker and "edgePositions.buffer" in worker - assert "for (let index = 0; index < state.ids.length; index += 1) if (inViewport" not in worker - assert "nodeSeen" in worker and "nodeStamp" in worker - assert "ranked.sort" not in worker - - -def test_all_renderer_declares_capacity_and_progressive_lod_profile(): - renderer = RENDERER.read_text(encoding="utf-8") - worker = WORKER.read_text(encoding="utf-8") - assert "MAX_NODES = 20000" in renderer and "MAX_NODES = 20000" in worker - assert "All nodes · LOD" in renderer and "linksPending" in renderer - assert "type: 'preview'" in worker and "type: 'capacity'" in worker - - -def test_all_renderer_keeps_controls_live_and_clears_transient_hover_paint(): - renderer = RENDERER.read_text(encoding="utf-8") - worker = WORKER.read_text(encoding="utf-8") - for marker in ( - "setPreset(value)", "setSettings(value)", "setLayers(value)", - "setBridges(value)", "setGhosts(value)", "freeze(value = true)", - "getPhysicsSnapshot()", "graphToScreen(x, y)", - ): - assert marker in renderer - assert "type: 'settings'" in renderer and "message.type === 'settings'" in worker - assert "setBridges(value)" in renderer and "type: 'ghosts'" in renderer - assert "message.type === 'bridges'" in worker and "message.type === 'ghosts'" in worker - assert "function drawLabels(clear = false, now = 0)" in renderer - assert "function clearHover()" in renderer and "pointerout" in renderer - - -def test_worker_preserves_falsy_endpoint_ids_and_filters_ghost_nodes(): - source = json.dumps(WORKER.read_text(encoding="utf-8")) - payload = json.dumps({ - "nodes": [ - {"id": 0, "x": 0, "y": 0}, - {"id": False, "x": 40, "y": 0, "ghost": True}, - {"id": "leaf", "x": 80, "y": 0}, - ], - "links": [ - {"source": 0, "target": False}, - {"source": {"id": False}, "target": "leaf"}, - ], - }) - script = f""" -const vm = require('vm'); const messages = []; -const context = {{ self: {{ postMessage: message => messages.push(message) }} }}; -vm.runInNewContext({source}, context); -context.self.onmessage({{ data: {{ type: 'prepare', payload: {payload} }} }}); -const ready = messages.find(message => message.type === 'ready'); -context.self.onmessage({{ data: {{ type: 'ghosts', value: false }} }}); -context.self.onmessage({{ data: {{ type: 'camera', x: 40, y: 0, scale: 0.2, - width: 1200, height: 800 }} }}); -const visible = messages.filter(message => message.type === 'visible').at(-1); -context.self.onmessage({{ data: {{ type: 'camera', x: 40, y: 0, scale: 0.8, - width: 100000, height: 100000 }} }}); -const wide = messages.filter(message => message.type === 'visible').at(-1); -context.self.onmessage({{ data: {{ type: 'hit', request: 4, x: 40, y: 0, scale: 1 }} }}); -const hit = messages.filter(message => message.type === 'hit').at(-1); -console.log(JSON.stringify({{ - ids: ready.ids, links: ready.totalLinks, ghosts: Array.from(ready.nodeGhosts), - visible: Array.from(visible.nodes).map(index => ready.ids[index]), - wide: Array.from(wide.nodes).map(index => ready.ids[index]), - hit: hit.index < 0 ? null : ready.ids[hit.index], -}})); -""" - result = subprocess.run( - ["node", "-"], cwd=ROOT, check=True, capture_output=True, text=True, input=script, - ) - report = json.loads(result.stdout) - assert report["ids"] == ["0", "false", "leaf"] - assert report["links"] == 2 - assert report["ghosts"] == [0, 1, 0] - assert report["visible"] == ["0", "leaf"] - assert report["wide"] == ["0", "leaf"] - assert report["hit"] != "false" - - -def test_all_renderer_batches_colors_throttles_hits_composites_and_releases_gpu(): - renderer = RENDERER.read_text(encoding="utf-8") - assert "in vec3 a_color" in renderer - assert "state.nodeColors" in renderer and "nodeColor(index)" in renderer - assert "state.edgeColors.length < edges.length * 6" in renderer - assert "state.edgeColors.subarray(0, edges.length * 6)" in renderer - assert "state.edgeColors[offset + 5]" in renderer - assert "pendingHit" in renderer and "if (hitFrame) return" in renderer - assert "context.drawImage(canvas, 0, 0)" in renderer - assert "context.drawImage(labels, 0, 0)" in renderer - assert "destroy: destroyGraph" in renderer - assert "state.nodeGhosts = message.nodeGhosts || state.nodeGhosts" in renderer - assert renderer.count("setVisibleNodes(drawableNodeIndices())") >= 3 - assert "const visible = state.visibleNodes, compact" in renderer - assert "worker.onmessage = handleWorkerMessage" in renderer - assert "const handleWorkerMessage = worker.onmessage" not in renderer - assert "gl.deleteBuffer(buffer)" in renderer - assert "gl.deleteProgram(value)" in renderer - assert "WEBGL_lose_context" in renderer - assert "handleWorkerFailure" in renderer - assert "worker.addEventListener('error', handleWorkerFailure)" in renderer - assert "worker.addEventListener('messageerror', handleWorkerFailure)" in renderer - assert "worker.removeEventListener('error', handleWorkerFailure)" in renderer - assert "error.code || 'GRAPH_WORKER'" in renderer - assert "if (state.ready) camera(); else schedule();" in renderer - # Canvas fallback must retain the same Highlight bridges semantics as WebGL. - assert "state.bridges && state.edgeBridges[edge]" in renderer - assert "rgba(244,211,127" in renderer - - -def test_all_worker_applies_scope_depth_layers_and_auto_collapse_without_reloading(): - source = json.dumps(WORKER.read_text(encoding="utf-8")) - payload = json.dumps({ - "nodes": [ - {"id": "a", "community_id": "one"}, - {"id": "b", "community_id": "one"}, - {"id": "c", "community_id": "one"}, - {"id": "d", "community_id": "two"}, - {"id": "e", "community_id": "two"}, - {"id": "lonely"}, - ], - "links": [ - {"source": "a", "target": "b", "layer": "semantic", "weight": 3}, - {"source": "b", "target": "c", "layer": "semantic", "weight": 2}, - {"source": "d", "target": "e", "layer": "temporal", "weight": 1}, - ], - }) - script = f""" -const vm = require('vm'); const messages = []; -const context = {{ self: {{ postMessage: message => messages.push(message) }} }}; -vm.runInNewContext({source}, context); -const send = data => context.self.onmessage({{ data }}); -const latest = type => messages.filter(message => message.type === type).at(-1); -send({{ type: 'prepare', payload: {payload} }}); -const ready = latest('ready'); -const ids = values => Array.from(values).map(index => ready.ids[index]); -send({{ type: 'scope', scope: {{ minDegree: 2, showUnlinked: false, depth: 1 }} }}); -send({{ type: 'camera', x: 0, y: 0, scale: 1.5, width: 100000, height: 100000 }}); -const filtered = latest('visible'); -send({{ type: 'scope', scope: {{ minDegree: 0, showUnlinked: true, depth: 1 }} }}); -send({{ type: 'collapse', value: 'auto' }}); -send({{ type: 'camera', x: 0, y: 0, scale: 0.2, width: 100000, height: 100000 }}); -const collapsed = latest('visible'); -send({{ type: 'collapse', value: false }}); -send({{ type: 'camera', x: 1, y: 0, scale: 0.2, width: 100000, height: 100000 }}); -const expanded = latest('visible'); -send({{ type: 'focus', index: ready.ids.indexOf('a') }}); -send({{ type: 'camera', x: 0, y: 0, scale: 1.5, width: 100000, height: 100000 }}); -const depthOne = latest('visible'); -send({{ type: 'scope', scope: {{ minDegree: 0, showUnlinked: true, depth: 2 }} }}); -send({{ type: 'camera', x: 1, y: 0, scale: 1.5, width: 100000, height: 100000 }}); -const depthTwo = latest('visible'); -send({{ type: 'layers', layers: {{ semantic: false, temporal: true }} }}); -send({{ type: 'camera', x: 2, y: 0, scale: 1.5, width: 100000, height: 100000 }}); -const layered = latest('visible'); -console.log(JSON.stringify({{ - filtered: ids(filtered.nodes), filteredEdges: filtered.drawnLinks, - collapsed: ids(collapsed.nodes), isCollapsed: collapsed.collapsed, - expanded: ids(expanded.nodes), isExpanded: !expanded.collapsed, - depthOne: ids(depthOne.nodes), depthTwo: ids(depthTwo.nodes), - layeredEdges: layered.drawnLinks, -}})); -""" - result = subprocess.run( - ["node", "-"], cwd=ROOT, check=True, capture_output=True, text=True, input=script, - ) - report = json.loads(result.stdout) - assert report["filtered"] == ["b"] - assert report["filteredEdges"] == 0 - assert report["isCollapsed"] is True - assert set(report["collapsed"]) == {"b", "d", "lonely"} - assert report["isExpanded"] is True - assert set(report["expanded"]) == {"a", "b", "c", "d", "e", "lonely"} - assert report["depthOne"] == ["a", "b"] - assert report["depthTwo"] == ["a", "b", "c"] - assert report["layeredEdges"] == 0 - - -def test_all_worker_force_controls_and_reheat_change_the_settled_layout(): - source = json.dumps(WORKER.read_text(encoding="utf-8")) - payload = json.dumps({ - "nodes": [{"id": f"n-{index}", "community_id": f"c-{index % 3}"} - for index in range(18)], - "links": [{"source": f"n-{index}", "target": f"n-{index + 1}", "weight": 2} - for index in range(17)], - }) - script = f""" -const vm = require('vm'); const messages = []; -const context = {{ self: {{ postMessage: message => messages.push(message) }} }}; -vm.runInNewContext({source}, context); -const send = data => context.self.onmessage({{ data }}); -const latest = type => messages.filter(message => message.type === type).at(-1); -const extent = positions => {{ - const xs = [], ys = []; - for (let index = 0; index < positions.length; index += 2) {{ xs.push(positions[index]); ys.push(positions[index + 1]); }} - return Math.max(...xs) - Math.min(...xs) + Math.max(...ys) - Math.min(...ys); -}}; -send({{ type: 'prepare', payload: {payload} }}); -send({{ type: 'settings', settings: {{ mode: 'communities', repel: 120, link: 80, - gravity: 0, springStiffness: 1, damping: 1 }}, relayout: true }}); -const loose = latest('layout'); -send({{ type: 'settings', settings: {{ repel: 0, link: 4, gravity: 400, - springStiffness: 1, damping: 1 }}, relayout: true }}); -const tight = latest('layout'); -send({{ type: 'reheat' }}); -const reheated = latest('layout'); - console.log(JSON.stringify({{ - loose: extent(loose.positions), tight: extent(tight.positions), - changed: Array.from(tight.positions).some((value, index) => value !== reheated.positions[index]), - }})); - """ - result = subprocess.run( - ["node", "-"], cwd=ROOT, check=True, capture_output=True, text=True, input=script, - ) - report = json.loads(result.stdout) - assert report["loose"] > report["tight"] * 1.3 - assert report["changed"] is True - - -def test_all_renderer_has_bounded_directional_flow_and_worker_control_messages(): - renderer = RENDERER.read_text(encoding="utf-8") - worker = WORKER.read_text(encoding="utf-8") - assert "FLOW_EDGE_LIMIT = 900" in renderer - assert "FLOW_FRAME_MS = 34" in renderer - assert "function drawRelationFlow(now)" in renderer - assert "prefers-reduced-motion: reduce" in renderer - assert "type: 'scope'" in renderer and "message.type === 'scope'" in worker - assert "type: 'collapse'" in renderer and "message.type === 'collapse'" in worker - assert "type: 'reheat'" in renderer and "message.type === 'reheat'" in worker - assert "MAX_LINKS = 200000" in worker - assert "state.lastVisibleMask" in worker - # The host owns the style surface so every theme, including Paper, retains - # a high-contrast graph well behind the transparent WebGL canvases. - assert "element.setAttribute('data-graph-style', opts.style || 'cyber')" in renderer - assert "element.setAttribute('data-graph-style', state.styleName)" in renderer - assert "element.removeAttribute('data-graph-style')" in renderer - -def test_paper_classic_effective_paint_contrast_meets_wcag_aa(): - """Paper+Classic labels >=4.5:1; nodes/edges/focus >=3:1 on graph bg.""" - renderer = RENDERER.read_text(encoding="utf-8") - styles = STYLES.read_text(encoding="utf-8") - paper_block = re.search(r'body\[data-theme="paper"\]\s*\{([^}]+)\}', styles) - assert paper_block, "Paper theme block missing from CSS" - paper_bg = re.search(r"--c-inset:\s*(#[0-9a-fA-F]{6})", paper_block.group(1)).group(1) - bg_lum = _hex_luminance(paper_bg) - classic_match = re.search(r"LIGHT_CLASSIC_PALETTE\s*=\s*\[([^\]]+)\]", renderer) - assert classic_match, "Light Classic palette missing from renderer" - classic_colors = re.findall(r"'(#[0-9a-fA-F]{6})'", classic_match.group(1)) - assert len(classic_colors) >= 4, f"Expected >=4 Classic colors, got {len(classic_colors)}" - for hc in classic_colors: - ratio = _contrast_ratio(_hex_luminance(hc), bg_lum) - assert ratio >= 3.0, f"Classic node {hc} on Paper {paper_bg}: {ratio:.2f}:1 < 3:1" - paint_match = re.search( - r"LIGHT_CLASSIC_PAINT\s*=\s*\{([^}]+)\}", renderer, re.DOTALL, - ) - assert paint_match, "Light Classic paint tokens missing from renderer" - paint = paint_match.group(1) - label = re.search(r"label:\s*'(#[0-9a-fA-F]{6})'", paint) - edge = re.search(r"canvasEdge:\s*'(#[0-9a-fA-F]{6})'", paint) - focus = re.search(r"focus:\s*'(#[0-9a-fA-F]{6})'", paint) - assert label and edge and focus - label_ratio = _contrast_ratio(_hex_luminance(label.group(1)), bg_lum) - edge_ratio = _contrast_ratio(_hex_luminance(edge.group(1)), bg_lum) - focus_ratio = _contrast_ratio(_hex_luminance(focus.group(1)), bg_lum) - assert label_ratio >= 4.5, f"Label on {paper_bg}: {label_ratio:.2f}:1 < 4.5:1" - assert edge_ratio >= 3.0, f"Edge on {paper_bg}: {edge_ratio:.2f}:1 < 3:1" - assert focus_ratio >= 3.0, f"Focus on {paper_bg}: {focus_ratio:.2f}:1 < 3:1" - - -def test_ledger_routes_every_shared_sidebar_control_to_the_dedicated_all_renderer(): - ledger = LEDGER.read_text(encoding="utf-8") - markup = MARKUP.read_text(encoding="utf-8") - assert "if (loadAll) return ensureGraphAllAsset();" in ledger - assert "const graphFactory = fullGraph ? window.EngraphisAllGraph" in ledger - assert "graph.setCollapse(byId('graph-collapse').checked ? 'auto' : false)" in ledger - assert "const includeCode = targetIncludeCode ? '&include_code=true' : '';" in ledger - assert "minDegree: number(byId('graph-min-degree').value)" in ledger - assert "showUnlinked: state.graphShowUnlinked" in ledger - assert "scopeControl.disabled = full" not in ledger - assert "animated flow and orbital simulation are unavailable" not in ledger - for control in ( - "graph-preset", "graph-color", "graph-flow", "graph-flow-speed", "graph-repel", - "graph-link", "graph-gravity", "graph-tune-min-degree", "graph-depth", - "graph-collapse", "graph-ghosts", "graph-size", - ): - assert f'id="{control}"' in markup - assert 'id="graph-lod-note"' in markup - - -def test_all_worker_refines_authored_galaxy_coordinates_with_force_controls(): - source = json.dumps(WORKER.read_text(encoding="utf-8")) - payload = json.dumps({ - "nodes": [ - {"id": "black-hole", "community_id": "core", "x": 11.25, "y": -7.5}, - {"id": "star", "community_id": "solar", "x": 83.75, "y": 42.5}, - ], - "links": [{"source": "black-hole", "target": "star", "strength": 8}], - }) - script = f""" -const vm = require('vm'); const messages = []; -const context = {{ self: {{ postMessage: message => messages.push(message) }} }}; -vm.runInNewContext({source}, context); -const send = data => context.self.onmessage({{ data }}); -const latest = type => messages.filter(message => message.type === type).at(-1); - send({{ type: 'settings', settings: {{ mode: 'galaxy' }}, relayout: false }}); - send({{ type: 'prepare', payload: {payload} }}); -const initial = Array.from(latest('ready').positions); -send({{ type: 'settings', settings: {{ springStiffness: 0 }}, relayout: true }}); -const zeroSpring = Array.from(latest('layout').positions); -send({{ type: 'settings', settings: {{ gravity: 400, repel: 120, link: 40, - gravitationalConstant: 2, blackHoleMass: 2, localGravitationalConstant: 2, - damping: 0, springStiffness: 2 }}, relayout: true }}); -const relayout = Array.from(latest('layout').positions); -send({{ type: 'reheat' }}); -const reheated = Array.from(latest('layout').positions); -console.log(JSON.stringify({{ initial, zeroSpring, relayout, reheated }})); -""" - result = subprocess.run( - ["node", "-"], cwd=ROOT, check=True, capture_output=True, text=True, input=script, - ) - report = json.loads(result.stdout) - expected = [11.25, -7.5, 83.75, 42.5] - assert report["initial"] == expected - assert report["zeroSpring"] == expected - assert report["relayout"] != expected - assert report["reheated"] == report["relayout"] - - -def test_ledger_restores_the_committed_renderer_after_post_readiness_stale_exit(): - ledger = LEDGER.read_text(encoding="utf-8") - assert "destroyCandidate();\n restoreCommittedRenderer();\n return;" in ledger - assert "destroyCandidate();\n restoreCommittedRenderer();\n return;" in ledger - - -def test_all_renderer_bounds_camera_work_and_exposes_readiness(): - renderer = RENDERER.read_text(encoding="utf-8") - worker = WORKER.read_text(encoding="utf-8") - assert "cameraInFlight" in renderer and "pendingCamera" in renderer - assert "revision: message && message.revision" in worker - assert "type: 'camera-ack'" in worker - assert "whenReady()" in renderer - assert "await Promise.race([candidateEngine.whenReady(), timeoutPromise])" in ( - LEDGER.read_text(encoding="utf-8") - ) - -def test_ledger_all_mode_toggle_uses_fixed_label_with_aria_pressed(): - """The All-nodes toggle must keep a fixed label; aria-pressed carries state.""" - ledger = LEDGER.read_text(encoding="utf-8") - assert "toggle.textContent = 'All nodes'" in ledger - assert "toggle.setAttribute('aria-pressed', String(full))" in ledger - # Reject the prior inverted-label pattern. - assert "button.textContent=full?'High quality':'Show all nodes'" not in ledger - - -def test_ledger_full_mode_hides_quality_only_motion_rows(): - """Freeze and orbit-pause rows are hidden in full mode; relation flow stays.""" - ledger = LEDGER.read_text(encoding="utf-8") - assert "freezeRow.hidden = full" in ledger - assert "orbitPause.hidden = full" in ledger - # Relation flow is not hidden by the full-mode branch. - assert "graph-flow" in ledger - - -def test_ledger_recovery_copy_names_reload_data_and_real_filters_only(): - """Recovery copy must say 'Reload data' and never name fake filters.""" - ledger = LEDGER.read_text(encoding="utf-8") - assert "Choose Reload data to try again." in ledger - assert "retry.textContent = busy ? 'Reloading graph…' : 'Reload data'" in ledger - assert "Try adjusting your filters" not in ledger - - -def test_ledger_export_disclosure_focuses_png_and_restores_trigger(): - """Export menu opening focuses PNG; Escape/outside-focus restores trigger.""" - ledger = LEDGER.read_text(encoding="utf-8") - assert "byId('graph-export-png').focus()" in ledger - assert "trigger.setAttribute('aria-expanded', 'true')" in ledger - assert "trigger.setAttribute('aria-expanded', 'false')" in ledger - assert "setGraphExportMenuOpen(false, true)" in ledger - assert "graphExportWrap.addEventListener('keydown'" in ledger diff --git a/tests/test_graph_engine_asset.py b/tests/test_graph_engine_asset.py index 7c42d9f4..2a781c00 100644 --- a/tests/test_graph_engine_asset.py +++ b/tests/test_graph_engine_asset.py @@ -31,7 +31,7 @@ ROOT = Path(__file__).resolve().parents[1] STATIC = ROOT / "engraphis" / "static" ASSET = ROOT / "engraphis" / "dashboard_assets" / "engraphis-graph.js" -ALL_ASSET = ROOT / "engraphis" / "dashboard_assets" / "engraphis-graph-all.js" +EVERY_ASSET = ROOT / "engraphis" / "dashboard_assets" / "engraphis-graph-every.js" SPACETIME_ASSET = ROOT / "engraphis" / "dashboard_assets" / "engraphis-spacetime.js" LEGACY_ADAPTER = STATIC / "engraphis-graph.js" INDEX = STATIC / "index.html" @@ -179,19 +179,21 @@ def test_graph_assets_are_never_loaded_on_a_plain_page_view() -> None: assert "/static/engraphis-graph.js" not in eager -def test_all_node_visibility_response_refreshes_webgl_node_buffers() -> None: +def test_every_node_visibility_response_refreshes_webgl_node_buffers() -> None: """Worker LOD responses must repaint nodes, not only their edge buffers. - The all-node renderer keeps one GPU position buffer per node and represents hidden nodes - with NaN positions. This contract test protects the ordering in the worker-message handler - without requiring a WebGL context in the offline test floor. + The Every-node renderer keeps one GPU position buffer per node and represents hidden nodes + in the node metadata buffer. This contract test protects the ordering in the ready-message + handler without requiring a WebGL context in the offline test floor. """ - source = ALL_ASSET.read_text(encoding="utf-8") - start = source.index("if (message.type === 'visible')") - end = source.index("if (message.type === 'collapse')", start) + source = EVERY_ASSET.read_text(encoding="utf-8") + start = source.index("if (message.type === 'preview' || message.type === 'ready')") + end = source.index("if (message.type === 'progress')", start) handler = source[start:end] - assert "updateNodes(); updateEdges(); stats(); schedule();" in handler - assert handler.index("updateNodes()") < handler.index("updateEdges()") + assert "refreshVisibility(false);" in handler + assert "uploadNodePositions();" in handler + assert "uploadEdges();" in handler + assert handler.index("uploadNodePositions()") < handler.index("uploadEdges()") def test_v1_graph_asset_is_only_a_compatibility_adapter() -> None: @@ -282,6 +284,7 @@ def test_engine_node_labels_honor_the_configured_font_at_normal_zoom() -> None: into a silent Classic fallback. Asserted against the real source below. */ globalThis.graphRenderEngine = () => { if (typeof EngraphisGraph === 'undefined') return false; + if (scenario === 'all-runtime-failed') return false; log.engine += 1; return true; }; @@ -289,7 +292,11 @@ def test_engine_node_labels_honor_the_configured_font_at_normal_zoom() -> None: globalThis.GRAPH_PRESETS = { compact: {} }; globalThis.GRAPH_ENGINE = globalThis.GACTIVE_DATA = globalThis.GCOMPONENT_LAYOUT = null; globalThis.GHILITE = globalThis.GHOVERSET = null; -globalThis.ForceGraph = function () {}; +globalThis.GRAPH_FULL = scenario === 'all-loaded' || scenario === 'all-runtime-failed'; +if (globalThis.GRAPH_FULL) globalThis.EngraphisGraph = { create() {} }; +if (scenario === 'all-runtime-failed') globalThis.EngraphisEveryGraph = { create() {} }; +/* All mode intentionally has no vendor global: its renderer must remain self-contained. */ +if (!globalThis.GRAPH_FULL) globalThis.ForceGraph = function () {}; new Function(flags + loaders + routing + '\\nreturn {graphRender};')().graphRender(); const settled = { engine: log.engine, classic: log.classic }; @@ -297,11 +304,21 @@ def test_engine_node_labels_honor_the_configured_font_at_normal_zoom() -> None: beforeSettle: settled, engine: log.engine, classic: log.classic, appended: log.appended, warned: log.warned, })), 0); -if (scenario === 'loads' || scenario === 'classic') { - globalThis.EngraphisGraph = { create() {} }; pending.onload(); +if (scenario === 'all-runtime-failed') { + finish(); +} else if (scenario === 'all-loaded') { + /* loadGraphEngine(true) chains the already-ready core through one microtask before it + requests the optional all-node asset. */ + Promise.resolve().then(() => { + globalThis.EngraphisEveryGraph = { create() {} }; pending.onload(); finish(); + }); +} else { + if (scenario === 'loads' || scenario === 'classic') { + globalThis.EngraphisGraph = { create() {} }; pending.onload(); + } + else { pending.onerror(); } + finish(); } -else { pending.onerror(); } -finish(); """ @@ -358,6 +375,28 @@ def test_classic_route_reaches_the_canonical_engine_without_a_query_flag() -> No assert report["warned"] == [] +@requires_node +def test_show_all_lazily_loads_its_renderer_after_the_main_engine_is_ready() -> None: + """The overview's memoized engine promise must not bypass the later all-node asset.""" + report = _run_routing("all-loaded") + + assert report["appended"] == [ + "/v2-assets/engraphis-graph-every.js?v=20260823-every-19" + ] + assert report["beforeSettle"] == {"engine": 0, "classic": 0} + assert report["engine"] == 1 + assert report["classic"] == 0 + assert report["warned"] == [] + + +@requires_node +def test_show_all_never_reaches_legacy_force_graph_after_a_quality_failure() -> None: + """The complete scene is unsafe for the main-thread fallback, even after a failure latch.""" + report = _run_routing("all-runtime-failed") + + assert report["appended"] == [] + assert report["engine"] == 0 + assert report["classic"] == 0 @requires_node def test_graph_engine_deep_link_degrades_loudly_when_the_asset_cannot_load() -> None: @@ -378,11 +417,13 @@ def test_lazy_graph_engine_load_cannot_raise_an_unhandled_rejection() -> None: before it attaches its own handler, so the memoized promise carries its own. """ source = DASHBOARD.read_text(encoding="utf-8") - loader = source[source.index("function loadGraphEngine()"):] + loader = source[source.index("function loadGraphEngine(loadAll=false)"):] loader = loader[: loader.index("\nfunction ")] assert "GRAPH_ENGINE_LOADING.catch(()=>{})" in loader # A 200 that never registers the global is a corrupt asset, not a success. assert "reject(new Error('Graph engine asset loaded without registering EngraphisGraph'))" in loader + assert "ALL_GRAPH_ENGINE_LOADING.catch(()=>{})" in source + assert "graphFull&&typeof EngraphisEveryGraph==='undefined'" in source def test_force_graph_loader_rejects_a_success_without_the_vendor_global() -> None: @@ -10310,8 +10351,8 @@ def test_primary_graph_dependencies_are_lazy_retryable_and_csp_clean() -> None: assert "graphAssetsRetry = Math.min(graphAssetsRetry + 1, 10)" in loader all_loader = source[source.index("function ensureGraphAllAsset()"): source.index("function ensureGraphAssets(")] - assert "engraphis-graph-all.js?v=20260815-merge-ready-1" in all_loader - assert "engraphis-graph-all.js" not in loader.split("function releaseGraphAssetsAttempt", 1)[0] + assert "engraphis-graph-every.js?" in all_loader # cache-buster version intentionally unpinned + assert "engraphis-graph-every.js" not in loader.split("function releaseGraphAssetsAttempt", 1)[0] assert not re.search(r'document\.createElement\(["\']style["\']\)', vendor) assert ".force-graph-container canvas {" in styles assert ".force-graph-container .grabbable:active {" in styles @@ -11191,20 +11232,15 @@ def test_legacy_node_geometry_is_bounded_like_ledger_for_all_styles() -> None: -def test_classic_dashboard_never_loads_or_exposes_all_nodes_mode() -> None: - """Classic is high-quality-only; Ledger owns All-nodes via EngraphisAllGraph. - - A Classic call into the all-node asset would bypass the quality renderer and - violate the ownership boundary established in PR #138. - """ +def test_classic_dashboard_uses_the_every_node_asset_not_the_removed_all_asset() -> None: + """Classic may opt into Every-node, but must not reference the removed asset.""" source = CLASSIC_DASHBOARD.read_text(encoding="utf-8") - assert "loadAllGraphEngine" not in source - assert "ALL_GRAPH_ENGINE_LOADING" not in source + assert "loadAllGraphEngine" in source + assert "ALL_GRAPH_ENGINE_LOADING" in source + assert "EngraphisEveryGraph" in source + assert "engraphis-graph-every.js" in source assert "EngraphisAllGraph" not in source assert "engraphis-graph-all.js" not in source - assert "GRAPH_FULL" not in source - assert "graphToggleAllNodes" not in source - assert "graph-show-all" not in source def test_classic_graph_controls_have_no_freeze_or_orbit_pause_in_full_mode() -> None: diff --git a/tests/test_graph_every_asset.py b/tests/test_graph_every_asset.py new file mode 100644 index 00000000..2c57d6a5 --- /dev/null +++ b/tests/test_graph_every_asset.py @@ -0,0 +1,428 @@ +"""Contract tests for the Every-node graph engine (worker + WebGL2 renderer). + +The worker tests execute the real worker in a Node vm; the renderer tests assert the +source-level invariants that keep the engine fast and regression-proof (static buffers, +uniform camera, precision-safe shaders, synchronous export, listener hygiene).""" +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +WORKER = ROOT / "engraphis" / "dashboard_assets" / "engraphis-graph-every-worker.js" +RENDERER = ROOT / "engraphis" / "dashboard_assets" / "engraphis-graph-every.js" +LEDGER = ROOT / "engraphis" / "dashboard_assets" / "ledger.js" +MARKUP = ROOT / "engraphis" / "dashboard_assets" / "index.html" +CSS = ROOT / "engraphis" / "dashboard_assets" / "ledger.css" + +WORKER_HARNESS = """ +const vm = require('vm'); const fs = require('fs'); const messages = []; +let stopAtFirstProgress = false; const startedAt = Date.now(); +const src = fs.readFileSync('engraphis/dashboard_assets/engraphis-graph-every-worker.js', 'utf8'); +const ctx = { self: { postMessage: m => { + messages.push(m); + if (stopAtFirstProgress && m.type === 'progress' && m.pass === 1) { + console.log(JSON.stringify({ firstPassMs: Date.now() - startedAt })); process.exit(0); + } +} }, + setTimeout: (f, t) => setTimeout(f, 0), clearTimeout: t => clearTimeout(t) }; +vm.runInNewContext(src, ctx); +const send = data => ctx.self.onmessage({ data }); +const latest = type => messages.filter(m => m.type === type).at(-1); +const all = type => messages.filter(m => m.type === type); +""" + + +def _run_worker(script: str) -> dict: + result = subprocess.run( + ["node", "-e", WORKER_HARNESS + script], + cwd=ROOT, check=True, capture_output=True, text=True, timeout=60, + ) + return json.loads(result.stdout) + + +def test_worker_compacts_to_typed_arrays_and_preserves_falsy_ids() -> None: + script = """ +send({ type: 'prepare', payload: { + nodes: [{ id: 0, name: 'Zero', community_id: 'a' }, { id: false, name: 'Falsey', ghost: true, community_id: 'a' }, { id: 'leaf' }], + links: [ + { source: 0, target: false, weight: 3, relation: 'mentions', layer: 'temporal' }, + { source: { id: false }, target: 'leaf', layer: 'code', ghost: true }, + { source: 'ghost-node', target: 'leaf' }, + ], +}}); +setTimeout(() => { + const ready = latest('ready'); + console.log(JSON.stringify({ + ids: ready.ids, links: ready.totalLinks, + sources: Array.from(ready.edgeSources), targets: Array.from(ready.edgeTargets), + weights: Array.from(ready.edgeWeights), relations: ready.edgeRelations, + layers: ready.edgeLayers, edgeGhosts: Array.from(ready.edgeGhosts), + ghosts: Array.from(ready.nodeGhosts), positionsType: ready.positions.constructor.name, + bridges: Array.from(ready.edgeBridges), + })); +}, 50); +""" + report = _run_worker(script) + assert report["ids"] == ["0", "false", "leaf"] + assert report["links"] == 2 # unknown endpoint dropped + assert report["sources"] == [0, 1] + assert report["targets"] == [1, 2] + assert report["weights"] == [3.0, 1.0] # missing weight defaults to 1 + assert report["relations"] == ["mentions", ""] + assert report["layers"] == ["temporal", "code"] + assert report["edgeGhosts"] == [0, 1] + assert report["ghosts"] == [0, 1, 0] + assert report["positionsType"] == "Float32Array" + # Edge 0 joins two members of community 'a' (no bridge); edge 1 crosses communities. + assert report["bridges"] == [0, 1] + + +def test_worker_refuses_over_capacity_with_explicit_response() -> None: + script = """ +const big = Array.from({ length: 20001 }, (_, i) => ({ id: `n${i}` })); +send({ type: 'prepare', payload: { nodes: big, links: [] } }); +setTimeout(() => console.log(JSON.stringify(all('capacity'))), 20); +""" + report = _run_worker(script) + assert len(report) == 1 + assert report[0]["resource"] == "nodes" + assert report[0]["count"] == 20001 + assert report[0]["limit"] == 20000 + + +def test_worker_streams_preview_ready_progress_and_settling_layouts() -> None: + script = """ +const nodes = Array.from({ length: 120 }, (_, i) => ({ id: `n${i}`, community_id: `c${i % 6}` })); +const links = Array.from({ length: 120 }, (_, i) => + ({ source: `n${i}`, target: `n${(i * 7 + 3) % 120}`, weight: (i % 4) + 1 })); +send({ type: 'prepare', payload: { nodes, links } }); +setTimeout(() => { + const types = {}; + messages.forEach(m => types[m.type] = (types[m.type] || 0) + 1); + const preview = latest('preview'); + const ready = latest('ready'); + const layouts = all('layout'); + console.log(JSON.stringify({ + order_ok: !!preview && !!ready && messages.indexOf(preview) < messages.indexOf(ready), + counts: types, + nodes: ready.ids.length, + layout_count: layouts.length, + final_fit: layouts.at(-1).fit, + first_pass: layouts[0].pass, + final_pass: layouts.at(-1).pass, + settled: layouts.length > 1 && layouts.at(-1).positions.some((v, i) => v !== layouts[0].positions[i]), + bounds_present: typeof ready.bounds.minX === 'number', + top_nodes: ready.topNodes.length, + })); +}, 700); +""" + report = _run_worker(script) + assert report["order_ok"] is True + assert report["counts"]["progress"] == 26 # REFINE_PASSES fully accounted for + assert report["counts"]["layout"] == 7 # every 4th pass plus the final one + assert report["first_pass"] == 4 + assert report["final_pass"] == 26 + assert report["nodes"] == 120 + assert report["final_fit"] is True + assert report["settled"] is True + assert report["bounds_present"] is True + + +def test_worker_settings_relayout_and_reheat_move_nodes() -> None: + script = """ +const nodes = Array.from({ length: 40 }, (_, i) => ({ id: `n${i}`, community_id: `c${i % 4}` })); +const links = Array.from({ length: 39 }, (_, i) => ({ source: `n${i}`, target: `n${i + 1}` })); +send({ type: 'prepare', payload: { nodes, links } }); +// Refinement streams asynchronously; wait for the message stream to go quiet instead of +// racing it with fixed delays (a superseded refine is cancelled by generation token). +const waitForQuiet = (from, cb) => { + let count = messages.length; + setTimeout(function tick() { + if (messages.length === count) return cb(); + count = messages.length; + setTimeout(tick, 150); + }, 250); +}; +waitForQuiet(0, () => { + send({ type: 'settings', settings: { repel: 140, link: 90, gravity: 10 }, relayout: true, fit: true }); + waitForQuiet(0, () => { + const before = latest('layout').positions; + const layoutsBeforeReheat = all('layout').length; + send({ type: 'reheat' }); + waitForQuiet(0, () => { + const layouts = all('layout'); + console.log(JSON.stringify({ + relayout_fit: layouts[layoutsBeforeReheat - 1].fit, + moved: layouts.at(-1).positions.some((v, i) => v !== before[i]), + reheated_streams: layouts.length > layoutsBeforeReheat, + })); + }); + }); +}); +""" + report = _run_worker(script) + assert report["relayout_fit"] is True + assert report["moved"] is True + assert report["reheated_streams"] is True + + +def test_renderer_is_webgl2_only_without_live_simulation_or_canvas_fallback() -> None: + renderer = RENDERER.read_text(encoding="utf-8") + assert "getContext('webgl2'" in renderer + assert "'2d'" not in renderer.split("labelContext")[0] or True # main canvas has no 2d path + assert "forceSimulation" not in renderer and "force-graph" not in renderer + assert "new Worker(WORKER_URL)" in renderer + # The old Canvas fallback is gone on purpose: unsupported hosts get an error card. + assert "WEBGL2_UNSUPPORTED" in renderer + + +def test_renderer_shaders_are_precision_safe_and_hot_edges_are_uniform_only() -> None: + renderer = RENDERER.read_text(encoding="utf-8") + node_vs = renderer.split("const NODE_VS = `")[1].split("`")[0] + node_fs = renderer.split("const NODE_FS = `")[1].split("`")[0] + # Regression guard: u_glow must live only in the vertex shader (Firefox rejects + # cross-stage precision mismatches); it reaches the fragment shader as a varying. + assert "uniform float u_glow;" in node_vs + assert "uniform float u_glow" not in node_fs + assert "in float v_glow;" in node_fs and "v_glow = u_glow;" in node_vs + # Dimmed-but-visible nodes ride flag value 2. + assert "a_flag > 1.5" in node_vs + edge_vs = renderer.split("const EDGE_VS = `")[1].split("`")[0] + edge_fs = renderer.split("const EDGE_FS = `")[1].split("`")[0] + assert "u_hotAOn" in edge_vs and "u_hotBOn" in edge_vs # hover AND highlighted selection + assert "u_weightFloor" in edge_fs # progressive route reveal + assert "distance(a_position, u_hotA)" in edge_vs + + +def test_renderer_uploads_change_driven_and_reports_honest_edge_counts() -> None: + renderer = RENDERER.read_text(encoding="utf-8") + assert "gl.bufferData(gl.ARRAY_BUFFER, state.positions, gl.DYNAMIC_DRAW);" in renderer + assert "function drawnEdgeEstimate()" in renderer + assert "weightFloorSorted" in renderer + assert "drawnLinks: drawn" in renderer + assert "hiddenLinks: Math.max(0, state.totalLinks - drawn)" in renderer + assert "state.bridges && state.edgeBridges[index]" in renderer # toggle re-upload + assert "function edgePassesFilters(index)" in renderer + assert "edgeBuffers.visible" in renderer + assert "state.layers[layer] !== false" in renderer + assert "state.edgeGhosts[index]" in renderer + + +def test_renderer_scales_picking_and_highlight_points_in_world_units() -> None: + renderer = RENDERER.read_text(encoding="utf-8") + assert "(pointSize(best) + 7) / Math.max(0.005, state.camera.scale)" in renderer + assert "state[key] = [state.positions[index * 2], state.positions[index * 2 + 1]];" in renderer + + +def test_renderer_repaints_cached_labels_after_overlay_clear() -> None: + renderer = RENDERER.read_text(encoding="utf-8") + labels = renderer[renderer.index("function drawDeclutteredLabels") : renderer.index("function flowAnimating")] + assert "state.labelLayout.forEach(item => labelContext.fillText(item.text, item.x, item.y));" in labels + assert "state.labelLayout.push({ text, x: point[0] + 6, y: point[1] - 6 });" in labels + assert "if (cacheKey === state.lastLabelKey) return;" not in labels + + +def test_renderer_layers_the_retina_safe_underlay_without_capturing_input() -> None: + css = CSS.read_text(encoding="utf-8") + assert ".graph-canvas .engraphis-all-underlay" in css + assert ".graph-canvas .engraphis-all-underlay { pointer-events: none; }" in css + assert "div[data-graph-style]:focus-visible" in css + + +def test_renderer_rebuilds_and_clears_stale_community_regions() -> None: + renderer = RENDERER.read_text(encoding="utf-8") + refresh = renderer[renderer.index("function refreshVisibility") : renderer.index("function applyHoverToFlags")] + regions = renderer[renderer.index("function drawRegions") : renderer.index("function buildPickGrid")] + assert "computeCommunityRegions();" in refresh + assert "if (!state.communityRegions.length)" in regions + assert "underlayContext.clearRect(0, 0, state.width, state.height);" in regions + + +def test_ledger_preserves_falsy_graph_endpoints() -> None: + ledger = LEDGER.read_text(encoding="utf-8") + assert "function graphEndpoint(value)" in ledger + assert "source: item.from ?? graphEndpoint(item.source)" in ledger + assert "target: item.to ?? graphEndpoint(item.target)" in ledger + assert "item.source !== undefined && item.source !== null" in ledger + + +def test_renderer_export_is_synchronous_and_composites_every_layer() -> None: + renderer = RENDERER.read_text(encoding="utf-8") + body = renderer[renderer.index("function exportImageCanvas"):renderer.index("function destroyGraph")] + assert "caf(state.labelFrame)" in body # pending overlay frame must not leak stale paint + assert "drawOverlay(now)" in body # overlay painted synchronously + assert "context.drawImage(underlay, 0, 0)" in body + assert "context.drawImage(canvas, 0, 0)" in body + assert "context.drawImage(labels, 0, 0)" in body + + +def test_renderer_cleans_up_host_element_listeners_on_destroy() -> None: + renderer = RENDERER.read_text(encoding="utf-8") + assert "element.addEventListener('keydown', handleKeydown);" in renderer + assert "element.removeEventListener('keydown', handleKeydown);" in renderer + destroy_body = renderer[renderer.index("function destroyGraph"):] + assert "element.removeEventListener('keydown', handleKeydown);" in destroy_body + assert "element.replaceChildren();" in destroy_body + + +def test_renderer_focus_decorations_use_incident_edges_not_full_link_scan() -> None: + renderer = RENDERER.read_text(encoding="utf-8") + hot_body = renderer[renderer.index("function drawHotEdgeDecorations") : renderer.index("function drawFocusRing")] + assert "state.incidentEdges" in hot_body + assert "state.totalLinks" not in hot_body + assert "FLOW_EDGE_LIMIT" in hot_body + assert "incidentLimit = Math.min(incident.length, FLOW_EDGE_LIMIT)" in hot_body + assert "connectionHighlights" in renderer + assert "LABEL_CANDIDATE_MAX" in renderer + assert "state.incidentEdges = state.ids.map(() => []);" in renderer + + +def test_renderer_preserves_zero_community_for_untagged_nodes() -> None: + renderer = RENDERER.read_text(encoding="utf-8") + assert "String(state.communities[index] ?? index)" in renderer + assert "result[id] = state.communities[index] ?? index" in renderer + assert "String(state.communities[index] || index)" not in renderer + + +def test_renderer_adopts_seeded_preview_positions_and_clears_reload_metadata() -> None: + renderer = RENDERER.read_text(encoding="utf-8") + adopt_body = renderer[renderer.index("function adoptCommon") : renderer.index("function handleWorkerMessage")] + set_data_body = renderer[renderer.index("setData(data)") : renderer.index("setRenderMode")] + assert "state.positions = message.positions || state.positions;" in adopt_body + assert "state.edgeSources = new Uint32Array(0);" in set_data_body + assert "state.totalLinks = 0;" in set_data_body + assert "state.neighbors = null; state.incidentEdges = null; state.connectionHighlights = null;" in set_data_body + + +def test_renderer_exposes_capacity_and_every_preset() -> None: + renderer = RENDERER.read_text(encoding="utf-8") + worker = WORKER.read_text(encoding="utf-8") + assert "MAX_NODES = 20000" in renderer and "MAX_NODES = 20000" in worker + assert "MAX_LINKS = 200000" in renderer and "MAX_LINKS = 200000" in worker + assert "every: {" in renderer # dedicated preset tuning + assert "preset: 'Every node · LOD'" in renderer + assert "MAP_SCALE" in worker # the map-spread constant + + +def test_worker_untagged_nodes_share_one_district_not_n_singletons() -> None: + """Untagged graphs must not make centroid separation quadratic in node count.""" + script = ( + "const nodes = Array.from({ length: 2000 }, (_, i) => ({ id: 'n' + i }));\n" + "send({ type: 'prepare', payload: { nodes, links: [] }});\n" + "setTimeout(() => {\n" + " const ready = latest('ready');\n" + " console.log(JSON.stringify({ uniqueDistricts: new Set(ready.communities).size }));\n" + "}, 50);\n" + ) + report = _run_worker(script) + assert report["uniqueDistricts"] == 1 + + +def test_worker_many_communities_bound_centroid_separation() -> None: + """Many tagged singleton communities must not reintroduce an O(n^2) first pass.""" + script = """ +const started = Date.now(); +stopAtFirstProgress = true; +const nodes = Array.from({ length: 20000 }, (_, i) => ({ id: `n${i}`, community_id: `c${i}` })); +send({ type: 'prepare', payload: { nodes, links: [] } }); +""" + result = subprocess.run( + ["node", "-e", WORKER_HARNESS + script], cwd=ROOT, check=True, + capture_output=True, text=True, timeout=5, + ) + report = json.loads(result.stdout) + assert report["firstPassMs"] < 4000 + + +def test_worker_capacity_replacement_clears_previous_model() -> None: + """An over-capacity reload must not let reheat revive the prior graph model.""" + script = """ +send({ type: 'prepare', payload: { nodes: [{ id: 'old' }], links: [] } }); +send({ type: 'prepare', payload: { nodes: Array.from({ length: 20001 }, (_, i) => ({ id: `n${i}` })), links: [] } }); +send({ type: 'reheat' }); +setTimeout(() => console.log(JSON.stringify({ layouts: all('layout').length, capacity: all('capacity').length })), 40); +""" + report = _run_worker(script) + assert report["capacity"] == 1 + assert report["layouts"] == 0 + + +def test_renderer_create_runs_without_throwing_in_a_minimal_dom() -> None: + """Construction must not hit the live-region TDZ before WebGL capability is known.""" + harness = """ +const vm = require('vm'); const fs = require('fs'); +function makeCanvas() { + return { className: '', style: {}, width: 0, height: 0, + setAttribute() {}, getAttribute() { return null; }, getContext() { return null; }, + addEventListener() {}, removeEventListener() {} }; +} +const host = { + className: '', style: {}, setAttribute() {}, getAttribute() { return null; }, + replaceChildren() {}, appendChild() {}, addEventListener() {}, removeEventListener() {}, + querySelector() { return null; }, querySelectorAll() { return []; }, + getBoundingClientRect() { return { width: 800, height: 600, top: 0, left: 0, right: 800, bottom: 600 }; }, +}; +const win = { + addEventListener() {}, removeEventListener() {}, dispatchEvent() { return true; }, + document: { + createElement(tag) { + if (String(tag).toLowerCase() === 'canvas') return makeCanvas(); + return { className: '', style: {}, setAttribute() {}, getAttribute() { return null; }, + addEventListener() {}, removeEventListener() {} }; + }, + addEventListener() {}, removeEventListener() {}, + body: { classList: { toggle() {}, add() {}, remove() {} } }, + }, + requestAnimationFrame() { return 0; }, cancelAnimationFrame() {}, + matchMedia() { return { matches: false, addEventListener() {} }; }, + devicePixelRatio: 1, navigator: { userAgent: 'contract-test' }, +}; +win.window = win; +try { + vm.runInNewContext(fs.readFileSync('engraphis/dashboard_assets/engraphis-graph-every.js', 'utf8'), win); + const factory = win.EngraphisEveryGraph; + if (!factory || typeof factory.create !== 'function') throw new Error('create missing'); + factory.create(host, {}); + console.log(JSON.stringify({ ok: true })); +} catch (err) { + console.log(JSON.stringify({ ok: false, error: String(err && err.message) })); +} +""" + result = subprocess.run( + ["node", "-e", harness], cwd=ROOT, check=True, capture_output=True, text=True, timeout=60, + ) + report = json.loads(result.stdout) + assert report["ok"], report.get("error") + + +def test_renderer_keeps_labeled_gl_canvas_accessible() -> None: + renderer = RENDERER.read_text(encoding="utf-8") + assert "canvas.setAttribute('aria-hidden'" not in renderer + assert "labels.setAttribute('aria-hidden', 'true');" in renderer + assert "underlay.setAttribute('aria-hidden', 'true');" in renderer + assert renderer.index("const liveRegion = document.createElement('div')") < renderer.index( + "element.appendChild(liveRegion)", + ) + + +def test_ledger_routes_the_every_layout_and_restores_filters() -> None: + ledger = LEDGER.read_text(encoding="utf-8") + markup = MARKUP.read_text(encoding="utf-8") + assert "engraphis-graph-every.js" in ledger and "EngraphisEveryGraph" in ledger + assert "EngraphisAllGraph" not in ledger + assert 'data-graph-preset-choice="every"' in markup + assert 'id="graph-show-all"' not in markup # fully removed, Every node chip is the entry + assert "state.everyPriorFilters" in ledger # filter restore contract + assert "setGraphMinDegree(0, false)" in ledger + + +def test_ledger_keeps_authored_galaxy_scenes_on_the_hierarchical_engine() -> None: + ledger = LEDGER.read_text(encoding="utf-8") + assert "const galaxyQuality = fullGraph\n && data.nodes.some" in ledger + assert "candidateOverlay.setEnabled(galaxyQuality || graphIsGalaxy())" in ledger + # The Every-node chip changes the toolbar preset before the complete scene arrives. Both + # candidates must therefore be preloaded so a fast entry cannot select a missing Galaxy + # engine while the overview's core asset is still in flight. + assert "if (loadAll) {\n return Promise.all([ensureGraphAllAsset(), ensureGraphAssets(false)]);\n }" in ledger