diff --git a/.changeset/fit-zoom-clamp.md b/.changeset/fit-zoom-clamp.md deleted file mode 100644 index 67710e5..0000000 --- a/.changeset/fit-zoom-clamp.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@modernrelay/orbit-core': minor -'@modernrelay/orbit-engine-cosmos': minor -'@modernrelay/orbit-react': minor ---- - -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). diff --git a/.changeset/settle-camera-presets.md b/.changeset/settle-camera-presets.md deleted file mode 100644 index b6c8163..0000000 --- a/.changeset/settle-camera-presets.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -'@modernrelay/orbit-core': minor -'@modernrelay/orbit-react': minor ---- - -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"`. diff --git a/apps/demo/run-sweep.mjs b/apps/demo/run-sweep.mjs new file mode 100644 index 0000000..6195f28 --- /dev/null +++ b/apps/demo/run-sweep.mjs @@ -0,0 +1,57 @@ +// Simulation param sweep: metrics + settle screenshot per combo (untracked). +// Usage: node run-sweep.mjs +import { chromium } from '@playwright/test'; + +const OUT = process.argv[2] ?? 'sweep-out'; +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)}`, + ); +} diff --git a/apps/demo/src/sweep.tsx b/apps/demo/src/sweep.tsx new file mode 100644 index 0000000..fc1b4de --- /dev/null +++ b/apps/demo/src/sweep.tsx @@ -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 | 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 ( +
+ + 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 + /> +
+ ); +} + +createRoot(document.getElementById('root')!).render(); diff --git a/apps/demo/sweep.html b/apps/demo/sweep.html new file mode 100644 index 0000000..9e8d81a --- /dev/null +++ b/apps/demo/sweep.html @@ -0,0 +1,12 @@ + + + + + sim sweep + + + +
+ + + diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index fd8ce2b..9748e93 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -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 diff --git a/packages/core/package.json b/packages/core/package.json index 87b3f68..7d087ae 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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": { diff --git a/packages/data/CHANGELOG.md b/packages/data/CHANGELOG.md index 21d392d..91ad220 100644 --- a/packages/data/CHANGELOG.md +++ b/packages/data/CHANGELOG.md @@ -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 diff --git a/packages/data/package.json b/packages/data/package.json index a4608dd..f7070c4 100644 --- a/packages/data/package.json +++ b/packages/data/package.json @@ -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": { diff --git a/packages/engine-cosmos/CHANGELOG.md b/packages/engine-cosmos/CHANGELOG.md index 33f2a07..b9bf90e 100644 --- a/packages/engine-cosmos/CHANGELOG.md +++ b/packages/engine-cosmos/CHANGELOG.md @@ -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 diff --git a/packages/engine-cosmos/package.json b/packages/engine-cosmos/package.json index 8cd475f..61b0704 100644 --- a/packages/engine-cosmos/package.json +++ b/packages/engine-cosmos/package.json @@ -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": { diff --git a/packages/omnigraph/CHANGELOG.md b/packages/omnigraph/CHANGELOG.md index c019e1d..f0c8369 100644 --- a/packages/omnigraph/CHANGELOG.md +++ b/packages/omnigraph/CHANGELOG.md @@ -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 diff --git a/packages/omnigraph/package.json b/packages/omnigraph/package.json index d2e6eda..5c38799 100644 --- a/packages/omnigraph/package.json +++ b/packages/omnigraph/package.json @@ -1,6 +1,6 @@ { "name": "@modernrelay/orbit-omnigraph", - "version": "0.15.0", + "version": "0.16.0", "description": "Omnigraph adapter for Orbit: og.export() loader with drift policies, .pg schema parser, and typed codegen.", "license": "MIT", "repository": { diff --git a/packages/react/CHANGELOG.md b/packages/react/CHANGELOG.md index 52c8b8c..f67c55b 100644 --- a/packages/react/CHANGELOG.md +++ b/packages/react/CHANGELOG.md @@ -1,5 +1,48 @@ # @modernrelay/orbit-react +## 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"`. + +### Patch Changes + +- Updated dependencies [212b31f] +- Updated dependencies [893a1d2] + - @modernrelay/orbit-core@0.16.0 + ## 0.15.0 ### Minor Changes diff --git a/packages/react/package.json b/packages/react/package.json index f867ba7..96ffa39 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,6 +1,6 @@ { "name": "@modernrelay/orbit-react", - "version": "0.15.0", + "version": "0.16.0", "description": "React binding for Orbit: plus tree-shakeable components (toolbar, search, minimap, tooltip, inspector, table, navigator, and more).", "license": "MIT", "repository": {