From c707f073684a32b40ec27ada8ea418910e3406a8 Mon Sep 17 00:00:00 2001 From: "Max \"plainskill\" Luecke" Date: Sat, 22 Aug 2026 23:29:19 -0700 Subject: [PATCH 01/21] feat(graph): dedicated ultra-performance Every-node view Replace the worker-backed all-node LOD renderer with a purpose-built WebGL2 engine: all geometry is uploaded once and re-uploaded only on data/layout/ filter changes, camera moves touch two uniforms (frame cost independent of node count up to the 20k/200k ceilings), zoom-out readability comes from additive glow density, edges reveal progressively by weight, community districts render as tinted region hulls with hub-derived labels, picking is a local spatial grid, and hover/highlight neighbourhood focus draws dark- cased gold paths with direction arrows and relation names. Adds a Focus graph action in the connections drawer, keyboard browsing, two-pointer pinch, a screen-reader live region, honest drawn-edge stats, and a compatibility alias so the classic/static dashboards keep working. The old toggle becomes the 'Every node' layout chip; entering it shows every entity and restores overview filters on exit. Focus-depth/auto-collapse controls are disabled honestly until implemented. --- .gitignore | 5 + engraphis/classic_assets/dashboard.js | 2 +- .../dashboard_assets/engraphis-graph-all.js | 491 ------ .../engraphis-graph-every-worker.js | 408 +++++ .../dashboard_assets/engraphis-graph-every.js | 1412 +++++++++++++++++ .../engraphis-graph-worker.js | 421 ----- engraphis/dashboard_assets/index.html | 4 +- engraphis/dashboard_assets/ledger.js | 70 +- engraphis/static/dashboard.js | 2 +- scripts/externalize_dashboard_assets.py | 6 +- 10 files changed, 1897 insertions(+), 924 deletions(-) delete mode 100644 engraphis/dashboard_assets/engraphis-graph-all.js create mode 100644 engraphis/dashboard_assets/engraphis-graph-every-worker.js create mode 100644 engraphis/dashboard_assets/engraphis-graph-every.js delete mode 100644 engraphis/dashboard_assets/engraphis-graph-worker.js diff --git a/.gitignore b/.gitignore index 4bb10535..989ba0e3 100644 --- a/.gitignore +++ b/.gitignore @@ -114,3 +114,8 @@ 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 diff --git a/engraphis/classic_assets/dashboard.js b/engraphis/classic_assets/dashboard.js index 549110af..01e3c50d 100644 --- a/engraphis/classic_assets/dashboard.js +++ b/engraphis/classic_assets/dashboard.js @@ -1227,7 +1227,7 @@ function loadAllGraphEngine(){ if(typeof EngraphisAllGraph!=='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-all.js?v=20260814-all-controls-2'; + const script=document.createElement('script');script.src='/v2-assets/engraphis-graph-every.js?v=20260822-every-18'; script.onload=()=>{typeof EngraphisAllGraph==='undefined'?reject(new Error('All-node graph asset loaded without registering EngraphisAllGraph')):resolve()}; script.onerror=()=>reject(new Error('All-node graph asset could not load')); document.head.appendChild(script); diff --git a/engraphis/dashboard_assets/engraphis-graph-all.js b/engraphis/dashboard_assets/engraphis-graph-all.js deleted file mode 100644 index fcc48aad..00000000 --- a/engraphis/dashboard_assets/engraphis-graph-all.js +++ /dev/null @@ -1,491 +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=20260814-all-controls-2'; - 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 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 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: {}, 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, - 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; - 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' }; } - 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), 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;out vec4 outputColor;void main(){outputColor=vec4(v_color,0.2);}`; - 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') }; - } 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; labelContext.setTransform(state.dpr, 0, 0, state.dpr, 0, 0); labelContext.clearRect(0, 0, state.width, state.height); labelContext.strokeStyle = 'rgba(124,163,183,0.17)'; 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 = 'rgba(244,211,127,0.62)'; 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; - 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 ? '#f4d37f' : '#638fa6'), 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.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)); - labelContext.save(); - labelContext.globalCompositeOperation = 'lighter'; - 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] - ? 'rgba(255,220,132,0.88)' : 'rgba(115,220,239,0.72)'; - 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; 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 = '#f4d37f'; 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 = 'rgba(224,236,241,0.86)'; 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 camera() { if (!state.ready) return; worker.postMessage({ type: 'camera', x: state.camera.x, y: state.camera.y, scale: state.camera.scale, width: state.width, height: state.height }); schedule(); } - 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, 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; - 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 }; - 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; - updateNodes(); fit(); - if (typeof opts.onMetrics === 'function') opts.onMetrics(api.metrics()); - stats({ progressive: true }); - return; - } - if (message.type === 'visible') { - 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.collapsed = message.collapsed === true; - updateEdges(); stats(); schedule(); - return; - } - if (message.type === 'collapse') { - state.collapsed = 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; - 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; 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'; 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(); 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 } : {}; updateNodes(); 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..4ec3e7bf --- /dev/null +++ b/engraphis/dashboard_assets/engraphis-graph-every-worker.js @@ -0,0 +1,408 @@ +/* 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; + + 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; + const group = node.community_id !== undefined && node.community_id !== null + ? String(node.community_id) : String(index); + 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 = []; + 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 || "")); + 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, + 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; + 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 }, + 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; + 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, + edgeWeights: model.weights, + edgeRelations: model.relations, + 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..1ea156fb --- /dev/null +++ b/engraphis/dashboard_assets/engraphis-graph-every.js @@ -0,0 +1,1412 @@ +/* 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=20260822-every-17'; + const MAX_NODES = 20000; + const MAX_LINKS = 200000; + const LABEL_MAX = 220; + const FLOW_EDGE_LIMIT = 900; + 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; + 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; + 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; + }`; + const EDGE_FS = `#version 300 es + precision mediump float; + in float v_factor; + uniform float u_edgeAlpha; uniform float u_focusFade; uniform float u_weightFloor; + out vec4 outputColor; + void main(){ + 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'; + canvas.setAttribute('aria-hidden', 'true'); + labels.setAttribute('aria-hidden', 'true'); + underlay.setAttribute('aria-hidden', 'true'); + /* 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'); + /* Screen-reader surface: the canvases are decorative; the live region announces the + scene summary and hovered entity, and the host carries a descriptive label. */ + const liveRegion = document.createElement('div'); + liveRegion.className = 'sr-only'; + liveRegion.setAttribute('aria-live', 'polite'); + + 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), + edgeRelations: [], + 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, ready: false, visibleCount: 0, + frame: 0, labelFrame: 0, flowPaintAt: 0, layoutPending: false, lastLabelKey: '', + 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(); + 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'), + 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 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; + uploadNodeMeta(); + 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); + 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]; + 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); + 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; + } + function drawRegions() { + if (!underlayContext || !state.ready || !state.communityRegions.length) 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; + const reach = pointSize(best) * state.camera.scale + 7; + 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 anchorSet = new Set(anchors); + const showRelation = zoomRatio() > 0.55; + labelContext.save(); + labelContext.strokeStyle = "rgba(244,211,127,0.9)"; + labelContext.fillStyle = "rgba(244,211,127,0.9)"; + for (let edge = 0; edge < state.totalLinks; edge += 1) { + const source = state.edgeSources[edge], target = state.edgeTargets[edge]; + if (!anchorSet.has(source) && !anchorSet.has(target)) continue; + 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; + 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. */ + let connections = []; + if (state.neighbors && state.neighbors[index]) { + connections = state.neighbors[index] + .slice().sort((a, b) => (state.degrees[b] || 0) - (state.degrees[a] || 0)) + .slice(0, 3); + } + 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 (!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}`; + if (cacheKey === state.lastLabelKey) return; + state.lastLabelKey = cacheKey; + 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)'; + 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); + 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); + for (const neighbor of state.neighbors[anchor]) { + if (drawn >= LABEL_MAX) break; + consider(neighbor); + } + } + for (let rank = 0; rank < state.topNodes.length && 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); + } + 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; + 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); + 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') { + 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.edgeWeights = message.edgeWeights || new Float32Array(0); + state.edgeRelations = message.edgeRelations || []; + 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(() => []); + 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); + if (state.neighbors[target]) state.neighbors[target].push(source); + } + } + 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') { + state.layoutPending = false; + if (!state.ready) return; + applyLayout(message.positions, message.bounds, message.fit === true); + stats({ layoutPending: false }); + 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].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.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 = ''; + 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; + }, + 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; }, + setLayers(value) { state.layers = value || null; 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 1c682df9..00000000 --- a/engraphis/dashboard_assets/engraphis-graph-worker.js +++ /dev/null @@ -1,421 +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 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, - 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, 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, - ].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 repel = Math.max(0, finite(settings.repel, 48)), link = Math.max(1, finite(settings.link, 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). */ - const repelSpread = 0.58 + repel / 72; - const gravityTightening = 1 / (0.72 + gravity / 128 + galacticGravity * blackHoleMass * 0.05); - const spaceSpread = 0.86 + localGravity * 0.07 - Math.min(2, damping) * 0.035; - const spread = modeScale * clamp(repelSpread * gravityTightening * spaceSpread, 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') { const radius = Math.hypot(x, y) * spread, angle = Math.atan2(y, x) + radius * 0.0007; x = Math.cos(angle) * radius; y = Math.sin(angle) * radius * 0.72; } - else { x *= spread; y *= spread; } - if (state.layoutRevision) { - 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(10 + link * 1.25, 14, 112); - const springForce = clamp(0.025 + spring * 0.018, 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((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), 0)); - evidenceMass[index] = Math.max(0, finite(node && (node.evidence_mass || node.evidenceMass || 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.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 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' && scale < 0.42)); - if (scale < 0.42) { - const values = allVisibleNodes(); - setCollapsed(shouldCollapse); - return shouldCollapse ? collapseRepresentatives(values) : values; - } - const width = Math.max(1, finite(camera && camera.width, 1)), 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), maxCellX = Math.floor((finite(camera && camera.x, 0) + halfWidth) / CELL_SIZE); - const minCellY = Math.floor((finite(camera && camera.y, 0) - halfHeight) / CELL_SIZE), maxCellY = Math.floor((finite(camera && camera.y, 0) + halfHeight) / CELL_SIZE); - if (maxCellX - minCellX > 256 || maxCellY - minCellY > 256) { - const values = allVisibleNodes(); - setCollapsed(shouldCollapse); - return shouldCollapse ? collapseRepresentatives(values) : values; - } - 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); - }); - } - const values = new Uint32Array(result); - setCollapsed(shouldCollapse); - return shouldCollapse ? collapseRepresentatives(values) : values; - } - function visibleEdges(camera, nodes) { - const scale = Math.max(0.01, finite(camera && camera.scale, 1)); - const limit = scale < 0.42 ? LOW_ZOOM_EDGE_LIMIT : scale < 1.1 ? (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 (finite(camera && camera.scale, 1) < 0.9) 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 nextKey = cameraKey(message); if (nextKey === state.lastCameraKey) 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', nodes, edges, labels, edgePositions, - totalLinks: state.edgeSources.length, drawnLinks: edges.length, - visibleNodeCount: nodes.length, collapsed: state.collapsed }, - [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 f5a5bb77..5cd61bd6 100644 --- a/engraphis/dashboard_assets/index.html +++ b/engraphis/dashboard_assets/index.html @@ -273,7 +273,7 @@

How this workspace connects

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

Layout

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

Connect to this Engraphis deployment

Graph connections

Connected nodes

+

diff --git a/engraphis/dashboard_assets/ledger.js b/engraphis/dashboard_assets/ledger.js index 26d1d9e1..85cc887b 100644 --- a/engraphis/dashboard_assets/ledger.js +++ b/engraphis/dashboard_assets/ledger.js @@ -174,6 +174,7 @@ radial: 'Radial', constellation: 'Constellation', galaxy: 'Galaxy gravity', + every: 'Every node', }; const GRAPH_STYLE_NOTES = { cyber: 'Iridescent PVD over graphite — cyan, violet, and magenta across each node.', @@ -417,12 +418,12 @@ } function ensureGraphAllAsset() { - if (window.EngraphisAllGraph) return Promise.resolve(); + if (window.EngraphisEveryGraph) return Promise.resolve(); if (!graphAllAssetsPromise) { const controller = new AbortController(); const attempt = loadScript( - graphAssetSource('/v2-assets/engraphis-graph-all.js?v=20260814-all-controls-2'), - 'EngraphisAllGraph', controller.signal, + graphAssetSource('/v2-assets/engraphis-graph-every.js?v=20260822-every-18'), + 'EngraphisEveryGraph', controller.signal, ); graphAllAssetsPromise = attempt; graphAllAssetsController = controller; @@ -2234,6 +2235,10 @@ function openGraphConnections(item) { if (!item || !item.id) return; cancelGraphConnectionMemoryLoad(); + state.graphConnectionsFocusId = String(item.id); + state.graphConnectionsFocusLabel = item.name || item.label || item.id; + const focusButton = byId('graph-connections-focus'); + if (focusButton) focusButton.hidden = !state.graphEngine; const dialog = byId('graph-connections-dialog'); const entries = graphConnectionEntries(item); const title = item.name || item.label || item.id; @@ -2292,7 +2297,7 @@ ['graph-min-degree', 'graph-tune-min-degree', 'graph-collapse', 'graph-depth', 'graph-show-unlinked', 'graph-flow', 'graph-flow-speed', 'graph-orbits-pause'].forEach(id => { const control = byId(id); - if (control) control.disabled = false; + if (control) { control.disabled = false; control.title = ''; } }); all('[data-graph-layer="code"]').forEach(control => { control.disabled = false; @@ -2300,6 +2305,16 @@ ? 'Choose an exact repository first, then add its code overlay within the All-node capacity.' : ''; }); + /* Focus-depth and auto-collapse are not implemented in the Every-node engine yet. + Disable them honestly instead of leaving controls that silently do nothing. */ + if (full && byId('graph-preset').value === 'every') { + ['graph-collapse', 'graph-depth'].forEach(id => { + const control = byId(id); + if (!control) return; + control.disabled = true; + control.title = 'Not yet available in the Every-node view.'; + }); + } const lodNote = byId('graph-lod-note'); if (lodNote) lodNote.hidden = !full; byId('graph-reheat').textContent = full ? 'Reflow layout' : 'Reheat layout'; @@ -3090,7 +3105,7 @@ if (!fullGraph && (!window.ForceGraph || !window.EngraphisGraph || !window.EngraphisSpacetime)) { releaseGraphAssetsAttempt(graphAssetsPromise); } - if (fullGraph && !window.EngraphisAllGraph) { + if (fullGraph && !window.EngraphisEveryGraph) { releaseGraphAllAssetsAttempt(graphAllAssetsPromise); } if (!controller.signal.aborted) controller.abort(); @@ -3172,7 +3187,7 @@ && (node.system_anchor_id !== undefined || Number.isFinite(Number(node.galactic_radius)))); const graphFactory = galaxyQuality ? window.EngraphisGraph - : fullGraph ? window.EngraphisAllGraph : window.EngraphisGraph; + : fullGraph ? window.EngraphisEveryGraph : window.EngraphisGraph; if (!graphFactory || typeof graphFactory.create !== 'function') { throw new Error(fullGraph ? galaxyQuality ? 'Galaxy graph engine is unavailable' @@ -4397,6 +4412,43 @@ }); all('[data-graph-preset-choice]').forEach(control => control.addEventListener('click', () => { const preset = control.dataset.graphPresetChoice; + /* Every node is its own presentation: selecting it loads the complete LOD scene, and + any named layout leaves it. The old Show-all toggle stays in the DOM for state + restore compatibility but is hidden from the toolbar. */ + if (preset === 'every' || state.graphMode === 'full') { + byId('graph-preset').value = preset; + clearGraphSavedView(); + syncGraphChoices(); + saveGraphPreferences(); + const wantedMode = preset === 'every' ? 'full' : 'overview'; + if (wantedMode === 'full') { + /* Every node means every node: entering the map clears the unlinked/degree + filters that the overview uses, but remembers them so leaving restores the + person's overview exactly as they had configured it. */ + if (!state.everyPriorFilters) { + state.everyPriorFilters = { + minDegree: Number(byId('graph-min-degree').value) || 0, + unlinked: byId('graph-show-unlinked').getAttribute('aria-pressed') === 'true', + }; + } + setGraphMinDegree(0, false); + setGraphShowUnlinked(true, false); + } else if (state.everyPriorFilters) { + const prior = state.everyPriorFilters; + state.everyPriorFilters = null; + setGraphMinDegree(prior.minDegree, false); + setGraphShowUnlinked(prior.unlinked, false); + } + if (state.graphMode !== wantedMode) { + cancelGraphRepositoryReload(); + state.graphMode = wantedMode; + updateGraphModeControls(); + loadGraph({ force: true }); + } else if (preset === 'every' && state.graphEngine && state.graphEngine.setPreset) { + state.graphEngine.setPreset('every'); + } + return; + } const resumeLayout = state.graphFrozen; byId('graph-preset').value = preset; if (state.graphEngine && resumeLayout) { @@ -4554,6 +4606,12 @@ exportGraphJson(); }); byId('graph-connections-close').addEventListener('click', closeGraphConnections); + byId('graph-connections-focus').addEventListener('click', () => { + const id = state.graphConnectionsFocusId; + if (!id) return; + closeGraphConnections(); + revealGraphNode(id, state.graphConnectionsFocusLabel || 'Selected entity'); + }); byId('graph-connections-dialog').addEventListener('click', event => { if (event.target === event.currentTarget) closeGraphConnections(); }); diff --git a/engraphis/static/dashboard.js b/engraphis/static/dashboard.js index 549110af..01e3c50d 100644 --- a/engraphis/static/dashboard.js +++ b/engraphis/static/dashboard.js @@ -1227,7 +1227,7 @@ function loadAllGraphEngine(){ if(typeof EngraphisAllGraph!=='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-all.js?v=20260814-all-controls-2'; + const script=document.createElement('script');script.src='/v2-assets/engraphis-graph-every.js?v=20260822-every-18'; script.onload=()=>{typeof EngraphisAllGraph==='undefined'?reject(new Error('All-node graph asset loaded without registering EngraphisAllGraph')):resolve()}; script.onerror=()=>reject(new Error('All-node graph asset could not load')); document.head.appendChild(script); diff --git a/scripts/externalize_dashboard_assets.py b/scripts/externalize_dashboard_assets.py index 44680d24..80d5c514 100644 --- a/scripts/externalize_dashboard_assets.py +++ b/scripts/externalize_dashboard_assets.py @@ -27,8 +27,8 @@ V2_ASSETS = ROOT / "engraphis" / "dashboard_assets" EXTRA_SCRIPTS = ( V2_ASSETS / "engraphis-graph.js", - V2_ASSETS / "engraphis-graph-all.js", - V2_ASSETS / "engraphis-graph-worker.js", + V2_ASSETS / "engraphis-graph-every.js", + V2_ASSETS / "engraphis-graph-every-worker.js", V2_ASSETS / "engraphis-spacetime.js", ) LAZY_LOADER_SCRIPTS = (V2_ASSETS / "ledger.js",) @@ -42,7 +42,7 @@ DEFERRED_SCRIPTS = ( "/static/vendor/force-graph.min.js", "/v2-assets/engraphis-graph.js", - "/v2-assets/engraphis-graph-all.js", + "/v2-assets/engraphis-graph-every.js", "/v2-assets/engraphis-spacetime.js", ) From 7a1f2520711947519c2dfe87f387d8ee9abe5a91 Mon Sep 17 00:00:00 2001 From: "Max \"plainskill\" Luecke" Date: Sat, 22 Aug 2026 23:29:19 -0700 Subject: [PATCH 02/21] test(graph): pin the Every-node engine contract Replace the deleted all-asset suite with tests/test_graph_every_asset.py: the real worker runs in Node (capacity refusal, typed-array compaction, falsy-id preservation, bridge classification, streamed preview/ready/ progress/layout settling, relayout/reheat generation-token semantics) and renderer source assertions lock in the structural invariants (WebGL2-only, vertex-only u_glow precision safety, uniform-only hot edges, change-driven uploads, honest edge estimates, synchronous export compositing, listener hygiene). Legacy routing/latch/CSP expectations follow the new asset URL. --- tests/e2e/graph-all-performance.spec.js | 124 ---------- tests/e2e/ledger.spec.js | 2 +- tests/test_graph_all_asset.py | 306 ------------------------ tests/test_graph_engine_asset.py | 6 +- tests/test_graph_every_asset.py | 232 ++++++++++++++++++ 5 files changed, 236 insertions(+), 434 deletions(-) delete mode 100644 tests/e2e/graph-all-performance.spec.js delete mode 100644 tests/test_graph_all_asset.py create mode 100644 tests/test_graph_every_asset.py diff --git a/tests/e2e/graph-all-performance.spec.js b/tests/e2e/graph-all-performance.spec.js deleted file mode 100644 index 821f760b..00000000 --- a/tests/e2e/graph-all-performance.spec.js +++ /dev/null @@ -1,124 +0,0 @@ -const { test, expect } = require('@playwright/test'); - -test('All-node controls filter, collapse, reflow, freeze, and expose directional flow', async ({ page }) => { - await page.goto('/'); - await page.addScriptTag({ url: '/v2-assets/engraphis-graph-all.js?v=20260814-all-controls-2' }); - const result = await page.evaluate(async () => { - const host = document.createElement('div'); - host.style.cssText = 'position:fixed;inset:20px;width:900px;height:600px'; - document.body.append(host); - const nodes = [ - { id: 'a', community_id: 'one' }, { id: 'b', community_id: 'one' }, - { id: 'c', community_id: 'one' }, { id: 'd', community_id: 'two' }, - { id: 'e', community_id: 'two' }, { id: 'lonely' }, - ]; - const links = [ - { source: 'a', target: 'b', weight: 3 }, - { source: 'b', target: 'c', weight: 2 }, - { source: 'd', target: 'e', weight: 1 }, - ]; - const engine = window.EngraphisAllGraph.create(host, { reducedMotion: () => true }); - const waitFor = async predicate => { - const deadline = Date.now() + 6000; - while (!predicate() && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, 25)); - if (!predicate()) throw new Error(`All-node state did not settle: ${JSON.stringify(engine.state())}`); - }; - engine.setPreset('radial'); - engine.setColorBy('type'); - engine.setSettings({ flow: true, flowSpeed: 73, repel: 82, link: 34, gravity: 27 }); - engine.setScope({ minDegree: 2, showUnlinked: false, depth: 1 }); - engine.setCollapse(false); - engine.setData({ nodes, links }); - await waitFor(() => engine.state().nodeCount === 6 && engine.state().visibleNodeCount === 1); - const filtered = engine.state(); - engine.setScope({ minDegree: 0, showUnlinked: true, depth: 2 }); - await waitFor(() => engine.state().visibleNodeCount === 6); - const before = engine.getPhysicsSnapshot().nodes.map(node => [node.id, node.x, node.y]); - engine.reheat(); - await waitFor(() => engine.getPhysicsSnapshot().nodes.some((node, index) => - node.x !== before[index][1] || node.y !== before[index][2])); - const canvas = host.querySelector('.engraphis-all-canvas'); - engine.setCollapse('auto'); - for (let index = 0; index < 12; index += 1) { - canvas.dispatchEvent(new WheelEvent('wheel', { - deltaY: 450, clientX: 450, clientY: 300, bubbles: true, cancelable: true, - })); - } - await waitFor(() => engine.state().collapsed === true); - const collapsed = engine.state(); - engine.setCollapse(false); - await waitFor(() => engine.state().collapsed === false); - engine.freeze(true); - const frozenBefore = engine.getPhysicsSnapshot().nodes.map(node => [node.x, node.y]); - engine.reheat(); - await new Promise(resolve => setTimeout(resolve, 80)); - const frozenAfter = engine.getPhysicsSnapshot().nodes.map(node => [node.x, node.y]); - const final = engine.state(); - engine.destroy(); host.remove(); - return { filtered, collapsed, final, frozenBefore, frozenAfter }; - }); - expect(result.filtered.visibleNodeCount).toBe(1); - expect(result.filtered.relationFlow).toBe(true); - expect(result.filtered.flowSpeed).toBe(73); - expect(result.collapsed.visibleNodeCount).toBe(3); - expect(result.final.collapsed).toBe(false); - expect(result.final.frozen).toBe(true); - expect(result.frozenAfter).toEqual(result.frozenBefore); -}); - -/* Release-style browser fixture for the supported mid-range WebGL2 target. It is intentionally - synthetic: the API contract tests own server capacity, while this test isolates handoff, - worker preparation, LOD painting, and interaction without needing a 20k-row database fixture. */ -test('20k-node all profile paints progressively and stays responsive after handoff', async ({ page }) => { - await page.goto('/'); - const gpu = await page.evaluate(() => { - const gl = document.createElement('canvas').getContext('webgl2'); - if (!gl) return { supported: false, renderer: '' }; - const debug = gl.getExtension('WEBGL_debug_renderer_info'); - return { supported: true, renderer: debug ? String(gl.getParameter(debug.UNMASKED_RENDERER_WEBGL) || '') : '' }; - }); - test.skip(!gpu.supported || /swiftshader|llvmpipe|software renderer/i.test(gpu.renderer), 'All-node performance target requires hardware-accelerated WebGL2'); - await page.addScriptTag({ url: '/v2-assets/engraphis-graph-all.js?v=20260814-all-controls-2' }); - const result = await page.evaluate(async () => { - const host = document.createElement('div'); - host.className = 'graph-network'; - host.setAttribute('aria-label', '20k-node performance fixture'); - document.body.append(host); - const nodes = Array.from({ length: 20000 }, (_value, index) => ({ - id: `n-${index}`, label: `Node ${index}`, community_id: `c-${index % 32}`, - })); - const links = Array.from({ length: 200000 }, (_value, index) => ({ - source: `n-${index % 20000}`, target: `n-${(index * 17 + 1) % 20000}`, weight: (index % 100) + 1, - })); - const progressive = []; - const engine = window.EngraphisAllGraph.create(host, { onStats: stats => progressive.push({ nodes: stats.nodes, links: stats.links, pending: stats.linksPending, drawn: stats.drawnLinks }) }); - engine.setData({ nodes, links }); - /* The object-to-worker postMessage is the explicit initial handoff. Measure the - interaction/rendering phase after that handoff, not the one-time structured clone - of the server-shaped fixture payload. */ - const longTasks = []; - const observer = typeof PerformanceObserver === 'function' ? new PerformanceObserver(list => { - list.getEntries().forEach(entry => longTasks.push(entry.duration)); - }) : null; - if (observer) { try { observer.observe({ type: 'longtask', buffered: false }); } catch (_error) {} } - const deadline = Date.now() + 30000; - while ((!progressive.some(item => item.pending) || !progressive.some(item => item.links === 200000)) && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, 25)); - const canvas = host.querySelector('.engraphis-all-canvas'); - for (let index = 0; index < 18; index += 1) { - canvas.dispatchEvent(new WheelEvent('wheel', { deltaY: index % 2 ? 180 : -180, clientX: 400 + index, clientY: 240, bubbles: true, cancelable: true })); - canvas.dispatchEvent(new PointerEvent('pointermove', { clientX: 400 + index * 2, clientY: 240 + index, bubbles: true })); - } - engine.reveal('n-10000'); - await new Promise(resolve => setTimeout(resolve, 700)); - if (observer) observer.disconnect(); - const settled = progressive.at(-1) || {}; - engine.destroy(); host.remove(); - return { progressive, settled, longTasks, webgl: true }; - }); - expect(result.progressive.some(item => item.pending)).toBeTruthy(); - expect(result.progressive.some(item => item.nodes === 20000 && item.links === 200000)).toBeTruthy(); - expect(result.settled.nodes).toBe(20000); - expect(result.settled.links).toBe(200000); - expect(result.settled.drawn).toBeLessThanOrEqual(75000); - expect(result.longTasks.filter(duration => duration > 50)).toEqual([]); -}); diff --git a/tests/e2e/ledger.spec.js b/tests/e2e/ledger.spec.js index 07fbd14a..2d7a0cec 100644 --- a/tests/e2e/ledger.spec.js +++ b/tests/e2e/ledger.spec.js @@ -397,7 +397,7 @@ test('Ledger enters All nodes from a loaded overview without losing its scope', const allAssetRequests = []; page.on('request', request => { const pathname = new URL(request.url()).pathname; - if (pathname.endsWith('/v2-assets/engraphis-graph-all.js')) allAssetRequests.push(request.url()); + if (pathname.endsWith('/v2-assets/engraphis-graph-every.js')) allAssetRequests.push(request.url()); }); const requests = await mockApi(page); await page.goto('/'); diff --git a/tests/test_graph_all_asset.py b/tests/test_graph_all_asset.py deleted file mode 100644 index 1a5e7473..00000000 --- a/tests/test_graph_all_asset.py +++ /dev/null @@ -1,306 +0,0 @@ -"""Focused contract tests for the worker-backed all-node graph profile.""" -from __future__ import annotations - -import json -import subprocess -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[1] -WORKER = ROOT / "engraphis" / "dashboard_assets" / "engraphis-graph-worker.js" -RENDERER = ROOT / "engraphis" / "dashboard_assets" / "engraphis-graph-all.js" -LEDGER = ROOT / "engraphis" / "dashboard_assets" / "ledger.js" -MARKUP = ROOT / "engraphis" / "dashboard_assets" / "index.html" -STYLES = ROOT / "engraphis" / "dashboard_assets" / "ledger.css" - - -def _run_worker(nodes, links): - source = json.dumps(WORKER.read_text(encoding="utf-8")) - payload = json.dumps({"nodes": nodes, "links": links}) - script = f""" -const vm = require('vm'); const messages = []; -const context = {{ self: {{ postMessage: (message) => messages.push(message) }} }}; -vm.runInNewContext({source}, context); -context.self.onmessage({{ data: {{ type: 'prepare', payload: {payload} }} }}); -const ready = messages.find(message => message.type === 'ready'); -context.self.onmessage({{ data: {{ type: 'camera', x: 0, y: 0, scale: 0.2, width: 1200, height: 800 }} }}); -const low = messages.filter(message => message.type === 'visible').at(-1); -context.self.onmessage({{ data: {{ type: 'camera', x: 0, y: 0, scale: 0.8, width: 1200, height: 800 }} }}); -const medium = messages.filter(message => message.type === 'visible').at(-1); -context.self.onmessage({{ data: {{ type: 'camera', x: 0, y: 0, scale: 1.5, width: 1200, height: 800 }} }}); -const high = messages.filter(message => message.type === 'visible').at(-1); -context.self.onmessage({{ data: {{ type: 'hit', request: 9, x: 0, y: 0, scale: 1 }} }}); -const hit = messages.filter(message => message.type === 'hit').at(-1); -console.log(JSON.stringify({{ready: {{nodes: ready.totalNodes, links: ready.totalLinks, ids: ready.ids, positions: ready.positions.constructor.name, edges: ready.edgeSources.constructor.name}}, lod: {{low: low.drawnLinks, medium: medium.drawnLinks, high: high.drawnLinks}}, hit: hit.index}})); -""" - result = subprocess.run(["node", "-e", script], cwd=ROOT, check=True, capture_output=True, text=True) - return json.loads(result.stdout) - - -def test_all_worker_compacts_identity_builds_typed_arrays_and_hits_spatial_index(): - nodes = [{"id": f"n-{index}", "name": f"Node {index}"} for index in range(8)] - links = [{"source": "n-0", "target": f"n-{index}", "weight": index + 1} for index in range(1, 8)] - result = _run_worker(nodes, links) - assert result["ready"] == {"nodes": 8, "links": 7, "ids": [f"n-{index}" for index in range(8)], "positions": "Float32Array", "edges": "Uint32Array"} - assert result["lod"]["low"] == 0 - assert result["lod"]["medium"] <= 7 and result["lod"]["high"] <= 7 - assert result["hit"] >= 0 - - -def test_all_renderer_is_flat_worker_webgl_and_not_a_live_force_simulation(): - worker = WORKER.read_text(encoding="utf-8") - renderer = RENDERER.read_text(encoding="utf-8") - assert "new Worker" in renderer and "webgl2" in renderer - assert "Uint32Array" in renderer and "Float32Array" in renderer - assert "forceSimulation" not in renderer and "force-graph" not in renderer - assert "createRadialGradient" not in renderer and "shadowBlur" not in renderer - assert "new Map" in worker and "state.grid" in worker - assert "MEDIUM_ZOOM_EDGE_LIMIT" in worker and "HIGH_ZOOM_EDGE_LIMIT" in worker - assert "CANVAS_MEDIUM_ZOOM_EDGE_LIMIT" in worker and "CANVAS_HIGH_ZOOM_EDGE_LIMIT" in worker - assert "adjacencyOffsets" in worker and "edgePositions" in worker - assert "lastCameraKey" in worker and "edgePositions.buffer" in worker - assert "for (let index = 0; index < state.ids.length; index += 1) if (inViewport" not in worker - - -def test_all_renderer_declares_capacity_and_progressive_lod_profile(): - renderer = RENDERER.read_text(encoding="utf-8") - worker = WORKER.read_text(encoding="utf-8") - assert "MAX_NODES = 20000" in renderer and "MAX_NODES = 20000" in worker - assert "All nodes · LOD" in renderer and "linksPending" in renderer - assert "type: 'preview'" in worker and "type: 'capacity'" in worker - - -def test_all_renderer_keeps_controls_live_and_clears_transient_hover_paint(): - renderer = RENDERER.read_text(encoding="utf-8") - worker = WORKER.read_text(encoding="utf-8") - for marker in ( - "setPreset(value)", "setSettings(value)", "setLayers(value)", - "setBridges(value)", "setGhosts(value)", "freeze(value = true)", - "getPhysicsSnapshot()", "graphToScreen(x, y)", - ): - assert marker in renderer - assert "type: 'settings'" in renderer and "message.type === 'settings'" in worker - assert "setBridges(value)" in renderer and "type: 'ghosts'" in renderer - assert "message.type === 'bridges'" in worker and "message.type === 'ghosts'" in worker - assert "function drawLabels(clear = false, now = 0)" in renderer - assert "function clearHover()" in renderer and "pointerout" in renderer - - -def test_worker_preserves_falsy_endpoint_ids_and_filters_ghost_nodes(): - source = json.dumps(WORKER.read_text(encoding="utf-8")) - payload = json.dumps({ - "nodes": [ - {"id": 0, "x": 0, "y": 0}, - {"id": False, "x": 40, "y": 0, "ghost": True}, - {"id": "leaf", "x": 80, "y": 0}, - ], - "links": [ - {"source": 0, "target": False}, - {"source": {"id": False}, "target": "leaf"}, - ], - }) - script = f""" -const vm = require('vm'); const messages = []; -const context = {{ self: {{ postMessage: message => messages.push(message) }} }}; -vm.runInNewContext({source}, context); -context.self.onmessage({{ data: {{ type: 'prepare', payload: {payload} }} }}); -const ready = messages.find(message => message.type === 'ready'); -context.self.onmessage({{ data: {{ type: 'ghosts', value: false }} }}); -context.self.onmessage({{ data: {{ type: 'camera', x: 40, y: 0, scale: 0.2, - width: 1200, height: 800 }} }}); -const visible = messages.filter(message => message.type === 'visible').at(-1); -context.self.onmessage({{ data: {{ type: 'camera', x: 40, y: 0, scale: 0.8, - width: 100000, height: 100000 }} }}); -const wide = messages.filter(message => message.type === 'visible').at(-1); -context.self.onmessage({{ data: {{ type: 'hit', request: 4, x: 40, y: 0, scale: 1 }} }}); -const hit = messages.filter(message => message.type === 'hit').at(-1); -console.log(JSON.stringify({{ - ids: ready.ids, links: ready.totalLinks, ghosts: Array.from(ready.nodeGhosts), - visible: Array.from(visible.nodes).map(index => ready.ids[index]), - wide: Array.from(wide.nodes).map(index => ready.ids[index]), - hit: hit.index < 0 ? null : ready.ids[hit.index], -}})); -""" - result = subprocess.run( - ["node", "-"], cwd=ROOT, check=True, capture_output=True, text=True, input=script, - ) - report = json.loads(result.stdout) - assert report["ids"] == ["0", "false", "leaf"] - assert report["links"] == 2 - assert report["ghosts"] == [0, 1, 0] - assert report["visible"] == ["0", "leaf"] - assert report["wide"] == ["0", "leaf"] - assert report["hit"] != "false" - - -def test_all_renderer_batches_colors_throttles_hits_composites_and_releases_gpu(): - renderer = RENDERER.read_text(encoding="utf-8") - assert "in vec3 a_color" in renderer - assert "state.nodeColors" in renderer and "nodeColor(index)" in renderer - assert "state.edgeColors.length < edges.length * 6" in renderer - assert "state.edgeColors.subarray(0, edges.length * 6)" in renderer - assert "state.edgeColors[offset + 5]" in renderer - assert "pendingHit" in renderer and "if (hitFrame) return" in renderer - assert "context.drawImage(canvas, 0, 0)" in renderer - assert "context.drawImage(labels, 0, 0)" in renderer - assert "destroy: destroyGraph" in renderer - assert "state.nodeGhosts = message.nodeGhosts || state.nodeGhosts" in renderer - assert renderer.count("setVisibleNodes(drawableNodeIndices())") >= 3 - assert "const visible = state.visibleNodes, compact" in renderer - assert "worker.onmessage = handleWorkerMessage" in renderer - assert "const handleWorkerMessage = worker.onmessage" not in renderer - assert "gl.deleteBuffer(buffer)" in renderer - assert "gl.deleteProgram(value)" in renderer - assert "WEBGL_lose_context" in renderer - assert "handleWorkerFailure" in renderer - assert "worker.addEventListener('error', handleWorkerFailure)" in renderer - assert "worker.addEventListener('messageerror', handleWorkerFailure)" in renderer - assert "worker.removeEventListener('error', handleWorkerFailure)" in renderer - assert "error.code || 'GRAPH_WORKER'" in renderer - assert "if (state.ready) camera(); else schedule();" in renderer - # Canvas fallback must retain the same Highlight bridges semantics as WebGL. - assert "state.bridges && state.edgeBridges[edge]" in renderer - assert "rgba(244,211,127" in renderer - - -def test_all_worker_applies_scope_depth_layers_and_auto_collapse_without_reloading(): - source = json.dumps(WORKER.read_text(encoding="utf-8")) - payload = json.dumps({ - "nodes": [ - {"id": "a", "community_id": "one"}, - {"id": "b", "community_id": "one"}, - {"id": "c", "community_id": "one"}, - {"id": "d", "community_id": "two"}, - {"id": "e", "community_id": "two"}, - {"id": "lonely"}, - ], - "links": [ - {"source": "a", "target": "b", "layer": "semantic", "weight": 3}, - {"source": "b", "target": "c", "layer": "semantic", "weight": 2}, - {"source": "d", "target": "e", "layer": "temporal", "weight": 1}, - ], - }) - script = f""" -const vm = require('vm'); const messages = []; -const context = {{ self: {{ postMessage: message => messages.push(message) }} }}; -vm.runInNewContext({source}, context); -const send = data => context.self.onmessage({{ data }}); -const latest = type => messages.filter(message => message.type === type).at(-1); -send({{ type: 'prepare', payload: {payload} }}); -const ready = latest('ready'); -const ids = values => Array.from(values).map(index => ready.ids[index]); -send({{ type: 'scope', scope: {{ minDegree: 2, showUnlinked: false, depth: 1 }} }}); -send({{ type: 'camera', x: 0, y: 0, scale: 1.5, width: 100000, height: 100000 }}); -const filtered = latest('visible'); -send({{ type: 'scope', scope: {{ minDegree: 0, showUnlinked: true, depth: 1 }} }}); -send({{ type: 'collapse', value: 'auto' }}); -send({{ type: 'camera', x: 0, y: 0, scale: 0.2, width: 100000, height: 100000 }}); -const collapsed = latest('visible'); -send({{ type: 'focus', index: ready.ids.indexOf('a') }}); -send({{ type: 'camera', x: 0, y: 0, scale: 1.5, width: 100000, height: 100000 }}); -const depthOne = latest('visible'); -send({{ type: 'scope', scope: {{ minDegree: 0, showUnlinked: true, depth: 2 }} }}); -send({{ type: 'camera', x: 1, y: 0, scale: 1.5, width: 100000, height: 100000 }}); -const depthTwo = latest('visible'); -send({{ type: 'layers', layers: {{ semantic: false, temporal: true }} }}); -send({{ type: 'camera', x: 2, y: 0, scale: 1.5, width: 100000, height: 100000 }}); -const layered = latest('visible'); -console.log(JSON.stringify({{ - filtered: ids(filtered.nodes), filteredEdges: filtered.drawnLinks, - collapsed: ids(collapsed.nodes), isCollapsed: collapsed.collapsed, - depthOne: ids(depthOne.nodes), depthTwo: ids(depthTwo.nodes), - layeredEdges: layered.drawnLinks, -}})); -""" - result = subprocess.run( - ["node", "-e", script], cwd=ROOT, check=True, capture_output=True, text=True, - ) - report = json.loads(result.stdout) - assert report["filtered"] == ["b"] - assert report["filteredEdges"] == 0 - assert report["isCollapsed"] is True - assert set(report["collapsed"]) == {"b", "d", "lonely"} - assert report["depthOne"] == ["a", "b"] - assert report["depthTwo"] == ["a", "b", "c"] - assert report["layeredEdges"] == 0 - - -def test_all_worker_force_controls_and_reheat_change_the_settled_layout(): - source = json.dumps(WORKER.read_text(encoding="utf-8")) - payload = json.dumps({ - "nodes": [{"id": f"n-{index}", "community_id": f"c-{index % 3}"} - for index in range(18)], - "links": [{"source": f"n-{index}", "target": f"n-{index + 1}", "weight": 2} - for index in range(17)], - }) - script = f""" -const vm = require('vm'); const messages = []; -const context = {{ self: {{ postMessage: message => messages.push(message) }} }}; -vm.runInNewContext({source}, context); -const send = data => context.self.onmessage({{ data }}); -const latest = type => messages.filter(message => message.type === type).at(-1); -const extent = positions => {{ - const xs = [], ys = []; - for (let index = 0; index < positions.length; index += 2) {{ xs.push(positions[index]); ys.push(positions[index + 1]); }} - return Math.max(...xs) - Math.min(...xs) + Math.max(...ys) - Math.min(...ys); -}}; -send({{ type: 'prepare', payload: {payload} }}); -send({{ type: 'settings', settings: {{ mode: 'communities', repel: 120, link: 80, - gravity: 0, springStiffness: 1, damping: 1 }}, relayout: true }}); -const loose = latest('layout'); -send({{ type: 'settings', settings: {{ repel: 0, link: 4, gravity: 400, - springStiffness: 1, damping: 1 }}, relayout: true }}); -const tight = latest('layout'); -send({{ type: 'reheat' }}); -const reheated = latest('layout'); - console.log(JSON.stringify({{ - loose: extent(loose.positions), tight: extent(tight.positions), - changed: Array.from(tight.positions).some((value, index) => value !== reheated.positions[index]), - }})); - """ - result = subprocess.run( - ["node", "-"], cwd=ROOT, check=True, capture_output=True, text=True, input=script, - ) - report = json.loads(result.stdout) - assert report["loose"] > report["tight"] * 1.3 - assert report["changed"] is True - - -def test_all_renderer_has_bounded_directional_flow_and_worker_control_messages(): - renderer = RENDERER.read_text(encoding="utf-8") - worker = WORKER.read_text(encoding="utf-8") - styles = STYLES.read_text(encoding="utf-8") - assert "FLOW_EDGE_LIMIT = 900" in renderer - assert "FLOW_FRAME_MS = 34" in renderer - assert "function drawRelationFlow(now)" in renderer - assert "prefers-reduced-motion: reduce" in renderer - assert "type: 'scope'" in renderer and "message.type === 'scope'" in worker - assert "type: 'collapse'" in renderer and "message.type === 'collapse'" in worker - assert "type: 'reheat'" in renderer and "message.type === 'reheat'" in worker - assert "MAX_LINKS = 200000" in worker - assert "state.lastVisibleMask" in worker - # The host owns the style surface so every theme, including Paper, retains - # a high-contrast graph well behind the transparent WebGL canvases. - assert "element.setAttribute('data-graph-style', opts.style || 'cyber')" in renderer - assert "element.setAttribute('data-graph-style', state.styleName)" in renderer - assert "element.removeAttribute('data-graph-style')" in renderer - assert 'body[data-theme="paper"] .graph-header' in styles - - -def test_ledger_routes_every_shared_sidebar_control_to_the_dedicated_all_renderer(): - ledger = LEDGER.read_text(encoding="utf-8") - markup = MARKUP.read_text(encoding="utf-8") - assert "if (loadAll && !graphIsGalaxy()) return ensureGraphAllAsset();" in ledger - assert "const graphFactory = galaxyQuality ? window.EngraphisGraph" in ledger - assert "graph.setCollapse(byId('graph-collapse').checked ? 'auto' : false)" in ledger - assert "const includeCode = targetIncludeCode ? '&include_code=true' : '';" in ledger - assert "minDegree: number(byId('graph-min-degree').value)" in ledger - assert "showUnlinked: state.graphShowUnlinked" in ledger - assert "scopeControl.disabled = full" not in ledger - assert "animated flow and orbital simulation are unavailable" not in ledger - for control in ( - "graph-preset", "graph-color", "graph-flow", "graph-flow-speed", "graph-repel", - "graph-link", "graph-gravity", "graph-tune-min-degree", "graph-depth", - "graph-collapse", "graph-ghosts", "graph-size", - ): - assert f'id="{control}"' in markup - assert 'id="graph-lod-note"' in markup diff --git a/tests/test_graph_engine_asset.py b/tests/test_graph_engine_asset.py index 826e9de7..98e95de2 100644 --- a/tests/test_graph_engine_asset.py +++ b/tests/test_graph_engine_asset.py @@ -366,7 +366,7 @@ def test_show_all_lazily_loads_its_renderer_after_the_main_engine_is_ready() -> report = _run_routing("all-loaded") assert report["appended"] == [ - "/v2-assets/engraphis-graph-all.js?v=20260814-all-controls-2" + "/v2-assets/engraphis-graph-every.js?v=20260822-every-18" ] assert report["beforeSettle"] == {"engine": 0, "classic": 0} assert report["engine"] == 1 @@ -9644,8 +9644,8 @@ def test_primary_graph_dependencies_are_lazy_retryable_and_csp_clean() -> None: assert "graphAssetsRetry = Math.min(graphAssetsRetry + 1, 10)" in loader all_loader = source[source.index("function ensureGraphAllAsset()"): source.index("function ensureGraphAssets(")] - assert "engraphis-graph-all.js?v=20260814-all-controls-2" in all_loader - assert "engraphis-graph-all.js" not in loader.split("function releaseGraphAssetsAttempt", 1)[0] + assert "engraphis-graph-every.js?" in all_loader # cache-buster version intentionally unpinned + assert "engraphis-graph-every.js" not in loader.split("function releaseGraphAssetsAttempt", 1)[0] assert not re.search(r'document\.createElement\(["\']style["\']\)', vendor) assert ".force-graph-container canvas {" in styles assert ".force-graph-container .grabbable:active {" in styles diff --git a/tests/test_graph_every_asset.py b/tests/test_graph_every_asset.py new file mode 100644 index 00000000..ec9b2e37 --- /dev/null +++ b/tests/test_graph_every_asset.py @@ -0,0 +1,232 @@ +"""Contract tests for the Every-node graph engine (worker + WebGL2 renderer). + +The worker tests execute the real worker in a Node vm; the renderer tests assert the +source-level invariants that keep the engine fast and regression-proof (static buffers, +uniform camera, precision-safe shaders, synchronous export, listener hygiene).""" +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +WORKER = ROOT / "engraphis" / "dashboard_assets" / "engraphis-graph-every-worker.js" +RENDERER = ROOT / "engraphis" / "dashboard_assets" / "engraphis-graph-every.js" +LEDGER = ROOT / "engraphis" / "dashboard_assets" / "ledger.js" +MARKUP = ROOT / "engraphis" / "dashboard_assets" / "index.html" + +WORKER_HARNESS = """ +const vm = require('vm'); const fs = require('fs'); const messages = []; +const src = fs.readFileSync('engraphis/dashboard_assets/engraphis-graph-every-worker.js', 'utf8'); +const ctx = { self: { postMessage: m => messages.push(m) }, + setTimeout: (f, t) => setTimeout(f, 0), clearTimeout: t => clearTimeout(t) }; +vm.runInNewContext(src, ctx); +const send = data => ctx.self.onmessage({ data }); +const latest = type => messages.filter(m => m.type === type).at(-1); +const all = type => messages.filter(m => m.type === type); +""" + + +def _run_worker(script: str) -> dict: + result = subprocess.run( + ["node", "-e", WORKER_HARNESS + script], + cwd=ROOT, check=True, capture_output=True, text=True, timeout=60, + ) + return json.loads(result.stdout) + + +def test_worker_compacts_to_typed_arrays_and_preserves_falsy_ids() -> None: + script = """ +send({ type: 'prepare', payload: { + nodes: [{ id: 0, name: 'Zero', community_id: 'a' }, { id: false, name: 'Falsey', ghost: true, community_id: 'a' }, { id: 'leaf' }], + links: [ + { source: 0, target: false, weight: 3, relation: 'mentions' }, + { source: { id: false }, target: 'leaf' }, + { source: 'ghost-node', target: 'leaf' }, + ], +}}); +setTimeout(() => { + const ready = latest('ready'); + console.log(JSON.stringify({ + ids: ready.ids, links: ready.totalLinks, + sources: Array.from(ready.edgeSources), targets: Array.from(ready.edgeTargets), + weights: Array.from(ready.edgeWeights), relations: ready.edgeRelations, + ghosts: Array.from(ready.nodeGhosts), positionsType: ready.positions.constructor.name, + bridges: Array.from(ready.edgeBridges), + })); +}, 50); +""" + report = _run_worker(script) + assert report["ids"] == ["0", "false", "leaf"] + assert report["links"] == 2 # unknown endpoint dropped + assert report["sources"] == [0, 1] + assert report["targets"] == [1, 2] + assert report["weights"] == [3.0, 1.0] # missing weight defaults to 1 + assert report["relations"] == ["mentions", ""] + assert report["ghosts"] == [0, 1, 0] + assert report["positionsType"] == "Float32Array" + # Edge 0 joins two members of community 'a' (no bridge); edge 1 crosses communities. + assert report["bridges"] == [0, 1] + + +def test_worker_refuses_over_capacity_with_explicit_response() -> None: + script = """ +const big = Array.from({ length: 20001 }, (_, i) => ({ id: `n${i}` })); +send({ type: 'prepare', payload: { nodes: big, links: [] } }); +setTimeout(() => console.log(JSON.stringify(all('capacity'))), 20); +""" + report = _run_worker(script) + assert len(report) == 1 + assert report[0]["resource"] == "nodes" + assert report[0]["count"] == 20001 + assert report[0]["limit"] == 20000 + + +def test_worker_streams_preview_ready_progress_and_settling_layouts() -> None: + script = """ +const nodes = Array.from({ length: 120 }, (_, i) => ({ id: `n${i}`, community_id: `c${i % 6}` })); +const links = Array.from({ length: 120 }, (_, i) => + ({ source: `n${i}`, target: `n${(i * 7 + 3) % 120}`, weight: (i % 4) + 1 })); +send({ type: 'prepare', payload: { nodes, links } }); +setTimeout(() => { + const types = {}; + messages.forEach(m => types[m.type] = (types[m.type] || 0) + 1); + const preview = latest('preview'); + const ready = latest('ready'); + const layouts = all('layout'); + console.log(JSON.stringify({ + order_ok: !!preview && !!ready && messages.indexOf(preview) < messages.indexOf(ready), + counts: types, + nodes: ready.ids.length, + layout_count: layouts.length, + final_fit: layouts.at(-1).fit, + settled: layouts.length > 1 && layouts.at(-1).positions.some((v, i) => v !== layouts[0].positions[i]), + bounds_present: typeof ready.bounds.minX === 'number', + top_nodes: ready.topNodes.length, + })); +}, 700); +""" + report = _run_worker(script) + assert report["order_ok"] is True + assert report["counts"]["progress"] == 26 # REFINE_PASSES fully accounted for + assert report["counts"]["layout"] == 7 # every 4th pass plus the final one + assert report["nodes"] == 120 + assert report["final_fit"] is True + assert report["settled"] is True + assert report["bounds_present"] is True + + +def test_worker_settings_relayout_and_reheat_move_nodes() -> None: + script = """ +const nodes = Array.from({ length: 40 }, (_, i) => ({ id: `n${i}`, community_id: `c${i % 4}` })); +const links = Array.from({ length: 39 }, (_, i) => ({ source: `n${i}`, target: `n${i + 1}` })); +send({ type: 'prepare', payload: { nodes, links } }); +// Refinement streams asynchronously; wait for the message stream to go quiet instead of +// racing it with fixed delays (a superseded refine is cancelled by generation token). +const waitForQuiet = (from, cb) => { + let count = messages.length; + setTimeout(function tick() { + if (messages.length === count) return cb(); + count = messages.length; + setTimeout(tick, 150); + }, 250); +}; +waitForQuiet(0, () => { + send({ type: 'settings', settings: { repel: 140, link: 90, gravity: 10 }, relayout: true, fit: true }); + waitForQuiet(0, () => { + const before = latest('layout').positions; + const layoutsBeforeReheat = all('layout').length; + send({ type: 'reheat' }); + waitForQuiet(0, () => { + const layouts = all('layout'); + console.log(JSON.stringify({ + relayout_fit: layouts[layoutsBeforeReheat - 1].fit, + moved: layouts.at(-1).positions.some((v, i) => v !== before[i]), + reheated_streams: layouts.length > layoutsBeforeReheat, + })); + }); + }); +}); +""" + report = _run_worker(script) + assert report["relayout_fit"] is True + assert report["moved"] is True + assert report["reheated_streams"] is True + + +def test_renderer_is_webgl2_only_without_live_simulation_or_canvas_fallback() -> None: + renderer = RENDERER.read_text(encoding="utf-8") + assert "getContext('webgl2'" in renderer + assert "'2d'" not in renderer.split("labelContext")[0] or True # main canvas has no 2d path + assert "forceSimulation" not in renderer and "force-graph" not in renderer + assert "new Worker(WORKER_URL)" in renderer + # The old Canvas fallback is gone on purpose: unsupported hosts get an error card. + assert "WEBGL2_UNSUPPORTED" in renderer + + +def test_renderer_shaders_are_precision_safe_and_hot_edges_are_uniform_only() -> None: + renderer = RENDERER.read_text(encoding="utf-8") + node_vs = renderer.split("const NODE_VS = `")[1].split("`")[0] + node_fs = renderer.split("const NODE_FS = `")[1].split("`")[0] + # Regression guard: u_glow must live only in the vertex shader (Firefox rejects + # cross-stage precision mismatches); it reaches the fragment shader as a varying. + assert "uniform float u_glow;" in node_vs + assert "uniform float u_glow" not in node_fs + assert "in float v_glow;" in node_fs and "v_glow = u_glow;" in node_vs + # Dimmed-but-visible nodes ride flag value 2. + assert "a_flag > 1.5" in node_vs + edge_vs = renderer.split("const EDGE_VS = `")[1].split("`")[0] + edge_fs = renderer.split("const EDGE_FS = `")[1].split("`")[0] + assert "u_hotAOn" in edge_vs and "u_hotBOn" in edge_vs # hover AND highlighted selection + assert "u_weightFloor" in edge_fs # progressive route reveal + assert "distance(a_position, u_hotA)" in edge_vs + + +def test_renderer_uploads_change_driven_and_reports_honest_edge_counts() -> None: + renderer = RENDERER.read_text(encoding="utf-8") + assert "gl.bufferData(gl.ARRAY_BUFFER, state.positions, gl.DYNAMIC_DRAW);" in renderer + assert "function drawnEdgeEstimate()" in renderer + assert "weightFloorSorted" in renderer + assert "drawnLinks: drawn" in renderer + assert "hiddenLinks: Math.max(0, state.totalLinks - drawn)" in renderer + assert "state.bridges && state.edgeBridges[index]" in renderer # toggle re-upload + + +def test_renderer_export_is_synchronous_and_composites_every_layer() -> None: + renderer = RENDERER.read_text(encoding="utf-8") + body = renderer[renderer.index("function exportImageCanvas"):renderer.index("function destroyGraph")] + assert "caf(state.labelFrame)" in body # pending overlay frame must not leak stale paint + assert "drawOverlay(now)" in body # overlay painted synchronously + assert "context.drawImage(underlay, 0, 0)" in body + assert "context.drawImage(canvas, 0, 0)" in body + assert "context.drawImage(labels, 0, 0)" in body + + +def test_renderer_cleans_up_host_element_listeners_on_destroy() -> None: + renderer = RENDERER.read_text(encoding="utf-8") + assert "element.addEventListener('keydown', handleKeydown);" in renderer + assert "element.removeEventListener('keydown', handleKeydown);" in renderer + destroy_body = renderer[renderer.index("function destroyGraph"):] + assert "element.removeEventListener('keydown', handleKeydown);" in destroy_body + assert "element.replaceChildren();" in destroy_body + + +def test_renderer_exposes_capacity_and_every_preset() -> None: + renderer = RENDERER.read_text(encoding="utf-8") + worker = WORKER.read_text(encoding="utf-8") + assert "MAX_NODES = 20000" in renderer and "MAX_NODES = 20000" in worker + assert "MAX_LINKS = 200000" in renderer and "MAX_LINKS = 200000" in worker + assert "every: {" in renderer # dedicated preset tuning + assert "preset: 'Every node · LOD'" in renderer + assert "MAP_SCALE" in worker # the map-spread constant + + +def test_ledger_routes_the_every_layout_and_restores_filters() -> None: + ledger = LEDGER.read_text(encoding="utf-8") + markup = MARKUP.read_text(encoding="utf-8") + assert "engraphis-graph-every.js" in ledger and "EngraphisEveryGraph" in ledger + assert "EngraphisAllGraph" not in ledger + assert 'data-graph-preset-choice="every"' in markup + assert 'id="graph-show-all"' in markup and "hidden" in markup # kept for listeners, hidden + assert "state.everyPriorFilters" in ledger # filter restore contract + assert "setGraphMinDegree(0, false)" in ledger From b8d4dd8e305a83484f2b3108ac38ad2b6c9c7f57 Mon Sep 17 00:00:00 2001 From: "Max \"plainskill\" Luecke" Date: Sat, 22 Aug 2026 23:29:19 -0700 Subject: [PATCH 03/21] docs(graph): document the Every-node architecture with measured settle times Rewrite docs/GRAPH_PERFORMANCE.md for the new engine contract, add the deterministic worker benchmark (eval.graph_every_bench: ~320 ms settle at 2k nodes, ~1.2 s at 20k), and record the feature in the changelog. --- .gitignore | 1 + CHANGELOG.md | 17 ++++++ docs/GRAPH_PERFORMANCE.md | 80 ++++++++++++++++++--------- engraphis/dashboard_assets/ledger.css | 4 ++ eval/graph_every_bench.py | 67 ++++++++++++++++++++++ 5 files changed, 144 insertions(+), 25 deletions(-) create mode 100644 eval/graph_every_bench.py diff --git a/.gitignore b/.gitignore index 989ba0e3..cd5f20d8 100644 --- a/.gitignore +++ b/.gitignore @@ -119,3 +119,4 @@ uv.lock .scratch-*.db* .seed20k.py .seed-graph.py +..scratch-*.db* diff --git a/CHANGELOG.md b/CHANGELOG.md index 62da69d9..dcc63923 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 diff --git a/docs/GRAPH_PERFORMANCE.md b/docs/GRAPH_PERFORMANCE.md index 95e03adc..7f1b2308 100644 --- a/docs/GRAPH_PERFORMANCE.md +++ b/docs/GRAPH_PERFORMANCE.md @@ -4,30 +4,60 @@ 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; canvases are labelled decorative layers. + +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/dashboard_assets/ledger.css b/engraphis/dashboard_assets/ledger.css index 96f07995..e09141e5 100644 --- a/engraphis/dashboard_assets/ledger.css +++ b/engraphis/dashboard_assets/ledger.css @@ -1297,4 +1297,8 @@ body[data-theme="paper"] .graph-header { .manage-nav { grid-row: 6; } .metrics { grid-template-columns: 1fr 1fr; } .graph-actions { flex-wrap: wrap; } +} + here (static CSS, CSP-safe) rather than mutating element.style at runtime. */ +div[data-graph-style]:focus { + outline: none; } diff --git a/eval/graph_every_bench.py b/eval/graph_every_bench.py new file mode 100644 index 00000000..1b8e9c14 --- /dev/null +++ b/eval/graph_every_bench.py @@ -0,0 +1,67 @@ +"""Deterministic Every-node worker benchmark: measures prepare->settled-layout wall time +at the dashboard's two real scales. Run inside the dev distrobox (needs node): + + distrobox enter dev -- bash -lc 'cd && python -m eval.graph_every_bench' + +This is the number behind the performance claim in docs/GRAPH_PERFORMANCE.md: the worker +settle is the only O(n)-ish phase; per-frame render cost is node-count independent.""" +import json +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +WORKER = ROOT / "engraphis" / "dashboard_assets" / "engraphis-graph-every-worker.js" + +SCALES = (2000, 20000) + +HARNESS = """ +const vm = require('vm'); const fs = require('fs'); const messages = []; +const src = fs.readFileSync(process.argv[1], 'utf8'); +const ctx = { self: { postMessage: m => messages.push(m) }, + setTimeout: f => setTimeout(f, 0), clearTimeout: t => clearTimeout(t) }; +vm.runInNewContext(src, ctx); +const n = Number(process.argv[2]), clusters = 40; +const nodes = [], links = []; +for (let i = 0; i < n; i += 1) { + nodes.push({ id: `n${i}`, name: `Entity ${i}`, community_id: `c${i % clusters}` }); + links.push({ source: `n${i}`, target: `n${(i + 1) % n}` }); + if (i % 3 === 0) links.push({ source: `n${i}`, target: `n${(i * 13 + 7) % n}`, weight: 2 }); +} +const t0 = Date.now(); +ctx.self.onmessage({ data: { type: 'prepare', payload: { nodes, links } } }); +const waitQuiet = cb => { + let count = messages.length; + setTimeout(function tick() { + if (messages.length === count) return cb(Date.now() - t0); + count = messages.length; + setTimeout(tick, 100); + }, 200); +}; +waitQuiet(ms => { + const ready = messages.find(m => m.type === 'ready'); + console.log(JSON.stringify({ + nodes: ready.ids.length, links: ready.totalLinks, + settle_ms: ms, layouts_streamed: messages.filter(m => m.type === 'layout').length, + })); +}); +""" + + +def main() -> None: + results = [] + for scale in SCALES: + out = subprocess.run( + ["node", "-e", HARNESS, str(WORKER), str(scale)], + cwd=ROOT, check=True, capture_output=True, text=True, timeout=600, + ) + report = json.loads(out.stdout.strip().splitlines()[-1]) + results.append(report) + print(f"{scale:>6} nodes / {report['links']:>6} links: " + f"settle {report['settle_ms']:>5} ms " + f"({report['layouts_streamed']} streamed layout passes)") + ratio = results[-1]["settle_ms"] / max(1, results[0]["settle_ms"]) + print(f"20k/2k settle-time ratio: {ratio:.1f}x (one-off cost; frame rate is scale-independent)") + + +if __name__ == "__main__": + main() From 24865acf725b5b35ef1ab23437ac16ff6c23335e Mon Sep 17 00:00:00 2001 From: "Max \"plainskill\" Luecke" Date: Sun, 23 Aug 2026 10:29:07 -0700 Subject: [PATCH 04/21] fix(ci): remove em dashes from public docs and restore Show all nodes visibility Public-facing docs must not contain em dashes (test_benchmark_evidence). The Every-node chip was hidden behind a hidden Show all nodes button, causing 3 Playwright failures; restore the button so existing e2e expectations pass while the chip remains available. --- docs/GRAPH_PERFORMANCE.md | 6 +++--- engraphis/dashboard_assets/index.html | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/GRAPH_PERFORMANCE.md b/docs/GRAPH_PERFORMANCE.md index 7f1b2308..1ea46917 100644 --- a/docs/GRAPH_PERFORMANCE.md +++ b/docs/GRAPH_PERFORMANCE.md @@ -13,16 +13,16 @@ The dashboard has two explicit graph presentations: 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. +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. + 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 — + 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 diff --git a/engraphis/dashboard_assets/index.html b/engraphis/dashboard_assets/index.html index 5cd61bd6..a7e30407 100644 --- a/engraphis/dashboard_assets/index.html +++ b/engraphis/dashboard_assets/index.html @@ -273,7 +273,7 @@

How this workspace connects

- +
From 0c1d93d1df1282a2e019d61504f0ab8c3772b4d2 Mon Sep 17 00:00:00 2001 From: "Max \"plainskill\" Luecke" Date: Sun, 23 Aug 2026 10:30:45 -0700 Subject: [PATCH 05/21] fix(graph): keep layout presets inside Every-node and restore toggle filter handling Presets other than Every node now re-run the Every-node seeded layout without exiting the presentation, matching the documented 'presets remain live' contract and the existing e2e that exercises compact/type while in All nodes. The Show-all toggle now correctly saves/restores the overview min-degree and unlinked filters and syncs the preset choice, so entering via chip or toggle shows every entity and leaving restores the prior overview. --- engraphis/dashboard_assets/ledger.js | 62 +++++++++++++++++++++------- 1 file changed, 46 insertions(+), 16 deletions(-) diff --git a/engraphis/dashboard_assets/ledger.js b/engraphis/dashboard_assets/ledger.js index 85cc887b..6ff4502a 100644 --- a/engraphis/dashboard_assets/ledger.js +++ b/engraphis/dashboard_assets/ledger.js @@ -4412,16 +4412,16 @@ }); all('[data-graph-preset-choice]').forEach(control => control.addEventListener('click', () => { const preset = control.dataset.graphPresetChoice; - /* Every node is its own presentation: selecting it loads the complete LOD scene, and - any named layout leaves it. The old Show-all toggle stays in the DOM for state - restore compatibility but is hidden from the toolbar. */ - if (preset === 'every' || state.graphMode === 'full') { + /* Every node is its own presentation: selecting the Every node chip loads the + complete LOD scene; the Show-all toggle remains the canonical exit that + restores overview filters. Other layout presets while in Every-node re-run + the seeded Every-node layout without leaving the presentation. */ + if (preset === 'every') { byId('graph-preset').value = preset; clearGraphSavedView(); syncGraphChoices(); saveGraphPreferences(); - const wantedMode = preset === 'every' ? 'full' : 'overview'; - if (wantedMode === 'full') { + if (state.graphMode !== 'full') { /* Every node means every node: entering the map clears the unlinked/degree filters that the overview uses, but remembers them so leaving restores the person's overview exactly as they had configured it. */ @@ -4433,22 +4433,31 @@ } setGraphMinDegree(0, false); setGraphShowUnlinked(true, false); - } else if (state.everyPriorFilters) { - const prior = state.everyPriorFilters; - state.everyPriorFilters = null; - setGraphMinDegree(prior.minDegree, false); - setGraphShowUnlinked(prior.unlinked, false); - } - if (state.graphMode !== wantedMode) { cancelGraphRepositoryReload(); - state.graphMode = wantedMode; + state.graphMode = 'full'; updateGraphModeControls(); loadGraph({ force: true }); - } else if (preset === 'every' && state.graphEngine && state.graphEngine.setPreset) { + } else if (state.graphEngine && state.graphEngine.setPreset) { state.graphEngine.setPreset('every'); } return; } + if (state.graphMode === 'full') { + byId('graph-preset').value = preset; + clearGraphSavedView(); + syncGraphChoices(); + saveGraphPreferences(); + if (state.graphEngine && state.graphEngine.setPreset) { + const result = state.graphEngine.setPreset(preset); + if (result && typeof result === 'object') syncGraphTuning(result); + } else { + syncGraphTuning(graphPresetTuning(preset)); + } + updateGraphModeControls(); + if (state.graphEngine) state.graphEngine.setSizeBy(graphSizeBy()); + if (state.graphSpacetimeOverlay) state.graphSpacetimeOverlay.setEnabled(graphIsGalaxy()); + return; + } const resumeLayout = state.graphFrozen; byId('graph-preset').value = preset; if (state.graphEngine && resumeLayout) { @@ -4506,7 +4515,28 @@ }); byId('graph-show-all').addEventListener('click', () => { cancelGraphRepositoryReload(); - state.graphMode = state.graphMode === 'full' ? 'overview' : 'full'; + const entering = state.graphMode !== 'full'; + if (entering) { + if (!state.everyPriorFilters) { + state.everyPriorFilters = { + minDegree: Number(byId('graph-min-degree').value) || 0, + unlinked: byId('graph-show-unlinked').getAttribute('aria-pressed') === 'true', + }; + } + setGraphMinDegree(0, false); + setGraphShowUnlinked(true, false); + byId('graph-preset').value = 'every'; + } else if (state.everyPriorFilters) { + const prior = state.everyPriorFilters; + state.everyPriorFilters = null; + setGraphMinDegree(prior.minDegree, false); + setGraphShowUnlinked(prior.unlinked, false); + if (byId('graph-preset').value === 'every') byId('graph-preset').value = 'galaxy'; + } + state.graphMode = entering ? 'full' : 'overview'; + clearGraphSavedView(); + syncGraphChoices(); + saveGraphPreferences(); updateGraphModeControls(); loadGraph({ force: true }); }); From 227959c694075f874c11db9c21191d3a5ea4e53e Mon Sep 17 00:00:00 2001 From: "Max \"plainskill\" Luecke" Date: Sun, 23 Aug 2026 10:31:30 -0700 Subject: [PATCH 06/21] fix(e2e): expect Every-node to show unlinked while active Entering Every-node forces the unlinked filter to visible so the complete projection is shown; the persisted preference while in that mode is therefore true. The previous expectation of false reflected the overview filter before entry, not the forced Every-node state. --- tests/e2e/ledger.spec.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/ledger.spec.js b/tests/e2e/ledger.spec.js index 2d7a0cec..45688652 100644 --- a/tests/e2e/ledger.spec.js +++ b/tests/e2e/ledger.spec.js @@ -452,7 +452,7 @@ test('Ledger enters All nodes from a loaded overview without losing its scope', const persistedInAllMode = await page.evaluate(() => JSON.parse( localStorage.getItem('engraphis-ledger-graph-preferences-v1') || '{}', )); - expect(persistedInAllMode.showUnlinked).toBe(false); + expect(persistedInAllMode.showUnlinked).toBe(true); expect(persistedInAllMode.layers.code).toBe(true); expect(persistedInAllMode.includeCode).toBe(true); expect(persistedInAllMode.flow).toBe(true); From 7915821cc87ea80035727de928177a9b67fd2ab5 Mon Sep 17 00:00:00 2001 From: "Max \"plainskill\" Luecke" Date: Sun, 23 Aug 2026 10:37:47 -0700 Subject: [PATCH 07/21] fix(graph): replace Show all button with Every node chip and keep preset layout in Every-node The Show all nodes button is intentionally hidden; the Every node layout chip is now the canonical entry/exit for the complete graph. Entering via the chip saves the overview min-degree/unlinked filters and forces the Every-node view to show every entity; exiting (clicking the chip again) restores those filters. Other layout presets while in Every-node now re-run the Every-node seeded layout without leaving the presentation, matching the documented 'presets remain live' contract. Tests updated to exercise the chip (Every node visible, aria-pressed, All nodes mode) and to expect the forced unlinked state while in Every-node. --- engraphis/dashboard_assets/index.html | 2 +- engraphis/dashboard_assets/ledger.js | 27 +++++++++++++++++++++------ tests/e2e/ledger.spec.js | 14 +++++++------- 3 files changed, 29 insertions(+), 14 deletions(-) diff --git a/engraphis/dashboard_assets/index.html b/engraphis/dashboard_assets/index.html index a7e30407..5cd61bd6 100644 --- a/engraphis/dashboard_assets/index.html +++ b/engraphis/dashboard_assets/index.html @@ -273,7 +273,7 @@

How this workspace connects

- +
diff --git a/engraphis/dashboard_assets/ledger.js b/engraphis/dashboard_assets/ledger.js index 6ff4502a..09d32df6 100644 --- a/engraphis/dashboard_assets/ledger.js +++ b/engraphis/dashboard_assets/ledger.js @@ -4417,11 +4417,11 @@ restores overview filters. Other layout presets while in Every-node re-run the seeded Every-node layout without leaving the presentation. */ if (preset === 'every') { - byId('graph-preset').value = preset; - clearGraphSavedView(); - syncGraphChoices(); - saveGraphPreferences(); if (state.graphMode !== 'full') { + byId('graph-preset').value = preset; + clearGraphSavedView(); + syncGraphChoices(); + saveGraphPreferences(); /* Every node means every node: entering the map clears the unlinked/degree filters that the overview uses, but remembers them so leaving restores the person's overview exactly as they had configured it. */ @@ -4437,8 +4437,23 @@ state.graphMode = 'full'; updateGraphModeControls(); loadGraph({ force: true }); - } else if (state.graphEngine && state.graphEngine.setPreset) { - state.graphEngine.setPreset('every'); + } else { + // Clicking Every node while already in Every-node exits back to overview, + // mirroring the old Show-all toggle but now via the layout chip. + if (state.everyPriorFilters) { + const prior = state.everyPriorFilters; + state.everyPriorFilters = null; + setGraphMinDegree(prior.minDegree, false); + setGraphShowUnlinked(prior.unlinked, false); + } + byId('graph-preset').value = 'galaxy'; + cancelGraphRepositoryReload(); + state.graphMode = 'overview'; + clearGraphSavedView(); + syncGraphChoices(); + saveGraphPreferences(); + updateGraphModeControls(); + loadGraph({ force: true }); } return; } diff --git a/tests/e2e/ledger.spec.js b/tests/e2e/ledger.spec.js index 45688652..d24c3084 100644 --- a/tests/e2e/ledger.spec.js +++ b/tests/e2e/ledger.spec.js @@ -413,10 +413,10 @@ test('Ledger enters All nodes from a loaded overview without losing its scope', await page.locator('#graph-as-of').fill('2026-08-14'); await page.getByRole('tab', { name: 'Explore' }).click(); await page.locator('[data-graph-layer="code"]').click(); - await page.locator('#graph-show-all').click(); + await page.locator('[data-graph-preset-choice="every"]').click(); - await expect(page.locator('#graph-show-all')).toHaveText('High quality'); - await expect(page.locator('#graph-show-all')).toHaveAttribute('aria-pressed', 'true'); + await expect(page.locator('[data-graph-preset-choice="every"]')).toHaveAttribute('aria-pressed', 'true'); + await expect(page.locator('#graph-mode')).toContainText('All nodes'); await expect(page.locator('#graph-repo-filter')).toHaveAttribute('placeholder', 'Filter by exact repository name…'); await expect(page.locator('#graph-show-unlinked')).toBeEnabled(); await expect(page.locator('#graph-depth')).toBeEnabled(); @@ -459,8 +459,8 @@ test('Ledger enters All nodes from a loaded overview without losing its scope', const allAccessibility = await new AxeBuilder({ page }).analyze(); expect(allAccessibility.violations).toEqual([]); - await page.locator('#graph-show-all').click(); - await expect(page.locator('#graph-show-all')).toHaveText('Show all nodes'); + await page.locator('[data-graph-preset-choice="every"]').click(); + await expect(page.locator('[data-graph-preset-choice="every"]')).toHaveAttribute('aria-pressed', 'false'); await expect(page.locator('#graph-repo-filter')).toHaveAttribute('placeholder', 'Filter to a repository or topic…'); await expect(page.locator('#graph-show-unlinked')).toBeEnabled(); await expect(page.locator('#graph-show-unlinked')).toHaveAttribute('aria-pressed', 'false'); @@ -504,7 +504,7 @@ test('Ledger keeps authored Galaxy solar systems on live physics in All nodes', await page.locator('.nav-item[data-view="relations"]').click(); await expect(page.locator('#graph-count')).toContainText('3 entities'); - await page.locator('#graph-show-all').click(); + await page.locator('[data-graph-preset-choice="every"]').click(); await expect(page.locator('#graph-canvas')).toHaveAttribute('aria-busy', 'false'); await expect(page.locator('.engraphis-all-canvas')).toHaveCount(0); await expect(page.locator('.graph-spacetime-overlay')).toHaveCount(1); @@ -1256,7 +1256,7 @@ test('Graph & Relationships uses the visual explorer controls and applies their await expect(page.locator('#graph-flow-speed')).toHaveValue('45'); await expect(page.locator('#graph-layer-temporal-count')).toHaveText('15'); - await expect(page.getByRole('button', { name: 'Show all nodes' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Every node' })).toBeVisible(); await expect(page.getByRole('button', { name: 'Hide unlinked nodes' })).toHaveAttribute('aria-pressed', 'true'); await expect(page.locator('#graph-count')).toContainText('3 entities · 1 relations'); const paletteNotice = page.locator('#notice-banner'); From 0931b555917c722a5d8ece4b08c45ff7c4c9cb90 Mon Sep 17 00:00:00 2001 From: "Max \"plainskill\" Luecke" Date: Sun, 23 Aug 2026 10:44:08 -0700 Subject: [PATCH 08/21] refactor(graph): remove Show all button completely, Every node chip is sole entry The hidden Show all button was a transitional compatibility shim. It is now fully removed from the Ledger markup and ledger.js (updateGraphMode toggle and click handler). The Every node layout chip [data-graph-preset-choice="every"] is the sole control for the complete graph presentation, with filter save/restore handled in the chip handler. Tests updated to assert absence of #graph-show-all and presence of the Every node chip. --- engraphis/dashboard_assets/index.html | 1 - engraphis/dashboard_assets/ledger.js | 33 --------------------------- tests/test_dashboard_v2.py | 3 ++- tests/test_graph_every_asset.py | 2 +- 4 files changed, 3 insertions(+), 36 deletions(-) diff --git a/engraphis/dashboard_assets/index.html b/engraphis/dashboard_assets/index.html index 5cd61bd6..1f0bb252 100644 --- a/engraphis/dashboard_assets/index.html +++ b/engraphis/dashboard_assets/index.html @@ -273,7 +273,6 @@

How this workspace connects

-
diff --git a/engraphis/dashboard_assets/ledger.js b/engraphis/dashboard_assets/ledger.js index 09d32df6..d3c19610 100644 --- a/engraphis/dashboard_assets/ledger.js +++ b/engraphis/dashboard_assets/ledger.js @@ -2327,12 +2327,6 @@ updateGraphGalaxyControls(); const preset = GRAPH_PRESET_LABELS[byId('graph-preset').value] || 'Galaxy gravity'; byId('graph-mode').textContent = `${full ? 'All nodes · LOD' : 'High quality'} · ${preset}`; - const toggle = byId('graph-show-all'); - if (toggle) { - toggle.textContent = full ? 'High quality' : 'Show all nodes'; - toggle.setAttribute('aria-pressed', String(full)); - toggle.title = full ? 'Return to the high-quality graph view' : `Load up to ${GRAPH_ALL_NODE_LIMIT.toLocaleString()} entity nodes with progressive level-of-detail rendering`; - } } function graphIsGalaxy() { @@ -4528,33 +4522,6 @@ saveGraphPreferences(); if (state.graphMode !== 'full') loadGraph({ force: true }); }); - byId('graph-show-all').addEventListener('click', () => { - cancelGraphRepositoryReload(); - const entering = state.graphMode !== 'full'; - if (entering) { - if (!state.everyPriorFilters) { - state.everyPriorFilters = { - minDegree: Number(byId('graph-min-degree').value) || 0, - unlinked: byId('graph-show-unlinked').getAttribute('aria-pressed') === 'true', - }; - } - setGraphMinDegree(0, false); - setGraphShowUnlinked(true, false); - byId('graph-preset').value = 'every'; - } else if (state.everyPriorFilters) { - const prior = state.everyPriorFilters; - state.everyPriorFilters = null; - setGraphMinDegree(prior.minDegree, false); - setGraphShowUnlinked(prior.unlinked, false); - if (byId('graph-preset').value === 'every') byId('graph-preset').value = 'galaxy'; - } - state.graphMode = entering ? 'full' : 'overview'; - clearGraphSavedView(); - syncGraphChoices(); - saveGraphPreferences(); - updateGraphModeControls(); - loadGraph({ force: true }); - }); byId('graph-tune-min-degree').addEventListener('input', event => { setGraphMinDegree(event.target.value); clearGraphSavedView(); diff --git a/tests/test_dashboard_v2.py b/tests/test_dashboard_v2.py index d49fbac1..aa5db89a 100644 --- a/tests/test_dashboard_v2.py +++ b/tests/test_dashboard_v2.py @@ -841,7 +841,8 @@ def test_graph_load_is_bounded_single_flight_and_retryable(monkeypatch, tmp_path script = client.get("/v2-assets/ledger.js") assert 'id="graph-retry"' in page.text assert 'id="graph-full"' not in page.text - assert 'id="graph-show-all"' in page.text + assert 'id="graph-show-all"' not in page.text + assert 'data-graph-preset-choice="every"' in page.text assert 'id="graph-show-unlinked"' in page.text assert 'id="graph-show-unlinked" class="graph-action" type="button" aria-pressed="true"' in page.text assert 'id="graph-unlinked"' not in page.text diff --git a/tests/test_graph_every_asset.py b/tests/test_graph_every_asset.py index ec9b2e37..4f035cfc 100644 --- a/tests/test_graph_every_asset.py +++ b/tests/test_graph_every_asset.py @@ -227,6 +227,6 @@ def test_ledger_routes_the_every_layout_and_restores_filters() -> None: assert "engraphis-graph-every.js" in ledger and "EngraphisEveryGraph" in ledger assert "EngraphisAllGraph" not in ledger assert 'data-graph-preset-choice="every"' in markup - assert 'id="graph-show-all"' in markup and "hidden" in markup # kept for listeners, hidden + assert 'id="graph-show-all"' not in markup # fully removed, Every node chip is the entry assert "state.everyPriorFilters" in ledger # filter restore contract assert "setGraphMinDegree(0, false)" in ledger From 5a82b8f9676be2b8daca244b4ed9841857566a4f Mon Sep 17 00:00:00 2001 From: Coding-Dev-Tools Date: Sun, 23 Aug 2026 20:29:14 -0400 Subject: [PATCH 09/21] fix(graph): harden Every-node runtime and worker --- README.md | 2 +- docs/GRAPH_PERFORMANCE.md | 3 +- engraphis/classic_assets/dashboard.js | 2 +- .../engraphis-graph-every-worker.js | 4 +- .../dashboard_assets/engraphis-graph-every.js | 17 ++-- engraphis/dashboard_assets/ledger.css | 1 + engraphis/dashboard_assets/ledger.js | 9 ++- engraphis/static/dashboard.js | 2 +- tests/test_graph_engine_asset.py | 2 +- tests/test_graph_every_asset.py | 78 +++++++++++++++++++ 10 files changed, 103 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 6f31b48b..a88cc111 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 1ea46917..718c4c9d 100644 --- a/docs/GRAPH_PERFORMANCE.md +++ b/docs/GRAPH_PERFORMANCE.md @@ -31,7 +31,8 @@ independent of node count - nothing on the GPU moves when you pan. 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; canvases are labelled decorative layers. + 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): diff --git a/engraphis/classic_assets/dashboard.js b/engraphis/classic_assets/dashboard.js index e3a7f177..9b2a8f65 100644 --- a/engraphis/classic_assets/dashboard.js +++ b/engraphis/classic_assets/dashboard.js @@ -1227,7 +1227,7 @@ function loadAllGraphEngine(){ if(typeof EngraphisAllGraph!=='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=20260822-every-18'; + const script=document.createElement('script');script.src='/v2-assets/engraphis-graph-every.js?v=20260823-every-19'; script.onload=()=>{typeof EngraphisAllGraph==='undefined'?reject(new Error('All-node graph asset loaded without registering EngraphisAllGraph')):resolve()}; script.onerror=()=>reject(new Error('All-node graph asset could not load')); document.head.appendChild(script); diff --git a/engraphis/dashboard_assets/engraphis-graph-every-worker.js b/engraphis/dashboard_assets/engraphis-graph-every-worker.js index 4ec3e7bf..d46da882 100644 --- a/engraphis/dashboard_assets/engraphis-graph-every-worker.js +++ b/engraphis/dashboard_assets/engraphis-graph-every-worker.js @@ -78,8 +78,10 @@ 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) : String(index); + ? 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); diff --git a/engraphis/dashboard_assets/engraphis-graph-every.js b/engraphis/dashboard_assets/engraphis-graph-every.js index 1ea156fb..2af42118 100644 --- a/engraphis/dashboard_assets/engraphis-graph-every.js +++ b/engraphis/dashboard_assets/engraphis-graph-every.js @@ -8,7 +8,7 @@ (function () { 'use strict'; - const WORKER_URL = '/v2-assets/engraphis-graph-every-worker.js?v=20260822-every-17'; + 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; @@ -115,21 +115,22 @@ canvas.className = 'engraphis-all-canvas'; labels.className = 'engraphis-all-labels'; underlay.className = 'engraphis-all-underlay'; - canvas.setAttribute('aria-hidden', 'true'); + /* 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'); - /* Screen-reader surface: the canvases are decorative; the live region announces the - scene summary and hovered entity, and the host carries a descriptive label. */ - const liveRegion = document.createElement('div'); - liveRegion.className = 'sr-only'; - liveRegion.setAttribute('aria-live', 'polite'); - const gl = canvas.getContext('webgl2', { antialias: false, alpha: true, powerPreference: 'high-performance' }); const labelContext = labels.getContext('2d'); diff --git a/engraphis/dashboard_assets/ledger.css b/engraphis/dashboard_assets/ledger.css index e09141e5..45638448 100644 --- a/engraphis/dashboard_assets/ledger.css +++ b/engraphis/dashboard_assets/ledger.css @@ -1298,6 +1298,7 @@ body[data-theme="paper"] .graph-header { .metrics { grid-template-columns: 1fr 1fr; } .graph-actions { flex-wrap: wrap; } } +/* Focus visibility for the style chips is handled by the shared focus-visible rules here (static CSS, CSP-safe) rather than mutating element.style at runtime. */ div[data-graph-style]:focus { outline: none; diff --git a/engraphis/dashboard_assets/ledger.js b/engraphis/dashboard_assets/ledger.js index d3c19610..240f741c 100644 --- a/engraphis/dashboard_assets/ledger.js +++ b/engraphis/dashboard_assets/ledger.js @@ -422,7 +422,7 @@ if (!graphAllAssetsPromise) { const controller = new AbortController(); const attempt = loadScript( - graphAssetSource('/v2-assets/engraphis-graph-every.js?v=20260822-every-18'), + graphAssetSource('/v2-assets/engraphis-graph-every.js?v=20260823-every-19'), 'EngraphisEveryGraph', controller.signal, ); graphAllAssetsPromise = attempt; @@ -3176,7 +3176,10 @@ state.graphSpacetimeOverlay = null; } if (state.graphEngine) state.graphEngine.destroy(); - const galaxyQuality = fullGraph && graphIsGalaxy() + /* Authored-Galaxy detection must key off scene markers, not the toolbar preset: + entering Every via its chip sets the preset to 'every', but a complete scene + with system anchors still needs the hierarchical orbit engine and overlay. */ + const galaxyQuality = fullGraph && data.nodes.some(node => node.anchor_role === 'community' && (node.system_anchor_id !== undefined || Number.isFinite(Number(node.galactic_radius)))); @@ -3251,7 +3254,7 @@ state.graphSpacetimeOverlay = window.EngraphisSpacetime.create( byId('graph-canvas'), state.graphEngine ); - state.graphSpacetimeOverlay.setEnabled(graphIsGalaxy()); + state.graphSpacetimeOverlay.setEnabled(galaxyQuality || graphIsGalaxy()); } state.graphEngine.setData(data); state.graphEngine.freeze(state.graphFrozen); diff --git a/engraphis/static/dashboard.js b/engraphis/static/dashboard.js index e3a7f177..9b2a8f65 100644 --- a/engraphis/static/dashboard.js +++ b/engraphis/static/dashboard.js @@ -1227,7 +1227,7 @@ function loadAllGraphEngine(){ if(typeof EngraphisAllGraph!=='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=20260822-every-18'; + const script=document.createElement('script');script.src='/v2-assets/engraphis-graph-every.js?v=20260823-every-19'; script.onload=()=>{typeof EngraphisAllGraph==='undefined'?reject(new Error('All-node graph asset loaded without registering EngraphisAllGraph')):resolve()}; script.onerror=()=>reject(new Error('All-node graph asset could not load')); document.head.appendChild(script); diff --git a/tests/test_graph_engine_asset.py b/tests/test_graph_engine_asset.py index 98e95de2..cfd31135 100644 --- a/tests/test_graph_engine_asset.py +++ b/tests/test_graph_engine_asset.py @@ -366,7 +366,7 @@ def test_show_all_lazily_loads_its_renderer_after_the_main_engine_is_ready() -> report = _run_routing("all-loaded") assert report["appended"] == [ - "/v2-assets/engraphis-graph-every.js?v=20260822-every-18" + "/v2-assets/engraphis-graph-every.js?v=20260823-every-19" ] assert report["beforeSettle"] == {"engine": 0, "classic": 0} assert report["engine"] == 1 diff --git a/tests/test_graph_every_asset.py b/tests/test_graph_every_asset.py index 4f035cfc..24ccf8c9 100644 --- a/tests/test_graph_every_asset.py +++ b/tests/test_graph_every_asset.py @@ -221,6 +221,78 @@ def test_renderer_exposes_capacity_and_every_preset() -> None: assert "MAP_SCALE" in worker # the map-spread constant +def test_worker_untagged_nodes_share_one_district_not_n_singletons() -> None: + """Untagged graphs must not make centroid separation quadratic in node count.""" + script = ( + "const nodes = Array.from({ length: 2000 }, (_, i) => ({ id: 'n' + i }));\n" + "send({ type: 'prepare', payload: { nodes, links: [] }});\n" + "setTimeout(() => {\n" + " const ready = latest('ready');\n" + " console.log(JSON.stringify({ uniqueDistricts: new Set(ready.communities).size }));\n" + "}, 50);\n" + ) + report = _run_worker(script) + assert report["uniqueDistricts"] == 1 + + +def test_renderer_create_runs_without_throwing_in_a_minimal_dom() -> None: + """Construction must not hit the live-region TDZ before WebGL capability is known.""" + harness = """ +const vm = require('vm'); const fs = require('fs'); +function makeCanvas() { + return { className: '', style: {}, width: 0, height: 0, + setAttribute() {}, getAttribute() { return null; }, getContext() { return null; }, + addEventListener() {}, removeEventListener() {} }; +} +const host = { + className: '', style: {}, setAttribute() {}, getAttribute() { return null; }, + replaceChildren() {}, appendChild() {}, addEventListener() {}, removeEventListener() {}, + querySelector() { return null; }, querySelectorAll() { return []; }, + getBoundingClientRect() { return { width: 800, height: 600, top: 0, left: 0, right: 800, bottom: 600 }; }, +}; +const win = { + addEventListener() {}, removeEventListener() {}, dispatchEvent() { return true; }, + document: { + createElement(tag) { + if (String(tag).toLowerCase() === 'canvas') return makeCanvas(); + return { className: '', style: {}, setAttribute() {}, getAttribute() { return null; }, + addEventListener() {}, removeEventListener() {} }; + }, + addEventListener() {}, removeEventListener() {}, + body: { classList: { toggle() {}, add() {}, remove() {} } }, + }, + requestAnimationFrame() { return 0; }, cancelAnimationFrame() {}, + matchMedia() { return { matches: false, addEventListener() {} }; }, + devicePixelRatio: 1, navigator: { userAgent: 'contract-test' }, +}; +win.window = win; +try { + vm.runInNewContext(fs.readFileSync('engraphis/dashboard_assets/engraphis-graph-every.js', 'utf8'), win); + const factory = win.EngraphisEveryGraph; + if (!factory || typeof factory.create !== 'function') throw new Error('create missing'); + factory.create(host, {}); + console.log(JSON.stringify({ ok: true })); +} catch (err) { + console.log(JSON.stringify({ ok: false, error: String(err && err.message) })); +} +""" + result = subprocess.run( + ["node", "-e", harness], cwd=ROOT, check=True, capture_output=True, text=True, timeout=60, + ) + report = json.loads(result.stdout) + assert report["ok"], report.get("error") + + +def test_renderer_keeps_labeled_gl_canvas_accessible() -> None: + renderer = RENDERER.read_text(encoding="utf-8") + assert "canvas.setAttribute('aria-hidden'" not in renderer + assert "labels.setAttribute('aria-hidden', 'true');" in renderer + assert "underlay.setAttribute('aria-hidden', 'true');" in renderer + assert renderer.index("const liveRegion = document.createElement('div')") < renderer.index( + "element.appendChild(liveRegion)", + ) + + def test_ledger_routes_the_every_layout_and_restores_filters() -> None: ledger = LEDGER.read_text(encoding="utf-8") markup = MARKUP.read_text(encoding="utf-8") @@ -230,3 +302,9 @@ def test_ledger_routes_the_every_layout_and_restores_filters() -> None: assert 'id="graph-show-all"' not in markup # fully removed, Every node chip is the entry assert "state.everyPriorFilters" in ledger # filter restore contract assert "setGraphMinDegree(0, false)" in ledger + + +def test_ledger_keeps_authored_galaxy_scenes_on_the_hierarchical_engine() -> None: + ledger = LEDGER.read_text(encoding="utf-8") + assert "const galaxyQuality = fullGraph\n && data.nodes.some" in ledger + assert "state.graphSpacetimeOverlay.setEnabled(galaxyQuality || graphIsGalaxy())" in ledger From 2fdec5c9532de51b619c9c2cb3485fd5b63f96b8 Mon Sep 17 00:00:00 2001 From: Coding-Dev-Tools Date: Sun, 23 Aug 2026 21:13:11 -0400 Subject: [PATCH 10/21] fix(graph): bound Every-node focus and centroid work --- engraphis/classic_assets/dashboard.js | 2 +- .../engraphis-graph-every-worker.js | 31 ++-- .../dashboard_assets/engraphis-graph-every.js | 137 +++++++++++------- engraphis/static/dashboard.js | 2 +- tests/test_graph_every_asset.py | 36 ++++- 5 files changed, 141 insertions(+), 67 deletions(-) diff --git a/engraphis/classic_assets/dashboard.js b/engraphis/classic_assets/dashboard.js index 9b2a8f65..9387fafb 100644 --- a/engraphis/classic_assets/dashboard.js +++ b/engraphis/classic_assets/dashboard.js @@ -1222,7 +1222,7 @@ function loadForceGraph(){ }); return FORCE_GRAPH_LOADING; } - let GRAPH_ENGINE_LOADING=null,ALL_GRAPH_ENGINE_LOADING=null; +let GRAPH_ENGINE_LOADING=null,ALL_GRAPH_ENGINE_LOADING=null; function loadAllGraphEngine(){ if(typeof EngraphisAllGraph!=='undefined')return Promise.resolve(); if(!ALL_GRAPH_ENGINE_LOADING){ diff --git a/engraphis/dashboard_assets/engraphis-graph-every-worker.js b/engraphis/dashboard_assets/engraphis-graph-every-worker.js index d46da882..7abe16ba 100644 --- a/engraphis/dashboard_assets/engraphis-graph-every-worker.js +++ b/engraphis/dashboard_assets/engraphis-graph-every-worker.js @@ -15,6 +15,11 @@ 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 }; @@ -286,18 +291,20 @@ } const separation = scaledSpacing * 2.6; const pushStrength = 0.05; - 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; } + 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; } + } } } diff --git a/engraphis/dashboard_assets/engraphis-graph-every.js b/engraphis/dashboard_assets/engraphis-graph-every.js index 2af42118..3fe64e21 100644 --- a/engraphis/dashboard_assets/engraphis-graph-every.js +++ b/engraphis/dashboard_assets/engraphis-graph-every.js @@ -12,7 +12,9 @@ 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 @@ -152,7 +154,7 @@ scope: { minDegree: 0, showUnlinked: true, depth: 2 }, collapse: false, collapsed: false, focus: -1, hover: -1, hoverPoint: [0, 0], focusPoint: [0, 0], - neighbors: null, ready: false, visibleCount: 0, + neighbors: null, incidentEdges: null, connectionHighlights: null, ready: false, visibleCount: 0, frame: 0, labelFrame: 0, flowPaintAt: 0, layoutPending: false, lastLabelKey: '', drag: null, pickGrid: null, pickDirty: true, destroyed: false, paused: false, unsupported: !gl, error: null, @@ -427,7 +429,7 @@ count: members.length, }); } - state.communityRegions = regions; + state.communityRegions = regions.sort((a, b) => b.count - a.count).slice(0, REGION_LIMIT); } function drawRegions() { if (!underlayContext || !state.ready || !state.communityRegions.length) return; @@ -557,49 +559,60 @@ if (!labelContext || !state.ready) return; const anchors = hotAnchors(); if (!anchors.length || !state.edgeVertexCount) return; - const anchorSet = new Set(anchors); + 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)"; - for (let edge = 0; edge < state.totalLinks; edge += 1) { - const source = state.edgeSources[edge], target = state.edgeTargets[edge]; - if (!anchorSet.has(source) && !anchorSet.has(target)) continue; - 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; - 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)"; + 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); + /* 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(); @@ -633,12 +646,7 @@ 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. */ - let connections = []; - if (state.neighbors && state.neighbors[index]) { - connections = state.neighbors[index] - .slice().sort((a, b) => (state.degrees[b] || 0) - (state.degrees[a] || 0)) - .slice(0, 3); - } + const connections = state.connectionHighlights && state.connectionHighlights[index] || []; labelContext.save(); labelContext.font = titleFont; const titleWidth = labelContext.measureText(title).width; @@ -754,12 +762,13 @@ const focusNeighborhood = anchor >= 0 && state.neighbors && state.neighbors[anchor]; if (focusNeighborhood) { consider(anchor); - for (const neighbor of state.neighbors[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(neighbor); + consider(state.neighbors[anchor][slot]); } } - for (let rank = 0; rank < state.topNodes.length && drawn < LABEL_MAX; rank += 1) { + 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; } @@ -944,11 +953,34 @@ /* 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); - if (state.neighbors[target]) state.neighbors[target].push(source); + 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); @@ -1231,6 +1263,7 @@ nodeProgram = edgeProgram = null; nodeBuffers = {}; edgeBuffers = {}; state.ids = []; state.idIndex = new Map(); state.labels = []; state.types = []; state.communities = []; + 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); diff --git a/engraphis/static/dashboard.js b/engraphis/static/dashboard.js index 9b2a8f65..9387fafb 100644 --- a/engraphis/static/dashboard.js +++ b/engraphis/static/dashboard.js @@ -1222,7 +1222,7 @@ function loadForceGraph(){ }); return FORCE_GRAPH_LOADING; } - let GRAPH_ENGINE_LOADING=null,ALL_GRAPH_ENGINE_LOADING=null; +let GRAPH_ENGINE_LOADING=null,ALL_GRAPH_ENGINE_LOADING=null; function loadAllGraphEngine(){ if(typeof EngraphisAllGraph!=='undefined')return Promise.resolve(); if(!ALL_GRAPH_ENGINE_LOADING){ diff --git a/tests/test_graph_every_asset.py b/tests/test_graph_every_asset.py index 24ccf8c9..ed6a2ae5 100644 --- a/tests/test_graph_every_asset.py +++ b/tests/test_graph_every_asset.py @@ -17,8 +17,14 @@ WORKER_HARNESS = """ const vm = require('vm'); const fs = require('fs'); const messages = []; +let stopAtFirstProgress = false; const startedAt = Date.now(); const src = fs.readFileSync('engraphis/dashboard_assets/engraphis-graph-every-worker.js', 'utf8'); -const ctx = { self: { postMessage: m => messages.push(m) }, +const ctx = { self: { postMessage: m => { + messages.push(m); + if (stopAtFirstProgress && m.type === 'progress' && m.pass === 1) { + console.log(JSON.stringify({ firstPassMs: Date.now() - startedAt })); process.exit(0); + } +} }, setTimeout: (f, t) => setTimeout(f, 0), clearTimeout: t => clearTimeout(t) }; vm.runInNewContext(src, ctx); const send = data => ctx.self.onmessage({ data }); @@ -211,6 +217,18 @@ def test_renderer_cleans_up_host_element_listeners_on_destroy() -> None: assert "element.replaceChildren();" in destroy_body +def test_renderer_focus_decorations_use_incident_edges_not_full_link_scan() -> None: + renderer = RENDERER.read_text(encoding="utf-8") + hot_body = renderer[renderer.index("function drawHotEdgeDecorations") : renderer.index("function drawFocusRing")] + assert "state.incidentEdges" in hot_body + assert "state.totalLinks" not in hot_body + assert "FLOW_EDGE_LIMIT" in hot_body + assert "incidentLimit = Math.min(incident.length, FLOW_EDGE_LIMIT)" in hot_body + assert "connectionHighlights" in renderer + assert "LABEL_CANDIDATE_MAX" in renderer + assert "state.incidentEdges = state.ids.map(() => []);" in renderer + + def test_renderer_exposes_capacity_and_every_preset() -> None: renderer = RENDERER.read_text(encoding="utf-8") worker = WORKER.read_text(encoding="utf-8") @@ -235,6 +253,22 @@ def test_worker_untagged_nodes_share_one_district_not_n_singletons() -> None: assert report["uniqueDistricts"] == 1 +def test_worker_many_communities_bound_centroid_separation() -> None: + """Many tagged singleton communities must not reintroduce an O(n^2) first pass.""" + script = """ +const started = Date.now(); +stopAtFirstProgress = true; +const nodes = Array.from({ length: 20000 }, (_, i) => ({ id: `n${i}`, community_id: `c${i}` })); +send({ type: 'prepare', payload: { nodes, links: [] } }); +""" + result = subprocess.run( + ["node", "-e", WORKER_HARNESS + script], cwd=ROOT, check=True, + capture_output=True, text=True, timeout=5, + ) + report = json.loads(result.stdout) + assert report["firstPassMs"] < 4000 + + def test_renderer_create_runs_without_throwing_in_a_minimal_dom() -> None: """Construction must not hit the live-region TDZ before WebGL capability is known.""" harness = """ From f52659e6dd55021ebaef81b41497a6086c43ede4 Mon Sep 17 00:00:00 2001 From: Coding-Dev-Tools Date: Sun, 23 Aug 2026 21:23:54 -0400 Subject: [PATCH 11/21] test(graph): align Every-node control contract --- tests/e2e/ledger.spec.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/e2e/ledger.spec.js b/tests/e2e/ledger.spec.js index d24c3084..9176caa0 100644 --- a/tests/e2e/ledger.spec.js +++ b/tests/e2e/ledger.spec.js @@ -419,7 +419,8 @@ test('Ledger enters All nodes from a loaded overview without losing its scope', await expect(page.locator('#graph-mode')).toContainText('All nodes'); await expect(page.locator('#graph-repo-filter')).toHaveAttribute('placeholder', 'Filter by exact repository name…'); await expect(page.locator('#graph-show-unlinked')).toBeEnabled(); - await expect(page.locator('#graph-depth')).toBeEnabled(); + await expect(page.locator('#graph-depth')).toBeDisabled(); + await expect(page.locator('#graph-depth')).toHaveAttribute('title', 'Not yet available in the Every-node view.'); await expect(page.locator('#graph-flow')).toBeEnabled(); await expect(page.locator('[data-graph-layer="code"]')).toBeEnabled(); await expect(page.locator('#graph-canvas')).toHaveAttribute('aria-busy', 'false'); @@ -430,7 +431,7 @@ test('Ledger enters All nodes from a loaded overview without losing its scope', expect(allQuery.repo).toBe('agent-memory'); expect(allQuery.include_code).toBe('true'); expect(allQuery.as_of).toBe(String(Date.parse('2026-08-14T23:59:59.999Z') / 1000)); - await expect(page.locator('#graph-count')).toContainText('2 visible of 3 entities'); + await expect(page.locator('#graph-count')).toContainText('3 entities'); await page.locator('[data-graph-preset-choice="compact"]').click(); await expect(page.locator('[data-graph-preset-choice="compact"]')).toHaveAttribute('aria-pressed', 'true'); await page.locator('[data-graph-color-choice="type"]').click(); From 9e11f37f2642b6bf077c79c001691999fd3d4a8a Mon Sep 17 00:00:00 2001 From: Coding-Dev-Tools Date: Sun, 23 Aug 2026 22:13:46 -0400 Subject: [PATCH 12/21] fix(graph): clear stale every-node reload state --- .../engraphis-graph-every-worker.js | 3 ++ .../dashboard_assets/engraphis-graph-every.js | 19 ++++++++++-- tests/test_graph_every_asset.py | 30 +++++++++++++++++++ 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/engraphis/dashboard_assets/engraphis-graph-every-worker.js b/engraphis/dashboard_assets/engraphis-graph-every-worker.js index 7abe16ba..4b52dbcb 100644 --- a/engraphis/dashboard_assets/engraphis-graph-every-worker.js +++ b/engraphis/dashboard_assets/engraphis-graph-every-worker.js @@ -351,6 +351,9 @@ 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(); diff --git a/engraphis/dashboard_assets/engraphis-graph-every.js b/engraphis/dashboard_assets/engraphis-graph-every.js index 3fe64e21..eb477c94 100644 --- a/engraphis/dashboard_assets/engraphis-graph-every.js +++ b/engraphis/dashboard_assets/engraphis-graph-every.js @@ -201,7 +201,7 @@ 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 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]; } @@ -925,6 +925,9 @@ 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); @@ -1278,7 +1281,17 @@ 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.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.edgeRelations = []; 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; }, @@ -1363,7 +1376,7 @@ zoomToNode(id) { return api.reveal(id); }, communityMap() { const result = {}; - state.ids.forEach((id, index) => { result[id] = state.communities[index] || index; }); + state.ids.forEach((id, index) => { result[id] = state.communities[index] ?? index; }); return result; }, resize, fit, diff --git a/tests/test_graph_every_asset.py b/tests/test_graph_every_asset.py index ed6a2ae5..f472f916 100644 --- a/tests/test_graph_every_asset.py +++ b/tests/test_graph_every_asset.py @@ -229,6 +229,23 @@ def test_renderer_focus_decorations_use_incident_edges_not_full_link_scan() -> N assert "state.incidentEdges = state.ids.map(() => []);" in renderer +def test_renderer_preserves_zero_community_for_untagged_nodes() -> None: + renderer = RENDERER.read_text(encoding="utf-8") + assert "String(state.communities[index] ?? index)" in renderer + assert "result[id] = state.communities[index] ?? index" in renderer + assert "String(state.communities[index] || index)" not in renderer + + +def test_renderer_adopts_seeded_preview_positions_and_clears_reload_metadata() -> None: + renderer = RENDERER.read_text(encoding="utf-8") + adopt_body = renderer[renderer.index("function adoptCommon") : renderer.index("function handleWorkerMessage")] + set_data_body = renderer[renderer.index("setData(data)") : renderer.index("setRenderMode")] + assert "state.positions = message.positions || state.positions;" in adopt_body + assert "state.edgeSources = new Uint32Array(0);" in set_data_body + assert "state.totalLinks = 0;" in set_data_body + assert "state.neighbors = null; state.incidentEdges = null; state.connectionHighlights = null;" in set_data_body + + def test_renderer_exposes_capacity_and_every_preset() -> None: renderer = RENDERER.read_text(encoding="utf-8") worker = WORKER.read_text(encoding="utf-8") @@ -269,6 +286,19 @@ def test_worker_many_communities_bound_centroid_separation() -> None: assert report["firstPassMs"] < 4000 +def test_worker_capacity_replacement_clears_previous_model() -> None: + """An over-capacity reload must not let reheat revive the prior graph model.""" + script = """ +send({ type: 'prepare', payload: { nodes: [{ id: 'old' }], links: [] } }); +send({ type: 'prepare', payload: { nodes: Array.from({ length: 20001 }, (_, i) => ({ id: `n${i}` })), links: [] } }); +send({ type: 'reheat' }); +setTimeout(() => console.log(JSON.stringify({ layouts: all('layout').length, capacity: all('capacity').length })), 40); +""" + report = _run_worker(script) + assert report["capacity"] == 1 + assert report["layouts"] == 0 + + def test_renderer_create_runs_without_throwing_in_a_minimal_dom() -> None: """Construction must not hit the live-region TDZ before WebGL capability is known.""" harness = """ From a6f28a813996d3f1e205b27068673294caf25470 Mon Sep 17 00:00:00 2001 From: Coding-Dev-Tools Date: Sun, 23 Aug 2026 22:50:40 -0400 Subject: [PATCH 13/21] fix(graph): harden every-node interaction and reload state --- .../engraphis-graph-every-worker.js | 2 ++ .../dashboard_assets/engraphis-graph-every.js | 16 +++++++++--- engraphis/dashboard_assets/ledger.css | 8 ++++-- engraphis/dashboard_assets/ledger.js | 16 ++++++++---- tests/test_graph_every_asset.py | 25 +++++++++++++++++++ 5 files changed, 57 insertions(+), 10 deletions(-) diff --git a/engraphis/dashboard_assets/engraphis-graph-every-worker.js b/engraphis/dashboard_assets/engraphis-graph-every-worker.js index 4b52dbcb..ce7620b4 100644 --- a/engraphis/dashboard_assets/engraphis-graph-every-worker.js +++ b/engraphis/dashboard_assets/engraphis-graph-every-worker.js @@ -338,6 +338,8 @@ type: 'layout', positions: model.positions.slice(), bounds: { ...model.bounds }, + pass, + total: REFINE_PASSES, fit: pass === REFINE_PASSES ? fitFinal === true : false, }); } diff --git a/engraphis/dashboard_assets/engraphis-graph-every.js b/engraphis/dashboard_assets/engraphis-graph-every.js index eb477c94..ea8f6bd4 100644 --- a/engraphis/dashboard_assets/engraphis-graph-every.js +++ b/engraphis/dashboard_assets/engraphis-graph-every.js @@ -523,7 +523,10 @@ } } if (best < 0) return -1; - const reach = pointSize(best) * state.camera.scale + 7; + /* 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; } @@ -901,6 +904,11 @@ 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; @@ -1002,10 +1010,12 @@ return; } if (message.type === 'layout') { - state.layoutPending = false; + 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: false }); + stats({ layoutPending: state.layoutPending }); return; } } diff --git a/engraphis/dashboard_assets/ledger.css b/engraphis/dashboard_assets/ledger.css index 45638448..52e9fda4 100644 --- a/engraphis/dashboard_assets/ledger.css +++ b/engraphis/dashboard_assets/ledger.css @@ -601,8 +601,12 @@ body[data-theme="paper"] .graph-header { .graph-actions { display: flex; gap: 6px; } .graph-canvas { position: absolute; inset: 0; } .graph-canvas .engraphis-all-canvas, -.graph-canvas .engraphis-all-labels { position: absolute; inset: 0; width: 100%; height: 100%; display: block; } -.graph-canvas .engraphis-all-labels { pointer-events: none; } +.graph-canvas .engraphis-all-labels, +.graph-canvas .engraphis-all-underlay { + position: absolute; inset: 0; width: 100%; height: 100%; display: block; +} +.graph-canvas .engraphis-all-labels, +.graph-canvas .engraphis-all-underlay { pointer-events: none; } .graph-canvas.engraphis-all-node-hover { cursor: pointer; } .force-graph-container canvas { display: block; diff --git a/engraphis/dashboard_assets/ledger.js b/engraphis/dashboard_assets/ledger.js index 240f741c..28b93b1e 100644 --- a/engraphis/dashboard_assets/ledger.js +++ b/engraphis/dashboard_assets/ledger.js @@ -2029,13 +2029,18 @@ })); } + function graphEndpoint(value) { + if (value && typeof value === 'object') return value.id ?? value; + return value; + } + function graphLinks(payload) { const source = payload.edges || payload.links || []; return source.map((item, index) => ({ ...item, id: item.id || `edge-${index}`, - source: item.from || (item.source && (item.source.id || item.source)), - target: item.to || (item.target && (item.target.id || item.target)), + source: item.from ?? graphEndpoint(item.source), + target: item.to ?? graphEndpoint(item.target), label: item.label || item.relation || 'related', layer: item.layer || 'semantic', valid_from: item.valid_from, @@ -2047,7 +2052,8 @@ ghost: item.ghost === true, bridge: item.bridge === true, visible_by_default: item.visible_by_default !== false, - })).filter(item => item.source && item.target); + })).filter(item => item.source !== undefined && item.source !== null + && item.target !== undefined && item.target !== null); } function revealGraphNode(id, label = 'Selected entity') { @@ -2157,7 +2163,7 @@ } async function showGraphConnectionMemories(item, includeHistory = false) { - if (!item || !item.id || !state.workspace) return; + if (!item || item.id === undefined || item.id === null || !state.workspace) return; cancelGraphConnectionMemoryLoad(); const request = ++state.graphConnectionsRequest; const workspace = state.workspace; @@ -2233,7 +2239,7 @@ } function openGraphConnections(item) { - if (!item || !item.id) return; + if (!item || item.id === undefined || item.id === null) return; cancelGraphConnectionMemoryLoad(); state.graphConnectionsFocusId = String(item.id); state.graphConnectionsFocusLabel = item.name || item.label || item.id; diff --git a/tests/test_graph_every_asset.py b/tests/test_graph_every_asset.py index f472f916..9e0d7949 100644 --- a/tests/test_graph_every_asset.py +++ b/tests/test_graph_every_asset.py @@ -14,6 +14,7 @@ RENDERER = ROOT / "engraphis" / "dashboard_assets" / "engraphis-graph-every.js" LEDGER = ROOT / "engraphis" / "dashboard_assets" / "ledger.js" MARKUP = ROOT / "engraphis" / "dashboard_assets" / "index.html" +CSS = ROOT / "engraphis" / "dashboard_assets" / "ledger.css" WORKER_HARNESS = """ const vm = require('vm'); const fs = require('fs'); const messages = []; @@ -106,6 +107,8 @@ def test_worker_streams_preview_ready_progress_and_settling_layouts() -> None: nodes: ready.ids.length, layout_count: layouts.length, final_fit: layouts.at(-1).fit, + first_pass: layouts[0].pass, + final_pass: layouts.at(-1).pass, settled: layouts.length > 1 && layouts.at(-1).positions.some((v, i) => v !== layouts[0].positions[i]), bounds_present: typeof ready.bounds.minX === 'number', top_nodes: ready.topNodes.length, @@ -116,6 +119,8 @@ def test_worker_streams_preview_ready_progress_and_settling_layouts() -> None: assert report["order_ok"] is True assert report["counts"]["progress"] == 26 # REFINE_PASSES fully accounted for assert report["counts"]["layout"] == 7 # every 4th pass plus the final one + assert report["first_pass"] == 4 + assert report["final_pass"] == 26 assert report["nodes"] == 120 assert report["final_fit"] is True assert report["settled"] is True @@ -198,6 +203,26 @@ def test_renderer_uploads_change_driven_and_reports_honest_edge_counts() -> None assert "state.bridges && state.edgeBridges[index]" in renderer # toggle re-upload +def test_renderer_scales_picking_and_highlight_points_in_world_units() -> None: + renderer = RENDERER.read_text(encoding="utf-8") + assert "pointSize(best) + 7 / Math.max(0.005, state.camera.scale)" in renderer + assert "state[key] = [state.positions[index * 2], state.positions[index * 2 + 1]];" in renderer + + +def test_renderer_layers_the_retina_safe_underlay_without_capturing_input() -> None: + css = CSS.read_text(encoding="utf-8") + assert ".graph-canvas .engraphis-all-underlay" in css + assert ".graph-canvas .engraphis-all-underlay { pointer-events: none; }" in css + + +def test_ledger_preserves_falsy_graph_endpoints() -> None: + ledger = LEDGER.read_text(encoding="utf-8") + assert "function graphEndpoint(value)" in ledger + assert "source: item.from ?? graphEndpoint(item.source)" in ledger + assert "target: item.to ?? graphEndpoint(item.target)" in ledger + assert "item.source !== undefined && item.source !== null" in ledger + + def test_renderer_export_is_synchronous_and_composites_every_layer() -> None: renderer = RENDERER.read_text(encoding="utf-8") body = renderer[renderer.index("function exportImageCanvas"):renderer.index("function destroyGraph")] From af7a3d793cd5080105c2bb4917accf98333a97b7 Mon Sep 17 00:00:00 2001 From: Coding-Dev-Tools Date: Sun, 23 Aug 2026 23:12:11 -0400 Subject: [PATCH 14/21] fix(graph): polish Every-node branch for merge readiness - Fix .gitignore pattern typos (duplicate double-dot scratch rule, literal --- .gitignore | 5 ++--- BENCHMARKS.md | 8 ++++++++ CHANGELOG.md | 3 +++ 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 1fb448b0..44395c8c 100644 --- a/.gitignore +++ b/.gitignore @@ -117,9 +117,8 @@ uv.lock # Every-node stress fixtures (local only) .scratch-*.db* -.seed20k.py -.seed-graph.py -..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. 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 e42c0678..aeb8ecc1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -125,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. - Importing more than 1,000 files through the dashboard no longer fails with "Internal Server Error": wizard upload routes parse multipart forms under the advertised 1,500-file ceiling instead of Starlette's hidden 1,000-part parser default, oversized batches return a clear 413, From 44aa352504c43bdcca0e2b70b318239a2cdc7ebf Mon Sep 17 00:00:00 2001 From: Coding-Dev-Tools Date: Mon, 24 Aug 2026 00:42:55 -0400 Subject: [PATCH 15/21] fix(graph): honor Every-node edge visibility filters --- .../engraphis-graph-every-worker.js | 9 ++- .../dashboard_assets/engraphis-graph-every.js | 74 ++++++++++++++++--- tests/test_graph_every_asset.py | 21 +++++- 3 files changed, 89 insertions(+), 15 deletions(-) diff --git a/engraphis/dashboard_assets/engraphis-graph-every-worker.js b/engraphis/dashboard_assets/engraphis-graph-every-worker.js index ce7620b4..7028eb13 100644 --- a/engraphis/dashboard_assets/engraphis-graph-every-worker.js +++ b/engraphis/dashboard_assets/engraphis-graph-every-worker.js @@ -98,6 +98,8 @@ 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)); @@ -108,6 +110,8 @@ 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; } @@ -146,6 +150,8 @@ 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, @@ -387,9 +393,10 @@ edgeSources: model.sources, edgeTargets: model.targets, edgeBridges: model.edgeBridges, + edgeGhosts: model.edgeGhosts, edgeWeights: model.weights, edgeRelations: model.relations, - edgeLayers: [], + edgeLayers: model.edgeLayers, topNodes: model.topNodes, totalLinks: model.totalLinks, }); diff --git a/engraphis/dashboard_assets/engraphis-graph-every.js b/engraphis/dashboard_assets/engraphis-graph-every.js index ea8f6bd4..48d13a60 100644 --- a/engraphis/dashboard_assets/engraphis-graph-every.js +++ b/engraphis/dashboard_assets/engraphis-graph-every.js @@ -76,11 +76,11 @@ outputColor = vec4(v_color, alpha); }`; const EDGE_VS = `#version 300 es - in vec2 a_position; in float a_factor; + 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_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; @@ -90,13 +90,15 @@ 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_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. */ @@ -145,7 +147,7 @@ topNodes: new Uint32Array(0), edgeSources: new Uint32Array(0), edgeTargets: new Uint32Array(0), edgeBridges: new Uint8Array(0), edgeWeights: new Float32Array(0), - edgeRelations: [], + 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', @@ -155,7 +157,7 @@ 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: '', + 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(), @@ -251,6 +253,7 @@ 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'), @@ -264,6 +267,7 @@ 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'), @@ -289,6 +293,13 @@ 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) { @@ -298,7 +309,10 @@ } state.visibleCount = visible; state.pickDirty = true; + state.lastLabelKey = ''; + state.labelLayout = []; uploadNodeMeta(); + uploadEdges(); applyHoverToFlags(); if (repaint) scheduleLabels(true); } @@ -362,6 +376,7 @@ 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); @@ -372,6 +387,13 @@ 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; @@ -392,6 +414,8 @@ 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; } @@ -526,7 +550,7 @@ /* 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); + const reach = (pointSize(best) + 7) / Math.max(0.005, state.camera.scale); return bestDist <= reach * reach ? best : -1; } @@ -575,6 +599,7 @@ 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; @@ -704,6 +729,7 @@ 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]; @@ -730,13 +756,19 @@ 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}`; - if (cacheKey === state.lastLabelKey) return; - state.lastLabelKey = cacheKey; 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; @@ -758,6 +790,7 @@ 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 @@ -867,6 +900,9 @@ 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); @@ -946,6 +982,7 @@ 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', @@ -957,8 +994,10 @@ 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 @@ -1263,7 +1302,7 @@ element.removeEventListener('keydown', handleKeydown); if (gl) { [nodeBuffers.position, nodeBuffers.color, nodeBuffers.size, nodeBuffers.flag, - edgeBuffers.position, edgeBuffers.factor].forEach(buffer => { + edgeBuffers.position, edgeBuffers.factor, edgeBuffers.visible].forEach(buffer => { if (buffer && typeof gl.deleteBuffer === 'function') gl.deleteBuffer(buffer); }); [nodeProgram, edgeProgram].forEach(value => { @@ -1276,6 +1315,9 @@ 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); @@ -1296,7 +1338,8 @@ 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.edgeRelations = []; state.topNodes = new Uint32Array(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); @@ -1340,6 +1383,16 @@ 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; }, @@ -1352,7 +1405,6 @@ return api; }, setGhosts(value) { state.ghosts = value !== false; refreshVisibility(); camera(); return api; }, - setLayers(value) { state.layers = value || null; return api; }, setHighlight(id) { const index = state.idIndex.get(String(id)); state.focus = index === undefined ? -1 : index; diff --git a/tests/test_graph_every_asset.py b/tests/test_graph_every_asset.py index 9e0d7949..96541eee 100644 --- a/tests/test_graph_every_asset.py +++ b/tests/test_graph_every_asset.py @@ -47,8 +47,8 @@ def test_worker_compacts_to_typed_arrays_and_preserves_falsy_ids() -> None: send({ type: 'prepare', payload: { nodes: [{ id: 0, name: 'Zero', community_id: 'a' }, { id: false, name: 'Falsey', ghost: true, community_id: 'a' }, { id: 'leaf' }], links: [ - { source: 0, target: false, weight: 3, relation: 'mentions' }, - { source: { id: false }, target: 'leaf' }, + { source: 0, target: false, weight: 3, relation: 'mentions', layer: 'temporal' }, + { source: { id: false }, target: 'leaf', layer: 'code', ghost: true }, { source: 'ghost-node', target: 'leaf' }, ], }}); @@ -58,6 +58,7 @@ def test_worker_compacts_to_typed_arrays_and_preserves_falsy_ids() -> None: ids: ready.ids, links: ready.totalLinks, sources: Array.from(ready.edgeSources), targets: Array.from(ready.edgeTargets), weights: Array.from(ready.edgeWeights), relations: ready.edgeRelations, + layers: ready.edgeLayers, edgeGhosts: Array.from(ready.edgeGhosts), ghosts: Array.from(ready.nodeGhosts), positionsType: ready.positions.constructor.name, bridges: Array.from(ready.edgeBridges), })); @@ -70,6 +71,8 @@ def test_worker_compacts_to_typed_arrays_and_preserves_falsy_ids() -> None: assert report["targets"] == [1, 2] assert report["weights"] == [3.0, 1.0] # missing weight defaults to 1 assert report["relations"] == ["mentions", ""] + assert report["layers"] == ["temporal", "code"] + assert report["edgeGhosts"] == [0, 1] assert report["ghosts"] == [0, 1, 0] assert report["positionsType"] == "Float32Array" # Edge 0 joins two members of community 'a' (no bridge); edge 1 crosses communities. @@ -201,14 +204,26 @@ def test_renderer_uploads_change_driven_and_reports_honest_edge_counts() -> None assert "drawnLinks: drawn" in renderer assert "hiddenLinks: Math.max(0, state.totalLinks - drawn)" in renderer assert "state.bridges && state.edgeBridges[index]" in renderer # toggle re-upload + assert "function edgePassesFilters(index)" in renderer + assert "edgeBuffers.visible" in renderer + assert "state.layers[layer] !== false" in renderer + assert "state.edgeGhosts[index]" in renderer def test_renderer_scales_picking_and_highlight_points_in_world_units() -> None: renderer = RENDERER.read_text(encoding="utf-8") - assert "pointSize(best) + 7 / Math.max(0.005, state.camera.scale)" in renderer + assert "(pointSize(best) + 7) / Math.max(0.005, state.camera.scale)" in renderer assert "state[key] = [state.positions[index * 2], state.positions[index * 2 + 1]];" in renderer +def test_renderer_repaints_cached_labels_after_overlay_clear() -> None: + renderer = RENDERER.read_text(encoding="utf-8") + labels = renderer[renderer.index("function drawDeclutteredLabels") : renderer.index("function flowAnimating")] + assert "state.labelLayout.forEach(item => labelContext.fillText(item.text, item.x, item.y));" in labels + assert "state.labelLayout.push({ text, x: point[0] + 6, y: point[1] - 6 });" in labels + assert "if (cacheKey === state.lastLabelKey) return;" not in labels + + def test_renderer_layers_the_retina_safe_underlay_without_capturing_input() -> None: css = CSS.read_text(encoding="utf-8") assert ".graph-canvas .engraphis-all-underlay" in css From 354c9407ddc80a021fd74a5ef8b715093ba13508 Mon Sep 17 00:00:00 2001 From: Coding-Dev-Tools Date: Mon, 24 Aug 2026 02:01:59 -0400 Subject: [PATCH 16/21] fix(graph): preload both full-view engines before scene selection --- engraphis/dashboard_assets/ledger.js | 13 ++++++------- tests/test_dashboard_v2.py | 3 ++- tests/test_graph_every_asset.py | 4 ++++ 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/engraphis/dashboard_assets/ledger.js b/engraphis/dashboard_assets/ledger.js index 28b93b1e..f2c1ca4e 100644 --- a/engraphis/dashboard_assets/ledger.js +++ b/engraphis/dashboard_assets/ledger.js @@ -438,12 +438,11 @@ /* The complete profile is an independent worker/WebGL renderer. Galaxy is the exception: its solar-system view needs the authoritative hierarchical orbit integrator, so a full Galaxy request uses the quality engine with the complete payload instead of the static - all-node worker. Other full presets retain the worker/WebGL path and its 20k-node cap. */ - if (loadAll && !graphIsGalaxy()) return ensureGraphAllAsset(); - if (loadAll && graphIsGalaxy()) { - /* Load both candidates before the complete scene arrives. The factory decision below is - data-sensitive: an ordinary graph that merely uses the Galaxy preset keeps the worker, - while an authored star/planet scene gets the live hierarchical engine. */ + all-node worker. The factory decision is data-sensitive, not toolbar-sensitive: the + Every-node chip changes the preset to "every" before the scene arrives, and a fast click + can race the overview load. Keep both candidates available so an authored star/planet + scene cannot select an engine whose asset is still in flight. */ + if (loadAll) { return Promise.all([ensureGraphAllAsset(), ensureGraphAssets(false)]); } const coreReady = window.ForceGraph && window.EngraphisGraph && window.EngraphisSpacetime; @@ -3102,7 +3101,7 @@ rejectTimeout = reject; }); const timeout = window.setTimeout(() => { - if (!fullGraph && (!window.ForceGraph || !window.EngraphisGraph || !window.EngraphisSpacetime)) { + if (!window.ForceGraph || !window.EngraphisGraph || !window.EngraphisSpacetime) { releaseGraphAssetsAttempt(graphAssetsPromise); } if (fullGraph && !window.EngraphisEveryGraph) { diff --git a/tests/test_dashboard_v2.py b/tests/test_dashboard_v2.py index aa5db89a..cb4efc40 100644 --- a/tests/test_dashboard_v2.py +++ b/tests/test_dashboard_v2.py @@ -930,7 +930,8 @@ def test_all_nodes_mode_preserves_scope_preferences_and_bounds_heavy_work(monkey assert "showUnlinked: state.graphShowUnlinked" in script.text assert "includeCode: state.graphIncludeCode" in script.text assert "minDegree: number(byId('graph-min-degree').value)" in script.text - assert "if (loadAll && !graphIsGalaxy()) return ensureGraphAllAsset();" in script.text + assert "if (loadAll) {" in script.text + assert "return Promise.all([ensureGraphAllAsset(), ensureGraphAssets(false)]);" in script.text assert "const graphFactory = galaxyQuality ? window.EngraphisGraph" in script.text assert "scopeControl.disabled = full" not in script.text assert "graph.setCollapse(byId('graph-collapse').checked ? 'auto' : false)" in script.text diff --git a/tests/test_graph_every_asset.py b/tests/test_graph_every_asset.py index 96541eee..991b371f 100644 --- a/tests/test_graph_every_asset.py +++ b/tests/test_graph_every_asset.py @@ -412,3 +412,7 @@ def test_ledger_keeps_authored_galaxy_scenes_on_the_hierarchical_engine() -> Non ledger = LEDGER.read_text(encoding="utf-8") assert "const galaxyQuality = fullGraph\n && data.nodes.some" in ledger assert "state.graphSpacetimeOverlay.setEnabled(galaxyQuality || graphIsGalaxy())" in ledger + # The Every-node chip changes the toolbar preset before the complete scene arrives. Both + # candidates must therefore be preloaded so a fast entry cannot select a missing Galaxy + # engine while the overview's core asset is still in flight. + assert "if (loadAll) {\n return Promise.all([ensureGraphAllAsset(), ensureGraphAssets(false)]);\n }" in ledger From a60556df8823bb4604dc95e3846884ebc731c98f Mon Sep 17 00:00:00 2001 From: Coding-Dev-Tools Date: Mon, 24 Aug 2026 02:47:55 -0400 Subject: [PATCH 17/21] fix(graph): restore keyboard focus visibility --- engraphis/dashboard_assets/ledger.css | 8 ++++++-- tests/test_graph_every_asset.py | 1 + 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/engraphis/dashboard_assets/ledger.css b/engraphis/dashboard_assets/ledger.css index 52e9fda4..fc9e525c 100644 --- a/engraphis/dashboard_assets/ledger.css +++ b/engraphis/dashboard_assets/ledger.css @@ -1302,8 +1302,12 @@ body[data-theme="paper"] .graph-header { .metrics { grid-template-columns: 1fr 1fr; } .graph-actions { flex-wrap: wrap; } } -/* Focus visibility for the style chips is handled by the shared focus-visible rules - here (static CSS, CSP-safe) rather than mutating element.style at runtime. */ +/* Avoid a pointer-focus ring around the canvas while retaining a visible keyboard + indicator for the tab-focusable graph host (static CSS, CSP-safe). */ div[data-graph-style]:focus { outline: none; } +div[data-graph-style]:focus-visible { + outline: 2px solid var(--c-acc); + outline-offset: -2px; +} diff --git a/tests/test_graph_every_asset.py b/tests/test_graph_every_asset.py index 991b371f..a7696633 100644 --- a/tests/test_graph_every_asset.py +++ b/tests/test_graph_every_asset.py @@ -228,6 +228,7 @@ def test_renderer_layers_the_retina_safe_underlay_without_capturing_input() -> N css = CSS.read_text(encoding="utf-8") assert ".graph-canvas .engraphis-all-underlay" in css assert ".graph-canvas .engraphis-all-underlay { pointer-events: none; }" in css + assert "div[data-graph-style]:focus-visible" in css def test_ledger_preserves_falsy_graph_endpoints() -> None: From 407d8877a0f876292dc24889d75534a0f57675d6 Mon Sep 17 00:00:00 2001 From: Coding-Dev-Tools Date: Mon, 24 Aug 2026 04:41:57 -0400 Subject: [PATCH 18/21] fix(graph): clear stale every-node region overlays --- engraphis/dashboard_assets/engraphis-graph-every.js | 10 +++++++++- tests/test_graph_every_asset.py | 9 +++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/engraphis/dashboard_assets/engraphis-graph-every.js b/engraphis/dashboard_assets/engraphis-graph-every.js index 48d13a60..ed508c7c 100644 --- a/engraphis/dashboard_assets/engraphis-graph-every.js +++ b/engraphis/dashboard_assets/engraphis-graph-every.js @@ -311,6 +311,9 @@ 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(); @@ -456,7 +459,12 @@ state.communityRegions = regions.sort((a, b) => b.count - a.count).slice(0, REGION_LIMIT); } function drawRegions() { - if (!underlayContext || !state.ready || !state.communityRegions.length) return; + 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 diff --git a/tests/test_graph_every_asset.py b/tests/test_graph_every_asset.py index a7696633..0a3f1f29 100644 --- a/tests/test_graph_every_asset.py +++ b/tests/test_graph_every_asset.py @@ -231,6 +231,15 @@ def test_renderer_layers_the_retina_safe_underlay_without_capturing_input() -> N assert "div[data-graph-style]:focus-visible" in css +def test_renderer_rebuilds_and_clears_stale_community_regions() -> None: + renderer = RENDERER.read_text(encoding="utf-8") + refresh = renderer[renderer.index("function refreshVisibility") : renderer.index("function applyHoverToFlags")] + regions = renderer[renderer.index("function drawRegions") : renderer.index("function buildPickGrid")] + assert "computeCommunityRegions();" in refresh + assert "if (!state.communityRegions.length)" in regions + assert "underlayContext.clearRect(0, 0, state.width, state.height);" in regions + + def test_ledger_preserves_falsy_graph_endpoints() -> None: ledger = LEDGER.read_text(encoding="utf-8") assert "function graphEndpoint(value)" in ledger From 2fde5858cc5814375132e493a1b0208f951cfb4e Mon Sep 17 00:00:00 2001 From: Coding-Dev-Tools Date: Mon, 24 Aug 2026 05:06:42 -0400 Subject: [PATCH 19/21] fix(graph): use Every-node loader global in legacy dashboards --- engraphis/classic_assets/dashboard.js | 8 ++++---- engraphis/static/dashboard.js | 8 ++++---- tests/test_graph_engine_asset.py | 6 +++--- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/engraphis/classic_assets/dashboard.js b/engraphis/classic_assets/dashboard.js index 9387fafb..1a234d90 100644 --- a/engraphis/classic_assets/dashboard.js +++ b/engraphis/classic_assets/dashboard.js @@ -1224,12 +1224,12 @@ function loadForceGraph(){ } let GRAPH_ENGINE_LOADING=null,ALL_GRAPH_ENGINE_LOADING=null; function loadAllGraphEngine(){ - if(typeof EngraphisAllGraph!=='undefined')return Promise.resolve(); + 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 EngraphisAllGraph==='undefined'?reject(new Error('All-node graph asset loaded without registering EngraphisAllGraph')):resolve()}; - script.onerror=()=>reject(new Error('All-node graph asset could not load')); + 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(()=>{}); @@ -1264,7 +1264,7 @@ function graphRender(fit=true,reheat=true){ 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'||(graphFull&&typeof EngraphisAllGraph==='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; diff --git a/engraphis/static/dashboard.js b/engraphis/static/dashboard.js index 9387fafb..1a234d90 100644 --- a/engraphis/static/dashboard.js +++ b/engraphis/static/dashboard.js @@ -1224,12 +1224,12 @@ function loadForceGraph(){ } let GRAPH_ENGINE_LOADING=null,ALL_GRAPH_ENGINE_LOADING=null; function loadAllGraphEngine(){ - if(typeof EngraphisAllGraph!=='undefined')return Promise.resolve(); + 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 EngraphisAllGraph==='undefined'?reject(new Error('All-node graph asset loaded without registering EngraphisAllGraph')):resolve()}; - script.onerror=()=>reject(new Error('All-node graph asset could not load')); + 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(()=>{}); @@ -1264,7 +1264,7 @@ function graphRender(fit=true,reheat=true){ 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'||(graphFull&&typeof EngraphisAllGraph==='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; diff --git a/tests/test_graph_engine_asset.py b/tests/test_graph_engine_asset.py index cfd31135..e19b164b 100644 --- a/tests/test_graph_engine_asset.py +++ b/tests/test_graph_engine_asset.py @@ -279,7 +279,7 @@ def test_engine_node_labels_honor_the_configured_font_at_normal_zoom() -> None: globalThis.GHILITE = globalThis.GHOVERSET = null; globalThis.GRAPH_FULL = scenario === 'all-loaded' || scenario === 'all-runtime-failed'; if (globalThis.GRAPH_FULL) globalThis.EngraphisGraph = { create() {} }; -if (scenario === 'all-runtime-failed') globalThis.EngraphisAllGraph = { create() {} }; +if (scenario === 'all-runtime-failed') globalThis.EngraphisEveryGraph = { create() {} }; /* All mode intentionally has no vendor global: its renderer must remain self-contained. */ if (!globalThis.GRAPH_FULL) globalThis.ForceGraph = function () {}; @@ -295,7 +295,7 @@ def test_engine_node_labels_honor_the_configured_font_at_normal_zoom() -> None: /* loadGraphEngine(true) chains the already-ready core through one microtask before it requests the optional all-node asset. */ Promise.resolve().then(() => { - globalThis.EngraphisAllGraph = { create() {} }; pending.onload(); finish(); + globalThis.EngraphisEveryGraph = { create() {} }; pending.onload(); finish(); }); } else { if (scenario === 'loads' || scenario === 'classic') { @@ -409,7 +409,7 @@ def test_lazy_graph_engine_load_cannot_raise_an_unhandled_rejection() -> None: # A 200 that never registers the global is a corrupt asset, not a success. assert "reject(new Error('Graph engine asset loaded without registering EngraphisGraph'))" in loader assert "ALL_GRAPH_ENGINE_LOADING.catch(()=>{})" in source - assert "graphFull&&typeof EngraphisAllGraph==='undefined'" in source + assert "graphFull&&typeof EngraphisEveryGraph==='undefined'" in source def test_force_graph_loader_rejects_a_success_without_the_vendor_global() -> None: From 31be36ede7a69726f47439f5984f4a83984424d2 Mon Sep 17 00:00:00 2001 From: Coding-Dev-Tools Date: Mon, 24 Aug 2026 07:58:09 -0400 Subject: [PATCH 20/21] Align Every-node browser contract --- engraphis/dashboard_assets/ledger.js | 4 ++- tests/e2e/ledger.spec.js | 46 ++++++++++++++-------------- tests/test_dashboard_v2.py | 6 ++-- 3 files changed, 30 insertions(+), 26 deletions(-) diff --git a/engraphis/dashboard_assets/ledger.js b/engraphis/dashboard_assets/ledger.js index 5a428385..60c5ef66 100644 --- a/engraphis/dashboard_assets/ledger.js +++ b/engraphis/dashboard_assets/ledger.js @@ -3175,11 +3175,13 @@ } function setGraphLoadControlsBusy(busy, disableRetry = true) { - const controls = disableRetry ? ['graph-show-all', 'graph-retry'] : ['graph-show-all']; + const controls = disableRetry ? ['graph-retry'] : []; controls.forEach(id => { const control = byId(id); if (control) control.disabled = busy; }); + const every = document.querySelector('[data-graph-preset-choice="every"]'); + if (every) every.disabled = busy; const retry = byId('graph-retry'); if (retry && ((busy && disableRetry) || !busy)) { retry.textContent = busy ? 'Reloading graph…' : 'Reload data'; diff --git a/tests/e2e/ledger.spec.js b/tests/e2e/ledger.spec.js index 74ab2b70..a903b3ce 100644 --- a/tests/e2e/ledger.spec.js +++ b/tests/e2e/ledger.spec.js @@ -404,7 +404,7 @@ test('Ledger retries a failed lazy graph load and opens search evidence by keybo await expect(dialog.locator('#graph-connection-memory-list')).toContainText('Database choice'); }); -test('Ledger enters All nodes from a loaded overview without losing its scope', async ({ page }) => { +test('Ledger enters Every node from a loaded overview without losing its scope', async ({ page }) => { const allAssetRequests = []; page.on('request', request => { const pathname = new URL(request.url()).pathname; @@ -483,7 +483,7 @@ test('Ledger enters All nodes from a loaded overview without losing its scope', expect(allAssetRequests).toHaveLength(1); }); -test('Ledger keeps authored Galaxy coordinates in the All-node renderer', async ({ page }) => { +test('Ledger keeps authored Galaxy coordinates in the Every-node renderer', async ({ page }) => { await mockApi(page, { graphScene: { nodes: [ @@ -519,10 +519,10 @@ test('Ledger keeps authored Galaxy coordinates in the All-node renderer', async await page.locator('[data-graph-preset-choice="every"]').click(); await expect(page.locator('#graph-canvas')).toHaveAttribute('aria-busy', 'false'); await expect(page.locator('.engraphis-all-canvas')).toHaveCount(1); - await expect(page.locator('.graph-spacetime-overlay')).toHaveCount(0); + await expect(page.locator('.graph-spacetime-overlay')).toHaveCount(1); }); -test('Ledger keeps the committed All-node renderer visible when a superseded load resolves', async ({ page }) => { +test('Ledger keeps the committed Every-node renderer visible when a superseded load resolves', async ({ page }) => { let releaseFirstScene; let deferredAllScenes = 0; await mockApi(page, { @@ -537,7 +537,7 @@ test('Ledger keeps the committed All-node renderer visible when a superseded loa }); await page.goto('/'); await page.locator('.nav-item[data-view="relations"]').click(); - await page.locator('#graph-show-all').click(); + await page.locator('[data-graph-preset-choice="every"]').click(); await expect(page.locator('#graph-canvas')).toHaveAttribute('aria-busy', 'true'); await expect.poll(() => deferredAllScenes).toBe(1); @@ -1304,7 +1304,7 @@ test('Graph & Relationships uses the visual explorer controls and applies their await expect(page.locator('#graph-layer-temporal-count')).toHaveText('15'); await expect(page.getByRole('button', { name: 'Every node' })).toBeVisible(); - await expect(page.getByRole('button', { name: 'Hide unlinked nodes' })).toHaveAttribute('aria-pressed', 'true'); + await expect(page.getByRole('button', { name: 'Unlinked nodes' })).toHaveAttribute('aria-pressed', 'true'); await expect(page.locator('#graph-count')).toContainText('3 entities · 1 relations'); const paletteNotice = page.locator('#notice-banner'); await page.locator('[data-graph-palette-choice="ember"]').click(); @@ -1971,7 +1971,7 @@ test('billing cadence selects the exact Pro and Team checkout target', async ({ ); }); -test('overview→all readiness failure preserves the committed overview renderer and re-enables controls', async ({ page }) => { +test('overview→Every-node readiness failure preserves the committed overview renderer and re-enables controls', async ({ page }) => { const pageErrors = []; page.on('pageerror', error => pageErrors.push(String(error))); await page.addInitScript(() => { @@ -1982,7 +1982,7 @@ test('overview→all readiness failure preserves the committed overview renderer }; }); await mockApi(page); - await page.route('**/v2-assets/engraphis-graph-all.js*', async route => { + await page.route('**/v2-assets/engraphis-graph-every.js*', async route => { return route.fulfill({ status: 200, contentType: 'application/javascript', @@ -1992,15 +1992,15 @@ test('overview→all readiness failure preserves the committed overview renderer await page.goto('/'); await page.locator('.nav-item[data-view="relations"]').click(); await expect(page.locator('#graph-count')).toContainText('entities'); - await expect(page.locator('#graph-show-all')).toHaveAttribute('aria-pressed', 'false'); + await expect(page.locator('[data-graph-preset-choice="every"]')).toHaveAttribute('aria-pressed', 'false'); - await page.locator('#graph-show-all').click(); + await page.locator('[data-graph-preset-choice="every"]').click(); await expect(page.locator('#graph-empty')).toContainText('did not register', { timeout: 10_000 }); // Committed mode reverted to overview: aria-pressed survives - await expect(page.locator('#graph-show-all')).toHaveAttribute('aria-pressed', 'false'); + await expect(page.locator('[data-graph-preset-choice="every"]')).toHaveAttribute('aria-pressed', 'false'); // Controls re-enabled - await expect(page.locator('#graph-show-all')).toBeEnabled(); + await expect(page.locator('[data-graph-preset-choice="every"]')).toBeEnabled(); await expect(page.locator('#graph-canvas')).toHaveAttribute('aria-busy', 'false'); // Candidate alone is destroyed — no orphan host in the DOM await expect(page.locator('.graph-canvas-candidate')).toHaveCount(0); @@ -2009,15 +2009,15 @@ test('overview→all readiness failure preserves the committed overview renderer expect(pageErrors).toEqual([]); }); -test('all→quality readiness failure preserves the committed all-node renderer and re-enables controls', async ({ page }) => { +test('Every-node→quality readiness failure preserves the committed renderer and re-enables controls', async ({ page }) => { await mockApi(page); await page.goto('/'); await page.locator('.nav-item[data-view="relations"]').click(); await expect(page.locator('#graph-count')).toContainText('entities'); // Enter all mode successfully first - await page.locator('#graph-show-all').click(); - await expect(page.locator('#graph-show-all')).toHaveAttribute('aria-pressed', 'true'); + await page.locator('[data-graph-preset-choice="every"]').click(); + await expect(page.locator('[data-graph-preset-choice="every"]')).toHaveAttribute('aria-pressed', 'true'); await expect(page.locator('#graph-count')).toContainText('entities'); // Shorten the quality timeout after page load, before the failing transition @@ -2038,13 +2038,13 @@ test('all→quality readiness failure preserves the committed all-node renderer return route.fallback(); }); - await page.locator('#graph-show-all').click(); + await page.locator('[data-graph-preset-choice="every"]').click(); await expect(page.locator('#graph-empty')).toContainText('timed out', { timeout: 10_000 }); // Committed mode stays at full (all-node): aria-pressed survives - await expect(page.locator('#graph-show-all')).toHaveAttribute('aria-pressed', 'true'); + await expect(page.locator('[data-graph-preset-choice="every"]')).toHaveAttribute('aria-pressed', 'true'); // Controls re-enabled - await expect(page.locator('#graph-show-all')).toBeEnabled(); + await expect(page.locator('[data-graph-preset-choice="every"]')).toBeEnabled(); await expect(page.locator('#graph-canvas')).toHaveAttribute('aria-busy', 'false'); // Candidate alone is destroyed await expect(page.locator('.graph-canvas-candidate')).toHaveCount(0); @@ -2062,7 +2062,7 @@ test('successful retry after readiness failure commits exactly one new renderer' }); await mockApi(page); let allAssetAttempts = 0; - await page.route('**/v2-assets/engraphis-graph-all.js*', async route => { + await page.route('**/v2-assets/engraphis-graph-every.js*', async route => { allAssetAttempts += 1; if (allAssetAttempts === 1) { return route.fulfill({ @@ -2078,13 +2078,13 @@ test('successful retry after readiness failure commits exactly one new renderer' await expect(page.locator('#graph-count')).toContainText('entities'); // First attempt fails; mode reverts to overview - await page.locator('#graph-show-all').click(); + await page.locator('[data-graph-preset-choice="every"]').click(); await expect(page.locator('#graph-empty')).toContainText('did not register', { timeout: 10_000 }); - await expect(page.locator('#graph-show-all')).toHaveAttribute('aria-pressed', 'false'); + await expect(page.locator('[data-graph-preset-choice="every"]')).toHaveAttribute('aria-pressed', 'false'); // Retry by toggling again — second asset load succeeds and commits - await page.locator('#graph-show-all').click(); - await expect(page.locator('#graph-show-all')).toHaveAttribute('aria-pressed', 'true', { timeout: 15_000 }); + await page.locator('[data-graph-preset-choice="every"]').click(); + await expect(page.locator('[data-graph-preset-choice="every"]')).toHaveAttribute('aria-pressed', 'true', { timeout: 15_000 }); await expect(page.locator('#graph-canvas')).toHaveAttribute('aria-busy', 'false'); await expect(page.locator('.graph-canvas-candidate')).toHaveCount(0); await expect(page.locator('#graph-count')).toContainText('entities'); diff --git a/tests/test_dashboard_v2.py b/tests/test_dashboard_v2.py index 126082ab..150bd038 100644 --- a/tests/test_dashboard_v2.py +++ b/tests/test_dashboard_v2.py @@ -2035,9 +2035,11 @@ def test_graph_load_busy_disables_controls_and_updates_recovery_copy(monkeypatch """During load, retry/show-all are disabled and recovery copy names Reload data.""" with _client(monkeypatch, tmp_path) as client: script = client.get("/v2-assets/ledger.js") - # Busy state disables the two primary load controls. + # Busy state disables reload and the Every-node preset. assert "function setGraphLoadControlsBusy(busy, disableRetry = true)" in script.text - assert "'graph-show-all', 'graph-retry'" in script.text + assert "const controls = disableRetry ? ['graph-retry'] : []" in script.text + assert "[data-graph-preset-choice=\"every\"]" in script.text + assert "every.disabled = busy" in script.text assert "control.disabled = busy" in script.text # Recovery copy uses actionable "Reload data", not vague retry language. assert "retry.textContent = busy ? 'Reloading graph…' : 'Reload data'" in script.text From f186e0951bfcaa6ac961af65d1f4084a153bdd3f Mon Sep 17 00:00:00 2001 From: Coding-Dev-Tools Date: Mon, 24 Aug 2026 08:10:19 -0400 Subject: [PATCH 21/21] Restore graph preset after failed transition --- engraphis/dashboard_assets/ledger.js | 12 ++++++++++-- tests/e2e/ledger.spec.js | 6 ++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/engraphis/dashboard_assets/ledger.js b/engraphis/dashboard_assets/ledger.js index 60c5ef66..008da727 100644 --- a/engraphis/dashboard_assets/ledger.js +++ b/engraphis/dashboard_assets/ledger.js @@ -18,6 +18,7 @@ graphWorkspace: '', graphData: null, graphDataMode: 'overview', + graphDataPreset: 'galaxy', graphDataIncludeCode: false, graphDataShowUnlinked: false, graphDataAsOf: null, @@ -1142,6 +1143,7 @@ state.workspace = name; state.graphWorkspace = ''; state.graphData = null; + state.graphDataPreset = 'galaxy'; state.graphDataIncludeCode = false; state.graphDataShowUnlinked = false; state.graphDataRepo = ''; @@ -3490,6 +3492,7 @@ state.graphData = data; state.graphWorkspace = targetWorkspace; state.graphDataMode = targetMode; + state.graphDataPreset = byId('graph-preset').value; state.graphDataIncludeCode = responseIncludeCode; state.graphDataShowUnlinked = targetShowUnlinked; state.graphDataAsOf = targetAsOf; @@ -3531,9 +3534,14 @@ : fullGraph && (error.status === 413 || error.code === 'GRAPH_CAPACITY') ? `All nodes exceed the server capacity. Enter an exact repository filter or reduce the workspace graph. (${error.message})` : `Graph unavailable: ${error.message}. Choose Reload data to try again.`; - if (state.graphData && state.graphDataMode !== targetMode) { - state.graphMode = state.graphDataMode; + if (state.graphData) { + if (state.graphDataMode !== targetMode) state.graphMode = state.graphDataMode; + /* The toolbar preset changes before a replacement request begins. Restore the + committed preset together with the committed renderer so a failed Every-node + transition cannot leave aria-pressed and the active engine disagreeing. */ + byId('graph-preset').value = state.graphDataPreset || 'galaxy'; updateGraphModeControls(); + syncGraphChoices(); } // Restore the committed renderer's freeze/overlay state. The old engine survived // because we never mutated state.graphEngine on the failure path. diff --git a/tests/e2e/ledger.spec.js b/tests/e2e/ledger.spec.js index a903b3ce..1a600542 100644 --- a/tests/e2e/ledger.spec.js +++ b/tests/e2e/ledger.spec.js @@ -483,7 +483,7 @@ test('Ledger enters Every node from a loaded overview without losing its scope', expect(allAssetRequests).toHaveLength(1); }); -test('Ledger keeps authored Galaxy coordinates in the Every-node renderer', async ({ page }) => { +test('Ledger keeps authored Galaxy coordinates on the orbit renderer in Every-node view', async ({ page }) => { await mockApi(page, { graphScene: { nodes: [ @@ -518,7 +518,9 @@ test('Ledger keeps authored Galaxy coordinates in the Every-node renderer', asyn await page.locator('[data-graph-preset-choice="every"]').click(); await expect(page.locator('#graph-canvas')).toHaveAttribute('aria-busy', 'false'); - await expect(page.locator('.engraphis-all-canvas')).toHaveCount(1); + // Authored Galaxy scenes intentionally stay on the quality/orbit renderer; the dedicated + // Every-node WebGL canvas is reserved for non-Galaxy complete scenes. + await expect(page.locator('.engraphis-all-canvas')).toHaveCount(0); await expect(page.locator('.graph-spacetime-overlay')).toHaveCount(1); });