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 @@
Graph connections