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 @@