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
24 changes: 15 additions & 9 deletions .cursor/rules/event-model-rules.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -124,24 +124,30 @@ When implementing event subscriptions in this project:
}

protected afterInit(): void {
// Register graph events with the onGraphEvent wrapper method
this.onGraphEvent("camera-change", this.handleCameraChange);

// Register DOM events with the appropriate wrapper methods
// Optional: cancel updates before commit
this.onGraphEvent("camera-change", (event) => {
if (shouldReject(event.detail)) {
event.preventDefault();
}
});

if (this.canvas) {
this.onCanvasEvent("mousedown", this.handleMouseDown);
}

if (this.html) {
this.onHtmlEvent("click", this.handleHtmlClick);
}

if (this.root) {
this.onRootEvent("keydown", this.handleRootKeyDown);
}

// Always call super.afterInit() at the end
super.afterInit();

super.afterInit(); // subscribes to $camera and calls onCameraChange()
}

protected onCameraChange(camera: TCameraState): void {
this.handleCameraChange(camera);
}
}
```
3 changes: 2 additions & 1 deletion .cursor/rules/layer-rules.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,8 @@ this.html.style.transform = `matrix(${camera.scale}, 0, 0, ${camera.scale}, ${ca
- Attach event listeners (e.g., `mousemove`, `mouseleave`) to the layer's root element (`this.root`) or specific layer elements (`this.getCanvas()`, `this.getHTML()`) within `afterInit`.
- Always clean up listeners and subscriptions in the `unmount` method.
- **Camera Interaction & Coordinates:**
- Subscribe to graph's `'camera-change'` event (`this.props.graph.on(...)`) to get updates. The event detail (`event.detail`) provides the `TCameraState` (containing `width`, `height`, `scale`, `x`, `y`).
- Override **`onCameraChange(camera)`** in derived layers to react to committed camera state (pan, zoom, resize). The base `Layer` subscribes to `graph.$camera` in `afterInit()` and calls this hook after built-in HTML/canvas transforms.
- Use **`camera-change` event** via `onGraphEvent` only when you need to intercept or cancel an update before commit (`event.preventDefault()`).
- `cameraState.x` and `cameraState.y` represent the *screen coordinates* of the world origin (0,0). Use these (`worldOriginScreenX`, `worldOriginScreenY`) for coordinate calculations.
- To convert **screen coordinates to world coordinates** (e.g., mouse position), use `this.context.camera.applyToPoint(screenX, screenY)`.
- To convert **world coordinates to screen coordinates** (e.g., placing ticks), use the formula: `screenX = worldX * scale + worldOriginScreenX` and `screenY = worldY * scale + worldOriginScreenY`.
Expand Down
82 changes: 39 additions & 43 deletions docs/react/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ This document describes all available React hooks for working with @gravity-ui/g
- [useSignal](#usesignal) - Subscribes to signal values for reactive updates
- [useComputedSignal](#usecomputedsignal) - Creates computed signals from other signals
- [useSignalEffect](#usesignaleffect) - Runs side effects when signal values change
- [useSignalLayoutEffect](#usesignallayouteffect) - Runs layout effects when signal values change

### Scheduler Hooks
- [useSchedulerDebounce](#useschedulerdebounce) - Creates frame-synchronized debounced function
Expand Down Expand Up @@ -199,18 +200,15 @@ function MyComponent({ graph }: Props): JSX.Element | null {
}
);

// With debounce options
// Intercept proposed camera state before commit
useGraphEvent(
graph,
"camera-change",
(detail: UnwrapGraphEventsDetail<"camera-change">) => {
console.log("Camera:", detail.camera);
graph,
"camera-change",
(detail: UnwrapGraphEventsDetail<"camera-change">, event: UnwrapGraphEvents<"camera-change">) => {
if (detail.scale < 0.1) {
event.preventDefault();
}
},
{
priority: ESchedulerPriority.MEDIUM,
frameInterval: 2, // Wait 2 frames
frameTimeout: 100, // Wait at least 100ms
}
);

// Prevent default behavior
Expand Down Expand Up @@ -528,6 +526,23 @@ function BlockLogger({ blockState }: Props): null {
| `effectFn` | `() => void` | Effect function that reads signals |
| `deps` | `DependencyList` | Dependencies array (like useEffect) |

### useSignalLayoutEffect

Like `useSignalEffect`, but uses `useLayoutEffect` internally. Use when the side effect must run synchronously after DOM updates and before the browser paints — for example, layout-dependent overlays tied to the camera.

```typescript
import { useSignalLayoutEffect } from "@gravity-ui/graph/react";

useSignalLayoutEffect(() => {
const camera = graph.$camera.value;
updateOverlay(camera);
}, [graph]);
```

#### Parameters

Same as `useSignalEffect`.

## Scheduler Hooks

These hooks integrate with the graph's internal scheduler for frame-based timing control. They are designed primarily for **synchronizing UI updates with graph component changes** on a per-frame basis.
Expand Down Expand Up @@ -668,7 +683,7 @@ Hook to create a throttled function that limits execution frequency.
Unlike debounce, throttle executes immediately on the first call and then enforces the delay for subsequent calls.

```typescript
import { useSchedulerThrottle, useGraphEvent } from "@gravity-ui/graph/react";
import { useSchedulerThrottle, useSignalLayoutEffect } from "@gravity-ui/graph/react";
import type { ESchedulerPriority, Graph, TCameraState } from "@gravity-ui/graph";

interface ThrottledFn<T extends (...args: unknown[]) => void> {
Expand All @@ -680,23 +695,20 @@ interface ThrottledFn<T extends (...args: unknown[]) => void> {
function MinimapOverlay({ graph }: { graph: Graph }): JSX.Element {
const [viewport, setViewport] = useState<TCameraState | null>(null);

// Throttle camera updates - executes immediately on first call,
// then waits for frame interval before next execution
const throttledCameraUpdate: ThrottledFn<(camera: TCameraState) => void> = useSchedulerThrottle(
(camera: TCameraState): void => {
setViewport(camera);
},
{
priority: ESchedulerPriority.LOW,
frameInterval: 2, // Update at most every 2 frames
frameTimeout: 32, // At most once per ~32ms
frameInterval: 2,
frameTimeout: 32,
}
);

// Camera events fire very frequently during pan/zoom
useGraphEvent(graph, "camera-change", ({ camera }) => {
throttledCameraUpdate(camera);
});
useSignalLayoutEffect(() => {
throttledCameraUpdate(graph.$camera.value);
}, [graph, throttledCameraUpdate]);

return (
<div className="minimap">
Expand Down Expand Up @@ -910,9 +922,10 @@ This means:

The hook subscribes to:

1. **`camera-change` event** - Fires when camera position, scale, or rotation changes
2. **HitTest `update` event** - Fires when blocks enter or leave the viewport
3. **Initial mount** - Calls the function immediately on component mount
1. **`graph.$camera` signal** (via `useSignalLayoutEffect`) — committed camera state after a non-prevented change
2. **HitTest `update` event** — fires when blocks enter or leave the viewport

Both paths call the debounced callback on mount and on subsequent updates.

#### Use Cases

Expand Down Expand Up @@ -978,32 +991,15 @@ function MyGraph(): JSX.Element {
### Performance Optimization with Debounced Events

```typescript
import { useGraphEvent } from "@gravity-ui/graph/react";
import { ESchedulerPriority } from "@gravity-ui/graph";
import type { Graph, TCameraState } from "@gravity-ui/graph";
import { useSignal } from "@gravity-ui/graph/react";
import type { Graph } from "@gravity-ui/graph";

interface Props {
graph: Graph;
}

function CameraInfo({ graph }: Props): JSX.Element | null {
const [cameraState, setCameraState] = useState<TCameraState | null>(null);

// Debounce camera updates for performance
useGraphEvent(
graph,
"camera-change",
({ camera }: { camera: TCameraState }): void => {
setCameraState(camera);
},
{
priority: ESchedulerPriority.LOW,
frameInterval: 2,
frameTimeout: 50,
}
);

if (!cameraState) return null;
function CameraInfo({ graph }: Props): JSX.Element {
const cameraState = useSignal(graph.$camera);

return (
<div>
Expand Down
1 change: 1 addition & 0 deletions docs/react/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ The library provides a comprehensive set of React hooks for working with the gra
| `useSignal` | Subscribe to signal values |
| `useComputedSignal` | Create computed signals |
| `useSignalEffect` | Run effects on signal changes |
| `useSignalLayoutEffect` | Run layout effects on signal changes (before paint) |
| `useSchedulerDebounce` | Create debounced function with frame timing |
| `useSchedulerThrottle` | Create throttled function with frame timing |
| `useScheduledTask` | Schedule task for frame-based execution |
Expand Down
30 changes: 17 additions & 13 deletions docs/rendering/layers.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,12 +204,14 @@ export class MyLayer extends Layer<MyLayerProps, MyLayerContext> {

// `afterInit` is called after the layer's elements (canvas/html) are created and attached.
protected afterInit() {
// Subscribe to events here
this.onGraphEvent("camera-change", this.performRender);
// Add other event listeners (e.g., to this.getHTML() or this.getCanvas())
// Base Layer applies transforms and calls onCameraChange() on $camera updates
super.afterInit();
}

protected onCameraChange(_camera: TCameraState): void {
this.performRender();
}

// --- Rendering ---
// This method is called automatically when performRender is invoked (e.g., on camera change).
protected render() {
Expand Down Expand Up @@ -337,10 +339,12 @@ The Layer class provides convenient wrapper methods for subscribing to events us
**Example:**
```typescript
protected afterInit() {
this.onGraphEvent("camera-change", this.handleCameraChange);
this.onCanvasEvent("mousedown", this.handleMouseDown);
super.afterInit();
}

protected onCameraChange(camera: TCameraState): void {
this.updateOverlay(camera.scale);
}
```

When the layer is unmounted, all handlers are automatically removed.
Expand All @@ -365,10 +369,9 @@ export class MyCustomLayer extends Layer<MyLayerProps> {
signal: this.eventAbortController.signal
});

// Option 2: Manual subscription to graph events with AbortController
this.props.graph.on("camera-change", this.performRender, {
signal: this.eventAbortController.signal
});
// Option 2: Direct $camera subscription — only when onCameraChange() is not enough
// (e.g. multiple handlers with different priorities before super.afterInit())
this.onSignal(this.props.graph.$camera, this.performRender);

// Option 3: Manual subscription without AbortController
// In this case, you must handle unsubscription manually
Expand Down Expand Up @@ -440,12 +443,13 @@ export class GridLayer extends Layer<GridLayerProps> {
* after the layer is unmounted and reattached.
*/
protected afterInit(): void {
// Use the onGraphEvent wrapper method for automatic cleanup
this.onGraphEvent("camera-change", this.performRender);

// Call parent afterInit to ensure proper initialization
// Committed camera state — override onCameraChange(), base Layer handles subscription
super.afterInit();
}

protected onCameraChange(_camera: TCameraState): void {
this.performRender();
}

protected render() {
const { ctx, camera } = this.context;
Expand Down
49 changes: 46 additions & 3 deletions docs/system/camera.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export type TCameraState = {

## Camera control

- `set(newState)` – update camera state (emits `camera-change`).
- `set(newState)` – update camera state (emits `camera-change`, then commits to `$camera` if not prevented).
- `resize({ width, height })` – update canvas size preserving center.
- `move(dx, dy)` – pan.
- `zoom(x, y, scale)` – zoom anchored to a screen-space point `(x, y)`.
Expand Down Expand Up @@ -198,9 +198,52 @@ graph.cameraService.disableAutoPanning();
- Canvas rendering and hit-testing use the full camera-space viewport (`relative*`) for correctness and performance.
- HTML/React view switches at defined zoom tiers (`getCameraBlockScaleLevel`), which depend on `scale` independent of insets.

## Events
## Events and reactive state

- `camera-change` – emitted on any camera state update (`set`, `move`, `resize`, `zoom`, etc.).
Camera updates use the same **preventable default action** pattern as `block-change`:

1. **`camera-change` event** — emitted synchronously **before** commit. `event.detail` is the **proposed** next `TCameraState`. Listeners may call `event.preventDefault()` to cancel the update.
2. **`graph.$camera` signal** — holds the **committed** camera state. Updated only when the default action was not prevented.

### Choosing an API

| API | State | Use for |
|-----|-------|---------|
| `camera-change` event | **Proposed** (`event.detail`) | Intercept, validate, `preventDefault()`, log intent before commit |
| `graph.$camera` | **Committed** | Reactive subscriptions, React hooks, rendering, derived UI |
| `graph.cameraService` | **Committed** (via `getCameraState()`) | Imperative control: `move`, `zoom`, `set`, `resize`, `setViewportInsets` |

Outside event handlers, `$camera.value` and `cameraService.getCameraState()` refer to the same committed state. During a `camera-change` handler, `event.detail` is proposed; `$camera` and `cameraService` still hold the previous committed state until the default action runs.

### Why both event and signal?

Event listeners run in registration order **before** commit. If an early handler calls `preventDefault()`, later handlers still see the proposed state in `event.detail` and may incorrectly update their own state unless they re-read committed values.

**Prefer `$camera` for rendering and side effects** (layers, React hooks, auto-panning sync). Subscribe via `Layer.onSignal(graph.$camera, …)` or `graph.$camera.subscribe()`.

**Keep `camera-change` for interception** — validation, logging, or cancelling a change before it is applied (same as `block-change`).

### Layer example

```typescript
protected onCameraChange(camera: TCameraState): void {
// Runs after built-in HTML/canvas transforms on each committed camera update
this.updateRuler(camera);
}
```

### React example

```typescript
import { useSignalLayoutEffect } from "@gravity-ui/graph/react";

useSignalLayoutEffect(() => {
const camera = graph.$camera.value;
// react to committed camera state
}, [graph]);
```

For imperative/event-based API (e.g. logging proposed state before commit), use `useGraphEvent(graph, "camera-change", …)`.

## Viewport insets (optional focus)

Expand Down
41 changes: 30 additions & 11 deletions docs/system/events.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,9 +97,28 @@ interface GraphMouseEvent<E extends Event = Event> = CustomEvent<{

### Camera Events

| Event | Description |
|-------|-------------|
| `camera-change` | Fires on camera state changes |
| Source | When it fires | Payload | Use for |
|--------|---------------|---------|---------|
| `camera-change` event | Before commit | Proposed `TCameraState` in `event.detail` | Interception, `preventDefault()`, logging intent |
| `graph.$camera` signal | After successful commit | Committed `TCameraState` | Rendering, transforms, derived state, React subscriptions |

```typescript
// Intercept before commit (same pattern as block-change)
graph.on("camera-change", (event) => {
if (shouldCancel(event.detail)) {
event.preventDefault();
}
});

// React to applied state — safe even if another listener cancelled an earlier change
graph.$camera.subscribe((camera) => {
applyCameraTransform(camera);
});
```

In `Layer` subclasses, override `onCameraChange()` for committed camera updates. The base `Layer` subscribes to `$camera` in `afterInit()`. Use `onGraphEvent("camera-change", …)` only to intercept or cancel a change before commit.

See [Camera Service — Choosing an API](./camera.md#choosing-an-api) for when to use `cameraService`, `$camera`, or `camera-change`.

## Event Cleanup with AbortController

Expand Down Expand Up @@ -147,19 +166,19 @@ export class MyLayer extends Layer {
* This is the proper place to set up event subscriptions using onGraphEvent().
*/
protected afterInit(): void {
// Use the onGraphEvent wrapper method that automatically includes the AbortController signal
this.onGraphEvent("camera-change", this.handleCameraChange);
this.onGraphEvent("blocks-selection-change", this.handleSelectionChange);
this.onGraphEvent("mousedown", this.handleMouseDown);

// DOM event listeners can also use the AbortController signal
this.getCanvas()?.addEventListener("mousedown", this.handleMouseDown, {
signal: this.eventAbortController.signal

this.getCanvas()?.addEventListener("mousedown", this.handleMouseDown, {
signal: this.eventAbortController.signal,
});

// Always call super.afterInit() at the end of your implementation

super.afterInit();
}

protected onCameraChange(_camera: TCameraState): void {
this.handleCameraChange();
}

// No need to manually remove event listeners in unmount
// They are automatically removed by the AbortController
Expand Down
Loading
Loading