From b39ac839fc70f1782284ca82322110fc69bee597 Mon Sep 17 00:00:00 2001 From: Jaixii Date: Sun, 26 Jul 2026 13:04:01 -0400 Subject: [PATCH 01/13] feat(dashboard): opt-in canvas graph engine behind ?graph-engine=next A second knowledge-graph renderer wrapping the already-vendored force-graph, opt-in behind ?graph-engine=next. Classic stays the default and the rollback path; graphRender falls through to it on any failure. Four styles are applied CSP-safely through a data-graph-style attribute rather than inline style. Fixed while reviewing, before this shipped: - XSS. setData stamped name on every node, and force-graph's nodeLabel defaults to that accessor and renders through innerHTML. Node names are entity labels extracted from ingested memories. Classic never set name, so the sink did not exist until this feature added it. Both accessors are now explicit and escaped. - Recursive Tarjan overflowed the stack at roughly 29.6k nodes on a chain component; rewritten iteratively. - The rAF loop never stopped: a hidden canvas kept repainting for the whole session. Added pause/resume driven from selectView and a real destroy(). - Unbounded O(V*E) betweenness on the main thread with Array.shift queues, now lazy, pivot-sampled and budgeted: 60k nodes went 25s to 3s, and 0s in the default view. - prefers-reduced-motion was ignored, against an existing dashboard contract. The CSP drift gate never scanned this asset -- it only ever read index.html, dashboard.css and dashboard.js -- so "the gate passes" was vacuous for any new first-party script. Extended to hold them to the same contract and to check that every /static script index.html references actually exists. All four new rules were negative-tested. The original test asserted formatting strings. Replaced with 24 tests that execute the asset under Node with only a bare window in scope, which is itself the load-order proof, and mutation-tested: removing the escaping fails them. Co-Authored-By: Claude Opus 5 (1M context) --- engraphis/static/dashboard.css | 5 + engraphis/static/dashboard.js | 123 +++- engraphis/static/engraphis-graph.js | 833 ++++++++++++++++++++++++ engraphis/static/index.html | 2 + scripts/externalize_dashboard_assets.py | 48 +- tests/test_graph_engine_asset.py | 391 +++++++++++ 6 files changed, 1382 insertions(+), 20 deletions(-) create mode 100644 engraphis/static/engraphis-graph.js create mode 100644 tests/test_graph_engine_asset.py 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..3cc941c 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}, @@ -528,6 +531,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 +557,85 @@ 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}); + GRAPH_ENGINE.apply(engine=>{ + engine.setSettings({...window.GSET}); + engine.setStyle(typeof GSTYLE!=='undefined'?GSTYLE:'cyber'); + engine.setColorBy(typeof GCOLORBY!=='undefined'?GCOLORBY:'community'); + engine.setPalette(typeof GCOLOR_PALETTE!=='undefined'?GCOLOR_PALETTE:'theme'); + engine.setTypeColors(GCOLOR_OVERRIDES||{}); + engine.setLayers(layers); + 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); + 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. */ +function graphEnginePause(){try{if(GRAPH_ENGINE)GRAPH_ENGINE.pause()}catch(e){}} +function graphEngineResume(){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 +645,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 +793,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 +849,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 +874,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(){ @@ -824,7 +911,11 @@ function graphRender(fit=true,reheat=true){ }); return; } - const element=document.getElementById('graph-net'),settings=window.GSET,mode=GRAPH_PRESETS[settings.mode]||GRAPH_PRESETS.compact,data=graphData(),dataChanged=GACTIVE_DATA!==data; + const element=document.getElementById('graph-net'),settings=window.GSET,mode=GRAPH_PRESETS[settings.mode]||GRAPH_PRESETS.compact,data=graphData(); + if(graphEngineEnabled()&&graphRenderEngine(data,fit,reheat))return; + /* Read AFTER the opt-in attempt: a failing engine resets GACTIVE_DATA precisely so the + classic renderer below rebuilds from scratch instead of assuming the canvas is current. */ + const dataChanged=GACTIVE_DATA!==data; GPERF={large:data.nodes.length>600||data.links.length>2400,dense:data.links.length>1500}; graphSyncReadouts();graphUpdateEditedBadge(); if(dataChanged){ @@ -924,6 +1015,7 @@ function graphSet(key,value){ const rd=document.querySelector('[data-graph-val="'+key+'"]'); if(rd)rd.textContent=key==='linkw'?Number(value).toFixed(1):(key==='size'?String(+Number(value).toFixed(1)):String(Math.round(value))); graphUpdateEditedBadge(); + if(GRAPH_ENGINE){GRAPH_ENGINE.setSettings({[key]:Number(value)});return} if(!FG)return; const layout=key==='repel'||key==='link'||key==='gravity'||key==='size'; if(key==='size')graphRefreshNodeMetrics(); @@ -953,7 +1045,7 @@ function graphApplyPreset(name){ const help=document.getElementById('graph-preset-help'),largeNote=GPERF.large?' Expensive animation, curves, and arrows stay off for this large graph.':''; if(help)help.textContent=notes[window.GSET.mode]+largeNote; graphRefreshNodeMetrics();graphSyncReadouts();graphUpdateEditedBadge(); - if(FG)graphRender(true,true); + if(FG||GRAPH_ENGINE)graphRender(true,true); } function graphSyncReadouts(){ ['repel','link','gravity','font','size','linkw','labelDensity'].forEach(k=>{const rd=document.querySelector('[data-graph-val="'+k+'"]');if(!rd)return;const v=window.GSET[k];rd.textContent=k==='linkw'?Number(v).toFixed(1):(k==='size'?String(+Number(v).toFixed(1)):String(Math.round(v)));const ctl=document.querySelector('[data-graph-setting="'+k+'"]');if(ctl)ctl.value=v*(Number(ctl.dataset.graphScale)||1);}); @@ -975,28 +1067,39 @@ function graphUpdateEditedBadge(){ showAs(btn,edited); } function graphResetPreset(){graphApplyPreset(window.GSET.mode==='custom'?'compact':window.GSET.mode);toast('Preset restored','ok')} -function graphToggleFlow(control){window.GSET.flow=control.checked;if(FG)graphRender(false,false)} +function graphToggleFlow(control){window.GSET.flow=control.checked;if(GRAPH_ENGINE)GRAPH_ENGINE.setSettings({flow:control.checked});else if(FG)graphRender(false,false)} function graphToggleFreeze(control){ - window.GSET.frozen=control.checked;if(!FG)return; + window.GSET.frozen=control.checked;if(GRAPH_ENGINE){GRAPH_ENGINE.freeze(control.checked);return}if(!FG)return; const ns=(FG.graphData().nodes)||[]; if(control.checked){ns.forEach(n=>{n.fx=n.x;n.fy=n.y});graphSetSimulationStatus('Layout frozen')} else{ns.forEach(n=>{n.fx=null;n.fy=null});if(!prefersReducedMotion())FG.d3ReheatSimulation()} } -function graphToggleLabels(control){window.GSET.labels=control.checked;if(FG)graphRender(false,false)} +function graphToggleLabels(control){window.GSET.labels=control.checked;if(GRAPH_ENGINE)GRAPH_ENGINE.setSettings({labels:control.checked});else if(FG)graphRender(false,false)} function graphRecolor(){ renderGraphColorControls(); + if(GRAPH_ENGINE){GRAPH_ENGINE.apply(engine=>{engine.setPalette(typeof GCOLOR_PALETTE!=='undefined'?GCOLOR_PALETTE:'theme');engine.setTypeColors(GCOLOR_OVERRIDES||{})},false,false);return} if(!FG)return; window.GCOL=graphReadThemeColors();graphRefreshNodeColors(); FG.linkColor(FG.linkColor());FG.linkWidth(FG.linkWidth());graphRedraw(); } -function graphFit(){if(FG)FG.zoomToFit(prefersReducedMotion()?0:500,72)} +function graphFit(){if(GRAPH_ENGINE)GRAPH_ENGINE.fit();else if(FG)FG.zoomToFit(prefersReducedMotion()?0:500,72)} function graphReheat(){ + if(GRAPH_ENGINE){if(prefersReducedMotion()){toast('Layout motion is off because reduced motion is enabled.','ok');return}GRAPH_ENGINE.reheat();return} if(!FG)return; if(prefersReducedMotion()){toast('Layout motion is off because reduced motion is enabled.','ok');return} graphSetSimulationStatus('Reheating layout',true);FG.d3ReheatSimulation(); } function graphFocus(name){ clearTimeout(window.__gfit); + if(GRAPH_ENGINE){ + /* Keep the classic contract: highlight, sync the explorer, and if the entity is filtered + out, offer the same "show unlinked" recovery instead of silently doing nothing. */ + if(GRAPH_ENGINE.zoomToNode(name)){graphSetHighlight(name);syncGraphExplorerSelection(name);return} + const isolated=document.getElementById('graph-show-iso'); + if(isolated&&!isolated.checked){isolated.checked=true;graphRender(false,true);setTimeout(()=>graphFocus(name),prefersReducedMotion()?0:500)} + else toast('Entity not in view','err'); + return; + } const node=FG&&(FG.graphData().nodes||[]).find(item=>item.id===name),duration=prefersReducedMotion()?0:550; if(node&&node.x!=null){graphSetHighlight(name);FG.centerAt(node.x,node.y,duration);FG.zoom(5,duration);syncGraphExplorerSelection(name)} else{const show=document.getElementById('graph-show-iso');if(show&&!show.checked){show.checked=true;graphRender(false,true);setTimeout(()=>graphFocus(name),duration?500:0)}else toast('Entity not in view','err')} diff --git a/engraphis/static/engraphis-graph.js b/engraphis/static/engraphis-graph.js new file mode 100644 index 0000000..6314077 --- /dev/null +++ b/engraphis/static/engraphis-graph.js @@ -0,0 +1,833 @@ +/* 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' }; + 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', '#3fd2c7', '#ffd68a', '#5b9bff', '#ff8f6b', '#8ea8ff', '#ffc98a', '#4fc0b0', '#ff9f5b', '#7fb0ff', '#ffd0a0', '#5ad0c0'], + cyber: ['#ff3ea5', '#22e0ff', '#b6ff3c', '#ffe14d', '#8b7bff', '#ff5c7a', '#3cffd0', '#ff9ae0', '#5ce0ff', '#d0ff3c', '#ffb03c', '#a06bff'] + }; + const GRAPH_HEAT = ['#3f7bff', '#6a5cff', '#a24bff', '#e0479f', '#ff6b6b', '#ffc23d']; + + 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(); + + function communities(nodes, links) { + const adj = {}; + const nodesById = new Map(nodes.map(node => [node.id, node])); + nodes.forEach(n => { adj[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); + }); + // 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(); + let group = 0; + 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++]; + const node = nodesById.get(id); + if (node) node.community = group; + (adj[id] || []).forEach(next => { if (!seen.has(next)) { seen.add(next); queue.push(next); } }); + } + group++; + }); + 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: {}, + 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; + let zoom = 1, collapsed = false; + 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'; + } + /* 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` always asks for a + reheat, which 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); + } + } + + 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 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)); + } + if (d3.forceCollide) fg.d3Force('collide', d3.forceCollide(n => n.radius + 1.5).iterations(2)); + } + + function styleBackground(ctx, scale) { + if (state.styleName === 'galaxy') { + 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'; + ctx.fillStyle = 'rgba(232,236,245,.9)'; + 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(); + } + if (state.styleName === 'galaxy') { + if (rich) { + 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) { + 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(); + } + 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) { 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 && node.degree >= Math.max(1, 12 - state.settings.labelDensity / 6)) || node.id === hilite || neighbor; + if (showLabel && scale > 0.35) { + const size = Math.max(2, state.settings.font / scale / 3.4); + 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); + ctx.fillStyle = 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); + } + + function refreshColors() { + const nodes = fg.graphData().nodes || []; + nodes.forEach(n => { n.color = nodeColor(n); n.stroke = contrastOn(n.color); }); + fg.nodeCanvasObject(fg.nodeCanvasObject()); + } + + function render(fit, reheat) { + if (destroyed) return; + if (suspended) { + pendingRender = pendingRender ? [pendingRender[0] || fit, pendingRender[1] || reheat] : [fit, reheat]; + return; + } + const motion = !reduced(); + const data = visible(); + 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); + }); + applyChrome(); + fg.graphData(data); + applyForces(); + fg.autoPauseRedraw(!needsContinuousFrames()); + if (fg.d3AlphaDecay) fg.d3AlphaDecay(0.035); + if (fg.d3VelocityDecay) fg.d3VelocityDecay(0.38); + if (fg.linkCurvature) fg.linkCurvature((PRESETS[state.settings.mode] || PRESETS.compact).curve || 0); + fg.linkDirectionalArrowLength(2.5).linkDirectionalArrowRelPos(1); + if (fg.linkDirectionalParticles) { + const particles = (state.settings.flow === false || !motion) + ? 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); } + 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); + }) + .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); + 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); + }; + api.setSettings = patch => { Object.assign(state.settings, patch); render(false, patch.mode !== 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(); }; + /* 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] || [])); + if (!destroyed) fg.nodeCanvasObject(fg.nodeCanvasObject()); + }; + 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(0.035); fg.d3ReheatSimulation(); } + }; + api.freeze = on => { + state.settings.frozen = on; + if (on) { fg.d3Force('charge').strength(0); fg.d3AlphaDecay(1); return; } + applyForces(); + if (reduced()) return; + fg.d3AlphaDecay(0.035); + if (fg.d3ReheatSimulation) fg.d3ReheatSimulation(); + }; + api.zoomToNode = id => { + const n = raw.nodes.find(x => x.id === id); + if (!n || !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 = {}; + 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/index.html b/engraphis/static/index.html index f92f8c5..4ce68af 100644 --- a/engraphis/static/index.html +++ b/engraphis/static/index.html @@ -323,6 +323,8 @@ + + diff --git a/scripts/externalize_dashboard_assets.py b/scripts/externalize_dashboard_assets.py index 4c27b2d..d34a9b6 100644 --- a/scripts/externalize_dashboard_assets.py +++ b/scripts/externalize_dashboard_assets.py @@ -19,6 +19,14 @@ 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. +EXTRA_SCRIPTS = (STATIC / "engraphis-graph.js",) + +SCRIPT_SRC = re.compile(r']+src=["\'](/static/[^"\']+)["\']') + STYLE_ATTR = re.compile(r"\sstyle=(?:\"([^\"]*)\"|'([^']*)')") EVENT_ATTR = re.compile(r"\s(on[a-z]+)=(?:\"([^\"]*)\"|'([^']*)')") STYLE_REF = re.compile(r'data-csp-style=["\'](s\d+)["\']') @@ -208,24 +216,44 @@ def check() -> None: failures.append("inline event attribute") if not CSS.is_file() or not JS.is_file(): failures.append("missing external asset") - if STYLE_ATTR.search(js): - failures.append("inline style attribute in generated dashboard markup") - if EVENT_ATTR.search(js): - failures.append("inline event attribute in generated dashboard markup") - if re.search(r"\.(?:style|cssText)\b|(?:get|set)Attribute\([\"']style[\"']", js): - failures.append("runtime inline-style mutation") - if re.search(r"\[on[a-z]+|(?:get|set)Attribute\([\"']on[a-z]+[\"']", js): - failures.append("legacy inline-handler selector") + extra = {path.name: path.read_text(encoding="utf-8") for path in EXTRA_SCRIPTS if path.is_file()} + missing_scripts = sorted(path.name for path in EXTRA_SCRIPTS if not path.is_file()) + if missing_scripts: + failures.append("missing first-party script: " + ", ".join(missing_scripts)) + + # Every /static asset the page asks for must exist, or the dashboard 404s at load time + # and the strict CSP turns a typo into a silently broken view. + absent = sorted( + { + reference + for reference in SCRIPT_SRC.findall(html) + if not (ROOT / "engraphis" / reference.lstrip("/")).is_file() + } + ) + if absent: + failures.append("index.html loads missing script: " + ", ".join(absent)) + + for name, source in [("dashboard.js", js), *extra.items()]: + if STYLE_ATTR.search(source): + failures.append(f"inline style attribute in {name}") + if EVENT_ATTR.search(source): + failures.append(f"inline event attribute in {name}") + if re.search(r"\.(?:style|cssText)\b|(?:get|set)Attribute\([\"']style[\"']", source): + failures.append(f"runtime inline-style mutation in {name}") + if re.search(r"\[on[a-z]+|(?:get|set)Attribute\([\"']on[a-z]+[\"']", source): + failures.append(f"legacy inline-handler selector in {name}") if "${" in css or "'+" in css or "+'" in css: failures.append("unresolved JavaScript interpolation in CSS") - style_refs = set(STYLE_REF.findall(html + "\n" + js)) + style_refs = set(STYLE_REF.findall("\n".join([html, js, *extra.values()]))) style_rules = set(STYLE_RULE.findall(css)) missing_styles = sorted(style_refs - style_rules) if missing_styles: failures.append("missing CSP style rules: " + ", ".join(missing_styles)) - handler_refs = HANDLER_REF.findall(html + "\n" + js) + # Handler *definitions* only ever live in the generated dashboard.js registry, but any + # first-party script may reference one, so references are collected across all of them. + handler_refs = HANDLER_REF.findall("\n".join([html, js, *extra.values()])) handler_ids = {handler_id for _event, handler_id in handler_refs} handler_defs = set(HANDLER_DEF.findall(js)) missing_handlers = sorted(handler_ids - handler_defs) diff --git a/tests/test_graph_engine_asset.py b/tests/test_graph_engine_asset.py new file mode 100644 index 0000000..9194a78 --- /dev/null +++ b/tests/test_graph_engine_asset.py @@ -0,0 +1,391 @@ +"""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 = 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)); +""" + + +def _run_node(script: str) -> 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]) + + +# ── load order and failure isolation ──────────────────────────────────────────────── + + +def test_opt_in_graph_asset_is_loaded_after_its_dependencies() -> None: + html = INDEX.read_text(encoding="utf-8") + d3 = html.index("/static/vendor/d3.min.js") + force_graph = html.index("/static/vendor/force-graph.min.js") + engine = html.index("/static/engraphis-graph.js") + dashboard = html.index("/static/dashboard.js") + assert d3 < force_graph < engine < dashboard + + +@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_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" + + +# ── 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 From 18b865ff5638f494941134aeafb277b17fa68d23 Mon Sep 17 00:00:00 2001 From: Jaixii Date: Sun, 26 Jul 2026 13:51:56 -0400 Subject: [PATCH 02/13] fix(dashboard): lazy-load the graph assets so the CSP stays clean Adding force-graph.min.js and engraphis-graph.js to index.html loaded them on every dashboard page, not just the graph view. force-graph applies inline styles at runtime, the production CSP is style-src 'self', so the browser blocked them and logged an error per attempt. Eleven pre-existing e2e tests assert a clean console and failed -- a regression this branch introduced. dashboard.js already had loadForceGraph() lazily fetching the vendor bundle for the Classic renderer, so neither script ever needed to be in index.html. Both tags are removed and engraphis-graph.js gets a matching memoized loader. The naive version of that would silently downgrade a ?graph-engine=next deep link to Classic, because graphRenderEngine's guard cannot tell "not fetched yet" from "unavailable". The engine fetch now starts alongside the vendor bundle -- one round trip, not two -- and graphRender waits on it and re-enters, so only a genuine load failure degrades, and that path warns. Proved by executing the real routing source under Node with a stub DOM: deep link renders the engine, a failing asset falls back and warns. Both are tests, and reverting to defer-without-await fails them. The "referenced scripts exist" gate rule became three, so removing the tags strengthens it rather than hollowing it out: lazy script.src references are existence-checked (their only reference is a JS string literal), neither asset may reappear as a parsed script src in index.html, and each must keep a lazy loader so deferring cannot orphan them. All negative-tested. Local gate: 1429 passed, 0 failed. The CSP fix itself cannot be proven locally -- Playwright needs node_modules, which is not installed here -- so CI is the real verification for the 11 e2e assertions. Co-Authored-By: Claude Opus 5 (1M context) --- engraphis/static/dashboard.js | 44 ++++++ engraphis/static/index.html | 8 +- scripts/externalize_dashboard_assets.py | 41 +++++- tests/test_externalize_dashboard_assets.py | 85 ++++++++++- tests/test_graph_engine_asset.py | 158 ++++++++++++++++++++- 5 files changed, 323 insertions(+), 13 deletions(-) diff --git a/engraphis/static/dashboard.js b/engraphis/static/dashboard.js index 3cc941c..aecba6f 100644 --- a/engraphis/static/dashboard.js +++ b/engraphis/static/dashboard.js @@ -887,6 +887,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 " "" - "", + "", encoding="utf-8", ) monkeypatch.setattr(assets, "INDEX", index) @@ -52,3 +57,81 @@ 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='/static/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_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 index 629cb18..d1daa21 100644 --- a/tests/test_graph_engine_asset.py +++ b/tests/test_graph_engine_asset.py @@ -51,9 +51,51 @@ """ -def _run_node(script: str) -> object: +#: 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. +ENGINE_PRELUDE = """ +const fs = require('fs'); +const source = fs.readFileSync(process.argv[1], 'utf8'); +const window = {}; +globalThis.requestAnimationFrame = () => {}; +const store = {}, calls = {}; +const fg = new Proxy({}, { + get: (_target, prop) => (...args) => { + if (!args.length) 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)], + [NODE, "-e", prelude + script, str(ASSET)], cwd=ROOT, capture_output=True, text=True, @@ -63,6 +105,10 @@ def _run_node(script: str) -> object: 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 ──────────────────────────────────────────────── @@ -450,6 +496,41 @@ def test_graph_analysis_is_stack_safe_and_bounded_on_a_large_store() -> None: 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_max_helper_survives_arrays_past_the_spread_limit() -> None: """``Math.max(...array)`` throws RangeError long before a store is unrenderable.""" @@ -477,6 +558,206 @@ def test_colour_helpers_handle_the_shorthand_hex_the_palettes_may_carry() -> Non 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 + + +@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)); + +const log = { created: 0, paused: 0, seeded: 0, scope: 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: () => [], +}; +const engine = { + setSettings() {}, setStyle() {}, setColorBy() {}, setPalette() {}, setTypeColors() {}, + setLayers() {}, setScope(patch) { log.scope = patch; }, + 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(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_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 + + # ── CSP, styling and lifecycle ────────────────────────────────────────────────────── From efa35a6744bd4a88a3b219e8a73fcb3e6000e7da Mon Sep 17 00:00:00 2001 From: Jaixii Date: Sun, 26 Jul 2026 17:47:41 -0400 Subject: [PATCH 04/13] fix(graph): rank communities, settle large galaxy graphs, follow the theme Three further findings from PR #74 review. Community IDs were assigned in raw payload order while graphRenderLegend() sorts communities by size and calls the largest "Cluster 1". Node colour indexes the palette by the ID itself, so whenever a smaller component appeared first the legend described one component with another's swatch. Components are now ranked by size before the IDs escape, matching the classic graphComputeCommunities(). Galaxy style held autoPauseRedraw(false) unconditionally, so a settled large graph repainted every node and link every frame forever. The starfield is the only paint force-graph cannot see; the classic path drops it past GPERF.large, so the same 600-node/2400-link signal now skips it and hands the redraw loop back to the vendor. Type colours resolved from hard-coded dark-theme constants, so switching to Light, Midnight, Solarized or Sepia moved the legend and controls while the canvas stayed dark. The engine cannot read CSS custom properties from a canvas, so the dashboard resolves --entity-* and supplies them through a new setThemeColors(), below style palettes and user overrides in priority. 1446 passed, 0 failed (+5 tests). ruff, externalize and manifest gates pass. Co-Authored-By: Claude Opus 5 --- engraphis/static/dashboard.js | 7 +- engraphis/static/engraphis-graph.js | 50 ++++++- tests/test_graph_engine_asset.py | 197 +++++++++++++++++++++++++++- 3 files changed, 244 insertions(+), 10 deletions(-) diff --git a/engraphis/static/dashboard.js b/engraphis/static/dashboard.js index fe26969..f20be54 100644 --- a/engraphis/static/dashboard.js +++ b/engraphis/static/dashboard.js @@ -510,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)});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(); @@ -614,6 +618,7 @@ function graphRenderEngine(data,fit,reheat){ 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); @@ -1137,7 +1142,7 @@ function graphToggleFreeze(control){ function graphToggleLabels(control){window.GSET.labels=control.checked;if(GRAPH_ENGINE)GRAPH_ENGINE.setSettings({labels:control.checked});else if(FG)graphRender(false,false)} function graphRecolor(){ renderGraphColorControls(); - if(GRAPH_ENGINE){GRAPH_ENGINE.apply(engine=>{engine.setPalette(typeof GCOLOR_PALETTE!=='undefined'?GCOLOR_PALETTE:'theme');engine.setTypeColors(GCOLOR_OVERRIDES||{})},false,false);return} + if(GRAPH_ENGINE){GRAPH_ENGINE.apply(engine=>{engine.setThemeColors(graphThemeTypeColors());engine.setPalette(typeof GCOLOR_PALETTE!=='undefined'?GCOLOR_PALETTE:'theme');engine.setTypeColors(GCOLOR_OVERRIDES||{})},false,false);return} if(!FG)return; window.GCOL=graphReadThemeColors();graphRefreshNodeColors(); FG.linkColor(FG.linkColor());FG.linkWidth(FG.linkWidth());graphRedraw(); diff --git a/engraphis/static/engraphis-graph.js b/engraphis/static/engraphis-graph.js index 9a23a07..28ba123 100644 --- a/engraphis/static/engraphis-graph.js +++ b/engraphis/static/engraphis-graph.js @@ -55,6 +55,14 @@ 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; + 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) { @@ -138,7 +146,7 @@ // 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(); - let group = 0; + 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 @@ -148,11 +156,21 @@ seen.add(n.id); while (head < queue.length) { const id = queue[head++]; - const node = nodesById.get(id); - if (node) node.community = group; (clusterAdj[id] || []).forEach(next => { if (!seen.has(next)) { seen.add(next); queue.push(next); } }); } - group++; + // `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; } @@ -267,13 +285,16 @@ // 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: {}, + 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; 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; let destroyed = false, running = true, fitTimer = 0, suspended = 0, pendingRender = null; let betweennessReady = false; const fg = ForceGraph()(el); @@ -293,7 +314,7 @@ 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'; + 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. @@ -317,10 +338,16 @@ } } + /* 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 THEME_ETYPE[type] || '#8c83e8'; + return state.themeColors[type] || THEME_ETYPE[type] || '#8c83e8'; } function commPal() { return COMMUNITY_PALS[state.styleName] || COMMUNITY_PALS.classic; } function heatColor(node) { @@ -438,6 +465,11 @@ 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'; @@ -602,6 +634,7 @@ } const motion = !reduced(); const data = visible(); + large = data.nodes.length > LARGE_NODE_LIMIT || data.links.length > LARGE_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); @@ -738,6 +771,9 @@ /* 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 => { diff --git a/tests/test_graph_engine_asset.py b/tests/test_graph_engine_asset.py index d1daa21..10b0341 100644 --- a/tests/test_graph_engine_asset.py +++ b/tests/test_graph_engine_asset.py @@ -531,6 +531,44 @@ def test_influence_relations_do_not_merge_two_topics_into_one_community() -> Non 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.""" @@ -593,6 +631,114 @@ def test_flow_particles_are_capped_on_a_large_relation_set() -> None: 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. @@ -651,16 +797,35 @@ def test_unlinked_entities_are_shown_only_when_the_engine_is_told_to() -> None: const start = src.indexOf('function graphRenderEngine('); const slice = src.slice(start, src.indexOf('/* Nav away from the graph view', start)); -const log = { created: 0, paused: 0, seeded: 0, scope: null, error: null }; +/* 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', +}; +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 = { @@ -685,7 +850,7 @@ def test_unlinked_entities_are_shown_only_when_the_engine_is_told_to() -> None: log.error = String((error && error.message) || error); }; -const graphRenderEngine = new Function(slice + '\\nreturn graphRenderEngine;')(); +const graphRenderEngine = new Function(themeSrc + slice + '\\nreturn graphRenderEngine;')(); const rendered = graphRenderEngine({ nodes: [{ id: 'a' }, { id: 'b' }, { id: 'lonely' }], links: [{ source: 'a', target: 'b' }], @@ -731,6 +896,34 @@ def test_dashboard_tells_the_engine_whether_to_show_unlinked_entities(checked: b 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" + # 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", + } + + +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. From f32edb2d806887a2b064de5d023bdc81667153b0 Mon Sep 17 00:00:00 2001 From: Jaixii Date: Sun, 26 Jul 2026 18:30:40 -0400 Subject: [PATCH 05/13] fix(graph): close the remaining review threads on the opt-in engine Six findings from the PR #74 review, all cases where the opt-in renderer diverged from the classic path it is meant to replace. - Relation labels: the dashboard's Labels checkbox drives two label layers on the classic path, but the engine configured no linkCanvasObject, so ticking it painted entity names only and a relation could be read only by hovering it. Paint them, with the classic gates (zoom, dense-graph). - zoomToNode: graphFocus() treats a false return as "run the recovery path". The engine answered from raw.nodes, which keeps the coordinates force-graph left on a node, so an entity hidden by a scope filter or by the auto-collapsed cluster view still reported success and the camera moved to nothing. Expand a collapsed view, then verify against the data force-graph is actually holding. - Reseeding: render() called graphData() unconditionally with newly allocated arrays, so Style, Color by, Labels and Flow each reset the d3 alpha and restarted the layout. Reseed only when the visible set really changed, and invalidate the paint when it did not. - Cooldown: nothing overrode force-graph's 15s default simulation window, so a large store kept simulating (and repainting) far past the 1.1s/80 ticks the classic path allows. Mirror its size-dependent bounds. - Dense graphs: curvature and arrowheads stayed on past the classic GPERF.dense threshold, paying a bezier and a filled triangle per relation across thousands of links. - Community colours: the cluster legend swatches are CSS (.graph-cluster-N) encoding the Cyber palette, but the engine's Cyber and Solar palettes were ordered differently from dashboard.js, so on the default style the legend described each of the first clusters with another cluster's colour. Each is covered by a test that fails without the change. Co-Authored-By: Claude Opus 5 --- engraphis/static/engraphis-graph.js | 141 ++++++++++++-- tests/test_graph_engine_asset.py | 282 ++++++++++++++++++++++++++++ 2 files changed, 411 insertions(+), 12 deletions(-) diff --git a/engraphis/static/engraphis-graph.js b/engraphis/static/engraphis-graph.js index 28ba123..6499d78 100644 --- a/engraphis/static/engraphis-graph.js +++ b/engraphis/static/engraphis-graph.js @@ -40,11 +40,17 @@ 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', '#3fd2c7', '#ffd68a', '#5b9bff', '#ff8f6b', '#8ea8ff', '#ffc98a', '#4fc0b0', '#ff9f5b', '#7fb0ff', '#ffd0a0', '#5ad0c0'], - cyber: ['#ff3ea5', '#22e0ff', '#b6ff3c', '#ffe14d', '#8b7bff', '#ff5c7a', '#3cffd0', '#ff9ae0', '#5ce0ff', '#d0ff3c', '#ffb03c', '#a06bff'] + 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']; @@ -63,6 +69,18 @@ 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) { @@ -294,7 +312,11 @@ 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; + 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); @@ -626,6 +648,61 @@ 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 = 'rgba(232,236,245,.62)'; + 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) { @@ -633,8 +710,14 @@ return; } const motion = !reduced(); - const data = visible(); + 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); @@ -645,13 +728,22 @@ n.stroke = contrastOn(n.color); }); applyChrome(); - fg.graphData(data); + if (!reused) { fg.graphData(data); seeded = data; } applyForces(); fg.autoPauseRedraw(!needsContinuousFrames()); - if (fg.d3AlphaDecay) fg.d3AlphaDecay(0.035); - if (fg.d3VelocityDecay) fg.d3VelocityDecay(0.38); - if (fg.linkCurvature) fg.linkCurvature((PRESETS[state.settings.mode] || PRESETS.compact).curve || 0); - fg.linkDirectionalArrowLength(2.5).linkDirectionalArrowRelPos(1); + /* 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 @@ -666,6 +758,9 @@ } 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); @@ -741,6 +836,10 @@ 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 = {}; @@ -789,19 +888,36 @@ api.reheat = () => { if (destroyed || reduced()) return; raw.nodes.forEach(n => { n.fx = undefined; n.fy = undefined; }); - if (fg.d3ReheatSimulation) { fg.d3AlphaDecay(0.035); fg.d3ReheatSimulation(); } + 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; } applyForces(); if (reduced()) return; - fg.d3AlphaDecay(0.035); + 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 || !Number.isFinite(n.x) || !Number.isFinite(n.y)) return false; + 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); @@ -872,6 +988,7 @@ } catch (e) { /* teardown is best-effort: never let it block a view change */ } raw = { nodes: [], links: [], suggestions: [] }; adj = {}; + seeded = null; hilite = null; hoverSet = null; }; diff --git a/tests/test_graph_engine_asset.py b/tests/test_graph_engine_asset.py index 10b0341..773297d 100644 --- a/tests/test_graph_engine_asset.py +++ b/tests/test_graph_engine_asset.py @@ -951,6 +951,288 @@ def test_leaving_the_graph_view_records_the_pause_as_well_as_applying_it() -> No 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 }); + const ticked = paintLinks(4); + // Relation labels are the noisiest layer: they stay off until the user zooms in. + const zoomedOut = paintLinks(1); + emit({ unticked, ticked, zoomedOut }); + """ + ) + assert report["unticked"] == [] + assert report["ticked"] == ["mentions"], "the Labels checkbox never paints relation names" + assert report["zoomedOut"] == [] + + +@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_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"] + + +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 ────────────────────────────────────────────────────── From b17ec4580a3ee87e3e98668badc128d7ee30c8b8 Mon Sep 17 00:00:00 2001 From: Jaixii Date: Sun, 26 Jul 2026 18:55:58 -0400 Subject: [PATCH 06/13] fix(graph): reheat the simulation when a physics slider moves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under ?graph-engine=next the Repel/Link/Gravity/Size sliders appeared dead. dashboard.js::graphSet routes them through api.setSettings, which only asked render() for a reheat when `mode` was present. applyForces() writes the new charge / link / forceX-forceY / collide values straight into the simulation force-graph is already running, and a settled graph sits at alpha~0 — so the new forces were installed and nothing moved until the user found the Reheat button. `size` is affected too: it feeds d3.forceCollide. The classic branch of that same function does the opposite — it treats `repel|link|gravity|size` as a layout change and reheats unless the user asked for reduced motion. setSettings now matches that set (plus `mode`, which swaps the whole force arrangement); render() already carries the reduced-motion exemption, so it is honoured for free. Appearance-only settings (font, link width, label density, labels, flow) still must not reheat: restarting the layout because a label got bigger throws away the arrangement being read. The regression test drives the API against the force-graph stand-in and counts d3ReheatSimulation() calls per key. Pre-fix it reported {repel: 0, link: 0, gravity: 0, size: 0, mode: 1}. Co-Authored-By: Claude Opus 5 --- engraphis/static/engraphis-graph.js | 19 +++++++-- tests/test_graph_engine_asset.py | 61 +++++++++++++++++++++++++++-- 2 files changed, 74 insertions(+), 6 deletions(-) diff --git a/engraphis/static/engraphis-graph.js b/engraphis/static/engraphis-graph.js index 6499d78..e134625 100644 --- a/engraphis/static/engraphis-graph.js +++ b/engraphis/static/engraphis-graph.js @@ -349,8 +349,9 @@ /* 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` always asks for a - reheat, which would reheat even on a `render(false, false)` refresh. */ + 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 { @@ -855,7 +856,19 @@ if (state.bridges || state.sizeBy === 'betweenness') ensureBetweenness(); render(true, true); }; - api.setSettings = patch => { Object.assign(state.settings, patch); render(false, patch.mode !== undefined); }; + /* 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'; diff --git a/tests/test_graph_engine_asset.py b/tests/test_graph_engine_asset.py index 773297d..a18c9e2 100644 --- a/tests/test_graph_engine_asset.py +++ b/tests/test_graph_engine_asset.py @@ -55,16 +55,18 @@ #: 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. +#: 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 = {}; +const store = {}, calls = {}, invocations = {}; const fg = new Proxy({}, { get: (_target, prop) => (...args) => { - if (!args.length) return store[prop]; + 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; @@ -1154,6 +1156,59 @@ def test_simulation_time_is_bounded_on_a_large_graph() -> None: 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). From 089f18563f4957d020d0652b40f442aee25e198e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Jul 2026 23:03:08 -0400 Subject: [PATCH 07/13] perf(graph): apply the classic renderer's large-graph guards to the opt-in engine The engine already computed `large` (>600 nodes / >2400 links, the classic `GPERF.large` signal) and used it for cooldown, alpha decay and the galaxy starfield, but two per-frame costs the classic path drops at that cutoff were still being paid: - `applyForces()` pinned `forceCollide().iterations(2)`. The second pass is another full quadtree traversal per node per tick, and a large store pays it on the initial layout and on every reheat. Now `iterations(large ? 1 : 2)`, matching `.iterations(GPERF.large?1:2)`. - Every `rich` node still got a glow: the galaxy halo, the solar corona *and* its sphere shading, and the cyber `shadowBlur`. A gradient is a fresh object per node and a blur is a convolution of the drawn shape, both per frame. All three are now gated on `!large`, with the flat `fillStyle = col` fallback the classic path uses for the solar sphere. Both are pinned by tests that drive the asset under Node and read the recorded force/canvas calls back, and both fail against the previous asset (601 collision iterations of 2; 607 gradients painted on a large solar graph). Co-Authored-By: Claude Opus 5 --- engraphis/static/engraphis-graph.js | 29 ++++++-- tests/test_graph_engine_asset.py | 109 ++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+), 8 deletions(-) diff --git a/engraphis/static/engraphis-graph.js b/engraphis/static/engraphis-graph.js index e134625..8dffa6c 100644 --- a/engraphis/static/engraphis-graph.js +++ b/engraphis/static/engraphis-graph.js @@ -483,7 +483,11 @@ 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)); } - if (d3.forceCollide) fg.d3Force('collide', d3.forceCollide(n => n.radius + 1.5).iterations(2)); + /* 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) { @@ -563,8 +567,13 @@ 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) { + if (rich && !large) { ctx.save(); ctx.globalCompositeOperation = 'lighter'; const R = r * (node.id === hilite ? 4.4 : 3.0); @@ -583,7 +592,7 @@ } else if (state.styleName === 'solar') { const sun = node.rank === 0; if (sun) r *= 1.7; - if (rich) { + if (rich && !large) { ctx.save(); ctx.globalCompositeOperation = 'lighter'; const cc = sun ? '#ffcf6b' : col, R2 = r * (sun ? 3.4 : 2.1); @@ -594,14 +603,18 @@ ctx.beginPath(); ctx.arc(node.x, node.y, R2, 0, 6.2832); ctx.fill(); ctx.restore(); } - 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; + 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) { ctx.shadowColor = col; ctx.shadowBlur = dim ? 2 : r * 2.6; } + 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(); diff --git a/tests/test_graph_engine_asset.py b/tests/test_graph_engine_asset.py index a18c9e2..4b65c3c 100644 --- a/tests/test_graph_engine_asset.py +++ b/tests/test_graph_engine_asset.py @@ -1251,6 +1251,115 @@ def test_curves_arrows_and_relation_labels_are_dropped_on_a_dense_graph() -> Non 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. From 1e351567480aa3ce6828204d7bab763ae24a07db Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Jul 2026 23:19:41 -0400 Subject: [PATCH 08/13] test(graph): exercise the opt-in engine in a real browser The Node harness in tests/test_graph_engine_asset.py drives the asset against a recording force-graph stand-in, which is right for the logic and proves nothing about a browser: no CSP, no real canvas, no vendor bundle, no ", encoding="utf-8", ) @@ -66,7 +66,7 @@ def test_check_rejects_unclosed_inline_asset_at_eof(tmp_path, monkeypatch, tag): _LOADERS = ( "script.src='/static/vendor/force-graph.min.js';" - "script.src='/static/engraphis-graph.js';" + "script.src='/v2-assets/engraphis-graph.js';" ) diff --git a/tests/test_graph_engine_asset.py b/tests/test_graph_engine_asset.py index 9403bad..0fcb598 100644 --- a/tests/test_graph_engine_asset.py +++ b/tests/test_graph_engine_asset.py @@ -29,7 +29,8 @@ ROOT = Path(__file__).resolve().parents[1] STATIC = ROOT / "engraphis" / "static" -ASSET = STATIC / "engraphis-graph.js" +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" @@ -129,6 +130,14 @@ def test_graph_assets_are_never_loaded_on_a_plain_page_view() -> None: 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(). @@ -137,7 +146,7 @@ def test_opt_in_graph_asset_is_lazily_loaded_after_its_dependencies() -> None: """ source = DASHBOARD.read_text(encoding="utf-8") assert "script.src='/static/vendor/force-graph.min.js'" in source - assert "script.src='/static/engraphis-graph.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'") @@ -146,6 +155,12 @@ def test_opt_in_graph_asset_is_lazily_loaded_after_its_dependencies() -> None: 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. @@ -234,7 +249,7 @@ def test_graph_engine_deep_link_reaches_the_next_engine_after_a_lazy_load() -> N report = _run_routing("loads") - assert report["appended"] == ["/static/engraphis-graph.js"] + 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. From daa7035f61000e79ca6e7b394062c09dc189c455 Mon Sep 17 00:00:00 2001 From: Jaixii Date: Mon, 27 Jul 2026 00:25:37 -0400 Subject: [PATCH 11/13] fix(graph): keep v2 assets lazy and themed --- engraphis/dashboard_assets/engraphis-graph.js | 4 +++- engraphis/static/dashboard.js | 2 +- scripts/externalize_dashboard_assets.py | 6 +++++- tests/test_externalize_dashboard_assets.py | 12 ++++++++++++ tests/test_graph_engine_asset.py | 9 ++++++++- 5 files changed, 29 insertions(+), 4 deletions(-) diff --git a/engraphis/dashboard_assets/engraphis-graph.js b/engraphis/dashboard_assets/engraphis-graph.js index 00a9ac6..532fcfb 100644 --- a/engraphis/dashboard_assets/engraphis-graph.js +++ b/engraphis/dashboard_assets/engraphis-graph.js @@ -553,7 +553,9 @@ 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'; - ctx.fillStyle = 'rgba(232,236,245,.9)'; + // 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; diff --git a/engraphis/static/dashboard.js b/engraphis/static/dashboard.js index 60cfdff..b012fbe 100644 --- a/engraphis/static/dashboard.js +++ b/engraphis/static/dashboard.js @@ -513,7 +513,7 @@ function graphTypeColor(type){if(GCOLOR_OVERRIDES[type])return GCOLOR_OVERRIDES[ /* 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');return colors} +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(); diff --git a/scripts/externalize_dashboard_assets.py b/scripts/externalize_dashboard_assets.py index 858ae40..c2aaf96 100644 --- a/scripts/externalize_dashboard_assets.py +++ b/scripts/externalize_dashboard_assets.py @@ -139,7 +139,11 @@ def _eager_scripts(html: str) -> list[str]: CSP-hostile bundle back onto every page view *and* drop it from the existence check — a gate that cannot fail. """ - return [src for src in _parse_page(html).script_srcs if src.startswith("/static/")] + return [ + src + for src in _parse_page(html).script_srcs + if src.startswith(("/static/", "/v2-assets/")) + ] def _replace_assets(html: str, replacements: list[tuple[_InlineAsset, str]]) -> str: diff --git a/tests/test_externalize_dashboard_assets.py b/tests/test_externalize_dashboard_assets.py index 900cf26..8a3b567 100644 --- a/tests/test_externalize_dashboard_assets.py +++ b/tests/test_externalize_dashboard_assets.py @@ -99,6 +99,18 @@ def test_check_rejects_an_eagerly_loaded_csp_hostile_script(tmp_path, monkeypatc 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 ``