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
+