diff --git a/engraphis/classic_assets/dashboard.js b/engraphis/classic_assets/dashboard.js
index a7b599ca..473e2e88 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.
`}
/* 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?(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){}}
+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_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},
@@ -724,9 +724,9 @@ function graphEngineEmptyMessage(){
const total=(GRAPH&&GRAPH.nodes&&GRAPH.nodes.length)||0;
return total?('No connected entities — tick "Show unlinked" to see all '+total+'.'):'No entities in this workspace yet.';
}
-function graphRenderEngine(data,fit,reheat){
- const element=document.getElementById('graph-net'),empty=document.getElementById('graph-empty');
- if(!element||typeof EngraphisGraph==='undefined')return false;
+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:[]});
@@ -738,7 +738,7 @@ function graphRenderEngine(data,fit,reheat){
const created=!GRAPH_ENGINE;
if(created){
GRAPH_ENGINE=EngraphisGraph.create(element,{
- renderMode:'overview',
+ renderMode:'overview',
reducedMotion:prefersReducedMotion,
onNodeClick:node=>{syncGraphExplorerSelection(node.id);graphNodeClick(node.label||node.name||node.id)},
onBackgroundClick:()=>graphSetHighlight(null),
@@ -756,7 +756,7 @@ function graphRenderEngine(data,fit,reheat){
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('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());
@@ -778,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(window.GSET.frozen?'Layout frozen':'Adaptive layout',false);
+ graphSetSimulationStatus(window.GSET.frozen?'Layout frozen':'Adaptive layout',false);
return true;
}catch(error){
graphEngineFallback(error);
@@ -798,15 +798,15 @@ 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;
- 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;
+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');
@@ -817,28 +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=!!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.
+ 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);
- }
+ }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;
@@ -851,10 +851,10 @@ async function 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;
- 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));
@@ -1214,57 +1214,57 @@ function loadForceGraph(){
});
return FORCE_GRAPH_LOADING;
}
-let GRAPH_ENGINE_LOADING=null,GRAPH_ENGINE_RETRY=0,ALL_GRAPH_ENGINE_LOADING=null;
-function loadAllGraphEngine(){
- if(typeof EngraphisEveryGraph!=='undefined')return Promise.resolve();
- if(!ALL_GRAPH_ENGINE_LOADING){
- ALL_GRAPH_ENGINE_LOADING=new Promise((resolve,reject)=>{
- const script=document.createElement('script');script.src='/v2-assets/engraphis-graph-every.js?v=20260823-every-19';
- script.onload=()=>{typeof EngraphisEveryGraph==='undefined'?reject(new Error('Every-node graph asset loaded without registering EngraphisEveryGraph')):resolve()};
- script.onerror=()=>reject(new Error('Every-node graph asset could not load'));
- document.head.appendChild(script);
- });
- ALL_GRAPH_ENGINE_LOADING.catch(()=>{});
- }
- return ALL_GRAPH_ENGINE_LOADING;
-}
-function loadGraphEngine(loadAll=false){
- let engineReady;
- if(typeof EngraphisGraph!=='undefined'){GRAPH_ENGINE_RETRY=0;engineReady=Promise.resolve();}
- else{
- if(!GRAPH_ENGINE_LOADING){
- GRAPH_ENGINE_LOADING=new Promise((resolve,reject)=>{
- const script=document.createElement('script');
- const bust=GRAPH_ENGINE_RETRY>0?'&r='+GRAPH_ENGINE_RETRY:'';
- script.src='/v2-assets/engraphis-graph.js?v=20260815-merge-ready-1'+bust;
- /* A 200 that never registers the global is a corrupt/truncated asset, not a success —
- resolving there would hand graphRenderEngine() an undefined EngraphisGraph. Failed
- attempts drop the script node and clear the memo so the next call retries with a
- cache-buster rather than returning the same rejected promise forever. */
- const cleanup=(failed)=>{if(script.parentNode)script.parentNode.removeChild(script);if(failed){GRAPH_ENGINE_LOADING=null;GRAPH_ENGINE_RETRY=Math.min(GRAPH_ENGINE_RETRY+1,2)}else{GRAPH_ENGINE_RETRY=0}};
- script.onload=()=>{if(typeof EngraphisGraph==='undefined'){cleanup(true);reject(new Error('Graph engine asset loaded without registering EngraphisGraph'))}else{cleanup(false);resolve()}};
- script.onerror=()=>{cleanup(true);reject(new Error('Graph engine could not load'))};
- document.head.appendChild(script);
- });
- GRAPH_ENGINE_LOADING.catch(()=>{});
- }
- engineReady=GRAPH_ENGINE_LOADING;
- }
- /* Mark the memoized promise handled. graphRender() can start this fetch on a pass that
- returns before attaching its own handler, and an unhandled rejection would print the exact
- console error this lazy-loading exists to remove. Callers still receive the rejection. */
- return loadAll?engineReady.then(()=>loadAllGraphEngine()):engineReady;
-}
-function graphRender(fit=true,reheat=true){
- const empty=document.getElementById('graph-empty');
- const graphFull=typeof GRAPH_FULL!=='undefined'&&GRAPH_FULL;
+let GRAPH_ENGINE_LOADING=null,GRAPH_ENGINE_RETRY=0,ALL_GRAPH_ENGINE_LOADING=null;
+function loadAllGraphEngine(){
+ if(typeof EngraphisEveryGraph!=='undefined')return Promise.resolve();
+ if(!ALL_GRAPH_ENGINE_LOADING){
+ ALL_GRAPH_ENGINE_LOADING=new Promise((resolve,reject)=>{
+ const script=document.createElement('script');script.src='/v2-assets/engraphis-graph-every.js?v=20260823-every-19';
+ script.onload=()=>{typeof EngraphisEveryGraph==='undefined'?reject(new Error('Every-node graph asset loaded without registering EngraphisEveryGraph')):resolve()};
+ script.onerror=()=>reject(new Error('Every-node graph asset could not load'));
+ document.head.appendChild(script);
+ });
+ ALL_GRAPH_ENGINE_LOADING.catch(()=>{});
+ }
+ return ALL_GRAPH_ENGINE_LOADING;
+}
+function loadGraphEngine(loadAll=false){
+ let engineReady;
+ if(typeof EngraphisGraph!=='undefined'){GRAPH_ENGINE_RETRY=0;engineReady=Promise.resolve();}
+ else{
+ if(!GRAPH_ENGINE_LOADING){
+ GRAPH_ENGINE_LOADING=new Promise((resolve,reject)=>{
+ const script=document.createElement('script');
+ const bust=GRAPH_ENGINE_RETRY>0?'&r='+GRAPH_ENGINE_RETRY:'';
+ script.src='/v2-assets/engraphis-graph.js?v=20260831-galaxy-floor-fix-2'+bust;
+ /* A 200 that never registers the global is a corrupt/truncated asset, not a success —
+ resolving there would hand graphRenderEngine() an undefined EngraphisGraph. Failed
+ attempts drop the script node and clear the memo so the next call retries with a
+ cache-buster rather than returning the same rejected promise forever. */
+ const cleanup=(failed)=>{if(script.parentNode)script.parentNode.removeChild(script);if(failed){GRAPH_ENGINE_LOADING=null;GRAPH_ENGINE_RETRY=Math.min(GRAPH_ENGINE_RETRY+1,2)}else{GRAPH_ENGINE_RETRY=0}};
+ script.onload=()=>{if(typeof EngraphisGraph==='undefined'){cleanup(true);reject(new Error('Graph engine asset loaded without registering EngraphisGraph'))}else{cleanup(false);resolve()}};
+ script.onerror=()=>{cleanup(true);reject(new Error('Graph engine could not load'))};
+ document.head.appendChild(script);
+ });
+ GRAPH_ENGINE_LOADING.catch(()=>{});
+ }
+ engineReady=GRAPH_ENGINE_LOADING;
+ }
+ /* Mark the memoized promise handled. graphRender() can start this fetch on a pass that
+ returns before attaching its own handler, and an unhandled rejection would print the exact
+ console error this lazy-loading exists to remove. Callers still receive the rejection. */
+ return loadAll?engineReady.then(()=>loadAllGraphEngine()):engineReady;
+}
+function graphRender(fit=true,reheat=true){
+ const empty=document.getElementById('graph-empty');
+ const graphFull=typeof GRAPH_FULL!=='undefined'&&GRAPH_FULL;
/* Kick the opt-in engine off alongside the vendor bundle instead of after it, so a
`?graph-engine=next` deep link costs one round trip rather than two. */
- const engineMissing=typeof EngraphisGraph==='undefined'||(graphFull&&typeof EngraphisEveryGraph==='undefined');
+ const engineMissing=typeof EngraphisGraph==='undefined'||(graphFull&&typeof EngraphisEveryGraph==='undefined');
/* All mode owns a dedicated bounded renderer and must remain available after a quality-renderer
runtime failure. The quality failure latch only authorizes the small legacy overview. */
- const enginePending=(graphFull||(!GRAPH_ENGINE_FAILED&&graphEngineEnabled()))&&engineMissing?loadGraphEngine(graphFull):null;
- if(!graphFull&&typeof ForceGraph==='undefined'){
+ const enginePending=(graphFull||(!GRAPH_ENGINE_FAILED&&graphEngineEnabled()))&&engineMissing?loadGraphEngine(graphFull):null;
+ if(!graphFull&&typeof ForceGraph==='undefined'){
showAs(empty,true,'flex');empty.textContent='Loading graph engine…';
graphSetLayoutStatus('Loading engine',true);
loadForceGraph().then(()=>graphRender(fit,reheat)).catch(error=>{
@@ -1281,28 +1281,28 @@ function graphRender(fit=true,reheat=true){
announced through graphEngineFallback() rather than silent. */
showAs(empty,true,'flex');empty.textContent='Loading graph engine…';
graphSetLayoutStatus('Loading engine',true);
- enginePending.then(()=>graphRender(fit,reheat)).catch(error=>{
- if(graphFull){
- empty.textContent=error.message+'; return to High quality or reload the dashboard assets.';
- graphSetLayoutStatus('All-node engine unavailable',false);
- return;
- }
- /* Latches GRAPH_ENGINE_FAILED, so the re-entry below takes the classic path and this
+ enginePending.then(()=>graphRender(fit,reheat)).catch(error=>{
+ if(graphFull){
+ empty.textContent=error.message+'; return to High quality or reload the dashboard assets.';
+ graphSetLayoutStatus('All-node engine unavailable',false);
+ return;
+ }
+ /* Latches GRAPH_ENGINE_FAILED, so the re-entry below takes the classic path and this
cannot loop. */
graphEngineFallback(error);
graphRender(fit,reheat);
});
return;
- }
- const element=document.getElementById('graph-net'),settings=window.GSET,mode=GRAPH_PRESETS[settings.mode]||GRAPH_PRESETS.compact,data=graphData();
- if(graphFull){
- if(graphRenderEngine(data,fit,reheat))return;
- showAs(empty,true,'flex');
- empty.textContent='All-node renderer unavailable; return to High quality or reload the dashboard assets.';
- graphSetLayoutStatus('All-node engine unavailable',false);
- return;
- }
- if(graphEngineEnabled()&&graphRenderEngine(data,fit,reheat))return;
+ }
+ const element=document.getElementById('graph-net'),settings=window.GSET,mode=GRAPH_PRESETS[settings.mode]||GRAPH_PRESETS.compact,data=graphData();
+ if(graphFull){
+ if(graphRenderEngine(data,fit,reheat))return;
+ showAs(empty,true,'flex');
+ empty.textContent='All-node renderer unavailable; return to High quality or reload the dashboard assets.';
+ graphSetLayoutStatus('All-node engine unavailable',false);
+ return;
+ }
+ if(graphEngineEnabled()&&graphRenderEngine(data,fit,reheat))return;
/* Read AFTER the opt-in attempt: a failing engine resets GACTIVE_DATA precisely so the
classic renderer below rebuilds from scratch instead of assuming the canvas is current. */
const dataChanged=GACTIVE_DATA!==data;
@@ -1532,7 +1532,7 @@ function graphKeyboard(event){
const node=nodes[GKEYINDEX],net=document.getElementById('graph-net');graphFocus(node.id);net.setAttribute('aria-label','Selected entity '+(node.label||node.id)+', '+(node.degree||0)+' relations. Press Enter to open. Use arrow keys to move.');
}
function syncGraphExplorerSelection(id){document.querySelectorAll('#graph-entity-list [data-entity]').forEach(button=>{const active=button.dataset.entity===id;button.classList.toggle('active',active);if(active)button.setAttribute('aria-current','true');else button.removeAttribute('aria-current')})}
-function graphQueueExplorer(query){clearTimeout(GEXPLORER_TIMER);GEXPLORER_TIMER=setTimeout(()=>renderGraphExplorer(query,true),120)}
+function graphQueueExplorer(query){clearTimeout(GEXPLORER_TIMER);GEXPLORER_TIMER=setTimeout(()=>renderGraphExplorer(query,true),120)}
function graphExplorerMore(kind){
if(kind==='nodes')GEXPLORER.nodeLimit+=GRAPH_EXPLORER_PAGE.nodes;else GEXPLORER.edgeLimit+=GRAPH_EXPLORER_PAGE.edges;
renderGraphExplorer(GEXPLORER.query,false);
@@ -1688,7 +1688,7 @@ function renderUpdateBanner(u){
}
function clearBootstrapError(){const overlay=document.getElementById('bootstrap-error-overlay');if(overlay){overlay.classList.remove('show');dialogChanged(overlay);overlay.remove()}}
function showBootstrapError(msg){clearBootstrapError();const overlay=document.createElement('div');overlay.id='bootstrap-error-overlay';overlay.className='mm-overlay show';overlay.setAttribute('aria-hidden','false');overlay.innerHTML='
Engraphis is unavailable
'+esc(msg)+'
';document.body.appendChild(overlay);dialogChanged(overlay)}
-async function boot(){clearBootstrapError();if(CURRENT_VIEW!=='graph')graphResetEngineFailure();try{const b=await api('/bootstrap');LIC=b.license;RELEASE_VERSION=typeof b.version==='string'?b.version.trim():'';renderSemBanner(b.embedder);renderUpdateBanner(b.update);WORKSPACES=b.workspaces||[];if(!WS&&WORKSPACES.length){WORKSPACES.sort((a,b)=>(b.memories||0)-(a.memories||0));setWS(WORKSPACES[0].name)}updateLicBadge();updateFeatureLocks();loadOverview();checkHealth()}catch(e){if(e.status===401&&await authenticateBrowser()){window.location.reload();return}const msg=e.status===404?'Dashboard APIs are unavailable. This usually means the legacy v1 server is running. Stop it, then launch scripts.start_dashboard.':'Dashboard initialization failed. Run engraphis-init --check for diagnostics.';showBootstrapError(msg)}}
+async function boot(){clearBootstrapError();if(CURRENT_VIEW!=='graph')graphResetEngineFailure();try{const b=await api('/bootstrap');LIC=b.license;RELEASE_VERSION=typeof b.version==='string'?b.version.trim():'';renderSemBanner(b.embedder);renderUpdateBanner(b.update);WORKSPACES=b.workspaces||[];if(!WS&&WORKSPACES.length){WORKSPACES.sort((a,b)=>(b.memories||0)-(a.memories||0));setWS(WORKSPACES[0].name)}updateLicBadge();updateFeatureLocks();loadOverview();checkHealth()}catch(e){if(e.status===401&&await authenticateBrowser()){window.location.reload();return}const msg=e.status===404?'Dashboard APIs are unavailable. This usually means the legacy v1 server is running. Stop it, then launch scripts.start_dashboard.':'Dashboard initialization failed. Run engraphis-init --check for diagnostics.';showBootstrapError(msg)}}
initTheme();
initDashboard();
boot();
diff --git a/engraphis/dashboard_assets/engraphis-graph-every-worker.js b/engraphis/dashboard_assets/engraphis-graph-every-worker.js
index 7028eb13..71d34b48 100644
--- a/engraphis/dashboard_assets/engraphis-graph-every-worker.js
+++ b/engraphis/dashboard_assets/engraphis-graph-every-worker.js
@@ -22,7 +22,11 @@
const MAX_CENTROID_GROUPS = 512;
let model = null;
- let settings = { repel: 48, link: 16, gravity: 48 };
+ let settings = {
+ repel: 48, link: 16, gravity: 48,
+ gravitationalConstant: 1, blackHoleMass: 1, localGravitationalConstant: 1,
+ damping: 1, springStiffness: 1,
+ };
let generation = 0;
function post(message) { self.postMessage(message); }
@@ -217,6 +221,11 @@
springs run weak — they are visual routes between districts, not licence to drag
the districts into one another over the settle passes. */
const scaledSpacing = SPACING * MAP_SCALE;
+ const spring = Number.isFinite(Number(settings.springStiffness))
+ ? Math.max(0, Math.min(100 / 32, Number(settings.springStiffness))) : 1;
+ // springStiffness is already a normalized multiplier from the dashboard. Preserve its
+ // zero endpoint so the Link spring control can actually disable pair attraction.
+ const springScale = spring;
const rest = Math.max(scaledSpacing * 1.9, Number(settings.link) * 1.6 * (MAP_SCALE * 0.55));
for (let edge = 0; edge < model.totalLinks; edge += 1) {
const a = model.sources[edge], b = model.targets[edge];
@@ -224,7 +233,7 @@
const dist = Math.sqrt(ddx * ddx + ddy * ddy) || 0.0001;
const crossCommunity =
model.communities[a] !== model.communities[b] ? 0.02 : 0.07;
- const force = (dist - rest) / dist * crossCommunity;
+ const force = (dist - rest) / dist * crossCommunity * springScale;
dx[a] -= ddx * force; dy[a] -= ddy * force;
dx[b] += ddx * force; dy[b] += ddy * force;
}
@@ -241,7 +250,11 @@
}
const minDist = SPACING * MAP_SCALE * 1.55;
const minDist2 = minDist * minDist;
- const push = Number(settings.repel) / 48;
+ const cohesion = Number.isFinite(Number(settings.localGravitationalConstant))
+ ? Math.max(0, Math.min(2, Number(settings.localGravitationalConstant))) : 1;
+ /* Cluster cohesion strengthens the attractive spring network above. Invert its influence
+ on the collision-style push so a higher cohesion setting does not spread clusters apart. */
+ const push = Number(settings.repel) / 48 * (1.5 - 0.5 * cohesion);
for (let index = 0; index < count; index += 1) {
const gx = Math.floor(pos[index * 2] / cell), gy = Math.floor(pos[index * 2 + 1] / cell);
let checked = 0;
@@ -314,14 +327,23 @@
}
}
- const gravity = Number(settings.gravity) / 48 * 0.0015;
+ const coreAttraction = Number.isFinite(Number(settings.gravitationalConstant))
+ ? Math.max(0, Math.min(2, Number(settings.gravitationalConstant))) : 1;
+ const coreMass = Number.isFinite(Number(settings.blackHoleMass))
+ ? Math.max(0, Math.min(2, Number(settings.blackHoleMass))) : 1;
+ const gravity = Number(settings.gravity) / 48 * 0.0015 * coreAttraction * coreMass;
for (let index = 0; index < count; index += 1) {
dx[index] += (cx - pos[index * 2]) * gravity;
dy[index] += (cy - pos[index * 2 + 1]) * gravity;
}
/* A tight per-pass step cap keeps the settle from smearing district boundaries. */
- const damp = 0.8, maxStep = SPACING * MAP_SCALE * 0.7;
+ const resistance = Number.isFinite(Number(settings.damping))
+ ? Math.max(0, Math.min(15, Number(settings.damping))) : 1;
+ /* Keep the full 0..15 control range responsive: the reciprocal curve reaches 0.2
+ at resistance 13, so a 0.2 floor would make the final two slider units inert. */
+ const damp = Math.max(0.15, Math.min(0.95, 0.8 / (0.75 + 0.25 * resistance)));
+ const maxStep = SPACING * MAP_SCALE * 0.7;
for (let index = 0; index < count; index += 1) {
let vx = dx[index] * damp, vy = dy[index] * damp;
const speed = Math.sqrt(vx * vx + vy * vy);
@@ -409,6 +431,15 @@
repel: Number.isFinite(Number(next.repel)) ? Number(next.repel) : settings.repel,
link: Number.isFinite(Number(next.link)) ? Number(next.link) : settings.link,
gravity: Number.isFinite(Number(next.gravity)) ? Number(next.gravity) : settings.gravity,
+ gravitationalConstant: Number.isFinite(Number(next.gravitationalConstant))
+ ? Number(next.gravitationalConstant) : settings.gravitationalConstant,
+ blackHoleMass: Number.isFinite(Number(next.blackHoleMass))
+ ? Number(next.blackHoleMass) : settings.blackHoleMass,
+ localGravitationalConstant: Number.isFinite(Number(next.localGravitationalConstant))
+ ? Number(next.localGravitationalConstant) : settings.localGravitationalConstant,
+ damping: Number.isFinite(Number(next.damping)) ? Number(next.damping) : settings.damping,
+ springStiffness: Number.isFinite(Number(next.springStiffness))
+ ? Number(next.springStiffness) : settings.springStiffness,
};
if (data.relayout && model) {
generation += 1;
diff --git a/engraphis/dashboard_assets/engraphis-graph-every.js b/engraphis/dashboard_assets/engraphis-graph-every.js
index ed508c7c..7d3aea6f 100644
--- a/engraphis/dashboard_assets/engraphis-graph-every.js
+++ b/engraphis/dashboard_assets/engraphis-graph-every.js
@@ -8,7 +8,7 @@
(function () {
'use strict';
- const WORKER_URL = '/v2-assets/engraphis-graph-every-worker.js?v=20260823-every-19';
+ const WORKER_URL = '/v2-assets/engraphis-graph-every-worker.js?v=20260830-spacetime-controls-20';
const MAX_NODES = 20000;
const MAX_LINKS = 200000;
const LABEL_MAX = 220;
@@ -151,7 +151,9 @@
totalLinks: 0, edgeVertexCount: 0,
camera: { x: 0, y: 0, scale: 1 }, baseScale: 1, width: 1, height: 1, dpr: 1,
styleName: opts.style || 'cyber', colorBy: 'community', typeColors: {}, themeColors: {}, palette: 'theme',
- settings: { labels: true, flow: false, flowSpeed: 45, frozen: false, mode: 'communities', repel: 48, link: 16, gravity: 48, font: 12, size: 3, linkw: 0.72 },
+ settings: { labels: true, flow: false, flowSpeed: 45, frozen: false, mode: 'communities', repel: 48, link: 16, gravity: 48, font: 12, size: 3, linkw: 0.72,
+ gravitationalConstant: 1, blackHoleMass: 1, localGravitationalConstant: 1,
+ damping: 1, springStiffness: 1 },
sizeBy: 'degree', bridges: true, ghosts: true,
scope: { minDegree: 0, showUnlinked: true, depth: 2 },
collapse: false, collapsed: false,
@@ -1376,7 +1378,10 @@
const patch = value || {};
state.settings = { ...state.settings, ...patch };
state.flowPaintAt = 0;
- const relayout = Object.keys(patch).some(key => ['mode', 'repel', 'link', 'gravity'].includes(key));
+ const relayout = Object.keys(patch).some(key => [
+ 'mode', 'repel', 'link', 'gravity', 'gravitationalConstant', 'blackHoleMass',
+ 'localGravitationalConstant', 'damping', 'springStiffness',
+ ].includes(key));
postSettings(relayout);
uploadNodeMeta();
camera();
diff --git a/engraphis/dashboard_assets/engraphis-graph.js b/engraphis/dashboard_assets/engraphis-graph.js
index 62bb3333..0ff5a419 100644
--- a/engraphis/dashboard_assets/engraphis-graph.js
+++ b/engraphis/dashboard_assets/engraphis-graph.js
@@ -131,23 +131,27 @@
upward so the default (and every other position) feels like the reference layout. */
return base * boost * 4 * galaxyGravityStrengthMultiplier(value) * 2.0;
}
- /* Gravity strength is the galaxy-wide black-hole control. Its explicit zero endpoint selects
- the shallow carrier floor; local stellar wells are supplied independently by the calibrated
- local setting below. */
- /* Keep a shallow black-hole well at the loose endpoint. Galaxy is an orbital presentation:
- zero user gravity means the loosest bound orbit, not a one-time tangent followed by a
- straight-line escape. Local stellar wells remain independently calibrated below. */
+ /* Gravity strength is the galaxy-wide black-hole control. The dashboard's Gravity slider
+ flows to the explicit global anchor: zero user gravity is a real zero field, and the
+ loose ↔ tight endpoints map to distinct central accelerations. Stability for community
+ systems is owned by the independent local-stellar well and the rigid event-horizon
+ contact layers, neither of which depends on this constant. */
const GALAXY_GLOBAL_GRAVITY_FLOOR_SETTING = 24;
function galaxyBlackHoleGravitySetting(setting, explicitGlobal) {
const raw = Number(setting);
const value = Number.isFinite(raw) ? Math.max(0, Math.min(GALAXY_GRAVITY_MAXIMUM, raw)) : 0;
- return explicitGlobal === true ? Math.max(GALAXY_GLOBAL_GRAVITY_FLOOR_SETTING, value) : value;
+ return value;
}
function galaxyBlackHoleGravityConstant(setting, explicitGlobal) {
return galaxyGravityConstant(galaxyBlackHoleGravitySetting(setting, explicitGlobal)) * 2;
}
function galaxyLocalGravityConstant(setting) {
- return galaxyBlackHoleGravityConstant(setting) * 0.5;
+ const raw = Number(setting);
+ const value = Number.isFinite(raw) ? Math.max(0, Math.min(GALAXY_GRAVITY_MAXIMUM, raw)) : 0;
+ /* Routes through galaxyBlackHoleGravityConstant to preserve the canonical
+ 2x black-hole-to-local scaling; pre-fix code relied on the alias chain
+ galaxyLocalGravityConstant = galaxyBlackHoleGravityConstant * 0.5. */
+ return galaxyBlackHoleGravityConstant(value) * 0.5;
}
/* A fit-to-view galaxy compresses stellar and galactic distances onto one canvas, so using
one physical clock made a valid planet orbit visually disappear under its system's
@@ -158,15 +162,19 @@
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
- erase either level of the hierarchy: eligible community stars retain the calibrated
- default stellar well, while the explicit global anchor uses the smaller floor above. */
- const GALAXY_STELLAR_GRAVITY_FLOOR_SETTING = 48;
+ /* The dashboard's Gravity control owns the black-hole well, and the local stellar setting
+ flows 1:1 from the slider. All callers pass a finite slider value (or an explicit per-star
+ override), so every position 0..200 produces a distinct local well and distinct carrier
+ geometry. Authored system stability is owned by the orbital-radius floor and the rigid
+ event-horizon contact layers, neither of which depends on this constant.
+
+ The Every-node integration path uses a fixed local setting independent of the slider so
+ that mode behaves as a calibrated reference, not as a slider follower. */
+ const GALAXY_FIXED_LOCAL_GRAVITY_SETTING = 48;
function galaxyStellarGravitySetting(setting) {
const raw = Number(setting);
- const value = Number.isFinite(raw)
+ return Number.isFinite(raw)
? Math.max(0, Math.min(GALAXY_GRAVITY_MAXIMUM, raw)) : 0;
- return Math.max(GALAXY_STELLAR_GRAVITY_FLOOR_SETTING, value);
}
function galaxyStellarGravityConstant(setting) {
return galaxyLocalGravityConstant(galaxyStellarGravitySetting(setting))
@@ -495,9 +503,9 @@
remains 3.6x while the extended range adds the stronger high-end response. */
function galaxyInwardConvergencePerMinute(gravitySetting) {
const setting = gravitySetting === undefined ? 48 : gravitySetting;
- /* The convergence helper is an optional density response, not the orbital well. Keep its
- zero endpoint neutral even though the Galaxy carrier field retains a shallow floor so
- stars do not turn into straight-line projectiles at the loosest setting. */
+ /* The convergence helper is an optional density response, not the orbital well. Normalize
+ against the calibrated reference setting (48) so the ratio stays monotonic across the
+ full 0..200 slider span; the rigid event-horizon contact keeps loose-end bodies bound. */
const relativeGravity = galaxyBlackHoleGravityConstant(setting, false)
/ galaxyBlackHoleGravityConstant(48, true);
return 1 - Math.pow(1 - GALAXY_INWARD_CONVERGENCE_PER_MINUTE,
@@ -613,12 +621,52 @@
const MAX_AUTO_FIT_ZOOM = 4;
const SETTINGS_ALPHA_TARGET = 0.12;
const ALPHA_TARGET_HOLD_MS = 180;
+ /* Inline utility: bound a value to [min, max]. The dashboard pipeline does not expose
+ a shared math helper, so this lives here alongside the spacetime tuners that need it. */
+ function clamp(value, min, max) {
+ const n = Number(value);
+ if (!Number.isFinite(n)) return min;
+ return Math.max(min, Math.min(max, n));
+ }
+ /* Mirror of graphBlackHoleMassMultiplier in ledger.js — kept inline so the d3-force
+ d3-install path in this file does not need to cross reference the ledger module. The
+ formula is identical: baseline 160 below which the multiplier is value/160, above which
+ it climbs linearly at 0.02/unit (so 500 -> 8.80, 1000 -> 21.80). */
+ const GRAPH_BLACK_HOLE_MASS_BASELINE = 160;
+ function blackHoleMassMultiplier(controlValue) {
+ const value = Number(controlValue);
+ if (!Number.isFinite(value)) return 1;
+ return value <= GRAPH_BLACK_HOLE_MASS_BASELINE
+ ? Math.max(0, value / GRAPH_BLACK_HOLE_MASS_BASELINE)
+ : 1 + (value - GRAPH_BLACK_HOLE_MASS_BASELINE) * 0.02;
+ }
/* Physics is allowed to respond live, but one bad force update must never turn a
settled graph into a high-speed slingshot. Keep the bounds in world units so they
remain meaningful at every camera zoom. */
const MIN_NODE_SPEED = 8;
const MAX_NODE_SPEED = 48;
+ function galaxyRelativeSpeedBudget(parent, absoluteLimit, requested, directionX, directionY) {
+ const limit = Math.max(0.01, Number(absoluteLimit) || MAX_NODE_SPEED);
+ const requestedSpeed = Math.max(0, Number(requested) || 0);
+ const parentVx = parent && Number.isFinite(parent.vx) ? parent.vx : 0;
+ const parentVy = parent && Number.isFinite(parent.vy) ? parent.vy : 0;
+ const directionLength = Math.hypot(Number(directionX) || 0, Number(directionY) || 0);
+ if (!(directionLength > 1e-9)) {
+ return Math.max(0, Math.min(requestedSpeed,
+ limit - Math.hypot(parentVx, parentVy)));
+ }
+ const unitX = directionX / directionLength;
+ const unitY = directionY / directionLength;
+ const projection = parentVx * unitX + parentVy * unitY;
+ /* Solve |parentVelocity + unitTangent * relativeSpeed| <= limit for the largest
+ non-negative relativeSpeed. This preserves a perpendicular local orbit even when
+ the carrier is already close to the absolute speed ceiling. */
+ const discriminant = projection * projection + limit * limit
+ - parentVx * parentVx - parentVy * parentVy;
+ const maximum = -projection + Math.sqrt(Math.max(0, discriminant));
+ return Math.max(0, Math.min(requestedSpeed, maximum));
+ }
/* The classic renderer's *dense* signal (`GPERF.dense`, `links>1500` in dashboard.js). Past
it the classic path turns off the two per-edge costs that scale with the link count and
@@ -1060,6 +1108,7 @@
function seedGalaxyHierarchicalLocalOrbits(nodes, gravity, softening, options) {
const opts = options || {};
const orbitalSpeed = galaxyOrbitalSpeedMultiplier(opts.orbitalSpeed);
+ const absoluteSpeedLimit = Math.max(0.01, Number(opts.speedLimit) || MAX_NODE_SPEED);
const epsilon = Math.max(0.1, Number(softening) || 8);
const centers = galaxyOrbitGroups(nodes);
centers.forEach(center => {
@@ -1087,14 +1136,18 @@
* radius / Math.max(1e-9, denominator);
const acceleration = localAccelerationCap > 0
? Math.min(localAccelerationCap, rawAcceleration) : rawAcceleration;
- const targetTangent = Math.min(GALAXY_LOCAL_RELATIVE_SPEED_LIMIT,
- Math.sqrt(Math.max(0, acceleration * radius)) * orbitalSpeed);
const parentVx = Number.isFinite(parent.vx) ? parent.vx : 0;
const parentVy = Number.isFinite(parent.vy) ? parent.vy : 0;
const relativeVx = (Number.isFinite(node.vx) ? node.vx : 0) - parentVx;
const relativeVy = (Number.isFinite(node.vy) ? node.vy : 0) - parentVy;
const tangentX = -dy / radius, tangentY = dx / radius;
const currentTangent = relativeVx * tangentX + relativeVy * tangentY;
+ const sign = Math.sign(currentTangent)
+ || ((seededHash(opts.layoutSeed, 'system:' + String(parent.id)) & 1) ? 1 : -1);
+ const targetTangent = galaxyRelativeSpeedBudget(parent, absoluteSpeedLimit,
+ Math.min(GALAXY_LOCAL_RELATIVE_SPEED_LIMIT,
+ Math.sqrt(Math.max(0, acceleration * radius)) * orbitalSpeed),
+ tangentX * sign, tangentY * sign);
const parentId = String(parent.id);
const previousParent = typeof node.__galaxyOrbitAnchorId === 'string'
? node.__galaxyOrbitAnchorId : '';
@@ -1103,8 +1156,6 @@
|| Math.abs(previousSpeed - orbitalSpeed) > 1e-9;
const needsSeed = previousParent !== parentId || Math.abs(currentTangent) < 1e-8;
if (needsSeed || speedChanged) {
- const sign = Math.sign(currentTangent)
- || ((seededHash(opts.layoutSeed, 'system:' + parentId) & 1) ? 1 : -1);
node.vx = parentVx + tangentX * targetTangent * sign;
node.vy = parentVy + tangentY * targetTangent * sign;
}
@@ -1122,6 +1173,7 @@
function seedGalaxyOrbits(nodes, layoutSeed, gravity, softening, reducedMotion, options) {
const opts = options || {};
const orbitalSpeed = galaxyOrbitalSpeedMultiplier(opts.orbitalSpeed);
+ const absoluteSpeedLimit = Math.max(0.01, Number(opts.speedLimit) || MAX_NODE_SPEED);
const orbitalRadius = galaxyOrbitalRadiusMultiplier(opts.orbitalSpeed);
const speedControlEnabled = opts.restorePhase !== true
&& Number.isFinite(Number(opts.orbitalSpeed));
@@ -1358,11 +1410,14 @@
const inwardAcceleration = localAccelerationCap > 0
? Math.min(localAccelerationCap, rawInwardAcceleration) : rawInwardAcceleration;
const omega = Math.sqrt(Math.max(0, inwardAcceleration / speedRadius));
- const targetTangent = Math.min(GALAXY_LOCAL_RELATIVE_SPEED_LIMIT,
- omega * speedRadius * orbitalSpeed);
const relativeVx = (Number.isFinite(satellite.vx) ? satellite.vx : 0) - anchorVx;
const relativeVy = (Number.isFinite(satellite.vy) ? satellite.vy : 0) - anchorVy;
const tangent = (-dy * relativeVx + dx * relativeVy) / currentRadius;
+ const targetTangent = galaxyRelativeSpeedBudget(anchor, absoluteSpeedLimit,
+ Math.min(GALAXY_LOCAL_RELATIVE_SPEED_LIMIT,
+ omega * speedRadius * orbitalSpeed),
+ -dy / currentRadius * direction,
+ dx / currentRadius * direction);
const previousAnchorId = typeof satellite.__galaxyOrbitAnchorId === 'string'
? satellite.__galaxyOrbitAnchorId : '';
const anchoredHere = previousAnchorId === anchorId;
@@ -1833,7 +1888,6 @@
repulsionPadding, repulsionRange, repulsionAcceleration,
maximumAcceleration: 0, capScale: 1,
gravitySetting: galaxyAccelerationCapReference(opts.gravity),
- stellarGravityFloorSetting: GALAXY_STELLAR_GRAVITY_FLOOR_SETTING,
stellarGravity: galaxyStellarGravityConstant(localGravitySetting)
* galaxyPhysicsMultiplier(opts.localGravitationalConstant,
GALAXY_LOCAL_GRAVITATIONAL_CONSTANT_MULTIPLIER, 8),
@@ -1852,7 +1906,7 @@
if (anchor.anchor_role === 'community') {
stats.eligibleStellarAnchors++;
if (Number.isFinite(Number(localGravitySetting))
- && Number(localGravitySetting) < GALAXY_STELLAR_GRAVITY_FLOOR_SETTING) {
+ && Number(localGravitySetting) < GALAXY_FIXED_LOCAL_GRAVITY_SETTING) {
stats.stellarFloorActive = true;
}
} else if (anchor.anchor_role === 'global') stats.globalAnchors++;
@@ -2481,7 +2535,6 @@
gravitationalConstant, gravitationalConstantMultiplier,
blackHoleMassMultiplier,
gravitySetting: galaxyBlackHoleGravitySetting(opts.gravity, explicitGlobal),
- floorActive: explicitGlobal && Number(opts.gravity) < GALAXY_GLOBAL_GRAVITY_FLOOR_SETTING,
traversals: centers.size,
};
}
@@ -2808,6 +2861,7 @@
function advanceGalaxyKinematicLocalMembers(members, carrier, carrierTarget, options) {
const opts = options || {};
const orbitalSpeed = galaxyOrbitalSpeedMultiplier(opts.orbitalSpeed);
+ const absoluteSpeedLimit = Math.max(0.01, Number(opts.speedLimit) || MAX_NODE_SPEED);
const orbitalRadius = galaxyOrbitalRadiusMultiplier(opts.orbitalSpeed);
const localSoftening = Math.max(0.1, Number(opts.localSoftening) || opts.softening || 40);
const timestep = Math.max(0.001, Math.min(2, Number(opts.timestep) || 1));
@@ -2868,15 +2922,25 @@
const omega = Math.min(
Math.sqrt(Math.max(0, acceleration / localRadius)) * orbitalSpeed,
GALAXY_LOCAL_RELATIVE_SPEED_LIMIT * orbitalSpeed / localRadius);
- local.angle += local.direction * omega * timestep;
- const localSpeed = omega * localRadius;
+ const requestedLocalSpeed = omega * localRadius;
+ const localTangentX = -Math.sin(local.angle) * local.direction;
+ const localTangentY = Math.cos(local.angle) * local.direction;
+ const phaseSpeed = Math.min(
+ galaxyRelativeSpeedBudget(parentTarget, absoluteSpeedLimit,
+ requestedLocalSpeed, localTangentX, localTangentY),
+ galaxyRelativeSpeedBudget(parentTarget, absoluteSpeedLimit, requestedLocalSpeed),
+ );
+ const cappedOmega = phaseSpeed / Math.max(1e-9, localRadius);
+ local.angle += local.direction * cappedOmega * timestep;
const offsetX = Math.cos(local.angle) * localRadius;
const offsetY = Math.sin(local.angle) * localRadius;
+ const advancedTangentX = -Math.sin(local.angle) * local.direction;
+ const advancedTangentY = Math.cos(local.angle) * local.direction;
const target = {
x: parentTarget.x + offsetX,
y: parentTarget.y + offsetY,
- vx: parentTarget.vx - Math.sin(local.angle) * localSpeed * local.direction,
- vy: parentTarget.vy + Math.cos(local.angle) * localSpeed * local.direction,
+ vx: parentTarget.vx + advancedTangentX * phaseSpeed,
+ vy: parentTarget.vy + advancedTangentY * phaseSpeed,
};
targets.set(node, target);
visiting.delete(node);
@@ -2920,6 +2984,7 @@
if (!anchor || !(field.gravitationalConstant > 0)) return empty;
const timestep = Math.max(0.001, Math.min(2, Number(opts.timestep) || 1));
const orbitalRadius = galaxyOrbitalRadiusMultiplier(opts.orbitalSpeed);
+ const absoluteSpeedLimit = Math.max(0.01, Number(opts.speedLimit) || MAX_NODE_SPEED);
const direction = (seededHash(opts.layoutSeed, 'galaxy-spin') & 1) ? 1 : -1;
const envelope = galaxyFarFieldEnvelope(bodies, opts);
const nodeRadius = node => finitePositive(node.radius,
@@ -2937,9 +3002,16 @@
if (Number.isFinite(node.fx)) node.fx = x;
if (Number.isFinite(node.fy)) node.fy = y;
};
- const angularFrequency = (radius, authoredCarrier) => (authoredCarrier
- ? galaxyAuthoredCarrierTargetSpeed(field, radius, opts.orbitalSpeed)
- : galaxyCarrierTargetSpeed(field, radius, opts.orbitalSpeed)) / Math.max(1e-6, radius);
+ const angularFrequency = (radius, authoredCarrier) => {
+ const requestedSpeed = authoredCarrier
+ ? galaxyAuthoredCarrierTargetSpeed(field, radius, opts.orbitalSpeed)
+ : galaxyCarrierTargetSpeed(field, radius, opts.orbitalSpeed);
+ /* The carrier is the parent frame for every local orbit. Cap it before
+ constructing that frame, otherwise a high authored clock can make
+ the child speed budget infeasible and scatter the local system. */
+ const speed = Math.min(absoluteSpeedLimit, Math.max(0, requestedSpeed));
+ return speed / Math.max(1e-6, radius);
+ };
const boundedRadius = (radius, extent) => {
const inner = nodeRadius(anchor) + Math.max(0, extent)
+ GALAXY_BLACK_HOLE_EXCLUSION_PADDING;
@@ -4624,16 +4696,9 @@
if (relativeSpeed > limit) scale = Math.min(scale, limit / relativeSpeed);
});
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. */
let carrierAdjusted = false;
if (anchor && Number.isFinite(absoluteLimit)) {
const carrierSpeed = Math.hypot(referenceVx, referenceVy);
- const carrierAllowance = Math.max(0, absoluteLimit - carrierSpeed);
- if (systemMaximum > 1e-12) {
- scale = Math.min(scale, carrierAllowance / systemMaximum);
- }
if (carrierSpeed > absoluteLimit + 1e-12) {
const carrierScale = carrierSpeed > 1e-12 ? absoluteLimit / carrierSpeed : 0;
const targetVx = referenceVx * carrierScale;
@@ -4650,18 +4715,34 @@
minimumScale = Math.min(minimumScale, carrierScale);
}
}
- if (!(scale < 1 - 1e-12) && !carrierAdjusted) return;
+ let systemLimited = carrierAdjusted || scale < 1 - 1e-12;
members.forEach(node => {
if (node === anchor) {
node.vx = referenceVx;
node.vy = referenceVy;
return;
}
- node.vx = referenceVx + (node.vx - referenceVx) * scale;
- node.vy = referenceVy + (node.vy - referenceVy) * scale;
+ const relativeVx = node.vx - referenceVx, relativeVy = node.vy - referenceVy;
+ const relativeSpeed = Math.hypot(relativeVx, relativeVy);
+ if (!(relativeSpeed > 1e-12)) return;
+ let localScale = scale;
+ if (Number.isFinite(absoluteLimit)) {
+ const candidateVx = relativeVx * localScale, candidateVy = relativeVy * localScale;
+ const candidateSpeed = Math.hypot(candidateVx, candidateVy);
+ if (candidateSpeed > 1e-12) {
+ const allowed = galaxyRelativeSpeedBudget(
+ { vx: referenceVx, vy: referenceVy }, absoluteLimit, candidateSpeed,
+ candidateVx, candidateVy);
+ localScale = Math.min(localScale, allowed / candidateSpeed);
+ }
+ }
+ if (localScale < 1 - 1e-12) systemLimited = true;
+ minimumScale = Math.min(minimumScale, localScale);
+ node.vx = referenceVx + relativeVx * localScale;
+ node.vy = referenceVy + relativeVy * localScale;
});
+ if (!systemLimited) return;
limitedSystems++;
- minimumScale = Math.min(minimumScale, scale);
});
return {
systems: systems.length, limitedSystems, maximumRelativeSpeed, minimumScale, limit,
@@ -5674,6 +5755,8 @@
function applyGalaxyOrbitalSpeedControl(nodes, options) {
const opts = options || {};
const orbitalSpeed = galaxyOrbitalSpeedMultiplier(opts.orbitalSpeed);
+ const absoluteSpeedLimit = Number.isFinite(Number(opts.speedLimit))
+ ? Math.max(0.01, Number(opts.speedLimit)) : Number.POSITIVE_INFINITY;
const orbitalRadius = galaxyOrbitalRadiusMultiplier(opts.orbitalSpeed);
const bodies = (nodes || []).filter(node => node && !node.ghost
&& Number.isFinite(node.x) && Number.isFinite(node.y));
@@ -5713,6 +5796,21 @@
node.vx = (Number.isFinite(node.vx) ? node.vx : 0) + tangentX * delta;
node.vy = (Number.isFinite(node.vy) ? node.vy : 0) + tangentY * delta;
});
+ if (Number.isFinite(absoluteSpeedLimit) && carrier.id !== opts.fixedNodeId) {
+ const carrierVx = Number.isFinite(carrier.vx) ? carrier.vx : 0;
+ const carrierVy = Number.isFinite(carrier.vy) ? carrier.vy : 0;
+ const carrierSpeed = Math.hypot(carrierVx, carrierVy);
+ if (carrierSpeed > absoluteSpeedLimit) {
+ const scale = absoluteSpeedLimit / carrierSpeed;
+ const correctionX = carrierVx * scale - carrierVx;
+ const correctionY = carrierVy * scale - carrierVy;
+ members.forEach(node => {
+ if (node.id === opts.fixedNodeId) return;
+ node.vx = (Number.isFinite(node.vx) ? node.vx : 0) + correctionX;
+ node.vy = (Number.isFinite(node.vy) ? node.vy : 0) + correctionY;
+ });
+ }
+ }
stats.systems++;
};
field.systems.forEach(item => {
@@ -5819,16 +5917,27 @@
collision, and relation work may translate the whole system, but they cannot turn
a planet backward or pull it onto a chord through the star. */
const timestep = Math.max(0.001, Math.min(2, Number(opts.timestep) || 1));
- const angularSpeed = baseSpeed * orbitalSpeed / Math.max(1e-6, targetRadius);
+ const requestedRelativeSpeed = baseSpeed * orbitalSpeed;
+ const phaseTangentX = -Math.sin(phase.angle) * phase.direction;
+ const phaseTangentY = Math.cos(phase.angle) * phase.direction;
+ /* Use one scalar for the phase clock and emitted velocity. The final tangent rotates
+ during the step, so also apply the direction-independent residual cap; reusing a
+ pre-step directional budget after that rotation must never exceed the absolute cap. */
+ const phaseSpeed = Math.min(
+ galaxyRelativeSpeedBudget(parent, absoluteSpeedLimit,
+ requestedRelativeSpeed, phaseTangentX, phaseTangentY),
+ galaxyRelativeSpeedBudget(parent, absoluteSpeedLimit, requestedRelativeSpeed),
+ );
+ const angularSpeed = phaseSpeed / Math.max(1e-6, targetRadius);
phase.angle += phase.direction * angularSpeed * timestep;
const unitX = Math.cos(phase.angle), unitY = Math.sin(phase.angle);
const tangentX = -unitY * phase.direction, tangentY = unitX * phase.direction;
const targetX = parent.x + unitX * targetRadius;
const targetY = parent.y + unitY * targetRadius;
const targetVx = (Number.isFinite(parent.vx) ? parent.vx : 0)
- + tangentX * baseSpeed * orbitalSpeed;
+ + tangentX * phaseSpeed;
const targetVy = (Number.isFinite(parent.vy) ? parent.vy : 0)
- + tangentY * baseSpeed * orbitalSpeed;
+ + tangentY * phaseSpeed;
const shiftX = targetX - node.x, shiftY = targetY - node.y;
const velocityShiftX = targetVx - (Number.isFinite(node.vx) ? node.vx : 0);
const velocityShiftY = targetVy - (Number.isFinite(node.vy) ? node.vy : 0);
@@ -6020,7 +6129,10 @@
) : { applied: 0, maximumAcceleration: 0, maximumPull: 0 };
const systemVelocity = stabilizeGalaxySystemVelocities(bodies, {
limit: opts.localRelativeSpeedLimit,
- absoluteLimit: speedLimit,
+ /* The integrator applies the world-speed ceiling below as one common scale so the
+ mass-weighted local frame keeps its momentum. The direct helper still accepts an
+ absoluteLimit for callers that need a per-vector projection. */
+ absoluteLimit: Infinity,
fixedNodeId: opts.fixedNodeId,
});
/* Restore the pointer target before the final contacts. The strict horizon and cached outer
@@ -6270,7 +6382,9 @@
};
const finalSystemVelocity = stabilizeGalaxySystemVelocities(bodies, {
limit: opts.localRelativeSpeedLimit,
- absoluteLimit: speedLimit,
+ /* Keep the final local pass momentum-preserving; the common world-speed projection below
+ is the sole absolute cap for a leapfrog slice. */
+ absoluteLimit: Infinity,
fixedNodeId: opts.fixedNodeId,
});
systemVelocity.limitedSystems += finalSystemVelocity.limitedSystems;
@@ -7968,15 +8082,60 @@
charge = d3.forceManyBody();
fg.d3Force('charge', charge);
}
- if (charge && charge.strength) charge.strength(-(mode === 'communities' ? Math.max(10, s.repel * 0.68) : s.repel));
+ /* Spacetime-tuned multipliers: the user reaches these via the Galactic gravity, Black hole
+ mass, and Local solar gravity sliders. In non-galaxy mode the d3-force simulator is the
+ only consumer, so the multipliers must reach the d3 forces directly.
+
+ The dashboard normalizes these settings in
+ ledger.js::graphSpacetimeEngineSettings() to a clean 0..2 range with the visible
+ default at 1.0x. Consume the normalized values directly as the multipliers. A
+ user-moved 0 reaches the engine as 0 (no force), the default 1.0 (no change), and
+ the high end 2.0 (double force). The `Number.isFinite` check handles the *missing*
+ case: if ledger.js never supplied a value (the engine was constructed without the
+ dashboard wiring), fall back to the neutral 1.0x multiplier so the layout does
+ not collapse. */
+ const gcRaw = Number(state.settings.gravitationalConstant);
+ const lgcRaw = Number(state.settings.localGravitationalConstant);
+ const bhmRaw = Number(state.settings.blackHoleMass);
+ const gravityMultiplier = Number.isFinite(gcRaw) ? clamp(gcRaw, 0, 2) : 1;
+ const massMultiplier = Number.isFinite(bhmRaw) ? clamp(bhmRaw, 0, 2) : 1;
+ const localMultiplier = Number.isFinite(lgcRaw) ? clamp(lgcRaw, 0, 2) : 1;
+ const baseRepel = mode === 'communities' ? Math.max(10, s.repel * 0.68) : s.repel;
+ /* Galactic gravity is an attractive control. Keep the separate Repel slider on the
+ negative many-body charge, and apply this multiplier to the attractive anchor forces
+ below so increasing gravity tightens the layout instead of spreading it apart. */
+ if (charge && charge.strength) charge.strength(-baseRepel);
if (link && link.distance) link.distance(s.link);
if (link && link.strength) link.strength(edge => {
const source = typeof edge.source === 'object' ? edge.source : layoutById.get(linkEndpoint(edge, 'source'));
const target = typeof edge.target === 'object' ? edge.target : layoutById.get(linkEndpoint(edge, 'target'));
- return 1 / Math.max(1, Math.min(
+ const base = 1 / Math.max(1, Math.min(
source && source.degree || 1, target && target.degree || 1
));
+ return base * localMultiplier;
});
+ /* Space friction (the dashboard's "damping" slider) maps onto d3's velocityDecay. The
+ slider's 0..15 visible range must reach the full d3 decay range so the lower quarter
+ is not inert. At the default (slider=1) the size-aware baseline (0.38 small / 0.45
+ large) is the neutral settling behaviour, so the slider's effect is a *multiplier*
+ on that baseline, not a replacement. Above 1 the layout settles harder, below 1
+ it stays more elastic. */
+ if (fg.d3VelocityDecay) {
+ const dampingRaw = Number(state.settings.damping);
+ const damping = Number.isFinite(dampingRaw) ? clamp(dampingRaw, 0, 15) : 1;
+ const baseline = large ? 0.45 : 0.38;
+ /* Linearly interpolate between the d3 velocityDecay floor (0.05) at damping=0,
+ the size-aware baseline at damping=1, and the d3 velocityDecay ceiling (0.85)
+ at damping=15. The full 0..15 visible range is now meaningful, and the default
+ (damping=1) keeps the size-aware settling behaviour the rest of the engine
+ already assumes. */
+ const floor = 0.05;
+ const ceiling = 0.85;
+ const target = damping <= 1
+ ? floor + (baseline - floor) * damping
+ : baseline + (ceiling - baseline) * (damping - 1) / 14;
+ fg.d3VelocityDecay(clamp(target, floor, ceiling));
+ }
if (typeof d3 === 'undefined') {
installVelocityGuard();
return;
@@ -8007,19 +8166,23 @@
});
/* A gentle origin-based centering keeps the layout coherent without fighting a
drag; the community grid is still visible through the charge/repel and link
- structure installed above. */
- const centering = Math.max(0.04, (Number(s.gravity) || 0) / 100);
+ structure installed above. Black-hole mass multiplies the centering strength so
+ the slider visibly pulls nodes toward the origin. */
+ const centering = Math.max(0.04, (Number(s.gravity) || 0) / 100)
+ * massMultiplier * gravityMultiplier;
fg.d3Force('x', d3.forceX(0).strength(centering));
fg.d3Force('y', d3.forceY(0).strength(centering));
} else if (mode === 'radial' && d3.forceRadial) {
const outerRadius = Math.max(180, Math.min(360, Math.sqrt(Math.max(1, layoutNodes.length)) * 18 + (Number(s.link) || 16) * 4));
const degreeScale = Math.max(1, maxOf(layoutNodes.map(node => node.degree || 0), 1));
- fg.d3Force('x', d3.forceX(0).strength(Math.max(0.05, (Number(s.gravity) || 0) / 500)));
- fg.d3Force('y', d3.forceY(0).strength(Math.max(0.05, (Number(s.gravity) || 0) / 500)));
+ const centering = Math.max(0.05, (Number(s.gravity) || 0) / 500)
+ * massMultiplier * gravityMultiplier;
+ fg.d3Force('x', d3.forceX(0).strength(centering));
+ fg.d3Force('y', d3.forceY(0).strength(centering));
fg.d3Force('radial', d3.forceRadial(node => {
const hubness = Math.max(0, Math.min(1, (node.degree || 0) / degreeScale));
return 34 + (outerRadius - 34) * (1 - hubness);
- }).strength(0.72));
+ }).strength(0.72 * gravityMultiplier));
} else if (mode === 'constellation') {
const positions = new Map(), total = Math.max(1, layoutNodes.length - 1);
const reach = Math.max(160, Math.min(330, 80 + Math.sqrt(Math.max(1, layoutNodes.length)) * 10));
@@ -8031,10 +8194,18 @@
positions.set(node.id, { x: Math.cos(angle) * radius * 1.18, y: Math.sin(angle) * radius * 0.76 });
});
const target = node => positions.get(node.id) || { x: 0, y: 0 };
- fg.d3Force('x', d3.forceX(node => target(node).x).strength(0.18));
- fg.d3Force('y', d3.forceY(node => target(node).y).strength(0.18));
+ /* Black-hole mass scales the constellation's anchor strength so the slider is
+ visible in this preset too. */
+ const targetStrength = 0.18 * massMultiplier * gravityMultiplier;
+ fg.d3Force('x', d3.forceX(node => target(node).x).strength(targetStrength));
+ fg.d3Force('y', d3.forceY(node => target(node).y).strength(targetStrength));
} else {
- const centering = mode === 'compact' ? Math.max(0.24, (Number(s.gravity) || 0) / 100) : Math.max(0.06, (Number(s.gravity) || 0) / 100);
+ const baseCentering = mode === 'compact'
+ ? Math.max(0.24, (Number(s.gravity) || 0) / 100)
+ : Math.max(0.06, (Number(s.gravity) || 0) / 100);
+ /* Black-hole mass scales the centering so the slider pulls compact and original
+ layouts toward the origin in proportion to its setting. */
+ const centering = baseCentering * massMultiplier * gravityMultiplier;
fg.d3Force('x', d3.forceX(0).strength(centering));
fg.d3Force('y', d3.forceY(0).strength(centering));
}
@@ -8096,10 +8267,31 @@
const link = Math.max(4, Number(s.link) || 4);
const nodeSize = Math.max(1, Number(s.size) || 3);
const compactness = galaxyLayoutCompactness(s.gravity);
- const localGap = (4 + nodeSize * 1.6 + Math.sqrt(repel) * 0.8 + link * 0.16) * compactness;
+ const control = (value, fallback, min, max) => Number.isFinite(Number(value))
+ ? clamp(value, min, max) : fallback;
+ const coreAttraction = control(s.gravitationalConstant, 1, 0, 2);
+ const coreMass = control(s.blackHoleMass, 1, 0, 2);
+ const clusterCohesion = control(s.localGravitationalConstant, 1, 0, 2);
+ const settlingResistance = control(s.damping, 1, 0, 15);
+ const linkSpring = control(s.springStiffness, 1, 0, 100 / 32);
+ /* Keep zero-force endpoints finite without flattening the lower slider range. The 0.5
+ baseline preserves a neutral scale of one at the default product, while the explicit
+ bounded response caps a zero product at sqrt(2) instead of spreading the layout across
+ millions of world units. */
+ const responseScale = product => {
+ const magnitude = Math.max(0, Number(product) || 0);
+ return 1 / Math.sqrt(0.5 + 0.5 * magnitude);
+ };
+ const coreScale = responseScale(coreAttraction * coreMass);
+ const cohesionScale = responseScale(clusterCohesion * linkSpring);
+ const settlingScale = 1 + (settlingResistance - 1) * 0.02;
+ const layoutPhysicsScale = coreScale * cohesionScale * settlingScale;
+ const localGap = (4 + nodeSize * 1.6 + Math.sqrt(repel) * 0.8 + link * 0.16)
+ * compactness * layoutPhysicsScale;
const columns = Math.max(1, Math.ceil(Math.sqrt(ordered.length)));
const largestGroup = ordered.reduce((largest, [, nodes]) => Math.max(largest, nodes.length), 1);
- const cell = Math.max(90, Math.sqrt(largestGroup) * localGap * 2.4 + link * 3) * compactness;
+ const cell = Math.max(90, Math.sqrt(largestGroup) * localGap * 2.4 + link * 3)
+ * compactness * Math.max(0.5, Math.sqrt(layoutPhysicsScale));
const golden = Math.PI * (3 - Math.sqrt(5));
ordered.forEach(([, nodes], groupIndex) => {
nodes.sort((a, b) => (b.degree || 0) - (a.degree || 0) || String(a.id).localeCompare(String(b.id)));
@@ -8516,7 +8708,12 @@
dragSoftening: activeDragNode ? Math.max(GALAXY_DRAG_GRAVITY_SOFTENING,
finitePositive(activeDragNode.radius, 2, 160) * 1.5) : GALAXY_DRAG_GRAVITY_SOFTENING,
gravity: state.settings.gravity,
- localGravitySetting: GALAXY_STELLAR_GRAVITY_FLOOR_SETTING,
+ localGravitySetting: GALAXY_FIXED_LOCAL_GRAVITY_SETTING,
+ /* The dashboard normalises the three spacetime sliders to a 0..2 range
+ (default 1.0). Preserve that normalized value at the Galaxy boundary:
+ the downstream multiplier helpers clamp their own direct-call range,
+ and multiplying here made the default field 4x/8x stronger than the
+ value shown by the controls. */
gravitationalConstant: galaxyPhysicsMultiplier(
state.settings.gravitationalConstant, GALAXY_GRAVITATIONAL_CONSTANT_MULTIPLIER, 8),
localGravitationalConstant: galaxyPhysicsMultiplier(
@@ -8698,8 +8895,6 @@
dragFollowers: dragFollowers.map(follower => follower.node.id),
dragFollowerGravity: { ...dragFollowerGravityReport },
gravitySetting: state.settings.gravity,
- globalGravityFloorSetting: GALAXY_GLOBAL_GRAVITY_FLOOR_SETTING,
- globalGravityFloorActive: state.settings.gravity < GALAXY_GLOBAL_GRAVITY_FLOOR_SETTING,
gravityStrengthMultiplier: galaxyGravityStrengthMultiplier(state.settings.gravity),
gravityResponseRateMultiplier: GALAXY_GRAVITY_RESPONSE_RATE_MULTIPLIER,
/* The two normalized controls are independent: G_center owns black-hole and
@@ -8722,8 +8917,8 @@
GALAXY_SPRING_STIFFNESS_MULTIPLIER, 8),
effectiveGravity,
blackHoleGravity: effectiveGravity,
- localGravity: galaxyLocalGravityConstant(GALAXY_STELLAR_GRAVITY_FLOOR_SETTING),
- effectiveLocalGravity: galaxyStellarGravityConstant(GALAXY_STELLAR_GRAVITY_FLOOR_SETTING)
+ localGravity: galaxyLocalGravityConstant(GALAXY_FIXED_LOCAL_GRAVITY_SETTING),
+ effectiveLocalGravity: galaxyStellarGravityConstant(GALAXY_FIXED_LOCAL_GRAVITY_SETTING)
* galaxyPhysicsMultiplier(state.settings.localGravitationalConstant,
GALAXY_LOCAL_GRAVITATIONAL_CONSTANT_MULTIPLIER, 8),
immediateGravityResponse: { ...galaxyLastGravityResponse },
@@ -9066,10 +9261,18 @@
render(false, true);
return;
}
+ /* In galaxy mode the d3 reheat is a no-op for layout: galaxy owns the integrator and
+ setSettings already scaled carriers. The follow-up render still repaints and
+ re-asserts the contact-correction invariant, but those corrections are
+ path-dependent on intermediate slider phase and would undo a burst sweep.
+ Phase-preserve the contact-correction pass on the very next render
+ so the immediate response survives until the live integrator ticks. */
+ const phaseLock = state.settings.mode === 'galaxy';
physicsFrame = requestFrame(() => {
physicsFrame = 0;
if (destroyed || suspended || !physicsReheatPending) return;
physicsReheatPending = false;
+ if (phaseLock) preserveGalaxyPhaseOnResume = true;
render(false, true);
});
}
@@ -9139,7 +9342,7 @@
orbitalSpeed: state.settings.repel,
gravitationalConstant: state.settings.gravitationalConstant,
localGravitationalConstant: state.settings.localGravitationalConstant,
- localGravitySetting: GALAXY_STELLAR_GRAVITY_FLOOR_SETTING }
+ localGravitySetting: GALAXY_FIXED_LOCAL_GRAVITY_SETTING }
);
} else pinFullGraphLayout(data);
fullLayoutDirty = false;
@@ -9169,7 +9372,7 @@
orbitalSpeed: state.settings.repel,
gravitationalConstant: state.settings.gravitationalConstant,
localGravitationalConstant: state.settings.localGravitationalConstant,
- localGravitySetting: GALAXY_STELLAR_GRAVITY_FLOOR_SETTING }
+ localGravitySetting: GALAXY_FIXED_LOCAL_GRAVITY_SETTING }
);
seedGalaxySystemOrbits(
data.nodes, raw.meta && raw.meta.layout_seed,
@@ -9177,7 +9380,7 @@
{ gravitationalConstant: state.settings.gravitationalConstant,
blackHoleMass: state.settings.blackHoleMass,
orbitalSpeed: state.settings.repel,
- localGravitySetting: GALAXY_STELLAR_GRAVITY_FLOOR_SETTING }
+ localGravitySetting: GALAXY_FIXED_LOCAL_GRAVITY_SETTING }
);
} else clearPinnedPositions(data);
/* graphData() may paint synchronously. Enforce the event horizon after every layout
@@ -9247,7 +9450,7 @@
orbitalSpeed: state.settings.repel,
gravitationalConstant: state.settings.gravitationalConstant,
localGravitationalConstant: state.settings.localGravitationalConstant,
- localGravitySetting: GALAXY_STELLAR_GRAVITY_FLOOR_SETTING }
+ localGravitySetting: GALAXY_FIXED_LOCAL_GRAVITY_SETTING }
);
seedGalaxySystemOrbits(
data.nodes, raw.meta && raw.meta.layout_seed,
@@ -9255,12 +9458,14 @@
{ gravitationalConstant: state.settings.gravitationalConstant,
blackHoleMass: state.settings.blackHoleMass,
orbitalSpeed: state.settings.repel,
- localGravitySetting: GALAXY_STELLAR_GRAVITY_FLOOR_SETTING }
+ localGravitySetting: GALAXY_FIXED_LOCAL_GRAVITY_SETTING }
);
}
/* Reused arrays bypass graphData(); size changes, static repins, and restored phases still
- receive the same strict painted-edge invariant before the next redraw. */
- if (reused && galaxyMode) {
+ receive the same strict painted-edge invariant before the next redraw — unless the
+ slider burst just rescaled carriers, in which case the corrections would fold the
+ burst's intermediate ratios into the layout (path dependence). */
+ if (reused && galaxyMode && !skipGalaxyReseed) {
const prePaintHorizon = applyGalaxyBlackHoleExclusion(
data.nodes, { padding: GALAXY_BLACK_HOLE_EXCLUSION_PADDING }
);
@@ -9312,7 +9517,17 @@
intentionally untouched; the fixed-step clock owns all three physical concerns. */
if (!galaxyMode && fg.d3AlphaDecay) fg.d3AlphaDecay(staticFullLayout ? 1 : alphaDecay());
if (!galaxyMode && fg.d3VelocityDecay) {
- fg.d3VelocityDecay(large ? 0.45 : 0.38);
+ /* applyForces() above already installed the user-facing damping slider value. The
+ size-aware baseline (0.38 small / 0.45 large) is only the *default* when the user
+ has not touched the slider, so this fallback must not clobber a value the user has
+ already set. The proxy in the test harness (and the real force-graph) returns the
+ same function for any property access, so we cannot ask "was the setter called?" —
+ instead we honour the slider's value whenever it is finite, and only fall back to
+ the size-aware baseline when the dashboard never supplied a damping value. */
+ const dampingSetting = Number(state.settings.damping);
+ if (!Number.isFinite(dampingSetting)) {
+ fg.d3VelocityDecay(large ? 0.45 : 0.38);
+ }
}
if (fg.linkCurvature) {
fg.linkCurvature(dense ? 0 : ((PRESETS[state.settings.mode] || PRESETS.compact).curve || 0));
@@ -9460,7 +9675,7 @@
const insertion = galaxySlingshotCapture(node, data.nodes || [],
dragReleaseVelocity, {
gravity: state.settings.gravity,
- localGravitySetting: GALAXY_STELLAR_GRAVITY_FLOOR_SETTING,
+ localGravitySetting: GALAXY_FIXED_LOCAL_GRAVITY_SETTING,
localGravitationalConstant: state.settings.localGravitationalConstant,
softening: galaxyLiveSoftening(),
layoutSeed: raw.meta && raw.meta.layout_seed,
@@ -9948,6 +10163,21 @@
const gravityChanged = next.gravity !== undefined
&& Number.isFinite(previousGravity) && Number.isFinite(nextGravity)
&& Math.abs(nextGravity - previousGravity) > 1e-12;
+ /* A galaxy slider burst (gravity / black-hole mass / damping / etc.) is a setting change,
+ not a fresh physics seed. Set the phase-preserve flag *before* any render below so the
+ inner immediate-render does not re-seed orbits and overwrite the just-scaled carrier
+ phase with a fresh seed-time correction. Without this, the user sees the carriers jump
+ back outward the moment the radial contraction would have crossed a system-anchor
+ exclusion boundary, and path-independence across a burst breaks. */
+ if (previousMode === 'galaxy' && state.settings.mode === 'galaxy'
+ && next.repel === undefined
+ && (next.gravity !== undefined || next.size !== undefined
+ || next.gravitationalConstant !== undefined || next.G_center !== undefined
+ || next.localGravitationalConstant !== undefined || next.G_star !== undefined
+ || next.blackHoleMass !== undefined || next.damping !== undefined
+ || next.springStiffness !== undefined)) {
+ preserveGalaxyPhaseOnResume = true;
+ }
if (gravityChanged && previousMode === 'galaxy' && state.settings.mode === 'galaxy') {
/* Gravity changes need an immediate, legible density response: a range control whose
visible result is only a slow orbital-velocity correction reads as broken. Scale
@@ -9999,6 +10229,13 @@
velocityAdjusted: 0, maximumVelocityShift: 0, anchorId: anchor.id,
};
render(false, false);
+ /* Re-arm: the inner render consumed the flag. The outer render below must also
+ skip the contact-correction pass so the burst's ratios never fold into the
+ layout (path independence). */
+ if (gravityChanged && previousMode === 'galaxy'
+ && state.settings.mode === 'galaxy') {
+ preserveGalaxyPhaseOnResume = true;
+ }
}
}
}
@@ -10017,16 +10254,10 @@
api.freeze(false);
return;
}
- /* Gravity, size, and coupling controls change the sampled field or paint geometry on the
- next fixed slice; they do not authorize a one-shot velocity rewrite in the same task.
- Preserve the exact current phase while the scheduled clock absorbs the new setting. */
- if (previousMode === 'galaxy' && state.settings.mode === 'galaxy'
- && next.repel === undefined
- && (next.gravity !== undefined || next.size !== undefined
- || next.gravitationalConstant !== undefined || next.G_center !== undefined
- || next.localGravitationalConstant !== undefined || next.G_star !== undefined
- || next.blackHoleMass !== undefined || next.damping !== undefined
- || next.springStiffness !== undefined)) {
+ /* Re-arm for the outer render + the synchronous physics reheat that schedulePhysicsUpdate
+ may run: both share the render path and would otherwise run the path-dependent
+ contact corrections on the post-scaling layout, undoing the slider's burst response. */
+ if (gravityChanged && previousMode === 'galaxy' && state.settings.mode === 'galaxy') {
preserveGalaxyPhaseOnResume = true;
}
render(false, false);
@@ -10508,12 +10739,10 @@
galaxyBlackHoleGravityConstant, galaxyBlackHoleGravitySetting,
galaxyCarrierTargetSpeed, galaxyAuthoredCarrierTargetSpeed,
galaxyBlackHoleSpinAngle, advanceGalaxyBlackHoleSpin,
- galaxyGlobalGravityFloorSetting: GALAXY_GLOBAL_GRAVITY_FLOOR_SETTING,
galaxyLocalGravityConstant,
galaxyLocalGravityMultiplier,
galaxyStellarGravityConstant, galaxyFallbackStellarGravityConstant,
galaxySystemGravityConstant, galaxyStellarGravitySetting,
- galaxyStellarGravityFloorSetting: GALAXY_STELLAR_GRAVITY_FLOOR_SETTING,
defaultGalaxyStellarAccelerationCap, defaultGalaxySystemAccelerationCap,
galaxySceneWithinLiveLimit,
galaxyRelationOrbitScale, galaxyOrbitalSpeedMultiplier, galaxyOrbitalRadiusMultiplier,
diff --git a/engraphis/dashboard_assets/index.html b/engraphis/dashboard_assets/index.html
index fb078074..e473f641 100644
--- a/engraphis/dashboard_assets/index.html
+++ b/engraphis/dashboard_assets/index.html
@@ -7,7 +7,7 @@
Engraphis Ledger
-
+
Skip to main content
@@ -708,6 +708,6 @@