Skip to content

Conversation

@draedful
Copy link
Collaborator

@draedful draedful commented Jun 26, 2025

Summary by Sourcery

Use a priority scheduler to replace debounce-based throttling across hit testing, graph zooming, and rendering pipelines. Expose the HitTest usable area as a reactive signal, implement chunked Path2D batching, and optimize React components and event handling to reduce redundant updates and improve overall performance.

New Features:

  • Add ESchedulerPriority enum and scheduler utility to enable prioritized, frame-based task scheduling
  • Expose HitTest.usableRect as a Preact signal and batch hit‐test add/remove/update through the global scheduler
  • Introduce Graph.scheduleTask to defer zoom operations via the scheduler
  • Implement chunked Path2D rendering in BatchPath2DRenderer with Path2DChunk grouping and markDirty support
  • Add GraphLayer.captureEvents and releaseCapture methods to lock event targets during drag

Enhancements:

  • Replace lodash.debounce in HitTest with scheduler‐driven batching and precise bounding‐box recalculation
  • Optimize Background component to use React state and subscribe directly to hitTest.usableRect
  • Refactor GraphBlock to apply CSS transforms and size changes only on actual state changes
  • Memoize BlocksList and wrap Block in React.memo to minimize re-renders with stable ID comparisons
  • Standardize pixel‐ratio handling via devicePixelRatio in Layer and utility functions
  • Remove CameraService’s unstable flag and debounce behavior for smoother camera updates
  • Improve trackpad detection by normalizing wheel deltas with devicePixelRatio
  • Remove legacy BlocksList usableRect logic and dead code

@sourcery-ai
Copy link

sourcery-ai bot commented Jun 26, 2025

Reviewer's Guide

This PR overhauls the hit testing workflow by integrating a centralized scheduler and reactive Preact signals, refactors components and APIs to leverage the new reactive usableRect, optimizes path rendering via chunking and caching, and streamlines event handling across graph and block layers.

Sequence diagram for event capture and release during drag operations

sequenceDiagram
    actor User
    participant GraphComponent
    participant GraphLayer
    participant EventedComponent

    User->>GraphComponent: drag start
    GraphComponent->>GraphLayer: captureEvents(this)
    Note right of GraphLayer: Sets capturedTargetComponent
    User->>GraphComponent: drag end
    GraphComponent->>GraphLayer: releaseCapture()
    Note right of GraphLayer: Unsets capturedTargetComponent
Loading

Class diagram for the refactored HitTest service and related classes

classDiagram
    class HitTest {
        +signal usableRect
        +load(items: HitBox[])
        +update(item: HitBox, bbox: HitBoxData)
        +add(item: HitBox)
        +remove(item: HitBox)
        +clear()
        +destroy()
        +testPoint(point: IPoint, pixelRatio: number)
        +testHitBox(item: HitBoxData)
    }
    class HitBox {
        +updateRect(rect: HitBoxData)
        +update(minX, minY, maxX, maxY, force?)
        +getRect()
    }
    HitTest "1" -- "*" HitBox : manages

    class ESchedulerPriority {
        HIGHEST
        HIGH
        MEDIUM
        LOW
        LOWEST
    }
    class GlobalScheduler {
        +addScheduler(scheduler, index)
        +removeScheduler(scheduler, index)
    }
    class schedule {
        <<function>>
    }
    schedule ..> ESchedulerPriority : uses
    HitTest ..> schedule : uses
    GlobalScheduler ..> ESchedulerPriority : uses
    schedule ..> GlobalScheduler : uses

    class signal {
        <<Preact signal>>
    }
    HitTest --> signal : exposes usableRect
Loading

Class diagram for optimized BatchPath2DRenderer and path chunking

classDiagram
    class BatchPath2DRenderer {
        +add(item: Path2DRenderInstance, ...)
        +delete(item: Path2DRenderInstance)
        +markDirty(item: Path2DRenderInstance)
        +reset()
        +render(ctx)
    }
    class Path2DGroup {
        +add(item: Path2DRenderInstance)
        +delete(item: Path2DRenderInstance)
        +resetItem(item: Path2DRenderInstance)
        +render(ctx)
    }
    class Path2DChunk {
        +add(item: Path2DRenderInstance)
        +delete(item: Path2DRenderInstance)
        +reset()
        +render(ctx)
        +size
    }
    BatchPath2DRenderer --> Path2DGroup : manages
    Path2DGroup --> Path2DChunk : contains
    Path2DChunk --> Path2DRenderInstance : contains
