Package name: @graphora/renderer
Planned responsibility: renderer interfaces, viewport and camera coordination, drawing, labels, style resolution, hit testing, and picking.
CanvasGraphRenderer is the first concrete renderer. It owns one <canvas>
element inside the container passed to mount, uses Canvas 2D drawing
primitives, and implements the public GraphRenderer lifecycle.
Minimal usage:
import { createGraphEngine } from '@graphora/engine'
import { CanvasGraphRenderer, createViewport } from '@graphora/renderer'
const renderer = new CanvasGraphRenderer()
const engine = createGraphEngine({
graph: {
nodes: [
{ id: 'a', label: 'Alpha', position: { x: -40, y: 0 } },
{ id: 'b', label: 'Beta', position: { x: 40, y: 0 } }
],
edges: [{ id: 'ab', source: 'a', target: 'b', label: 'connects' }]
},
renderer,
viewport: createViewport({ width: 640, height: 360 })
})
renderer.resize({ width: 640, height: 360 })
engine.mount(document.querySelector('#graph')!)
The renderer clears and paints the snapshot background, then draws edges,
basic directed arrowheads, nodes, and resolved labels in a stable order. It
uses worldToScreen for placement and stores a device-pixel-ratio-adjusted
canvas backing size during resize.
Image export is available from the Canvas renderer after it has rendered at least one snapshot:
const image = renderer.exportImage({ type: 'image/png' })
console.log(image.width, image.height, image.backgroundColor)
This exports the current viewport from the Canvas backing store. width and
height are backing-pixel dimensions, displayWidth and displayHeight are
CSS-pixel dimensions, and pixelRatio records the ratio used for the backing
store. The background is the rendered snapshot theme background. Full-graph
export is intentionally deferred until Graphora has a graph-fitting capture
pipeline that can render offscreen without disturbing the live viewport.
Edge, arrow, and label behavior:
edge.style.curved is trueedge.style.arrow.end is trueDrawing, labels, arrow direction, and picking share the same derived route. Derivation never modifies graph records or the renderer snapshot.
Labels retain the compatible draw-all default. Applications can opt into zoom-aware collision suppression:
const renderer = new CanvasGraphRenderer({
labels: {
mode: 'automatic',
minZoom: 0.65,
edgeMinZoom: 1,
collisionPadding: 3,
showEdgeLabels: true
}
})
Automatic mode keeps dragging, selected, focused, hovered, and highlighted
labels ahead of ordinary labels. Explicit label.visible: false always wins.
Set showEdgeLabels: false to suppress edge labels at every zoom.
Current limits:
pick(point, snapshot?) tests visible nodes first, then the rendered edge
routes, and returns a typed canvas result when neither is hit.exportImage(options?) is Canvas-only and exports the current viewport. It
fails if called before mount or before the first render. Supported requested
types are image/png, image/jpeg, and image/webp; browsers may fall back
to PNG for unsupported encoders, and the returned type reports the detected
data URL type.ResizeObserver, and animation-frame invalidation belong
to TASK-033.@graphora/renderer owns the V0.1 style and theme model. The public helpers are
pure and framework-agnostic:
resolveRenderTheme(theme?)resolveNodeStyle(input)resolveEdgeStyle(input)resolveLabelStyle(input)resolveInteractionStyles(input?)mergeInteractionRenderState(input?)The style priority order is:
Interaction overlays apply in this order: dimmed context, programmatic highlight, hover, focus, selection, active drag. Later overlays win for the same property only, so highlighting can reduce unrelated context without overwriting pointer or selection feedback. A hidden label stays hidden when it is highlighted.
Graph data can opt into item-level style overrides through the project-owned attribute key:
import { GRAPHORA_STYLE_ATTRIBUTE } from '@graphora/renderer'
const graph = {
nodes: [
{
id: 'service-api',
label: 'API',
attributes: {
[GRAPHORA_STYLE_ATTRIBUTE]: {
fill: '#16a34a',
radius: 24,
label: { visible: true, fontSize: 14 }
}
}
}
],
edges: [
{
source: 'service-api',
target: 'database',
attributes: {
[GRAPHORA_STYLE_ATTRIBUTE]: {
stroke: '#475569',
strokeWidth: 3,
arrow: { end: true, size: 10 }
}
}
}
]
}
This is deliberately not a selector engine. V0.1 supports node fill, stroke, stroke width, radius, opacity, visibility; edge stroke, stroke width, opacity, visibility, curved flag, arrow end settings; and label fill, font family, font size, font weight, and visibility.
Import GRAPHORA_LIGHT_THEME or GRAPHORA_DARK_THEME from
@graphora/renderer and pass it directly as createGraphEngine({ theme }).
Light is the canonical DEFAULT_RENDER_THEME; both presets and all nested
objects are frozen. Resolution copies caller overrides into immutable tokens.
import { createGraphEngine } from '@graphora/engine'
import { GRAPHORA_DARK_THEME } from '@graphora/renderer'
const engine = createGraphEngine({
graph,
renderer,
theme: {
...GRAPHORA_DARK_THEME,
node: { ...GRAPHORA_DARK_THEME.node, radius: 22 },
edge: {
...GRAPHORA_DARK_THEME.edge,
arrow: { ...GRAPHORA_DARK_THEME.edge.arrow, size: 10 }
}
},
interactionStyles: {
highlight: {
node: { stroke: '#5eead4' },
edge: { stroke: '#5eead4' },
label: { fill: '#99f6e4' }
},
dimmed: { label: { fill: '#94a3b8' } }
}
})
Spread each nested object you override to retain the remaining preset tokens.
Passing only node: { radius: 22 } replaces that input object; omitted fields
then fall back to built-in light defaults. Nested arrow overrides work the same
way. No merge helper is needed for the existing small token surface.
A node's explicit GRAPHORA_STYLE_ATTRIBUTE fill still wins over the preset
fill. Interaction overlays remain higher priority and independent of presets:
the example above supplies bright highlight labels for a dark background.
Review application-specific hover, focus, selection and drag colors alongside
any item colors you introduce. Presets do not automatically choose overlays or
follow OS color preferences. The engine accepts theme configuration at creation;
this slice adds no runtime theme setter.
The custom-styles vanilla example compares both presets and toggles path
highlighting. Themes remain renderer configuration, with no new package or
React provider. A future @graphora/themes package needs multiple renderer
consumers, larger collections, palette dependencies or external reuse demand;
see ADR 0020 in the product workspace.
@graphora/renderer now exposes the public GraphRenderer contract. The
contract is framework-agnostic and does not import React.
Renderer lifecycle:
mount(container) attaches renderer-owned resources to an HTMLElement.resize(size) accepts the shared ViewportSize shape.render(snapshot) receives a renderer-ready RenderSnapshot.pick(point, snapshot?) returns a typed node, edge, canvas, or miss result,
or null when a renderer chooses not to report a result.unmount() releases mounted resources while keeping the renderer reusable.destroy() is optional and releases final renderer resources.RenderSnapshot is resolved drawing input. It contains:
viewport: current ViewportStatenodes: render nodes with graph identity, world position, size/radius,
optional labels/data/attributes, resolved node style, and needsLayoutedges: render edges with graph identity, endpoint identity, resolved
endpoint world points, optional labels/data/attributes, direction, and stylelabels: resolved label items owned by nodes or edgestheme: default resolved theme values for V0.1interaction: hover, selection, focus, and drag state for draw passesversion: compatible optional source version for cache invalidationsourceVersion: explicit canonical source snapshot versionview: optional visible-view ID and membership versionRenderers should not accept raw graph input directly. Raw graph parsing,
normalization, mutation, and store snapshots remain outside the renderer
package. The engine is responsible for adapting core graph snapshots into
renderer-ready snapshots before calling render.
The current contract defines the API shape only. It does not implement Canvas drawing, render scheduling, hit-test algorithms, interactions, layouts, React bindings, or custom visual plugins.
The current implementation exposes pure viewport state and coordinate utilities. These utilities do not depend on DOM, Canvas, or browser globals.
Coordinate convention:
x moves right.y moves down.zoom is pixels per world unit.center is the world coordinate shown at the center of the viewport.Public viewport helpers:
createViewportsetViewportworldToScreenscreenToWorldpanByzoomAtfitBoundsclampZoomclampViewportZoomsetViewport, zoomAt, and fitBounds clamp zoom to the viewport's
minZoom and maxZoom.
panBy accepts a screen-space drag delta. A drag delta of
{ x: 10, y: 0 } at zoom 1 moves the viewport center to the left by 10
world units, so visible world content follows the drag direction.
fitBounds(null) returns the current viewport normalized through the same
validation and clamping path. Zero-size bounds center on the point and use the
clamped maximum zoom.
Set theme.node.shape or attributes['graphora:style'].shape to circle
(default), square, diamond, or rounded-rect. Radius defines the half-size
of the first three; rounded rectangles use the snapshot's size (the engine
currently resolves this to diameter × diameter). Fill, stroke, opacity, labels
and interaction emphasis continue to compose normally; interaction overlays
cannot switch shapes.
theme.edge.shape and edge style attributes accept line and curve.
The existing curved flag remains supported; when both occur in one input,
shape wins. Parallel/reciprocal routes and self-loops still route automatically.
Use isBuiltInNodeShape(value) and isBuiltInEdgeShape(value) to validate
untyped inputs. Unknown values resolve to circle/line. The immutable
BUILT_IN_NODE_SHAPES and BUILT_IN_EDGE_SHAPES arrays support application
controls; arbitrary Canvas callbacks are intentionally outside this registry.
Drawing, fill picking and endpoint clipping share geometry. Stroke outside the fill is excluded from picking and clipping. Labels and layout bounds retain the current radius-based policy; independent engine dimensions, image nodes, icons and custom visual callbacks are future work. See the vanilla node-shapes example for all forms with directed, reciprocal and self-loop edges.
Optional RenderEdge.route supplies a world-space center-anchored polyline. Canvas
clips endpoint legs and shares its resolved segments across draw, pick, arrows,
fallback labels and bitmap export. RenderLabel.layoutCenter overrides automatic
anchor selection with centered screen-sized text; visibility and collision policy
still apply. Edges without usable routes keep the existing line/curve/loop fallback.
See layout artifacts.
The engine prepares snapshots with freezeRenderSnapshot. Direct renderer users
can call this helper to copy and freeze rendering geometry, styles and camera
state while retaining opaque application data and attributes. Reuse the returned
snapshot until rendering fields change; then prepare a new snapshot. Arbitrary
mutable snapshots remain supported through the uncached reference path.
Canvas retains one prepared scene, reuses edge geometry, lazily indexes picking candidates and skips offscreen primary shapes. Precise hit testing and original draw/tie order are preserved. Labels retain their full placement pass, including visible labels whose owners are offscreen. Camera, graph, style, interaction or view changes produce a new engine snapshot and replace the cache. Unmount and destroy release the retained snapshot and scene; implicit picking after unmount returns the canvas until a new snapshot is rendered.
labels: { mode: 'placement' } measures actual Canvas text bounds and searches
positions around visible nodes and edge paths. Priorities, deterministic check
limits and a time cutoff produce explicit placement/suppression diagnostics.
getLabelPlacements() exposes the latest immutable report; resize/unmount/destroy
clear it. placeRenderLabels(snapshot, measure, options) exposes the same policy
with an application metrics provider. See
automatic label placement
for defaults, fonts, candidate limits and explicit layout centers.
Use display detail for explicit runtime modes, zoom/count hysteresis and direct renderer policy decisions.
CanvasGraphRenderer.addLayer installs Canvas or HTML definitions with scoped
mount resources and enable/disable/dispose handles. See custom layers
for coordinates, ordering, input ownership, invalidation and export omissions.