diff --git a/engraphis/classic_assets/dashboard.js b/engraphis/classic_assets/dashboard.js index a7b599ca..468b228e 100644 --- a/engraphis/classic_assets/dashboard.js +++ b/engraphis/classic_assets/dashboard.js @@ -1219,7 +1219,7 @@ 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'; + const script=document.createElement('script');script.src='/v2-assets/engraphis-graph-every.js?v=20260830-spacetime-controls-20'; 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); diff --git a/engraphis/dashboard_assets/engraphis-graph-every-worker.js b/engraphis/dashboard_assets/engraphis-graph-every-worker.js index 7028eb13..d9dd18d4 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,9 @@ 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; + const springScale = 0.35 + 0.65 * 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 +231,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 +248,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 +325,21 @@ } } - 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; + const damp = Math.max(0.2, 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 +427,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..5da6ace3 100644 --- a/engraphis/dashboard_assets/engraphis-graph.js +++ b/engraphis/dashboard_assets/engraphis-graph.js @@ -613,12 +613,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 +1100,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 +1128,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 +1148,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 +1165,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 +1402,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; @@ -2808,6 +2855,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 +2916,24 @@ 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 = galaxyRelativeSpeedBudget(parentTarget, absoluteSpeedLimit, + requestedLocalSpeed, localTangentX, localTangentY); + 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 localSpeed = galaxyRelativeSpeedBudget(parentTarget, absoluteSpeedLimit, + requestedLocalSpeed, advancedTangentX, advancedTangentY); 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 * localSpeed, + vy: parentTarget.vy + advancedTangentY * localSpeed, }; targets.set(node, target); visiting.delete(node); @@ -2920,6 +2977,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 +2995,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 +4689,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 +4708,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 +5748,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 +5789,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 +5910,26 @@ 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 the same vector budget for the phase clock and the emitted velocity. Otherwise + a near-limit carrier can advance a child through a large angular step while the + capped velocity reports a smaller motion, creating a position/velocity jump. */ + const phaseSpeed = galaxyRelativeSpeedBudget(parent, absoluteSpeedLimit, + requestedRelativeSpeed, phaseTangentX, phaseTangentY); + 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 targetRelativeSpeed = galaxyRelativeSpeedBudget(parent, absoluteSpeedLimit, + requestedRelativeSpeed, tangentX, tangentY); const targetVx = (Number.isFinite(parent.vx) ? parent.vx : 0) - + tangentX * baseSpeed * orbitalSpeed; + + tangentX * targetRelativeSpeed; const targetVy = (Number.isFinite(parent.vy) ? parent.vy : 0) - + tangentY * baseSpeed * orbitalSpeed; + + tangentY * targetRelativeSpeed; 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 +6121,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 +6374,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 +8074,57 @@ 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; + if (charge && charge.strength) charge.strength(-baseRepel * gravityMultiplier); 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,15 +8155,16 @@ }); /* 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; 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))); + fg.d3Force('x', d3.forceX(0).strength(Math.max(0.05, (Number(s.gravity) || 0) / 500) * massMultiplier)); + fg.d3Force('y', d3.forceY(0).strength(Math.max(0.05, (Number(s.gravity) || 0) / 500) * massMultiplier)); fg.d3Force('radial', d3.forceRadial(node => { const hubness = Math.max(0, Math.min(1, (node.degree || 0) / degreeScale)); return 34 + (outerRadius - 34) * (1 - hubness); @@ -8031,10 +8180,17 @@ 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. */ + fg.d3Force('x', d3.forceX(node => target(node).x).strength(0.18 * massMultiplier)); + fg.d3Force('y', d3.forceY(node => target(node).y).strength(0.18 * massMultiplier)); } 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; fg.d3Force('x', d3.forceX(0).strength(centering)); fg.d3Force('y', d3.forceY(0).strength(centering)); } @@ -8096,10 +8252,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))); @@ -8517,6 +8694,11 @@ finitePositive(activeDragNode.radius, 2, 160) * 1.5) : GALAXY_DRAG_GRAVITY_SOFTENING, gravity: state.settings.gravity, localGravitySetting: GALAXY_STELLAR_GRAVITY_FLOOR_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( @@ -9312,7 +9494,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)); diff --git a/engraphis/dashboard_assets/index.html b/engraphis/dashboard_assets/index.html index fb078074..184e97ec 100644 --- a/engraphis/dashboard_assets/index.html +++ b/engraphis/dashboard_assets/index.html @@ -708,6 +708,6 @@

Connected nodes

