Package name: @graphora/engine
Responsibility: framework-agnostic runtime orchestration for Graphora.
The engine creates or accepts a GraphStore, exposes core graph selectors and
event subscriptions, coordinates renderer and layout lifecycle hooks, and
owns teardown cleanup. It does not implement rendering, layout algorithms,
interactions, viewport math, or React behavior.
import { createGraphEngine } from '@graphora/engine'
const engine = createGraphEngine({
graph: {
nodes: [{ id: 'a' }, { id: 'b' }],
edges: [{ source: 'a', target: 'b' }]
}
})
engine.on('graph:changed', (event) => {
console.log(event.version)
})
engine.mount(container)
engine.loadGraph({ nodes: [{ id: 'next' }] })
engine.destroy()
Renderer integrations use the public GraphRenderer contract from
@graphora/renderer. GraphEngineRenderer remains as a compatibility type
alias, not a separate renderer API. The engine adapts core graph snapshots into
renderer-ready RenderSnapshot values before calling renderer.render.
Theme and interaction style inputs are also passed through createGraphEngine
and resolved while the engine creates render snapshots:
const engine = createGraphEngine({
graph,
renderer,
theme: {
backgroundColor: '#f8fafc',
node: { fill: '#94a3b8', radius: 18 },
edge: { stroke: '#475569' },
label: { visible: true }
},
interactionStyles: {
hover: { node: { fill: '#38bdf8' } },
selection: { node: { stroke: '#7c3aed', strokeWidth: 3 } }
}
})
The engine keeps style resolution in @graphora/renderer; it only supplies the
graph snapshot items, theme input, explicit item attributes, and current
interaction state. setInteractionState(input) updates hover, selection,
focus, or drag render state and schedules a redraw when the engine is mounted.
Layout integrations use the public GraphLayout contract from
@graphora/layouts. GraphEngineLayout remains as a compatibility type alias,
not a separate engine-owned layout API. Layout execution is explicit:
import { createGraphEngine } from '@graphora/engine'
import { createCircularLayout } from '@graphora/layouts'
const engine = createGraphEngine({
graph: {
nodes: [{ id: 'a' }, { id: 'b' }],
edges: [{ source: 'a', target: 'b' }]
},
layout: createCircularLayout()
})
await engine.runLayout({ fitToView: true })
Size-aware layouts use explicit renderer-neutral geometry. The default producer
derives each built-in Canvas node's fill-body box from the engine theme and node
attributes; interaction emphasis is excluded and label measurement remains a
separate follow-up. It does not infer persistent stroke or decoration extents,
so callers that need that clearance must include it in padding. Install the
frozen snapshot before running the layout:
const geometry = engine.createLayoutGeometry({
padding: 8,
styleRevision: 0,
metricsRevision: 0
})
engine.setLayoutGeometry(geometry)
await engine.runLayout({ fitToView: true })
getLayoutGeometry() returns the active isolated snapshot. Graph changes clear
it, while a successful layout position commit conditionally rebases it to the
applied graph version. Reentrant observers that mutate the graph or install new
measurements prevent the old snapshot from being restored.
runLayout() calls the configured layout with the current graph snapshot,
applies the returned positions through one atomic position update, and returns
the applied result metadata. applyLayoutResult(result) is available
when callers run a layout themselves. It validates that every result node still
exists by public id and internal key before mutating the store, so stale or
unknown layout results fail clearly instead of partially applying.
The returned appliedVersion identifies the graph version assigned to that
atomic position publication. layout:completed follows the core position and
graph events for that version.
Async layouts use the same contract. The engine rejects a pending layout result
before applying it when a newer runLayout() call has superseded it or when the
graph version changed while it was running. Future worker-backed layouts should
present themselves as normal async layouts and rely on this engine-level guard
in addition to any transport-level cancellation.
Layout results can explicitly replace or clear edge routes and measured label
placements. Install matching geometry first. Manual applyLayoutResult validates
all fields atomically; runLayout({ output }) additionally enforces requested
outputs and declared capabilities. Defaults are ignore; required unsupported
outputs fail before calculation. Inspect frozen stage state with
getLayoutArtifacts(): null means absent, an empty array means current empty output.
Position patches invalidate previous routes/labels; route updates invalidate old
labels; label-only stages preserve routes. Same-result replacements are rebased
after the exact successful position commit. Reentrant source/view/geometry changes
cannot revive stale artifacts. New geometry, drag, filtering and data mutations
clear stage state; camera/selection changes preserve it. Canvas uses accepted
world-space polylines and label centers for drawing, picking and bitmap export.
This is the application boundary; obstacle-routing and label-placement algorithms
remain separate features. See docs/concepts/layout-artifacts.md.
Engine work has its own typed event stream, separate from core graph mutation events. Subscribe to one event type or to the complete lifecycle union:
const stopWatchingLayouts = engine.onLifecycle(
'layout:completed',
({ runId, appliedVersion, appliedNodeCount }) => {
console.log({ runId, appliedVersion, appliedNodeCount })
}
)
const stopWatchingEverything = engine.onAnyLifecycle((event) => {
console.log(event.type)
})
Render work emits render:scheduled, render:before, render:after, and
render:error. Scheduled frames carry their first reason; execution events
carry every coalesced reason and the graph snapshot version used for the draw.
Layout runs emit layout:started and exactly one of layout:completed,
layout:stale, layout:cancelled, or layout:error. engine:destroyed is the
last lifecycle event and clears the subscriptions.
Starting a newer layout cancels the engine-facing promise for older work, graph changes mark pending work stale, and destroy cancels it. A layout adapter owns its underlying computation: an opaque promise can continue after cancellation, but its late result or rejection is consumed and cannot update the engine. The built-in browser Worker layout can also terminate its private worker transport.
The engine can own an ordered list of synchronous node and edge filters. The
canonical source remains available from snapshot(), while viewSnapshot() is
the current graph passed to rendering, layout, and guarded engine.pick():
const engine = createGraphEngine({
graph,
renderer,
viewFilters: [
{
id: 'active',
kind: 'node-filter',
predicate: (node) => node.data?.active === true
}
]
})
engine.setViewFilterEnabled('active', false)
engine.setViewFilters(nextFilters)
Filtered snapshots expose sourceVersion, stable { id, version } view
identity, coherent clone-safe viewAdjacency, and frozen source provenance. Source
mutations reevaluate installed filters automatically. If that refresh fails,
getViewState() retains the last committed snapshot for diagnostics while
current layout and picking stay blocked until refreshView() or a successful
configuration replacement recovers the view.
Filter predicates must be pure, synchronous, and return booleans. Grouping, collapse/expand, synthetic topology, and many-source provenance are outside the filter runtime and remain follow-up work.
Observer exceptions do not interrupt engine bookkeeping. Provide
lifecycleObserverErrorHandler to report them through application telemetry;
otherwise the engine uses the platform error reporter or an asynchronous throw.
The optional handler's own errors use the same default reporting path.
Viewport state can be read or updated without reaching into the renderer:
const viewport = engine.getViewport()
engine.setViewport({
center: { x: viewport.center.x, y: viewport.center.y },
zoom: 2
})
Mounted engines schedule renderer updates through render invalidation. Graph
changes, viewport updates, layout application, and resize changes request a
render and coalesce while a frame is already pending. render() remains
available as an immediate escape hatch for tests or controlled integrations.
engine.invalidateRender()
engine.resize({ width: 640, height: 360 })
engine.render()
resize(size) updates the engine viewport dimensions, calls
renderer.resize(size), and invalidates the mounted renderer. During mount,
the engine reads the container dimensions when possible, resizes the renderer
before the first scheduled render, and attaches a ResizeObserver when the
environment provides one. unmount() and destroy() cancel pending renders and
disconnect resize observation so renderer work does not run after teardown.
Tests and non-browser adapters can inject a narrow renderScheduler or
resizeObserverFactory through createGraphEngine options. These hooks are
for lifecycle integration only; application features should still use the
public engine, renderer, layout, and interaction APIs.
Dedicated renderer, layout, interaction, viewport, and React work should extend their own packages rather than expanding this package into an application SDK.
createGraphPositionMemory() preserves finite coordinates across filtering and
expand/collapse visibility changes without mutating source graph input. Capture
the current runtime snapshot before loading a newly derived graph, then prepare
that graph before passing it to the engine:
const positions = createGraphPositionMemory()
positions.capture(engine.snapshot())
engine.loadGraph(positions.prepare(nextVisibleGraph))
Incoming finite node.position values are authoritative.
Otherwise the helper restores the last position for the normalized node ID.
Never-seen nodes are placed deterministically near visible neighbors, near the
last collapsed group that represented them, or in a stable fallback spiral.
Hidden positions remain in this helper instance until clear(); they are not
persisted globally.
createClusteredGraph(snapshot, options) derives a clustered visible graph from
an existing source snapshot without mutating the source GraphStore.
import { createGraphStore } from '@graphora/core'
import { createClusteredGraph } from '@graphora/engine'
const store = createGraphStore({
nodes: [
{ id: 'api', data: { domain: 'ingest' } },
{ id: 'queue', data: { domain: 'ingest' } }
],
edges: [{ id: 'api-queue', source: 'api', target: 'queue' }]
})
const clusteredGraph = createClusteredGraph(store.snapshot(), {
getClusterId: (node) => node.data?.domain
})
The returned value is normal RawGraph input: synthetic cluster nodes replace
eligible source nodes, edges between the same visible endpoints are grouped, and
internal cluster edges are hidden. Synthetic nodes and grouped edges carry
GRAPHORA_CLUSTER_ATTRIBUTE metadata with the source node or edge IDs they
represent. Expand/collapse state, community-detection algorithms, nested
folding, and a full transform pipeline remain separate future work.
createCollapseState() and createCollapsedGraph(snapshot, options) provide
the first framework-agnostic group visibility helper.
import {
createCollapsedGraph,
createCollapseState,
toggleCollapsedGroup
} from '@graphora/engine'
let collapseState = createCollapseState({
collapsedGroupIds: ['backend']
})
const visibleGraph = createCollapsedGraph(store.snapshot(), {
state: collapseState,
getGroupId: (node) => node.data?.area
})
collapseState = toggleCollapsedGroup(collapseState, 'backend')
Collapsed groups become synthetic visible nodes, internal source edges are
hidden, and crossing edges are grouped when they resolve to the same visible
endpoints. Synthetic collapsed nodes and grouped edges carry
GRAPHORA_COLLAPSE_ATTRIBUTE metadata with represented source IDs. The helper
does not mutate the source store and does not implement nested folding,
drilldown graph views, parent-relative editor geometry, or selection expansion
policies.
installGraphExtension installs explicit behavior/plugin objects with scoped
activation, disable and disposal. See scoped extensions
for ownership, cleanup, failure handling and the runnable counter example.
styleRules in engine options and setStyleRules accept ordered typed node/edge
rules with stable IDs, optional pure predicates and constant or synchronous
appearance callbacks. getStyleRules returns frozen configuration;
refreshStyles handles captured application state. Rules do not mutate core
attributes; item attributes and interaction overlays retain priority.
Evaluation is cached per visible snapshot/configuration and reused for camera,
picking and interaction changes. Replacement preflights atomically; later data
errors surface as GraphStyleEvaluationError through the render lifecycle and
prevent stale picking/measurements. Layout base sizes use the same rules;
paint-only updates retain geometry, while geometry-sensitive updates invalidate
measurements/artifacts. See the styling guide.