Loading

Class diagram for Background and MiniMapLayer usableRect refactor

classDiagram
    class Background {
        -extendedUsableRect: IRect
        +state: TBackgroundState
        +subscribe()
        +setupExtendedUsableRect(usableRect: TRect)
        +updateChildren()
    }
    class MiniMapLayer {
        +didRender()
        -unSubscribeUsableRectLoaded
    }
    Background ..> signal : subscribes to usableRect
    MiniMapLayer ..> signal : subscribes to usableRect
Loading

Class diagram for Graph, GraphLayer, and event capture changes

classDiagram
    class Graph {
        +scheduleTask(cb: () => void)
        +getGraphLayer()
        +zoomTo(target, config?)
    }
    class GraphLayer {
        +captureEvents(component: EventedComponent)
        +releaseCapture()
        +handleEvent(e: Event)
        -capturedTargetComponent
    }
    Graph --> GraphLayer : manages
    GraphLayer --> EventedComponent : captures events for
Loading

File-Level Changes

Change Details Files
Refactor HitTest service to batch updates via scheduler and Preact signals
  • Expose usableRect as a Preact signal
  • Replace debounce flows with scheduled batch load/remove/update
  • Maintain maps/sets for scheduledItems and scheduledRemoveItems
  • Compute bounding box and update usableRect reactively
  • Emit update events within scheduled task
src/services/HitTest.ts
Introduce priority scheduler and schedule utility
  • Add ESchedulerPriority enum
  • Enhance GlobalScheduler.addScheduler to return removal function
  • Implement schedule util wrapping scheduler with frameInterval/once options
  • Replace lodash.debounce scheduling with schedule utility
src/lib/Scheduler.ts
src/utils/utils/schedule.ts
Add Graph.scheduleTask API for deferred callbacks
  • Expose Graph.scheduleTask that schedules one-off tasks via scheduler
  • Wrap zoomTo logic inside scheduleTask to defer execution
src/graph.ts
Optimize Path2D rendering with chunking and caching
  • Split Path2DGroup into Path2DChunk and chunked grouping with CHUNK_SIZE
  • Cache visible items and reset on add/delete
  • Merge and apply styles only from visible items
  • Expose markDirty to reset specific item paths
src/components/canvas/connections/BatchPath2D/index.tsx
Refactor Background and MiniMapLayer to use reactive usableRect
  • Subscribe to graph.hitTest.usableRect instead of legacy store
  • Use component state for extendedUsableRect and trigger re-renders on state change
  • Pass updated bounds to PointerGrid
  • Update MiniMapLayer to use new usableRect signal
src/components/canvas/layers/belowLayer/Background.ts
src/plugins/minimap/layer.ts
Optimize React Block and BlocksList components
  • Wrap Block and BlocksList in React.memo
  • UseRef to track last layout state and only apply transform/size/zIndex when changed
  • Switch to inline style updates instead of CSS vars
  • Refactor BlocksList update to use functional setState and stable update callback
src/react-components/Block.tsx
src/react-components/BlocksList.tsx
src/react-components/Block.css
Remove legacy usableRect logic from BlockListStore and unify API
  • Strip out signal, throttle, and recalcUsableRect from BlockListStore
  • Simplify constructor to accept graph only
  • Update PublicGraphApi.getUsableRect to read from hitTest.signal
src/store/block/BlocksList.ts
src/api/PublicGraphApi.ts
Enable pointer event capture/release in GraphLayer
  • Add captureEvents and releaseCapture methods
  • Honor capturedTargetComponent in event dispatch to lock pointer
  • Invoke capture/release in GraphComponent and BlockController on drag
src/components/canvas/layers/graphLayer/GraphLayer.ts
src/components/canvas/GraphComponent/index.tsx
src/components/canvas/blocks/controllers/BlockController.ts
Miscellaneous service and util enhancements
  • Remove camera unstable debounce logic from CameraService
  • Normalize isTrackpadDetector deltas by devicePixelRatio
  • Listen/remove resize on LayersService
  • Switch DPI references in Layer to devicePixelRatio
  • Skip render in Block when hidden
src/services/camera/CameraService.ts
src/utils/functions/index.ts
src/services/LayersService.ts
src/services/Layer.ts
src/components/canvas/blocks/Block.ts

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@gravity-ui-bot
Copy link
Contributor

