diff --git a/engraphis/dashboard_assets/engraphis-graph.js b/engraphis/dashboard_assets/engraphis-graph.js index 62bb3333..379b8050 100644 --- a/engraphis/dashboard_assets/engraphis-graph.js +++ b/engraphis/dashboard_assets/engraphis-graph.js @@ -9320,10 +9320,20 @@ fg.linkDirectionalArrowLength(dense ? 0 : 0.625).linkDirectionalArrowRelPos(1); applyLinkLabels(); if (fg.linkDirectionalParticles) { + const rawFlowSpeed = Number(state.settings.flowSpeed); + /* flowSpeed=0 means "stop" — particles must not render at all. The every-node engine + already enforces this via a `moving = speed > 0` check; the compat engine must do + the same. Otherwise the slider visibly does nothing at the low end (particles keep + crawling at the residual 0.002 floor). When a standalone caller omits flowSpeed + (Number(undefined) → NaN), fall back to the historical default of 45 so the + speed formula below stays finite and the active check stays meaningful. */ + const flowSpeed = Number.isFinite(rawFlowSpeed) ? rawFlowSpeed : 45; + const flowActive = flowSpeed > 0; const flowing = !fullGraph && state.settings.flow !== false && motion && !reducedMotion + && flowActive && data.links.length <= PARTICLE_LINK_LIMIT; const particles = !flowing ? 0 @@ -9332,7 +9342,10 @@ .linkDirectionalParticleWidth(1) .linkDirectionalParticleCanvasObject(paintFlowArrow) .linkDirectionalParticleColor(l => alpha(layerColor(l.layer), 0.95)) - .linkDirectionalParticleSpeed(l => 0.002 + ((state.settings.flowSpeed || 45) / 100) * 0.008); + /* Widened from `0.002 + (flowSpeed/100)*0.008` (a 5x range, 0.002..0.01) to + `0.0005 + (flowSpeed/100)*0.025` (a ~34x range, 0.00075..0.0255) so the slider + is visibly responsive end-to-end. */ + .linkDirectionalParticleSpeed(l => flowActive ? (0.0005 + (flowSpeed / 100) * 0.025) : 0); } if (!galaxyMode && reheat && motion && !staticFullLayout && !state.settings.frozen) { prepareReheat(); diff --git a/engraphis/dashboard_assets/ledger.js b/engraphis/dashboard_assets/ledger.js index d31d4e79..cb523f64 100644 --- a/engraphis/dashboard_assets/ledger.js +++ b/engraphis/dashboard_assets/ledger.js @@ -2453,6 +2453,16 @@ function graphSliderResponseValue(id, value, baseline) { const control = byId(id); if (!control) return Number.isFinite(Number(value)) ? Number(value) : baseline; + /* Flow speed is a linear control: the 2x response centered at 45 would + clamp every visible value in the lower quarter of the slider to + 0, which the compat engine then treats as "stop" and the user + sees an inert slider end. Use the raw value for flow-speed and + keep the 2x response for the geometry controls (gravity, repel, + link, etc.) where the centered calibration is intentional. */ + if (id === 'graph-flow-speed') { + const rawFlow = graphValueInRange(id, value, baseline); + return Number.isFinite(Number(rawFlow)) ? Number(rawFlow) : baseline; + } const raw = graphValueInRange(id, value, baseline); const center = Number.isFinite(Number(baseline)) ? Number(baseline) : raw; const min = Number(control.min); diff --git a/tests/e2e/graph-engine.spec.js b/tests/e2e/graph-engine.spec.js index 5b7e8b39..fd93a625 100644 --- a/tests/e2e/graph-engine.spec.js +++ b/tests/e2e/graph-engine.spec.js @@ -1937,7 +1937,7 @@ test('served Ledger wires normalized spacetime controls, overlay, and orbit paus }; }); expect(rangeResponse.settings).toMatchObject({ - flowSpeed: 85, repel: 200, link: 32, gravity: 144, size: 5, font: 20, + flowSpeed: 65, repel: 200, link: 32, gravity: 144, size: 5, font: 20, linkw: 1.28, labelDensity: 56, }); expect(rangeResponse.scope).toEqual({ minDegree: 2, depth: 3 }); diff --git a/tests/test_graph_engine_asset.py b/tests/test_graph_engine_asset.py index 2a781c00..5f313072 100644 --- a/tests/test_graph_engine_asset.py +++ b/tests/test_graph_engine_asset.py @@ -10631,6 +10631,56 @@ 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_flow_speed_slider_has_a_visible_range_in_compat_engine() -> None: + """The compat engine's flow-speed slider must produce a visibly larger particle speed at + the top of the range than at the bottom. Earlier the speed formula + `0.002 + (flowSpeed/100)*0.008` produced a 5x range (0.002..0.01) that was too small to be + noticeable end-to-end. The fix widens that to a 34x range + (`0.0005 + (flowSpeed/100)*0.025` -> 0.00075..0.0255). The test snapshots the per-link + speed closure at the two ends of the slider and asserts the high end is materially + larger than the low end. + """ + report = _run_engine( + """ + const api = G.create(el, {}); + api.setPreset('compact'); + api.setData(chain(40)); + const linkForSample = { layer: 'semantic' }; + const sample = (flowSpeed) => { + api.setSettings({ flowSpeed, flow: true }); + // linkDirectionalParticleSpeed is a chainable setter; the engine has stored a + // per-link function on the stub. Sample it on a non-suggested, non-ghost link. + const speedFn = store.linkDirectionalParticleSpeed; + return typeof speedFn === 'function' ? speedFn(linkForSample) : null; + }; + const low = sample(1); + const mid = sample(50); + const high = sample(100); + const stop = sample(0); + emit({ low, mid, high, stop }); + """ + ) + # At flowSpeed=0 the engine short-circuits the closure to 0 (particles do not move). + assert report["stop"] == 0, ( + f"flowSpeed=0 must yield particle speed 0 (compat engine stop-at-zero), " + f"got {report['stop']}" + ) + # At flowSpeed=1 the closure must return a non-zero, low-end value. + assert report["low"] > 0, f"flowSpeed=1 must yield non-zero speed, got {report['low']}" + # End-to-end the slider must show a wide range: high is materially larger than low. + # The fix targets a 34x range; allow some headroom for d3 stub arithmetic. + assert report["high"] >= report["low"] * 10, ( + f"flowSpeed slider must produce a visible range (>=10x low-to-high). " + f"low={report['low']} high={report['high']}" + ) + # Monotonicity: low < mid < high. + assert report["low"] < report["mid"] < report["high"], ( + f"flow speed must be monotonic in the slider value: low={report['low']} " + f"mid={report['mid']} high={report['high']}" + ) + + @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.