Skip to content
Merged
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
16 changes: 0 additions & 16 deletions .changeset/fit-zoom-clamp.md

This file was deleted.

26 changes: 0 additions & 26 deletions .changeset/settle-camera-presets.md

This file was deleted.

57 changes: 57 additions & 0 deletions apps/demo/run-sweep.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// Simulation param sweep: metrics + settle screenshot per combo (untracked).
// Usage: node run-sweep.mjs <outDir>
import { chromium } from '@playwright/test';

const OUT = process.argv[2] ?? 'sweep-out';

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 Missing output directory

When a developer uses a new output directory or the default sweep-out, the runner writes its first screenshot before creating that directory, causing the sweep to abort before producing measurements or its summary.

Suggested change
const OUT = process.argv[2] ?? 'sweep-out';
const OUT = process.argv[2] ?? 'sweep-out';
await (await import('node:fs/promises')).mkdir(OUT, { recursive: true });

Fix in Claude Code

const BASE = 'http://localhost:5199/sweep.html';

// Sweep grid: current default first, then damping/decay/gravity/repulsion moves.
const COMBOS = [
{ id: 'cosmos-default', rep: 1, grav: 0.25, fric: 0.85, decay: 5000 },
{ id: 'calm-a', rep: 1.4, grav: 0.15, fric: 0.55, decay: 1400 },
{ id: 'calm-b', rep: 1.4, grav: 0.15, fric: 0.6, decay: 1000 },
{ id: 'calm-c', rep: 1.6, grav: 0.12, fric: 0.5, decay: 800 },
{ id: 'spread-a', rep: 2, grav: 0.1, fric: 0.6, decay: 1400 },
{ id: 'tight-a', rep: 0.8, grav: 0.3, fric: 0.55, decay: 1200 },
];

const browser = await chromium.launch({
headless: false,
args: ['--window-size=1320,880', '--window-position=80,80'],
});
const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 }, deviceScaleFactor: 2 });
const page = await ctx.newPage();
page.on('pageerror', (e) => console.error('[pageerror]', e.message));

const results = [];
for (const c of COMBOS) {
const url = `${BASE}?n=800&clusters=6&rep=${c.rep}&grav=${c.grav}&fric=${c.fric}&decay=${c.decay}`;
await page.goto(url, { waitUntil: 'load' });
await page.waitForFunction('window.__sweep && window.__sweep.ready === true', null, { timeout: 30_000 });
// mid-flight snapshot for motion judging
await page.waitForTimeout(2500);
await page.screenshot({ path: `${OUT}/${c.id}-mid.png` });
// wait for settle (or 20s cap)
await page
.waitForFunction('window.__sweep.settled === true', null, { timeout: 14_000 })
.catch(() => {});
await page.waitForTimeout(400);
const m = await page.evaluate('window.__sweep.metrics()');
const settleMs = await page.evaluate('window.__sweep.settleMs');
const motion = await page.evaluate('window.__sweep.motion');
// seconds until max displacement stays under 1.5 space units/s
let stillAt = -1;
for (let i = 0; i < motion.length - 2; i++) {
if (motion[i] < 1.5 && motion[i + 1] < 1.5 && motion[i + 2] < 1.5) { stillAt = (i * 0.5).toFixed(1); break; }
}
await page.screenshot({ path: `${OUT}/${c.id}-end.png` });
results.push({ ...c, settleMs, stillAt, motion: motion.filter((_, i) => i % 2 === 0).slice(0, 14), ...m });
console.log(JSON.stringify(results[results.length - 1]));
}
await browser.close();
console.log('--- summary ---');
for (const r of results) {
console.log(
`${r.id.padEnd(16)} still@${String(r.stillAt).padStart(5)}s settleFlag=${String(r.settleMs).padStart(6)}ms fill=${r.fillX}x${r.fillY} motion=${JSON.stringify(r.motion)}`,
);
}
159 changes: 159 additions & 0 deletions apps/demo/src/sweep.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
/**
* Simulation parameter sweep harness (untracked working file).
*
* ?n=800&clusters=6&rep=1&grav=0.25&fric=0.85&decay=5000&dist=10&spring=1
*
* Exposes window.__sweep = { ready, settled, settleMs, metrics() } where
* metrics() reports the graph bounding box vs the visible viewport rect in
* space units — the "fill fraction" a user actually sees, no pixel reading.
*/

import { useEffect, useRef } from 'react';
import { createRoot } from 'react-dom/client';
import type { SimulationConfig } from '@modernrelay/orbit-core';
import { CosmosEngine } from '@modernrelay/orbit-engine-cosmos';
import { Graph } from '@modernrelay/orbit-react';
import type { GraphHandle } from '@modernrelay/orbit-react';
import { generateGraph } from './generate';
import type { DemoEdgeAttrs, DemoNodeAttrs } from './generate';
import { clusterColor } from './styles';