Preview is ready.

fix(ReactBlock): fix performance issue
fix(Layer): use current devicePixelRation in Layer
fix(Camera): fix tracking trackpad on pan event with browser zoom
feat: add isPathVisible method to BlockConnection and ConnectionArrow, improve path visibility handling in BatchPath2D
@draedful draedful marked this pull request as ready for review June 30, 2025 09:34
Copy link

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @draedful - I've reviewed your changes - here's some feedback:

  • In Background.setupExtendedUsableRect, you’re calling setState up to four times per update—batch those rectangle changes into a single setState call to avoid extra renders.
  • The HitTest scheduler always defers updates by your frameInterval; consider exposing a flush or synchronous update method so initial usableRect values aren’t delayed.
  • The hard-coded CHUNK_SIZE in your Path2DGroup could be made configurable (or extracted to a constant) so you can tune performance without touching the class internals.
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- In Background.setupExtendedUsableRect, you’re calling setState up to four times per update—batch those rectangle changes into a single setState call to avoid extra renders.
- The HitTest scheduler always defers updates by your frameInterval; consider exposing a flush or synchronous update method so initial usableRect values aren’t delayed.
- The hard-coded CHUNK_SIZE in your Path2DGroup could be made configurable (or extracted to a constant) so you can tune performance without touching the class internals.

## Individual Comments

### Comment 1
<location> `src/services/HitTest.ts:115` </location>
<code_context>
-    if (force) {
-      this.scheduleLoad.flush();
-    }
+  public add(item: HitBox) {
+    this.scheduledItems.set(item, item);
   }
</code_context>

<issue_to_address>
Adding item to scheduledItems with itself as bbox may cause issues.

scheduledItems should use a HitBox as the key and a HitBoxData as the value. Passing item as both may cause type or logic errors. Please ensure the correct value type is used.
</issue_to_address>

### Comment 2
<location> `src/services/LayersService.ts:17` </location>
<code_context>

   constructor(public $root?: HTMLDivElement) {
     super();
+    window.addEventListener("resize", this.handleRootResize);
   }

</code_context>

<issue_to_address>
Potential for multiple event listeners if constructor is called multiple times.

Multiple Layers instances will each add a resize event listener to the window. Consider preventing duplicate listeners or clarifying usage expectations.

Suggested implementation:

```typescript
  private _resizeListenerAdded = false;

  constructor(public $root?: HTMLDivElement) {
    super();
    this.handleRootResize = this.handleRootResize.bind(this);
    if (!this._resizeListenerAdded) {
      window.addEventListener("resize", this.handleRootResize);
      this._resizeListenerAdded = true;
    }
  }

```

```typescript
  public unmount() {
    this.detach(true);
    this.destroy();
    if (this._resizeListenerAdded) {
      window.removeEventListener("resize", this.handleRootResize);
      this._resizeListenerAdded = false;
    }

```
</issue_to_address>

### Comment 3
<location> `src/services/HitTest.ts:44` </location>
<code_context>

+  public usableRect = signal<TRect>({ x: 0, y: 0, width: 0, height: 0 });
+
+  protected scheduledItems = new Map<HitBox, HitBoxData>();
+  protected scheduledRemoveItems = new Set<HitBox>();
+
</code_context>

<issue_to_address>
Consider replacing multiple state-tracking structures and custom scheduling logic with a single queue and one debounced job to handle all add, update, and remove operations.

Here’s one way to collapse all of those flags/sets/maps and two flows (add/update vs remove vs emit) into a single queue + one scheduled job. You keep exactly the same semantics—items “null”‐marked get removed, non‐null get updated—then you do your tree updates, recompute usableRect if needed, emit once, and clear the queue:

```ts
// 1) Replace both maps/sets + needEmit flag
protected queue = new Map<HitBox, HitBoxData|null>();

// 2) One scheduled job (debounce or your scheduler)
protected processQueue = debounce(() => {
  let needUpdRect = false;

  for (const [item, bbox] of this.queue) {
    if (bbox === null) {
      this.tree.remove(item);
    } else {
      this.tree.remove(item);
      item.updateRect(bbox);
      this.tree.load([item]);
      needUpdRect = true;
    }
  }
  this.queue.clear();

  if (needUpdRect) this.updateUsableRect();
  this.emit("update", this);
}, 50);

// 3) This replaces add/update/remove/emitUpdate
public add(item: HitBox) {
  this.queue.set(item, item);
  this.processQueue();
}
public update(item: HitBox, bbox: HitBoxData) {
  this.queue.set(item, bbox);
  this.processQueue();
}
public remove(item: HitBox) {
  this.queue.set(item, null);
  this.processQueue();
}
```

