diff --git a/CHANGELOG.md b/CHANGELOG.md index f118ca59..13657664 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,9 +24,9 @@ All notable changes to Engraphis are documented here. Format loosely follows relation labels no longer suppress direct star/system motion. - Galaxy physics ticks now explicitly invalidate the canvas camera, so advancing orbital coordinates repaints visibly even when force-graph's automatic redraw loop is paused. -- Complete graph capacity is doubled to 40,000 entity nodes and 200,000 raw relationships, - with matching evidence, connector, payload, and full-loader ceilings; live-render safety - thresholds remain unchanged so oversized scenes stay on the static/kinematic path. +- Complete graph analysis now scans up to 40,000 entity rows and 200,000 raw relationships, + while the explicit all-node renderer retains its 20,000-node, 200,000-link refusal ceiling. + Live-render safety thresholds remain unchanged so oversized scenes stay on the static path. - Show all nodes now keeps the complete sidebar live: deterministic worker layouts respond to repel, link-distance, gravity, and advanced force controls; minimum relations, unlinked nodes, focus depth, relation layers, ghosts, and auto-collapse filter the LOD scene without a reload. diff --git a/engraphis/classic_assets/dashboard.js b/engraphis/classic_assets/dashboard.js index 2c76ed03..74bf3cc2 100644 --- a/engraphis/classic_assets/dashboard.js +++ b/engraphis/classic_assets/dashboard.js @@ -569,7 +569,7 @@ async function exportWorkspace(){try{const d=await api('/export?workspace='+enco async function loadTeam(){const el=document.getElementById('team-body'),teamCta=hostedCta('team','team_tab');try{const st=await api('/auth/state');if(teamCta.href==='#'&&st&&st.cloud_url)teamCta.href=safeUrl(st.cloud_url)}catch(e){}el.innerHTML=`
Engraphis Team Cloud HOSTED
Organizations, invitations, roles, named seats, scoped device credentials, and team audit run on the private hosted service. This local dashboard is intentionally single-user.
${esc(teamTeaserNote())} Private-service account grace is capped at 24 hours, never extends Team access, and never restricts the free local core.
${ctaLinkHtml(teamCta,'btn btn-primary btn-sm','team_tab')}
`} /* health + settings */ function connectionContext(){const host=(location.hostname||'').toLowerCase();return host==='localhost'||host==='127.0.0.1'||host==='::1'||host.endsWith('.localhost')?'Local engine':'Remote customer node'} -async function checkHealth(){const label=connectionContext();try{await api('/health');const d=document.getElementById('health-dot'),t=document.getElementById('health-text');if(d){d.classList.add('health-ok');d.classList.remove('health-error')}if(t)t.textContent=label+' connected'}catch(e){const d=document.getElementById('health-dot'),t=document.getElementById('health-text');if(d){d.classList.add('health-error');d.classList.remove('health-ok')}if(t)t.textContent=label+' unavailable'}try{const auth=await api('/auth/state');const m=document.getElementById('deployment-mode-indicator');if(m){const isLocal=auth.deployment_mode==='local';m.textContent=isLocal?'LOCAL':'HOSTED';m.title=isLocal?'Local mode: no hosted cloud configured':'Hosted mode: connected to Engraphis Cloud';m.className='deployment-mode '+(isLocal?'mode-local':'mode-hosted');m.hidden=false}}catch(e){}} +async function checkHealth(){const label=connectionContext();try{await api('/health');const d=document.getElementById('health-dot'),t=document.getElementById('health-text');if(d){d.classList.add('health-ok');d.classList.remove('health-error')}if(t)t.textContent=label+' connected'}catch(e){const d=document.getElementById('health-dot'),t=document.getElementById('health-text');if(d){d.classList.add('health-error');d.classList.remove('health-ok')}if(t)t.textContent=label+' unavailable'}try{const auth=await api('/auth/state');const m=document.getElementById('deployment-mode-indicator');if(m){const isLocal=auth.deployment_mode==='local';m.textContent=isLocal?'LOCAL':'HOSTED';m.title=isLocal?(auth.enabled?'Local API token required':'Local mode: no hosted cloud configured'):'Hosted mode: connected to Engraphis Cloud';m.className='deployment-mode '+(isLocal?'mode-local':'mode-hosted');m.hidden=false}}catch(e){}} function loadSettings(){loadLicense();loadSyncStatus();loadHostedAgentAccess();loadLlmStatus();const s=document.getElementById('cfg-store');if(s)s.textContent=location.host;api('/info').then(function(d){var v=document.getElementById('cfg-version');if(v&&d&&d.version)v.textContent=d.version}).catch(function(){})} async function loadLlmStatus(){const el=document.getElementById('llm-body');if(!el)return;try{const st=await api('/llm/status');const ok=st.configured;const badge=ok?'configured':'not configured';const keyLine=st.key_set?'API key set ✓':'No API key set';let modelSel='';let provSel='';el.innerHTML=`
Provider · Model${badge}
${provSel}${modelSel}
${keyLine} · extractor: ${esc(st.extractor)}
Add this to your .env and restart Engraphis:
LLM extraction${st.extractor_enabled?'ON':'OFF'}
While ON, ingested memory content is sent to your LLM provider for schema-validated extraction. OFF disables extraction transfers only; retention supervision is configured separately.
`}catch(e){el.innerHTML='
'+esc(e.message)+'
'}} @@ -597,7 +597,7 @@ const syncNowBase=syncNow; syncNow=async function(){if(!await confirmCloudTransfer('Sync shared workspaces','Cloud Sync sends eligible changes from your shared workspaces to Engraphis Cloud and receives authorized changes from your other installations; secret and session-scoped rows stay local.','Sync now',CLOUD_SYNC_PRIVACY_COPY))return;return syncNowBase()} /* ─── knowledge graph (force-graph + d3-force: compact defaults and selectable layouts) ─── */ -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}, GRAPH_FULL=false, GRAPH_SCOPE_BEFORE_FULL=null, GRAPH_LOAD_REQUEST=0, GRAPH_LOAD_CONTROLLER=null; +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}, GRAPH_LOAD_REQUEST=0, GRAPH_LOAD_CONTROLLER=null; 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}, @@ -716,14 +716,17 @@ function graphEngineFallback(error){ 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); } +/* Explicit user-initiated Reload (boot()) must not be haunted by a prior failed attempt: + drop the quality-engine failure latch and any cached failed promises so the next render + retries with a fresh URL. Mirrors the per-loader cleanup that runs on each rejection. */ +function graphResetEngineFailure(){GRAPH_ENGINE_FAILED=false;FORCE_GRAPH_LOADING=null;FORCE_GRAPH_RETRY=0;GRAPH_ENGINE_LOADING=null;GRAPH_ENGINE_RETRY=0;try{if(GRAPH_ENGINE)GRAPH_ENGINE.destroy()}catch(e){}GRAPH_ENGINE=null;GACTIVE_DATA=null;} 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'); - const fullGraph=typeof GRAPH_FULL!=='undefined'&&GRAPH_FULL; - if(!element||typeof EngraphisGraph==='undefined')return false; +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:[]}); @@ -735,7 +738,7 @@ function graphRenderEngine(data,fit,reheat){ const created=!GRAPH_ENGINE; if(created){ GRAPH_ENGINE=EngraphisGraph.create(element,{ - renderMode:fullGraph?'all':'overview', + renderMode:'overview', reducedMotion:prefersReducedMotion, onNodeClick:node=>{syncGraphExplorerSelection(node.id);graphNodeClick(node.label||node.name||node.id)}, onBackgroundClick:()=>graphSetHighlight(null), @@ -750,10 +753,10 @@ function graphRenderEngine(data,fit,reheat){ engine re-filters by degree on its own state. Leaving the engine on its defaults (showUnlinked:false, minDegree:1) drops every degree-zero entity graphData() just supplied, so the checkbox appeared to do nothing under ?graph-engine=next. */ - const isolated=document.getElementById('graph-show-iso'),showUnlinked=fullGraph||!!(isolated&&isolated.checked); + const isolated=document.getElementById('graph-show-iso'),showUnlinked=!!(isolated&&isolated.checked); GRAPH_ENGINE.apply(engine=>{ engine.setSettings({...window.GSET}); - if(typeof engine.setRenderMode==='function')engine.setRenderMode(fullGraph?'all':'overview'); + if(typeof engine.setRenderMode==='function')engine.setRenderMode('overview'); engine.setStyle(typeof GSTYLE!=='undefined'?GSTYLE:'cyber'); engine.setColorBy(typeof GCOLORBY!=='undefined'?GCOLORBY:'community'); engine.setThemeColors(graphThemeTypeColors()); @@ -775,7 +778,7 @@ function graphRenderEngine(data,fit,reheat){ null. Re-apply the parked state here so a renderer created against a hidden pane never starts a rAF that nothing will stop. */ if(GRAPH_ENGINE_PARKED)GRAPH_ENGINE.pause(); - graphSetSimulationStatus(fullGraph?'All nodes · settled LOD':(window.GSET.frozen?'Layout frozen':'Adaptive layout'),false); + graphSetSimulationStatus(window.GSET.frozen?'Layout frozen':'Adaptive layout',false); return true; }catch(error){ graphEngineFallback(error); @@ -795,15 +798,18 @@ 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(){ - const request=++GRAPH_LOAD_REQUEST,targetFull=GRAPH_FULL; - const previousController=GRAPH_LOAD_CONTROLLER,controller=new AbortController();GRAPH_LOAD_CONTROLLER=controller; - if(previousController&&!previousController.signal.aborted)previousController.abort(); - graphInjectCss();graphInvalidateData();GRAPH=null; +async function loadLegacyGraph(){ + const request=++GRAPH_LOAD_REQUEST; + const previousController=GRAPH_LOAD_CONTROLLER,controller=new AbortController();GRAPH_LOAD_CONTROLLER=controller; + if(previousController&&!previousController.signal.aborted)previousController.abort(); + /* Transactional reload: keep the existing graph visible until the replacement payload + succeeds. Only invalidate caches (not the rendered graph) so a network failure leaves + the user looking at the previous data rather than an error screen. */ + const previousGraph=GRAPH,previousEngine=GRAPH_ENGINE,previousActive=GACTIVE_DATA; + GDATA_CACHE=null; const empty=document.getElementById('graph-empty'),net=document.getElementById('graph-net'),nodesBox=document.getElementById('graph-entity-list'),edgesBox=document.getElementById('graph-relation-list'); showAs(empty,true,'flex');empty.textContent='Loading graph…';graphSetLayoutStatus('Loading data',true); if(net)net.setAttribute('aria-busy','true'); - renderGraphExplorer(); if(!GRESIZE){ GRESIZE=true; window.addEventListener('resize',()=>{ @@ -811,29 +817,28 @@ async function loadLegacyGraph(){ 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(),showUnlinked=targetFull||!!document.getElementById('graph-show-iso').checked; - try{ - const query=encodeURIComponent(WS||'')+(repo?'&repo='+encodeURIComponent(repo):'')+layerFilter; - let nextGraph; - if(targetFull){ - /* The complete scene and its dedicated renderer are independent requests. Starting them - together avoids adding an asset round-trip after a potentially large scene response, and - awaiting both guarantees that complete data can never fall into the legacy ForceGraph. */ - const [response]=await Promise.all([ - api('/graph/scene?workspace='+query+'&level=complete&presentation=all&include_memory_nodes=false',{signal:controller.signal}), - loadGraphEngine(true) - ]); - const scene=response.scene||response; - nextGraph={nodes:(scene.nodes||[]).map(node=>({...node,id:node.id,label:node.label||node.name||node.id,degree:node.degree??node.weighted_degree??0,etype:node.etype||'entity'})),edges:(scene.edges||[]).map(edge=>({...edge,from:edge.from??edge.source??edge.src,to:edge.to??edge.target??edge.dst,label:edge.label||edge.relation||'related',layer:edge.layer||'semantic'})),meta:scene.meta||{}}; - }else{ - nextGraph=await api('/graph?workspace='+query+'&include_code='+(includeCode?'true':'false')+'&limit=1000&node_limit=1000&edge_limit=2000'+(showUnlinked?'':'&connected_only=true'),{signal:controller.signal}); - } - if(request!==GRAPH_LOAD_REQUEST||targetFull!==GRAPH_FULL)return; - GRAPH=nextGraph; - renderGraphSide();graphRender(); - }catch(error){ - if(request!==GRAPH_LOAD_REQUEST||error.name==='AbortError')return; - showAs(empty,true,'flex');empty.textContent='Graph failed: '+error.message;graphSetLayoutStatus('Load failed',false); + 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(),showUnlinked=!!document.getElementById('graph-show-iso').checked; + try{ + const query=encodeURIComponent(WS||'')+(repo?'&repo='+encodeURIComponent(repo):'')+layerFilter; + const nextGraph=await api('/graph?workspace='+query+'&include_code='+(includeCode?'true':'false')+'&limit=1000&node_limit=1000&edge_limit=2000'+(showUnlinked?'':'&connected_only=true'),{signal:controller.signal}); + if(request!==GRAPH_LOAD_REQUEST)return; + /* Commit: the new payload arrived successfully. Now tear down the old renderer and + install the replacement graph. */ + if(previousEngine){try{previousEngine.destroy()}catch(e){}} + GRAPH_ENGINE=null;GACTIVE_DATA=null;GCOMPONENT_LAYOUT=null;GHILITE=null;GHOVERSET=null; + GRAPH=nextGraph; + renderGraphSide();renderGraphExplorer();graphRender(); + }catch(error){ + if(request!==GRAPH_LOAD_REQUEST||error.name==='AbortError')return; + /* Rollback: restore the previous graph so the user is not left staring at an error. + If there was no previous graph (first load), show the error message. */ + if(previousGraph){ + GRAPH=previousGraph;GRAPH_ENGINE=previousEngine;GACTIVE_DATA=previousActive; + showAs(empty,false);graphSetLayoutStatus('Reload failed — showing previous data',false); + toast('Reload data failed: '+error.message,'err'); + }else{ + showAs(empty,true,'flex');empty.textContent='Graph failed: '+error.message;graphSetLayoutStatus('Load failed',false); + } }finally{ if(request!==GRAPH_LOAD_REQUEST)return; if(GRAPH_LOAD_CONTROLLER===controller)GRAPH_LOAD_CONTROLLER=null; @@ -846,27 +851,10 @@ async function loadLegacyGraph(){ } } } -function graphUpdateAllNodesControl(){ - const full=GRAPH_FULL,button=document.getElementById('graph-show-all'),isolated=document.getElementById('graph-show-iso'),includeCode=document.getElementById('graph-include-code'); - if(button){button.textContent=full?'High quality':'Show all nodes';button.setAttribute('aria-pressed',String(full));button.title=full?'Return to the high-quality graph view':'Load every node, including unconnected entities, for this graph view'} - if(isolated){isolated.disabled=full;isolated.title=full?'All nodes are already visible.':'Show entities that have no relations (unlinked nodes). Hidden by default to keep the graph readable.'} - if(includeCode){includeCode.disabled=full;includeCode.title=full?'Code overlay is available in High quality mode.':''} -} -function graphToggleAllNodes(){ - const isolated=document.getElementById('graph-show-iso'); - if(!GRAPH_FULL){GRAPH_SCOPE_BEFORE_FULL={showUnlinked:!!(isolated&&isolated.checked)};GRAPH_FULL=true;if(isolated)isolated.checked=true} - else{GRAPH_FULL=false;if(isolated&&GRAPH_SCOPE_BEFORE_FULL)isolated.checked=GRAPH_SCOPE_BEFORE_FULL.showUnlinked;GRAPH_SCOPE_BEFORE_FULL=null} - graphUpdateAllNodesControl();loadLegacyGraph(); -} -function graphData(){ - const _si=document.getElementById('graph-show-iso');const hideIso=!(_si&&_si.checked); - if(GDATA_CACHE&&GDATA_CACHE.graph===GRAPH&&GDATA_CACHE.hideIso===hideIso)return GDATA_CACHE.data; - if(GRAPH_FULL){ - /* The flat all-node worker accepts the scene's node and from/to edge shapes directly. - Avoid cloning and decorating up to 20k nodes and 200k relations for quality-only paint. */ - const data={nodes:GRAPH.nodes||[],links:GRAPH.edges||[]};GDATA_CACHE={graph:GRAPH,hideIso,data};return data; - } - let sourceNodes=GRAPH.nodes;if(hideIso)sourceNodes=sourceNodes.filter(node=>node.degree>0); +function graphData(){ + const _si=document.getElementById('graph-show-iso');const hideIso=!(_si&&_si.checked); + if(GDATA_CACHE&&GDATA_CACHE.graph===GRAPH&&GDATA_CACHE.hideIso===hideIso)return GDATA_CACHE.data; + let sourceNodes=GRAPH.nodes;if(hideIso)sourceNodes=sourceNodes.filter(node=>node.degree>0); const names=new Set(sourceNodes.map(node=>node.id)); const nodes=sourceNodes.map(node=>({id:node.id,label:node.label||node.id,displayLabel:(node.label||node.id).length>30?(node.label||node.id).slice(0,29)+'…':(node.label||node.id),etype:node.etype,degree:node.degree||0,val:1+(node.degree||0)})); const maxDegree=Math.max(1,...nodes.map(node=>node.degree||0)); @@ -1207,47 +1195,42 @@ function graphRedraw(){ loading it on a page that never opens the graph turns a plain dashboard view into a wall of console errors. Both loaders are memoized, so a re-entrant graphRender() reuses the in-flight fetch rather than appending a second + diff --git a/engraphis/core/graph_scene.py b/engraphis/core/graph_scene.py index 90edce6f..f60e5881 100644 --- a/engraphis/core/graph_scene.py +++ b/engraphis/core/graph_scene.py @@ -2952,6 +2952,73 @@ def eligible(node_id: str) -> bool: "facets": _facets(graph), } +_ALL_PRESENTATION_NODE_FIELDS = ( + "id", "label", "type", "node_kind", "community_id", "ghost", "member_ids", + "x", "y", "gravity_mass", "visual_radius", "mass_score", + "weighted_degree", "pagerank", "support_count", "scene_rank", + "anchor_role", "system_anchor_id", "orbit_tier", "orbit_radius", +) +_ALL_PRESENTATION_EDGE_FIELDS = ( + "id", "source", "target", "relation", "layer", "ghost", "strength", + "rest_length", "spring_strength", +) +_ALL_PRESENTATION_META_FIELDS = ( + "workspace", "level", "scene_hash", "index_generation", + "total_nodes", "total_edges", "shown_nodes", "shown_edges", "truncated", + "query_ms", "layout_seed", "index_state", "connected_only", + "include_history", "include_memory_nodes", "algorithm_version", +) + + +def project_all_presentation(scene: Mapping[str, Any]) -> dict[str, Any]: + """Return the compact renderer contract for ``presentation=all``. + + Complete analytical scenes retain provenance, temporal evidence, and inspector fields. + The all-node renderer needs only stable identity, canonical layout/hierarchy, display + metrics, and relation physics. Keeping this projection explicit prevents multi-megabyte + evidence arrays from crossing the HTTP/worker boundary only to be discarded. + """ + nodes = [] + for node in scene.get("nodes", ()): + projected_node = { + key: node[key] for key in _ALL_PRESENTATION_NODE_FIELDS + if key != "member_ids" and key in node + } + if node.get("ghost"): + member_ids = node.get("member_ids") + if isinstance(member_ids, Sequence) and not isinstance(member_ids, (str, bytes)): + member_id = next(( + value for value in member_ids + if isinstance(value, str) and value + ), "") + if member_id: + projected_node["member_ids"] = [member_id] + nodes.append(projected_node) + communities = { + str(node.get("id") or ""): str(node.get("community_id") or "") + for node in nodes + } + edges = [] + for edge in scene.get("edges", ()): + projected = { + key: edge[key] for key in _ALL_PRESENTATION_EDGE_FIELDS if key in edge + } + source_community = communities.get(str(projected.get("source") or ""), "") + target_community = communities.get(str(projected.get("target") or ""), "") + projected["bridge"] = bool( + source_community and target_community + and source_community != target_community + ) + edges.append(projected) + meta = { + key: scene.get("meta", {})[key] + for key in _ALL_PRESENTATION_META_FIELDS + if key in scene.get("meta", {}) + } + meta["all_projected"] = True + return {"meta": meta, "nodes": nodes, "edges": edges} + + def strongest_path(graph: dict[str, Any], source: str, target: str, *, max_hops: int = 8, max_visits: int = 10_000) -> dict[str, Any]: diff --git a/engraphis/dashboard_app.py b/engraphis/dashboard_app.py index 0f86f153..40554c66 100644 --- a/engraphis/dashboard_app.py +++ b/engraphis/dashboard_app.py @@ -28,6 +28,7 @@ from fastapi.responses import FileResponse, JSONResponse, Response from fastapi.routing import APIRoute from fastapi.staticfiles import StaticFiles +from starlette.middleware.gzip import GZipMiddleware from pydantic import BaseModel, Field from starlette.exceptions import HTTPException as StarletteHTTPException @@ -420,6 +421,9 @@ async def _lifespan(app: FastAPI): openapi_url="/api/openapi.json", lifespan=_lifespan) app.state.mcp_over_http = _mcp_asgi is not None app.add_middleware(_RequestBodyLimitMiddleware) + # Complete all-node scenes are the largest dashboard response. Compress them (and any other + # sizeable JSON/static response) before they cross the browser boundary. + app.add_middleware(GZipMiddleware, minimum_size=1000) # Honour the advertised allow-list on the actual GA dashboard entrypoint. A # wildcard can never carry browser credentials. diff --git a/engraphis/dashboard_assets/engraphis-graph-all.js b/engraphis/dashboard_assets/engraphis-graph-all.js index cd3a4854..5efd8c49 100644 --- a/engraphis/dashboard_assets/engraphis-graph-all.js +++ b/engraphis/dashboard_assets/engraphis-graph-all.js @@ -3,7 +3,7 @@ geometry, and a bounded overlay communicates relation direction without moving nodes. */ (function () { 'use strict'; - const WORKER_URL = '/v2-assets/engraphis-graph-worker.js?v=20260817-all-nodes-lod-3'; + const WORKER_URL = '/v2-assets/engraphis-graph-worker.js?v=20260815-merge-ready-1'; const MAX_NODES = 20000; const MAX_LINKS = 200000; const FLOW_EDGE_LIMIT = 900; @@ -15,6 +15,22 @@ classic: ['#9ab2c7', '#839db2', '#b0a4c8', '#7aa7a6', '#c0aa7b', '#8aa6c9'], }; const TYPE_COLORS = { person_or_concept: '#8d82e3', mention: '#5ba1a6', hashtag: '#c9a15b', email: '#8eb3e6', organization: '#d48173', location: '#7ebf8e', memory: '#5ba1a6', repo: '#c9a15b', file: '#8eb3e6' }; + const LIGHT_CLASSIC_PALETTE = ['#455d72', '#526a7d', '#625878', '#426b6a', '#75623d', '#4f6685']; + const LIGHT_CLASSIC_TYPE_COLORS = { person_or_concept: '#5146a1', mention: '#2f6f73', hashtag: '#725716', email: '#35658f', organization: '#8a4a3f', location: '#397147', memory: '#2f6f73', repo: '#725716', file: '#35658f' }; + const DARK_GRAPH_PAINT = { + canvasEdge: 'rgba(124,163,183,0.17)', canvasBridge: 'rgba(244,211,127,0.62)', + webglEdge: '#638fa6', webglBridge: '#f4d37f', webglOpacity: 0.2, + focus: '#f4d37f', label: 'rgba(224,236,241,0.86)', + flow: 'rgba(115,220,239,0.72)', flowBridge: 'rgba(255,220,132,0.88)', + flowComposite: 'lighter', + }; + const LIGHT_CLASSIC_PAINT = { + canvasEdge: '#66757e', canvasBridge: '#7a5a12', + webglEdge: '#66757e', webglBridge: '#7a5a12', webglOpacity: 1, + focus: '#5c50b7', label: '#202126', + flow: '#1f6775', flowBridge: '#7a5a12', + flowComposite: 'source-over', + }; const PRESETS = { galaxy: { repel: 60, link: 8, gravity: 48, font: 12, size: 3, linkw: 0.72, labelDensity: 24 }, original: { repel: 120, link: 30, gravity: 14, font: 13, size: 3, linkw: 1, labelDensity: 40 }, @@ -28,6 +44,18 @@ const clamp = (value, low, high) => Math.max(low, Math.min(high, value)); const color = value => /^#[0-9a-f]{6}$/i.test(String(value || '')) ? String(value) : '#86a8bf'; const rgb = value => { const text = color(value).slice(1); return [parseInt(text.slice(0, 2), 16) / 255, parseInt(text.slice(2, 4), 16) / 255, parseInt(text.slice(4, 6), 16) / 255]; }; + function isLightColor(value) { + const match = /^#([0-9a-f]{6})$/i.exec(String(value || '')); + if (!match) return false; + const packed = parseInt(match[1], 16); + const linear = channel => { + const normalized = channel / 255; + return normalized <= 0.04045 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4; + }; + return 0.2126 * linear((packed >> 16) & 255) + + 0.7152 * linear((packed >> 8) & 255) + + 0.0722 * linear(packed & 255) > 0.5; + } function create(element, options) { if (!element) throw new Error('all graph renderer requires a host element'); @@ -44,13 +72,33 @@ edgeVertexPositions: new Float32Array(0), edgeColors: new Float32Array(0), edgeVertexCount: 0, nodeColors: new Float32Array(0), nodeSizes: new Float32Array(0), bounds: null, camera: { x: 0, y: 0, scale: 1 }, width: 1, height: 1, dpr: 1, styleName: opts.style || 'cyber', colorBy: 'community', typeColors: {}, settings: { labels: true, flow: false, flowSpeed: 45, frozen: false, mode: 'communities', repel: 48, link: 16, gravity: 48, font: 12, size: 3, linkw: 0.72, labelDensity: 24 }, - palette: 'theme', themeColors: {}, layers: null, sizeBy: 'degree', bridges: true, ghosts: true, + palette: 'theme', themeColors: {}, lightSurface: false, layers: null, sizeBy: 'degree', bridges: true, ghosts: true, scope: { minDegree: 1, showUnlinked: true, depth: 2 }, collapse: false, collapsed: false, focus: -1, hover: -1, ready: false, totalLinks: 0, drawnLinks: 0, visibleNodeCount: 0, - frame: 0, flowPaintAt: 0, layoutPending: false, hitRequest: 0, drag: null, destroyed: false, error: null, + lodTier: 'medium', frame: 0, flowPaintAt: 0, layoutPending: false, + hitRequest: 0, drag: null, destroyed: false, error: null, }; let nodeProgram = null, edgeProgram = null, nodeBuffers = {}, edgeBuffers = {}; let hitFrame = 0, pendingHit = null, layoutFrame = 0, pendingLayoutFit = false; + let cameraRevision = 0, cameraInFlight = 0, pendingCamera = null; + let readyResolve = null, readyReject = null, readyPromise = Promise.resolve(); + function renewReadyPromise() { + readyPromise = new Promise((resolve, reject) => { + readyResolve = resolve; + readyReject = reject; + }); + /* Lifecycle consumers may opt out; keep worker errors observable without an unhandled + rejection in embedders that only use callbacks. */ + readyPromise.catch(() => {}); + } + function settleReady(error) { + const resolve = readyResolve, reject = readyReject; + readyResolve = null; + readyReject = null; + if (error) { + if (reject) reject(error); + } else if (resolve) resolve(api); + } const reducedMotion = () => { if (typeof opts.reducedMotion === 'function') return opts.reducedMotion() === true; if (opts.reducedMotion === true) return true; @@ -84,7 +132,10 @@ if (state.ready) camera(); else schedule(); } function nodeAt(index) { return { id: state.ids[index], label: state.labels[index] || state.ids[index], type: state.types[index] || 'person_or_concept' }; } + const usesLightClassicPaint = () => state.styleName === 'classic' && state.lightSurface; + const graphPaint = () => usesLightClassicPaint() ? LIGHT_CLASSIC_PAINT : DARK_GRAPH_PAINT; function activePalette() { + if (usesLightClassicPaint() && (state.palette === 'theme' || state.palette === 'ocean')) return LIGHT_CLASSIC_PALETTE; if (state.palette === 'ember') return PALETTES.solar; if (state.palette === 'ocean') return PALETTES.classic; if (state.palette === 'contrast') return ['#ffffff', '#8fe8ff', '#ffd166', '#ff7aa2', '#b9ffb0', '#d6b3ff']; @@ -93,7 +144,8 @@ } function nodeColor(index) { const item = nodeAt(index); - const themed = state.typeColors[item.type] || state.themeColors[item.type] || TYPE_COLORS[item.type]; + const fallback = usesLightClassicPaint() ? LIGHT_CLASSIC_TYPE_COLORS[item.type] : TYPE_COLORS[item.type]; + const themed = state.typeColors[item.type] || state.themeColors[item.type] || fallback; if (state.colorBy === 'type' || state.palette === 'custom') return color(themed || item.color); const palette = activePalette(); if (state.colorBy === 'connections') return palette[Math.min(5, Math.floor(Math.log1p(state.degrees[index] || 0) * 1.5))]; @@ -140,11 +192,11 @@ const edgeVertex = `#version 300 es in vec2 a_position;in vec3 a_color;uniform vec2 u_camera;uniform float u_scale;uniform vec2 u_resolution;out vec3 v_color;void main(){vec2 px=(a_position-u_camera)*u_scale+u_resolution*0.5;vec2 clip=px/u_resolution*2.0-1.0;gl_Position=vec4(clip.x,-clip.y,0.0,1.0);v_color=a_color;}`; const edgeFragment = `#version 300 es - precision mediump float;in vec3 v_color;out vec4 outputColor;void main(){outputColor=vec4(v_color,0.2);}`; + precision mediump float;in vec3 v_color;uniform float u_opacity;out vec4 outputColor;void main(){outputColor=vec4(v_color,u_opacity);}`; nodeProgram = program(vertex, fragment); edgeProgram = program(edgeVertex, edgeFragment); nodeBuffers.position = gl.createBuffer(); nodeBuffers.color = gl.createBuffer(); nodeBuffers.size = gl.createBuffer(); edgeBuffers.position = gl.createBuffer(); edgeBuffers.color = gl.createBuffer(); nodeBuffers.attrs = { position: gl.getAttribLocation(nodeProgram, 'a_position'), color: gl.getAttribLocation(nodeProgram, 'a_color'), size: gl.getAttribLocation(nodeProgram, 'a_size'), camera: gl.getUniformLocation(nodeProgram, 'u_camera'), scale: gl.getUniformLocation(nodeProgram, 'u_scale'), resolution: gl.getUniformLocation(nodeProgram, 'u_resolution') }; - edgeBuffers.attrs = { position: gl.getAttribLocation(edgeProgram, 'a_position'), color: gl.getAttribLocation(edgeProgram, 'a_color'), camera: gl.getUniformLocation(edgeProgram, 'u_camera'), scale: gl.getUniformLocation(edgeProgram, 'u_scale'), resolution: gl.getUniformLocation(edgeProgram, 'u_resolution') }; + edgeBuffers.attrs = { position: gl.getAttribLocation(edgeProgram, 'a_position'), color: gl.getAttribLocation(edgeProgram, 'a_color'), camera: gl.getUniformLocation(edgeProgram, 'u_camera'), scale: gl.getUniformLocation(edgeProgram, 'u_scale'), resolution: gl.getUniformLocation(edgeProgram, 'u_resolution'), opacity: gl.getUniformLocation(edgeProgram, 'u_opacity') }; } catch (error) { nodeProgram = edgeProgram = null; if (window.console && console.warn) console.warn('All-node WebGL2 unavailable; using flat Canvas.', error); } } function updateNodes() { @@ -170,23 +222,29 @@ gl.bindBuffer(gl.ARRAY_BUFFER, nodeBuffers.size); gl.bufferData(gl.ARRAY_BUFFER, state.nodeSizes, gl.DYNAMIC_DRAW); } function drawCanvas() { - if (!labelContext) return; labelContext.setTransform(state.dpr, 0, 0, state.dpr, 0, 0); labelContext.clearRect(0, 0, state.width, state.height); labelContext.strokeStyle = 'rgba(124,163,183,0.17)'; labelContext.lineWidth = Math.max(0.35, Number(state.settings.linkw || 0.72)) * (state.camera.scale < 1 ? 0.65 : 1); labelContext.beginPath(); + if (!labelContext) return; + const paint = graphPaint(); + labelContext.setTransform(state.dpr, 0, 0, state.dpr, 0, 0); + labelContext.clearRect(0, 0, state.width, state.height); + labelContext.strokeStyle = paint.canvasEdge; + labelContext.lineWidth = Math.max(0.35, Number(state.settings.linkw || 0.72)) * (state.camera.scale < 1 ? 0.65 : 1); + labelContext.beginPath(); for (let index = 0; index < state.visibleEdges.length; index += 1) { const edge = state.visibleEdges[index]; if (state.bridges && state.edgeBridges[edge]) continue; const source = state.edgeSources[edge], target = state.edgeTargets[edge], a = screen(state.positions[source * 2], state.positions[source * 2 + 1]), b = screen(state.positions[target * 2], state.positions[target * 2 + 1]); labelContext.moveTo(a[0], a[1]); labelContext.lineTo(b[0], b[1]); } labelContext.stroke(); - if (state.bridges) { labelContext.strokeStyle = 'rgba(244,211,127,0.62)'; labelContext.beginPath(); for (let index = 0; index < state.visibleEdges.length; index += 1) { const edge = state.visibleEdges[index]; if (!state.edgeBridges[edge]) continue; const source = state.edgeSources[edge], target = state.edgeTargets[edge], a = screen(state.positions[source * 2], state.positions[source * 2 + 1]), b = screen(state.positions[target * 2], state.positions[target * 2 + 1]); labelContext.moveTo(a[0], a[1]); labelContext.lineTo(b[0], b[1]); } labelContext.stroke(); } + if (state.bridges) { labelContext.strokeStyle = paint.canvasBridge; labelContext.beginPath(); for (let index = 0; index < state.visibleEdges.length; index += 1) { const edge = state.visibleEdges[index]; if (!state.edgeBridges[edge]) continue; const source = state.edgeSources[edge], target = state.edgeTargets[edge], a = screen(state.positions[source * 2], state.positions[source * 2 + 1]), b = screen(state.positions[target * 2], state.positions[target * 2 + 1]); labelContext.moveTo(a[0], a[1]); labelContext.lineTo(b[0], b[1]); } labelContext.stroke(); } const visible = state.visibleNodes, compact = state.camera.scale < 0.55; for (let cursor = 0; cursor < visible.length; cursor += 1) { const index = visible[cursor], point = screen(state.positions[index * 2], state.positions[index * 2 + 1]); if (point[0] < -4 || point[0] > state.width + 4 || point[1] < -4 || point[1] > state.height + 4) continue; const radius = compact ? 1.3 : clamp(pointSize(index) * Math.min(1, state.camera.scale), 1, 7); labelContext.fillStyle = nodeColor(index); labelContext.fillRect(point[0] - radius, point[1] - radius, radius * 2, radius * 2); } } function updateEdges() { if (!gl || !edgeProgram) return; - const edges = state.visibleEdges, required = edges.length * 4; + const edges = state.visibleEdges, required = edges.length * 4, paint = graphPaint(); if (state.edgeVertexPositions.length < required) state.edgeVertexPositions = new Float32Array(required); gl.bindBuffer(gl.ARRAY_BUFFER, edgeBuffers.position); gl.bufferData(gl.ARRAY_BUFFER, state.edgeVertexPositions.subarray(0, required), gl.STREAM_DRAW); /* LINES consumes two vertices per relation, and an RGB attribute belongs to each vertex. */ if (state.edgeColors.length < edges.length * 6) state.edgeColors = new Float32Array(edges.length * 6); for (let index = 0; index < edges.length; index += 1) { const bridge = state.bridges && state.edgeBridges[edges[index]]; - const value = rgb(bridge ? '#f4d37f' : '#638fa6'), offset = index * 6; + const value = rgb(bridge ? paint.webglBridge : paint.webglEdge), offset = index * 6; state.edgeColors[offset] = value[0]; state.edgeColors[offset + 1] = value[1]; state.edgeColors[offset + 2] = value[2]; state.edgeColors[offset + 3] = value[0]; state.edgeColors[offset + 4] = value[1]; state.edgeColors[offset + 5] = value[2]; } @@ -196,7 +254,7 @@ function drawWebgl() { if (!gl || !nodeProgram || !state.ready) return false; gl.clearColor(0, 0, 0, 0); gl.clear(gl.COLOR_BUFFER_BIT); gl.enable(gl.BLEND); gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); const edges = state.visibleEdges; - if (edges.length) { gl.useProgram(edgeProgram); gl.bindBuffer(gl.ARRAY_BUFFER, edgeBuffers.position); gl.enableVertexAttribArray(edgeBuffers.attrs.position); gl.vertexAttribPointer(edgeBuffers.attrs.position, 2, gl.FLOAT, false, 0, 0); gl.bindBuffer(gl.ARRAY_BUFFER, edgeBuffers.color); gl.enableVertexAttribArray(edgeBuffers.attrs.color); gl.vertexAttribPointer(edgeBuffers.attrs.color, 3, gl.FLOAT, false, 0, 0); gl.uniform2f(edgeBuffers.attrs.camera, state.camera.x, state.camera.y); gl.uniform1f(edgeBuffers.attrs.scale, state.camera.scale * state.dpr); gl.uniform2f(edgeBuffers.attrs.resolution, canvas.width, canvas.height); gl.lineWidth(Math.max(1, Number(state.settings.linkw || 0.72) * state.dpr)); gl.drawArrays(gl.LINES, 0, state.edgeVertexCount); } + if (edges.length) { gl.useProgram(edgeProgram); gl.bindBuffer(gl.ARRAY_BUFFER, edgeBuffers.position); gl.enableVertexAttribArray(edgeBuffers.attrs.position); gl.vertexAttribPointer(edgeBuffers.attrs.position, 2, gl.FLOAT, false, 0, 0); gl.bindBuffer(gl.ARRAY_BUFFER, edgeBuffers.color); gl.enableVertexAttribArray(edgeBuffers.attrs.color); gl.vertexAttribPointer(edgeBuffers.attrs.color, 3, gl.FLOAT, false, 0, 0); gl.uniform2f(edgeBuffers.attrs.camera, state.camera.x, state.camera.y); gl.uniform1f(edgeBuffers.attrs.scale, state.camera.scale * state.dpr); gl.uniform2f(edgeBuffers.attrs.resolution, canvas.width, canvas.height); gl.uniform1f(edgeBuffers.attrs.opacity, graphPaint().webglOpacity); gl.lineWidth(Math.max(1, Number(state.settings.linkw || 0.72) * state.dpr)); gl.drawArrays(gl.LINES, 0, state.edgeVertexCount); } gl.useProgram(nodeProgram); gl.bindBuffer(gl.ARRAY_BUFFER, nodeBuffers.position); gl.enableVertexAttribArray(nodeBuffers.attrs.position); gl.vertexAttribPointer(nodeBuffers.attrs.position, 2, gl.FLOAT, false, 0, 0); gl.bindBuffer(gl.ARRAY_BUFFER, nodeBuffers.color); gl.enableVertexAttribArray(nodeBuffers.attrs.color); gl.vertexAttribPointer(nodeBuffers.attrs.color, 3, gl.FLOAT, false, 0, 0); gl.bindBuffer(gl.ARRAY_BUFFER, nodeBuffers.size); gl.enableVertexAttribArray(nodeBuffers.attrs.size); gl.vertexAttribPointer(nodeBuffers.attrs.size, 1, gl.FLOAT, false, 0, 0); gl.uniform2f(nodeBuffers.attrs.camera, state.camera.x, state.camera.y); gl.uniform1f(nodeBuffers.attrs.scale, state.camera.scale * state.dpr); gl.uniform2f(nodeBuffers.attrs.resolution, canvas.width, canvas.height); gl.drawArrays(gl.POINTS, 0, state.ids.length); return true; } function drawRelationFlow(now) { @@ -205,8 +263,9 @@ const moving = speed > 0 && !state.settings.frozen && !state.settings.orbitPaused && !reducedMotion(); const stride = Math.max(1, Math.ceil(state.visibleEdges.length / FLOW_EDGE_LIMIT)); + const paint = graphPaint(); labelContext.save(); - labelContext.globalCompositeOperation = 'lighter'; + labelContext.globalCompositeOperation = paint.flowComposite; for (let cursor = 0; cursor < state.visibleEdges.length; cursor += stride) { const offset = cursor * 4; const a = screen(state.edgeVertexPositions[offset], state.edgeVertexPositions[offset + 1]); @@ -219,7 +278,7 @@ : 0.68; const x = a[0] + (b[0] - a[0]) * phase, y = a[1] + (b[1] - a[1]) * phase; labelContext.fillStyle = state.bridges && state.edgeBridges[edge] - ? 'rgba(255,220,132,0.88)' : 'rgba(115,220,239,0.72)'; + ? paint.flowBridge : paint.flow; labelContext.beginPath(); labelContext.arc(x, y, state.camera.scale < 0.8 ? 1.15 : 1.65, 0, Math.PI * 2); labelContext.fill(); @@ -227,9 +286,15 @@ labelContext.restore(); } function drawLabels(clear = false, now = 0) { - if (!labelContext) return; labelContext.setTransform(state.dpr, 0, 0, state.dpr, 0, 0); if (clear) labelContext.clearRect(0, 0, state.width, state.height); drawRelationFlow(now); labelContext.save(); const focused = state.focus >= 0 ? state.focus : state.hover; - if (focused >= 0 && focused < state.ids.length) { const point = screen(state.positions[focused * 2], state.positions[focused * 2 + 1]); labelContext.beginPath(); labelContext.arc(point[0], point[1], clamp(7 + state.camera.scale * 2, 7, 15), 0, Math.PI * 2); labelContext.strokeStyle = '#f4d37f'; labelContext.lineWidth = 1.5; labelContext.stroke(); } - if (state.settings.labels) { labelContext.font = `${clamp(Number(state.settings.font || 12) + state.camera.scale * 1.5, 8, 24)}px ui-sans-serif,system-ui,sans-serif`; labelContext.textBaseline = 'middle'; labelContext.fillStyle = 'rgba(224,236,241,0.86)'; for (let index = 0; index < state.visibleLabels.length; index += 1) { const item = state.visibleLabels[index], point = screen(state.positions[item * 2], state.positions[item * 2 + 1]); labelContext.fillText(state.labels[item] || state.ids[item], point[0] + 6, point[1] - 6); } } + if (!labelContext) return; + const paint = graphPaint(); + labelContext.setTransform(state.dpr, 0, 0, state.dpr, 0, 0); + if (clear) labelContext.clearRect(0, 0, state.width, state.height); + drawRelationFlow(now); + labelContext.save(); + const focused = state.focus >= 0 ? state.focus : state.hover; + if (focused >= 0 && focused < state.ids.length) { const point = screen(state.positions[focused * 2], state.positions[focused * 2 + 1]); labelContext.beginPath(); labelContext.arc(point[0], point[1], clamp(7 + state.camera.scale * 2, 7, 15), 0, Math.PI * 2); labelContext.strokeStyle = paint.focus; labelContext.lineWidth = 1.5; labelContext.stroke(); } + if (state.settings.labels) { labelContext.font = `${clamp(Number(state.settings.font || 12) + state.camera.scale * 1.5, 8, 24)}px ui-sans-serif,system-ui,sans-serif`; labelContext.textBaseline = 'middle'; labelContext.fillStyle = paint.label; for (let index = 0; index < state.visibleLabels.length; index += 1) { const item = state.visibleLabels[index], point = screen(state.positions[item * 2], state.positions[item * 2 + 1]); labelContext.fillText(state.labels[item] || state.ids[item], point[0] + 6, point[1] - 6); } } labelContext.restore(); } function clearHover() { @@ -259,7 +324,31 @@ if (flowAnimating()) schedule(); } function schedule() { if (!state.destroyed && !state.paused && !state.frame) state.frame = raf(draw); } - function camera() { if (!state.ready) return; worker.postMessage({ type: 'camera', x: state.camera.x, y: state.camera.y, scale: state.camera.scale, width: state.width, height: state.height }); schedule(); } + function postCamera(snapshot) { + const revision = ++cameraRevision; + cameraInFlight = revision; + worker.postMessage({ type: 'camera', revision, ...snapshot }); + } + function camera() { + if (!state.ready || state.destroyed) return; + const snapshot = { + x: state.camera.x, y: state.camera.y, scale: state.camera.scale, + width: state.width, height: state.height, + }; + if (cameraInFlight) pendingCamera = snapshot; + else postCamera(snapshot); + schedule(); + } + function completeCamera(message) { + if (message.revision !== undefined && Number(message.revision) !== cameraInFlight) { + return false; + } + cameraInFlight = 0; + const next = pendingCamera; + pendingCamera = null; + if (next) postCamera(next); + return true; + } function postSettings(relayout, fitLayout = false) { if (!relayout) { worker.postMessage({ type: 'settings', settings: state.settings, relayout: false }); @@ -278,7 +367,7 @@ }); } function fit() { if (!state.positions.length) return; const bounds = state.bounds || { minX: state.positions[0], maxX: state.positions[0], minY: state.positions[1], maxY: state.positions[1] }; state.camera.x = (bounds.minX + bounds.maxX) / 2; state.camera.y = (bounds.minY + bounds.maxY) / 2; state.camera.scale = clamp(Math.min(state.width / Math.max(120, bounds.maxX - bounds.minX + 120), state.height / Math.max(120, bounds.maxY - bounds.minY + 120)), 0.03, 4); camera(); } - function stats(extra) { if (typeof opts.onStats === 'function') opts.onStats({ nodes: state.ids.length, visibleNodes: state.visibleNodeCount || state.visibleNodes.length, links: state.totalLinks, drawnLinks: state.drawnLinks, hiddenLinks: Math.max(0, state.totalLinks - state.drawnLinks), collapsed: state.collapsed, relationFlow: state.settings.flow === true, layoutPending: state.layoutPending, presentation: 'all', preset: 'All nodes · LOD', renderer: gl && nodeProgram ? 'webgl2' : 'canvas', ...extra }); } + function stats(extra) { if (typeof opts.onStats === 'function') opts.onStats({ nodes: state.ids.length, visibleNodes: state.visibleNodeCount || state.visibleNodes.length, links: state.totalLinks, drawnLinks: state.drawnLinks, hiddenLinks: Math.max(0, state.totalLinks - state.drawnLinks), collapsed: state.collapsed, lodTier: state.lodTier, relationFlow: state.settings.flow === true, layoutPending: state.layoutPending, presentation: 'all', preset: 'All nodes · LOD', renderer: gl && nodeProgram ? 'webgl2' : 'canvas', ...extra }); } /* Coalesce pointer samples to the display cadence. Otherwise a high-polling mouse can queue hundreds of obsolete worker hit tests behind the latest camera request. */ function requestHit(event) { @@ -307,6 +396,7 @@ error.code = error.code || 'GRAPH_WORKER'; state.error = { code: error.code, message: error.message }; state.ready = false; + settleReady(error); if (typeof opts.onError === 'function') opts.onError(error); } worker.addEventListener('error', handleWorkerFailure); @@ -318,6 +408,7 @@ const error = new Error(`All-node capacity exceeded: ${message.count.toLocaleString()} ${resource} (limit ${Number(message.limit || (resource === 'relations' ? MAX_LINKS : MAX_NODES)).toLocaleString()}). Filter the graph before loading all nodes.`); error.code = 'GRAPH_CAPACITY'; state.error = { code: error.code, message: error.message }; + settleReady(error); if (typeof opts.onError === 'function') opts.onError(error); return; } @@ -356,23 +447,30 @@ state.nodeVisible = new Uint8Array(state.ids.length); state.nodeVisible.fill(1); setVisibleNodes(drawableNodeIndices()); state.ready = true; + settleReady(); updateNodes(); fit(); if (typeof opts.onMetrics === 'function') opts.onMetrics(api.metrics()); stats({ progressive: true }); return; } + if (message.type === 'camera-ack') { + completeCamera(message); + return; + } if (message.type === 'visible') { + if (!completeCamera(message)) return; setVisibleNodes(message.nodes || state.visibleNodes); state.visibleEdges = message.edges || new Uint32Array(0); state.visibleLabels = message.labels || new Uint32Array(0); state.edgeVertexPositions = message.edgePositions || new Float32Array(0); state.drawnLinks = Number(message.drawnLinks || 0); - state.collapsed = message.collapsed === true; - updateEdges(); stats(); schedule(); + state.lodTier = message.lodTier || state.lodTier; + state.collapsed = state.collapse === false ? false : message.collapsed === true; + updateNodes(); updateEdges(); stats(); schedule(); return; } if (message.type === 'collapse') { - state.collapsed = message.value === true; + state.collapsed = state.collapse === false ? false : message.value === true; if (typeof opts.onCollapseChange === 'function') opts.onCollapseChange(state.collapsed); stats(); return; @@ -431,6 +529,11 @@ state.destroyed = true; state.paused = true; state.hitRequest += 1; + pendingCamera = null; + cameraInFlight = 0; + const destroyedError = new Error('All-node renderer was destroyed before it became ready.'); + destroyedError.code = 'GRAPH_DESTROYED'; + settleReady(destroyedError); pendingHit = null; if (hitFrame) { caf(hitFrame); hitFrame = 0; } if (layoutFrame) { caf(layoutFrame); layoutFrame = 0; } @@ -467,14 +570,15 @@ const api = { exportImageCanvas, apply(fn, shouldFit) { if (typeof fn === 'function') fn(api); if (shouldFit) fit(); return api; }, - setData(data) { if (state.destroyed) return api; const nodes = Array.isArray(data && data.nodes) ? data.nodes : [], links = Array.isArray(data && data.links) ? data.links : (data && data.edges) || []; state.ready = false; state.error = null; worker.postMessage({ type: 'prepare', payload: { nodes, links } }); return api; }, + setData(data) { if (state.destroyed) return api; const nodes = Array.isArray(data && data.nodes) ? data.nodes : [], links = Array.isArray(data && data.links) ? data.links : (data && data.edges) || []; state.ready = false; state.error = null; pendingCamera = null; cameraInFlight = 0; renewReadyPromise(); worker.postMessage({ type: 'prepare', payload: { nodes, links } }); return api; }, + whenReady() { return readyPromise; }, setRenderMode(value) { state.renderMode = value === 'full' ? 'full' : 'all'; return api; }, setPreset(value) { const preset = PRESETS[value] ? value : 'communities'; const next = { ...state.settings, ...PRESETS[preset], mode: preset }; state.settings = next; pendingLayoutFit = true; postSettings(true, true); updateNodes(); schedule(); return { ...next }; }, - setStyle(value) { state.styleName = value || state.styleName; element.setAttribute('data-graph-style', state.styleName); updateNodes(); schedule(); return api; }, + setStyle(value) { state.styleName = value || state.styleName; element.setAttribute('data-graph-style', state.styleName); updateNodes(); updateEdges(); schedule(); return api; }, setColorBy(value) { state.colorBy = value || state.colorBy; updateNodes(); schedule(); return api; }, setPalette(value) { state.palette = typeof value === 'string' ? value : state.palette; if (state.palette !== 'custom') state.typeColors = {}; updateNodes(); schedule(); return api; }, setTypeColors(value) { state.typeColors = value && typeof value === 'object' ? { ...state.typeColors, ...value } : {}; updateNodes(); schedule(); return api; }, - setThemeColors(value) { state.themeColors = value && typeof value === 'object' ? { ...value } : {}; updateNodes(); schedule(); return api; }, + setThemeColors(value) { state.themeColors = value && typeof value === 'object' ? { ...value } : {}; state.lightSurface = isLightColor(state.themeColors.canvas || state.themeColors.surface); updateNodes(); updateEdges(); schedule(); return api; }, setSettings(value) { const patch = value || {}; state.settings = { ...state.settings, ...patch }; state.flowPaintAt = 0; const relayout = Object.keys(patch).some(key => ['mode', 'repel', 'link', 'gravity', 'gravitationalConstant', 'localGravitationalConstant', 'blackHoleMass', 'damping', 'springStiffness'].includes(key)); postSettings(relayout); updateNodes(); camera(); schedule(); return api; }, setScope(value) { state.scope = value && typeof value === 'object' ? { ...state.scope, ...value } : { minDegree: 1, showUnlinked: true, depth: 2 }; worker.postMessage({ type: 'scope', scope: state.scope }); camera(); return api; }, setRepoFilter(value) { state.repoFilter = String(value || '').slice(0, 200); return api; }, diff --git a/engraphis/dashboard_assets/engraphis-graph-worker.js b/engraphis/dashboard_assets/engraphis-graph-worker.js index 1c682df9..735d101a 100644 --- a/engraphis/dashboard_assets/engraphis-graph-worker.js +++ b/engraphis/dashboard_assets/engraphis-graph-worker.js @@ -11,6 +11,9 @@ const CANVAS_HIGH_ZOOM_EDGE_LIMIT = 25000; const LABEL_LIMIT = 220; const CELL_SIZE = 48; + const FAR_ENTER = 0.35, FAR_EXIT = 0.45; + const MEDIUM_ENTER = 0.9, MEDIUM_EXIT = 1.2; + const FAR_NODE_BUDGET = 500, MEDIUM_NODE_BUDGET = 3000; const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5)); const state = { ids: [], labels: [], types: [], positions: new Float32Array(0), basePositions: new Float32Array(0), degrees: new Float32Array(0), betweenness: new Float32Array(0), evidenceMass: new Float32Array(0), nodeGhosts: new Uint8Array(0), @@ -18,12 +21,14 @@ edgeTargets: new Uint32Array(0), edgeStrength: new Float32Array(0), edgeLayers: [], edgeBridges: new Uint8Array(0), edgeGhosts: new Uint8Array(0), edgeOrder: new Uint32Array(0), edgeRank: new Uint32Array(0), adjacencyOffsets: new Uint32Array(0), adjacencyEdges: new Uint32Array(0), edgeSeen: new Uint32Array(0), edgeStamp: 0, + nodeSeen: new Uint32Array(0), nodeStamp: 0, allNodes: new Uint32Array(0), grid: new Map(), layers: null, focusIndex: -1, lastCameraKey: '', lastVisibleNodes: new Uint32Array(0), lastVisibleEdges: new Uint32Array(0), lastVisibleLabels: new Uint32Array(0), canvasFallback: false, showBridges: true, showGhosts: true, paintOrder: new Uint32Array(0), layoutSettings: {}, labelDensity: 24, scope: { minDegree: 1, showUnlinked: true, depth: 2 }, collapseMode: false, - collapsed: false, lastVisibleMask: new Uint8Array(0), layoutRevision: 0, + collapsed: false, lodTier: 'medium', lastVisibleMask: new Uint8Array(0), + layoutRevision: 0, }; const finite = (value, fallback) => Number.isFinite(Number(value)) ? Number(value) : fallback; const clamp = (value, low, high) => Math.max(low, Math.min(high, value)); @@ -53,6 +58,7 @@ state.layers ? JSON.stringify(state.layers) : '', state.scope.minDegree, state.scope.showUnlinked, state.scope.depth, state.collapseMode || '', state.showGhosts, + state.lodTier, ].join('|'); } function makePositions(nodes, groups) { @@ -85,7 +91,12 @@ function applyLayout(notify = false, fit = false) { if (!state.basePositions.length) return; const settings = state.layoutSettings || {}, mode = key(settings.mode || 'communities'); - const repel = Math.max(0, finite(settings.repel, 48)), link = Math.max(1, finite(settings.link, 16)); + const galaxyMode = mode === 'galaxy'; + /* Galaxy coordinates and hierarchy are server-authored. Each bounded refinement starts + from those coordinates, preserving the authored scene while allowing the shared force + controls to make a deterministic, hierarchy-preserving adjustment. */ + const repel = Math.max(0, finite(settings.repel, galaxyMode ? 60 : 48)); + const link = Math.max(1, finite(settings.link, galaxyMode ? 8 : 16)); const gravity = Math.max(0, finite(settings.gravity, 48)); const galacticGravity = Math.max(0, finite(settings.gravitationalConstant, 1)); const blackHoleMass = Math.max(0.1, finite(settings.blackHoleMass, 1)); @@ -96,19 +107,32 @@ /* The All profile stays deterministic and worker-only, but its controls are real forces: repel expands the initial envelope, link is the spring target below, and gravity pulls the settled result toward the global centre. The bounded passes are O(nodes + links). */ - const repelSpread = 0.58 + repel / 72; - const gravityTightening = 1 / (0.72 + gravity / 128 + galacticGravity * blackHoleMass * 0.05); - const spaceSpread = 0.86 + localGravity * 0.07 - Math.min(2, damping) * 0.035; - const spread = modeScale * clamp(repelSpread * gravityTightening * spaceSpread, 0.42, 3.2); + /* Galaxy's server coordinates are the neutral point for the full-node profile. Relative + controls keep the authored scene unchanged at the default preset while making every + exposed force a bounded refinement around that hierarchy. */ + const repelSpread = galaxyMode + ? (0.58 + repel / 72) / (0.58 + 60 / 72) + : 0.58 + repel / 72; + const gravityTightening = galaxyMode + ? (0.72 + 48 / 128 + 0.05) + / (0.72 + gravity / 128 + galacticGravity * blackHoleMass * 0.05) + : 1 / (0.72 + gravity / 128 + galacticGravity * blackHoleMass * 0.05); + const spaceSpread = galaxyMode + ? (0.86 + localGravity * 0.07 - Math.min(2, damping) * 0.035) + / (0.86 + 0.07 - 0.035) + : 0.86 + localGravity * 0.07 - Math.min(2, damping) * 0.035; + const linkSpread = galaxyMode ? 1 + (link - 8) * 0.01 : 1; + const spread = (galaxyMode ? 1 : modeScale) + * clamp(repelSpread * gravityTightening * spaceSpread * linkSpread, 0.42, 3.2); const baseBounds = makeBounds(state.basePositions), centerX = (baseBounds.minX + baseBounds.maxX) / 2, centerY = (baseBounds.minY + baseBounds.maxY) / 2; state.positions = new Float32Array(state.basePositions.length); for (let index = 0; index < state.basePositions.length; index += 2) { let x = state.basePositions[index] - centerX, y = state.basePositions[index + 1] - centerY; if (mode === 'radial') { const angle = Math.atan2(y, x), radius = Math.hypot(x, y) * spread; x = Math.cos(angle) * radius; y = Math.sin(angle) * radius; } else if (mode === 'constellation') { x *= spread; y = y * spread * 0.72 + Math.sin(index * GOLDEN_ANGLE + state.layoutRevision) * 8; } - else if (mode === 'galaxy') { const radius = Math.hypot(x, y) * spread, angle = Math.atan2(y, x) + radius * 0.0007; x = Math.cos(angle) * radius; y = Math.sin(angle) * radius * 0.72; } + else if (mode === 'galaxy') { x *= spread; y *= spread; } else { x *= spread; y *= spread; } - if (state.layoutRevision) { + if (state.layoutRevision && !galaxyMode) { const phase = (index / 2 + 1) * GOLDEN_ANGLE + state.layoutRevision * 0.73; const jitter = Math.min(10, 1.5 + link * 0.08); x += Math.cos(phase) * jitter; y += Math.sin(phase) * jitter; @@ -117,8 +141,10 @@ } if (state.edgeSources.length) { const nodeCount = state.positions.length / 2; - const desired = clamp(10 + link * 1.25, 14, 112); - const springForce = clamp(0.025 + spring * 0.018, 0.025, 0.2) + const desired = clamp((galaxyMode ? 20 : 10) + link * 1.25 + - (galaxyMode ? 8 * 1.25 : 0), 14, 112); + const springForce = clamp((galaxyMode ? Math.max(0, spring - 1) * 0.018 : 0.025 + spring * 0.018), + galaxyMode ? 0 : 0.025, 0.2) * (mode === 'compact' ? 1.22 : mode === 'original' ? 0.72 : 1); const settle = 1 / (1 + Math.min(12, damping) * 0.18); const passes = nodeCount > 12000 ? 1 : 2; @@ -137,7 +163,9 @@ delta[target * 2] -= dx * pull; delta[target * 2 + 1] -= dy * pull; counts[source] += 1; counts[target] += 1; } - const centrePull = clamp((gravity / 400 + galacticGravity * blackHoleMass * 0.035) * 0.05, 0, 0.075); + const centrePull = clamp((galaxyMode + ? (Math.abs(gravity - 48) / 400 + Math.abs(galacticGravity * blackHoleMass - 1) * 0.035) + : gravity / 400 + galacticGravity * blackHoleMass * 0.035) * 0.05, 0, 0.075); for (let index = 0; index < nodeCount; index += 1) { const offset = index * 2, divisor = Math.max(1, counts[index]); const x = state.positions[offset], y = state.positions[offset + 1]; @@ -181,8 +209,10 @@ state.positions = positions.slice(); state.layoutRevision = 0; state.lastVisibleMask = new Uint8Array(ids.length); - const communities = nodes.map(node => key(node && (node.community_id != null ? node.community_id : node.community))); - const types = nodes.map(node => key(node && (node.etype || node.type || 'person_or_concept'))); + const communities = nodes.map(node => key( + node && (node.community_id != null ? node.community_id : node.community))); + const types = nodes.map(node => key( + node && (node.etype || node.type || 'person_or_concept'))); const previewPositions = state.positions.slice(); const previewGhosts = nodeGhosts.slice(); self.postMessage({ type: 'preview', ids, labels, types, positions: previewPositions, communities, nodeGhosts: previewGhosts, bounds: makeBounds(state.positions), totalNodes: ids.length }, [previewPositions.buffer, previewGhosts.buffer]); @@ -200,8 +230,11 @@ order.forEach((edge, rank) => { edgeRank[edge] = rank; }); const betweenness = new Float32Array(ids.length), evidenceMass = new Float32Array(ids.length); nodes.forEach((node, index) => { - betweenness[index] = Math.max(0, finite(node && (node.betweenness || node.bridge_score), 0)); - evidenceMass[index] = Math.max(0, finite(node && (node.evidence_mass || node.evidenceMass || node.mass), degrees[index] || 0)); + betweenness[index] = Math.max(0, finite( + node && (node.betweenness || node.bridge_score || node.pagerank), 0)); + evidenceMass[index] = Math.max(0, finite( + node && (node.evidence_mass || node.evidenceMass || node.gravity_mass + || node.mass_score || node.mass), degrees[index] || 0)); }); state.ids = ids; state.labels = labels; state.types = types; state.degrees = degrees; state.betweenness = betweenness; state.evidenceMass = evidenceMass; state.nodeGhosts = nodeGhosts; state.communities = communities; state.edgeSources = new Uint32Array(edges.map(edge => edge.source)); state.edgeTargets = new Uint32Array(edges.map(edge => edge.target)); @@ -219,6 +252,7 @@ adjacencyEdges.set(segment, start); } state.adjacencyOffsets = adjacencyOffsets; state.adjacencyEdges = adjacencyEdges; state.edgeSeen = new Uint32Array(edges.length); state.edgeStamp = 0; + state.nodeSeen = new Uint32Array(ids.length); state.nodeStamp = 0; state.topNodes = new Uint32Array(Array.from({ length: ids.length }, (_v, index) => index).sort((a, b) => degrees[b] - degrees[a] || a - b)); state.allNodes = new Uint32Array(ids.length); for (let index = 0; index < ids.length; index += 1) state.allNodes[index] = index; rebuildPaintOrder(); @@ -279,6 +313,37 @@ } return new Uint32Array([...representatives.values()].sort((a, b) => a - b)); } + function resolveLodTier(scale) { + const current = state.lodTier; + if (current === 'far') { + if (scale < FAR_EXIT) return 'far'; + return scale >= MEDIUM_EXIT ? 'near' : 'medium'; + } + if (current === 'near') { + if (scale >= MEDIUM_ENTER) return 'near'; + return scale < FAR_ENTER ? 'far' : 'medium'; + } + if (scale < FAR_ENTER) return 'far'; + if (scale >= MEDIUM_EXIT) return 'near'; + return 'medium'; + } + function boundedNodes(values, limit) { + if (values.length <= limit) return values; + state.nodeStamp = (state.nodeStamp + 1) >>> 0; + if (!state.nodeStamp) { state.nodeSeen.fill(0); state.nodeStamp = 1; } + for (let index = 0; index < values.length; index += 1) { + state.nodeSeen[values[index]] = state.nodeStamp; + } + const retained = []; + const focused = state.focusIndex; + if (focused >= 0 && state.nodeSeen[focused] === state.nodeStamp) retained.push(focused); + for (let index = 0; index < state.topNodes.length && retained.length < limit; index += 1) { + const node = state.topNodes[index]; + if (node !== focused && state.nodeSeen[node] === state.nodeStamp) retained.push(node); + } + retained.sort((left, right) => left - right); + return new Uint32Array(retained); + } function visibleNodes(camera) { const scale = Math.max(0.01, finite(camera && camera.scale, 1)); const focused = focusMask(); @@ -289,36 +354,49 @@ } return new Uint32Array(result); }; - const shouldCollapse = state.focusIndex < 0 - && (state.collapseMode === true || (state.collapseMode === 'auto' && scale < 0.42)); - if (scale < 0.42) { + const shouldCollapse = state.focusIndex < 0 && ( + state.collapseMode === true + || (state.collapseMode === 'auto' && state.lodTier === 'far') + ); + if (state.lodTier === 'far') { const values = allVisibleNodes(); setCollapsed(shouldCollapse); - return shouldCollapse ? collapseRepresentatives(values) : values; + const representatives = shouldCollapse ? collapseRepresentatives(values) : values; + return boundedNodes(representatives, FAR_NODE_BUDGET); } - const width = Math.max(1, finite(camera && camera.width, 1)), height = Math.max(1, finite(camera && camera.height, 1)); + const width = Math.max(1, finite(camera && camera.width, 1)); + const height = Math.max(1, finite(camera && camera.height, 1)); const halfWidth = width / scale / 2 * 1.05, halfHeight = height / scale / 2 * 1.05; - const minCellX = Math.floor((finite(camera && camera.x, 0) - halfWidth) / CELL_SIZE), maxCellX = Math.floor((finite(camera && camera.x, 0) + halfWidth) / CELL_SIZE); - const minCellY = Math.floor((finite(camera && camera.y, 0) - halfHeight) / CELL_SIZE), maxCellY = Math.floor((finite(camera && camera.y, 0) + halfHeight) / CELL_SIZE); + const minCellX = Math.floor((finite(camera && camera.x, 0) - halfWidth) / CELL_SIZE); + const maxCellX = Math.floor((finite(camera && camera.x, 0) + halfWidth) / CELL_SIZE); + const minCellY = Math.floor((finite(camera && camera.y, 0) - halfHeight) / CELL_SIZE); + const maxCellY = Math.floor((finite(camera && camera.y, 0) + halfHeight) / CELL_SIZE); + let values; if (maxCellX - minCellX > 256 || maxCellY - minCellY > 256) { - const values = allVisibleNodes(); - setCollapsed(shouldCollapse); - return shouldCollapse ? collapseRepresentatives(values) : values; - } - const result = []; - for (let cellY = minCellY; cellY <= maxCellY; cellY += 1) for (let cellX = minCellX; cellX <= maxCellX; cellX += 1) { - const bucket = state.grid.get(`${cellX},${cellY}`); - if (bucket) bucket.forEach(index => { - if (nodeAllowed(index, focused)) result.push(index); - }); + values = allVisibleNodes(); + } else { + const result = []; + for (let cellY = minCellY; cellY <= maxCellY; cellY += 1) { + for (let cellX = minCellX; cellX <= maxCellX; cellX += 1) { + const bucket = state.grid.get(`${cellX},${cellY}`); + if (bucket) bucket.forEach(index => { + if (nodeAllowed(index, focused)) result.push(index); + }); + } + } + values = new Uint32Array(result); } - const values = new Uint32Array(result); setCollapsed(shouldCollapse); - return shouldCollapse ? collapseRepresentatives(values) : values; + if (shouldCollapse) values = collapseRepresentatives(values); + return state.lodTier === 'medium' + ? boundedNodes(values, MEDIUM_NODE_BUDGET) : values; } function visibleEdges(camera, nodes) { - const scale = Math.max(0.01, finite(camera && camera.scale, 1)); - const limit = scale < 0.42 ? LOW_ZOOM_EDGE_LIMIT : scale < 1.1 ? (state.canvasFallback ? CANVAS_MEDIUM_ZOOM_EDGE_LIMIT : MEDIUM_ZOOM_EDGE_LIMIT) : (state.canvasFallback ? CANVAS_HIGH_ZOOM_EDGE_LIMIT : HIGH_ZOOM_EDGE_LIMIT); + const tier = state.lodTier; + const limit = tier === 'far' ? LOW_ZOOM_EDGE_LIMIT + : tier === 'medium' + ? (state.canvasFallback ? CANVAS_MEDIUM_ZOOM_EDGE_LIMIT : MEDIUM_ZOOM_EDGE_LIMIT) + : (state.canvasFallback ? CANVAS_HIGH_ZOOM_EDGE_LIMIT : HIGH_ZOOM_EDGE_LIMIT); if (!limit) return new Uint32Array(0); const visible = new Uint8Array(state.ids.length); for (let index = 0; index < nodes.length; index += 1) visible[nodes[index]] = 1; @@ -345,7 +423,7 @@ return new Uint32Array(result); } function visibleLabels(camera, nodes) { - if (finite(camera && camera.scale, 1) < 0.9) return new Uint32Array(0); + if (state.lodTier === 'far') return new Uint32Array(0); const density = Math.max(0.25, Math.min(3, finite(state.labelDensity, 24) / 24)); const limit = Math.min(LABEL_LIMIT, Math.max(12, Math.floor(80 * finite(camera && camera.scale, 1) * density))), result = [], visible = new Uint8Array(state.ids.length); for (let index = 0; index < nodes.length; index += 1) visible[nodes[index]] = 1; @@ -353,7 +431,14 @@ return new Uint32Array(result); } function camera(message) { - const nextKey = cameraKey(message); if (nextKey === state.lastCameraKey) return; state.lastCameraKey = nextKey; + const scale = Math.max(0.01, finite(message && message.scale, 1)); + state.lodTier = resolveLodTier(scale); + const nextKey = cameraKey(message); + if (nextKey === state.lastCameraKey) { + self.postMessage({ type: 'camera-ack', revision: message && message.revision }); + return; + } + state.lastCameraKey = nextKey; const nodes = visibleNodes(message), edges = visibleEdges(message, nodes), labels = visibleLabels(message, nodes); const visibleMask = new Uint8Array(state.ids.length); for (let index = 0; index < nodes.length; index += 1) visibleMask[nodes[index]] = 1; @@ -361,9 +446,10 @@ for (let index = 0; index < edges.length; index += 1) { const edge = edges[index], source = state.edgeSources[edge], target = state.edgeTargets[edge], offset = index * 4; edgePositions[offset] = state.positions[source * 2]; edgePositions[offset + 1] = state.positions[source * 2 + 1]; edgePositions[offset + 2] = state.positions[target * 2]; edgePositions[offset + 3] = state.positions[target * 2 + 1]; } state.lastVisibleNodes = nodes; state.lastVisibleEdges = edges; state.lastVisibleLabels = labels; state.lastVisibleMask = visibleMask; - self.postMessage({ type: 'visible', nodes, edges, labels, edgePositions, - totalLinks: state.edgeSources.length, drawnLinks: edges.length, - visibleNodeCount: nodes.length, collapsed: state.collapsed }, + self.postMessage({ type: 'visible', revision: message && message.revision, + nodes, edges, labels, edgePositions, totalLinks: state.edgeSources.length, + drawnLinks: edges.length, visibleNodeCount: nodes.length, + collapsed: state.collapsed, lodTier: state.lodTier }, [nodes.buffer, edges.buffer, labels.buffer, edgePositions.buffer]); } function hit(message) { diff --git a/engraphis/dashboard_assets/engraphis-graph.js b/engraphis/dashboard_assets/engraphis-graph.js index 332011a1..62bb3333 100644 --- a/engraphis/dashboard_assets/engraphis-graph.js +++ b/engraphis/dashboard_assets/engraphis-graph.js @@ -154,8 +154,8 @@ black-hole sweep. Give independent community stars a 3.25x angular clock by multiplying their gravitational parameter by clock^2. Both the circular seed and every live inverse-square sample consume this same constant: the result is a faster bound central - orbit, not a per-frame carousel or an unbalanced tangential kick. The global anchor keeps - the original local scale because its surrounding bulge belongs to the black-hole well. */ + orbit, not a per-frame carousel or an unbalanced tangential kick. Direct global children + use the black-hole clock because their carrier seed and live well are the same field. */ const GALAXY_STELLAR_ORBIT_CLOCK = 3.25; const GALAXY_FALLBACK_STELLAR_ORBIT_CLOCK = 2.5; /* The dashboard's Gravity control owns the black-hole well. A saved zero value must not @@ -183,10 +183,33 @@ function galaxyLocalGravitySetting(setting, localSetting) { return localSetting === undefined ? setting : localSetting; } + function galaxyHasAuthoredParent(node, parent) { + return !!(node && parent && node.system_anchor_id !== undefined + && node.system_anchor_id !== null && String(node.system_anchor_id) !== '' + && String(node.system_anchor_id) === String(parent.id)); + } + function orderedGalaxyLocalOrbitMembers(members, carrier, byId) { + const lookup = byId || new Map((members || []).map(item => [String(item.id), item])); + const depths = new Map(); + const visiting = new Set(); + const depthOf = node => { + if (!node || node === carrier) return 0; + if (depths.has(node)) return depths.get(node); + if (visiting.has(node)) return 1; + visiting.add(node); + const parent = galaxyLocalOrbitParent(node, members, carrier, lookup); + const depth = parent && parent !== node ? depthOf(parent) + 1 : 1; + visiting.delete(node); + depths.set(node, depth); + return depth; + }; + return (members || []).slice().sort((left, right) => depthOf(left) - depthOf(right) + || String(left.id).localeCompare(String(right.id))); + } function galaxySystemGravityConstant(anchor, setting, localSetting, authoredHierarchy) { const effectiveLocalSetting = galaxyLocalGravitySetting(setting, localSetting); if (anchor && anchor.anchor_role === 'global') { - return galaxyBlackHoleGravityConstant(setting, true) * 0.5; + return galaxyBlackHoleGravityConstant(setting, true); } if (authoredHierarchy !== false) { return galaxyStellarGravityConstant(effectiveLocalSetting); @@ -208,7 +231,7 @@ const effectiveLocalSetting = galaxyLocalGravitySetting(gravity, localSetting); if (anchor && anchor.anchor_role === 'global') { return GALAXY_CENTER_ACCELERATION_CAP - * galaxyBlackHoleGravityConstant(gravity, true) * 0.5 / 24; + * galaxyBlackHoleGravityConstant(gravity, true) / 24; } if (authoredHierarchy !== false) { return defaultGalaxyStellarAccelerationCap(effectiveLocalSetting); @@ -427,6 +450,8 @@ near-horizon. This finite chart-space thickness keeps curvature local to the event horizon while the scale still controls smaller/custom black holes. */ const GALAXY_EVENT_HORIZON_BAND_LIMIT = 24; + /* Visual emphasis must not leak into collision, packing, or event-horizon geometry. */ + const GALAXY_BLACK_HOLE_PAINT_SCALE = 2; const GALAXY_EVENT_HORIZON_DECAY_RATE = 0.005; const GALAXY_EVENT_HORIZON_INWARD_ACCELERATION = 0.28; const GALAXY_TIDAL_STRENGTH_FRACTION = 0.18; @@ -670,54 +695,6 @@ return String(node && node.community !== undefined && node.community !== null ? node.community : 0); } - function setGalaxyBlackHoleChild(node, value) { - if (!node) return; - if (!value) { - try { delete node.__galaxyBlackHoleChild; } catch (_) { /* compatibility payload */ } - return; - } - try { - Object.defineProperty(node, '__galaxyBlackHoleChild', { - value: true, writable: true, configurable: true, enumerable: false, - }); - } catch (_) { - node.__galaxyBlackHoleChild = true; - } - } - /* A direct black-hole edge is only a compatibility hierarchy declaration when an older - payload lacks system_anchor_id. Current scenes author the parent explicitly; an ordinary - evidence edge to the black hole must never replace a community's declared central star. */ - function markGalaxyBlackHoleChildren(nodes, links) { - const values = Array.isArray(nodes) ? nodes : []; - const anchor = galaxyGlobalAnchor(values); - const connected = new Set(); - const endpointId = endpoint => endpoint && typeof endpoint === 'object' - ? endpoint.id : endpoint; - (Array.isArray(links) ? links : []).forEach(link => { - const source = endpointId(link && link.source); - const target = endpointId(link && link.target); - const anchorId = anchor ? String(anchor.id) : null; - if (anchorId === null) return; - if (String(source) === anchorId && target !== undefined && target !== null) { - connected.add(String(target)); - } else if (String(target) === anchorId && source !== undefined && source !== null) { - connected.add(String(source)); - } - }); - values.forEach(node => { - if (!node || node === anchor) return; - const declaredParent = node.system_anchor_id === undefined - || node.system_anchor_id === null ? '' : String(node.system_anchor_id); - const declaresBlackHole = anchor && declaredParent === String(anchor.id); - /* Relation wording remains irrelevant for legacy scenes, but authoritative scene - topology wins whenever it is present. This prevents one cross-system relation from - collapsing a complete solar system into the black-hole carrier group. */ - const isDirectChild = connected.has(String(node.id)) - && (!declaredParent || declaresBlackHole); - setGalaxyBlackHoleChild(node, isDirectChild); - }); - return values; - } function fallbackGravityMass(degree, maxDegree) { const normalized = Math.max(0, Math.min(1, finitePositive(degree, 0, Number.MAX_VALUE) / Math.max(1, Number(maxDegree) || 1))); @@ -759,11 +736,7 @@ const evidenceRadius = Math.max(0.8, Math.min(80 * scale, finitePositive(node && node.visual_radius, radiusFromGravityMass(node && node.gravity_mass), 64) * scale)); - /* The global evidence anchor is both the physical and visual black hole. Double only its - rendered/hit radius; gravity_mass remains canonical and community stars retain ordinary - evidence geometry. Adornments consume node.radius, so their halo follows this scale. */ - return node && !node.ghost && node.anchor_role === 'global' - ? evidenceRadius * 2 : evidenceRadius; + return evidenceRadius; } function seededHash(seed, value) { @@ -828,84 +801,77 @@ }); return centers; } + /* ``system_anchor_id`` is the hierarchy contract for authored Galaxy scenes. Community + fallback remains only for unannotated compatibility scenes; relation edges never promote + a node into the black-hole frame. */ function galaxyOrbitGroups(nodes) { + const values = Array.isArray(nodes) ? nodes : []; const groups = new Map(); const communityAnchors = new Map(); - const globalAnchor = (nodes || []).find(node => node && !node.ghost + const globalAnchor = values.find(node => node && !node.ghost && node.anchor_role === 'global'); - const blackHoleCommunities = new Set(); - const byId = new Map((nodes || []).filter(node => node && node.id !== undefined) + const globalId = globalAnchor ? String(globalAnchor.id) : ''; + const byId = new Map(values.filter(node => node && node.id !== undefined) .map(node => [String(node.id), node])); - (nodes || []).forEach(node => { - if (!node || node.ghost) return; + values.forEach(node => { + if (!node || node.ghost + || (node.anchor_role !== 'global' && node.anchor_role !== 'community')) return; const key = communityKey(node); - if (globalAnchor && (node.__galaxyBlackHoleChild === true - || String(node.system_anchor_id || '') === String(globalAnchor.id))) { - blackHoleCommunities.add(key); - } - if (node.anchor_role !== 'global' && node.anchor_role !== 'community') return; const existing = communityAnchors.get(key); - if (!existing || node.anchor_role === 'global') { - communityAnchors.set(key, { - id: String(node.id), global: node.anchor_role === 'global', - }); - } + if (!existing || node.anchor_role === 'global') communityAnchors.set(key, node); }); - (nodes || []).forEach(node => { + values.forEach(node => { if (!node || node.ghost || !Number.isFinite(node.x) || !Number.isFinite(node.y)) return; - const declared = communityAnchors.get(communityKey(node)); let root = node; let current = node; - const visited = new Set(); + const visited = new Set([String(node.id)]); while (current && current.system_anchor_id !== undefined && current.system_anchor_id !== null) { const parentId = String(current.system_anchor_id); - if (!parentId || parentId === String(current.id) - || (globalAnchor && parentId === String(globalAnchor.id)) - || visited.has(parentId)) break; - const parentNode = byId.get(parentId); - if (!parentNode) break; + if (!parentId || parentId === String(current.id) || visited.has(parentId)) break; + if (parentId === globalId) { + root = globalAnchor; + break; + } + const parent = byId.get(parentId); + if (!parent) break; visited.add(parentId); - root = parentNode; - current = parentNode; - } - /* Parent metadata can be absent on a filtered member. Infer the local star from its - community, then resolve nested planets/moons to the same top-level carrier. */ - const rootHasNoParent = root.system_anchor_id === undefined - || root.system_anchor_id === null || String(root.system_anchor_id) === String(root.id); - const rootCanUseCommunityFallback = rootHasNoParent && ( - (root.anchor_role !== 'global' && root.anchor_role !== 'community') - || (declared && declared.global)); - if (declared && declared.id !== root.id && rootCanUseCommunityFallback) { - const declaredNode = byId.get(String(declared.id)); - if (declaredNode) root = declaredNode; + root = parent; + current = parent; } + const hasExplicitParent = node.system_anchor_id !== undefined + && node.system_anchor_id !== null && String(node.system_anchor_id) !== ''; + if (!hasExplicitParent && root === node) { + const declared = communityAnchors.get(communityKey(node)); + /* A local community anchor is a safe compatibility parent. The global anchor is not: + sharing its display community must never imply black-hole ancestry. */ + if (declared && declared !== node && declared.anchor_role === 'community') root = declared; + } + let rootId = String(root.id); const rootParentId = root.system_anchor_id === undefined || root.system_anchor_id === null ? '' : String(root.system_anchor_id); - const rootIsBlackHoleChild = root.__galaxyBlackHoleChild === true - || (globalAnchor && rootParentId === String(globalAnchor.id)); - const rootIsGlobal = globalAnchor && String(root.id) === String(globalAnchor.id); - const hasExplicitSystemAnchor = node.system_anchor_id !== undefined - && node.system_anchor_id !== null && String(node.system_anchor_id) !== ''; - const compatibilityCommunityRoot = root === node && !hasExplicitSystemAnchor && !declared - && node.anchor_role !== 'global' && node.anchor_role !== 'community'; - const rootKey = compatibilityCommunityRoot ? communityKey(node) : String(root.id); - const followsBlackHoleCommunity = globalAnchor - && blackHoleCommunities.has(communityKey(node)); - const key = globalAnchor && (rootIsGlobal || rootIsBlackHoleChild - || followsBlackHoleCommunity) - ? String(globalAnchor.id) : rootKey; + if (globalAnchor && (root === globalAnchor || rootParentId === globalId)) { + rootId = globalId; + } else if (root === node && !hasExplicitParent + && node.anchor_role !== 'global' && node.anchor_role !== 'community') { + rootId = communityKey(node); + } const mass = finitePositive(node.gravity_mass, 1, 1000); - let group = groups.get(key); + let group = groups.get(rootId); if (!group) { - group = { id: key, mass: 0, x: 0, y: 0, nodes: [] }; - groups.set(key, group); + group = { id: rootId, mass: 0, x: 0, y: 0, nodes: [] }; + groups.set(rootId, group); } - group.mass += mass; group.x += node.x * mass; group.y += node.y * mass; + group.mass += mass; + group.x += node.x * mass; + group.y += node.y * mass; group.nodes.push(node); }); groups.forEach(group => { - if (group.mass > 0) { group.x /= group.mass; group.y /= group.mass; } + if (group.mass > 0) { + group.x /= group.mass; + group.y /= group.mass; + } }); return groups; } @@ -970,85 +936,26 @@ if (inferred && inferred.node !== node) return inferred.node; return carrier && carrier !== node ? carrier : null; } - function galaxyHasAuthoredParent(node, parent) { - return !!(node && parent && node.system_anchor_id !== undefined - && node.system_anchor_id !== null && String(node.system_anchor_id) !== '' - && String(node.system_anchor_id) === String(parent.id)); - } - /* Local velocity repair is hierarchical: a moon must see the already-repaired velocity of - its planet, and a planet must see the already-repaired velocity of its star. Payload order - is not a hierarchy (filtered/API responses commonly put children first), so all callers - that mutate orbital phase use this stable parent-before-child order. */ - function orderedGalaxyLocalOrbitMembers(members, carrier, byId) { - const lookup = byId || new Map((members || []).map(item => [String(item.id), item])); - const depths = new Map(); - const visiting = new Set(); - const depthOf = node => { - if (!node || node === carrier) return 0; - if (depths.has(node)) return depths.get(node); - if (visiting.has(node)) return 1; - visiting.add(node); - const parent = galaxyLocalOrbitParent(node, members, carrier, lookup); - const depth = parent && parent !== node ? depthOf(parent) + 1 : 1; - visiting.delete(node); - depths.set(node, depth); - return depth; - }; - return (members || []).slice().sort((left, right) => depthOf(left) - depthOf(right) - || String(left.id).localeCompare(String(right.id))); - } - /* A community anchor can itself be an explicit black-hole satellite. Keep its declared - stellar children in the same central carrier group so support translates the local system - together instead of leaving the planet group to orbit its already-detached star. */ + /* Split the global group into its authoritative top-level carrier trees. */ function galaxyBlackHoleCoreSystems(members, globalAnchor) { const values = (members || []).filter(node => node && node !== globalAnchor); const byId = new Map(values.map(node => [String(node.id), node])); - const communityAnchors = new Map(); - values.forEach(node => { - if (!node || (node.anchor_role !== 'community' - && node.__galaxyBlackHoleChild !== true)) return; - const key = communityKey(node); - const previous = communityAnchors.get(key); - if (!previous || finitePositive(node.gravity_mass, 1, 1000) - > finitePositive(previous.gravity_mass, 1, 1000) - || (finitePositive(node.gravity_mass, 1, 1000) - === finitePositive(previous.gravity_mass, 1, 1000) - && String(node.id).localeCompare(String(previous.id)) < 0)) { - communityAnchors.set(key, node); - } - }); + const globalId = String(globalAnchor && globalAnchor.id); const groups = new Map(); values.forEach(node => { let root = node; let current = node; - let followedExplicitParent = false; - const nodeParentId = node.system_anchor_id === undefined - || node.system_anchor_id === null ? '' : String(node.system_anchor_id); - const directlyFollowsBlackHole = node.__galaxyBlackHoleChild === true - || nodeParentId === String(globalAnchor && globalAnchor.id); - const visited = new Set(); + const visited = new Set([String(node.id)]); while (current && current.system_anchor_id !== undefined && current.system_anchor_id !== null) { const parentId = String(current.system_anchor_id); if (!parentId || parentId === String(current.id) - || parentId === String(globalAnchor && globalAnchor.id) - || visited.has(parentId)) break; - visited.add(parentId); + || parentId === globalId || visited.has(parentId)) break; const parent = byId.get(parentId); if (!parent) break; + visited.add(parentId); root = parent; current = parent; - followedExplicitParent = true; - } - /* Older/filtered payloads often retain the community anchor but omit the per-node - system_anchor_id. In a black-hole carrier group, that omission must not turn every - planet into an independent BH satellite: infer the local star from its community. */ - /* Two direct black-hole children are peer galactic carriers even when an old payload gives - them the same community label. Community fallback is only for a descendant whose local - parent metadata is missing; it must never turn direct BH siblings into one solar frame. */ - if (!followedExplicitParent && !directlyFollowsBlackHole) { - const communityAnchor = communityAnchors.get(communityKey(node)); - if (communityAnchor && communityAnchor !== node) root = communityAnchor; } const key = String(root.id); if (!groups.has(key)) groups.set(key, []); @@ -1218,10 +1125,9 @@ const orbitalRadius = galaxyOrbitalRadiusMultiplier(opts.orbitalSpeed); const speedControlEnabled = opts.restorePhase !== true && Number.isFinite(Number(opts.orbitalSpeed)); - /* Core-community satellites are local children of the explicit black hole. Admit only - those that begin inside its painted horizon before taking a star-relative radius sample; - the generic system seed below then gives them the ordinary BH-relative circular tangent. - A pointer-owned node remains exact and is intentionally left for the drag/horizon path. */ + /* Direct children of the explicit black hole use compact physical lanes before the + generic system seed supplies their ordinary black-hole-relative circular tangent. + A pointer-owned node remains exact and is left for the drag/horizon path. */ const blackHole = (nodes || []).find(node => node && !node.ghost && node.anchor_role === 'global' && Number.isFinite(node.x) && Number.isFinite(node.y)); if (blackHole) { @@ -1229,8 +1135,7 @@ evidenceNodeRadius(blackHole, 3), 160); const coreSatellites = (nodes || []).filter(node => node && node !== blackHole && !node.ghost && node.id !== opts.fixedNodeId - && (String(node.system_anchor_id || '') === String(blackHole.id) - || node.__galaxyBlackHoleChild === true) + && String(node.system_anchor_id || '') === String(blackHole.id) && Number.isFinite(node.x) && Number.isFinite(node.y)); /* Coincident core children used to inherit the farthest authored distance, then every child was placed on that same distant ring. Admit compact black-hole lanes instead: @@ -1969,28 +1874,19 @@ childrenByParent.get(parent).push(node); }); childrenByParent.forEach((satellites, parent) => { - /* The live black-hole field owns an explicitly declared direct-BH carrier. Legacy - payloads can still contain a global anchor with an unannotated local satellite; that - shape is a standalone two-body system and must retain its local circular well. */ + /* The global potential owns every explicitly declared direct child. */ const skipGlobalParent = parent.anchor_role === 'global' && (opts.skipGlobalParent === true || (opts.allowGlobalParent !== true - && satellites.some(satellite => satellite.__galaxyBlackHoleChild === true - || (satellite.system_anchor_id !== undefined - && satellite.system_anchor_id !== null - && String(satellite.system_anchor_id) === String(parent.id))))); + && satellites.some(satellite => satellite.system_anchor_id !== undefined + && satellite.system_anchor_id !== null + && String(satellite.system_anchor_id) === String(parent.id)))); if (skipGlobalParent) return; const parentMass = finitePositive(parent.gravity_mass, 1, 1000); const authoredHierarchy = satellites.some(satellite => galaxyHasAuthoredParent(satellite, parent)); const parentGravityMultiplier = galaxyLocalGravityMultiplier(parent, opts); - const explicitLegacyGlobalPair = parent.anchor_role === 'global' - && opts.central === false && satellites.some(satellite => - satellite.system_anchor_id !== undefined - && satellite.system_anchor_id !== null - && String(satellite.system_anchor_id) === String(parent.id)); const parentGravity = galaxySystemGravityConstant(parent, opts.gravity, - localGravitySetting, authoredHierarchy) - * parentGravityMultiplier * (explicitLegacyGlobalPair ? 1.1 : 1); + localGravitySetting, authoredHierarchy) * parentGravityMultiplier; satellites.sort((left, right) => Number(left.orbit_tier || 0) - Number(right.orbit_tier || 0) || String(left.id).localeCompare(String(right.id))); satellites.forEach(satellite => { @@ -4703,7 +4599,6 @@ .filter(value => value !== undefined && value !== null).map(String)); const anchor = members.find(node => node.id === opts.fixedNodeId) || (resolvedAnchor && (resolvedAnchor.anchor_role === 'community' - || resolvedAnchor.__galaxyBlackHoleChild === true || declaredIds.has(String(resolvedAnchor.id))) ? resolvedAnchor : null); let referenceVx = 0, referenceVy = 0; if (anchor) { @@ -4731,9 +4626,7 @@ maximumRelativeSpeed = Math.max(maximumRelativeSpeed, systemMaximum); /* A planet's local tangent rides on top of the star's galactic carrier velocity. The carrier is the primary orbit: preserve it whenever it is inside the emergency ceiling, - and clamp only the local frame to the remaining vector budget. The old implementation - did the reverse (scaled the carrier after local motion consumed the budget), which made - a solar system spin around its star while its star stopped orbiting the black hole. */ + and clamp only the local frame to the remaining vector budget. */ let carrierAdjusted = false; if (anchor && Number.isFinite(absoluteLimit)) { const carrierSpeed = Math.hypot(referenceVx, referenceVy); @@ -4741,8 +4634,6 @@ if (systemMaximum > 1e-12) { scale = Math.min(scale, carrierAllowance / systemMaximum); } - /* Only an already-invalid carrier may be reduced. Supported galaxy lanes are well - below this ceiling, so this is an emergency guard rather than an orbital controller. */ if (carrierSpeed > absoluteLimit + 1e-12) { const carrierScale = carrierSpeed > 1e-12 ? absoluteLimit / carrierSpeed : 0; const targetVx = referenceVx * carrierScale; @@ -7395,7 +7286,8 @@ if (!ctx || !node || !Number.isFinite(node.x) || !Number.isFinite(node.y)) return 0; const role = node.anchor_role; if (role !== 'global' && role !== 'community') return 0; - const radius = finitePositive(node.radius, 3, 160); + const radius = finitePositive(node.radius, 3, 160) + * (role === 'global' ? GALAXY_BLACK_HOLE_PAINT_SCALE : 1); const color = accent || node.color || '#9d7bff'; const inverseScale = 1 / Math.max(0.1, Number(scale) || 1); if (role === 'community') { @@ -8270,10 +8162,17 @@ } } + function nodePaintRadius(node) { + const radius = Number(node && node.radius); + if (!Number.isFinite(radius)) return 0; + return radius * (state.settings.mode === 'galaxy' && node.anchor_role === 'global' + ? GALAXY_BLACK_HOLE_PAINT_SCALE : 1); + } + 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; + let r = nodePaintRadius(node); const col = node.color; const spacetimeFade = state.settings.mode === 'galaxy' && node.anchor_role !== 'global' ? 1 - 0.55 * Math.max(0, Math.min(1, Number(node.__galaxySpacetimeWarp) || 0)) @@ -9231,34 +9130,23 @@ if (staticFullLayout) { if (galaxyMode) { pinGalaxySceneLayout(data); - /* Oversized Galaxy scenes skip the live admission branch, but their direct - black-hole children still need compact core lanes before the O(n) kinematic - clock starts. Keep the nodes pinned to the newly admitted coordinates. */ - markGalaxyBlackHoleChildren(data.nodes, data.links); seedGalaxyOrbits( data.nodes, raw.meta && raw.meta.layout_seed, state.settings.gravity, galaxyLiveSoftening(), reducedMotion, { fixedNodeId: activeDragNode ? activeDragNode.id : null, restorePhase: galaxyPhaseRestorePending, coreOnly: true, - orbitalSpeed: state.settings.repel, - gravitationalConstant: state.settings.gravitationalConstant, - localGravitationalConstant: state.settings.localGravitationalConstant, - localGravitySetting: GALAXY_STELLAR_GRAVITY_FLOOR_SETTING } + orbitalSpeed: state.settings.repel, + gravitationalConstant: state.settings.gravitationalConstant, + localGravitationalConstant: state.settings.localGravitationalConstant, + localGravitySetting: GALAXY_STELLAR_GRAVITY_FLOOR_SETTING } ); } else pinFullGraphLayout(data); fullLayoutDirty = false; } else if (galaxyMode) { - /* Canonical v5 scenes already carry compact deterministic coordinates. Compatibility - payloads and direct embeds may not: D3 is intentionally disabled in Galaxy mode, - so fill only those missing positions before the one-shot orbital seed. Finite - server coordinates are preserved byte-for-byte by ensureGalaxyPositions(). */ + /* Preserve finite server coordinates; synthesize positions only for malformed embeds. */ ensureGalaxyPositions(data.nodes, raw.meta && raw.meta.layout_seed); releasePinnedPositions(data); - markGalaxyBlackHoleChildren(data.nodes, data.links); - /* Fresh server coordinates may contain dozens of mutually intersecting complete - systems. Pack them once in open space before any carrier velocity or finite outer - envelope is cached; the later field is then sized from the already-clear scene. */ const authoredGalaxy = data.nodes.some(node => node.anchor_role === 'global') && data.nodes.filter(node => node.anchor_role === 'community').length > 1; if (authoredGalaxy) { @@ -9281,7 +9169,7 @@ orbitalSpeed: state.settings.repel, gravitationalConstant: state.settings.gravitationalConstant, localGravitationalConstant: state.settings.localGravitationalConstant, - localGravitySetting: GALAXY_STELLAR_GRAVITY_FLOOR_SETTING } + localGravitySetting: GALAXY_STELLAR_GRAVITY_FLOOR_SETTING } ); seedGalaxySystemOrbits( data.nodes, raw.meta && raw.meta.layout_seed, @@ -9351,7 +9239,6 @@ const skipGalaxyReseed = preserveGalaxyPhaseOnResume; preserveGalaxyPhaseOnResume = false; if (reused && galaxyMode && !staticFullLayout && !skipGalaxyReseed) { - markGalaxyBlackHoleChildren(data.nodes, data.links); seedGalaxyOrbits( data.nodes, raw.meta && raw.meta.layout_seed, state.settings.gravity, galaxyLiveSoftening(), reducedMotion, @@ -9360,7 +9247,7 @@ orbitalSpeed: state.settings.repel, gravitationalConstant: state.settings.gravitationalConstant, localGravitationalConstant: state.settings.localGravitationalConstant, - localGravitySetting: GALAXY_STELLAR_GRAVITY_FLOOR_SETTING } + localGravitySetting: GALAXY_STELLAR_GRAVITY_FLOOR_SETTING } ); seedGalaxySystemOrbits( data.nodes, raw.meta && raw.meta.layout_seed, @@ -9476,7 +9363,7 @@ state.collapse = false; render(false, true); clearTimeout(clusterExpandTimer); - clusterExpandTimer = setTimeout(() => { clusterExpandTimer = 0; fg.centerAt(node.x, node.y, 500); fg.zoom(1.6, 500); }, 60); + clusterExpandTimer = setTimeout(() => { clusterExpandTimer = 0; const d = reduced() ? 0 : 500; fg.centerAt(node.x, node.y, d); fg.zoom(1.6, d); }, 60); if (opts.onCollapseChange) opts.onCollapseChange(false); return; } @@ -9667,7 +9554,7 @@ if (!Number.isFinite(node.x) || !Number.isFinite(node.y) || !Number.isFinite(node.radius)) return; ctx.fillStyle = color; ctx.beginPath(); - ctx.arc(node.x, node.y, node.radius + 2, 0, 6.2832); ctx.fill(); + ctx.arc(node.x, node.y, nodePaintRadius(node) + 2, 0, 6.2832); ctx.fill(); }) .linkColor(l => { const focus = hoverSet && hoverSet.size > 1; @@ -10633,7 +10520,6 @@ applyGalaxyOrbitalSpeedControl, galaxyOrbitalSeparationPadding, galaxyOrbitalSeparationStrength, communityKey, communityCenters, galaxyOrbitGroups, ensureGalaxyPositions, - markGalaxyBlackHoleChildren, seedGalaxyOrbits, seedGalaxySystemOrbits, applyGalaxyGravity, applyGalaxySystemHaloGravity, applyGalaxyEnclosedSystemGravity, applyGalaxySystemAnchorGravity, applyGalaxySystemAnchorExclusion, diff --git a/engraphis/dashboard_assets/index.html b/engraphis/dashboard_assets/index.html index 5c3c70db..ee541347 100644 --- a/engraphis/dashboard_assets/index.html +++ b/engraphis/dashboard_assets/index.html @@ -7,7 +7,7 @@ Engraphis Ledger - + @@ -273,8 +273,8 @@

How this workspace connects

- - + +
@@ -330,7 +330,7 @@

Colour

Motion

Relation flowdirection
Entity labelsnames
-
Freeze simulationpause physics
+
Freeze simulationpause physics
@@ -375,7 +375,7 @@

Saved views

-
Pause orbitsphysics
+
Pause orbitsphysics

Drag and release a node to slingshot it into a new orbit.

@@ -707,6 +707,6 @@

Connected nodes

- + diff --git a/engraphis/dashboard_assets/ledger.css b/engraphis/dashboard_assets/ledger.css index 96f07995..2e8f0993 100644 --- a/engraphis/dashboard_assets/ledger.css +++ b/engraphis/dashboard_assets/ledger.css @@ -121,6 +121,10 @@ body[data-theme="matrix"] { button, input, select, textarea { font: inherit; } button, select { cursor: pointer; } +:where(button, input, select, textarea):disabled { + cursor: not-allowed; + opacity: .5; +} a { color: var(--c-acc); } :where(button, a, input, select, textarea, summary):focus-visible { outline: 2px solid var(--c-acc); @@ -600,6 +604,11 @@ body[data-theme="paper"] .graph-header { .graph-header h1 { font-size: 20px; } .graph-actions { display: flex; gap: 6px; } .graph-canvas { position: absolute; inset: 0; } +.graph-canvas[data-graph-style="classic"] { background: var(--c-inset); } +.graph-canvas-candidate { + visibility: hidden; + pointer-events: none; +} .graph-canvas .engraphis-all-canvas, .graph-canvas .engraphis-all-labels { position: absolute; inset: 0; width: 100%; height: 100%; display: block; } .graph-canvas .engraphis-all-labels { pointer-events: none; } diff --git a/engraphis/dashboard_assets/ledger.js b/engraphis/dashboard_assets/ledger.js index fc6cc685..ab6f2ea5 100644 --- a/engraphis/dashboard_assets/ledger.js +++ b/engraphis/dashboard_assets/ledger.js @@ -422,7 +422,7 @@ if (!graphAllAssetsPromise) { const controller = new AbortController(); const attempt = loadScript( - graphAssetSource('/v2-assets/engraphis-graph-all.js?v=20260817-all-nodes-lod-3'), + graphAssetSource('/v2-assets/engraphis-graph-all.js?v=20260815-merge-ready-1'), 'EngraphisAllGraph', controller.signal, ); graphAllAssetsPromise = attempt; @@ -435,17 +435,10 @@ } function ensureGraphAssets(loadAll = false) { - /* The complete profile is an independent worker/WebGL renderer. Galaxy is the exception: - its solar-system view needs the authoritative hierarchical orbit integrator, so a full - Galaxy request uses the quality engine with the complete payload instead of the static - all-node worker. Other full presets retain the worker/WebGL path and its 20k-node cap. */ - if (loadAll && !graphIsGalaxy()) return ensureGraphAllAsset(); - if (loadAll && graphIsGalaxy()) { - /* Load both candidates before the complete scene arrives. The factory decision below is - data-sensitive: an ordinary graph that merely uses the Galaxy preset keeps the worker, - while an authored star/planet scene gets the live hierarchical engine. */ - return Promise.all([ensureGraphAllAsset(), ensureGraphAssets(false)]); - } + /* Complete scenes always use the worker/WebGL renderer. Galaxy hierarchy is already + encoded in canonical server coordinates; loading ForceGraph here would restore the + duplicate live simulation that Show all is specifically designed to avoid. */ + if (loadAll) return ensureGraphAllAsset(); const coreReady = window.ForceGraph && window.EngraphisGraph && window.EngraphisSpacetime; if (!coreReady && !graphAssetsPromise) { const controller = new AbortController(); @@ -456,7 +449,7 @@ graphAssetSource('/v2-assets/vendor/force-graph.min.js?v=20260727-final'), 'ForceGraph', controller.signal, )).then(() => loadScript( - graphAssetSource('/v2-assets/engraphis-graph.js?v=20260819-v24-physics-final'), + graphAssetSource('/v2-assets/engraphis-graph.js?v=20260815-merge-ready-1'), 'EngraphisGraph', controller.signal, )).then(() => loadScript( graphAssetSource('/v2-assets/engraphis-spacetime.js?v=20260812-stable-orbit-lanes-7'), @@ -2300,7 +2293,7 @@ ? 'Filter by exact repository name' : 'Filter to a repository or topic'; ['graph-min-degree', 'graph-tune-min-degree', 'graph-collapse', 'graph-depth', - 'graph-show-unlinked', 'graph-flow', 'graph-flow-speed', 'graph-orbits-pause'].forEach(id => { + 'graph-show-unlinked', 'graph-flow', 'graph-flow-speed'].forEach(id => { const control = byId(id); if (control) control.disabled = false; }); @@ -2313,9 +2306,10 @@ const lodNote = byId('graph-lod-note'); if (lodNote) lodNote.hidden = !full; byId('graph-reheat').textContent = full ? 'Reflow layout' : 'Reheat layout'; - byId('graph-freeze-label').textContent = full ? 'Freeze LOD motion' : 'Freeze simulation'; - byId('graph-freeze-detail').textContent = full ? 'hold flow' : 'pause physics'; - byId('graph-freeze').setAttribute('aria-label', full ? 'Freeze LOD motion' : 'Freeze simulation'); + const freezeRow = byId('graph-freeze-row'); + if (freezeRow) freezeRow.hidden = full; + const orbitPause = byId('graph-orbit-pause-row'); + if (orbitPause) orbitPause.hidden = full; const style = byId('graph-style').value; const styleNotes = full ? GRAPH_LOD_STYLE_NOTES : GRAPH_STYLE_NOTES; byId('graph-style-note').textContent = styleNotes[style] || styleNotes.classic; @@ -2324,7 +2318,7 @@ byId('graph-mode').textContent = `${full ? 'All nodes · LOD' : 'High quality'} · ${preset}`; const toggle = byId('graph-show-all'); if (toggle) { - toggle.textContent = full ? 'High quality' : 'See all nodes · LOD'; + toggle.textContent = 'All nodes'; toggle.setAttribute('aria-pressed', String(full)); toggle.title = full ? 'Return to the High quality graph' : `Load up to ${GRAPH_ALL_NODE_LIMIT.toLocaleString()} entities and ${GRAPH_ALL_EDGE_LIMIT.toLocaleString()} relationships with progressive LOD rendering`; } @@ -2378,10 +2372,9 @@ byId('graph-spacetime-note').textContent = full ? 'These values refine the settled worker layout. The High quality orbit model stays unchanged.' : 'Drag and release a node to slingshot it into a new orbit.'; - byId('graph-orbits-pause-label').textContent = full ? 'Pause relation motion' : 'Pause orbits'; - byId('graph-orbits-pause-detail').textContent = full ? 'LOD' : 'physics'; - byId('graph-orbits-pause').setAttribute('aria-label', full - ? 'Pause relation motion' : 'Pause orbital physics'); + byId('graph-orbits-pause-label').textContent = 'Pause orbits'; + byId('graph-orbits-pause-detail').textContent = 'physics'; + byId('graph-orbits-pause').setAttribute('aria-label', 'Pause orbital physics'); } function setChoicePressed(selector, dataKey, selected) { @@ -2624,7 +2617,7 @@ const next = on === true; state.graphShowUnlinked = next; const control = byId('graph-show-unlinked'); - control.textContent = next ? 'Hide unlinked nodes' : 'Show unlinked nodes'; + control.textContent = 'Unlinked nodes'; control.setAttribute('aria-pressed', String(next)); control.title = next ? 'Hide entities that have no relations in this graph view' @@ -2864,6 +2857,10 @@ ...graphPresetTuning(preset), ...(view.tuning && typeof view.tuning === 'object' ? view.tuning : {}), }); + syncGraphSpacetimeTuning( + view.spacetimeTuning && typeof view.spacetimeTuning === 'object' + ? view.spacetimeTuning : {} + ); setGraphMinDegree(view.minDegree == null ? 1 : view.minDegree, false); setGraphDepth(view.depth == null ? 2 : view.depth, false); setGraphShowUnlinked(view.showUnlinked === true, false); @@ -2894,6 +2891,9 @@ }, false, !state.graphFrozen); state.graphEngine.freeze(state.graphFrozen); } + if (state.graphSpacetimeOverlay) { + state.graphSpacetimeOverlay.setEnabled(graphIsGalaxy()); + } saveGraphPreferences(); if (previousIncludeCode !== state.graphIncludeCode || previousShowUnlinked !== state.graphShowUnlinked || previousAsOf !== asOf @@ -2986,6 +2986,20 @@ window.setTimeout(() => URL.revokeObjectURL(href), 0); } + function setGraphExportMenuOpen(open, restoreTriggerFocus = false) { + const trigger = byId('graph-export'); + const menu = byId('graph-export-menu'); + if (open) { + menu.hidden = false; + trigger.setAttribute('aria-expanded', 'true'); + byId('graph-export-png').focus(); + return; + } + trigger.setAttribute('aria-expanded', 'false'); + if (restoreTriggerFocus || menu.contains(document.activeElement)) trigger.focus(); + menu.hidden = true; + } + function exportGraphJson() { const graph = state.graphEngine && state.graphEngine.exportData ? state.graphEngine.exportData() @@ -3139,6 +3153,18 @@ && request.repo === (byId('graph-repo-filter').value || '').trim()); } + function setGraphLoadControlsBusy(busy, disableRetry = true) { + const controls = disableRetry ? ['graph-show-all', 'graph-retry'] : ['graph-show-all']; + controls.forEach(id => { + const control = byId(id); + if (control) control.disabled = busy; + }); + const retry = byId('graph-retry'); + if (retry && ((busy && disableRetry) || !busy)) { + retry.textContent = busy ? 'Reloading graph…' : 'Reload data'; + } + } + function retryGraphLoad() { // A Retry click starts a new request rather than inheriting a timed-out promise. Keep its // pending state local to the button so rapid clicks cannot repeatedly cancel fresh work. @@ -3198,6 +3224,17 @@ state.graphLoadRepo = targetRepo; state.graphLoadController = controller; if (previousController && !previousController.signal.aborted) previousController.abort(); + // The initial load remains retryable; once a user explicitly starts a replacement, lock + // the retry control until that transaction settles so repeated clicks cannot churn it. + setGraphLoadControlsBusy(true, force); + const oldEngine = state.graphEngine; + const oldOverlay = state.graphSpacetimeOverlay; + /* Loading is a transaction: freeze the committed renderer before fetching its replacement. + A failed request restores it; a successful request destroys it immediately before commit. */ + if (state.graphEngine && typeof state.graphEngine.freeze === 'function') { + state.graphEngine.freeze(true); + } + if (state.graphSpacetimeOverlay) state.graphSpacetimeOverlay.setEnabled(false); byId('graph-canvas').setAttribute('aria-busy', 'true'); byId('graph-empty').hidden = false; byId('graph-empty').textContent = fullGraph @@ -3210,6 +3247,48 @@ const timeoutPromise = new Promise((_, reject) => { rejectTimeout = reject; }); + // Transactional candidate tracking: host/engine/overlay live here until commit. + // destroyCandidate() is the single cleanup path for stale, error, and timeout outcomes. + const restoreCommittedRenderer = () => { + /* A newer request owns the committed renderer while it is replacing this one. Do not + thaw that renderer from a stale response; its own transaction will settle it. */ + if (state.graphEngine !== oldEngine + || (state.graphLoadController && state.graphLoadController !== controller)) return; + if (oldEngine && typeof oldEngine.freeze === 'function') { + oldEngine.freeze(state.graphFrozen); + } + if (oldOverlay) oldOverlay.setEnabled(graphIsGalaxy()); + }; + let candidateHost = null; + let candidateEngine = null; + let candidateOverlay = null; + let candidateStats = null; + let candidateMetrics = null; + const destroyCandidate = () => { + if (!candidateEngine) { + candidateOverlay = null; + if (candidateHost && candidateHost.parentNode) { + candidateHost.remove(); + } + candidateHost = null; + return; + } + // Keep committed references live: renderer callbacks close over candidateEngine. + // Nulling it here would make every post-readiness stats/metrics callback look stale. + if (state.graphEngine === candidateEngine) return; + if (candidateOverlay && typeof candidateOverlay.destroy === 'function') { + try { candidateOverlay.destroy(); } catch (_) { /* best-effort */ } + } + candidateOverlay = null; + if (typeof candidateEngine.destroy === 'function') { + try { candidateEngine.destroy(); } catch (_) { /* best-effort */ } + } + candidateEngine = null; + if (candidateHost && candidateHost.parentNode) { + candidateHost.remove(); + } + candidateHost = null; + }; const timeout = window.setTimeout(() => { if (!fullGraph && (!window.ForceGraph || !window.EngraphisGraph || !window.EngraphisSpacetime)) { releaseGraphAssetsAttempt(graphAssetsPromise); @@ -3245,7 +3324,10 @@ ]), timeoutPromise, ]); - if (!isCurrentGraphLoad(request)) return; + if (!isCurrentGraphLoad(request)) { + restoreCommittedRenderer(); + return; + } if (payload && payload.error) throw new Error(String(payload.error)); const scene = payload.scene && typeof payload.scene === 'object' ? payload.scene : payload; const data = { @@ -3260,25 +3342,8 @@ metadata: scene.metadata || payload.metadata || {}, layout_seed: scene.layout_seed ?? (scene.meta && scene.meta.layout_seed) ?? (payload.meta && payload.meta.layout_seed), }; - state.graphData = data; - state.graphWorkspace = targetWorkspace; - state.graphDataMode = targetMode; - state.graphDataIncludeCode = targetIncludeCode; - state.graphDataShowUnlinked = targetShowUnlinked; - state.graphDataAsOf = targetAsOf; - state.graphDataRepo = targetRepo; const sceneMeta = scene.meta || payload.meta || {}; - if (sceneMeta.degraded && sceneMeta.requested_include_code - && sceneMeta.include_code === false) { - state.graphIncludeCode = false; - state.graphDataIncludeCode = false; - setGraphLayers({ ...graphLayerState(), code: false }); - saveGraphPreferences(); - showNotice(sceneMeta.degraded_reason === 'code_overlay_requires_repository_filter' - ? 'Code overlay skipped for this workspace. Choose a repository filter to include code relationships.' - : 'Code overlay was unavailable for this request. Showing the entity graph.'); - } - state.graphMeta = { + const nextMeta = { ...sceneMeta, nodes_available: sceneMeta.nodes_available == null ? (sceneMeta.total_nodes == null ? data.nodes.length : sceneMeta.total_nodes) : sceneMeta.nodes_available, @@ -3286,40 +3351,43 @@ ? (sceneMeta.truncated == null ? fullGraph : !sceneMeta.truncated) : sceneMeta.nodes_complete, }; - if (state.graphSpacetimeOverlay) { - state.graphSpacetimeOverlay.destroy(); - state.graphSpacetimeOverlay = null; - } - if (state.graphEngine) state.graphEngine.destroy(); - const galaxyQuality = fullGraph && graphIsGalaxy() - && data.nodes.some(node => node.anchor_role === 'community' - && (node.system_anchor_id !== undefined - || Number.isFinite(Number(node.galactic_radius)))); - const graphFactory = galaxyQuality ? window.EngraphisGraph - : fullGraph ? window.EngraphisAllGraph : window.EngraphisGraph; + const responseIncludeCode = sceneMeta.include_code === false ? false : targetIncludeCode; + const codeOverlayDegraded = targetIncludeCode && !responseIncludeCode; + const oldHost = byId('graph-canvas'); + // oldEngine/oldOverlay were captured before the first await so the failure path + // can restore the exact committed renderer even when candidate setup never begins. + candidateHost = oldHost.cloneNode(false); + candidateHost.id = `graph-canvas-candidate-${request.id}`; + candidateHost.classList.add('graph-canvas-candidate'); + candidateHost.setAttribute('aria-hidden', 'true'); + oldHost.insertAdjacentElement('afterend', candidateHost); + const graphFactory = fullGraph ? window.EngraphisAllGraph : window.EngraphisGraph; if (!graphFactory || typeof graphFactory.create !== 'function') { throw new Error(fullGraph - ? galaxyQuality ? 'Galaxy graph engine is unavailable' - : 'all-node graph engine asset is unavailable' + ? 'all-node graph engine asset is unavailable' : 'graph engine asset is unavailable'); } - state.graphEngine = graphFactory.create(byId('graph-canvas'), { - renderMode: galaxyQuality ? 'full' : fullGraph ? 'all' : 'overview', + candidateEngine = graphFactory.create(candidateHost, { + renderMode: fullGraph ? 'all' : 'overview', onNodeClick: item => openGraphConnections(item), - onBackgroundClick: () => state.graphEngine && state.graphEngine.clearFocus(), + onBackgroundClick: () => candidateEngine.clearFocus(), onStats: stats => { - if (state.graphLoadRequest === request.id) graphStatsChanged(stats); + if (state.graphEngine === candidateEngine + && state.graphLoadRequest === request.id) graphStatsChanged(stats); + else if (state.graphLoadRequest === request.id) candidateStats = stats; }, onMetrics: metrics => { - if (state.graphLoadRequest === request.id) graphMetricsChanged(metrics); + if (state.graphEngine === candidateEngine + && state.graphLoadRequest === request.id) graphMetricsChanged(metrics); + else if (state.graphLoadRequest === request.id) candidateMetrics = metrics; }, onError: error => { - if (!fullGraph || state.graphLoadRequest !== request.id - || state.graphMode !== 'full') return; + if (state.graphEngine !== candidateEngine || !fullGraph + || state.graphLoadRequest !== request.id || state.graphMode !== 'full') return; byId('graph-empty').hidden = false; byId('graph-empty').textContent = error && error.code === 'GRAPH_CAPACITY' - ? `All nodes exceed renderer capacity. Narrow by repository or entity type. (${error.message})` - : 'The All Nodes renderer stopped. Choose Reload data to start a fresh worker.'; + ? `All nodes exceed renderer capacity. Enter an exact repository filter or reduce the workspace graph. (${error.message})` + : 'The all-node renderer stopped. Choose Reload data to start a fresh worker.'; byId('graph-canvas').setAttribute('aria-busy', 'false'); }, onCollapseChange: collapsed => { @@ -3339,18 +3407,20 @@ } }, }); - state.graphEngine.apply(graph => { + candidateEngine.apply(graph => { graph.setPreset(byId('graph-preset').value); graph.setStyle(byId('graph-style').value); graph.setColorBy(byId('graph-color').value); graph.setThemeColors(graphThemeColors()); - applyGraphPalette(byId('graph-palette').value); + const paletteName = byId('graph-palette').value; + graph.setPalette(paletteName); + if (paletteName === 'custom') graph.setTypeColors(GRAPH_CUSTOM_PALETTE); graph.setSettings({ ...graphTuningEngineSettings(), ...graphSpacetimeEngineSettings(), flow: byId('graph-flow').getAttribute('aria-checked') === 'true', labels: byId('graph-labels').getAttribute('aria-checked') === 'true', - frozen: state.graphFrozen, + frozen: fullGraph ? false : state.graphFrozen, }); graph.setScope(graphScope()); graph.setLayers(graphLayerState()); @@ -3361,31 +3431,96 @@ graph.setCollapse(byId('graph-collapse').checked ? 'auto' : false); graph.setGhosts(byId('graph-ghosts').checked); }, false, false); - if ((!fullGraph || galaxyQuality) && window.EngraphisSpacetime + candidateEngine.setData(data); + candidateEngine.freeze(fullGraph ? false : state.graphFrozen); + if (!fullGraph && window.EngraphisSpacetime && window.EngraphisSpacetime.create) { - state.graphSpacetimeOverlay = window.EngraphisSpacetime.create( - byId('graph-canvas'), state.graphEngine + candidateOverlay = window.EngraphisSpacetime.create( + candidateHost, candidateEngine ); - state.graphSpacetimeOverlay.setEnabled(graphIsGalaxy()); + candidateOverlay.setEnabled(graphIsGalaxy()); + } + if (typeof candidateEngine.whenReady === 'function') { + await Promise.race([candidateEngine.whenReady(), timeoutPromise]); + if (!isCurrentGraphLoad(request)) { + destroyCandidate(); + restoreCommittedRenderer(); + return; + } } - state.graphEngine.setData(data); - state.graphEngine.freeze(state.graphFrozen); + oldHost.id = `graph-canvas-retired-${request.id}`; + candidateHost.id = 'graph-canvas'; + candidateHost.classList.remove('graph-canvas-candidate'); + candidateHost.removeAttribute('aria-hidden'); + oldHost.replaceWith(candidateHost); + candidateHost = null; + state.graphEngine = candidateEngine; + state.graphSpacetimeOverlay = candidateOverlay; + state.graphData = data; + state.graphWorkspace = targetWorkspace; + state.graphDataMode = targetMode; + state.graphDataIncludeCode = responseIncludeCode; + state.graphDataShowUnlinked = targetShowUnlinked; + state.graphDataAsOf = targetAsOf; + state.graphDataRepo = targetRepo; + state.graphMeta = nextMeta; + if (codeOverlayDegraded) { + state.graphIncludeCode = false; + const layers = { ...graphLayerState(), code: false }; + setGraphLayers(layers); + candidateEngine.setLayers(layers); + clearGraphSavedView(); + saveGraphPreferences(); + showNotice(sceneMeta.degraded_reason === 'code_overlay_requires_repository_filter' + ? 'Code overlay needs a repository filter; showing entity relationships only.' + : 'Code overlay is unavailable; showing entity relationships only.'); + } + if (oldOverlay) oldOverlay.destroy(); + if (oldEngine) oldEngine.destroy(); byId('graph-empty').hidden = Boolean(data.nodes.length); if (!data.nodes.length) byId('graph-empty').textContent = 'No entities exist in this workspace yet.'; updateGraphModeControls(); updateGraphFacts(data); + // Candidate stats emitted before commit are retained, then published only after the + // renderer transaction commits. Raw payload counts are wrong when a frozen renderer + // has already applied repository, layer, ghost, or collapse visibility filters. + graphStatsChanged(candidateStats || { nodes: data.nodes.length, links: data.links.length }); + if (candidateMetrics) graphMetricsChanged(candidateMetrics); updateGraphLayerCounts(data, scene.layers || payload.layers); } catch (error) { - if (!isCurrentGraphLoad(request)) return; + if (!isCurrentGraphLoad(request)) { + destroyCandidate(); + restoreCommittedRenderer(); + return; + } + destroyCandidate(); byId('graph-empty').hidden = false; byId('graph-empty').textContent = error && error.name === 'AbortError' - ? `${fullGraph ? 'All-node graph' : 'High-quality graph'} loading timed out. Choose Retry to try again.` + ? `${fullGraph ? 'All-node graph' : 'High-quality graph'} loading timed out. Choose Reload data to try again.` : fullGraph && (error.status === 413 || error.code === 'GRAPH_CAPACITY') - ? `All nodes exceed the 20,000-entity or 200,000-relationship capacity. Narrow by repository or entity type. (${error.message})` - : `Graph unavailable: ${error.message}`; + ? `All nodes exceed the server capacity. Enter an exact repository filter or reduce the workspace graph. (${error.message})` + : `Graph unavailable: ${error.message}. Choose Reload data to try again.`; + if (state.graphData && state.graphDataMode !== targetMode) { + state.graphMode = state.graphDataMode; + updateGraphModeControls(); + } + // Restore the committed renderer's freeze/overlay state. The old engine survived + // because we never mutated state.graphEngine on the failure path. + if (oldEngine && typeof oldEngine.freeze === 'function') { + oldEngine.freeze(state.graphFrozen); + } + if (oldOverlay) { + oldOverlay.setEnabled(graphIsGalaxy()); + } } finally { + // Safety net: if control left the try/catch without committing or cleaning up + // (e.g. an unexpected throw in finally itself), ensure no candidate leaks. + destroyCandidate(); window.clearTimeout(timeout); - if (isCurrentGraphLoad(request)) byId('graph-canvas').setAttribute('aria-busy', 'false'); + if (state.graphLoadRequest === request.id && state.graphLoadController === controller) { + byId('graph-canvas').setAttribute('aria-busy', 'false'); + setGraphLoadControlsBusy(false); + } if (state.graphLoadController === controller) state.graphLoadController = null; } })(); @@ -4586,7 +4721,6 @@ byId('graph-show-all').addEventListener('click', () => { cancelGraphRepositoryReload(); state.graphMode = state.graphMode === 'full' ? 'overview' : 'full'; - updateGraphModeControls(); loadGraph({ force: true }); }); byId('graph-tune-min-degree').addEventListener('input', event => { @@ -4671,20 +4805,32 @@ if (state.graphEngine) state.graphEngine.setSizeBy(graphSizeBy()); saveGraphPreferences(); }); + const graphExportWrap = byId('graph-export').closest('.graph-export-wrap'); byId('graph-export').addEventListener('click', () => { - const menu = byId('graph-export-menu'); - const open = menu.hidden; - menu.hidden = !open; - byId('graph-export').setAttribute('aria-expanded', String(open)); + setGraphExportMenuOpen(byId('graph-export-menu').hidden); + }); + graphExportWrap.addEventListener('keydown', event => { + if (event.key !== 'Escape' || byId('graph-export-menu').hidden) return; + event.preventDefault(); + event.stopPropagation(); + setGraphExportMenuOpen(false, true); + }); + document.addEventListener('focusin', event => { + if (!byId('graph-export-menu').hidden && !graphExportWrap.contains(event.target)) { + setGraphExportMenuOpen(false); + } + }); + document.addEventListener('pointerdown', event => { + if (!byId('graph-export-menu').hidden && !graphExportWrap.contains(event.target)) { + setGraphExportMenuOpen(false); + } }); byId('graph-export-png').addEventListener('click', () => { - byId('graph-export-menu').hidden = true; - byId('graph-export').setAttribute('aria-expanded', 'false'); + setGraphExportMenuOpen(false, true); exportGraphPng(); }); byId('graph-export-json').addEventListener('click', () => { - byId('graph-export-menu').hidden = true; - byId('graph-export').setAttribute('aria-expanded', 'false'); + setGraphExportMenuOpen(false, true); exportGraphJson(); }); byId('graph-connections-close').addEventListener('click', closeGraphConnections); diff --git a/engraphis/service.py b/engraphis/service.py index 98cdbb89..38886acc 100644 --- a/engraphis/service.py +++ b/engraphis/service.py @@ -44,6 +44,7 @@ ALGORITHM_VERSION as GRAPH_SCENE_ALGORITHM_VERSION, build_canonical_graph, build_graph_scene, + project_all_presentation, is_broad_search_fragment, strongest_path, ) @@ -286,6 +287,10 @@ def _with_retrieval_capabilities(payload: dict, embedder, store=None) -> dict: # refusal ceilings, not render caps: callers receive an explicit capacity error rather # than a silently incomplete chart. MAX_GRAPH_COMPLETE_MEMORIES = 100_000 +# Prompt eligibility is a Python security predicate over two JSON envelopes. Bound +# the raw candidate walk independently so rejected imports cannot force an unbounded +# parse while still leaving room for eligible rows behind rejected candidates. +MAX_GRAPH_COMPLETE_MEMORY_CANDIDATES = 200_000 MAX_GRAPH_COMPLETE_MEMORY_LINKS = 300_000 MAX_GRAPH_COMPLETE_CODE_MEMORY_LINKS = 300_000 MAX_GRAPH_COMPLETE_PAYLOAD_BYTES = 128 * 1024 * 1024 @@ -306,6 +311,11 @@ def _with_retrieval_capabilities(payload: dict, embedder, store=None) -> dict: GRAPH_ENTITY_EVIDENCE_CANDIDATE_LIMIT = 400 GRAPH_ENTITY_HISTORY_LIMIT = 50 CONFLICT_REVIEW_SCAN_LIMIT = 10_000 +def _sqlite_prompt_eligible(provenance: object, metadata: object) -> int: + """Expose the exact Python prompt-security predicate to scoped SQLite joins.""" + return int(prompt_eligible(_loads(provenance, {}), _loads(metadata, {}))) + + @@ -1191,6 +1201,11 @@ def __init__(self, engine: MemoryEngine, *, owned_connector: Optional[Any] = None) -> None: self.engine = engine self.store = engine.store + connection = getattr(self.store, "conn", None) + if connection is not None: + connection.create_function( + "_engraphis_prompt_eligible", 2, _sqlite_prompt_eligible, + ) # Connector created by MemoryService.create() — closed in close() so the # SQLCipher key pragma doesn't outlive the service. None means the caller # injected a connector and owns its lifecycle. @@ -8475,44 +8490,327 @@ def temporal_ghost(row: Any) -> bool: if lower_time is not None and upper_time is not None and lower_time > upper_time: raise ValidationError("time_from must be less than or equal to time_to") - # Classify public endpoint visibility before applying the entity candidate cap. - # A repository filter scopes the expensive support join; the endpoint-only scan - # below still preserves workspace-wide private-edge classification. + # Classify public endpoint visibility before applying the edge candidate cap. + # Build the same repository/time/type candidate relation used by the later entity + # query so unrelated repository edges cannot consume the selected scene's budget. visibility_sql = ( + "WITH visibility_candidates AS (" + "SELECT selected_entity.id FROM entities selected_entity " + "WHERE selected_entity.workspace_id=? " + ) + visibility_params: list[Any] = [wid] + if repo_id: + visibility_sql += ( + "AND (selected_entity.repo_id=? OR selected_entity.repo_id IS NULL) " + ) + visibility_params.append(repo_id) + visibility_sql += ( + "AND (selected_entity.created_at IS NULL OR selected_entity.created_at<=?) " + ) + visibility_params.append(known_t) + if clean_entity_types: + clean_types = sorted(set(clean_entity_types)) + if clean_types: + marks = ",".join("?" for _ in clean_types) + visibility_sql += f"AND selected_entity.etype IN ({marks}) " + visibility_params.extend(clean_types) + visibility_sql += ( + "), visibility_groups AS (" "SELECT visibility_edge.repo_id, visibility_edge.src, visibility_edge.dst, " "MAX(CASE " - "WHEN visibility_support.edge_id IS NULL THEN 1 " - "WHEN visibility_memory.id IS NOT NULL " + "WHEN NOT EXISTS (SELECT 1 FROM edge_supports visibility_any_support " + "WHERE visibility_any_support.edge_id=visibility_edge.id) THEN 1 " + "WHEN visibility_support.edge_id IS NOT NULL " + "AND visibility_memory.id IS NOT NULL " "AND COALESCE(visibility_memory.scope, 'workspace')!='session' THEN 1 " "ELSE 0 END) AS public_edge " "FROM edges visibility_edge " + "JOIN visibility_candidates visibility_src " + "ON visibility_src.id=visibility_edge.src " + "JOIN visibility_candidates visibility_dst " + "ON visibility_dst.id=visibility_edge.dst " "LEFT JOIN edge_supports visibility_support " "ON visibility_support.edge_id=visibility_edge.id " + ) + # A live scene must count only the same temporal edge/support/memory rows + # that the later edge query can render. History keeps closed rows available + # as ghosts, but still applies the selected world/system-time anchors. + if not include_history: + visibility_sql += ( + "AND (visibility_support.valid_from IS NULL " + "OR visibility_support.valid_from<=?) " + "AND (visibility_support.valid_to IS NULL " + "OR ?