diff --git a/.changeset/guided-imports-live-overviews.md b/.changeset/guided-imports-live-overviews.md new file mode 100644 index 0000000..b318750 --- /dev/null +++ b/.changeset/guided-imports-live-overviews.md @@ -0,0 +1,12 @@ +--- +"@modernrelay/orbit-core": minor +"@modernrelay/orbit-data": minor +"@modernrelay/orbit-react": patch +"@modernrelay/orbit-engine-cosmos": patch +--- + +Add bounded, cancellable graph preparation with progress notifications and a CSV preview helper. All preparation formats now support optional columnar output while retaining object snapshots as the default and preserving the v1 artifact format. + +Expose live overview subscriptions and share a bounded position cache between labels and the minimap. Cosmos uses asynchronous position reads for scenes up to 10,000 nodes when available and a conservative synchronous cadence for larger scenes, rejects stale results after graph or engine changes, and stops sampling at rest. + +Add a guided CSV import flow to the demo and a complete first-graph quickstart verified against packed packages. diff --git a/README.md b/README.md index a06d1e1..84219d8 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,10 @@ Handles 100K+ node graphs. UI components included: search, tables, histograms, a npm install @modernrelay/orbit-react @modernrelay/orbit-core @modernrelay/orbit-engine-cosmos ``` +Start with [Your first Orbit graph](docs/core/getting-started.md): a complete +React + TypeScript app with a three-node graph, search, and controlled selection. +The example is compiled and exercised against the published package shape in CI. + The Cosmos engine and OmniGraph adapter require Node.js 22 or newer. All five packages release in lockstep. See diff --git a/apps/demo/e2e/csv-import.spec.ts b/apps/demo/e2e/csv-import.spec.ts new file mode 100644 index 0000000..08b8631 --- /dev/null +++ b/apps/demo/e2e/csv-import.spec.ts @@ -0,0 +1,109 @@ +import { expect, test } from '@playwright/test'; +import type { Page } from '@playwright/test'; + +const csv = (name: string, text: string) => ({ name, mimeType: 'text/csv', buffer: Buffer.from(text) }); +const dialog = (page: Page) => page.getByRole('dialog', { name: 'Import CSV graph' }); + +async function ready(page: Page): Promise { + await page.goto('/?declNodes=100'); + await page.waitForSelector('[data-testid="status-dot"][title="ready"]'); +} + +// Delay actual File reads, preserving real CSV parsing and AbortSignal handling. +// This makes cancellation/replacement deterministic without waiting for a huge file. +async function slowFileReads(page: Page): Promise { + await page.addInitScript(() => { + const stream = File.prototype.stream; + File.prototype.stream = function () { + const source = stream.call(this); + if (this.name !== 'slow.csv') return source; + const reader = source.getReader(); + let cancelled = false; + return new ReadableStream({ + async pull(controller) { + await new Promise((resolve) => setTimeout(resolve, 250)); + if (cancelled) return; + const next = await reader.read(); + if (cancelled) return; + if (next.done) controller.close(); else controller.enqueue(next.value); + }, + async cancel() { cancelled = true; await reader.cancel(); }, + }); + }; + }); +} +const slowCsv = () => csv('slow.csv', 'source,target,weight\n' + Array.from({ length: 20_000 }, (_, i) => `n${i},n${i + 1},${i}\n`).join('')); + +test('preview and map custom headers, with node attributes and string IDs', async ({ page }) => { + await ready(page); + const before = await page.getByTestId('node-count').innerText(); + await page.getByTestId('csv-file-input').setInputFiles(csv('connections.csv', + 'relation,from_person,to_person,notes\nr1,001,002,"a,b"\nr2,002,003,hello\n')); + await expect(dialog(page).getByRole('table', { name: 'Edges preview' })).toContainText('a,b'); + await expect(page.getByTestId('node-count')).toHaveText(before); + await dialog(page).getByRole('combobox', { name: 'Source column', exact: true }).selectOption('from_person'); + await dialog(page).getByRole('combobox', { name: 'Target column', exact: true }).selectOption('to_person'); + await dialog(page).getByRole('combobox', { name: 'Edge ID column', exact: true }).selectOption('relation'); + await page.getByTestId('csv-nodes-input').setInputFiles(csv('people.csv', + 'person_id,name,cluster,degree\n001,Alice,west,many\n002,Bob,east,2\n003,Carol,north,3\n')); + await expect(dialog(page).getByRole('table', { name: 'Nodes preview' })).toContainText('Alice'); + await dialog(page).getByRole('combobox', { name: 'Node ID column', exact: true }).selectOption('person_id'); + await dialog(page).getByRole('button', { name: 'Import graph', exact: true }).click(); + await expect(dialog(page)).not.toBeVisible(); + await expect(page.getByTestId('node-count')).toHaveText('3'); + await expect(page.getByTestId('csv-summary')).toContainText('2 edges'); + await page.locator('[data-orbit-search-input]').fill('Alice'); + await expect(page.locator('[data-orbit-search-result]').first()).toContainText('Alice'); + await page.getByTestId('explorer-toggle').click(); + const explorer = page.getByRole('region', { name: 'Graph exploration' }); + await expect(explorer.locator('[data-orbit-table-row="001"]')).toBeVisible(); +}); + +test('cancelling an in-flight import keeps the current dataset', async ({ page }) => { + await slowFileReads(page); + await ready(page); + const before = await page.getByTestId('node-count').innerText(); + await page.getByTestId('csv-file-input').setInputFiles(slowCsv()); + await dialog(page).getByRole('button', { name: 'Import graph', exact: true }).click(); + await expect(page.getByTestId('csv-progress')).toContainText('Preparing graph'); + await dialog(page).getByRole('button', { name: 'Cancel import', exact: true }).click(); + await expect(dialog(page)).not.toBeVisible(); + await expect(page.getByTestId('csv-panel')).toContainText('Import cancelled'); + await expect(page.getByTestId('node-count')).toHaveText(before); + // A subsequent successful import must remain authoritative when the old read settles. + await page.getByTestId('csv-file-input').setInputFiles(csv('small.csv', 'source,target\na,b\n')); + await dialog(page).getByRole('button', { name: 'Import graph', exact: true }).click(); + await expect(page.getByTestId('node-count')).toHaveText('2'); + await expect(page.getByTestId('csv-summary')).toContainText('small.csv'); +}); + +test('a second upload replaces an active preparation instead of being ignored', async ({ page }) => { + await slowFileReads(page); + await ready(page); + await page.getByTestId('csv-file-input').setInputFiles(slowCsv()); + await dialog(page).getByRole('button', { name: 'Import graph', exact: true }).click(); + await expect(page.getByTestId('csv-progress')).toBeVisible(); + const chooseReplacement = page.waitForEvent('filechooser'); + await dialog(page).getByRole('button', { name: 'Choose different edges file', exact: true }).click(); + await (await chooseReplacement).setFiles(csv('replacement.csv', 'source,target\nnew-a,new-b\nnew-b,new-c\n')); + await expect(dialog(page)).toContainText('replacement.csv'); + await dialog(page).getByRole('button', { name: 'Import graph', exact: true }).click(); + await expect(page.getByTestId('csv-summary')).toContainText('replacement.csv'); + await expect(page.getByTestId('node-count')).toHaveText('3'); +}); + +test('preview errors preserve the graph and the importer fits a narrow screen', async ({ page }) => { + await ready(page); + const before = await page.getByTestId('node-count').innerText(); + await page.setViewportSize({ width: 390, height: 844 }); + await page.getByTestId('csv-file-input').setInputFiles(csv('empty.csv', '')); + await expect(dialog(page).getByRole('alert')).toContainText(/header|empty/i); + await expect(dialog(page).getByRole('button', { name: 'Import graph', exact: true })).toBeDisabled(); + await expect(page.getByTestId('node-count')).toHaveText(before); + const bounds = await dialog(page).boundingBox(); + expect(bounds).not.toBeNull(); + expect(bounds!.x).toBeGreaterThanOrEqual(0); + expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(390); + await page.keyboard.press('Escape'); + await expect(dialog(page)).not.toBeVisible(); +}); diff --git a/apps/demo/e2e/data-search.spec.ts b/apps/demo/e2e/data-search.spec.ts index 85af3a9..9a7d32a 100644 --- a/apps/demo/e2e/data-search.spec.ts +++ b/apps/demo/e2e/data-search.spec.ts @@ -1,5 +1,5 @@ /** - * Data + search smoke for the v0.9 surface. + * Data, search and live minimap integration checks. * * Drives the packaged , the , the * , and the CSV prepared-data lane through the demo @@ -10,15 +10,15 @@ * - a host-filtered target activates to 'unavailable/filtered', surfaced by * the demo's status-bar `search-unavailable` line (the result * contract: search NEVER mutates scope/filters itself); - * - the minimap rasterizes (the demo declares seeded node positions — the - * documented v0.9 CPU-fallback scene source) and a corner click issues a - * REAL `setViewport` pan, asserted via the status bar's data-viewport-x/y; + * - the minimap rasterizes both seeded data and unpositioned CSV nodes from + * the shared live position cache; a corner click pans the real viewport, + * asserted via the status bar's data-viewport-x/y; * - hovering a node (grid hunt around the center — the same strategy as the * ingestion spec's right-click, hit-testing exact centers is flaky) shows * the hover card with label + attr rows; * - `setInputFiles` on the CSV panel's hidden input feeds a 50-row edges - * fixture through `prepareGraphData` (deriveNodes) — the header counts - * swap to the derived 51/50 and the summaries line renders. + * fixture through preview/confirmation and `prepareGraphData` (deriveNodes): + * counts swap to 51/50, summaries render and live force positions paint. * * CI note (repo pattern): the force simulation is paused before interaction * phases — cosmos free-runs its rAF while data is present and on @@ -127,9 +127,8 @@ test('minimap: canvas rasterizes and a corner click pans the real viewport', asy const canvas = page.locator(MINIMAP_CANVAS); await expect(canvas).toBeVisible(); - // The viewport RECTANGLE only displays once a frame has rasterized (the - // demo's seeded declared positions are the v0.9 CPU-fallback scene source) - // AND the store has a live viewport — so its visibility proves the blit. + // The viewport rectangle displays only after a position snapshot has + // rasterized and the store has a live viewport. await expect(page.locator(MINIMAP_RECT)).toBeVisible({ timeout: 10_000 }); const zoom = page.locator(ZOOM_VALUE); @@ -155,6 +154,9 @@ test('minimap: canvas rasterizes and a corner click pans the real viewport', asy test('tooltip: hovering a node shows the hover card with label and attr rows', async ({ page, }) => { + // Pausing physics does not cancel the mount camera fit. Freeze the camera + // as well so the picked node remains under the pointer through the delay. + await page.emulateMedia({ reducedMotion: 'reduce' }); await gotoReady(page); await pauseSimulation(page); @@ -212,6 +214,7 @@ test('csv drop: a 50-row edges fixture derives nodes and swaps the dataset', asy // The drop-zone's hidden input is the testability seam for DnD. await page.setInputFiles('[data-testid="csv-file-input"]', fixture); + await page.getByRole('button', { name: 'Import graph', exact: true }).click(); // prepared snapshot applied on a fresh instance (key remount) → new counts. await expect(page.getByTestId('node-count')).toHaveText('51', { timeout: 20_000 }); @@ -226,6 +229,19 @@ test('csv drop: a 50-row edges fixture derives nodes and swaps the dataset', asy await expect(summary).toContainText('weight'); await expect(page.getByTestId('csv-error')).toHaveCount(0); + // Derived CSV nodes have no declared x/y. Their force layout must still + // produce a nonempty live minimap and a usable world-space pan mapping. + await pauseSimulation(page); + await expect(page.locator(MINIMAP_RECT)).toBeVisible({ timeout: 10_000 }); + await expect.poll(() => page.locator(MINIMAP_CANVAS).evaluate((el) => { + const canvas = el as HTMLCanvasElement; + const pixels = canvas.getContext('2d')?.getImageData(0, 0, canvas.width, canvas.height).data; + return pixels?.some((value, i) => i % 4 === 3 && value > 0) ?? false; + }), { message: 'unpositioned force nodes should paint the live overview' }).toBe(true); + const centerBefore = await page.locator(ZOOM_VALUE).getAttribute('data-viewport-x'); + await page.locator(MINIMAP_CANVAS).click({ position: { x: 18, y: 18 } }); + await expect.poll(() => page.locator(ZOOM_VALUE).getAttribute('data-viewport-x')).not.toBe(centerBefore); + // The CSV dataset is searchable by id (derived nodes carry no attrs, so // the index is id-only here). await pauseSimulation(page); @@ -234,7 +250,7 @@ test('csv drop: a 50-row edges fixture derives nodes and swaps the dataset', asy await expect(page.locator(SEARCH_LABEL).first()).toHaveText(/^c17/); }); -test('a newer data-mode choice supersedes an in-flight CSV preparation', async ({ +test('cancelling an in-flight CSV preparation permits choosing another data mode', async ({ page, }, testInfo) => { // Make the otherwise-small fixture deterministically asynchronous. Orbit's @@ -261,6 +277,7 @@ test('a newer data-mode choice supersedes an in-flight CSV preparation', async ( } else controller.enqueue(next.value); }, cancel(reason) { + state.__orbitCsvStreamFinished = true; return reader.cancel(reason); }, }); @@ -272,12 +289,15 @@ test('a newer data-mode choice supersedes an in-flight CSV preparation', async ( await gotoReady(page); await page.setInputFiles('[data-testid="csv-file-input"]', fixture); + await page.getByRole('button', { name: 'Import graph', exact: true }).click(); + await page.keyboard.press('Escape'); + await expect(page.getByRole('dialog')).not.toBeVisible(); await page.getByTestId('semantic-mode').click(); await expect(page.getByTestId('m5-panel')).toBeVisible({ timeout: 15_000 }); - // Wait for EOF rather than a wall-clock approximation, then give the - // preparation promise and React commit a chance to drain. Its completion - // must not replace the newer semantic mode or publish a CSV summary. + // Wait for EOF/cancellation rather than a wall-clock approximation, then + // give preparation and React a chance to drain. A cancelled import must + // not replace the newer mode or publish a CSV summary. await page.waitForFunction( () => (window as typeof window & { __orbitCsvStreamFinished?: boolean }) diff --git a/apps/demo/e2e/exploration-workflows.spec.ts b/apps/demo/e2e/exploration-workflows.spec.ts index 9cf0544..1547fb4 100644 --- a/apps/demo/e2e/exploration-workflows.spec.ts +++ b/apps/demo/e2e/exploration-workflows.spec.ts @@ -7,6 +7,7 @@ test('CSV exploration connects search, paths, table filtering, and checkpoint re name: 'suppliers.csv', mimeType: 'text/csv', buffer: Buffer.from('source,target,type,evidence\nAcme,Beta,SUPPLIES,Contract\nBeta,Cedar,SUPPLIES,Invoice\n'), }); + await page.getByRole('button', { name: 'Import graph', exact: true }).click(); await expect(page.getByTestId('node-count')).toHaveText('3'); await page.getByTestId('explorer-toggle').click(); const explorer = page.getByRole('region', { name: 'Graph exploration' }); diff --git a/apps/demo/e2e/ingestion.spec.ts b/apps/demo/e2e/ingestion.spec.ts index 9588181..171a101 100644 --- a/apps/demo/e2e/ingestion.spec.ts +++ b/apps/demo/e2e/ingestion.spec.ts @@ -54,18 +54,34 @@ async function openNodeContextMenu(page: Page): Promise { } } const candidates: readonly [number, number][] = [[cx, cy], ...dense, ...grid]; - for (const [x, y] of candidates) { - await page.mouse.click(x, y, { button: 'right' }); - try { - await expect(page.locator(CONTEXT_MENU)).toBeVisible({ timeout: 1_000 }); - } catch { - continue; // click landed before pointer wiring settled — try the next + // Candidate misses can open Chromium's native macOS menu, whose tracking + // loop blocks context teardown and ignores CDP Escape. Prevent only that + // browser default during probing; Orbit's context-menu handlers still run. + const nativeMenuGuard = await page.evaluateHandle(() => { + const controller = new AbortController(); + document.addEventListener('contextmenu', (event) => event.preventDefault(), { + signal: controller.signal, + }); + return controller; + }); + try { + for (const [x, y] of candidates) { + await page.mouse.click(x, y, { button: 'right' }); + try { + await expect(page.locator(CONTEXT_MENU)).toBeVisible({ timeout: 1_000 }); + } catch { + await page.keyboard.press('Escape'); + continue; + } + if ((await page.locator(ISOLATE_ITEM).count()) > 0) return; // node menu + await page.keyboard.press('Escape'); // background menu — dismiss, retry + await expect(page.locator(CONTEXT_MENU)).toHaveCount(0); } - if ((await page.locator(ISOLATE_ITEM).count()) > 0) return; // node menu - await page.keyboard.press('Escape'); // background menu — dismiss, retry - await expect(page.locator(CONTEXT_MENU)).toHaveCount(0); + throw new Error('no right-click candidate landed on a node'); + } finally { + await nativeMenuGuard.evaluate((controller) => controller.abort()); + await nativeMenuGuard.dispose(); } - throw new Error('no right-click candidate landed on a node'); } test('stream feed → live meter → commit → isolate via context menu → reset scope', async ({ diff --git a/apps/demo/src/App.tsx b/apps/demo/src/App.tsx index d49baac..b9d7017 100644 --- a/apps/demo/src/App.tsx +++ b/apps/demo/src/App.tsx @@ -55,8 +55,8 @@ * live sim positions in the reconciler, so force layouts keep drifting). * - always mounted; right dock behind a * toolbar toggle (it replaces the workbench sidebar while open). - * - CSV drop (csvDrop.tsx): one edges CSV → `prepareGraphData` - * edges-only/deriveNodes → applied as a declarative snapshot on a + * - CSV import (csvDrop.tsx): preview and map edges/optional nodes → `prepareGraphData` + * → applied as a declarative snapshot on a * fresh instance (`key` remount — the previous mode may have been a * replace-session stream), summaries as a panel line. * @@ -139,7 +139,7 @@ import { GraphTimeline } from '@modernrelay/orbit-react/components/Timeline'; import { GraphToolbar } from '@modernrelay/orbit-react/components/Toolbar'; import { GraphTooltip } from '@modernrelay/orbit-react/components/Tooltip'; -import { CsvDropZone, loadCsvEdgesFile } from './csvDrop'; +import { CsvDropZone } from './csvDrop'; import type { CsvDropResult } from './csvDrop'; import { DEFAULT_GENERATE, clusterName, generateGraph, nextSeed } from './generate'; import type { DemoEdgeAttrs, DemoNodeAttrs } from './generate'; @@ -179,10 +179,10 @@ import type { StreamFamily, StreamProgress } from './streamFeed'; import * as S from './styles'; import { BACKGROUND, LIGHT_BACKGROUND, clusterColor, typeColor } from './styles'; -/** Union attrs: generated demo shapes or Omnigraph-loaded shapes, discriminated - * by the adapter-injected type field that demo attrs lack. */ -type AppNodeAttrs = DemoNodeAttrs | OmnigraphDisplayNodeAttrs; -type AppEdgeAttrs = DemoEdgeAttrs | OmnigraphDisplayEdgeAttrs; +/** Generated, Omnigraph, or imported CSV attributes. Narrow caller fields + * before applying demo-specific rendering. */ +type AppNodeAttrs = DemoNodeAttrs | OmnigraphDisplayNodeAttrs | Record; +type AppEdgeAttrs = DemoEdgeAttrs | OmnigraphDisplayEdgeAttrs | Record; type NodeMap = ReadonlyMap>; @@ -213,15 +213,15 @@ const SIMULATION: SimulationConfig = { repulsion: 0.6, gravity: 0.25 }; const nodeColor = (node: GraphNode): string => { const a = node.attrs; if (a === undefined) return clusterColor(0); - if (ORBIT_TYPE_KEY in a) return typeColor(a[ORBIT_TYPE_KEY]); - return clusterColor(a.cluster); + if (ORBIT_TYPE_KEY in a && typeof a[ORBIT_TYPE_KEY] === 'string') return typeColor(a[ORBIT_TYPE_KEY]); + return clusterColor('cluster' in a && typeof a.cluster === 'number' && Number.isFinite(a.cluster) ? a.cluster : 0); }; /** Degree-scaled size; omnigraph degree is host-computed during ingest and * absent attrs fall back to the base size. */ const nodeSize = (node: GraphNode): number => { const a = node.attrs; - const degree = a !== undefined && 'degree' in a ? a.degree ?? 0 : 0; + const degree = a !== undefined && 'degree' in a && typeof a.degree === 'number' && Number.isFinite(a.degree) ? Math.max(0, a.degree) : 0; return 2 + Math.sqrt(degree); }; @@ -278,10 +278,10 @@ const CLUSTER_COLOR_SCALE: Scale = { const labelOf = (node: GraphNode): string => { const a = node.attrs; if (a === undefined) return node.id; - if (ORBIT_TYPE_KEY in a) { - return `${a[ORBIT_TYPE_KEY]} · ${'title' in a ? a.title : a.name}`; - } - return a.label; + const label = 'label' in a && typeof a.label === 'string' ? a.label + : 'title' in a && typeof a.title === 'string' ? a.title + : 'name' in a && typeof a.name === 'string' ? a.name : node.id; + return ORBIT_TYPE_KEY in a && typeof a[ORBIT_TYPE_KEY] === 'string' ? `${a[ORBIT_TYPE_KEY]} · ${label}` : label; }; /** Hover-card title for an EDGE. Omnigraph edges carry the relationship name @@ -358,14 +358,15 @@ const ACCESSIBILITY: AccessibilityConfig = { // below takes effect on every mode switch. The default local service indexes // node ids ALWAYS plus exactly these attr fields — it never guesses attr names. -/** Generated data (declarative/stream/CSV): the `label` attr. CSV-derived - * nodes carry no attrs at all, so search degrades to id-only there. */ +/** Generated data indexes labels. Imported nodes can also name a person or + * title a document; edge-only CSV imports remain searchable by node ID. */ const SEARCH_INDEX: readonly string[] = ['label']; +const CSV_SEARCH_INDEX: readonly string[] = ['label', 'name', 'title']; /** Omnigraph mode: the intel-fixture content fields. */ const OG_SEARCH_INDEX: readonly string[] = ['name', 'title', 'brief']; -// --- minimap seed positions -------------------------------------------------- +// --- generated graph seed positions ----------------------------------------- const GOLDEN_ANGLE = 2.399963229728653; /** Cosmos space is [0, 4096] with unknown-position ring seeding at r=1024 @@ -374,9 +375,9 @@ const SEED_CENTER = 2048; const SEED_RADIUS_STEP = 17; // r = 17·√i → ≈1005 at i=3500 /** - * v0.9 minimap trim: the CPU fallback rasterizes DECLARED node - * positions, so the demo declares a deterministic golden-angle sunflower - * seed for every position-less node. Seeds depend ONLY on the node's array + * Give generated graphs deterministic golden-angle sunflower positions. + * Imported graphs use the engine's default seeds and the live minimap cache. + * These seeds depend only on the node's array * index — stable across the Add-500 superset — and the reconciler defers * UNCHANGED declarations to live sim positions, so the force layout still * owns motion; the declaration doubles as the sim's initial placement. @@ -626,11 +627,9 @@ export function App() { const [inspectorOpen, setInspectorOpen] = useState(false); /** Last unavailable activation ("