That single `debounce` removes all the manual frame counting, `needEmitUpdate`, `scheduledRemoveItems`, `removeSchedulersFns` plumbing, and still:

- coalesces rapid calls  
- batches adds/updates/removes  
- updates `usableRect` when any bbox changed  
- emits `"update"` exactly once per batch  

This should collapse ~100 lines of custom scheduler into <30, dramatically improving readability while preserving functionality.
</issue_to_address>

### Comment 4
<location> `src/components/canvas/connections/BatchPath2D/index.tsx:22` </location>
<code_context>
+class Path2DChunk {
   protected items: Set<Path2DRenderInstance> = new Set();

+  protected visibleItems = cache(() => {
+    return Array.from(this.items).filter((item) => item.isPathVisible?.() ?? true);
+  });
</code_context>

<issue_to_address>
Consider centralizing visibility filtering into a single cached list to eliminate repeated inline visibility checks in rendering and styling methods.

Here’s a small-focused refactor of `Path2DChunk` that removes the duplicate visibility checks and centralizes all filtering into one `visibleItems` cache.  You can apply the same pattern to your `render`/`applyStyles` and drop the inlined `.isPathVisible` checks in the `reduce`:

```ts
class Path2DChunk {
  protected items = new Set<Path2DRenderInstance>();

  // 1) One cache → only-visible items
  protected visibleItems = cache(() =>
    Array.from(this.items).filter(i => i.isPathVisible?.() ?? true)
  );

  // 2) One cache → combined Path2D of visible items
  protected path = cache(() => {
    const p = new Path2D();
    p.moveTo(0, 0);
    for (const item of this.visibleItems.get()) {
      const sub = item.getPath();
      if (sub) p.addPath(sub);
    }
    return p;
  });

  // 3) Style comes from first visible
  protected applyStyles(ctx: CanvasRenderingContext2D) {
    const first = this.visibleItems.get()[0];
    return first?.style(ctx);
  }

  public add(item: Path2DRenderInstance) {
    this.items.add(item);
    this.reset();
  }

  public delete(item: Path2DRenderInstance) {
    this.items.delete(item);
    this.reset();
  }

  public reset() {
    this.visibleItems.reset();
    this.path.reset();
  }

  public render(ctx: CanvasRenderingContext2D) {
    const vis = this.visibleItems.get();
    if (!vis.length) return;

    ctx.save();
    const style = this.applyStyles(ctx);
    if (style) {
      const p = this.path.get();
      if (style.type === "fill" || style.type === "both") {
        ctx.fill(p, style.fillRule);
      }
      if (style.type === "stroke" || style.type === "both") {
        ctx.stroke(p);
      }
    }
    ctx.restore();

    for (const item of vis) {
      item.afterRender?.(ctx);
    }
  }
}
```

Benefits:
- removes all inline `(item.isPathVisible?..)` checks
- uses a single `visibleItems` cache for both styling and path-building
- avoids the extra `reduce` + manual filter in two places
- keeps exactly the same behavior and cache‐reset logic.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

@Antamansid
Copy link
Collaborator

Tested on the project: it's just a class. There are a couple of small ts errors during build, but these are the little things.

…ve usability; add PATH2D_CHUNK_SIZE constant for batch rendering; update Background component to use debounced state updates
@draedful draedful force-pushed the usable_rect_perf branch from fd68e87 to a8ea93e Compare July 4, 2025 13:15
@draedful draedful changed the title feat: Improved performance by optimizing the HitTest service and its interaction with the scheduler. feat: Improved performance by optimizing the HitTest service and its interaction with the scheduler Jul 4, 2025
… viewport threshold and improve hit test stability checks
@draedful draedful force-pushed the usable_rect_perf branch from 7b5aa53 to 6dfe38f Compare July 4, 2025 16:39
@draedful draedful force-pushed the usable_rect_perf branch from 6dfe38f to caa65c5 Compare July 4, 2025 16:42
@draedful draedful merged commit 51f0279 into main Jul 6, 2025
4 checks passed
This was referenced Jul 6, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants