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
12 changes: 12 additions & 0 deletions .changeset/guided-imports-live-overviews.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
109 changes: 109 additions & 0 deletions apps/demo/e2e/csv-import.spec.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<void> {
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<Uint8Array>({
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();
});
46 changes: 33 additions & 13 deletions apps/demo/e2e/data-search.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/**
* Data + search smoke for the v0.9 surface.
* Data, search and live minimap integration checks.
*
* Drives the packaged <GraphSearch>, the <GraphMinimap>, the
* <GraphTooltip>, and the CSV prepared-data lane through the demo
Expand All @@ -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
Expand Down Expand Up @@ -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);
Expand All @@ -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);

Expand Down Expand Up @@ -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 });
Expand All @@ -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);
Expand All @@ -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
Expand All @@ -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);
},
});
Expand All @@ -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 })
Expand Down
1 change: 1 addition & 0 deletions apps/demo/e2e/exploration-workflows.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
Expand Down
36 changes: 26 additions & 10 deletions apps/demo/e2e/ingestion.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,18 +54,34 @@ async function openNodeContextMenu(page: Page): Promise<void> {
}
}
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 ({
Expand Down
Loading
Loading