Skip to content
Open
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
6 changes: 6 additions & 0 deletions .changeset/curious-orbits-explore.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@modernrelay/orbit-core": minor
"@modernrelay/orbit-react": minor
---

Add bounded relationship inspection and paginated typed expansion, layout preservation, explicit path outcomes, and source-bound investigation checkpoints with replayable queries. Add GraphExplorer, passive node/edge/selection inspection, and controlled search/table intent. Include seven Storybook workflows and a demo workspace.
12 changes: 9 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ Handles 100K+ node graphs. UI components included: search, tables, histograms, a
|---|---|
| **Declarative end to end** | The graph is a prop — rendering, force layout, transitions, selection, and undo/redo are handled. No imperative canvas code. |
| **Built for large graphs** | GPU rendering holds 100K+ nodes interactive; incremental filtering keeps the core's per-brush cost near 1 ms at that scale, measured on a disclosed reference machine. |
| **Analyst UI included** | 13 packaged components — search, minimap, tables, histograms, timelines, legends, inspectors, and more. Headless-styleable, one import each. |
| **Analyst UI included** | 14 packaged components — search, minimap, tables, histograms, timelines, legends, inspectors, and more. Headless-styleable, one import each. |
| **Testable without WebGL** | A headless core and an engine seam with a `FakeEngine` double; integration tests run in plain jsdom. |
| **Typed, honest boundaries** | Malformed data degrades with batched diagnostics — never throws mid-render. |

