Files
OpenNoodl/dev-docs/reference/CANVAS-OVERLAY-ARCHITECTURE.md
2026-01-04 00:17:33 +01:00

8.1 KiB

Canvas Overlay Architecture

Overview

This document explains how canvas overlays integrate with the NodeGraphEditor and the editor's data flow.

Integration Points

1. NodeGraphEditor Initialization

The overlay is created when the NodeGraphEditor is constructed:

// In nodegrapheditor.ts constructor
export default class NodeGraphEditor {
  commentLayer: CommentLayer;

  constructor(domElement, options) {
    // ... canvas setup

    // Create overlay
    this.commentLayer = new CommentLayer(this);
    this.commentLayer.setReadOnly(this.readOnly);
  }
}

2. DOM Structure

The overlay requires two divs in the DOM hierarchy:

<div id="nodegraph-editor">
  <canvas id="nodegraph-canvas"></canvas>
  <div id="nodegraph-background-layer"></div>
  <!-- Behind canvas -->
  <div id="nodegraph-dom-layer"></div>
  <!-- In front of canvas -->
</div>

CSS z-index layering:

  • Background layer: z-index: 0
  • Canvas: z-index: 1
  • Foreground layer: z-index: 2

3. Render Target Setup

The overlay attaches to the DOM layers:

// In nodegrapheditor.ts
const backgroundDiv = this.el.find('#nodegraph-background-layer').get(0);
const foregroundDiv = this.el.find('#nodegraph-dom-layer').get(0);

this.commentLayer.renderTo(backgroundDiv, foregroundDiv);

4. Viewport Synchronization

The overlay updates whenever the canvas pan/zoom changes:

// In nodegrapheditor.ts paint() method
paint() {
  // ... canvas drawing

  // Update overlay transform
  this.commentLayer.setPanAndScale({
    x: this.xOffset,
    y: this.yOffset,
    scale: this.scale
  });
}

Data Flow

EventDispatcher Integration

Overlays typically subscribe to model changes using EventDispatcher:

class MyOverlay {
  setComponentModel(model: ComponentModel) {
    if (this.model) {
      this.model.off(this); // Clean up old subscriptions
    }

    this.model = model;

    // Subscribe to changes
    model.on('nodeAdded', this.onNodeAdded.bind(this), this);
    model.on('nodeRemoved', this.onNodeRemoved.bind(this), this);
    model.on('connectionChanged', this.onConnectionChanged.bind(this), this);

    this.render();
  }

  onNodeAdded(node) {
    // Update overlay state
    this.render();
  }
}

Typical Data Flow

User Action
    ↓
Model Change (ProjectModel/ComponentModel)
    ↓
EventDispatcher fires event
    ↓
Overlay handler receives event
    ↓
Overlay updates React state
    ↓
React re-renders overlay

Lifecycle Management

Creation

constructor(nodegraphEditor: NodeGraphEditor) {
  this.nodegraphEditor = nodegraphEditor;
  this.props = { /* initial state */ };
}

Attachment

renderTo(backgroundDiv: HTMLDivElement, foregroundDiv: HTMLDivElement) {
  this.backgroundDiv = backgroundDiv;
  this.foregroundDiv = foregroundDiv;

  // Create React roots
  this.backgroundRoot = createRoot(backgroundDiv);
  this.foregroundRoot = createRoot(foregroundDiv);

  // Initial render
  this._renderReact();
}

Updates

setPanAndScale(viewport: Viewport) {
  // Update CSS transform
  const transform = `scale(${viewport.scale}) translate(${viewport.x}px, ${viewport.y}px)`;
  this.backgroundDiv.style.transform = transform;
  this.foregroundDiv.style.transform = transform;

  // Notify React if scale changed (important for react-rnd)
  if (this.props.scale !== viewport.scale) {
    this.props.scale = viewport.scale;
    this._renderReact();
  }
}

Disposal

dispose() {
  // Unmount React
  if (this.backgroundRoot) {
    this.backgroundRoot.unmount();
  }
  if (this.foregroundRoot) {
    this.foregroundRoot.unmount();
  }

  // Unsubscribe from models
  if (this.model) {
    this.model.off(this);
  }

  // Clean up DOM event listeners
  // (CommentLayer uses a clever cloneNode trick to remove all listeners)
}

