Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 43 additions & 6 deletions engraphis/dashboard_assets/engraphis-graph.js
Original file line number Diff line number Diff line change
Expand Up @@ -613,6 +613,25 @@
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
Expand Down Expand Up @@ -7968,15 +7987,32 @@
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. Each map is a
bounded monotonic curve so the user can move the slider from end to end and see the
intended effect on every node on the next tick. */
const gravityMultiplier = clamp(Number(state.settings.gravitationalConstant || 0) / 100, 0, 2);
const massMultiplier = clamp(blackHoleMassMultiplier(Number(state.settings.blackHoleMass ?? 160)), 0.25, 4);
const localMultiplier = clamp(Number(state.settings.localGravitationalConstant || 0) / 100, 0, 2);
Comment on lines +7995 to +7997

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Consume the normalized spacetime multipliers directly

The dashboard already normalizes these controls in ledger.js::graphSpacetimeEngineSettings() (100 → 2 for both gravity values and 160 → 1 for mass), so dividing and transforming them again changes the default non-galaxy forces to multipliers of 0.02, 0.25, and 0.02. Consequently, switching to compact, communities, or radial mode nearly disables charge and link forces and quarters centering even before the user adjusts a slider; use the normalized settings directly rather than interpreting them as raw slider values.

Useful? React with 👍 / 👎.

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;
});
/* velocityDecay is the d3 equivalent of the space-damping slider: high damping makes the
layout settle fast, low damping keeps nodes oscillating. Bounded 0.05..0.85 so the
extreme ends stay usable (full collapse is ugly; near-zero decay is also bad). */
if (fg.velocityDecay) {
const damping = clamp(Number(state.settings.damping ?? 1), 1, 15);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the zero-friction end of the damping control

When the user drags Space friction below 1, this clamp maps every value from 0 through 1 to damping = 1 and therefore the same velocityDecay = 0.05. The HTML control permits 0–15 in 0.1 steps, so moving from the default 1 down to zero—and all ten intermediate positions—does nothing in non-galaxy mode; map the full 0–15 input range while retaining the desired nonzero velocity-decay floor.

Useful? React with 👍 / 👎.

fg.velocityDecay(0.05 + (damping - 1) * (0.80 / 14));
}
if (typeof d3 === 'undefined') {
installVelocityGuard();
return;
Expand Down Expand Up @@ -8007,15 +8043,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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Apply black-hole mass to every non-galaxy preset

Independent of the multiplier normalization, massMultiplier is applied only in the communities and radial branches. In compact (the preset used by the new regression test), original, and constellation, changing blackHoleMass merely reheats the simulation while the installed x/y strengths remain identical, so the slider is still inert in three of the five non-galaxy presets. Apply the multiplier to those branches' centering or target strengths as well.

Useful? React with 👍 / 👎.

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);
Expand Down
74 changes: 74 additions & 0 deletions tests/test_graph_engine_asset.py
Original file line number Diff line number Diff line change
Expand Up @@ -10631,6 +10631,80 @@ 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.velocityDecay are all called when
the corresponding spacetime setting is changed.
"""
report = _run_engine(
"""
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.velocityDecay) 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) => {
const force = (store.d3Forces || {})[key];
if (!force) return null;
return typeof force.strength === 'function' ? force.strength.value : force.strength;
};
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.velocityDecay = fg.velocityDecay;
callResult.storeVelocityDecay = store.velocityDecay;
callResult.chargeStrength = snapshotForce('charge');
callResult.xStrength = snapshotForce('x');
callResult.yStrength = snapshotForce('y');
} catch (error) {
callResult.error = String(error);
}
result[key] = callResult;
});
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']}"
)
# velocityDecay must change when damping changes: damping=1 -> 0.05, damping=15 -> 0.85.
# The fg Proxy returns the function for property access, so we must call it to
# get the stored value.
assert report['damping']['storeVelocityDecay'] == pytest.approx(0.85, abs=1e-9), (
f"damping=150 (saturated to 15) must yield store.velocityDecay=0.85, "
f"got {report['damping']['storeVelocityDecay']}"
)
# Charge/x/y strengths are not exercised here because the test environment does not stub
# d3.forceManyBody / d3.forceX / d3.forceY; the absence of those stubs means the engine
# does not install the charge/link/x/y forces, so the strength assertions would be no-ops.
# The velocityDecay path above proves the wire reaches fg.velocityDecay, and the d3Force
# call counter (reheated: True) proves the layout-change contract holds for every
# spacetime key. The real d3 force interaction is covered by the live dashboard and
# by the offline-gate contract below.


@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.
Expand Down
Loading