Expand All @@ -34,7 +34,7 @@ npm install @modernrelay/orbit-react @modernrelay/orbit-core @modernrelay/orbit-
| Package | Role |
|---|---|
| `@modernrelay/orbit-core` | Headless core: validation, reconciliation, projection, the instance + store. Subpaths: `/engine` (the `GraphEngine` contract), `/testing` (`FakeEngine`, worker double). No React or engine imports. |
| `@modernrelay/orbit-react` | `<Graph/>`, `GraphProvider`, 13 packaged UI components, hooks, ref API. React 18+ peer. |
| `@modernrelay/orbit-react` | `<Graph/>`, `GraphProvider`, 14 packaged UI components, hooks, ref API. React 18+ peer. |
| `@modernrelay/orbit-engine-cosmos` | The default rendering engine: WebGL drawing and GPU force simulation, built on [cosmos.gl](https://github.com/cosmosgl/graph). Loaded lazily when the graph mounts. |
| `@modernrelay/orbit-data` | Prepared-data adapters: rows/CSV/JSON in the root entry; Arrow and Parquet as isolated subpath entries that never reach the root bundle. |
| `@modernrelay/orbit-omnigraph` | Omnigraph server adapter: streamed export loader, `.pg` schema tooling, search service. |
Expand All @@ -55,11 +55,12 @@ Packaged components — each ships as its own entry point

| Component | Entry | What it does |
|---|---|---|
| `GraphExplorer` | `components/Explorer` | Unified search, table, passive inspection, bounded expansion, ordered paths, and saved investigations |
| `GraphSearch` | `components/Search` | Search box with debounced queries, result list, keyboard activation |
| `GraphNavigator` | `components/Navigator` | Bounded semantic keyboard navigator (arrow/paging traversal with a11y announcements) |
| `GraphMinimap` | `components/Minimap` | Whole-graph thumbnail with a draggable viewport rectangle |
| `GraphTooltip` | `components/Tooltip` | Hover card for nodes and edges |
| `GraphInspector` | `components/Inspector` | Docked detail panel for the focused/selected entity |
| `GraphInspector` | `components/Inspector` | Node, relationship, and selection comparison with passive inspection |
| `GraphTable` | `components/Table` | Virtualized tabular view of nodes or edges, crossfilter-connected text filtering |
| `GraphHistogram` | `components/Histogram` | Crossfilter histogram — drag-brush a numeric dimension to filter the graph |
| `GraphTimeline` | `components/Timeline` | Timeline band over a temporal dimension with brush + playback |
Expand All @@ -80,6 +81,11 @@ Packaged components — each ships as its own entry point
- **Persistence & export**: deep-linkable view state, undo/redo, SVG / streamed JSON / PNG exports.
- **Scale**: measured performance gates, telemetry snapshots, degradation ladder, off-main-thread data acceptance.

For runnable examples of search recovery, typed pagination, stable layouts, path explanations,
and durable checkpoints, see the [exploration workflow guide](docs/core/exploration-workflows.md)
and **Exploration → Investigation workflows** in Storybook. The demo’s **Explore graph**
button opens the same workspace for generated, CSV, streamed, and Omnigraph data.

## Hooks & imperative API

All hooks read the instance through `GraphProvider` (or the nearest `<Graph/>`):
Expand Down
54 changes: 54 additions & 0 deletions apps/demo/e2e/exploration-workflows.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { expect, test } from '@playwright/test';

test('CSV exploration connects search, paths, table filtering, and checkpoint restore', async ({ page }, testInfo) => {
await page.goto('/');
await page.waitForSelector('[data-testid="status-dot"][title="ready"]');
await page.getByTestId('csv-file-input').setInputFiles({
name: 'suppliers.csv', mimeType: 'text/csv',
buffer: Buffer.from('source,target,type,evidence\nAcme,Beta,SUPPLIES,Contract\nBeta,Cedar,SUPPLIES,Invoice\n'),
});
await expect(page.getByTestId('node-count')).toHaveText('3');
await page.getByTestId('explorer-toggle').click();
const explorer = page.getByRole('region', { name: 'Graph exploration' });
await expect(explorer).toBeVisible();
await explorer.getByRole('combobox', { name: 'Search graph' }).fill('Acme');
await explorer.getByRole('option', { name: /Acme/ }).click();
await expect(explorer.getByRole('complementary')).toContainText('Acme');
await explorer.locator('[data-orbit-table-row="Acme"]').click();
await explorer.getByRole('button', { name: 'Hide selected', exact: true }).click();
await explorer.getByRole('combobox', { name: 'Search graph' }).fill('Beta');
await explorer.getByRole('combobox', { name: 'Search graph' }).fill('Acme');
await explorer.getByRole('option', { name: /Acme/ }).click();
await expect(explorer.getByRole('button', { name: 'Reveal filtered entity' })).toBeVisible();
await explorer.getByRole('button', { name: 'Reveal filtered entity' }).click();
await expect(explorer.getByRole('region', { name: 'Active constraints' })).toContainText('3 nodes');
await explorer.getByLabel('Path source', { exact: true }).fill('Acme');
await explorer.getByLabel('Path target', { exact: true }).fill('Cedar');
await explorer.getByLabel('Path direction', { exact: true }).selectOption('outgoing');
await explorer.getByRole('button', { name: 'Find connection', exact: true }).click();
await expect(explorer.locator('[data-orbit-saved-path] li')).toHaveText(['Acme → SUPPLIES', 'Beta → SUPPLIES', 'Cedar']);
await explorer.getByLabel('Investigation title').fill('Supplier evidence');
await explorer.getByLabel('Investigation notes').fill('Verify the contract and invoice.');
await explorer.locator('[data-orbit-table-filter]').fill('Beta');
await expect(explorer.getByRole('region', { name: 'Active constraints' })).toContainText('1 nodes');
await explorer.getByRole('button', { name: 'Save checkpoint', exact: true }).click();
await expect(explorer.getByRole('button', { name: 'Restore Supplier evidence' })).toBeVisible();
await explorer.locator('[data-orbit-table-filter]').fill('');
await explorer.getByLabel('Investigation notes').fill('Changed');
await explorer.getByRole('button', { name: 'Restore Supplier evidence' }).click();
await expect(explorer.getByLabel('Investigation notes')).toHaveValue('Verify the contract and invoice.');
await expect(explorer.locator('[data-orbit-table-filter]')).toHaveValue('Beta');
await expect(explorer.getByRole('region', { name: 'Active constraints' })).toContainText('1 nodes');
await page.screenshot({ path: testInfo.outputPath('exploration-workspace.png') });
await page.setViewportSize({ width: 800, height: 900 });
const panel = await explorer.boundingBox();
expect(panel!.x).toBeGreaterThanOrEqual(0);
expect(panel!.x + panel!.width).toBeLessThanOrEqual(800);
await expect(page.getByRole('button', { name: 'Close explorer' })).toBeVisible();
await page.reload();
await page.waitForSelector('[data-testid="status-dot"][title="ready"]');
await page.getByTestId('explorer-toggle').click();
await explorer.getByRole('button', { name: 'Restore Supplier evidence' }).click();
await expect(explorer.getByRole('alert')).toContainText('Load the checkpoint source');
await expect(explorer.getByLabel('Investigation notes')).toHaveValue('');
});
56 changes: 49 additions & 7 deletions apps/demo/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ import {
useSyncExternalStore,
} from 'react';
import type { ReactNode } from 'react';
import { flushSync } from 'react-dom';
import { ExplorationWorkspace, EXPLORATION_CSS } from './ExplorationWorkspace';

import type {
AcceptedEdge,
Expand All @@ -96,6 +98,7 @@ import type {
GraphViewState,
GroupSpec,
InstanceStatus,
JsonValue,
LabelConfig,
MetaEdge,
NodeId,
Expand Down Expand Up @@ -413,7 +416,7 @@ const CREATED_DIM: DimensionSpec<AppNodeAttrs> = {
},
};

const CROSSFILTER_DIMS: readonly DimensionSpec<AppNodeAttrs>[] = [SCORE_DIM, CREATED_DIM];
const CROSSFILTER_DIMS: readonly DimensionSpec<AppNodeAttrs>[] = [SCORE_DIM, CREATED_DIM, M5_TABLE_DIMENSION];

// Omnigraph mode: intel-style graphs carry no generated metrics, but the
// adapter injects `type`, most content nodes declare a `domain` enum, and the
Expand Down Expand Up @@ -441,6 +444,7 @@ const OG_CREATED_DIM: DimensionSpec<AppNodeAttrs> = {
const OG_CROSSFILTER_DIMS: readonly DimensionSpec<AppNodeAttrs>[] = [
OG_DOMAIN_DIM,
OG_CREATED_DIM,
M5_TABLE_DIMENSION,
];

/** M5 mode adds the id-keyed dimension `<GraphTable>`'s filter brushes
Expand Down Expand Up @@ -574,6 +578,8 @@ declare global {
}

export function App() {
const [explorerOpen, setExplorerOpen] = useState(false);
const [explorerControlsOpen, setExplorerControlsOpen] = useState(false);
const graphRef = useRef<GraphHandle<AppNodeAttrs, AppEdgeAttrs> | null>(null);
const [gen, setGen] = useState<GenState>(INITIAL_GEN);
const [mode, setMode] = useState<DataMode>(DECLARATIVE);
Expand Down Expand Up @@ -1175,8 +1181,13 @@ export function App() {
);
const onBackgroundClick = useCallback(() => setSelection([]), []);
const onEdgeClick = useCallback(
({ edge }: { edge: AcceptedEdge<AppEdgeAttrs> }) => setSelection([edge.source, edge.target]),
[],
({ edge }: { edge: AcceptedEdge<AppEdgeAttrs> }) => {
if (explorerOpen) {
flushSync(() => setSelection([]));
graphRef.current?.instance.selectEdges([edge.id]);
} else setSelection([edge.source, edge.target]);
},
[explorerOpen],
);
const onNodeDragStart = useCallback(({ node }: { node: GraphNode<AppNodeAttrs> }) => {
setDragNote(`dragging ${labelOf(node)}…`);
Expand Down Expand Up @@ -1376,6 +1387,21 @@ export function App() {
sync();
return instance.store.subscribe(sync);
}, [graphKey]);
const clearExplorerFilters = useCallback(() => {
flushSync(() => { setExcludedClusters(EMPTY_CLUSTER_SET); setExcludedTypes(EMPTY_TYPE_SET); });
}, []);
const restoreExplorerFilters = useCallback((raw: JsonValue) => {
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) throw new Error('Invalid saved filters');
const { clusters, types, mode: savedMode } = raw;
if (!Array.isArray(clusters) || !clusters.every((value) => typeof value === 'number') ||
!Array.isArray(types) || !types.every((value) => typeof value === 'string') ||
(savedMode !== 'hide' && savedMode !== 'dim')) throw new Error('Invalid saved filters');
flushSync(() => {
setExcludedClusters(new Set(clusters as number[]));
setExcludedTypes(new Set(types as string[]));
setFilterMode(savedMode);
});
}, []);
const semantic = mode.kind === 'semantic';
const streaming = meter !== null && meter.phase === 'streaming';
// In stream mode the cluster ids exist only once the replace committed;
Expand All @@ -1388,8 +1414,9 @@ export function App() {
style={{ ...S.appRoot, ...S.themeVars(themeBase) }}
data-theme={themeBase}
data-testid="app-root"
data-exploring={explorerOpen}
>
<style>{BUTTON_CSS}</style>
<style>{BUTTON_CSS + EXPLORATION_CSS}</style>
{mismatchRaw !== null && (
<div data-testid="view-mismatch-banner" style={S.mismatchBanner}>
This view was saved over different data — restoring it may not show what the sender saw.
Expand All @@ -1403,6 +1430,7 @@ export function App() {
)}
<Graph<AppNodeAttrs, AppEdgeAttrs>
key={graphKey}
className="demo-graph"
ref={graphRef}
engine={engineFactory}
{...(mode.kind === 'declarative'
Expand Down Expand Up @@ -1486,7 +1514,20 @@ export function App() {
onError={onError}
style={S.graphStyle}
>
<div style={S.overlayRoot}>
<ExplorationWorkspace
open={explorerOpen}
typeField={mode.kind === 'omnigraph' ? ORBIT_TYPE_KEY : 'type'}
filters={{ clusters: [...excludedClusters], types: [...excludedTypes], mode: filterMode }}
filterLabels={excludedClusters.size + excludedTypes.size === 0 ? [] : [`Host filters (${excludedClusters.size + excludedTypes.size})`]}
restoreFilters={restoreExplorerFilters}
clearFilters={clearExplorerFilters}
/>
{explorerOpen && <nav className="demo-exploration-nav" aria-label="Exploration workspace controls">
<ToolButton testId="explorer-close" onClick={() => setExplorerOpen(false)}>Close explorer</ToolButton>
<ToolButton onClick={() => setExplorerControlsOpen((open) => !open)}>{explorerControlsOpen ? 'Hide data controls' : 'Data and display controls'}</ToolButton>
<GraphToolbar style={S.embeddedOverlay} />
</nav>}
<div style={{ ...S.overlayRoot, ...(explorerOpen ? explorerControlsOpen ? { paddingTop: 64 } : { display: 'none' } : {}) }}>
<div style={S.topRow}>
<div style={S.headerColumn}>
{/* FIRST in DOM (Tab reaches its toggle first); CSS `order`
Expand All @@ -1495,6 +1536,7 @@ export function App() {
<header style={S.headerPanel}>
<span style={S.title}>orbit demo</span>
<HeaderCounts />
<ToolButton testId="explorer-toggle" onClick={() => setExplorerOpen(true)}>Explore graph</ToolButton>
</header>
<DataPanel
streaming={streaming}
Expand Down Expand Up @@ -1597,14 +1639,14 @@ export function App() {

{/* Search box: top-center, collapsible. AFTER the top row
in DOM so the navigator toggle stays the first tabbable. */}
<SearchSection omnigraph={mode.kind === 'omnigraph'} />
{!explorerOpen && <SearchSection omnigraph={mode.kind === 'omnigraph'} />}

{error !== null && <div style={S.errorBanner}>engine error: {error}</div>}

{/* The docked inspector replaces the workbench sidebar while open;
in M5 mode the semantic dock (table + sim controls) owns the
right edge instead — all three live below the toolbar rows. */}
{inspectorOpen ? (
{explorerOpen ? null : inspectorOpen ? (
<GraphInspector dock="right" style={S.inspectorOverride} />
) : mode.kind === 'semantic' ? (
<SemanticDock
Expand Down
83 changes: 83 additions & 0 deletions apps/demo/src/ExplorationWorkspace.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { useEffect, useRef, useState } from 'react';
import { createInvestigationSession } from '@modernrelay/orbit-core';
import type { InvestigationSession, JsonValue } from '@modernrelay/orbit-core';
import { useGraphInstance } from '@modernrelay/orbit-react';
import { GraphExplorer } from '@modernrelay/orbit-react/components/Explorer';

const STORAGE_KEY = 'orbit-demo-investigations-v1';

/** Demo-owned storage and predicate filters; neither belongs in the library. */
export function ExplorationWorkspace(props: {
open: boolean;
typeField: string;
filters: JsonValue;
filterLabels: readonly string[];
restoreFilters: (filters: JsonValue) => void;
clearFilters: () => void;
}) {
const instance = useGraphInstance();
const latest = useRef(props);
latest.current = props;
const [session, setSession] = useState<InvestigationSession | null>(null);
const [storageError, setStorageError] = useState<string | null>(null);
useEffect(() => {
const investigation = createInvestigationSession(instance, {
captureHostState: () => latest.current.filters,
restoreHostState: (filters) => latest.current.restoreFilters(filters),
});
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored !== null) {
const saved: unknown = JSON.parse(stored);
if (!Array.isArray(saved)) throw new Error('Saved investigations must be a list');
for (const checkpoint of saved) investigation.importCheckpoint(checkpoint);
}
} catch (error) { setStorageError(`Could not open saved investigations: ${String(error)}`); }
const unsubscribe = investigation.store.subscribe((next, previous) => {
if (next.checkpoints === previous.checkpoints) return;
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(next.checkpoints));
setStorageError(null);
} catch (error) { setStorageError(`Browser storage failed; export your checkpoint: ${String(error)}`); }
});
setSession(investigation);
return () => { unsubscribe(); investigation.destroy(); };
}, [instance]);

if (session === null || !props.open) return null;
return <aside className="demo-exploration-panel" hidden={!props.open}>
{storageError !== null && <p role="alert">{storageError}</p>}
<GraphExplorer
investigation={session}
layout="panel"
typeField={props.typeField}
height="100%"
constraints={props.filterLabels.map((label) => ({ id: label, label, onClear: props.clearFilters }))}
onRecoverSearchResult={async (result, reason) => {
if (reason === 'not-loaded') throw new Error('Load this entity using the Data controls, then search again.');
if (reason === 'out-of-scope') {
const scope = instance.store.getState().scope;
if (scope !== null) instance.applyHostUpdate({ subgraph: { ...scope, seedIds: [...scope.seedIds, result.id], reflow: false } });
} else {
props.clearFilters();
instance.showNodes([result.id]);
const crossfilter = instance.getCrossfilterSession();
for (const { key } of instance.getViewState().crossfilter) await crossfilter?.setBrush(key, null);
session.setTableQuery('');
}
}}
/>
</aside>;
}

export const EXPLORATION_CSS = `
.demo-exploration-panel { position: fixed; right: 12px; top: 12px; bottom: 12px; width: 620px; z-index: 30; }
.demo-exploration-panel[hidden] { display: none; }
[data-exploring="true"] > .demo-graph { width: calc(100% - 644px) !important; }
.demo-exploration-nav { position: fixed; top: 14px; left: 14px; right: 658px; z-index: 40; display: flex; flex-wrap: wrap; gap: 8px; }
@media (max-width: 1000px) {
[data-exploring="true"] > .demo-graph { width: 100% !important; height: 45% !important; }
.demo-exploration-panel { left: 8px; right: 8px; top: 46%; bottom: 8px; width: auto; }
.demo-exploration-nav { right: 14px; }
}
`;
Loading
Loading