const q = new URLSearchParams(window.location.search);
const num = (k: string, d: number): number => {
const v = Number(q.get(k));
return Number.isFinite(v) && q.get(k) !== null ? v : d;
};

const N = num('n', 800);
const SIMULATION: SimulationConfig = {
repulsion: num('rep', 1),
gravity: num('grav', 0.25),
friction: num('fric', 0.85),
decay: num('decay', 5000),
linkDistance: num('dist', 10),
linkSpring: num('spring', 1),
};

const data = generateGraph({
seed: 7,
nodes: N,
clusters: num('clusters', 6),
intraEdgeFactor: 1.6,
interEdgeProb: 0.06,
datasetKey: 'sweep',
sourceRevision: 1,
});

let engine: CosmosEngine | null = null;
const engineFactory = () => (engine = new CosmosEngine());
const nodeColor = (n: { attrs?: DemoNodeAttrs }): string => clusterColor(n.attrs?.cluster ?? 0);
const nodeSize = (n: { attrs?: DemoNodeAttrs }): number =>
2 + Math.sqrt(n.attrs?.degree ?? 0);

function App(): React.ReactNode {
const ref = useRef<GraphHandle<DemoNodeAttrs, DemoEdgeAttrs> | null>(null);

useEffect(() => {
const w = window as unknown as {
__sweep?: {
ready: boolean;
settled: boolean;
settleMs: number;
metrics: () => unknown;
};
};
const started = performance.now();
const sweep = {
ready: false,
settled: false,
settleMs: -1,
metrics: () => {
const inst = ref.current?.instance;
const eng = engine;
if (!inst || !eng) return null;
const pos = eng.getPositions();
const vp = eng.getViewport();
if (!pos || !vp) return null;
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
for (let i = 0; i < pos.length; i += 2) {
const x = pos[i]!, y = pos[i + 1]!;
if (Number.isNaN(x) || Number.isNaN(y)) continue;
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
// visible space rect from viewport: zoom = px per space unit
const w2 = window.innerWidth / vp.zoom;
const h2 = window.innerHeight / vp.zoom;
return {
graphW: Math.round(maxX - minX),
graphH: Math.round(maxY - minY),
visW: Math.round(w2),
visH: Math.round(h2),
fillX: Math.round(((maxX - minX) / w2) * 100) / 100,
fillY: Math.round(((maxY - minY) / h2) * 100) / 100,
zoom: Math.round(vp.zoom * 1000) / 1000,
running: inst.isSimulationRunning(),
};
},
};
w.__sweep = sweep;
// visible-motion tracker: max node displacement per second, sampled 500ms
let prevPos: Float32Array | null = null;
let lastSample = 0;
(sweep as unknown as { motion: number[] }).motion = [];
const motionIv = setInterval(() => {
const eng = engine;
if (!eng) return;
const pos = eng.getPositions();
if (!pos) return;
const now = performance.now();
if (prevPos !== null && prevPos.length === pos.length) {
let maxD = 0;
for (let i = 0; i < pos.length; i += 2) {
const dx = pos[i]! - prevPos[i]!;
const dy = pos[i + 1]! - prevPos[i + 1]!;
const d = Math.hypot(dx, dy);
if (d > maxD) maxD = d;
}
const perSec = (maxD * 1000) / Math.max(1, now - lastSample);
(sweep as unknown as { motion: number[] }).motion.push(Math.round(perSec * 100) / 100);
}
prevPos = pos.slice();
lastSample = now;
}, 500);
const iv = setInterval(() => {
const inst = ref.current?.instance;
if (inst === undefined) return;
sweep.ready = true;
if (!sweep.settled && sweep.settleMs < 0 && !inst.isSimulationRunning()) {
// first quiescence after mount
if (performance.now() - started > 1500) {
sweep.settled = true;
sweep.settleMs = Math.round(performance.now() - started);
}
}
}, 100);
return () => { clearInterval(iv); clearInterval(motionIv); };
}, []);

return (
<div style={{ position: 'fixed', inset: 0 }}>
<Graph<DemoNodeAttrs, DemoEdgeAttrs>
ref={ref}
engine={engineFactory}
data={data}
nodeColor={nodeColor}
nodeSize={nodeSize}
linkColor="rgba(255,255,255,0.15)"
layout="force"
simulation={SIMULATION}
theme={{ base: 'dark', background: '#0b0e14' }}
fitViewOnFirstData
/>
</div>
);
}

createRoot(document.getElementById('root')!).render(<App />);
12 changes: 12 additions & 0 deletions apps/demo/sweep.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>sim sweep</title>
<style>html, body, #root { margin: 0; height: 100%; } body { background: #0b0e14; }</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/sweep.tsx"></script>
</body>
</html>
37 changes: 37 additions & 0 deletions packages/core/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,42 @@
# @modernrelay/orbit-core

## 0.16.0

### Minor Changes

- 212b31f: Fit zoom clamp: small graphs no longer balloon.

Measured: a 60-node graph fit at zoom 4.3 (nodes render as balloons), 300
nodes at 3.3. Every internally issued fit (first-data fit, settle follow,
public `fitView`) now carries a zoom upper bound — new `fitViewMaxZoom`
option, default 1.5, `null` to disable. The engine contract's
`FitViewOptions` gains `maxZoom`; when the natural fit zoom exceeds the
bound, the cosmos engine centers the scene bbox at the bound with one
animated transform instead. Verified live: small-graph fits land at exactly
1.5 (previously 3.1–4.3).

- 893a1d2: First-load feel: the settle camera and simulation presets.

Two measured problems on every fresh force-layout mount: the first-data fit
frames the seed ring while the simulation contracts the graph to 5–17% of
that frame (a distant blob), and the engine's default cooling keeps visible
motion alive for tens of seconds (reads as endless jitter).

- **Settle camera** — new `fitViewOnSettle` option (`'follow'` (default) |
`'once'` | `false`). Under `'follow'` the camera keeps the settling graph
framed with periodic animated refits riding the engine frame fan-out (no
timers, no extra rAF) and a final fit at first quiescence; any user camera
input cancels it. `'once'` fits a single time at quiescence; `false`
restores the previous behavior.
- **Simulation presets** — `simulation` now also accepts a preset name:
`'calm'`, `'spread'`, `'tight'`, or `'lively'` (`SIMULATION_PRESETS` and
`resolveSimulation` are exported). Presets were selected on a measured
protocol: seconds until sustained visible stillness on an 800-node
clustered graph.
- **Default changed**: an omitted `simulation` now resolves to the `'calm'`
preset (visually still in ~5s) instead of the engine's own defaults. The
old feel is one prop away: `simulation="lively"`.

## 0.15.0

### Minor Changes
Expand Down
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@modernrelay/orbit-core",
"version": "0.15.0",
"version": "0.16.0",
"description": "Headless graph-visualization core: typed declarative snapshots reconciled into atomic engine commits. No React, no DOM, no engine imports.",
"license": "MIT",
"repository": {
Expand Down
8 changes: 8 additions & 0 deletions packages/data/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# @modernrelay/orbit-data

## 0.16.0

### Patch Changes

- Updated dependencies [212b31f]
- Updated dependencies [893a1d2]
- @modernrelay/orbit-core@0.16.0

## 0.15.0

### Minor Changes
Expand Down
2 changes: 1 addition & 1 deletion packages/data/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@modernrelay/orbit-data",
"version": "0.15.0",
"version": "0.16.0",
"description": "Data preparation for Orbit: streaming CSV, JSON paths, Arrow and Parquet into typed graph snapshots.",
"license": "MIT",
"repository": {
Expand Down
21 changes: 21 additions & 0 deletions packages/engine-cosmos/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,26 @@
# @modernrelay/orbit-engine-cosmos

## 0.16.0

### Minor Changes

- 212b31f: Fit zoom clamp: small graphs no longer balloon.

Measured: a 60-node graph fit at zoom 4.3 (nodes render as balloons), 300
nodes at 3.3. Every internally issued fit (first-data fit, settle follow,
public `fitView`) now carries a zoom upper bound — new `fitViewMaxZoom`
option, default 1.5, `null` to disable. The engine contract's
`FitViewOptions` gains `maxZoom`; when the natural fit zoom exceeds the
bound, the cosmos engine centers the scene bbox at the bound with one
animated transform instead. Verified live: small-graph fits land at exactly
1.5 (previously 3.1–4.3).

### Patch Changes

- Updated dependencies [212b31f]
- Updated dependencies [893a1d2]
- @modernrelay/orbit-core@0.16.0

## 0.15.0

### Minor Changes
Expand Down
2 changes: 1 addition & 1 deletion packages/engine-cosmos/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@modernrelay/orbit-engine-cosmos",
"version": "0.15.0",
"version": "0.16.0",
"description": "cosmos.gl engine adapter for Orbit: GPU force layout, native picking, context-loss recovery.",
"license": "MIT",
"repository": {
Expand Down
8 changes: 8 additions & 0 deletions packages/omnigraph/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# @modernrelay/orbit-omnigraph

## 0.16.0

### Patch Changes

- Updated dependencies [212b31f]
- Updated dependencies [893a1d2]
- @modernrelay/orbit-core@0.16.0

## 0.15.0

### Minor Changes
Expand Down
Loading
Loading