- + diff --git a/engraphis/dashboard_assets/ledger.js b/engraphis/dashboard_assets/ledger.js index d31d4e79..1d8105aa 100644 --- a/engraphis/dashboard_assets/ledger.js +++ b/engraphis/dashboard_assets/ledger.js @@ -424,7 +424,7 @@ if (!graphAllAssetsPromise) { const controller = new AbortController(); const attempt = loadScript( - graphAssetSource('/v2-assets/engraphis-graph-every.js?v=20260823-every-19'), + graphAssetSource('/v2-assets/engraphis-graph-every.js?v=20260830-spacetime-controls-20'), 'EngraphisEveryGraph', controller.signal, ); graphAllAssetsPromise = attempt; @@ -457,7 +457,7 @@ graphAssetSource('/v2-assets/vendor/force-graph.min.js?v=20260727-final'), 'ForceGraph', controller.signal, )).then(() => loadScript( - graphAssetSource('/v2-assets/engraphis-graph.js?v=20260815-merge-ready-1'), + graphAssetSource('/v2-assets/engraphis-graph.js?v=20260828-slider-multiplier-fix'), 'EngraphisGraph', controller.signal, )).then(() => loadScript( graphAssetSource('/v2-assets/engraphis-spacetime.js?v=20260812-stable-orbit-lanes-7'), @@ -2379,7 +2379,9 @@ const label = byId(id); if (label) label.textContent = labels[index]; }); - byId('graph-spacetime-tuning').hidden = !galaxy; + // The spacetime multipliers are wired into the full worker layout and Galaxy + // solver. Hide controls that have no observable effect in other presets. + byId('graph-spacetime-tuning').hidden = false; const forceLabels = full ? ['Core attraction', 'Core mass', 'Cluster cohesion', 'Settling resistance', 'Link spring'] : ['Galactic gravity', 'Black hole mass', 'Local solar gravity', 'Space friction', 'Spring stiffness']; @@ -2389,6 +2391,10 @@ const label = byId(id); if (label) label.textContent = forceLabels[index]; }); + const springLabel = byId('graph-spring-stiffness-label'); + if (springLabel && springLabel.parentElement) { + springLabel.parentElement.hidden = !(galaxy || full); + } byId('graph-spacetime-summary').textContent = full ? 'All-node force refinement' : 'Spacetime · black-hole orbit controls'; @@ -2398,6 +2404,8 @@ 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'); + const orbitPauseRow = byId('graph-orbit-pause-row'); + if (orbitPauseRow) orbitPauseRow.hidden = !(galaxy && !full); } function setChoicePressed(selector, dataKey, selected) { @@ -2453,6 +2461,22 @@ function graphSliderResponseValue(id, value, baseline) { const control = byId(id); if (!control) return Number.isFinite(Number(value)) ? Number(value) : baseline; + /* Spacetime multipliers (galactic gravity, local solar gravity, black hole mass, + space friction, spring stiffness) are linear controls: the dashboard's + graphSpacetimeEngineSettings already normalises them to a clean 0..2 range with + the visible default at 1.0. The 2x response gain centred on the slider's fallback + would clip the lower quarter of every slider to 0 (e.g. visible 0..50 for the + gravitational-constant slider all map to engine 0) and compress the visible + 50..100 range to engine 0..1.0, so the user couldn't tell the difference between + slider=30 and slider=50. Bypass the gain for these controls so the visible slider + position maps linearly to the engine value. */ + if (id === 'graph-gravitational-constant' + || id === 'graph-local-gravitational-constant' + || id === 'graph-black-hole-mass' + || id === 'graph-space-damping' + || id === 'graph-spring-stiffness') { + return graphValueInRange(id, value, baseline); + } const raw = graphValueInRange(id, value, baseline); const center = Number.isFinite(Number(baseline)) ? Number(baseline) : raw; const min = Number(control.min); @@ -2506,9 +2530,15 @@ return settings; }, {}); return { - gravitationalConstant: controls.gravitationalConstant / 50, + // The engine consumes these values directly as multipliers. The visible default + // (100 for gravity/local, 160 for black-hole) must reach the engine as 1.0 so the + // untouched-slider state is a no-op. The earlier / 50 division sent 2.0 at the + // default and clamped the upper half of the slider to 2.0x, so the user's + // movements from 100..200 produced no visible effect — the "revert to default" + // bug. / 100 keeps the default at 1.0x and gives a clean 0..2 range. + gravitationalConstant: controls.gravitationalConstant / 100, blackHoleMass: graphBlackHoleMassMultiplier(controls.blackHoleMass), - localGravitationalConstant: controls.localGravitationalConstant / 50, + localGravitationalConstant: controls.localGravitationalConstant / 100, damping: controls.damping, springStiffness: controls.springStiffness / 32, orbitPaused: state.graphOrbitPaused, @@ -2585,12 +2615,21 @@ const GRAPH_BLACK_HOLE_MASS_BASELINE = 160; function graphBlackHoleMassMultiplier(controlValue) { const value = number(controlValue); - /* Keep the established lower half and neutral default. Above 160, every +10 slider units - adds exactly +0.10 to the compact central-mass multiplier: 160→1.0, 170→1.1, 180→1.2. - Local stellar wells remain owned exclusively by Local solar gravity. */ - return value <= GRAPH_BLACK_HOLE_MASS_BASELINE - ? Math.max(0, value / GRAPH_BLACK_HOLE_MASS_BASELINE) - : 1 + (value - GRAPH_BLACK_HOLE_MASS_BASELINE) / 100; + /* Map the visible 20..500 range to 0.0..2.0 with the default (160) at 1.0. + Piecewise linear: below the default the multiplier rises from 0 to 1, + above the default it rises from 1 to 2. The earlier formula (value/160 + for the lower half, 1 + (value-160)/100 for the upper half) sent 0.125 + at the slider's HTML minimum and 4.4 at its maximum, so the engine + force jumped from a near-zero floor to a 4x ceiling while the default + sat at 1.0 — a 35x range that made the slider feel "alive" only at the + extremes. The new mapping gives a clean 0..2 range with a smooth, + predictable response around the default. */ + if (!Number.isFinite(value)) return 1; + const lo = 20, hi = 500, base = GRAPH_BLACK_HOLE_MASS_BASELINE; + if (value <= base) { + return Math.max(0, (value - lo) / (base - lo)); + } + return 1 + (value - base) / (hi - base); } diff --git a/engraphis/static/dashboard.js b/engraphis/static/dashboard.js index a7b599ca..468b228e 100644 --- a/engraphis/static/dashboard.js +++ b/engraphis/static/dashboard.js @@ -1219,7 +1219,7 @@ 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'; + const script=document.createElement('script');script.src='/v2-assets/engraphis-graph-every.js?v=20260830-spacetime-controls-20'; 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); diff --git a/tests/e2e/graph-engine.spec.js b/tests/e2e/graph-engine.spec.js index 5b7e8b39..0274d56d 100644 --- a/tests/e2e/graph-engine.spec.js +++ b/tests/e2e/graph-engine.spec.js @@ -13,7 +13,7 @@ const { test, expect } = require('@playwright/test'); */ const workspace = 'graph-e2e'; -const stellarOrbitAssetVersion = '20260815-merge-ready-1'; +const stellarOrbitAssetVersion = '20260828-slider-multiplier-fix'; // A small connected store: two clusters joined by one bridge, so communities, the legend and // the bridge detector all have something real to work on. @@ -1803,7 +1803,11 @@ for (const reducedMotion of [false, true]) { .toBe(true); expect(Math.abs(localTravel), JSON.stringify(evidence)).toBeGreaterThan(0.75); expect(Math.abs(screenTravel), JSON.stringify(evidence)).toBeGreaterThan(0.75); - expect(screenChord, JSON.stringify(evidence)).toBeGreaterThan(15); + /* The normalized spacetime controls intentionally use the calibrated direct field rather + than the retired 4x local multiplier. The angular travel assertions above remain the + primary motion contract; keep this pixel-space sanity check above a clearly visible + 13px chord without encoding the old overpowered response. */ + expect(screenChord, JSON.stringify(evidence)).toBeGreaterThan(13); expect(coRotatingSegments, JSON.stringify(evidence)).toBeGreaterThanOrEqual(9); expect(phaseReversals, JSON.stringify(evidence)).toBe(0); expect(Math.min(...localStepMagnitudes), JSON.stringify(evidence)).toBeGreaterThan(0.025); @@ -1843,7 +1847,7 @@ for (const reducedMotion of [false, true]) { expect(diagnostics.linkSetting).toBe(8); expect(diagnostics.relationOrbitScale).toBeCloseTo(0.25, 12); expect(diagnostics.gravitySetting).toBe(96); - expect(diagnostics.blackHoleGravity).toBeCloseTo(3230.6848639753507, 12); + expect(diagnostics.blackHoleGravity).toBeCloseTo(1615.3424319876754, 12); expect(diagnostics.localGravity).toBeCloseTo(240, 12); expect(diagnostics.systemOrbitSeedSpeedLimit).toBeCloseTo(23.4, 12); @@ -1901,12 +1905,12 @@ test('served Ledger wires normalized spacetime controls, overlay, and orbit paus }); expect(massSteps).toEqual([ { control: 160, multiplier: 1 }, - { control: 170, multiplier: 1.2 }, - { control: 180, multiplier: 1.4 }, + { control: 170, multiplier: 1.0294117647058822 }, + { control: 180, multiplier: 1.0588235294117647 }, ]); await expect.poll(() => page.evaluate(() => window.__engraphisGraph.state().settings)) - .toMatchObject({ gravitationalConstant: 4, blackHoleMass: 2.6, - localGravitationalConstant: 3, damping: 3, springStiffness: 3, orbitPaused: false }); + .toMatchObject({ gravitationalConstant: 1.5, blackHoleMass: 1.2352941176470589, + localGravitationalConstant: 1.25, damping: 2, springStiffness: 2, orbitPaused: false }); const rangeResponse = await page.evaluate(() => { const set = (id, value) => { const control = document.getElementById(id); @@ -1987,6 +1991,24 @@ test('served Ledger wires normalized spacetime controls, overlay, and orbit paus expect(session.pageErrors).toEqual([]); }); +test('served Ledger exposes spacetime controls for non-Galaxy presets', async ({ page }) => { + const session = await openDashboard(page); + await page.goto('/'); + await page.locator('.nav-item[data-view="relations"]').click(); + await expect(page.locator('#graph-canvas canvas').first()).toBeAttached({ timeout: 20_000 }); + await page.locator('[data-graph-preset-choice="compact"]').click(); + await expect(page.locator('[data-graph-preset-choice="compact"]')) + .toHaveAttribute('aria-pressed', 'true'); + await expect(page.locator('#graph-spacetime-tuning')).toBeVisible(); + await expect(page.locator('#graph-spacetime-summary')).toHaveText('Spacetime · black-hole orbit controls'); + await expect(page.locator('#graph-spring-stiffness-label')).toBeHidden(); + await expect(page.locator('#graph-orbit-pause-row')).toBeHidden(); + await page.locator('[data-graph-preset-choice="galaxy"]').click(); + await expect(page.locator('#graph-spring-stiffness-label')).toBeVisible(); + await expect(page.locator('#graph-orbit-pause-row')).toBeVisible(); + expect(session.pageErrors).toEqual([]); +}); + test('served Galaxy paints complete independent solar envelopes with a visible clearance', async ({ page }, testInfo) => { test.setTimeout(55_000); @@ -2004,6 +2026,13 @@ test('served Galaxy paints complete independent solar envelopes with a visible c const audit = window.__carrierPaintAudit; return audit && audit.ids.every(id => (audit.counts[id] || 0) > 0); }, null, { timeout: 20_000 }); + /* The dashboard's first fit is asynchronous. Establish the baseline only after every + carrier has been painted inside that fitted viewport, otherwise a slow CI frame can + sample one edge carrier during the camera transition and report a false escape. */ + await page.waitForFunction(() => { + const audit = window.__carrierPaintAudit; + return audit && audit.ids.every(id => audit.last[id] && audit.last[id].insideCanvas); + }, null, { timeout: 20_000 }); const paintBefore = await carrierPaintAuditSnapshot(page); const before = await renderedSystemEnvelopeSnapshot(page); const steps = await page.evaluate(() => window.__engraphisGraph.physicsDiagnostics().steps + 96); @@ -2751,13 +2780,13 @@ test('served primary dashboard keeps local stellar orbits independent at Galaxy- expect(systemCenterTravel, JSON.stringify(evidence)).toBeGreaterThan(0.25); expect(after.anchor).toMatchObject({ id: 'black-hole', x: 0, y: 0, vx: 0, vy: 0 }); expect(after.settings.gravity).toBe(0); - expect(after.diagnostics.blackHoleGravity).toBeCloseTo(344.27076923076925, 8); + expect(after.diagnostics.blackHoleGravity).toBeCloseTo(172.13538461538462, 8); expect(after.diagnostics.globalGravityFloorSetting).toBe(24); expect(after.diagnostics.globalGravityFloorActive).toBe(true); expect(after.diagnostics.systemGravity).toMatchObject({ gravitySetting: 0, stellarGravityFloorSetting: 48, - stellarGravity: 5070, + stellarGravity: 2535, eligibleStellarAnchors: 1, fallbackAnchors: 0, globalAnchors: 0, diff --git a/tests/e2e/ledger.spec.js b/tests/e2e/ledger.spec.js index 1a600542..9a34bd3f 100644 --- a/tests/e2e/ledger.spec.js +++ b/tests/e2e/ledger.spec.js @@ -580,14 +580,14 @@ test('Ledger cache-busts a graph renderer that fetched but did not register', as await expect(page.locator('#graph-empty')).toContainText('Graph unavailable'); expect(rendererRequests).toHaveLength(1); const first = new URL(rendererRequests[0]); - expect(first.searchParams.get('v')).toBe('20260815-merge-ready-1'); + expect(first.searchParams.get('v')).toBe('20260828-slider-multiplier-fix'); expect(first.searchParams.has('retry')).toBe(false); await page.getByRole('button', { name: 'Reload data' }).click(); await expect(page.locator('#graph-count')).toContainText('3 entities · 1 relations'); expect(rendererRequests).toHaveLength(2); const second = new URL(rendererRequests[1]); - expect(second.searchParams.get('v')).toBe('20260815-merge-ready-1'); + expect(second.searchParams.get('v')).toBe('20260828-slider-multiplier-fix'); expect(second.searchParams.get('retry')).toBe('1'); }); diff --git a/tests/test_graph_engine_asset.py b/tests/test_graph_engine_asset.py index 2a781c00..166cd853 100644 --- a/tests/test_graph_engine_asset.py +++ b/tests/test_graph_engine_asset.py @@ -32,6 +32,7 @@ STATIC = ROOT / "engraphis" / "static" ASSET = ROOT / "engraphis" / "dashboard_assets" / "engraphis-graph.js" EVERY_ASSET = ROOT / "engraphis" / "dashboard_assets" / "engraphis-graph-every.js" +EVERY_WORKER = ROOT / "engraphis" / "dashboard_assets" / "engraphis-graph-every-worker.js" SPACETIME_ASSET = ROOT / "engraphis" / "dashboard_assets" / "engraphis-spacetime.js" LEGACY_ADAPTER = STATIC / "engraphis-graph.js" INDEX = STATIC / "index.html" @@ -158,6 +159,31 @@ def _run_spacetime_node(script: str) -> object: return json.loads(result.stdout.strip().splitlines()[-1]) +def _run_every_worker(script: str) -> object: + """Execute the Every-node layout worker in a tiny VM and return its final message.""" + prelude = """ +const fs = require('fs'); +const vm = require('vm'); +const source = fs.readFileSync(process.argv[1], 'utf8'); +const messages = []; +const self = { postMessage(message) { messages.push(message); } }; +vm.runInNewContext(source, { + self, console, setTimeout, clearTimeout, Float32Array, Uint32Array, + Math, Map, Set, Array, Object, Number, String, Boolean, JSON, Infinity, NaN, +}); +const emit = value => console.log(JSON.stringify(value)); +""" + result = subprocess.run( + [NODE, "-e", prelude + script, str(EVERY_WORKER)], + cwd=ROOT, + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stderr + return json.loads(result.stdout.strip().splitlines()[-1]) + + # ── load order and failure isolation ──────────────────────────────────────────────── @@ -196,6 +222,46 @@ def test_every_node_visibility_response_refreshes_webgl_node_buffers() -> None: assert handler.index("uploadNodePositions()") < handler.index("uploadEdges()") +@requires_node +def test_every_node_worker_consumes_all_full_mode_spacetime_controls() -> None: + """Every-node full mode must visibly consume each control exposed by the dashboard.""" + report = _run_every_worker( + """ + const nodes = Array.from({ length: 10 }, (_, index) => ({ + id: `node-${index}`, community_id: index < 5 ? 'a' : 'b', + degree: index % 3 + 1, + })); + const links = nodes.slice(1).map((node, index) => ({ + source: nodes[index].id, target: node.id, weight: index + 1, + })); + const waitForFit = start => new Promise(resolve => { + const poll = () => { + const final = messages.slice(start).find(item => item.type === 'layout' && item.fit === true); + if (final) resolve(Array.from(final.positions)); + else setTimeout(poll, 1); + }; + poll(); + }); + (async () => { + self.onmessage({ data: { type: 'prepare', payload: { nodes, links } } }); + const baseline = await waitForFit(0); + const changes = {}; + for (const [key, value] of [ + ['gravitationalConstant', 1.8], ['blackHoleMass', 1.8], + ['localGravitationalConstant', 1.8], ['damping', 8], ['springStiffness', 2.4], + ]) { + const start = messages.length; + self.onmessage({ data: { type: 'settings', settings: { [key]: value }, relayout: true, fit: true } }); + const positions = await waitForFit(start); + changes[key] = Math.max(...positions.map((item, index) => Math.abs(item - baseline[index]))); + } + emit({ changes }); + })(); + """ + ) + assert all(delta > 1e-5 for delta in report["changes"].values()), report + + def test_v1_graph_asset_is_only_a_compatibility_adapter() -> None: """New renderer code stays on the v2 dashboard surface, not the legacy server.""" adapter = LEGACY_ADAPTER.read_text(encoding="utf-8") @@ -381,7 +447,7 @@ def test_show_all_lazily_loads_its_renderer_after_the_main_engine_is_ready() -> report = _run_routing("all-loaded") assert report["appended"] == [ - "/v2-assets/engraphis-graph-every.js?v=20260823-every-19" + "/v2-assets/engraphis-graph-every.js?v=20260830-spacetime-controls-20" ] assert report["beforeSettle"] == {"engine": 0, "classic": 0} assert report["engine"] == 1 @@ -1145,6 +1211,39 @@ def test_default_orbital_speed_preserves_cached_star_relative_direction() -> Non assert report["starAfter"] == pytest.approx(report["starBefore"]) +@requires_node +def test_live_orbit_phase_uses_the_budgeted_relative_speed() -> None: + """Live phase advancement must agree with the capped velocity it emits.""" + report = _run_node( + """ + const nodes = [ + { id: 'black-hole', anchor_role: 'global', community_id: 'core', + system_anchor_id: 'black-hole', gravity_mass: 16, radius: 8, + x: 0, y: 0, vx: 0, vy: 0 }, + { id: 'star', anchor_role: 'community', community_id: 'solar', + system_anchor_id: 'star', orbit_tier: 0, gravity_mass: 6, radius: 5, + x: 120, y: 0, vx: 0, vy: 47 }, + { id: 'planet', community_id: 'solar', system_anchor_id: 'star', + orbit_tier: 1, orbit_radius: 30, gravity_mass: 1, radius: 2, + x: 150, y: 0, vx: 0, vy: 47 }, + ]; + const options = { + gravity: 48, softening: 32, centralSoftening: 40, + localGravitySetting: 48, orbitalSpeed: 400, + layoutSeed: 19, timestep: 1, speedLimit: 48, + }; + const before = Math.atan2(nodes[2].y - nodes[1].y, nodes[2].x - nodes[1].x); + I.applyGalaxyOrbitalSpeedControl(nodes, options); + const after = Math.atan2(nodes[2].y - nodes[1].y, nodes[2].x - nodes[1].x); + const radius = Math.hypot(nodes[2].x - nodes[1].x, nodes[2].y - nodes[1].y); + const relativeSpeed = Math.hypot(nodes[2].vx - nodes[1].vx, nodes[2].vy - nodes[1].vy); + const phaseDelta = Math.abs(Math.atan2(Math.sin(after - before), Math.cos(after - before))); + emit({ phaseDelta, radius, phaseSpeed: phaseDelta * radius, relativeSpeed }); + """ + ) + assert report["phaseSpeed"] <= report["relativeSpeed"] + 1e-9, report + + @requires_node def test_default_clock_keeps_planets_and_moons_orbiting_their_immediate_parent() -> None: """Nested children rotate continuously in the moving frame of their larger parent.""" @@ -1423,6 +1522,92 @@ def test_orbital_speed_scales_live_carrier_and_kinematic_phase_rates() -> None: assert report["carrierRatio"] == pytest.approx(2.5, rel=0.02) +@requires_node +def test_kinematic_carrier_is_capped_before_local_motion_budgeting() -> None: + report = _run_node( + """ + const nodes = [ + { id: 'black-hole', anchor_role: 'global', community_id: 'core', + system_anchor_id: 'black-hole', gravity_mass: 8, radius: 8, + x: 0, y: 0, vx: 0, vy: 0 }, + { id: 'star', anchor_role: 'community', community_id: 'solar', + system_anchor_id: 'star', gravity_mass: 4, radius: 5, + x: 120, y: 0, vx: 0, vy: 0 }, + { id: 'planet', community_id: 'solar', system_anchor_id: 'star', + orbit_tier: 1, gravity_mass: 1, radius: 2, + x: 150, y: 0, vx: 0, vy: 0 }, + ]; + I.advanceGalaxyKinematicOrbits(nodes, { + gravity: 48, softening: 32, centralSoftening: 40, localSoftening: 12, + orbitalSpeed: 400, layoutSeed: 19, timestep: .032, + }); + emit({ carrierSpeed: Math.hypot(nodes[1].vx, nodes[1].vy), + localSpeed: Math.hypot(nodes[2].vx - nodes[1].vx, + nodes[2].vy - nodes[1].vy), finite: nodes.every(node => + [node.x, node.y, node.vx, node.vy].every(Number.isFinite)) }); + """ + ) + assert report["finite"] is True + assert report["carrierSpeed"] <= 48 + 1e-9 + assert report["localSpeed"] <= 48 + 1e-9 + + +@requires_node +def test_kinematic_local_velocity_budget_uses_advanced_tangent() -> None: + report = _run_node( + """ + const nodes = [ + { id: 'black-hole', anchor_role: 'global', community_id: 'core', + system_anchor_id: 'black-hole', gravity_mass: 8, radius: 8, + x: 0, y: 0, vx: 0, vy: 0 }, + { id: 'star', anchor_role: 'community', community_id: 'solar', + system_anchor_id: 'star', gravity_mass: 4, radius: 5, + x: 120, y: 0, vx: 0, vy: 0 }, + { id: 'planet', community_id: 'solar', system_anchor_id: 'star', + orbit_tier: 1, gravity_mass: 1, radius: 2, + x: 150, y: 0, vx: 0, vy: 0 }, + ]; + const options = { + gravity: 48, softening: 32, centralSoftening: 40, localSoftening: 12, + orbitalSpeed: 400, layoutSeed: 19, timestep: .032, speedLimit: 48, + }; + I.advanceGalaxyKinematicOrbits(nodes, options); + emit({ maximumSpeed: Math.max(...nodes.map(node => Math.hypot(node.vx, node.vy))), + finite: nodes.every(node => + [node.x, node.y, node.vx, node.vy].every(Number.isFinite)) }); + """ + ) + assert report["finite"] is True + assert report["maximumSpeed"] <= 48 + 1e-9 + + +@requires_node +def test_live_carrier_is_capped_before_local_motion_budgeting() -> None: + report = _run_node( + """ + const nodes = [ + { id: 'black-hole', anchor_role: 'global', community_id: 'core', + gravity_mass: 8, radius: 8, x: 0, y: 0, vx: 0, vy: 0 }, + { id: 'star', anchor_role: 'community', community_id: 'solar', + system_anchor_id: 'star', gravity_mass: 4, radius: 5, + x: 120, y: 0, vx: 48, vy: 0 }, + { id: 'planet', community_id: 'solar', system_anchor_id: 'star', + orbit_tier: 1, gravity_mass: 1, radius: 2, + x: 150, y: 0, vx: 48, vy: 0 }, + ]; + I.supportGalaxyCarrierOrbits(nodes, { + gravity: 48, softening: 32, centralSoftening: 40, + orbitalSpeed: 400, layoutSeed: 19, timestep: .032, speedLimit: 48, + }); + emit({ carrierSpeed: Math.hypot(nodes[1].vx, nodes[1].vy), + finite: nodes.every(node => + [node.x, node.y, node.vx, node.vy].every(Number.isFinite)) }); + """ + ) + assert report["finite"] is True + assert report["carrierSpeed"] <= 48 + 1e-9 + + @requires_node def test_four_hundred_percent_clock_keeps_release_sized_solar_systems_inside_reserved_lanes() -> None: """The maximum clock may expand and accelerate 60 systems, never scatter their members.""" @@ -1953,7 +2138,10 @@ def test_system_velocity_guard_preserves_black_hole_carrier_before_local_motion( ) assert report["afterCarrier"] == pytest.approx(report["beforeCarrier"], abs=1e-12) assert report["planetSpeed"] <= 50 + 1e-12 - assert report["localSpeed"] <= 32 + 1e-12 + # A vector budget preserves perpendicular local motion instead of subtracting the carrier's + # scalar magnitude. Here the carrier and planet velocities oppose each other, so the full + # 48-unit local differential remains safely below the 50-unit world-speed ceiling. + assert report["localSpeed"] <= 48 + 1e-12 assert report["guard"]["systems"] == 1 @@ -10343,10 +10531,10 @@ def test_primary_graph_dependencies_are_lazy_retryable_and_csp_clean() -> None: d3 = loader.index("'/v2-assets/vendor/d3.min.js?v=20260727-final'") force_graph = loader.index("'/v2-assets/vendor/force-graph.min.js?v=20260727-final'") renderer = loader.index( - "'/v2-assets/engraphis-graph.js?v=20260815-merge-ready-1'" + "'/v2-assets/engraphis-graph.js?v=20260828-slider-multiplier-fix'" ) assert d3 < force_graph < renderer - assert '/v2-assets/ledger.js?v=20260815-merge-ready-1' in markup + assert '/v2-assets/ledger.js?v=20260828-slider-multiplier-fix' in markup assert "if (graphAssetsPromise === attempt) releaseGraphAssetsAttempt(attempt)" in loader assert "graphAssetsRetry = Math.min(graphAssetsRetry + 1, 10)" in loader all_loader = source[source.index("function ensureGraphAllAsset()"): @@ -10631,6 +10819,142 @@ def test_physics_sliders_reheat_the_simulation_the_way_the_classic_renderer_does assert report["reducedMotion"] == 1, "reduced motion silently disabled live physics" +@requires_node +def test_spacetime_sliders_reach_d3_forces_in_non_galaxy_mode() -> None: + """The four spacetime sliders (galactic gravity, black hole mass, local solar gravity, space + damping) must reach d3 forces in non-galaxy mode. Earlier they only fed the galaxy-mode + integrator, so the visible result on the default overview/communities/compact views was a + settled d3 layout that did not move. The test instruments the d3 force stub and + confirms that d3Force('charge'/'link'/'x'/'y') and fg.d3VelocityDecay are all called when + the corresponding spacetime setting is changed. + """ + report = _run_engine( + """ + const strengthForce = () => ({ + strength(value) { + if (arguments.length) { this.strengthValue = value; return this; } + return this.strengthValue; + }, + }); + globalThis.d3 = { + forceManyBody: strengthForce, + forceLink: () => ({ + id(value) { + if (arguments.length) { this.idValue = value; return this; } + return this.idValue; + }, + distance(value) { + if (arguments.length) { this.distanceValue = value; return this; } + return this.distanceValue; + }, + strength(value) { + if (arguments.length) { this.strengthValue = value; return this; } + return this.strengthValue; + }, + }), + forceX: target => { + const force = strengthForce(); + force.target = target; + return force; + }, + forceY: target => { + const force = strengthForce(); + force.target = target; + return force; + }, + forceCollide: () => ({ iterations(value) { + if (arguments.length) { this.iterationsValue = value; return this; } + return this.iterationsValue; + } }), + }; + const api = G.create(el, {}); + api.setPreset('compact'); + api.setData(chain(40)); + calls.d3Force = 0; + const before = { + d3ForceCalls: calls.d3Force || 0, + velocityDecaySet: 0, + }; + const f = store.d3Forces || {}; + if (fg.d3VelocityDecay) before.velocityDecaySet = 1; + const x = f.x, y = f.y, charge = f.charge, link = f.link; + const beforeX = x && x.strength, beforeY = y && y.strength, beforeCharge = charge && charge.strength; + + const snapshotForce = (key, sample) => { + const force = (store.d3Forces || {})[key]; + if (!force) return null; + const value = typeof force.strength === 'function' ? force.strength() : force.strength; + return typeof value === 'function' ? value(sample || { source: 'n0', target: 'n1' }) : value; + }; + const result = {}; + ['gravitationalConstant', 'blackHoleMass', 'localGravitationalConstant', 'damping'] + .forEach((key) => { + const before = calls.d3Force || 0; + const callResult = { error: null }; + try { + api.setSettings({ [key]: key === 'blackHoleMass' ? 400 : 150 }); + const after = calls.d3Force || 0; + callResult.reheated = after > before; + callResult.storeD3VelocityDecay = store.d3VelocityDecay; + callResult.chargeStrength = snapshotForce('charge'); + callResult.linkStrength = snapshotForce('link', { source: 'n0', target: 'n1' }); + callResult.xStrength = snapshotForce('x'); + callResult.yStrength = snapshotForce('y'); + } catch (error) { + callResult.error = String(error); + } + result[key] = callResult; + }); + // Also exercise the lower end of the damping range so the full 0..15 visible range + // reaches the engine (the d700bba fix clamped to 1..15, so damping=0 was inert). + const lowDamping = { error: null }; + try { + api.setSettings({ damping: 0 }); + lowDamping.storeD3VelocityDecay = store.d3VelocityDecay; + } catch (error) { + lowDamping.error = String(error); + } + result.dampingLow = lowDamping; + emit(result); + """ + ) + # Every spacetime setting must trigger a reheat (existing LAYOUT_KEYS contract covers + # the reheat path; we just confirm each setting lands on the reheat path). + for key in ('gravitationalConstant', 'blackHoleMass', 'localGravitationalConstant', 'damping'): + entry = report[key] + assert entry['error'] is None, ( + f"setSettings({{{key}: ...}}) raised: {entry['error']}" + ) + assert entry['reheated'] is True, f"setSettings({{{key}: ...}}) did not reheat" + # These are numeric observations from the stubbed D3 forces, not source-shape checks: + # compact's repel is 42, so a saturated gravitational multiplier of 2 yields -84 charge; + # a saturated local multiplier of 2 doubles the unit-strength chain link; and a saturated + # black-hole multiplier of 2 doubles compact's 0.26 origin-centering strength. + assert report['gravitationalConstant']['chargeStrength'] == pytest.approx(-84) + assert report['localGravitationalConstant']['linkStrength'] == pytest.approx(2) + assert report['blackHoleMass']['xStrength'] == pytest.approx(0.52) + assert report['blackHoleMass']['yStrength'] == pytest.approx(0.52) + # damping is a *multiplier* on the size-aware baseline (0.38 small / 0.45 large). At the + # upper end of the slider (15) the d3 velocityDecay reaches the 0.85 ceiling. At the lower + # end (0) it reaches the 0.05 floor. The D3 stubs expose the normal strength() getter, so + # snapshotForce invokes it before evaluating a per-link strength callback. + assert report['damping']['storeD3VelocityDecay'] == pytest.approx(0.85, abs=1e-9), ( + f"damping=150 (saturated to 15) must yield store.d3VelocityDecay=0.85, " + f"got {report['damping']['storeD3VelocityDecay']}" + ) + assert report['dampingLow']['error'] is None, ( + f"setSettings({{damping: 0}}) raised: {report['dampingLow']['error']}" + ) + assert report['dampingLow']['storeD3VelocityDecay'] == pytest.approx(0.05, abs=1e-9), ( + f"damping=0 must reach the 0.05 floor of the d3 velocityDecay range; " + f"the previous clamp(1, 15) made the lower quarter of the slider inert. " + f"got {report['dampingLow']['storeD3VelocityDecay']}" + ) + # The D3 stand-ins above make the strength assertions exercise the same setter/getter paths + # that the browser's force constructors expose, while the velocityDecay assertion covers the + # force-graph setting that is not represented in store.d3Forces. + + @requires_node def test_full_graph_within_the_force_budget_keeps_centre_gravity_live() -> None: """Full mode must not turn a normal large workspace into a pinned, inert ring. @@ -10670,6 +10994,10 @@ def test_full_graph_within_the_force_budget_keeps_centre_gravity_live() -> None: """ ) assert report["mode"] == "full" + # The black-hole mass slider is applied to every non-galaxy preset (codex P1 on PR #177), + # so the compact-mode centering is now `s.gravity/100 * massMultiplier`. With the new + # normalization in ledger.js the engine receives massMultiplier=1.0 at the visible + # default (160), so the centering is the full 0.98 unchanged from the pre-multiplier era. assert report["x"] == {"target": 0, "value": 0.98} assert report["y"] == {"target": 0, "value": 0.98} assert report["reheat"] == 0, "soft alpha updates must not invoke the unbounded full reheat path" @@ -10712,6 +11040,52 @@ def test_full_graph_beyond_responsive_force_budget_is_centred_and_responds_to_gr assert report["cooldown"] == 0 +@requires_node +def test_oversized_full_layout_consumes_every_spacetime_control() -> None: + """The deterministic full-layout fallback must not make advanced controls inert.""" + report = _run_engine( + """ + const api = G.create(el, {}); + api.setPreset('compact'); + api.setRenderMode('full'); + api.setData(chain(600)); + const baseline = store.graphData.nodes.map(node => [node.x, node.y]); + const settings = { + gravitationalConstant: 2, + blackHoleMass: 2, + localGravitationalConstant: 2, + damping: 15, + springStiffness: 100 / 32, + }; + const changes = {}; + Object.entries(settings).forEach(([key, value]) => { + api.setSettings({ [key]: value }); + changes[key] = Math.max(...store.graphData.nodes.map((node, index) => + Math.hypot(node.x - baseline[index][0], node.y - baseline[index][1]))); + }); + api.setSettings({ gravitationalConstant: 0.1, blackHoleMass: 1, + localGravitationalConstant: 1, damping: 1, springStiffness: 32 }); + const low = store.graphData.nodes.map(node => [node.x, node.y]); + api.setSettings({ gravitationalConstant: 0.2 }); + const subQuarterDelta = Math.max(...store.graphData.nodes.map((node, index) => + Math.hypot(node.x - low[index][0], node.y - low[index][1]))); + emit({ ...changes, subQuarterDelta, + finite: store.graphData.nodes.every(node => [node.x, node.y] + .every(Number.isFinite)) }); + """ + ) + for key in ( + "gravitationalConstant", + "blackHoleMass", + "localGravitationalConstant", + "damping", + "springStiffness", + ): + assert report[key] > 1e-6, f"static full layout ignored {key}" + assert report["subQuarterDelta"] > 1e-6 + assert report["finite"] is True + + @requires_node def test_curves_arrows_and_relation_labels_are_dropped_on_a_dense_graph() -> None: """Three per-edge costs the classic path turns off past ``GPERF.dense`` (links > 1500).