diff --git a/MANIFEST.in b/MANIFEST.in index 4ac8777..4192c7b 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -3,6 +3,7 @@ global-exclude *.pyo recursive-include engraphis/static *.html *.css *.js *.png *.ico recursive-include engraphis/static/vendor * +recursive-include engraphis/dashboard_assets *.js include engraphis/commercial_manifest.json include LICENSE NOTICE README.md CHANGELOG.md include pyproject.toml diff --git a/engraphis/dashboard_app.py b/engraphis/dashboard_app.py index 804f1b0..e013d1f 100644 --- a/engraphis/dashboard_app.py +++ b/engraphis/dashboard_app.py @@ -23,6 +23,7 @@ from engraphis.service import MemoryService _STATIC = Path(__file__).resolve().parent / "static" +_V2_ASSETS = Path(__file__).resolve().parent / "dashboard_assets" _INDEX = _STATIC / "index.html" # The public package is a single-user local runtime. Hosted account, Team, trial, and @@ -231,6 +232,10 @@ async def _auth_gate(request: Request, call_next): ) return await call_next(request) + # New dashboard capabilities belong to the v2 application surface. The old ``static`` + # directory remains mounted for the legacy shell and compatibility adapters only. + if _V2_ASSETS.is_dir(): + app.mount("/v2-assets", StaticFiles(directory=str(_V2_ASSETS)), name="v2-assets") if _STATIC.is_dir(): app.mount("/static", StaticFiles(directory=str(_STATIC)), name="static") diff --git a/engraphis/dashboard_assets/__init__.py b/engraphis/dashboard_assets/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/engraphis/dashboard_assets/engraphis-graph.js b/engraphis/dashboard_assets/engraphis-graph.js new file mode 100644 index 0000000..0749f28 --- /dev/null +++ b/engraphis/dashboard_assets/engraphis-graph.js @@ -0,0 +1,1070 @@ +/* Engraphis knowledge graph — the dashboard's opt-in force-graph engine. + Restores the shipped behaviour: GRAPH_PRESETS, GSTYLE render modes (cyber/galaxy/solar/classic), + STYLE_PAL / STYLE_LAYERS / STYLE_BG, COMMUNITY_PALS, GRAPH_HEAT, colour-by community/type/connections, + GRAPH_PALETTES with per-entity-type overrides, d3 force wiring, directional particles, label ranking, + hover neighbourhood highlight, freeze, fit and reheat. Values copied from dashboard.js. + + The public graph endpoint calls its fields `label`, `from` and `to`; the engine also + accepts the renderer-friendly `name`, `source` and `target` aliases so it can be used + with both the dashboard adapter and standalone scene payloads. */ +(function () { + const PRESETS = { + original: { label: 'Original force', repel: 120, link: 30, gravity: 14, font: 13, size: 3, linkw: 1, labelDensity: 40, curve: 0, particles: 0 }, + compact: { label: 'Compact clusters', repel: 42, link: 20, gravity: 26, font: 12, size: 3, linkw: 0.7, labelDensity: 30, curve: 0.08, particles: 0 }, + communities: { label: 'Community islands', repel: 48, link: 16, gravity: 48, font: 12, size: 3, linkw: 0.72, labelDensity: 24, curve: 0.12, particles: 0 }, + radial: { label: 'Radial orbit', repel: 68, link: 26, gravity: 12, font: 13, size: 3, linkw: 0.75, labelDensity: 55, curve: 0.22, particles: 0 }, + constellation: { label: 'Constellation flow', repel: 34, link: 16, gravity: 38, font: 12, size: 3, linkw: 0.65, labelDensity: 35, curve: 0.32, particles: 2 }, + custom: { label: 'Custom tuning', curve: 0.1, particles: 0 } + }; + + const STYLE_PAL = { + galaxy: { person_or_concept: '#b789ff', mention: '#7bb4ff', hashtag: '#ffcf6b', email: '#8aa2ff', organization: '#66e0d0', location: '#ff7ea8' }, + solar: { person_or_concept: '#ffb454', mention: '#3fd2c7', hashtag: '#ffd68a', email: '#8ea8ff', organization: '#5b9bff', location: '#ff8f6b' }, + cyber: { person_or_concept: '#ff3ea5', mention: '#b6ff3c', hashtag: '#ffe14d', email: '#8b7bff', organization: '#22e0ff', location: '#ff5c7a' } + }; + const STYLE_LAYERS = { + classic: { temporal: '#6f9fd8', entity: '#5aafb3', causal: '#d7a84b', semantic: '#8c83e8' }, + galaxy: { temporal: '#7bb4ff', entity: '#66e0d0', causal: '#ffcf6b', semantic: '#b789ff' }, + solar: { temporal: '#5b9bff', entity: '#3fd2c7', causal: '#ffb454', semantic: '#ffd68a' }, + cyber: { temporal: '#22e0ff', entity: '#b6ff3c', causal: '#ffe14d', semantic: '#ff3ea5' } + }; + /* The per-style pane backgrounds are NOT defined here. `style-src-attr 'none'` forbids + writing them onto the element, so dashboard.css owns them behind + `#graph-net[data-graph-style="galaxy|solar|cyber"]` and this file only sets that + attribute. Keeping a second copy of the gradients in JS would be dead drift. */ + const PALETTES = { + theme: null, + aurora: { person_or_concept: '#8b7cf6', mention: '#2dd4bf', hashtag: '#fbbf24', email: '#60a5fa', organization: '#f472b6', location: '#a3e635' }, + ocean: { person_or_concept: '#38bdf8', mention: '#2dd4bf', hashtag: '#facc15', email: '#818cf8', organization: '#22d3ee', location: '#34d399' }, + ember: { person_or_concept: '#f97316', mention: '#fb7185', hashtag: '#facc15', email: '#a78bfa', organization: '#ef4444', location: '#84cc16' }, + contrast: { person_or_concept: '#0072b2', mention: '#009e73', hashtag: '#e69f00', email: '#56b4e9', organization: '#cc79a7', location: '#d55e00' } + }; + const THEME_ETYPE = { person_or_concept: '#8c83e8', mention: '#5aafb3', hashtag: '#d7a84b', email: '#6f9fd8', organization: '#58b882', location: '#df7478' }; + /* Community colour is the *palette slot*, not the node: `nodeColor` indexes this by the + community id, and communities are numbered by size (largest == 0). The legend beside the + canvas paints its swatches from `.graph-cluster-N` in dashboard.css, which encodes the + Cyber palette — the default style — slot for slot. These arrays must therefore stay + byte-identical to `COMMUNITY_PALS` in dashboard.js, or "Cluster 1" gets one colour in the + legend and another on the canvas. Ordering is load-bearing; this is not free-choice art. */ + const COMMUNITY_PALS = { + classic: ['#8c83e8', '#5aafb3', '#d7a84b', '#6f9fd8', '#58b882', '#df7478', '#b07de0', '#4fb0a0', '#e0894a', '#7c9be0', '#e06a9a', '#9ac25a'], + galaxy: ['#b789ff', '#7bb4ff', '#66e0d0', '#ffcf6b', '#ff7ea8', '#8aa2ff', '#c98bff', '#5ad0e0', '#ffa0d0', '#9d7bff', '#6ad0b0', '#ffb060'], + solar: ['#ffb454', '#5b9bff', '#3fd2c7', '#ffd68a', '#ff8f6b', '#8ea8ff', '#ffc24a', '#6ac0d0', '#ff9f7a', '#7ab0ff', '#e0b050', '#5fd0b0'], + cyber: ['#22e0ff', '#ff3ea5', '#b6ff3c', '#ffe14d', '#8b7bff', '#ff5c7a', '#3affd0', '#ff7be0', '#7affea', '#c0ff4a', '#5c9bff', '#ff9b3c'] + }; + const GRAPH_HEAT = ['#3f7bff', '#6a5cff', '#a24bff', '#e0479f', '#ff6b6b', '#ffc23d']; + + /* Flow particles are per *relation*, and force-graph advances every one of them on every + frame — three particles on a few thousand relations is tens of thousands of animated + objects and a canvas that stops responding. The classic renderer already refuses to draw + them past this many links (`data.links.length>800` in dashboard.js's graphRender); the + opt-in engine uses the same cutoff rather than inventing a second large-graph signal. */ + const PARTICLE_LINK_LIMIT = 800; + + /* The classic renderer's large-graph signal (`GPERF` in dashboard.js, set from the rendered + data as `nodes>600 || links>2400`). Past it the classic path drops the galaxy starfield + outright — `if(GPERF.large)return` in graphStyleBackground — because repainting 110 stars + plus every node and link on every frame is what makes a big store unusable. The opt-in + engine reuses the same thresholds rather than inventing a second signal. */ + const LARGE_NODE_LIMIT = 600; + const LARGE_LINK_LIMIT = 2400; + + /* The classic renderer's *dense* signal (`GPERF.dense`, `links>1500` in dashboard.js). Past + it the classic path turns off the two per-edge costs that scale with the link count and + buy nothing at that density: link curvature (a quadratic bezier per relation instead of a + straight line) and the directional arrowhead (a filled triangle per relation, recomputed + every frame). Relation labels get the same treatment unless one node is highlighted. Same + thresholds and same behaviour here — a second signal would only drift. */ + const DENSE_LINK_LIMIT = 1500; + + /* Relation labels are the noisiest layer on the canvas, so — exactly as the classic + `linkCanvasObject` does — they only appear once the user has zoomed in past this scale. */ + const LINK_LABEL_MIN_SCALE = 2.4; + + function idOf(value) { return value && typeof value === 'object' ? value.id : value; } + function nodeName(node) { return String(node.name || node.label || node.id || ''); } + function linkEndpoint(link, side) { + return idOf(link[side] !== undefined ? link[side] : link[side === 'source' ? 'from' : 'to']); + } + function asOfValue(value) { + if (value instanceof Date) return value.getTime(); + if (typeof value === 'number') return Number.isFinite(value) ? value * (value < 1e11 ? 1000 : 1) : null; + if (typeof value === 'string' && value.trim()) { + const numeric = Number(value); + if (Number.isFinite(numeric)) return asOfValue(numeric); + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : null; + } + return null; + } + function temporalValue(item, key, fallback) { + const value = item[key] !== undefined ? item[key] : item[key === 'valid_from' ? 'born' : 'closed']; + if (value === undefined || value === null || value === '') return fallback; + return asOfValue(value); + } + + /* Node and link labels come from ingested memories, i.e. untrusted text. force-graph's + tooltip renders a string label through `innerHTML` (see float-tooltip in + vendor/force-graph.min.js), so every label handed to it must already be escaped. */ + function esc(value) { + if (value === undefined || value === null) return ''; + return String(value) + .replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"').replace(/'/g, '''); + } + + function hexRgb(c) { + if (!c) return [140, 131, 232]; + if (c[0] === '#') { + const hex = c.length === 4 ? c[1] + c[1] + c[2] + c[2] + c[3] + c[3] : c.slice(1, 7); + const n = parseInt(hex, 16); + if (!Number.isFinite(n)) return [140, 131, 232]; + return [n >> 16 & 255, n >> 8 & 255, n & 255]; + } + const m = c.match(/\d+/g) || [140, 131, 232]; + return [+m[0], +m[1], +m[2]]; + } + function alpha(c, a) { const [r, g, b] = hexRgb(c); return 'rgba(' + r + ',' + g + ',' + b + ',' + a + ')'; } + function lighten(c, amt) { let [r, g, b] = hexRgb(c); r = Math.round(r + (255 - r) * amt); g = Math.round(g + (255 - g) * amt); b = Math.round(b + (255 - b) * amt); return 'rgb(' + r + ',' + g + ',' + b + ')'; } + function contrastOn(c) { const [r, g, b] = hexRgb(c); return (0.2126 * r + 0.7152 * g + 0.0722 * b) > 150 ? '#111827' : '#f8fafc'; } + + function makeStars() { + const a = [], c = ['#dfe6ff', '#dfe6ff', '#c9b6ff', '#a7c6ff', '#ffd9ef']; + for (let i = 0; i < 110; i++) a.push({ x: (Math.random() - 0.5) * 1200, y: (Math.random() - 0.5) * 1200, r: Math.random() * 1.1 + 0.25, a: Math.random() * 0.7 + 0.25, tw: Math.random() * 1.6 + 0.4, ph: Math.random() * 6.28, c: c[i % c.length] }); + return a; + } + const STARS = makeStars(); + + /* Relations that cross topics rather than describe one. The classic renderer keeps them + visible and traversable but builds its *clustering* adjacency without them (`GCOMM_ADJ` + in dashboard.js), because a single sparse `influences` edge otherwise fuses two unrelated + topics into one connected component — one Community-Islands colour and one force centre + for both. Same semantics here. */ + const CLUSTER_EXCLUDED_LABELS = { influences: true }; + function clustersAcross(link) { + return !!(link && CLUSTER_EXCLUDED_LABELS[link.label]); + } + + function communities(nodes, links) { + const adj = {}; + // Traversal adjacency (hover neighbourhood, focus depth, bridges, betweenness) keeps every + // relation; only the community BFS below reads `clusterAdj`. + const clusterAdj = {}; + const nodesById = new Map(nodes.map(node => [node.id, node])); + nodes.forEach(n => { adj[n.id] = []; clusterAdj[n.id] = []; }); + links.forEach(l => { + const s = linkEndpoint(l, 'source'), t = linkEndpoint(l, 'target'); + if (adj[s]) adj[s].push(t); + if (adj[t]) adj[t].push(s); + if (clustersAcross(l)) return; + if (clusterAdj[s]) clusterAdj[s].push(t); + if (clusterAdj[t]) clusterAdj[t].push(s); + }); + // Respect clusters supplied with the data (a store that already knows its topics); + // otherwise fall back to connected-component BFS, as the dashboard does. + if (nodes.length && nodes.every(n => typeof n.community === 'number')) return adj; + const seen = new Set(); + const groups = []; + nodes.forEach(n => { + if (seen.has(n.id)) return; + // Read head instead of Array#shift: shift() is O(n) per pop, which turns this BFS + // quadratic on the large stores the dashboard is expected to open. + const queue = [n.id]; + let head = 0; + seen.add(n.id); + while (head < queue.length) { + const id = queue[head++]; + (clusterAdj[id] || []).forEach(next => { if (!seen.has(next)) { seen.add(next); queue.push(next); } }); + } + // `queue` has accumulated the whole component by now, so it *is* the group. + groups.push(queue); + }); + /* Rank by size before the IDs become visible. `graphRenderLegend()` sorts communities by + size and labels the largest "Cluster 1", while node colour indexes the palette by the + community ID itself (`nodeColor` -> `commPal()[community % n]`). Assigning IDs in raw + node order therefore let the legend describe one component with another's swatch + whenever a smaller component happened to appear first in the payload. The classic + renderer sorts its components the same way (`graphComputeCommunities` in dashboard.js), + so largest == community 0 == palette slot 0 == "Cluster 1" on both paths. */ + groups.sort((a, b) => b.length - a.length); + groups.forEach((group, index) => { + group.forEach(id => { const node = nodesById.get(id); if (node) node.community = index; }); + }); + return adj; + } + + function maxOf(values, floor) { + // Math.max(...array) throws RangeError once the array outgrows the argument limit, + // which a real store reaches long before the renderer gets slow. + let best = floor; + for (let i = 0; i < values.length; i++) if (values[i] > best) best = values[i]; + return best; + } + + /* Brandes betweenness — which entity is the bridge whose loss would split a topic. + Brandes is O(V·E); on a large store that is seconds of blocked main thread, so above + BETWEENNESS_PIVOTS sources we run the standard pivot approximation over a deterministic, + evenly-spaced sample. The score is only ever used as a *relative* size/highlight signal + (it is normalised to the maximum), so a sampled estimate is fit for purpose. */ + const BETWEENNESS_PIVOTS = 220; + const BETWEENNESS_BUDGET = 1.5e6; + function betweenness(nodes, adj) { + const bc = {}; + nodes.forEach(n => { bc[n.id] = 0; }); + // Each pivot costs O(V) just to initialise its bookkeeping, so cap pivots by total work + // as well as by count: without the budget a 60k-entity store blocks the main thread for + // ~25s. This is a relative sizing signal, so fewer pivots degrades quality, not truth. + const pivots = Math.max(1, Math.min( + BETWEENNESS_PIVOTS, + Math.floor(BETWEENNESS_BUDGET / Math.max(1, nodes.length)) + )); + const stride = nodes.length > pivots ? Math.ceil(nodes.length / pivots) : 1; + for (let index = 0; index < nodes.length; index += stride) { + const src = nodes[index]; + const stack = [], pred = {}, sigma = {}, dist = {}, delta = {}; + nodes.forEach(n => { pred[n.id] = []; sigma[n.id] = 0; dist[n.id] = -1; delta[n.id] = 0; }); + sigma[src.id] = 1; dist[src.id] = 0; + const queue = [src.id]; + let head = 0; + while (head < queue.length) { + const v = queue[head++]; + stack.push(v); + (adj[v] || []).forEach(w => { + if (dist[w] < 0) { dist[w] = dist[v] + 1; queue.push(w); } + if (dist[w] === dist[v] + 1) { sigma[w] += sigma[v]; pred[w].push(v); } + }); + } + while (stack.length) { + const w = stack.pop(); + pred[w].forEach(v => { delta[v] += (sigma[v] / sigma[w]) * (1 + delta[w]); }); + if (w !== src.id) bc[w] += delta[w]; + } + } + const max = maxOf(Object.values(bc), 1); + nodes.forEach(n => { n.betweenness = bc[n.id] / max; }); + return bc; + } + + /* Bridge edges (Tarjan): removing one disconnects part of the store. */ + function findBridges(nodes, links, adj) { + const disc = {}, low = {}, parent = {}, bridges = new Set(); + const multiplicity = {}; + links.forEach(link => { + const s = linkEndpoint(link, 'source'), t = linkEndpoint(link, 'target'); + const key = s < t ? s + '|' + t : t + '|' + s; + multiplicity[key] = (multiplicity[key] || 0) + 1; + }); + let timer = 0; + // Iterative Tarjan. The recursive form recurses once per node along a path, so a + // chain-shaped component of a few thousand entities overflows the call stack and takes + // the whole render down with it — an explicit frame stack has no such ceiling. + const visit = root => { + const frames = [{ u: root, i: 0 }]; + disc[root] = low[root] = ++timer; + while (frames.length) { + const frame = frames[frames.length - 1]; + const u = frame.u, neighbors = adj[u] || []; + if (frame.i < neighbors.length) { + const v = neighbors[frame.i++]; + if (!disc[v]) { + parent[v] = u; + disc[v] = low[v] = ++timer; + frames.push({ u: v, i: 0 }); + } else if (v !== parent[u]) { + low[u] = Math.min(low[u], disc[v]); + } + continue; + } + frames.pop(); + const p = parent[u]; + if (p !== undefined) { + low[p] = Math.min(low[p], low[u]); + const key = p < u ? p + '|' + u : u + '|' + p; + if (low[u] > disc[p] && multiplicity[key] === 1) { + bridges.add(p + '|' + u); + bridges.add(u + '|' + p); + } + } + } + }; + nodes.forEach(n => { if (!disc[n.id]) visit(n.id); }); + links.forEach(l => { + const s = linkEndpoint(l, 'source'), t = linkEndpoint(l, 'target'); + l.bridge = bridges.has(s + '|' + t); + }); + return bridges; + } + + function create(el, options) { + if (typeof ForceGraph === 'undefined') throw new Error('force-graph not loaded'); + if (!el || typeof el.getAttribute !== 'function') throw new Error('graph container missing'); + const opts = options || {}; + const state = { + // Named `styleName`, not `style`: scripts/externalize_dashboard_assets.py scans this + // asset for runtime inline-style mutation with a text pattern, and a plain data field + // by the shorter name reads as one. The longer name keeps that gate honest. + styleName: 'cyber', colorBy: 'community', palette: 'theme', overrides: {}, themeColors: {}, + settings: Object.assign({}, PRESETS.communities, { mode: 'communities', labels: false, flow: true, frozen: false }), + minDegree: 1, showUnlinked: false, focusId: null, depth: 2, layers: { temporal: true, entity: true, causal: true, semantic: true, code: false }, + path: null, asOf: null, ghost: true, sizeBy: 'degree', bridges: false, suggestions: false, collapse: 'auto' + }; + let raw = { nodes: [], links: [], suggestions: [] }, adj = {}, hilite = null, hoverSet = null, maxDeg = 1; + // The classic renderer treats label density as a hard ranked cap, not merely a looser + // degree threshold. Keeping chosen IDs outside the paint callback bounds fillText work. + let labelIds = new Set(); + let zoom = 1, collapsed = false; + /* Recomputed from the *rendered* data on every render, exactly as the classic path + recomputes GPERF — filters and focus can take a huge store down to a small view. */ + let large = false, dense = false; + /* The node/link arrays last handed to force-graph. Seeding is not free: the vendor copies + the data in and d3 resets the simulation alpha to 1, so a paint-only change would restart + the whole layout. See `sameData`/`render`. */ + let seeded = null; + let destroyed = false, running = true, fitTimer = 0, suspended = 0, pendingRender = null; + let betweennessReady = false; + const fg = ForceGraph()(el); + const api = {}; + + /* The dashboard already honours `prefers-reduced-motion` for the classic renderer; this + engine must not quietly reintroduce perpetual motion for the same user. */ + function reduced() { + if (typeof opts.reducedMotion === 'function') return !!opts.reducedMotion(); + try { + return !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches); + } catch (e) { return false; } + } + /* force-graph already keeps redrawing while the simulation runs or any link still has + particles in flight, so `autoPauseRedraw(false)` is only needed for paint this engine + does behind its back: the galaxy starfield lives in onRenderFramePre and is invisible + to that change detection. Everywhere else, letting force-graph park the redraw is what + keeps a settled graph off the CPU. */ + function needsContinuousFrames() { + return !reduced() && state.styleName === 'galaxy' && !large; + } + /* Betweenness is the one analysis that is superlinear in the store size, and nothing in + the default view consumes it — the bridge overlay and betweenness-sizing are both off. + Computing it lazily keeps opening the graph cheap; the first toggle pays for it once. */ + function ensureBetweenness() { + if (betweennessReady) return; + betweennessReady = true; + betweenness(raw.nodes, adj); + } + /* Apply a batch of setters with exactly one render at the end. Each public setter renders + on its own, so a single dashboard sync used to cost six full re-simulations (and six + zoom-to-fit timers). The caller also states the intent explicitly, because the merged + intent of the individual setters is not the caller's: `setSettings` asks for a reheat + whenever the patch carries a physics key, and the dashboard's sync hands it the whole + GSET — so it would reheat even on a `render(false, false)` refresh. */ + function batch(fn, fit, reheat) { + suspended++; + try { fn(api); } finally { + suspended--; + pendingRender = null; + render(!!fit, !!reheat); + } + } + + /* Priority mirrors the classic renderer's graphTypeColor(): an explicit user override wins, + then a non-classic style's own palette, then the *active theme*. The theme tier is the + reason `themeColors` exists — it cannot be folded into `overrides`, which outrank + STYLE_PAL. The dashboard owns the CSS custom properties (`--entity-*`), so it supplies + the resolved values through setThemeColors() on every applyTheme()/graphRecolor(); + THEME_ETYPE stays only as the standalone-embed fallback for a caller that never does. */ + function etypeColor(type) { + if (state.overrides[type]) return state.overrides[type]; + if (state.styleName !== 'classic' && STYLE_PAL[state.styleName] && STYLE_PAL[state.styleName][type]) return STYLE_PAL[state.styleName][type]; + return state.themeColors[type] || THEME_ETYPE[type] || '#8c83e8'; + } + function commPal() { return COMMUNITY_PALS[state.styleName] || COMMUNITY_PALS.classic; } + function heatColor(node) { + const t = (node.rank || 0) / Math.max(1, raw.nodes.length - 1); + return GRAPH_HEAT[Math.min(GRAPH_HEAT.length - 1, Math.floor(t * GRAPH_HEAT.length))]; + } + function nodeColor(node) { + if (state.colorBy === 'community') { const p = commPal(); return p[(node.community || 0) % p.length]; } + if (state.colorBy === 'connections') return heatColor(node); + return etypeColor(node.etype); + } + function layerColor(layer) { return (STYLE_LAYERS[state.styleName] || STYLE_LAYERS.classic)[layer] || '#8c83e8'; } + + function born(item) { return temporalValue(item, 'valid_from', -Infinity); } + function closed(item) { return temporalValue(item, 'valid_to', null); } + function aliveAt(item, date) { + const start = born(item), end = closed(item); + return start <= date && (end === null || end > date); + } + + function collapsedData(nodes, links) { + const groups = {}; + nodes.forEach(n => { + const c = n.community || 0; + if (!groups[c]) groups[c] = { id: 'cluster-' + c, cluster: true, community: c, name: (n.topic || 'Cluster ' + (c + 1)), etype: n.etype, members: 0, degree: 0, betweenness: 0 }; + groups[c].members++; + groups[c].degree += n.degree || 0; + groups[c].betweenness = Math.max(groups[c].betweenness, n.betweenness || 0); + }); + const cnodes = Object.values(groups); + const seen = {}; + const clinks = []; + // Indexed lookup, not Array#find per endpoint: auto-collapse fires on every zoom-out, + // and the scan made that O(nodes x links) — a visible freeze on a real store. + const byId = new Map(raw.nodes.map(n => [n.id, n])); + links.forEach(l => { + const s = byId.get(linkEndpoint(l, 'source')); + const t = byId.get(linkEndpoint(l, 'target')); + if (!s || !t) return; + const a = 'cluster-' + (s.community || 0), b = 'cluster-' + (t.community || 0); + if (a === b) return; + const key = a < b ? a + '|' + b : b + '|' + a; + if (seen[key]) { seen[key].weight++; return; } + const link = { source: a, target: b, layer: l.layer, weight: 1, aggregate: true }; + seen[key] = link; + clinks.push(link); + }); + return { nodes: cnodes, links: clinks }; + } + + function visible() { + const keepLayer = l => state.layers[l.layer] !== false; + let nodes = raw.nodes.filter(n => (state.showUnlinked || n.degree > 0) && n.degree >= state.minDegree); + if (state.repo) nodes = nodes.filter(n => (n.repo || n.topic || '').toLowerCase().includes(state.repo) || nodeName(n).toLowerCase().includes(state.repo)); + if (state.asOf !== null) { + const live = nodes.filter(n => aliveAt(n, state.asOf)); + const ghosts = state.ghost ? nodes.filter(n => !aliveAt(n, state.asOf) && born(n) <= state.asOf).map(n => Object.assign(n, { ghost: true })) : []; + live.forEach(n => { n.ghost = false; }); + nodes = live.concat(ghosts); + } else { + nodes.forEach(n => { n.ghost = false; }); + } + if (state.focusId != null) { + const keep = new Set([state.focusId]); + let frontier = [state.focusId]; + for (let h = 0; h < state.depth; h++) { + const next = []; + frontier.forEach(id => (adj[id] || []).forEach(n => { if (!keep.has(n)) { keep.add(n); next.push(n); } })); + frontier = next; + } + nodes = nodes.filter(n => keep.has(n.id)); + } + const ids = new Set(nodes.map(n => n.id)); + let links = raw.links.filter(l => keepLayer(l) && ids.has(linkEndpoint(l, 'source')) && ids.has(linkEndpoint(l, 'target'))); + if (state.asOf !== null) { + links.forEach(l => { l.ghost = !aliveAt(l, state.asOf); }); + if (!state.ghost) links = links.filter(l => !l.ghost); + links = links.filter(l => born(l) <= state.asOf); + } else { + links.forEach(l => { l.ghost = false; }); + } + if (state.suggestions && raw.suggestions) { + raw.suggestions.forEach(s => { + const source = linkEndpoint(s, 'source'), target = linkEndpoint(s, 'target'); + if (ids.has(source) && ids.has(target)) links = links.concat([Object.assign({}, s, { source, target, layer: 'semantic', suggested: true })]); + }); + } + if (collapsed) return collapsedData(nodes, links.filter(l => !l.suggested)); + return { nodes, links }; + } + + function applyForces() { + const s = state.settings, mode = s.mode || 'compact'; + fg.d3Force('charge').strength(-s.repel); + fg.d3Force('link').distance(s.link); + if (typeof d3 === 'undefined') return; + fg.d3Force('radial', null); + if (mode === 'communities') { + const target = node => { + const c = node.community || 0; + const ring = 240 + (c % 3) * 90; + const a = (c * 2.399); + return { x: Math.cos(a) * ring, y: Math.sin(a) * ring * 0.62 }; + }; + fg.d3Force('x', d3.forceX(n => target(n).x).strength(s.gravity / 100)); + fg.d3Force('y', d3.forceY(n => target(n).y).strength(s.gravity / 100)); + } else { + const centering = mode === 'radial' ? Math.max(0.04, s.gravity / 300) : s.gravity / 100; + fg.d3Force('x', d3.forceX(0).strength(centering)); + fg.d3Force('y', d3.forceY(0).strength(centering)); + if (mode === 'radial' && d3.forceRadial) fg.d3Force('radial', d3.forceRadial(n => Math.max(0, 5 - Math.min(5, n.degree || 0)) * Math.max(8, s.link * 0.72)).strength(0.32)); + } + /* One collision pass on a large graph, two otherwise — the classic path's + `.iterations(GPERF.large?1:2)`. The second pass costs another full quadtree traversal + per node on every tick, and a large store pays that on the initial layout and on every + reheat, which is exactly where it is least affordable. */ + if (d3.forceCollide) fg.d3Force('collide', d3.forceCollide(n => n.radius + 1.5).iterations(large ? 1 : 2)); + } + + function styleBackground(ctx, scale) { + if (state.styleName === 'galaxy') { + /* Matches the classic path's `if(GPERF.large)return`. Paired with the `large` term in + needsContinuousFrames(), this is what lets a big galaxy graph settle: the starfield + is the only paint force-graph cannot see, so once it is skipped there is nothing + left that requires a frame the vendor would not have scheduled itself. */ + if (large) return; + const t = performance.now() / 1000; + ctx.save(); + ctx.globalCompositeOperation = 'lighter'; + for (let i = 0; i < STARS.length; i++) { + const s = STARS[i], al = s.a * (0.5 + 0.5 * Math.sin(t * s.tw + s.ph)); + if (al <= 0.02) continue; + ctx.globalAlpha = al; + ctx.beginPath(); + ctx.arc(s.x, s.y, s.r, 0, 6.2832); + ctx.fillStyle = s.c; + ctx.fill(); + } + ctx.restore(); + } else if (state.styleName === 'solar') { + ctx.save(); + const g = ctx.createRadialGradient(0, 0, 2, 0, 0, 130); + g.addColorStop(0, 'rgba(255,192,112,.20)'); + g.addColorStop(0.6, 'rgba(255,150,80,.05)'); + g.addColorStop(1, 'rgba(255,150,80,0)'); + ctx.fillStyle = g; + ctx.beginPath(); + ctx.arc(0, 0, 130, 0, 6.2832); + ctx.fill(); + ctx.strokeStyle = 'rgba(255,190,120,.10)'; + ctx.lineWidth = 1 / scale; + [72, 132, 200, 286, 384].forEach(r => { ctx.beginPath(); ctx.ellipse(0, 0, r, r * 0.66, 0, 0, 6.2832); ctx.stroke(); }); + ctx.restore(); + } + } + + function styleNode(node, ctx, scale) { + if (!Number.isFinite(node.x) || !Number.isFinite(node.y)) return; + const focus = hoverSet && hoverSet.size > 1, neighbor = focus && hoverSet.has(node.id), dim = focus && !neighbor; + let r = node.radius; + const col = node.color, rich = node.id === hilite || neighbor || node.hub || node.degree >= 3; + ctx.globalAlpha = node.ghost ? 0.22 : (dim ? 0.12 : 1); + if (node.ghost) { + ctx.lineWidth = 1.1 / scale; + ctx.strokeStyle = col; + ctx.beginPath(); ctx.arc(node.x, node.y, r, 0, 6.2832); ctx.stroke(); + ctx.globalAlpha = 1; + return; + } + if (node.cluster) { + const g = ctx.createRadialGradient(node.x, node.y, r * 0.2, node.x, node.y, r * 1.5); + g.addColorStop(0, alpha(col, 0.9)); + g.addColorStop(0.7, alpha(col, 0.35)); + g.addColorStop(1, alpha(col, 0)); + ctx.fillStyle = g; + ctx.beginPath(); ctx.arc(node.x, node.y, r * 1.5, 0, 6.2832); ctx.fill(); + ctx.fillStyle = contrastOn(col); + ctx.font = '600 ' + Math.max(3, r * 0.55) + 'px system-ui, sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(String(node.members), node.x, node.y); + ctx.font = '500 ' + Math.max(2.6, r * 0.4) + 'px system-ui, sans-serif'; + // Cluster names sit outside the coloured bubble. They therefore need the active + // theme's text colour, not the dark-theme near-white that disappears on light canvas. + ctx.fillStyle = state.themeColors.label || '#e7e9ee'; + ctx.fillText(nodeName(node), node.x, node.y + r * 1.5 + r * 0.5); + ctx.textAlign = 'left'; + ctx.globalAlpha = 1; + return; + } + if (state.bridges && node.betweenness > 0.35) { + ctx.save(); + ctx.strokeStyle = alpha('#ff5c7a', 0.75); + ctx.lineWidth = 1.2 / scale; + ctx.setLineDash([2 / scale, 2 / scale]); + ctx.beginPath(); ctx.arc(node.x, node.y, r + 3 / scale, 0, 6.2832); ctx.stroke(); + ctx.restore(); + } + /* Every per-node glow below is gated on `!large`, matching the classic path's + `if(rich&&!GPERF.large)` / `if(!GPERF.large)` pairs. A radial gradient or a shadow blur + is per-node, per-frame canvas work: at the >600-node cutoff that is hundreds of + gradients rebuilt on every tick of the layout, which is what makes a big store crawl. + The flat fill in the `else` is the classic fallback, not a missing branch. */ + if (state.styleName === 'galaxy') { + if (rich && !large) { + ctx.save(); + ctx.globalCompositeOperation = 'lighter'; + const R = r * (node.id === hilite ? 4.4 : 3.0); + const g = ctx.createRadialGradient(node.x, node.y, 0, node.x, node.y, R); + g.addColorStop(0, alpha(col, dim ? 0.15 : 0.6)); + g.addColorStop(0.42, alpha(col, dim ? 0.05 : 0.16)); + g.addColorStop(1, alpha(col, 0)); + ctx.fillStyle = g; + ctx.beginPath(); + ctx.arc(node.x, node.y, R, 0, 6.2832); + ctx.fill(); + ctx.restore(); + } + ctx.beginPath(); ctx.arc(node.x, node.y, r, 0, 6.2832); ctx.fillStyle = col; ctx.fill(); + ctx.beginPath(); ctx.arc(node.x, node.y, Math.max(0.4, r * 0.4), 0, 6.2832); ctx.fillStyle = 'rgba(255,255,255,.9)'; ctx.fill(); + } else if (state.styleName === 'solar') { + const sun = node.rank === 0; + if (sun) r *= 1.7; + if (rich && !large) { + ctx.save(); + ctx.globalCompositeOperation = 'lighter'; + const cc = sun ? '#ffcf6b' : col, R2 = r * (sun ? 3.4 : 2.1); + const g2 = ctx.createRadialGradient(node.x, node.y, 0, node.x, node.y, R2); + g2.addColorStop(0, alpha(cc, dim ? 0.1 : (sun ? 0.6 : 0.3))); + g2.addColorStop(1, alpha(cc, 0)); + ctx.fillStyle = g2; + ctx.beginPath(); ctx.arc(node.x, node.y, R2, 0, 6.2832); ctx.fill(); + ctx.restore(); + } + if (large) { + ctx.fillStyle = col; + } else { + const sg = ctx.createRadialGradient(node.x - r * 0.4, node.y - r * 0.4, Math.max(0.1, r * 0.12), node.x, node.y, r); + sg.addColorStop(0, lighten(sun ? '#ffe4ad' : col, 0.5)); + sg.addColorStop(1, sun ? '#e08a25' : col); + ctx.fillStyle = sg; + } + ctx.beginPath(); ctx.arc(node.x, node.y, r, 0, 6.2832); ctx.fill(); + } else if (state.styleName === 'cyber') { + ctx.save(); + if (rich && !large) { ctx.shadowColor = col; ctx.shadowBlur = dim ? 2 : r * 2.6; } + ctx.beginPath(); ctx.arc(node.x, node.y, r, 0, 6.2832); ctx.fillStyle = col; ctx.fill(); + ctx.restore(); + ctx.beginPath(); ctx.arc(node.x, node.y, Math.max(0.4, r * 0.42), 0, 6.2832); ctx.fillStyle = '#eafcff'; ctx.fill(); + } else { + ctx.beginPath(); ctx.arc(node.x, node.y, r, 0, 6.2832); ctx.fillStyle = col; ctx.fill(); + if (node.hub) { ctx.lineWidth = 0.8 / scale; ctx.strokeStyle = node.stroke; ctx.stroke(); } + } + if (node.id === hilite) { + ctx.lineWidth = 1.3 / scale; + ctx.strokeStyle = state.styleName === 'cyber' ? '#ffffff' : 'rgba(255,255,255,.9)'; + ctx.beginPath(); ctx.arc(node.x, node.y, r + 1.4 / scale, 0, 6.2832); ctx.stroke(); + } + const showLabel = (state.settings.labels && labelIds.has(node.id)) || node.id === hilite || neighbor; + if (showLabel && scale > 0.35) { + // The dashboard setting is a screen-space font size. As on the classic renderer, + // compensate only for graph zoom; an extra artistic divisor makes a configured 12px + // label unreadable at normal zoom and makes the Font size control misleading. + const size = Math.max(2, state.settings.font / scale); + ctx.font = '500 ' + size + 'px system-ui, sans-serif'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = 'rgba(0,0,0,.5)'; + ctx.fillText(nodeName(node), node.x + r + 1.6 + 0.3, node.y + 0.3); + // Node names sit directly on the canvas, so Classic's light and sepia themes need + // the dashboard-resolved text colour just like relation and collapsed-cluster labels. + ctx.fillStyle = state.themeColors.label || (node.id === hilite ? '#ffffff' : 'rgba(232,236,245,.86)'); + ctx.fillText(nodeName(node), node.x + r + 1.6, node.y); + } + ctx.globalAlpha = 1; + } + + function applyChrome() { + // Keep the asset compatible with `style-src-attr 'none'`: the CSP-safe dashboard + // stylesheet owns the visual backgrounds, while the canvas owns the data-driven paint. + el.setAttribute('data-graph-style', state.styleName); + } + + /* force-graph parks its redraw loop as soon as the simulation settles and no particle is in + flight (`autoPauseRedraw`), and it has no way to know that `hilite`/`hoverSet` — plain + closure state read by the paint callbacks — changed. Re-setting an accessor to its own + value is the vendor's own invalidation hook, so highlight changes still paint with + reduced motion on, flow off, or a settled graph. */ + function invalidate() { + if (destroyed) return; + fg.nodeCanvasObject(fg.nodeCanvasObject()); + } + + function refreshColors() { + const nodes = fg.graphData().nodes || []; + nodes.forEach(n => { n.color = nodeColor(n); n.stroke = contrastOn(n.color); }); + invalidate(); + } + + /* The dashboard's **Labels** checkbox turns on *both* label layers on the classic path: + entity names (painted by styleNode) and relation names (a `linkCanvasObject`, drawn + 'after' the line so it sits on top of it). Without this second half the checkbox silently + did half its job under `?graph-engine=next` and a relation name could only be read by + hovering one edge at a time. Same gates as classic graphRender(): zoomed in past + LINK_LABEL_MIN_SCALE, the relation actually carries a label, and — on a dense graph — + only while something is highlighted, so thousands of overlapping strings are never + painted at once. Canvas text is not an HTML sink, so the raw label is drawn here; the + escaped copy is for `linkLabel`, whose tooltip *is* one. */ + function applyLinkLabels() { + if (!fg.linkCanvasObject || !fg.linkCanvasObjectMode) return; + if (!state.settings.labels) { fg.linkCanvasObjectMode(() => undefined); return; } + fg.linkCanvasObjectMode(() => 'after').linkCanvasObject((link, ctx, scale) => { + if (!link || !link.label || scale < LINK_LABEL_MIN_SCALE) return; + if (dense && !hilite) return; + const source = link.source, target = link.target; + if (!source || !target || typeof source !== 'object' || typeof target !== 'object') return; + if (!Number.isFinite(source.x) || !Number.isFinite(source.y)) return; + if (!Number.isFinite(target.x) || !Number.isFinite(target.y)) return; + if (link.ghost) return; + ctx.font = ((state.settings.font || 12) * 0.82) / scale + 'px system-ui, sans-serif'; + ctx.fillStyle = state.themeColors.relation_label || '#7e8795'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(String(link.label), (source.x + target.x) / 2, (source.y + target.y) / 2); + ctx.textAlign = 'left'; + }); + } + + /* Does this render show the same entities and relations as the one force-graph is already + holding? Compared by identity of the *view*, not of the payload: `visible()` allocates + fresh arrays every call (and `collapsedData` fresh cluster nodes), so an object compare + would report a change for Style, Color by, Labels and Flow — none of which move a node. */ + function sameData(previous, next) { + if (!previous) return false; + if (previous.nodes.length !== next.nodes.length) return false; + if (previous.links.length !== next.links.length) return false; + for (let i = 0; i < next.nodes.length; i++) { + if (previous.nodes[i].id !== next.nodes[i].id) return false; + } + for (let i = 0; i < next.links.length; i++) { + const a = previous.links[i], b = next.links[i]; + if (linkEndpoint(a, 'source') !== linkEndpoint(b, 'source')) return false; + if (linkEndpoint(a, 'target') !== linkEndpoint(b, 'target')) return false; + if ((a.layer || '') !== (b.layer || '')) return false; + if (!a.suggested !== !b.suggested) return false; + if (!a.ghost !== !b.ghost) return false; + } + return true; + } + + /* Large graphs settle harder, exactly as the classic path does (`GPERF.large?.055:.035`). + Shared so reheat() and freeze() cannot drift back to the small-graph constant. */ + function alphaDecay() { return large ? 0.055 : 0.035; } + + function render(fit, reheat) { + if (destroyed) return; + if (suspended) { + pendingRender = pendingRender ? [pendingRender[0] || fit, pendingRender[1] || reheat] : [fit, reheat]; + return; + } + const motion = !reduced(); + const next = visible(); + /* Reuse the arrays force-graph already holds when the view is unchanged: the sizing and + colouring pass below must write onto the objects the vendor is painting from, and the + collapsed view hands out freshly built cluster nodes on every call. */ + const reused = sameData(seeded, next); + const data = reused ? seeded : next; + large = data.nodes.length > LARGE_NODE_LIMIT || data.links.length > LARGE_LINK_LIMIT; + dense = data.links.length > DENSE_LINK_LIMIT; + const sizeMetric = n => state.sizeBy === 'betweenness' ? (n.betweenness || 0) : ((n.degree || 0) / Math.max(1, maxDeg)); + data.nodes.forEach(n => { + const base = (state.settings.size || 3); + n.radius = n.cluster + ? Math.max(3, base * (1.4 + Math.min(3, Math.sqrt(n.members || 1) * 0.7))) + : Math.max(0.8, base * (0.55 + Math.min(1.6, sizeMetric(n) * 1.9))); + n.color = nodeColor(n); + n.stroke = contrastOn(n.color); + }); + const labelCap = Math.max(1, Math.round(Number(state.settings.labelDensity) || 40)); + labelIds = new Set(data.nodes + .filter(n => !n.cluster && !n.ghost) + .sort((a, b) => (b.degree || 0) - (a.degree || 0) + || (b.betweenness || 0) - (a.betweenness || 0) + || String(a.id).localeCompare(String(b.id))) + .slice(0, labelCap) + .map(n => n.id)); + applyChrome(); + if (!reused) { fg.graphData(data); seeded = data; } + applyForces(); + fg.autoPauseRedraw(!needsContinuousFrames()); + /* Bound the simulation the way the classic path does. Without these force-graph keeps its + 15-second default window, so every load and every reheat of a large store runs the + layout — and repaints every node and link — for more than ten seconds longer. */ + if (fg.cooldownTime) fg.cooldownTime(motion ? (large ? 1100 : 2200) : 0); + if (fg.cooldownTicks) fg.cooldownTicks(motion ? (large ? 80 : 160) : 1); + if (fg.warmupTicks) fg.warmupTicks(motion ? (large ? 18 : 40) : 45); + if (fg.d3AlphaDecay) fg.d3AlphaDecay(alphaDecay()); + if (fg.d3VelocityDecay) fg.d3VelocityDecay(large ? 0.45 : 0.38); + if (fg.linkCurvature) { + fg.linkCurvature(dense ? 0 : ((PRESETS[state.settings.mode] || PRESETS.compact).curve || 0)); + } + fg.linkDirectionalArrowLength(dense ? 0 : 2.5).linkDirectionalArrowRelPos(1); + applyLinkLabels(); + if (fg.linkDirectionalParticles) { + const flowing = state.settings.flow !== false + && motion + && data.links.length <= PARTICLE_LINK_LIMIT; + const particles = !flowing + ? 0 + : (state.styleName === 'cyber' ? 3 : ((PRESETS[state.settings.mode] || {}).particles || 2)); + fg.linkDirectionalParticles(l => l.suggested || l.ghost ? 0 : particles) + .linkDirectionalParticleWidth(2) + .linkDirectionalParticleColor(l => alpha(layerColor(l.layer), 0.95)) + .linkDirectionalParticleSpeed(l => 0.002 + ((state.settings.flowSpeed || 45) / 100) * 0.008); + } + if (reheat && motion && !state.settings.frozen && fg.d3ReheatSimulation) fg.d3ReheatSimulation(); + if ((state.settings.frozen || !motion) && fg.d3AlphaDecay) { /* keep painting, stop layout */ fg.d3AlphaDecay(1); } + /* Nothing was reseeded, so force-graph's own change detection saw no reason to repaint — + but Style, Color by and Labels all just changed how the *same* data must be drawn. */ + if (reused) invalidate(); + if (fit) { + clearTimeout(fitTimer); + fitTimer = setTimeout(() => { if (!destroyed) fg.zoomToFit(motion ? 600 : 0, 40); }, motion ? 320 : 0); + } + if (opts.onStats) opts.onStats({ nodes: data.nodes.length, links: data.links.length, total: raw.nodes.length, totalLinks: raw.links.length, preset: (PRESETS[state.settings.mode] || PRESETS.compact).label, collapsed: collapsed, ghosts: data.nodes.filter(n => n.ghost).length, bridges: data.links.filter(l => l.bridge).length, suggested: data.links.filter(l => l.suggested).length }); + } + + fg.backgroundColor('rgba(0,0,0,0)').nodeRelSize(1).autoPauseRedraw(true) + /* force-graph's default `nodeLabel`/`linkLabel` is the literal accessor "name", and its + tooltip renders a string label with innerHTML. Node names here are entity labels + extracted from ingested memories — untrusted input — so both accessors are set + explicitly and escaped rather than left on the vendor default. */ + .nodeLabel(node => esc(nodeName(node))) + .linkLabel(link => esc(link && link.label ? link.label : '')) + .onRenderFramePre((ctx, scale) => { try { styleBackground(ctx, scale); } catch (e) { } }) + .nodeCanvasObject((node, ctx, scale) => styleNode(node, ctx, scale)) + .nodePointerAreaPaint((node, color, ctx) => { ctx.fillStyle = color; ctx.beginPath(); ctx.arc(node.x, node.y, node.radius + 2, 0, 6.2832); ctx.fill(); }) + .linkColor(l => { + const focus = hoverSet && hoverSet.size > 1; + const s = linkEndpoint(l, 'source'), t = linkEndpoint(l, 'target'); + const active = !focus || s === hilite || t === hilite; + if (l.suggested) return alpha('#ffffff', active ? 0.34 : 0.1); + if (l.ghost) return alpha(layerColor(l.layer), 0.12); + if (state.bridges && l.bridge) return alpha('#ff5c7a', active ? 0.95 : 0.5); + const base = layerColor(l.layer); + return active ? alpha(base, focus ? 0.85 : 0.4) : alpha(base, 0.06); + }) + .linkLineDash(l => l.suggested ? [2, 2] : (l.ghost ? [1, 3] : null)) + .linkWidth(l => { + const w = state.settings.linkw || 1; + const focus = hoverSet && hoverSet.size > 1; + const s = linkEndpoint(l, 'source'), t = linkEndpoint(l, 'target'); + if (l.aggregate) return Math.min(6, 0.6 + Math.log2(1 + (l.weight || 1)) * 1.4) * w; + if (state.bridges && l.bridge) return 2.6 * w; + if (!focus) return 0.82 * w; + return (s === hilite || t === hilite) ? 2.4 * w : 0.4 * w; + }) + .onNodeHover(node => { + hilite = node ? node.id : null; + hoverSet = node ? new Set([node.id].concat(adj[node.id] || [])) : null; + el.classList.toggle('engraphis-graph-node-hover', !!node); + invalidate(); + }) + .onNodeClick(node => { + if (node.cluster) { collapsed = false; state.collapse = false; render(false, true); setTimeout(() => { fg.centerAt(node.x, node.y, 500); fg.zoom(1.6, 500); }, 60); if (opts.onCollapseChange) opts.onCollapseChange(false); return; } + if (opts.onNodeClick) opts.onNodeClick(node); + }) + .onNodeDragEnd(node => { node.fx = node.x; node.fy = node.y; }) + .onBackgroundClick(() => { if (opts.onBackgroundClick) opts.onBackgroundClick(); }) + .onZoom(z => { + zoom = z.k || 1; + if (state.collapse !== 'auto') return; + const next = zoom < 0.55; + if (next !== collapsed) { + collapsed = next; + render(false, true); + if (opts.onCollapseChange) opts.onCollapseChange(collapsed); + } + }); + + api.setData = data => { + const inputNodes = Array.isArray(data && data.nodes) ? data.nodes : []; + const nodes = inputNodes + .filter(node => node && node.id != null) + .map(node => Object.assign({}, node, { name: nodeName(node) })); + const nodeIds = new Set(nodes.map(node => node.id)); + const links = (Array.isArray(data && (data.links || data.edges)) ? (data.links || data.edges) : []) + .map(link => { + const source = linkEndpoint(link, 'source'), target = linkEndpoint(link, 'target'); + return Object.assign({}, link, { source, target }); + }) + .filter(link => link.source != null && link.target != null && nodeIds.has(link.source) && nodeIds.has(link.target)); + const suggestions = (Array.isArray(data && data.suggestions) ? data.suggestions : []) + .map(link => Object.assign({}, link, { source: linkEndpoint(link, 'source'), target: linkEndpoint(link, 'target') })) + .filter(link => link.source != null && link.target != null); + /* A fresh payload means fresh node objects, so the cached seed is stale even when the + ids are identical — force-graph must be re-pointed at the new objects or the render + below would style ones nobody is painting from. */ + seeded = null; + raw = { nodes, links, suggestions }; + adj = communities(raw.nodes, raw.links); + const deg = {}; + raw.links.forEach(l => { const s = linkEndpoint(l, 'source'), t = linkEndpoint(l, 'target'); deg[s] = (deg[s] || 0) + 1; deg[t] = (deg[t] || 0) + 1; }); + raw.nodes.forEach(n => { n.degree = deg[n.id] || 0; n.betweenness = 0; }); + maxDeg = maxOf(raw.nodes.map(n => n.degree), 1); + const ranked = [...raw.nodes].sort((a, b) => b.degree - a.degree); + ranked.forEach((n, i) => { n.rank = i; n.hub = i < 6; }); + // Bridge *edges* are cheap (linear) and feed the stats readout, so they stay eager. + // Betweenness is not: see ensureBetweenness. + findBridges(raw.nodes, raw.links, adj); + betweennessReady = false; + if (state.bridges || state.sizeBy === 'betweenness') ensureBetweenness(); + render(true, true); + }; + /* Which of these settings changes the *layout* rather than just the paint, matching the + classic path's `key==='repel'||key==='link'||key==='gravity'||key==='size'` in + dashboard.js::graphSet — `size` counts because it feeds d3.forceCollide, and `mode` + swaps the whole force arrangement. applyForces() only writes the new charge / link / + forceX-forceY / collide values into the simulation force-graph is already running, and a + settled graph sits at alpha~0, so without the reheat those sliders install a force that + moves nothing. The paint-only settings must keep the arrangement the user is reading. + render() applies the reduced-motion exemption (`if(layout&&!prefersReducedMotion())`). */ + const LAYOUT_KEYS = ['mode', 'repel', 'link', 'gravity', 'size']; + api.setSettings = patch => { + Object.assign(state.settings, patch); + render(false, LAYOUT_KEYS.some(k => patch && patch[k] !== undefined)); + }; + api.setPreset = name => { + const p = PRESETS[name] || PRESETS.compact; + state.settings.mode = PRESETS[name] ? name : 'compact'; + ['repel', 'link', 'gravity', 'font', 'size', 'linkw', 'labelDensity'].forEach(k => { if (p[k] !== undefined) state.settings[k] = p[k]; }); + render(true, true); + return { ...state.settings }; + }; + api.setStyle = name => { state.styleName = ['classic', 'galaxy', 'solar', 'cyber'].indexOf(name) < 0 ? 'cyber' : name; render(false, false); }; + api.setColorBy = name => { state.colorBy = name; refreshColors(); render(false, false); }; + api.setPalette = name => { state.palette = name; state.overrides = PALETTES[name] ? { ...PALETTES[name] } : {}; refreshColors(); }; + api.setTypeColor = (type, color) => { state.overrides[type] = color; state.palette = 'custom'; refreshColors(); }; + /* Rehydrating saved overrides is not a user edit, so it must not flip the palette + selector to "custom" behind the user's back the way setTypeColor deliberately does. */ + api.setTypeColors = map => { Object.assign(state.overrides, map || {}); refreshColors(); }; + /* The active theme's resolved `--entity-*` values. Replaced wholesale rather than merged: + a theme switch must not leave the previous theme's colour for a type the new one omits. */ + api.setThemeColors = map => { state.themeColors = map && typeof map === 'object' ? { ...map } : {}; refreshColors(); }; + /* One render for a whole batch of setters — see `batch`. */ + api.apply = (fn, fit, reheat) => { batch(fn, fit, reheat); }; + api.setHighlight = id => { + hilite = id == null ? null : id; + hoverSet = id == null ? null : new Set([id].concat(adj[id] || [])); + invalidate(); + }; + api.setScope = patch => { Object.assign(state, patch); render(false, true); }; + api.setLayers = layers => { state.layers = layers; render(false, false); }; + api.focus = id => { state.focusId = id; render(true, true); }; + api.clearFocus = () => { state.focusId = null; render(true, true); }; + api.fit = () => { if (!destroyed) fg.zoomToFit(reduced() ? 0 : 500, 40); }; + api.reheat = () => { + if (destroyed || reduced()) return; + raw.nodes.forEach(n => { n.fx = undefined; n.fy = undefined; }); + if (fg.d3ReheatSimulation) { fg.d3AlphaDecay(alphaDecay()); fg.d3ReheatSimulation(); } + }; + api.freeze = on => { + state.settings.frozen = on; + if (on) { fg.d3Force('charge').strength(0); fg.d3AlphaDecay(1); return; } + // Dragging pins a node with fx/fy. Unfreezing is a request to resume the layout, not + // merely the unpinned subset, so release those anchors before the simulation reheats. + raw.nodes.forEach(n => { n.fx = undefined; n.fy = undefined; }); + applyForces(); + if (reduced()) return; + fg.d3AlphaDecay(alphaDecay()); + if (fg.d3ReheatSimulation) fg.d3ReheatSimulation(); + }; + /* Returning `false` is not a failure: it is the signal the dashboard's graphFocus() uses to + run its recovery path ("show unlinked", then retry, then say so). Reporting success for an + entity that is not on the canvas is therefore worse than reporting failure — the user gets + a camera move to nothing and no explanation. Two ways that happened: the auto-collapsed + view paints only `cluster-*` bubbles, and any filtered-out node keeps the x/y force-graph + left on it from an earlier render, so "found in `raw.nodes` with finite coordinates" was + never evidence of visibility. Expand a collapsed view first — focusing a named entity is + an explicit request to see it — then confirm against the data force-graph is holding. */ + api.zoomToNode = id => { + if (destroyed) return false; + const n = raw.nodes.find(x => x.id === id); + if (!n) return false; + if (collapsed) { + collapsed = false; + state.collapse = false; + render(false, true); + if (opts.onCollapseChange) opts.onCollapseChange(false); + } + const shown = ((fg.graphData() || {}).nodes || []).some(node => node && node.id === id); + if (!shown || !Number.isFinite(n.x) || !Number.isFinite(n.y)) return false; + const duration = reduced() ? 0 : 500; + fg.centerAt(n.x, n.y, duration); + fg.zoom(3, duration); + return true; + }; + api.state = () => ({ ...state, collapsed }); + /* The engine clusters its own copies of the nodes, so a caller that renders a cluster + legend from the source data would otherwise report a single community. */ + api.communityMap = () => { + const map = {}; + raw.nodes.forEach(n => { map[n.id] = n.community || 0; }); + return map; + }; + api.setGhosts = on => { state.ghost = on; render(false, false); }; + api.setRepoFilter = repo => { state.repo = (repo || '').trim().toLowerCase(); render(false, true); }; + api.setAsOf = date => { state.asOf = asOfValue(date); render(false, true); }; + api.setSizeBy = metric => { + state.sizeBy = metric === 'betweenness' ? metric : 'degree'; + if (state.sizeBy === 'betweenness') ensureBetweenness(); + render(false, false); + }; + api.setBridges = on => { state.bridges = on; if (on) ensureBetweenness(); render(false, false); }; + /* Forces the lazy analysis; the dashboard does not display these yet, so nothing calls it + on the render path. Kept as the seam a "most load-bearing entity" panel would use. */ + api.metrics = () => { + ensureBetweenness(); + return { + top: [...raw.nodes].sort((a, b) => b.betweenness - a.betweenness).slice(0, 5) + .map(n => ({ id: n.id, name: nodeName(n), score: n.betweenness })), + bridges: raw.links.filter(l => l.bridge).length + }; + }; + api.setSuggestions = on => { state.suggestions = on; render(false, true); }; + api.setCollapse = mode => { + state.collapse = mode; + const next = mode === true || (mode === 'auto' && zoom < 0.55); + collapsed = next; + render(true, true); + }; + api.presets = PRESETS; + api.resize = () => { measure(); }; + /* Leaving the graph view must stop the simulation loop. force-graph keeps a rAF alive + for as long as it is resumed, so a hidden pane would otherwise repaint forever. */ + api.pause = () => { + if (destroyed || !running) return; + running = false; + if (fg.pauseAnimation) fg.pauseAnimation(); + }; + api.resume = () => { + if (destroyed || running) return; + running = true; + if (fg.resumeAnimation) fg.resumeAnimation(); + measure(); + }; + api.destroyed = () => destroyed; + api.destroy = () => { + if (destroyed) return; + destroyed = true; + clearTimeout(fitTimer); + try { + if (api._ro) { api._ro.disconnect(); api._ro = null; } + // `_destructor` pauses the rAF and drops the graph data; it does not detach the + // canvas, so clear the container too or a re-create leaves the old one attached. + if (fg._destructor) fg._destructor(); + el.removeAttribute('data-graph-style'); + el.classList.remove('engraphis-graph-node-hover'); + el.innerHTML = ''; + } catch (e) { /* teardown is best-effort: never let it block a view change */ } + raw = { nodes: [], links: [], suggestions: [] }; + adj = {}; + seeded = null; + hilite = null; + hoverSet = null; + }; + + // A hidden pane measures 0x0; writing that into force-graph collapses the canvas and + // nothing restores it, so only a real box is ever applied. + const measure = () => { + if (destroyed) return; + const w = el.clientWidth, h = el.clientHeight; + if (w > 0 && h > 0) fg.width(w).height(h); + }; + measure(); + requestAnimationFrame(() => { if (destroyed) return; measure(); fg.zoomToFit(reduced() ? 0 : 400, 40); }); + if (typeof ResizeObserver !== 'undefined') { + api._ro = new ResizeObserver(() => measure()); + api._ro.observe(el); + } + applyChrome(); + return api; + } + + window.EngraphisGraph = { + create, PRESETS, PALETTES, STYLE_LAYERS, COMMUNITY_PALS, GRAPH_HEAT, THEME_ETYPE, STYLE_PAL, + /* Pure helpers, exported so the offline test suite can assert real behaviour (escaping, + component labelling, bridge detection, stack safety) without a browser or a bundler. + Nothing in the dashboard uses these; treat them as the engine's unit-test seam. */ + _internals: { + esc, hexRgb, alpha, contrastOn, communities, betweenness, findBridges, maxOf, + nodeName, linkEndpoint, asOfValue + } + }; +})(); diff --git a/engraphis/static/dashboard.css b/engraphis/static/dashboard.css index fc2c328..59c4ec4 100644 --- a/engraphis/static/dashboard.css +++ b/engraphis/static/dashboard.css @@ -743,3 +743,8 @@ progress.graph-degree[data-graph-node-type="person_or_concept"]::-webkit-progres .gslider{display:flex;align-items:center;gap:8px;margin:6px 0}.gslider label{width:88px;flex-shrink:0;font-size:12px;color:var(--color-text-mute,var(--color-text-dim))}.gslider input[type=range]{flex:1;min-width:0;accent-color:var(--color-accent)} .gx-chip-full{width:100%;justify-content:flex-start;border-radius:7px;margin-bottom:2px} .gx-dot-dim{background:var(--color-text-dim,#888)} +#graph-net[data-graph-style="galaxy"]{background:radial-gradient(58% 50% at 24% 22%,rgba(126,64,208,.30),transparent 66%),radial-gradient(52% 58% at 82% 78%,rgba(220,72,164,.20),transparent 68%),radial-gradient(46% 52% at 62% 42%,rgba(58,120,224,.16),transparent 70%),#06040f} +#graph-net[data-graph-style="solar"]{background:radial-gradient(40% 48% at 50% 50%,rgba(255,184,92,.16),transparent 60%),radial-gradient(90% 90% at 50% 50%,rgba(18,32,64,.55),transparent 82%),#05070d} +#graph-net[data-graph-style="cyber"]{background:linear-gradient(rgba(34,224,255,.055) 1px,transparent 1px) 0 0/30px 30px,linear-gradient(90deg,rgba(34,224,255,.055) 1px,transparent 1px) 0 0/30px 30px,radial-gradient(72% 60% at 50% 0%,rgba(255,62,165,.12),transparent 72%),#050810} +#graph-net.engraphis-graph-node-hover{cursor:pointer} +#graph-net:not(.engraphis-graph-node-hover){cursor:grab} diff --git a/engraphis/static/dashboard.js b/engraphis/static/dashboard.js index d9da2e8..b012fbe 100644 --- a/engraphis/static/dashboard.js +++ b/engraphis/static/dashboard.js @@ -105,6 +105,9 @@ function selectView(v){ /* Plan pills live in the topbar; clear them on navigation so only the active view's loader can repopulate one. */ ['an-lock','au-lock'].forEach(id=>{const p=document.getElementById(id);if(p){p.textContent='';p.className='pill pill-muted topbar-lock'}}); + /* The graph canvas is the only view that owns an animation loop. Park it while it is not + on screen so navigating away does not leave a hidden canvas repainting forever. */ + if(v==='graph')graphEngineResume();else graphEnginePause(); closeMobileNav(); (LOADERS[v]||function(){})(); heading.tabIndex=-1; @@ -474,7 +477,7 @@ function renderSync(d){const el=document.getElementById('sync-body');if(!el)retu async function syncNow(){const b=document.getElementById('sync-btn')||document.getElementById('sync-retry-btn');const original=b&&b.textContent;const s=document.getElementById('sync-status');if(b){b.disabled=true;b.textContent='Syncing…'}if(s)s.textContent='Contacting the cloud…';try{const d=await api('/sync/run',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'});const su=d.summary||{};toast('Synced — pushed '+(su.exported||0)+', '+(su.added||0)+' new from other devices','ok');await loadSyncStatus()}catch(e){if(e.status===401||e.status===402||e.status===403){const el=document.getElementById('sync-body');if(el)el.innerHTML=syncRecoveryHtml();toast(e.status===402?'Cloud Sync requires an active Pro or Team entitlement — open Engraphis Cloud to upgrade or renew.':'Cloud Sync authorization is no longer active — reconnect in Engraphis Cloud.','err');return}toast('Sync failed: '+e.message,'err');if(b){b.disabled=false;b.textContent=original||'Sync now'}if(s)s.textContent='Sync failed — try again.'}} /* ─── knowledge graph (force-graph + d3-force: compact defaults and selectable layouts) ─── */ -let GRAPH=null, FG=null, GRESIZE=false, GRESIZEFRAME=0, GADJ={}, GCOMM_ADJ={}, GCOMPONENTS={}, GCOMPONENT_LAYOUT=null, GHILITE=null, GHOVERSET=null, GLABELRANK={}, GLABELBOXES=[], GDATA_CACHE=null, GACTIVE_DATA=null, GREDRAWFRAME=0, GPERF={large:false,dense:false}; +let GRAPH=null, FG=null, GRAPH_ENGINE=null, GRESIZE=false, GRESIZEFRAME=0, GADJ={}, GCOMM_ADJ={}, GCOMPONENTS={}, GCOMPONENT_LAYOUT=null, GHILITE=null, GHOVERSET=null, GLABELRANK={}, GLABELBOXES=[], GDATA_CACHE=null, GACTIVE_DATA=null, GREDRAWFRAME=0, GPERF={large:false,dense:false}; const GRAPH_PRESETS={ original:{label:'Original force',repel:120,link:30,gravity:14,font:13,size:3,linkw:1,labelDensity:40,curve:0,particles:0}, compact:{label:'Compact clusters',repel:42,link:20,gravity:26,font:12,size:3,linkw:.7,labelDensity:30,curve:.08,particles:0}, @@ -507,6 +510,10 @@ function graphLoadColorPreferences(){ } function graphSaveColorPreferences(){try{localStorage.setItem(GRAPH_COLOR_KEY,JSON.stringify({palette:GCOLOR_PALETTE,colors:GCOLOR_OVERRIDES}))}catch(e){}} function graphTypeColor(type){if(GCOLOR_OVERRIDES[type])return GCOLOR_OVERRIDES[type];if(typeof GSTYLE!=='undefined'&&GSTYLE&&GSTYLE!=='classic'&&STYLE_PAL[GSTYLE]&&STYLE_PAL[GSTYLE][type])return STYLE_PAL[GSTYLE][type];return cssvar(ETYPE_TOKEN[type]||'--entity-concept',cssvar('--color-accent','#8c83e8'))} +/* The engine renders to a canvas, so it cannot read `--entity-*` itself the way the legend and + the controls do. Resolve the active theme's values here and hand them over; without this the + opt-in canvas keeps dark-theme node colours after a switch to Light/Solarized/Sepia. */ +function graphThemeTypeColors(){const colors={},fallback=cssvar('--color-accent','#8c83e8');Object.keys(ETYPE_TOKEN).forEach(type=>{colors[type]=cssvar(ETYPE_TOKEN[type],fallback)});colors.relation_label=cssvar('--color-text-dim','#7e8795');colors.label=cssvar('--color-text','#e7e9ee');return colors} function graphContrastColor(color){if(!graphValidColor(color))return cssvar('--color-canvas','#0e1014');const n=parseInt(color.slice(1),16),lum=.2126*(n>>16)+.7152*((n>>8)&255)+.0722*(n&255);return lum>150?'#111827':'#f8fafc'} const ETYPE_COLOR=new Proxy({},{get:(_,type)=>graphTypeColor(type)}); graphLoadColorPreferences(); @@ -528,6 +535,7 @@ function graphSetTypeColor(type,color,persist){ if(!type||!graphValidColor(color))return; GCOLOR_OVERRIDES[type]=color.toLowerCase();GCOLOR_PALETTE='custom'; const picker=document.getElementById('graph-palette');if(picker)picker.value='custom'; + if(GRAPH_ENGINE){GRAPH_ENGINE.setTypeColor(type,color);if(persist)graphSaveColorPreferences();return} graphRefreshNodeColors(); if(persist)graphSaveColorPreferences(); } @@ -553,7 +561,102 @@ function graphUpdateHud(data){ if(count&&data)count.textContent=data.nodes.length.toLocaleString()+' entities · '+data.links.length.toLocaleString()+' relations'; if(badge)badge.textContent=GPERF.large?'Large graph mode':'Adaptive rendering'; } -function graphInvalidateData(){GDATA_CACHE=null;GACTIVE_DATA=null;GCOMPONENT_LAYOUT=null;GHILITE=null;GHOVERSET=null} +/* ── opt-in next-generation renderer (`?graph-engine=next`) ────────────────────────────── + The classic renderer stays the default and the rollback path. Everything below is written + so that any failure in the opt-in engine degrades to classic rather than taking the graph + view down: one throw sets GRAPH_ENGINE_FAILED and the flag is never honoured again for the + life of the page. */ +let GRAPH_ENGINE_FAILED=false; +function graphEngineEnabled(){ + if(GRAPH_ENGINE_FAILED)return false; + try{return new URLSearchParams(window.location.search).get('graph-engine')==='next'}catch(e){return false} +} +function graphEngineFallback(error){ + GRAPH_ENGINE_FAILED=true; + try{if(GRAPH_ENGINE)GRAPH_ENGINE.destroy()}catch(e){} + GRAPH_ENGINE=null; + /* The classic renderer skips seeding when GACTIVE_DATA still points at the current data, + so a failure *after* a successful engine render would hand it an empty canvas. Clearing + the marker makes the very next graphRender() a full classic build. */ + GACTIVE_DATA=null;GCOMPONENT_LAYOUT=null;GHILITE=null;GHOVERSET=null; + if(window.console&&console.warn)console.warn('graph-engine=next failed; falling back to the classic renderer',error); +} +function graphEngineEmptyMessage(){ + const total=(GRAPH&&GRAPH.nodes&&GRAPH.nodes.length)||0; + return total?('No connected entities — tick "Show unlinked" to see all '+total+'.'):'No entities in this workspace yet.'; +} +function graphRenderEngine(data,fit,reheat){ + const element=document.getElementById('graph-net'),empty=document.getElementById('graph-empty'); + if(!element||typeof EngraphisGraph==='undefined')return false; + try{ + if(!data.nodes.length){ + if(GRAPH_ENGINE)GRAPH_ENGINE.setData({nodes:[],links:[]}); + showAs(empty,true,'flex'); + if(empty)empty.textContent=graphEngineEmptyMessage(); + GACTIVE_DATA=null;graphSetLayoutStatus('No entities',false);return true; + } + showAs(empty,false);GPERF={large:data.nodes.length>600||data.links.length>2400,dense:data.links.length>1500}; + const created=!GRAPH_ENGINE; + if(created){ + GRAPH_ENGINE=EngraphisGraph.create(element,{ + reducedMotion:prefersReducedMotion, + onNodeClick:node=>{syncGraphExplorerSelection(node.id);graphNodeClick(node.label||node.name||node.id)}, + onBackgroundClick:()=>graphSetHighlight(null), + onStats:stats=>{const count=document.getElementById('graph-hud-count');if(count)count.textContent=stats.nodes.toLocaleString()+' entities · '+stats.links.toLocaleString()+' relations'} + }); + } + /* Re-seeding identical data would re-copy every node and throw its x/y away, restarting + the layout on each slider or preset change. The classic path guards the same way. */ + const dataChanged=created||GACTIVE_DATA!==data; + const layers={};document.querySelectorAll('#graph-layer-filters input').forEach(input=>{layers[input.value]=input.checked}); + /* "Show unlinked nodes" is applied twice: graphData() decides what is handed over, and the + engine re-filters by degree on its own state. Leaving the engine on its defaults + (showUnlinked:false, minDegree:1) drops every degree-zero entity graphData() just supplied, + so the checkbox appeared to do nothing under ?graph-engine=next. */ + const isolated=document.getElementById('graph-show-iso'),showUnlinked=!!(isolated&&isolated.checked); + GRAPH_ENGINE.apply(engine=>{ + engine.setSettings({...window.GSET}); + engine.setStyle(typeof GSTYLE!=='undefined'?GSTYLE:'cyber'); + engine.setColorBy(typeof GCOLORBY!=='undefined'?GCOLORBY:'community'); + engine.setThemeColors(graphThemeTypeColors()); + engine.setPalette(typeof GCOLOR_PALETTE!=='undefined'?GCOLOR_PALETTE:'theme'); + engine.setTypeColors(GCOLOR_OVERRIDES||{}); + engine.setLayers(layers); + engine.setScope({showUnlinked,minDegree:showUnlinked?0:1}); + if(dataChanged)engine.setData(data); + },fit,reheat&&!prefersReducedMotion()); + /* Mirror the engine's clustering back onto the dashboard's own node objects, or the + cluster legend (which reads GACTIVE_DATA) reports one community for the whole store. */ + const communityMap=GRAPH_ENGINE.communityMap(); + data.nodes.forEach(node=>{node.community=communityMap[node.id]||0}); + GACTIVE_DATA=data;graphSyncReadouts();graphUpdateEditedBadge();graphUpdateHud(data);graphRenderLegend(GRAPH); + if(dataChanged)graphSetHighlight(null); + if(window.GSET.frozen)GRAPH_ENGINE.freeze(true); + /* The renderer can be born after the user has already left the view: /graph and both lazy + scripts resolve asynchronously, and the pause on nav-away ran while GRAPH_ENGINE was still + null. Re-apply the parked state here so a renderer created against a hidden pane never + starts a rAF that nothing will stop. */ + if(GRAPH_ENGINE_PARKED)GRAPH_ENGINE.pause(); + graphSetSimulationStatus(prefersReducedMotion()?'Static layout':'Adaptive layout',false); + return true; + }catch(error){ + graphEngineFallback(error); + return false; + } +} +/* Nav away from the graph view: park the engine's animation frame. Without this the opt-in + renderer keeps repainting a hidden canvas for the rest of the session. + The intent is *recorded* as well as applied, because pausing an engine that does not exist + yet is a no-op: leaving Graph before /graph (or either lazy script) resolves would otherwise + let the pending callback create and start a renderer against a hidden pane with no later + pause to stop it. graphRenderEngine() re-applies GRAPH_ENGINE_PARKED for that case. */ +let GRAPH_ENGINE_PARKED=false; +function graphEnginePause(){GRAPH_ENGINE_PARKED=true;try{if(GRAPH_ENGINE)GRAPH_ENGINE.pause()}catch(e){}} +function graphEngineResume(){GRAPH_ENGINE_PARKED=false;try{if(GRAPH_ENGINE)GRAPH_ENGINE.resume()}catch(e){}} +function graphInvalidateData(){ + if(GRAPH_ENGINE){try{GRAPH_ENGINE.destroy()}catch(e){}GRAPH_ENGINE=null} + GDATA_CACHE=null;GACTIVE_DATA=null;GCOMPONENT_LAYOUT=null;GHILITE=null;GHOVERSET=null +} async function loadLegacyGraph(){ graphInjectCss();graphInvalidateData();GRAPH=null; const empty=document.getElementById('graph-empty'),net=document.getElementById('graph-net'),nodesBox=document.getElementById('graph-entity-list'),edgesBox=document.getElementById('graph-relation-list'); @@ -563,8 +666,8 @@ async function loadLegacyGraph(){ if(!GRESIZE){ GRESIZE=true; window.addEventListener('resize',()=>{ - if(!FG||GRESIZEFRAME)return; - GRESIZEFRAME=requestAnimationFrame(()=>{GRESIZEFRAME=0;const element=document.getElementById('graph-net');if(FG&&element)FG.width(element.clientWidth).height(element.clientHeight)}); + if((!FG&&!GRAPH_ENGINE)||GRESIZEFRAME)return; + GRESIZEFRAME=requestAnimationFrame(()=>{GRESIZEFRAME=0;const element=document.getElementById('graph-net');if(GRAPH_ENGINE)GRAPH_ENGINE.resize();else if(FG&&element)FG.width(element.clientWidth).height(element.clientHeight)}); }); } const layerInputs=Array.from(document.querySelectorAll('#graph-layer-filters input')),selectedLayers=layerInputs.filter(input=>input.checked).map(input=>input.value),layerFilter=selectedLayers.length===layerInputs.length?'':'&layers='+encodeURIComponent(selectedLayers.join(',')),includeCode=document.getElementById('graph-include-code').checked,repo=(document.getElementById('graph-repo-filter').value||'').trim(); @@ -711,6 +814,7 @@ function graphSetStyle(name){ if(['classic','galaxy','solar','cyber'].indexOf(name)<0)name='cyber'; GSTYLE=name;try{localStorage.setItem('engraphis-graph-style',name)}catch(e){} graphApplyStyleChrome(); + if(GRAPH_ENGINE){GRAPH_ENGINE.setStyle(name);return} if(GRAPH&&FG){graphRefreshNodeColors();graphRenderLegend();graphRender(false,false);} } /* ─── colorful graphs even when every node is one entity type: color by community or connections ─── */ @@ -766,6 +870,7 @@ function graphSetColorBy(mode){ if(['type','community','connections'].indexOf(mode)<0)mode='community'; GCOLORBY=mode;try{localStorage.setItem('engraphis-graph-colorby',mode)}catch(e){} var sel=document.getElementById('graph-colorby');if(sel&&sel.value!==mode)sel.value=mode; + if(GRAPH_ENGINE){GRAPH_ENGINE.setColorBy(mode);graphRenderLegend();return} if(GRAPH&&FG&&GACTIVE_DATA){graphComputeCommunities(GACTIVE_DATA.nodes);GMAXDEG=GACTIVE_DATA.nodes.reduce(function(m,n){return Math.max(m,n.degree||0);},1);graphRefreshNodeColors();graphRenderLegend();} } function graphApplyForces(){ @@ -790,6 +895,9 @@ function graphApplyForces(){ function graphSetHighlight(id){ GHILITE=id||null; GHOVERSET=id?new Set([id,...(GADJ[id]||[])]):null; + /* graphRedraw() is a no-op without the classic FG instance, so the opt-in engine needs + telling directly — otherwise hovering the entity list highlights nothing on canvas. */ + if(GRAPH_ENGINE){try{GRAPH_ENGINE.setHighlight(GHILITE)}catch(e){}return} graphRedraw(); } function graphRefreshNodeMetrics(){ @@ -800,6 +908,12 @@ function graphRedraw(){ if(!FG||GREDRAWFRAME)return; GREDRAWFRAME=requestAnimationFrame(()=>{GREDRAWFRAME=0;if(FG)FG.nodeCanvasObject(FG.nodeCanvasObject())}); } +/* ── on-demand graph assets ─────────────────────────────────────────────────────────────── + Neither script is in index.html. force-graph.min.js applies inline styles at runtime, and + under the production CSP (`style-src 'self'`) every one of those is blocked and reported; + loading it on a page that never opens the graph turns a plain dashboard view into a wall of + console errors. Both loaders are memoized, so a re-entrant graphRender() reuses the in-flight + fetch rather than appending a second + diff --git a/pyproject.toml b/pyproject.toml index da56949..9a2ee7a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -186,6 +186,7 @@ include = ["engraphis*", "scripts*"] # runtime artifacts such as static/__pycache__/*.pyc after a local compile check. # vendor/**/* covers nested vendor assets too (vendor/* alone doesn't cross "/"). "engraphis.static" = ["*.html", "*.css", "*.js", "*.png", "*.ico", "vendor/*", "vendor/**/*"] +"engraphis.dashboard_assets" = ["*.js"] "engraphis" = ["commercial_manifest.json"] [tool.setuptools.exclude-package-data] diff --git a/scripts/externalize_dashboard_assets.py b/scripts/externalize_dashboard_assets.py index 4c27b2d..c2aaf96 100644 --- a/scripts/externalize_dashboard_assets.py +++ b/scripts/externalize_dashboard_assets.py @@ -19,6 +19,26 @@ CSS = STATIC / "dashboard.css" JS = STATIC / "dashboard.js" +#: First-party scripts the dashboard page loads besides ``dashboard.js``. They run under the +#: same strict CSP, so they are held to the same no-inline-style/no-inline-handler contract. +#: Vendored bundles under ``static/vendor`` are excluded: they are third-party artefacts we +#: do not rewrite, and pinning them is the job of the commercial-manifest check. +V2_ASSETS = ROOT / "engraphis" / "dashboard_assets" +EXTRA_SCRIPTS = (V2_ASSETS / "engraphis-graph.js",) + +#: Scripts that must never be referenced from a ``", + "", encoding="utf-8", ) monkeypatch.setattr(assets, "INDEX", index) @@ -52,3 +57,158 @@ def test_check_rejects_unclosed_inline_asset_at_eof(tmp_path, monkeypatch, tag): with pytest.raises(SystemExit, match=f"inline {tag} block"): assets.check() + + +# ── deferred graph assets ─────────────────────────────────────────────────────────────── +# force-graph.min.js applies inline styles at runtime, so under `style-src 'self'` loading it +# on every page reported a CSP violation per attempt. It and the opt-in engine are fetched on +# demand instead; these rules keep that true and keep the deferred references checked. + +_LOADERS = ( + "script.src='/static/vendor/force-graph.min.js';" + "script.src='/v2-assets/engraphis-graph.js';" +) + + +def _gate(tmp_path, monkeypatch, html: str, js: str) -> None: + index, css, script = ( + tmp_path / "index.html", + tmp_path / "dashboard.css", + tmp_path / "dashboard.js", + ) + index.write_text(html, encoding="utf-8") + css.write_text("", encoding="utf-8") + script.write_text(js, encoding="utf-8") + monkeypatch.setattr(assets, "INDEX", index) + monkeypatch.setattr(assets, "CSS", css) + monkeypatch.setattr(assets, "JS", script) + # Isolate these cases to the script-reference rules; the first-party CSP scan has its own. + monkeypatch.setattr(assets, "EXTRA_SCRIPTS", ()) + + +def test_check_rejects_an_eagerly_loaded_csp_hostile_script(tmp_path, monkeypatch): + """Putting the tag back in index.html is precisely the regression this rule catches.""" + _gate( + tmp_path, + monkeypatch, + '', + _LOADERS, + ) + + with pytest.raises(SystemExit, match="must not eagerly load: /static/vendor/force-graph"): + assets.check() + + +def test_check_rejects_an_eagerly_loaded_v2_graph_engine(tmp_path, monkeypatch): + _gate( + tmp_path, + monkeypatch, + '', + _LOADERS, + ) + + with pytest.raises(SystemExit, match="must not eagerly load: /v2-assets/engraphis-graph"): + assets.check() + + +def test_check_allows_a_deferred_script_named_only_in_a_comment(tmp_path, monkeypatch): + """The rule reads parsed ``' + "" + '' + '' + "" + "" + "" + ) + + assert found == [ + "/static/vendor/force-graph.min.js", + "/static/engraphis-graph.js", + "/static/dashboard.js", + ] + + +def test_check_rejects_an_eagerly_loaded_script_in_any_tag_case(tmp_path, monkeypatch): + """The uppercase spelling is loaded by browsers, so it must fail the same rule.""" + _gate( + tmp_path, + monkeypatch, + '', + _LOADERS, + ) + + with pytest.raises(SystemExit, match="must not eagerly load: /static/vendor/force-graph"): + assets.check() + + +def test_check_rejects_a_mixed_case_reference_to_a_missing_file(tmp_path, monkeypatch): + """The existence check must not have a case-shaped hole either.""" + _gate( + tmp_path, + monkeypatch, + "", + _LOADERS, + ) + + with pytest.raises(SystemExit, match=r"referenced script is missing: .*renamed-bundle\.js"): + assets.check() + + +def test_uppercase_script_src_is_not_mistaken_for_an_inline_block(tmp_path, monkeypatch): + """The inline-block rule reads the same parse, so an external tag stays external.""" + _gate( + tmp_path, + monkeypatch, + '', + _LOADERS, + ) + + assets.check() diff --git a/tests/test_graph_engine_asset.py b/tests/test_graph_engine_asset.py new file mode 100644 index 0000000..931565e --- /dev/null +++ b/tests/test_graph_engine_asset.py @@ -0,0 +1,1566 @@ +"""Contract checks for the opt-in browser graph engine (``?graph-engine=next``). + +These tests intentionally stay dependency-light: the dashboard's offline CI floor does +not need a browser or a JavaScript package manager just to validate a shipped static +asset. Where Node is available the asset is *executed* rather than pattern-matched, so +the checks assert behaviour (escaping, bridge detection, stack safety, load-order +independence) instead of the presence of source substrings. + +The properties guarded here are the ones whose failure is silent in a browser: + +* the asset must define its global without touching ``ForceGraph``/``document``, so a + blocked or missing vendor bundle degrades instead of white-screening the dashboard; +* every label crossing into force-graph must be escaped, because force-graph's tooltip + is an ``innerHTML`` sink and entity labels come from ingested memories; +* the client-side graph analysis must not recurse per node or run unbounded work; +* the per-style pane backgrounds must stay in CSS, since the production CSP sets + ``style-src-attr 'none'``. +""" + +from __future__ import annotations + +import json +import re +import shutil +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +STATIC = ROOT / "engraphis" / "static" +ASSET = ROOT / "engraphis" / "dashboard_assets" / "engraphis-graph.js" +LEGACY_ADAPTER = STATIC / "engraphis-graph.js" +INDEX = STATIC / "index.html" +CSS = STATIC / "dashboard.css" +DASHBOARD = STATIC / "dashboard.js" +VENDOR = STATIC / "vendor" / "force-graph.min.js" + +NODE = shutil.which("node") +requires_node = pytest.mark.skipif(NODE is None, reason="node is not installed") + +#: Evaluates the asset with nothing but a bare ``window`` object in scope. Any top-level +#: use of a browser or vendor global would raise here, which is the point. +PRELUDE = """ +const fs = require('fs'); +const source = fs.readFileSync(process.argv[1], 'utf8'); +const window = {}; +new Function('window', source)(window); +const G = window.EngraphisGraph; +const I = G._internals; +const emit = value => console.log(JSON.stringify(value)); +""" + + +#: Same, plus a recording stand-in for force-graph so ``create()`` can be *driven*. Every +#: accessor is a chainable setter that returns the stored value when called with no arguments — +#: force-graph's own kapsule semantics — so the paint configuration the engine installs can be +#: read back and invoked instead of pattern-matched. ``calls`` counts the invalidations the +#: engine requests, which is the only observable form a "redraw now" takes. ``invocations`` +#: counts the *argument-less* calls, which under kapsule semantics are the commands rather than +#: the setters — ``d3ReheatSimulation()`` is one, and it has no other observable effect here. +ENGINE_PRELUDE = """ +const fs = require('fs'); +const source = fs.readFileSync(process.argv[1], 'utf8'); +const window = {}; +globalThis.requestAnimationFrame = () => {}; +const store = {}, calls = {}, invocations = {}; +const fg = new Proxy({}, { + get: (_target, prop) => (...args) => { + if (!args.length) { invocations[prop] = (invocations[prop] || 0) + 1; return store[prop]; } + calls[prop] = (calls[prop] || 0) + 1; + store[prop] = args.length === 1 ? args[0] : args; + return fg; + }, +}); +globalThis.ForceGraph = () => () => fg; +const el = { + attrs: {}, innerHTML: '', clientWidth: 800, clientHeight: 600, + getAttribute(name) { return this.attrs[name] === undefined ? null : this.attrs[name]; }, + setAttribute(name, value) { this.attrs[name] = value; }, + removeAttribute(name) { delete this.attrs[name]; }, + classList: { toggle() {}, remove() {} }, +}; +const chain = count => { + const nodes = [], links = []; + for (let i = 0; i <= count; i++) nodes.push({ id: 'n' + i }); + for (let i = 0; i < count; i++) { + links.push({ source: 'n' + i, target: 'n' + (i + 1), layer: 'semantic' }); + } + return { nodes, links }; +}; +new Function('window', source)(window); +const G = window.EngraphisGraph; +const I = G._internals; +const emit = value => console.log(JSON.stringify(value)); +""" + + +def _run_node(script: str, prelude: str = PRELUDE) -> object: + result = subprocess.run( + [NODE, "-e", prelude + script, str(ASSET)], + cwd=ROOT, + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stderr + return json.loads(result.stdout.strip().splitlines()[-1]) + + +def _run_engine(script: str) -> object: + return _run_node(script, prelude=ENGINE_PRELUDE) + + +# ── load order and failure isolation ──────────────────────────────────────────────── + + +def test_graph_assets_are_never_loaded_on_a_plain_page_view() -> None: + """Neither graph script may sit in index.html. + + force-graph applies inline styles at runtime, so under the production CSP + (``style-src 'self'``) every page load that fetched it reported a violation per attempt — + including the pages that never open the graph. + """ + html = INDEX.read_text(encoding="utf-8") + eager = re.findall(r']+src=["\'](/static/[^"\']+)["\']', html) + assert "/static/vendor/d3.min.js" in eager + assert "/static/dashboard.js" in eager + assert "/static/vendor/force-graph.min.js" not in eager + assert "/static/engraphis-graph.js" not in eager + + +def test_v1_graph_asset_is_only_a_compatibility_adapter() -> None: + """New renderer code stays on the v2 dashboard surface, not the legacy server.""" + adapter = LEGACY_ADAPTER.read_text(encoding="utf-8") + assert "canonicalAsset: '/v2-assets/engraphis-graph.js'" in adapter + assert "window.EngraphisGraph =" not in adapter + assert "window.EngraphisGraph =" in ASSET.read_text(encoding="utf-8") + + +def test_opt_in_graph_asset_is_lazily_loaded_after_its_dependencies() -> None: + """The load order the removed script tags used to guarantee now lives in graphRender(). + + ``graphRender`` returns early until ForceGraph is defined, so by the time the engine + branch runs its dependency is already in scope. + """ + source = DASHBOARD.read_text(encoding="utf-8") + assert "script.src='/static/vendor/force-graph.min.js'" in source + assert "script.src='/v2-assets/engraphis-graph.js'" in source + render = source[source.index("function graphRender("):] + render = render[: render.index("\nfunction ")] + force_graph_gate = render.index("typeof ForceGraph==='undefined'") + engine_gate = render.index("if(enginePending)") + classic = render.index("graphRenderEngine(data,fit,reheat)") + assert force_graph_gate < engine_gate < classic + + +def test_engine_node_labels_honor_the_configured_font_at_normal_zoom() -> None: + source = ASSET.read_text(encoding="utf-8") + assert "state.settings.font / scale / 3.4" not in source + assert "state.settings.font / scale" in source + + +#: Executes dashboard.js's real graph-render *routing* decision against a stub DOM. +#: ``graphEngineEnabled``, ``graphEngineFallback``, ``loadForceGraph``, ``loadGraphEngine`` and +#: the routing half of ``graphRender`` are verbatim source slices — nothing is re-implemented. +#: Only the classic renderer body below the routing decision is swapped for a ``CLASSIC()`` +#: marker, so the test can see which renderer a deep link actually reaches. +ROUTING_HARNESS = """ +const fs = require('fs'); +const src = fs.readFileSync(process.argv.slice(1).find(a => a.endsWith('dashboard.js')), 'utf8'); +const scenario = process.argv[process.argv.length - 1]; +const between = (from, to) => src.slice(src.indexOf(from), src.indexOf(to, src.indexOf(from))); +const flags = between('let GRAPH_ENGINE_FAILED=false;', 'function graphEngineEmptyMessage'); +const loaders = between('let FORCE_GRAPH_LOADING=null;', 'function graphRender('); +const DECISION = 'if(graphEngineEnabled()&&graphRenderEngine(data,fit,reheat))return;'; +const start = src.indexOf('function graphRender('); +const routing = src.slice(start, src.indexOf(DECISION, start) + DECISION.length) + + '\\n CLASSIC();\\n}'; + +const log = { appended: [], warned: [], engine: 0, classic: 0 }; +let pending = null; +const element = { clientWidth: 800, clientHeight: 600, classList: { toggle() {} }, + setAttribute() {}, set textContent(v) {} }; +globalThis.document = { + getElementById: () => element, + querySelectorAll: () => [], + createElement: () => (pending = {}), + head: { appendChild: s => log.appended.push(s.src) }, +}; +globalThis.window = { location: { search: '?graph-engine=next' }, GSET: { mode: 'compact' }, + console: globalThis.console }; +globalThis.console = { warn: (...a) => log.warned.push(String(a[0])) }; +globalThis.showAs = () => {}; +globalThis.graphSetLayoutStatus = () => {}; +globalThis.graphData = () => ({ nodes: [], links: [] }); +/* Mirrors graphRenderEngine's real first line — `if(!element||typeof EngraphisGraph=== + 'undefined')return false` — because that bail is exactly what a naive lazy-load would turn + into a silent Classic fallback. Asserted against the real source below. */ +globalThis.graphRenderEngine = () => { + if (typeof EngraphisGraph === 'undefined') return false; + log.engine += 1; + return true; +}; +globalThis.CLASSIC = () => { log.classic += 1; }; +globalThis.GRAPH_PRESETS = { compact: {} }; +globalThis.GRAPH_ENGINE = globalThis.GACTIVE_DATA = globalThis.GCOMPONENT_LAYOUT = null; +globalThis.GHILITE = globalThis.GHOVERSET = null; +/* The vendor bundle is already in scope: this exercises the engine gate, not the vendor gate. */ +globalThis.ForceGraph = function () {}; + +new Function(flags + loaders + routing + '\\nreturn {graphRender};')().graphRender(); +const settled = { engine: log.engine, classic: log.classic }; +if (scenario === 'loads') { globalThis.EngraphisGraph = { create() {} }; pending.onload(); } +else { pending.onerror(); } +setTimeout(() => process.stdout.write(JSON.stringify({ + beforeSettle: settled, engine: log.engine, classic: log.classic, + appended: log.appended, warned: log.warned, +})), 0); +""" + + +def _run_routing(scenario: str) -> dict: + result = subprocess.run( + [NODE, "-e", ROUTING_HARNESS, str(DASHBOARD), scenario], + cwd=ROOT, + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stderr + return json.loads(result.stdout.strip().splitlines()[-1]) + + +@requires_node +def test_graph_engine_deep_link_reaches_the_next_engine_after_a_lazy_load() -> None: + """``?graph-engine=next`` must not degrade just because its asset is not loaded yet. + + ``graphRenderEngine`` bails when ``EngraphisGraph`` is undefined, and that bail cannot tell + "not fetched yet" from "unavailable". Deferring the script would turn every deep link into + that bail — the user asks for the new engine and silently gets Classic. So graphRender + fetches the asset and waits, then renders. + """ + # Keep the harness's stub honest: it only proves anything while the real function really + # does bail on an undefined global. + source = DASHBOARD.read_text(encoding="utf-8") + engine_path = source[source.index("function graphRenderEngine"):] + assert "typeof EngraphisGraph==='undefined')return false" in engine_path[:400] + + report = _run_routing("loads") + + assert report["appended"] == ["/v2-assets/engraphis-graph.js"] + # It waits rather than rendering something wrong in the meantime. + assert report["beforeSettle"] == {"engine": 0, "classic": 0} + # And it lands on the next engine, never touching the classic renderer. + assert report["engine"] == 1 + assert report["classic"] == 0 + assert report["warned"] == [] + + +@requires_node +def test_graph_engine_deep_link_degrades_loudly_when_the_asset_cannot_load() -> None: + """A genuine load failure is the only thing that reaches Classic, and it says so.""" + report = _run_routing("fails") + + assert report["engine"] == 0 + assert report["classic"] == 1 + assert report["warned"] == [ + "graph-engine=next failed; falling back to the classic renderer" + ] + + +def test_lazy_graph_engine_load_cannot_raise_an_unhandled_rejection() -> None: + """An unhandled rejection prints a console error — the exact thing this fix removes. + + ``graphRender`` can start the engine fetch on a pass that returns at the ForceGraph gate, + before it attaches its own handler, so the memoized promise carries its own. + """ + source = DASHBOARD.read_text(encoding="utf-8") + loader = source[source.index("function loadGraphEngine()"):] + loader = loader[: loader.index("\nfunction ")] + assert "GRAPH_ENGINE_LOADING.catch(()=>{})" in loader + # A 200 that never registers the global is a corrupt asset, not a success. + assert "reject(new Error('Graph engine asset loaded without registering EngraphisGraph'))" in loader + + +def test_force_graph_loader_rejects_a_success_without_the_vendor_global() -> None: + """A truncated 200 must not enter the render loop without ``ForceGraph``.""" + source = DASHBOARD.read_text(encoding="utf-8") + loader = source[source.index("function loadForceGraph()"):] + loader = loader[: loader.index("\nlet GRAPH_ENGINE_LOADING")] + assert "typeof ForceGraph==='undefined'" in loader + assert "reject(new Error('Force graph asset loaded without registering ForceGraph'))" in loader + + +@requires_node +def test_graph_asset_defines_its_global_without_touching_its_dependencies() -> None: + """Nothing may run at parse time except pure setup. + + ``PRELUDE`` supplies no ``ForceGraph``, no ``document`` and no ``requestAnimationFrame``. + If the asset reached for any of them at the top level this would throw, and in a browser + the same reach would abort the script and take ``window.EngraphisGraph`` with it. + """ + report = _run_node( + """ + emit({ + create: typeof G.create, + presets: Object.keys(G.PRESETS).sort(), + styles: Object.keys(G.STYLE_LAYERS).sort(), + }); + """ + ) + assert report["create"] == "function" + assert "communities" in report["presets"] + assert report["styles"] == ["classic", "cyber", "galaxy", "solar"] + + +@requires_node +def test_create_fails_loudly_when_force_graph_is_unavailable() -> None: + """A blocked vendor bundle must raise, not half-initialise a dead canvas.""" + report = _run_node( + """ + let message = null; + try { G.create({ getAttribute() { return null; } }, {}); } + catch (error) { message = error.message; } + emit({ message }); + """ + ) + assert report["message"] == "force-graph not loaded" + + +def test_dashboard_falls_back_to_the_classic_renderer_when_the_engine_throws() -> None: + source = DASHBOARD.read_text(encoding="utf-8") + # The opt-in flag must be latched off after a failure, and the render path must catch. + assert "GRAPH_ENGINE_FAILED" in source + assert "if(GRAPH_ENGINE_FAILED)return false" in source + assert "graphEngineFallback(error)" in source + engine_path = source[source.index("function graphRenderEngine"):] + engine_path = engine_path[: engine_path.index("\nfunction ")] + assert "try{" in engine_path and "}catch(error){" in engine_path + + +# ── XSS: untrusted entity labels reaching force-graph ─────────────────────────────── + + +def test_force_graph_tooltip_is_still_an_inner_html_sink() -> None: + """Guards the *reason* the engine sets its own label accessors. + + force-graph defaults ``nodeLabel``/``linkLabel`` to the accessor ``"name"`` and renders a + string label through ``innerHTML``. Node names here are entity labels extracted from + ingested memories, i.e. untrusted. If a vendor bump ever changes this, revisit whether + the explicit escaped accessors below are still the right shape. + """ + vendor = VENDOR.read_text(encoding="utf-8", errors="ignore") + assert 'nodeLabel:{default:"name"' in vendor + assert 'linkLabel:{default:"name"' in vendor + + +def test_engine_never_relies_on_the_default_label_accessor() -> None: + source = ASSET.read_text(encoding="utf-8") + assert ".nodeLabel(node => esc(nodeName(node)))" in source + assert ".linkLabel(" in source + assert "eval(" not in source + # The engine paints to canvas; the only markup sink it may use is clearing its own + # container on teardown. Anything else would be a route for an unescaped entity label. + writes = re.findall(r"\w+\.(?:inner|outer)HTML\s*=\s*[^;]+", source) + assert writes == ["el.innerHTML = ''"], writes + assert not re.search(r"insertAdjacentHTML|document\.write|createContextualFragment", source) + + +@requires_node +@pytest.mark.parametrize( + "payload", + [ + "", + "", + "\" onmouseover=\"alert(1)", + "", + ], +) +def test_entity_labels_are_escaped_before_they_can_reach_a_dom_sink(payload: str) -> None: + report = _run_node( + "emit({ escaped: I.esc(%s), named: I.nodeName({ label: %s }) });" + % (json.dumps(payload), json.dumps(payload)) + ) + escaped = report["escaped"] + assert "<" not in escaped and ">" not in escaped + assert '"' not in escaped and "'" not in escaped + assert "<" in escaped or """ in escaped + # nodeName is the raw value; escaping is the accessor's job, so this documents the split. + assert report["named"] == payload + + +# ── payload compatibility with the shipped /graph endpoint ────────────────────────── + + +@requires_node +def test_engine_accepts_both_the_api_and_renderer_link_shapes() -> None: + report = _run_node( + """ + const api = { from: 'a', to: 'b' }; + const renderer = { source: { id: 'c' }, target: 'd' }; + emit({ + apiSource: I.linkEndpoint(api, 'source'), + apiTarget: I.linkEndpoint(api, 'target'), + rendererSource: I.linkEndpoint(renderer, 'source'), + rendererTarget: I.linkEndpoint(renderer, 'target'), + label: I.nodeName({ label: 'Ada' }), + name: I.nodeName({ name: 'Grace' }), + fallback: I.nodeName({ id: 'ent_1' }), + }); + """ + ) + assert report["apiSource"] == "a" and report["apiTarget"] == "b" + assert report["rendererSource"] == "c" and report["rendererTarget"] == "d" + assert report["label"] == "Ada" + assert report["name"] == "Grace" + assert report["fallback"] == "ent_1" + + +@requires_node +def test_valid_time_accepts_seconds_milliseconds_and_iso_strings() -> None: + report = _run_node( + """ + emit({ + seconds: I.asOfValue(1700000000), + millis: I.asOfValue(1700000000000), + iso: I.asOfValue('2023-11-14T22:13:20Z'), + blank: I.asOfValue(''), + junk: I.asOfValue('not a date'), + }); + """ + ) + assert report["seconds"] == report["millis"] == 1700000000000 + assert report["iso"] == 1700000000000 + assert report["blank"] is None and report["junk"] is None + + +# ── client-side analysis: correctness and cost ────────────────────────────────────── + + +@requires_node +def test_bridge_detection_matches_a_known_graph() -> None: + """A triangle has no bridges; the tail hanging off it is all bridges.""" + report = _run_node( + """ + const nodes = ['a', 'b', 'c', 'd', 'e'].map(id => ({ id })); + const links = [['a','b'], ['b','c'], ['c','a'], ['c','d'], ['d','e']] + .map(([source, target]) => ({ source, target })); + const adj = I.communities(nodes, links); + I.findBridges(nodes, links, adj); + emit({ + bridges: links.filter(l => l.bridge).map(l => l.source + '-' + l.target), + communities: new Set(nodes.map(n => n.community)).size, + }); + """ + ) + assert report["bridges"] == ["c-d", "d-e"] + assert report["communities"] == 1 + + +@requires_node +def test_parallel_edges_are_not_reported_as_bridges() -> None: + report = _run_node( + """ + const nodes = [{ id: 'a' }, { id: 'b' }]; + const links = [{ source: 'a', target: 'b' }, { source: 'a', target: 'b' }]; + const adj = I.communities(nodes, links); + I.findBridges(nodes, links, adj); + emit({ bridges: links.filter(l => l.bridge).length }); + """ + ) + assert report["bridges"] == 0 + + +@requires_node +def test_disconnected_entities_are_labelled_as_separate_communities() -> None: + report = _run_node( + """ + const nodes = ['a', 'b', 'c', 'd'].map(id => ({ id })); + const links = [{ source: 'a', target: 'b' }, { source: 'c', target: 'd' }]; + const adj = I.communities(nodes, links); + emit({ groups: new Set(nodes.map(n => n.community)).size }); + """ + ) + assert report["groups"] == 2 + + +@requires_node +def test_graph_analysis_is_stack_safe_and_bounded_on_a_large_store() -> None: + """A long chain of entities is the worst case for both analyses. + + A recursive Tarjan overflows the call stack here, and exact Brandes betweenness is + O(V*E) — minutes of blocked main thread. Both are guarded, so this must finish well + inside the bound even on a slow machine. + """ + report = _run_node( + """ + const N = 40000; + const nodes = [], links = []; + for (let i = 0; i < N; i++) { + nodes.push({ id: 'n' + i }); + if (i) links.push({ source: 'n' + (i - 1), target: 'n' + i }); + } + const adj = I.communities(nodes, links); + const started = Date.now(); + I.findBridges(nodes, links, adj); + I.betweenness(nodes, adj); + const scores = nodes.map(n => n.betweenness); + emit({ + ms: Date.now() - started, + allBridges: links.every(l => l.bridge), + finite: scores.every(Number.isFinite), + peak: Math.max.apply(null, scores.slice(0, 1000).concat(scores.slice(-1000))), + }); + """ + ) + assert report["allBridges"] is True + assert report["finite"] is True + # Ends of a chain are never on a shortest path between others. + assert report["peak"] < 0.5 + assert report["ms"] < 30000, f"graph analysis took {report['ms']}ms on 40k entities" + + +@requires_node +def test_influence_relations_do_not_merge_two_topics_into_one_community() -> None: + """Community Islands must not fuse two topics over a single cross-topic relation. + + ``influences`` edges routinely span otherwise separate bodies of work. The classic + renderer keeps them drawn and traversable but builds its clustering adjacency without + them (``GCOMM_ADJ``); adding every link to one adjacency gives both topics the same + colour and the same force centre. + """ + report = _run_node( + """ + const nodes = ['a', 'b', 'c', 'd'].map(id => ({ id })); + const links = [ + { source: 'a', target: 'b', label: 'mentions' }, + { source: 'c', target: 'd', label: 'mentions' }, + { source: 'b', target: 'c', label: 'influences' }, + ]; + const adj = I.communities(nodes, links); + I.findBridges(nodes, links, adj); + emit({ + groups: new Set(nodes.map(n => n.community)).size, + merged: nodes[1].community === nodes[2].community, + neighbours: (adj.b || []).slice().sort(), + bridges: links.filter(l => l.bridge).length, + }); + """ + ) + assert report["groups"] == 2 + assert report["merged"] is False + # The relation itself stays in the traversal adjacency: hover neighbourhood, focus depth + # and bridge detection all still see it. Only the clustering ignores it. + assert report["neighbours"] == ["a", "c"] + assert report["bridges"] == 3 + + +@requires_node +def test_community_ids_are_ranked_by_size_so_the_legend_describes_the_right_nodes() -> None: + """Legend labels and canvas swatches must agree about which cluster is "Cluster 1". + + ``graphRenderLegend()`` sorts communities by size and calls the largest "Cluster 1", but + node colour indexes the palette by the community *id* (``commPal()[community % n]``). + Assigning ids in raw payload order therefore made the legend describe one component with + another's colour whenever a smaller component appeared first — which the payload order + alone decides. The classic ``graphComputeCommunities()`` sorts before assigning; so must + this. + """ + report = _run_node( + """ + // Payload order is deliberately worst-case: the singleton comes first, the largest + // component last, so raw iteration order and size order disagree completely. + const nodes = ['solo', 'm1', 'm2', 'a', 'b', 'c'].map(id => ({ id })); + const links = [ + { source: 'm1', target: 'm2' }, + { source: 'a', target: 'b' }, + { source: 'b', target: 'c' }, + ]; + I.communities(nodes, links); + const byId = {}; + nodes.forEach(n => { byId[n.id] = n.community; }); + emit({ byId, distinct: new Set(nodes.map(n => n.community)).size }); + """ + ) + assert report["distinct"] == 3 + # Largest component (3 nodes) owns palette slot 0, i.e. the legend's "Cluster 1". + assert report["byId"]["a"] == 0 + assert report["byId"]["b"] == 0 + assert report["byId"]["c"] == 0 + # Then the 2-node component, then the singleton — strictly by size, not by payload order. + assert report["byId"]["m1"] == 1 + assert report["byId"]["m2"] == 1 + assert report["byId"]["solo"] == 2 + + +@requires_node +def test_max_helper_survives_arrays_past_the_spread_limit() -> None: + """``Math.max(...array)`` throws RangeError long before a store is unrenderable.""" + report = _run_node("emit({ max: I.maxOf(new Array(400000).fill(7), 1) });") + assert report["max"] == 7 + + +@requires_node +def test_colour_helpers_handle_the_shorthand_hex_the_palettes_may_carry() -> None: + report = _run_node( + """ + emit({ + short: I.hexRgb('#abc'), + long: I.hexRgb('#8c83e8'), + empty: I.hexRgb(''), + light: I.contrastOn('#ffffff'), + dark: I.contrastOn('#000000'), + }); + """ + ) + assert report["short"] == [170, 187, 204] + assert report["long"] == [140, 131, 232] + assert report["empty"] == [140, 131, 232] + assert report["light"] == "#111827" + assert report["dark"] == "#f8fafc" + + +# ── render configuration: what the engine actually installs on force-graph ────────── + + +@requires_node +def test_flow_particles_are_capped_on_a_large_relation_set() -> None: + """Three animated particles per relation does not survive a real ``/graph`` response. + + force-graph advances every particle on every frame, so a few thousand relations is tens + of thousands of animated objects and an unusable canvas. The classic renderer refuses to + draw them past 800 links; the opt-in engine must use the same cutoff rather than trusting + that no store is big. + """ + report = _run_engine( + """ + const api = G.create(el, {}); + const particlesFor = link => store.linkDirectionalParticles(link || { layer: 'semantic' }); + api.setStyle('cyber'); + api.setSettings({ flow: true }); + api.setData(chain(40)); + const small = particlesFor(); + api.setData(chain(800)); + const atLimit = particlesFor(); + api.setData(chain(801)); + const overLimit = particlesFor(); + api.setData(chain(4000)); + emit({ small, atLimit, overLimit, realistic: particlesFor() * 4000 }); + """ + ) + assert report["small"] == 3 + assert report["atLimit"] == 3 + assert report["overLimit"] == 0 + # The number this guards: 4k relations x 3 particles was 12,000 animated objects a frame. + assert report["realistic"] == 0 + + +#: A canvas 2D stand-in that counts the fills the galaxy starfield performs. The engine wraps +#: ``onRenderFramePre`` in a try/catch, so a stub too thin to survive the real paint would read +#: as "no stars drawn"; the small-graph leg of the test below is what proves it is thick enough. +CANVAS_STUB = """ +let fills = 0; +const ctx = { + globalAlpha: 1, globalCompositeOperation: '', fillStyle: '', strokeStyle: '', lineWidth: 1, + save() {}, restore() {}, beginPath() {}, arc() {}, ellipse() {}, stroke() {}, + fill() { fills += 1; }, + createRadialGradient() { return { addColorStop() {} }; }, +}; +""" + + +@requires_node +def test_galaxy_stops_animating_once_the_graph_is_large() -> None: + """A settled graph must fall off the CPU, and galaxy was the one style that never did. + + The starfield lives in ``onRenderFramePre``, which force-graph's change detection cannot + see, so the engine holds ``autoPauseRedraw(false)`` for it — repainting every node and link + every frame, forever, even after particles and the simulation have stopped. The classic + path simply drops the starfield past ``GPERF.large`` (``if(GPERF.large)return``); with the + stars gone there is nothing left that needs a frame the vendor would not schedule itself. + """ + report = _run_engine( + CANVAS_STUB + + """ + const api = G.create(el, {}); + api.setStyle('galaxy'); + + api.setData(chain(40)); + const smallAutoPause = store.autoPauseRedraw; + fills = 0; store.onRenderFramePre(ctx, 1); + const smallStars = fills; + + // 3001 entities / 3000 relations — past the classic renderer's 600-node signal. + api.setData(chain(3000)); + const bigAutoPause = store.autoPauseRedraw; + fills = 0; store.onRenderFramePre(ctx, 1); + const bigStars = fills; + + // Style is what costs the frames, not size alone: cyber never asked for them. + api.setStyle('cyber'); + api.setData(chain(40)); + emit({ smallAutoPause, bigAutoPause, smallStars, bigStars, + cyberAutoPause: store.autoPauseRedraw }); + """ + ) + # Small galaxy graph: the animation is affordable, so the engine keeps driving frames. + assert report["smallAutoPause"] is False + assert report["smallStars"] > 0, "canvas stub never reached the starfield" + # Large galaxy graph: no starfield, and the redraw loop is handed back to force-graph. + assert report["bigStars"] == 0 + assert report["bigAutoPause"] is True, "a large galaxy graph repaints every frame forever" + assert report["cyberAutoPause"] is True + + +@requires_node +def test_type_colours_follow_the_active_theme_not_a_hard_coded_dark_palette() -> None: + """``applyTheme()`` recolours the canvas, but the engine had no theme to recolour to. + + The legend and controls read the ``--entity-*`` custom properties, so switching to Light, + Midnight, Solarized or Sepia moved them while the canvas kept the dark-theme constants — + an inconsistent palette and, on the light themes, poor contrast. The engine cannot read + CSS variables from a canvas, so the dashboard supplies the resolved values. + """ + report = _run_engine( + """ + const api = G.create(el, {}); + // setData first: the force-graph stand-in only starts answering graphData() once the + // engine has pushed data into it, where the real vendor seeds an empty graph. + // Linked, because the default scope hides degree-zero entities. + api.setData({ + nodes: [{ id: 'a', etype: 'person_or_concept' }, { id: 'b', etype: 'person_or_concept' }], + links: [{ source: 'a', target: 'b', layer: 'entity' }], + }); + api.setColorBy('type'); + api.setStyle('classic'); + // `store` holds the values handed to force-graph, so this is the node object the + // engine actually painted from — recoloured in place by refreshColors()/render(). + const colour = () => store.graphData.nodes[0].color; + + const fallback = colour(); + api.setThemeColors({ person_or_concept: '#112233' }); + const themed = colour(); + + // A style palette still outranks the theme, exactly as classic graphTypeColor() does. + api.setStyle('cyber'); + const styled = colour(); + + // ...and an explicit user override still outranks both. + api.setStyle('classic'); + api.setTypeColor('person_or_concept', '#abcdef'); + const overridden = colour(); + + // A theme with no entry for the type must not strand the previous theme's colour. + api.setThemeColors({}); + emit({ fallback, themed, styled, overridden, cleared: colour() }); + """ + ) + assert report["fallback"] == "#8c83e8" + assert report["themed"] == "#112233", "the engine ignores the active theme" + assert report["styled"] == "#ff3ea5" + assert report["overridden"] == "#abcdef" + # The override survives; only the theme tier was replaced. + assert report["cleared"] == "#abcdef" + + +@requires_node +def test_hovering_a_node_asks_for_a_redraw() -> None: + """A highlight nobody repaints is invisible. + + ``onNodeHover`` mutates closure state the paint callbacks read. With reduced motion on, + flow disabled, or a settled simulation, force-graph's ``autoPauseRedraw`` loop has nothing + left to animate and will not repaint just because the callback fired. + """ + report = _run_engine( + """ + const api = G.create(el, { reducedMotion: () => true }); + api.setData({ nodes: [{ id: 'a' }, { id: 'b' }], links: [{ source: 'a', target: 'b' }] }); + const settled = calls.nodeCanvasObject; + store.onNodeHover({ id: 'a' }); + const hovered = calls.nodeCanvasObject; + store.onNodeHover(null); + emit({ + settled, hovered, cleared: calls.nodeCanvasObject, + particles: store.linkDirectionalParticles({ layer: 'semantic' }), + }); + """ + ) + # Reduced motion: nothing is in flight, so an unrequested redraw would never arrive. + assert report["particles"] == 0 + assert report["hovered"] > report["settled"] + assert report["cleared"] > report["hovered"] + + +@requires_node +def test_unlinked_entities_are_shown_only_when_the_engine_is_told_to() -> None: + """The lever the dashboard has to pull for its "Show unlinked nodes" checkbox.""" + report = _run_engine( + """ + const seen = []; + const api = G.create(el, { onStats: stats => seen.push(stats.nodes) }); + api.setData({ + nodes: [{ id: 'a' }, { id: 'b' }, { id: 'lonely' }], + links: [{ source: 'a', target: 'b' }], + }); + const hidden = seen[seen.length - 1]; + api.setScope({ showUnlinked: true, minDegree: 0 }); + emit({ hidden, shown: seen[seen.length - 1] }); + """ + ) + assert report["hidden"] == 2 + assert report["shown"] == 3 + + +#: Executes the *real* ``graphRenderEngine`` source against stubs. Only its collaborators are +#: faked; the function itself is a verbatim slice, so what it forwards to the engine — and when +#: it parks a freshly created renderer — is observed rather than asserted about the source text. +RENDER_HARNESS = """ +const fs = require('fs'); +const src = fs.readFileSync(process.argv.slice(1).find(a => a.endsWith('dashboard.js')), 'utf8'); +const scenario = JSON.parse(process.argv[process.argv.length - 1]); +const start = src.indexOf('function graphRenderEngine('); +const slice = src.slice(start, src.indexOf('/* Nav away from the graph view', start)); + +/* The theme-colour lookup is sliced verbatim too, not stubbed: the property under test is + that the dashboard resolves the *active* CSS custom properties and hands them over, so + faking the resolver would assert nothing. Only `getComputedStyle` below is synthetic. */ +const between = (from, to) => src.slice(src.indexOf(from), src.indexOf(to, src.indexOf(from))); +const themeSrc = between('const ETYPE_TOKEN=', 'const GRAPH_PALETTES=') + + between('function cssvar(', 'function graphValidColor(') + + between('function graphThemeTypeColors(', 'function graphContrastColor('); + +/* A stand-in for a non-dark theme: every --entity-* token differs from the engine's + hard-coded THEME_ETYPE constants, so a renderer that ignored these would be visible. */ +const THEME_VARS = { + '--entity-concept': '#112233', '--entity-mention': '#223344', '--entity-hashtag': '#334455', + '--entity-email': '#445566', '--entity-organization': '#556677', '--entity-location': '#667788', + '--color-accent': '#778899', '--color-text-dim': '#123456', +}; +globalThis.getComputedStyle = () => ({ getPropertyValue: name => THEME_VARS[name] || '' }); + +const log = { created: 0, paused: 0, seeded: 0, scope: null, themeColors: null, error: null }; +const checkbox = { checked: scenario.showUnlinked }; +const element = { classList: { toggle() {} }, setAttribute() {}, set textContent(value) {} }; +globalThis.document = { + getElementById: id => (id === 'graph-show-iso' ? checkbox : element), + querySelectorAll: () => [], + body: {}, +}; +const engine = { + setSettings() {}, setStyle() {}, setColorBy() {}, setPalette() {}, setTypeColors() {}, + setLayers() {}, setScope(patch) { log.scope = patch; }, + setThemeColors(map) { log.themeColors = map; }, + setData(data) { log.seeded = data.nodes.length; }, +}; +const api = { + apply(fn) { fn(engine); }, communityMap: () => ({}), + freeze() {}, destroy() {}, resume() {}, pause() { log.paused += 1; }, +}; +globalThis.EngraphisGraph = { create() { log.created += 1; return api; } }; +globalThis.window = { GSET: { mode: 'compact', frozen: false } }; +globalThis.GRAPH = { nodes: [] }; +globalThis.GRAPH_ENGINE = null; +globalThis.GACTIVE_DATA = null; +globalThis.GCOLOR_OVERRIDES = {}; +/* The state the nav-away pause recorded while GRAPH_ENGINE was still null. */ +globalThis.GRAPH_ENGINE_PARKED = scenario.parked; +globalThis.showAs = () => {}; +globalThis.prefersReducedMotion = () => false; +for (const name of ['graphSetLayoutStatus', 'graphSyncReadouts', 'graphUpdateEditedBadge', + 'graphUpdateHud', 'graphRenderLegend', 'graphSetHighlight', + 'graphSetSimulationStatus', 'syncGraphExplorerSelection', 'graphNodeClick', + 'graphEngineEmptyMessage']) globalThis[name] = () => {}; +globalThis.graphEngineFallback = error => { + log.error = String((error && error.message) || error); +}; + +const graphRenderEngine = new Function(themeSrc + slice + '\\nreturn graphRenderEngine;')(); +const rendered = graphRenderEngine({ + nodes: [{ id: 'a' }, { id: 'b' }, { id: 'lonely' }], + links: [{ source: 'a', target: 'b' }], +}, true, true); +console.log(JSON.stringify(Object.assign({ rendered }, log))); +""" + + +def _run_render(*, show_unlinked: bool = False, parked: bool = False) -> dict: + source = DASHBOARD.read_text(encoding="utf-8") + # The harness slices real source; keep its landmarks honest. + assert "function graphRenderEngine(" in source + assert "/* Nav away from the graph view" in source + scenario = json.dumps({"showUnlinked": show_unlinked, "parked": parked}) + result = subprocess.run( + [NODE, "-e", RENDER_HARNESS, str(DASHBOARD), scenario], + cwd=ROOT, + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stderr + report = json.loads(result.stdout.strip().splitlines()[-1]) + assert report["error"] is None, report["error"] + assert report["rendered"] is True + return report + + +@requires_node +@pytest.mark.parametrize("checked", [False, True]) +def test_dashboard_tells_the_engine_whether_to_show_unlinked_entities(checked: bool) -> None: + """"Show unlinked nodes" is filtered twice, and only one half was wired up. + + ``graphData()`` starts supplying degree-zero entities when the box is ticked, but the + engine re-filters on its own ``showUnlinked``/``minDegree`` state — which stays at the + defaults that drop exactly those entities — unless the dashboard says otherwise. + """ + report = _run_render(show_unlinked=checked) + + assert report["scope"] is not None, "the engine never learns the checkbox state" + assert report["scope"]["showUnlinked"] is checked + # minDegree matters just as much: showUnlinked alone still loses to `degree >= 1`. + assert report["scope"]["minDegree"] == (0 if checked else 1) + + +@requires_node +def test_dashboard_hands_the_engine_the_active_themes_entity_colours() -> None: + """The other half of the theme fix: the engine can only use what it is given.""" + report = _run_render() + + assert report["themeColors"] is not None, "the engine never learns the active theme" + # Resolved from the stubbed --entity-* custom properties, not from any JS constant. + assert report["themeColors"]["person_or_concept"] == "#112233" + assert report["themeColors"]["organization"] == "#556677" + assert report["themeColors"]["relation_label"] == "#123456" + assert report["themeColors"]["label"] == "#e7e9ee" + # Every type the legend can show must be covered, or the canvas falls back per type. + assert set(report["themeColors"]) == { + "person_or_concept", "mention", "hashtag", "email", "organization", "location", + "relation_label", "label", + } + + +def test_a_theme_switch_repaints_the_opt_in_canvas() -> None: + """``applyTheme()`` is the only place a theme change is observable. + + It already calls ``graphRecolor()``; that path has to reach the engine, or the canvas keeps + the previous theme until the next full graph render. + """ + source = DASHBOARD.read_text(encoding="utf-8") + assert "if(typeof graphRecolor==='function')graphRecolor()" in source + recolor = source[source.index("function graphRecolor()"):] + recolor = recolor[: recolor.index("\nfunction graphFit")] + assert "engine.setThemeColors(graphThemeTypeColors())" in recolor + + +@requires_node +def test_a_renderer_created_after_leaving_the_graph_view_is_born_paused() -> None: + """The rAF leak this PR already fixed once, reached by a different route. + + ``/graph`` and both lazy scripts resolve asynchronously. Leaving Graph before they do runs + the pause while ``GRAPH_ENGINE`` is still null, so the pending callback would create and + start a renderer against a hidden pane that nothing ever pauses again. + """ + parked = _run_render(parked=True) + assert parked["created"] == 1 + assert parked["paused"] == 1, "a renderer created off-view keeps repainting forever" + + # On the view, the same path must not park a renderer the user is looking at. + live = _run_render(parked=False) + assert live["created"] == 1 + assert live["paused"] == 0 + + +def test_leaving_the_graph_view_records_the_pause_as_well_as_applying_it() -> None: + source = DASHBOARD.read_text(encoding="utf-8") + assert "if(v==='graph')graphEngineResume();else graphEnginePause()" in source + pause = source[source.index("function graphEnginePause()"):] + pause = pause[: pause.index("\nfunction graphInvalidateData")] + assert "GRAPH_ENGINE_PARKED=true" in pause + assert "GRAPH_ENGINE_PARKED=false" in pause + + +#: Force-graph resolves each link's ``source``/``target`` from an id to the node object once it +#: owns the data, and the paint callbacks read ``.x``/``.y`` off those objects. The recording +#: stand-in stores the arrays untouched, so a test that wants to *drive* a link painter has to +#: do that resolution — and give the nodes coordinates — itself. +LAY_OUT = """ +const layOut = () => { + const data = store.graphData; + const byId = new Map(data.nodes.map(n => [n.id, n])); + data.nodes.forEach((n, i) => { n.x = i * 10; n.y = i; }); + data.links.forEach(l => { + const s = byId.get(l.source && l.source.id !== undefined ? l.source.id : l.source); + const t = byId.get(l.target && l.target.id !== undefined ? l.target.id : l.target); + if (s) l.source = s; + if (t) l.target = t; + }); + return data; +}; +let painted = []; +const linkCtx = { + font: '', fillStyle: '', textAlign: '', textBaseline: '', + fillText(text) { painted.push(String(text)); }, +}; +const paintLinks = (scale, links) => { + painted = []; + const mode = store.linkCanvasObjectMode ? store.linkCanvasObjectMode() : undefined; + const draw = store.linkCanvasObject; + if (mode === 'after' && draw) (links || store.graphData.links).forEach(l => draw(l, linkCtx, scale)); + return painted.slice(); +}; +""" + + +@requires_node +def test_relation_labels_are_painted_when_the_labels_box_is_ticked() -> None: + """**Labels** turns on two label layers on the classic path; the engine only had one. + + ``graphToggleLabels`` forwards the checkbox straight to ``setSettings({labels})``, and the + classic renderer answers it with *both* entity names and a ``linkCanvasObject`` that paints + each ``link.label``. The opt-in engine configured no link painter at all, so relation names + silently disappeared under ``?graph-engine=next`` and could only be read by hovering one + edge at a time. + """ + report = _run_engine( + LAY_OUT + + """ + const api = G.create(el, { reducedMotion: () => true }); + api.setData({ + nodes: [{ id: 'a' }, { id: 'b' }], + links: [{ source: 'a', target: 'b', layer: 'entity', label: 'mentions' }], + }); + layOut(); + const unticked = paintLinks(4); + api.setSettings({ labels: true }); + api.setThemeColors({ relation_label: '#123456' }); + const ticked = paintLinks(4); + const labelColor = linkCtx.fillStyle; + // Relation labels are the noisiest layer: they stay off until the user zooms in. + const zoomedOut = paintLinks(1); + emit({ unticked, ticked, zoomedOut, labelColor }); + """ + ) + assert report["unticked"] == [] + assert report["ticked"] == ["mentions"], "the Labels checkbox never paints relation names" + assert report["labelColor"] == "#123456", "relation labels ignore the active theme" + assert report["zoomedOut"] == [] + + +@requires_node +def test_node_labels_are_capped_at_the_configured_density() -> None: + """A high density setting must still bound per-frame node-label painting.""" + report = _run_engine( + """ + let labels = []; + const ctx = { + globalAlpha: 1, fillStyle: '', strokeStyle: '', lineWidth: 1, font: '', textBaseline: '', + save() {}, restore() {}, beginPath() {}, arc() {}, stroke() {}, fill() {}, + createRadialGradient() { return { addColorStop() {} }; }, + fillText(text) { labels.push(String(text)); }, + }; + const api = G.create(el, { reducedMotion: () => true }); + api.setData(chain(20)); + api.setSettings({ labels: true, labelDensity: 3 }); + store.graphData.nodes.forEach((node, index) => { + node.x = index * 10; node.y = 0; store.nodeCanvasObject(node, ctx, 1); + }); + const names = labels.filter(value => value.startsWith('n')); + emit({ names, distinct: [...new Set(names)] }); + """ + ) + assert len(report["distinct"]) == 3 + assert len(report["names"]) == 6 # shadow + foreground per selected node + + +def test_collapsed_cluster_labels_use_the_active_theme_text_colour() -> None: + source = ASSET.read_text(encoding="utf-8") + cluster_paint = source[source.index("if (node.cluster)"):source.index("if (state.bridges", source.index("if (node.cluster)"))] + assert "state.themeColors.label || '#e7e9ee'" in cluster_paint + + +@requires_node +def test_node_labels_use_the_active_theme_text_colour() -> None: + """Classic labels paint onto the canvas, so near-white is unreadable on light themes.""" + + report = _run_engine( + LAY_OUT + + """ + const api = G.create(el, { reducedMotion: () => true }); + api.setData(chain(2)); + const data = layOut(); + api.setStyle('classic'); + api.setThemeColors({ label: '#123456' }); + api.setHighlight('n0'); + const styles = []; + const ctx = { + set fillStyle(value) { styles.push(value); }, get fillStyle() { return ''; }, + font: '', textBaseline: '', lineWidth: 0, strokeStyle: '', globalAlpha: 1, + beginPath() {}, arc() {}, fill() {}, stroke() {}, fillText() {}, save() {}, restore() {}, + }; + store.nodeCanvasObject(data.nodes[0], ctx, 1); + emit({ styles }); + """ + ) + assert "#123456" in report["styles"], "node labels ignored the active theme text colour" + + +@requires_node +def test_unfreezing_releases_nodes_pinned_by_dragging() -> None: + """Freeze off must resume the whole layout, including nodes a prior drag pinned.""" + + report = _run_engine( + """ + const api = G.create(el, { reducedMotion: () => false }); + api.setData(chain(2)); + const node = store.graphData.nodes[0]; + node.x = 17; node.y = 23; + store.onNodeDragEnd(node); + const pinned = { fx: node.fx, fy: node.fy }; + api.freeze(true); + api.freeze(false); + emit({ pinned, released: { fx: node.fx, fy: node.fy } }); + """ + ) + assert report["pinned"] == {"fx": 17, "fy": 23} + assert report["released"] == {}, "unfreezing left a dragged node immovable" + + +@requires_node +def test_focusing_an_entity_the_canvas_is_not_showing_does_not_report_success() -> None: + """``zoomToNode`` is the dashboard's visibility oracle, and it was answering from memory. + + ``graphFocus`` treats ``false`` as "offer the recovery path" — tick *Show unlinked*, retry, + and otherwise say *Entity not in view*. The engine answered from ``raw.nodes``, which keeps + the coordinates force-graph left on a node from an earlier render, so a node hidden by the + auto-collapsed view (only ``cluster-*`` bubbles are drawn below zoom 0.55) or by a scope + filter still reported success — the camera moved to nothing and the user got no explanation. + """ + report = _run_engine( + """ + const collapses = []; + const api = G.create(el, { + reducedMotion: () => true, onCollapseChange: value => collapses.push(value), + }); + api.setData({ + nodes: [{ id: 'a' }, { id: 'b' }, { id: 'c' }, { id: 'lonely' }], + links: [{ source: 'a', target: 'b' }, { source: 'b', target: 'c' }], + }); + const shownIds = () => (store.graphData.nodes || []).map(n => n.id); + // Everything visible once, so every entity carries real coordinates from here on. + api.setScope({ showUnlinked: true, minDegree: 0 }); + store.graphData.nodes.forEach((n, i) => { n.x = i * 10; n.y = i; }); + + // 1. Hidden by the scope filter, but still remembered with valid coordinates. + api.setScope({ showUnlinked: false, minDegree: 1 }); + const filtered = { found: api.zoomToNode('lonely'), shown: shownIds() }; + + // 2. Hidden by the collapsed view, which paints cluster bubbles instead of entities. + api.setCollapse(true); + const whileCollapsed = shownIds(); + const focused = api.zoomToNode('c'); + emit({ + filtered, whileCollapsed, focused, collapses, + afterFocus: shownIds(), collapsed: api.state().collapsed, + }); + """ + ) + # A filtered-out entity is not in view, so the dashboard must be told to recover. + assert report["filtered"]["found"] is False, "a filtered-out entity reported as visible" + assert "lonely" not in report["filtered"]["shown"] + # A collapsed view really is showing only bubbles... + assert report["whileCollapsed"] == ["cluster-0"] + # ...so focusing a named entity has to expand it rather than claim it is already on screen. + assert report["focused"] is True + assert report["collapsed"] is False + assert "c" in report["afterFocus"], "the entity is still not on the canvas" + assert report["collapses"][-1] is False, "the dashboard was never told the view expanded" + + +@requires_node +def test_appearance_only_changes_do_not_restart_the_layout() -> None: + """Style, Color by, Labels and Flow repaint the graph; they must not re-run it. + + ``visible()`` allocates fresh arrays on every call, and force-graph treats any ``graphData`` + call as a data update: it re-copies the nodes and d3 resets the simulation alpha to 1. So + every appearance-only setter threw the settled layout away and made the whole graph move. + The classic renderer guards the same seed with ``if(dataChanged)FG.graphData(data)``. + """ + report = _run_engine( + """ + const api = G.create(el, { reducedMotion: () => true }); + const nodes = [{ id: 'lonely', etype: 'organization' }], links = []; + for (let i = 0; i < 12; i++) nodes.push({ id: 'n' + i, etype: 'person_or_concept' }); + for (let i = 0; i < 11; i++) links.push({ source: 'n' + i, target: 'n' + (i + 1) }); + api.setData({ nodes, links }); + const seeded = calls.graphData; + const before = store.graphData.nodes[0].color; + const repaintsBefore = calls.nodeCanvasObject; + + api.setStyle('galaxy'); + api.setColorBy('type'); + api.setSettings({ labels: true }); + api.setSettings({ flow: false }); + const paintOnly = calls.graphData; + const recoloured = store.graphData.nodes[0].color; + const repaintsAfter = calls.nodeCanvasObject; + + // A genuine change to the visible set still has to reach force-graph. + api.setScope({ showUnlinked: true, minDegree: 0 }); + emit({ + seeded, paintOnly, afterScope: calls.graphData, before, recoloured, + repaintsBefore, repaintsAfter, shown: store.graphData.nodes.length, + }); + """ + ) + assert report["paintOnly"] == report["seeded"], "an appearance change restarted the layout" + assert report["afterScope"] > report["seeded"], "a real view change never reached the canvas" + assert report["shown"] == 13 + # Skipping the reseed must not mean skipping the paint. + assert report["recoloured"] != report["before"] + assert report["repaintsAfter"] > report["repaintsBefore"] + + +@requires_node +def test_simulation_time_is_bounded_on_a_large_graph() -> None: + """force-graph's default cooldown is 15 seconds; nothing here was overriding it. + + The classic path caps a large graph at 1.1s / 80 ticks precisely because running the layout + — and therefore repainting every node and link — for the full default window is what makes a + big store feel broken on load and after every reheat. + """ + report = _run_engine( + """ + const api = G.create(el, {}); + api.setData(chain(40)); + const small = { + time: store.cooldownTime, ticks: store.cooldownTicks, warmup: store.warmupTicks, + alpha: store.d3AlphaDecay, velocity: store.d3VelocityDecay, + }; + // 3001 entities / 3000 relations — past the classic renderer's 600-node signal. + api.setData(chain(3000)); + const big = { + time: store.cooldownTime, ticks: store.cooldownTicks, warmup: store.warmupTicks, + alpha: store.d3AlphaDecay, velocity: store.d3VelocityDecay, + }; + const still = G.create(el, { reducedMotion: () => true }); + still.setData(chain(40)); + emit({ + small, big, + reduced: { time: store.cooldownTime, ticks: store.cooldownTicks }, + }); + """ + ) + assert report["small"]["time"] == 2200 + assert report["small"]["ticks"] == 160 + # The number this guards: the vendor default left a 3k-relation store simulating for 15s. + assert report["big"]["time"] == 1100 + assert report["big"]["ticks"] == 80 + assert report["big"]["warmup"] == 18 + # A large graph also settles harder, exactly as GPERF.large does on the classic path. + assert report["big"]["alpha"] > report["small"]["alpha"] + assert report["big"]["velocity"] > report["small"]["velocity"] + # Reduced motion asks for a static layout, not a shorter animation. + assert report["reduced"]["time"] == 0 + assert report["reduced"]["ticks"] == 1 + + +@requires_node +def test_physics_sliders_reheat_the_simulation_the_way_the_classic_renderer_does() -> None: + """Installing a new force on a settled graph moves nothing without a reheat. + + ``graphSet`` (dashboard.js) routes Repel/Link/Gravity/Size/Font/Link-width/Label-density + through ``setSettings`` under ``?graph-engine=next``. The classic branch of that same + function treats ``repel|link|gravity|size`` as *layout* changes: it re-applies the forces + and then reheats unless the user asked for reduced motion. The engine's ``applyForces()`` + only swaps the charge/link/forceX-forceY/collide values into the running simulation — and a + settled graph sits at alpha~0 — so without the reheat those four sliders are inert until + the user finds the Reheat button. The paint-only settings must *not* reheat: restarting + the layout because a label got bigger throws away the arrangement the user is reading. + """ + report = _run_engine( + """ + const reheats = () => invocations.d3ReheatSimulation || 0; + const bump = (api, patch) => { const before = reheats(); api.setSettings(patch); return reheats() - before; }; + + const api = G.create(el, {}); + api.setData(chain(40)); + const layout = { + repel: bump(api, { repel: 260 }), + link: bump(api, { link: 90 }), + gravity: bump(api, { gravity: 12 }), + size: bump(api, { size: 5 }), + mode: bump(api, { mode: 'radial' }), + }; + const paint = { + font: bump(api, { font: 11 }), + linkw: bump(api, { linkw: 2.4 }), + labelDensity: bump(api, { labelDensity: 40 }), + labels: bump(api, { labels: true }), + flow: bump(api, { flow: false }), + }; + + // The classic path's `if(layout&&!prefersReducedMotion())` exemption. + const still = G.create(el, { reducedMotion: () => true }); + still.setData(chain(40)); + const reducedMotion = bump(still, { repel: 260 }); + emit({ layout, paint, reducedMotion }); + """ + ) + # The four sliders the classic renderer calls a layout change, plus the preset itself. + assert report["layout"] == { + "repel": 1, "link": 1, "gravity": 1, "size": 1, "mode": 1 + }, "a physics slider installed new forces on a settled graph and nothing moved" + # Appearance-only settings keep the arrangement the user is looking at. + assert report["paint"] == { + "font": 0, "linkw": 0, "labelDensity": 0, "labels": 0, "flow": 0 + }, "an appearance change restarted the layout" + assert report["reducedMotion"] == 0, "reduced motion still got an animated relayout" + + +@requires_node +def test_curves_arrows_and_relation_labels_are_dropped_on_a_dense_graph() -> None: + """Three per-edge costs the classic path turns off past ``GPERF.dense`` (links > 1500). + + A curved link is a quadratic bezier instead of a straight line, an arrowhead is a filled + triangle, and a relation label is a text layout — each per relation, each every frame. At + this density they are unreadable anyway, so the classic renderer pays for none of them. + """ + report = _run_engine( + LAY_OUT + + """ + const api = G.create(el, { reducedMotion: () => true }); + api.setSettings({ labels: true }); + + api.setData(chain(1500)); + const atLimit = { + curve: store.linkCurvature, arrow: store.linkDirectionalArrowLength, + }; + + api.setData(chain(1501)); + const overLimit = { + curve: store.linkCurvature, arrow: store.linkDirectionalArrowLength, + }; + // One laid-out relation is enough to drive the label painter at this size. + const data = layOut(); + data.links[0].label = 'mentions'; + const denseUnhighlighted = paintLinks(4, [data.links[0]]); + store.onNodeHover(data.nodes[0]); + const denseHighlighted = paintLinks(4, [data.links[0]]); + emit({ atLimit, overLimit, denseUnhighlighted, denseHighlighted }); + """ + ) + # 1500 links is the classic threshold itself, so nothing is dropped yet. + assert report["atLimit"]["curve"] == 0.12 + assert report["atLimit"]["arrow"] == 2.5 + assert report["overLimit"]["curve"] == 0 + assert report["overLimit"]["arrow"] == 0 + # Relation labels come back for the one neighbourhood the user is actually pointing at. + assert report["denseUnhighlighted"] == [] + assert report["denseHighlighted"] == ["mentions"] + + +#: A ``d3`` stand-in for the force constructors ``applyForces()`` reaches for. The asset reads +#: ``d3`` as a free variable, so assigning it on ``globalThis`` is what the browser's global +#: script tag does; without it ``applyForces()`` returns before it ever configures collision. +D3_STUB = """ +let collide = null; +globalThis.d3 = { + forceX: () => ({ strength: () => ({}) }), + forceY: () => ({ strength: () => ({}) }), + forceRadial: () => ({ strength: () => ({}) }), + forceCollide: radius => ({ radius, iterations(n) { collide = { radius, iterations: n }; return this; } }), +}; +""" + + +@requires_node +def test_collision_runs_one_pass_on_a_large_graph_like_the_classic_renderer() -> None: + """``forceCollide().iterations(2)`` is a second full quadtree traversal per node per tick. + + ``graphApplyForces()`` on the classic path spends it only when it is affordable + (``.iterations(GPERF.large?1:2)``). The opt-in engine computes the same ``large`` signal for + its cooldown and alpha-decay constants but was pinning two iterations regardless, so the one + case where the extra pass hurts most — the initial layout and every reheat of a big store — + was the case that paid for it twice over. + """ + report = _run_engine( + D3_STUB + + """ + const api = G.create(el, { reducedMotion: () => true }); + + api.setData(chain(40)); + const small = collide.iterations; + + // 601 entities / 600 relations — one past the classic renderer's 600-node cutoff. + api.setData(chain(600)); + const big = collide.iterations; + + // A slider move re-runs applyForces() on the running simulation; it must not undo this. + api.setSettings({ repel: 90 }); + const afterSlider = collide.iterations; + emit({ small, big, afterSlider, radiusIsAFunction: typeof collide.radius === 'function' }); + """ + ) + assert report["small"] == 2 + assert report["big"] == 1, "a large graph still runs two collision passes per tick" + assert report["afterSlider"] == 1, "a slider move restored the expensive collision pass" + # Guards the whole call rather than the argument in isolation: a per-node radius, not a + # constant, is what makes collision agree with the sizes the renderer actually painted. + assert report["radiusIsAFunction"] is True + + +#: Counts the two per-node glow primitives independently. ``createRadialGradient`` is the halo +#: and the solar sphere shading; ``shadowBlur`` is the cyber bloom. Both are per node, per frame. +GLOW_CANVAS_STUB = """ +let gradients = 0, blurs = 0, fills = 0; +const ctx = { + globalAlpha: 1, globalCompositeOperation: '', strokeStyle: '', lineWidth: 1, font: '', + textBaseline: '', shadowColor: '', + set shadowBlur(v) { if (v) blurs += 1; }, + get shadowBlur() { return 0; }, + set fillStyle(v) {}, get fillStyle() { return ''; }, + save() {}, restore() {}, beginPath() {}, arc() {}, ellipse() {}, stroke() {}, + setLineDash() {}, fillText() {}, + fill() { fills += 1; }, + createRadialGradient() { gradients += 1; return { addColorStop() {} }; }, +}; +const paintNodes = () => { + gradients = 0; blurs = 0; fills = 0; + const draw = store.nodeCanvasObject; + store.graphData.nodes.forEach((n, i) => { n.x = i * 10; n.y = i; draw(n, ctx, 4); }); + return { gradients, blurs, fills }; +}; +""" + + +@requires_node +@pytest.mark.parametrize("style", ["cyber", "galaxy", "solar"]) +def test_per_node_glow_is_dropped_on_a_large_graph(style: str) -> None: + """Every ``rich`` node was getting a bloom or a gradient on every frame, at any size. + + The classic renderer gates all three of them on ``!GPERF.large`` — the galaxy halo, the solar + corona *and* its sphere shading, and the cyber ``shadowBlur``. A shadow blur is a full + convolution of the drawn shape and a radial gradient is a fresh object per node; at the + >600-node cutoff that is hundreds of them rebuilt per tick, on top of the layout, which is + what made a dense workspace crawl even after the other large-graph optimisations kicked in. + + ``fills`` is the control: the nodes are still being drawn, so a zero glow count means the + effect was skipped, not that the paint never ran. + """ + report = _run_engine( + GLOW_CANVAS_STUB + + f""" + const api = G.create(el, {{ reducedMotion: () => true }}); + api.setStyle("{style}"); + + api.setData(chain(40)); + const small = paintNodes(); + + api.setData(chain(600)); + const big = paintNodes(); + emit({{ small, big }}); + """ + ) + small, big = report["small"], report["big"] + assert small["fills"] > 0 and big["fills"] > 0, "canvas stub never reached the node painter" + assert small["gradients"] + small["blurs"] > 0, "the small graph lost its glow entirely" + assert big["gradients"] == 0, f"{style} still builds a radial gradient per node when large" + assert big["blurs"] == 0, f"{style} still shadow-blurs every node when large" + + +def _community_palettes(source: str) -> dict: + """Parse a ``COMMUNITY_PALS`` literal out of either renderer.""" + # Anchor on the declaration: both files also name the table in prose comments. + match = re.search(r"COMMUNITY_PALS\s*=\s*\{", source) + assert match is not None, "COMMUNITY_PALS is not declared here" + block = source[match.end():source.index("};", match.end())] + return { + name: re.findall(r"#[0-9a-fA-F]{3,8}", body) + for name, body in re.findall(r"(\w+)\s*:\s*\[([^\]]*)\]", block) + } + + +def test_community_colours_match_the_dashboard_and_the_legend_swatches() -> None: + """The cluster legend is painted from CSS, so palette *order* is a contract, not a taste. + + ``graphRenderLegend`` sorts communities by size and gives the largest a + ``.graph-cluster-0`` swatch, while the canvas colours that same community with palette slot + 0. The swatch colours live in ``dashboard.css`` and encode the Cyber palette — the default + style — so a renderer whose slot 0 is a different colour makes the legend describe cluster 1 + with cluster 2's colour, on the default style, for every workspace. + """ + engine = _community_palettes(ASSET.read_text(encoding="utf-8")) + classic = _community_palettes(DASHBOARD.read_text(encoding="utf-8")) + assert engine, "COMMUNITY_PALS could not be parsed out of the engine" + assert engine == classic, "the opt-in renderer paints communities a different colour" + + swatches = dict( + re.findall(r"\.graph-cluster-(\d+)\{background:(#[0-9a-fA-F]{3,8})\}", + CSS.read_text(encoding="utf-8")) + ) + assert swatches, "the cluster legend swatches are missing from the stylesheet" + for index, colour in sorted(swatches.items()): + assert engine["cyber"][int(index)].lower() == colour.lower(), ( + f"legend swatch {index} does not match the canvas colour for that cluster" + ) + + +# ── CSP, styling and lifecycle ────────────────────────────────────────────────────── + + +def test_pane_backgrounds_are_owned_by_css_not_by_the_asset() -> None: + """``style-src-attr 'none'`` forbids writing these onto the element.""" + css = CSS.read_text(encoding="utf-8") + source = ASSET.read_text(encoding="utf-8") + for style in ("galaxy", "solar", "cyber"): + assert f'#graph-net[data-graph-style="{style}"]' in css + assert "data-graph-style" in source + # The gradients must exist in exactly one place, or the two copies drift. + assert "radial-gradient" not in source + assert "linear-gradient" not in source + + +def test_hover_cursor_class_the_asset_toggles_exists_in_css() -> None: + css = CSS.read_text(encoding="utf-8") + source = ASSET.read_text(encoding="utf-8") + assert "engraphis-graph-node-hover" in source + assert ".engraphis-graph-node-hover" in css + + +def test_csp_gate_covers_the_graph_asset() -> None: + from scripts.externalize_dashboard_assets import EXTRA_SCRIPTS, check + + assert ASSET in EXTRA_SCRIPTS, "the graph engine must be inside the CSP drift gate" + check() + + +def test_engine_exposes_a_teardown_and_the_dashboard_drives_it() -> None: + source = ASSET.read_text(encoding="utf-8") + dashboard = DASHBOARD.read_text(encoding="utf-8") + for member in ("api.destroy", "api.pause", "api.resume", "api.resize"): + assert member in source + # force-graph keeps a rAF alive while resumed; leaving the view must park it. + assert "if(v==='graph')graphEngineResume();else graphEnginePause()" in dashboard + assert "GRAPH_ENGINE.destroy()" in dashboard + + +def test_reduced_motion_is_honoured_by_the_opt_in_renderer() -> None: + source = ASSET.read_text(encoding="utf-8") + dashboard = DASHBOARD.read_text(encoding="utf-8") + assert "prefers-reduced-motion: reduce" in source + assert "opts.reducedMotion" in source + assert "reducedMotion:prefersReducedMotion" in dashboard + + +def test_graph_engine_is_syntactically_valid_when_node_is_installed() -> None: + if NODE is None: + pytest.skip("node is not installed") + result = subprocess.run( + [NODE, "--check", str(ASSET)], + cwd=ROOT, + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stderr