This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
@gravity-ui/graph is a graph visualization library that combines Canvas for high-performance rendering with HTML/React for rich interactions. The library automatically switches between rendering modes based on zoom level.
# Install dependencies (use npm, not yarn or pnpm)
npm install
# Development mode (watch TypeScript and CSS)
npm run dev
# Run Storybook for development
npm run storybook
# Build for production
npm run build:publish
# Type checking
npm run typecheck
# Linting
npm run lint
# Testing
npm run test
# Update snapshots
npm run test -- --updateSnapshot
# Build Storybook
npm run build-storybookThe library uses a hybrid Canvas + React architecture:
- Canvas Mode (Zoomed Out): Entire graph rendered on Canvas for maximum performance with thousands of elements
- React Mode (Zoomed In): React components activated for blocks when camera scale reaches threshold
- Automatic Switching:
ReactLayermanages the transition based onactivationScaleconfiguration
Key Files:
src/components/canvas/layers/graphLayer/GraphLayer.ts- Main Canvas renderingsrc/react-components/layer/ReactLayer.tsx- React Portal integrationsrc/react-components/GraphCanvas.tsx- React wrapper component
The library implements a custom component system (not React) for Canvas rendering:
Component Hierarchy:
CoreComponent(src/lib/CoreComponent.ts) - Tree structure, children management, context propagationComponent(src/lib/Component.ts) - Lifecycle hooks, state/props managementGraphComponent(src/components/canvas/GraphComponent/) - HitBox, dragging, ports for graph elementsBlock,BlockConnection- Specific implementations
Lifecycle Flow:
willMount → firstIterate → willRender → render → didRender
→ willUpdateChildren → didUpdateChildren
→ propsChanged/stateChanged/contextChanged
→ unmount
Update Pattern: Components call performRender() to schedule updates via the scheduler, which batches renders in the next animation frame.
Layers are the primary extension mechanism. Each layer is a Component that manages Canvas and/or HTML rendering:
Built-in Layers (render order):
- BelowLayer (zIndex: 1) - Background grid
- GraphLayer (zIndex: 2) - Blocks and connections (Canvas)
- SelectionLayer (zIndex: 3) - Selection visualization
- ReactLayer (zIndex: 3) - React components (HTML)
- CursorLayer (zIndex: 4+) - Dynamic cursor management
Layer Lifecycle:
init()- Create Canvas/HTML elementsattachLayer()- Attach to DOM, callafterInit()afterInit()- IMPORTANT: Set up ALL event listeners here (NOT in constructor/init)unmount()- Clean up via AbortController
Creating Custom Layers:
class MyLayer extends Layer {
constructor(props) {
super({
canvas: { zIndex: 2, respectPixelRatio: true },
html: { zIndex: 3, transformByCameraPosition: true },
...props
});
}
protected afterInit() {
// Subscribe to events using wrapper methods
this.onGraphEvent("camera-change", this.handleCameraChange);
this.onCanvasEvent("mousedown", this.handleMouseDown);
super.afterInit(); // Call at end
}
}
// Add to graph
graph.addLayer(MyLayer, { customProp: 'value' });
// Do NOT pass graph, camera, root - these are auto-providedGlobalScheduler (src/lib/Scheduler.ts) drives all rendering via requestAnimationFrame:
- Priority Queues: 5 levels (HIGHEST → LOWEST)
- Batched Updates:
performRender()marks components dirty, actual render happens in next frame - Tree Traversal: Only dirty components actually call
render() - Lifecycle:
graph.start()begins animation loop,graph.stop()pauses
The library uses @preact/signals-core for reactive state management:
Store Structure (src/store/):
RootStore
├── blocksList: BlockListStore
│ ├── $blocks: Signal<BlockState[]>
│ └── blockSelectionBucket
├── connectionsList: ConnectionsStore
│ ├── $connections: Signal<ConnectionState[]>
│ └── connectionSelectionBucket
├── groupsList: GroupsListStore
├── settings: GraphEditorSettings
└── selectionService: SelectionService
Block State (src/store/block/Block.ts):
BlockState {
$rawState: Signal<TBlock> // Raw block data
$state: computed(() => ...) // Full state + selection
$geometry: computed(() => ...) // x, y, width, height
$selected: computed(() => ...) // Selection state
$anchorStates: Signal<AnchorState[]>
}Port State (src/store/connection/port/Port.ts):
PortState {
$state: Signal<TPort> // Raw port data (id, x, y, component, lookup)
$point: computed(() => ...) // Effective position (respects delegation)
delegate(target): void // Mirror another port's position
undelegate(): void // Restore own position
isDelegated: boolean // Whether currently delegated
}Key Patterns:
- Components subscribe to signals via
onSignal()inafterInit() - Use
batch(() => { ... })to wrap multiple signal updates - React integration via
useSignal()hook - Automatic cleanup via AbortController
- Port delegation:
port.delegate(targetPort)makes port mirror target's$point;port.undelegate()restores saved position
CameraService (src/services/camera/CameraService.ts) manages viewport and zoom:
State:
{
x, y // Camera position in canvas space
width, height // Viewport dimensions
scale // Zoom level (0.01 - 1.0)
relativeX/Y // Scale-aware camera space coordinates
viewportInsets // UI overlay padding
autoPanningEnabled
}Coordinate Conversion:
- Screen → World:
camera.applyToPoint(screenX, screenY) - World → Screen: Apply camera transform manually in Canvas
Event Subscription Patterns:
In Layers (Preferred):
protected afterInit() {
// Graph events
this.onGraphEvent("camera-change", this.handleCameraChange);
// DOM events
this.onCanvasEvent("mousedown", this.handleMouseDown);
this.onHtmlEvent("click", this.handleClick);
this.onRootEvent("keydown", this.handleKeyDown);
// Signal subscriptions
this.onSignal(this.context.graph.$graphColors, this.handleColorsChange);
super.afterInit();
}In React:
import { useGraphEvent } from '@gravity-ui/graph';
function MyComponent() {
const { graph } = useGraph(config);
useGraphEvent(graph, 'block:select', (detail, event) => {
console.log('Block selected:', detail.target?.id);
});
return <GraphCanvas graph={graph} />;
}IMPORTANT: Always use AbortController pattern. The layer wrapper methods (onGraphEvent, etc.) handle this automatically. Never subscribe in constructor - always in afterInit().
import { batch } from '@preact/signals-core';
batch(() => {
store.blocksList.updateBlocks(blocks);
store.connectionsList.updateConnections(connections);
});const customBlocks = {
"MyBlockType": CustomBlockComponent,
"default": DefaultBlockComponent
};
graph.rootStore.settings.setBlockComponents(customBlocks);import { useLayer } from '@gravity-ui/graph';
function MyComponent() {
const { graph } = useGraph(config);
useLayer(graph, DevToolsLayer, {
showRuler: true, // Only custom props
// graph, camera, root auto-provided
});
return <GraphCanvas graph={graph} />;
}- Types and interfaces start with
TorI:TBlock,ICamera,TConnection - Component files:
ComponentName.tsxorComponentName.ts - Utilities:
utilityName.ts - Tests:
ComponentName.test.ts - Styles:
ComponentName.css
- Always set up event subscriptions in
afterInit(), NOT in constructor - Use layer wrapper methods:
onGraphEvent,onCanvasEvent,onHtmlEvent,onRootEvent - These methods use the layer's
eventAbortControllerfor automatic cleanup - Call
super.afterInit()at the END of yourafterInit()method - Call
super.unmount()in unmount to ensure proper cleanup
- DOM operations must be in
afterInit(), notinit()or constructor - Layers are created (
constructor→init) before DOM attachment afterInit()is called afterattachLayer()when layer is in DOM- This ensures root element is available for DOM operations and measurements
- Extend base
LayerPropswhen creating custom layer props - Pass
graph,camera,root,emitterautomatically viagraph.addLayer() - Override specific options in constructor:
super({ ...props, canvas: { zIndex: 2 } }) - Use
respectPixelRatio: truefor sharp Canvas rendering - Use
transformByCameraPosition: truefor layers that follow camera
- No dedicated "HTMLLayer" class - set
htmlproperty in Layer constructor transformByCameraPosition: trueenables HTML transform with camera- Uses CSS
matrix()transform for performance - React components render via
ReactLayerusingcreatePortal
Data Flow:
User Input
↓
GraphLayer Event Handler
↓
Store Signal Update (batched)
↓
Scheduler marks components dirty
↓
requestAnimationFrame tick
↓
Component.iterate() traversal
↓
render() methods called
↓
Canvas/HTML updates
Rendering Pipeline:
- Components schedule updates via
performRender() - GlobalScheduler batches all updates
- On next frame, scheduler traverses component tree
- Only dirty components call
render() - Canvas context updates drawn to screen
State Management:
- Centralized in
RootStore - Reactive via
@preact/signals-core - Computed signals for derived state
- Automatic component updates when signals change
- React integration via
useSignal()hook
The primary extension mechanism is Custom Layers:
- Adding Visuals: Create a layer with Canvas rendering
- Adding Interactions: Create a layer with event handlers
- Adding UI Overlays: Create a layer with HTML elements
- Custom Behaviors: Extend base components (Block, Connection)
Layers receive:
graphinstance for API accesscamerafor viewport informationrootelement for DOM attachment- Full context with colors, constants, services
- Tests use Jest with
@swc/jestfor TypeScript - Canvas mocking via
jest-canvas-mock - Test files in
__tests__directories or*.test.tsalongside source - Run specific test:
npm test -- <pattern>
Comprehensive docs in docs/:
docs/system/- Component lifecycle, events, scheduler, public APIdocs/components/- Canvas components, blocks, anchorsdocs/rendering/- Rendering mechanism, layersdocs/blocks/- Block groupsdocs/connections/- Connection systemdocs/react/- React integration guide
Live examples: https://preview.gravity-ui.com/graph/
Strict Rules:
- Use specific types whenever possible (
MouseEvent,KeyboardEvent,HTMLElement) - Use
voidfor functions that don't return a value - Use
unknownonly as a last resort when the type is truly unpredictable - Implement appropriate interfaces instead of type casting
- Use generics to create flexible but type-safe APIs
Type Casting:
- Avoid
as anyat all costs - If type casting is necessary, use
as unknown as TargetType - Prefer type guards (
instanceof,typeof, custom type predicates) over type casting
Examples:
// ❌ Bad
function handleEvent(event: any) {
event.preventDefault();
}
// ✅ Good
function handleEvent(event: MouseEvent): void {
event.preventDefault();
}
// ❌ Bad
const element = document.getElementById('foo') as any;
// ✅ Good
const element = document.getElementById('foo') as HTMLElement | null;
if (element) {
// Use element
}Rule: Always specify return types for functions explicitly.
// ❌ Bad
function getBlockById(id: string) {
return this.blocks.find(b => b.id === id);
}
// ✅ Good
function getBlockById(id: string): BlockState | undefined {
return this.blocks.find(b => b.id === id);
}
// ✅ Good - void for no return
function updateBlock(block: TBlock): void {
this.$blocks.value = [...this.$blocks.value, block];
}Prefer interfaces for objects, type aliases for unions:
// ✅ Good - interface for object shape
interface BlockData {
id: string;
type: 'input' | 'output' | 'process';
position: { x: number; y: number };
}
// ✅ Good - type alias for union
type BlockType = 'input' | 'output' | 'process';
// ❌ Bad - overly generic
interface BlockData {
[key: string]: any;
}Use meaningful names and clear constraints:
// ❌ Bad
function transformBlock<T>(block: T): any {
return { ...block };
}
// ✅ Good
function transformBlock<TData extends BlockData>(
block: TData
): TransformedBlock<TData> {
return {
...block,
transformed: true,
};
}Rules:
- Use explicit accessibility modifiers (
public,private,protected) for methods and accessors - Parameter properties (
constructor(private name: string)) are encouraged - Regular properties don't require explicit accessibility (default
public) - Constructors don't require
public
// ✅ Good
class Block extends Component {
private isDirty: boolean = false;
constructor(
private readonly id: string,
protected state: BlockState
) {
super();
}
public render(): void {
// ...
}
protected updateState(): void {
// ...
}
}Best Practices:
- Use barrel exports (
index.ts) for public APIs - Keep internal types in separate files
- Use type-only imports:
import type { MyType } from './types'; - Export types explicitly when needed
// index.ts - barrel export
export * from "./Block";
export * from "./BlockState";
export type { TBlock, TBlockId } from "./types";
// someFile.ts - type-only import
import type { TBlock } from "./types";
import { BlockState } from "./BlockState";Formatting Rules:
- Quotes: Always double quotes (
") - Semicolons: Always (
;) - Trailing commas: In multi-line arrays, objects, function parameters
- Line length: Maximum 100 characters
- Indentation: 2 spaces for all files
- Object literals: Add spaces inside braces
{ foo: "bar" }
Example:
const config = {
blocks: [
{ id: "1", type: "input" },
{ id: "2", type: "output" },
],
connections: [
{ source: "1", target: "2" },
],
};
function createBlock(
id: string,
type: BlockType,
options: BlockOptions,
): Block {
return new Block(id, type, options);
}Fixing ESLint Errors:
- Always run
npx eslint --fix PATH_TO_FILEfirst - Don't attempt manual fixes before running auto-fix
Rules:
- Use
setState()instead of directthis.state = - Use
setProps()instead of directthis.props = - Both methods automatically trigger re-render
// ❌ Bad
this.state = { selected: true };
this.props = { x: 100, y: 200 };
// ✅ Good
this.setState({ selected: true });
this.setProps({ x: 100, y: 200 });Rules:
- Call
performRender()to request re-render - NEVER call
render()directly setState()andsetProps()automatically callperformRender()
// ❌ Bad
this.render();
// ✅ Good
this.performRender();
// ✅ Good - automatic re-render
this.setState({ selected: true }); // No need to call performRender()Rules:
- Use
Component.create(props)instead ofnew Component(props) - Create children only through
updateChildren()method
// ❌ Bad
const child = new Block({ id: "1" });
this.children.push(child);
// ✅ Good
protected updateChildren(): Component[] {
return this.blocks.map(block =>
Block.create({ id: block.id, ...block })
);
}Best Practices:
- Use
subscribeSignal()for reactive updates - Override
stateChanged()to optimize rendering - Use
shouldRender = falseto skip unnecessary renders - Use
isVisible()to check viewport visibility before rendering
class Block extends Component {
protected afterInit(): void {
// Subscribe to block state changes
this.subscribeSignal(
this.blockState.$state,
this.handleStateChange.bind(this)
);
super.afterInit();
}
protected stateChanged(prevState: TBlockState): void {
// Only re-render if position changed
if (
prevState.x !== this.state.x ||
prevState.y !== this.state.y
) {
this.performRender();
}
}
public render(ctx: CanvasRenderingContext2D): void {
if (!this.isVisible()) return;
// Render logic
}
}Coordinate System:
- DO NOT transform original coordinates to world coordinates in components
- Components receive world coordinates, canvas transform handles screen conversion
Scaling:
- Scale line widths based on camera zoom for consistent visual appearance
public render(ctx: CanvasRenderingContext2D): void {
const scale = this.context.camera.getCameraScale();
ctx.lineWidth = Math.round(2 / scale); // Consistent 2px visual width
// Use theme colors
ctx.fillStyle = this.context.colors.block.background;
ctx.strokeStyle = this.context.colors.block.border;
// Render block
}BatchPath2DRenderer:
- Prefer
update()over conditionaladd()/delete()logic update()internally callsdelete()thenadd()
// ❌ Bad
if (this.isAdded) {
this.batchRenderer.delete(this.id);
}
this.batchRenderer.add(this.id, this.path);
this.isAdded = true;
// ✅ Good
this.batchRenderer.update(this.id, this.path);Primary Goal: Layers are the main extension mechanism for the library.
Use layers for:
- Adding new visual elements to the graph
- Adding new behaviors and interactions
- Managing non-visual logic that participates in lifecycle
- Creating UI overlays
Initialization Sequence:
- Creation (
constructor→init()) - Layer created, NOT in DOM - Attachment (
attachLayer()→afterInit()) - Layer attached to DOM - Updates (rendering cycle) - Layer responds to changes
- Detachment (
detachLayer()→unmount()) - Layer removed from DOM
CRITICAL RULE: All DOM operations MUST be in afterInit(), NOT in init() or constructor.
Why: During creation, the root element may be undefined or not yet in document. afterInit() guarantees DOM is ready.
class MyLayer extends Layer {
constructor(props: LayerProps) {
super(props);
// ❌ Bad - NO DOM operations here
// this.canvas.addEventListener('click', ...);
}
protected init(): void {
super.init();
// ❌ Bad - NO DOM operations here
}
protected afterInit(): void {
// ✅ Good - DOM operations here
this.onCanvasEvent("click", this.handleClick);
this.onGraphEvent("camera-change", this.handleCamera);
// Inject styles
if (this.html) {
this.html.style.pointerEvents = "none";
}
super.afterInit(); // Always call at end
}
public unmount(): void {
// Cleanup happens automatically via AbortController
super.unmount();
}
}LayerProps structure:
interface LayerProps {
canvas?: {
zIndex: number;
classNames: string[];
respectPixelRatio: boolean; // default: true
transformByCameraPosition: boolean; // default: false
};
html?: {
zIndex: number;
classNames: string[];
transformByCameraPosition: boolean; // default: false
};
camera: ICamera;
graph: Graph;
root: HTMLElement;
}Creating a layer:
class MyLayer extends Layer<TMyLayerProps, LayerContext, TComponentState> {
constructor(props: TMyLayerProps) {
super({
...props,
canvas: {
...props.canvas,
zIndex: 100,
respectPixelRatio: true,
classNames: ["my-layer-canvas"],
},
html: {
...props.html,
zIndex: 101,
transformByCameraPosition: true,
classNames: ["my-layer-html"],
},
});
}
}
// TMyLayerProps must extend LayerProps
interface TMyLayerProps extends LayerProps {
myCustomProp: string;
}Key Points:
- No separate "HTMLLayer" class
- Configure HTML rendering via
htmlproperty in constructor transformByCameraPosition: trueenables automatic camera transform- Transform uses CSS
matrix()for performance
constructor(props: LayerProps) {
super({
html: {
zIndex: 3,
classNames: ["my-html-layer"],
transformByCameraPosition: true, // Enables camera transform
},
...props,
});
}
protected afterInit(): void {
// Camera transform is applied automatically
// Subscription set up in afterInit() for proper reattachment
this.onGraphEvent("camera-change", this.handleCameraChange);
super.afterInit();
}In Config:
const graphConfig = {
layers: [
[MyLayer, { myCustomProp: "value" }], // Only custom props
],
};Dynamically:
// graph.addLayer() automatically provides graph, camera, root, emitter
const layer = graph.addLayer(MyLayer, {
myCustomProp: "value",
// Do NOT pass: graph, camera, root, emitter
});For crisp Canvas rendering:
- Set
respectPixelRatio: true(default) in canvas config - Base Layer class handles DPR scaling automatically
Manual transform methods:
public render(ctx: CanvasRenderingContext2D): void {
// Clear canvas and apply base transform
this.resetTransform();
// Apply additional transform if needed
this.applyTransform(x, y, scale, respectPixelRatio);
// Get current DPR
const dpr = this.getDRP();
// Render...
}Camera coordinate conversion:
// Screen to World
const worldPoint = this.context.camera.applyToPoint(screenX, screenY);
// World to Screen (manual)
const cameraState = this.context.camera.getCameraState();
const screenX = worldX * cameraState.scale + cameraState.x;
const screenY = worldY * cameraState.scale + cameraState.y;CRITICAL RULE: Use wrapper methods in afterInit(), NOT in constructor.
Wrapper methods (use AbortController internally):
onGraphEvent(eventName, handler)- Graph eventsonCanvasEvent(eventName, handler)- Canvas element eventsonHtmlEvent(eventName, handler)- HTML element eventsonRootEvent(eventName, handler)- Root element eventsonSignal(signal, handler)- Signal subscriptions
protected afterInit(): void {
// ✅ Good - wrapper methods handle AbortController
this.onGraphEvent("camera-change", this.handleCameraChange);
this.onCanvasEvent("mousedown", this.handleMouseDown);
if (this.html) {
this.onHtmlEvent("click", this.handleHtmlClick);
}
if (this.root) {
this.onRootEvent("keydown", this.handleKeyDown);
}
// Signal subscription
this.onSignal(
this.context.graph.$graphColors,
this.handleColorsChange
);
super.afterInit(); // Always at end
}
// Handlers are automatically cleaned up on unmount via AbortControllerProblem: Layers added to root may intercept events intended for camera (pan/zoom).
Solution: Override getParent() to delegate to camera:
public getParent(): Component | undefined {
return this.props.graph.getGraphLayer().$.camera;
}In Layers (Preferred): Use wrapper methods - they handle AbortController automatically.
protected afterInit(): void {
this.onGraphEvent("block:select", this.handleBlockSelect);
this.onCanvasEvent("mousemove", this.handleMouseMove);
super.afterInit();
}
private handleBlockSelect = (event: CustomEvent): void => {
const block = event.detail.target;
console.log("Block selected:", block?.id);
};In React (Recommended):
Use useGraphEvent hook - automatic cleanup.
import { useGraphEvent } from "@gravity-ui/graph";
function MyComponent() {
const { graph } = useGraph(config);
useGraphEvent(graph, "block:select", (detail, event) => {
console.log("Block selected:", detail.target?.id);
});
useGraphEvent(graph, "camera-change", (detail) => {
console.log("Camera state:", detail);
});
return <GraphCanvas graph={graph} />;
}Core API (Manual Cleanup Required):
// ❌ Avoid - manual cleanup
const unsubscribe = graph.on("event-name", handler);
// Must call: unsubscribe();
// ✅ Preferred - AbortController
const controller = new AbortController();
graph.on("event-name", handler, { signal: controller.signal });
// Cleanup: controller.abort();Event structure:
- Data in
event.detail - Target component in
event.detail.target(for interaction events) - Source DOM event in
event.detail.sourceEvent
useGraphEvent(graph, "block:click", (detail, event) => {
const block = detail.target; // BlockComponent
const mouseEvent = detail.sourceEvent; // MouseEvent
if (mouseEvent.shiftKey) {
// Handle shift+click
}
// Cancel default behavior
event.preventDefault();
});GraphCanvas - Main Container:
import { GraphCanvas, useGraph } from "@gravity-ui/graph";
function App() {
const { graph } = useGraph({
layers: [[MyLayer, { customProp: "value" }]],
});
return (
<GraphCanvas
graph={graph}
renderBlock={(graph, block) => (
<GraphBlock graph={graph} block={block}>
<div>Custom content</div>
</GraphBlock>
)}
/>
);
}GraphBlock - Block Wrapper:
<GraphBlock graph={graph} block={block} className="custom-block">
<div className="block-content">
<h3>{block.name}</h3>
<p>{block.description}</p>
</div>
</GraphBlock>Automatic handling:
- Position synchronization with canvas
- State management (selection, hover)
- Layout management
- Z-index handling
- CSS variables injection
GraphBlockAnchor - Connection Points:
<GraphBlockAnchor
graph={graph}
anchor={anchor}
position="fixed" // or "auto"
>
{(state) => (
<div className={cn("anchor", { selected: state.selected })}>
<div className="anchor-dot" />
</div>
)}
</GraphBlockAnchor>Hook signature:
const layer = useLayer(graph, LayerConstructor, props);Features:
- Automatic initialization when graph is ready
- Automatic cleanup on unmount
- Props updates via
setProps() - Returns layer instance
Example:
import { useLayer } from "@gravity-ui/graph";
function MyComponent() {
const { graph } = useGraph(config);
const devTools = useLayer(graph, DevToolsLayer, {
showRuler: true,
rulerSize: 20,
// graph, camera, root auto-provided
});
// Can access layer instance
useEffect(() => {
if (devTools) {
console.log("DevTools layer ready");
}
}, [devTools]);
return <GraphCanvas graph={graph} />;
}Automatic CSS variables injected by GraphBlock:
.custom-block {
/* Position (automatic) */
--graph-block-geometry-x: 0px;
--graph-block-geometry-y: 0px;
--graph-block-geometry-width: 200px;
--graph-block-geometry-height: 100px;
--graph-block-z-index: 1;
/* Theme colors (from config) */
--graph-block-bg: rgba(37, 27, 37, 1);
--graph-block-border: rgba(229, 229, 229, 0.2);
--graph-block-border-selected: rgba(255, 190, 92, 1);
/* Use variables */
position: absolute;
left: var(--graph-block-geometry-x);
top: var(--graph-block-geometry-y);
width: var(--graph-block-geometry-width);
height: var(--graph-block-geometry-height);
z-index: var(--graph-block-z-index);
background: var(--graph-block-bg);
border: 1px solid var(--graph-block-border);
}
.custom-block.selected {
border-color: var(--graph-block-border-selected);
}View Configuration:
const config: TGraphConfig = {
viewConfiguration: {
colors: {
block: {
background: "rgba(37, 27, 37, 1)",
border: "rgba(229, 229, 229, 0.2)",
selectedBorder: "rgba(255, 190, 92, 1)",
},
connection: {
background: "rgba(255, 255, 255, 0.5)",
selectedBackground: "rgba(234, 201, 74, 1)",
},
anchor: {
background: "rgba(255, 255, 255, 0.8)",
selectedBackground: "rgba(255, 190, 92, 1)",
},
},
},
};Behavior Settings:
const config: TGraphConfig = {
settings: {
canDragCamera: true,
canZoomCamera: true,
canDragBlocks: true,
canSelectBlocks: true,
canCreateNewConnections: true,
showConnectionArrows: true,
useBezierConnections: true,
blockSelectionMode: "multiple", // or "single"
},
};Use useGraph hook (Recommended):
import { useGraph, GraphCanvas, GraphBlock } from "@gravity-ui/graph";
import { useEffect } from "react";
export const MyStory = () => {
const { graph } = useGraph({
layers: [[MyLayer, { customProp: "value" }]],
renderBlock: (g, block) => (
<GraphBlock graph={g} block={block}>
<div>{block.name}</div>
</GraphBlock>
),
});
useEffect(() => {
if (graph) {
graph.setEntities({
blocks: blocksData,
connections: connectionsData,
});
graph.start();
}
}, [graph]);
return (
<div style={{ width: "100%", height: "600px" }}>
<GraphCanvas graph={graph} />
</div>
);
};Blocks must have required TBlock fields:
const blocksData: TBlock[] = [
{
is: "Block" as const, // Required
id: "block-1", // Required
name: "Block 1", // Required
selected: false, // Required
x: 100,
y: 100,
width: 200,
height: 100,
anchors: [], // Required
},
];Connections must include source and target:
const connectionsData: TConnection[] = [
{
sourceBlockId: "block-1", // Required
targetBlockId: "block-2", // Required
id: "conn-1",
},
];Story Structure:
- Keep data outside component for reusability
- Use
useEffectfor graph initialization - Use
useCallbackforrenderBlockto prevent re-renders - Set explicit container dimensions
Testing:
- Test at different zoom levels (Canvas vs React rendering)
- Test with various DPR settings if possible
- Test edge cases (empty data, viewport boundaries)
- Test interactions (drag, select, zoom, pan)
Example with controls:
export const Interactive = () => {
const [blocks, setBlocks] = useState(initialBlocks);
const { graph } = useGraph({
renderBlock: useCallback(
(g, block) => <GraphBlock graph={g} block={block} />,
[]
),
});
useEffect(() => {
if (graph) {
graph.setEntities({ blocks, connections });
graph.start();
}
}, [graph, blocks]);
return (
<>
<button onClick={() => setBlocks([...blocks, newBlock])}>
Add Block
</button>
<div style={{ height: "600px" }}>
<GraphCanvas graph={graph} />
</div>
</>
);
};Rules:
- Keep event handlers lightweight
- Debounce/throttle frequent events (resize, scroll, mousemove)
- Always cleanup on unmount
import { debounce } from "lodash-es";
class MyLayer extends Layer {
private handleMouseMoveDebounced = debounce(
this.handleMouseMove.bind(this),
16 // ~60fps
);
protected afterInit(): void {
this.onCanvasEvent("mousemove", this.handleMouseMoveDebounced);
super.afterInit();
}
private handleMouseMove(event: MouseEvent): void {
// Heavy logic here
}
}Rules:
- Always use AbortController for event listeners
- Unsubscribe from signals on unmount
- Clean up timers and intervals
- Dispose of resources properly
class MyComponent {
private timerId?: number;
protected afterInit(): void {
this.onGraphEvent("block:select", this.handleSelect);
this.timerId = window.setInterval(() => {
// Periodic task
}, 1000);
super.afterInit();
}
public unmount(): void {
if (this.timerId) {
clearInterval(this.timerId);
}
super.unmount(); // Calls AbortController cleanup
}
}Viewport Culling:
- Only render visible elements
- Use
isVisible()in components - React components automatically culled by
ReactLayer
Batch Updates:
import { batch } from "@preact/signals-core";
batch(() => {
graph.rootStore.blocksList.updateBlocks(blocks);
graph.rootStore.connectionsList.updateConnections(connections);
graph.rootStore.settings.updateSettings(settings);
});Connection Rendering:
- Use
BatchPath2DRendererfor multiple connections - Prefer
update()overadd()/delete()logic
Zoom Level Optimization:
- Simplify rendering at low zoom (schematic view)
- Full detail only at high zoom (detailed view)
- Leverage automatic Canvas/React switching
Files:
- Components:
ComponentName.tsxorComponentName.ts - Utilities:
utilityName.ts - Tests:
ComponentName.test.ts - Styles:
ComponentName.css
Types and Interfaces:
- Types: Start with
Tprefix:TBlock,TConnection,TGraphConfig - Interfaces: Start with
Iprefix:ICamera,ISelectionBucket,IService
Constants:
- UPPER_SNAKE_CASE for true constants
- camelCase for configuration objects
Functions:
- camelCase for regular functions
- PascalCase for component factories/constructors