Component Model Integration

Accessing Graph Data

The overlay has access to the full component graph through NodeGraphEditor:

class MyOverlay {
  getNodesInView(): NodeGraphNode[] {
    const model = this.nodegraphEditor.nodeGraphModel;
    const nodes: NodeGraphNode[] = [];

    model.forEachNode((node) => {
      nodes.push(node);
    });

    return nodes;
  }

  getConnections(): Connection[] {
    const model = this.nodegraphEditor.nodeGraphModel;
    return model.getAllConnections();
  }
}

Node Position Access

Node positions are available through the graph model:

getNodeScreenPosition(nodeId: string): Point | null {
  const model = this.nodegraphEditor.nodeGraphModel;
  const node = model.findNodeWithId(nodeId);

  if (!node) return null;

  // Node positions are in canvas space
  return {
    x: node.x,
    y: node.y
  };
}

Communication with NodeGraphEditor

From Overlay to Canvas

The overlay can trigger canvas operations:

// Clear canvas selection
this.nodegraphEditor.clearSelection();

// Select nodes on canvas
this.nodegraphEditor.selectNode(node);

// Trigger repaint
this.nodegraphEditor.repaint();

// Navigate to node
this.nodegraphEditor.zoomToFitNodes([node]);

From Canvas to Overlay

The canvas notifies the overlay of changes:

// In nodegrapheditor.ts
selectNode(node) {
  // ... canvas logic

  // Notify overlay
  this.commentLayer.clearSelection();
}

Best Practices

Do

  1. Clean up subscriptions - Always unsubscribe from EventDispatcher on dispose
  2. Use the context object pattern - Pass this as context to EventDispatcher subscriptions
  3. Batch updates - Group multiple state changes before calling render
  4. Check for existence - Always check if DOM elements exist before using them

Don't

  1. Don't modify canvas directly - Work through NodeGraphEditor API
  2. Don't store duplicate data - Reference the model as the source of truth
  3. Don't subscribe without context - Direct EventDispatcher subscriptions leak
  4. Don't assume initialization order - Check for null before accessing properties

Example: Complete Overlay Setup

import React from 'react';
import { createRoot, Root } from 'react-dom/client';

import { ComponentModel } from '@noodl-models/componentmodel';

import { NodeGraphEditor } from './nodegrapheditor';

export default class DataLineageOverlay {
  private nodegraphEditor: NodeGraphEditor;
  private model: ComponentModel;
  private root: Root;
  private container: HTMLDivElement;
  private viewport: Viewport;

  constructor(nodegraphEditor: NodeGraphEditor) {
    this.nodegraphEditor = nodegraphEditor;
  }

  renderTo(container: HTMLDivElement) {
    this.container = container;
    this.root = createRoot(container);
    this.render();
  }

  setComponentModel(model: ComponentModel) {
    if (this.model) {
      this.model.off(this);
    }

    this.model = model;

    if (model) {
      model.on('connectionChanged', this.onDataChanged.bind(this), this);
      model.on('nodeRemoved', this.onDataChanged.bind(this), this);
    }

    this.render();
  }

  setPanAndScale(viewport: Viewport) {
    this.viewport = viewport;
    const transform = `scale(${viewport.scale}) translate(${viewport.x}px, ${viewport.y}px)`;
    this.container.style.transform = transform;
  }

  private onDataChanged() {
    this.render();
  }

  private render() {
    if (!this.root) return;

    const paths = this.calculateDataPaths();

    this.root.render(
      <DataLineageView paths={paths} viewport={this.viewport} onPathClick={this.handlePathClick.bind(this)} />
    );
  }

  private calculateDataPaths() {
    // Analyze graph connections to build data flow paths
    // ...
  }

  private handlePathClick(path: DataPath) {
    // Select nodes involved in this path
    const nodeIds = path.nodes.map((n) => n.id);
    this.nodegraphEditor.selectNodes(nodeIds);
  }

  dispose() {
    if (this.root) {
      this.root.unmount();
    }
    if (this.model) {
      this.model.off(this);
    }
  }
}