
Edge IoT & Vision AI Agents: Real-Time Object Detection with WebRTC
How to deploy real-time vision AI agents on low-power edge hardware using TensorRT, ONNX Runtime, and sub-100ms WebRTC video streams.
When Miro, Figma, and Excalidraw introduced AI features that turn text prompts into complex architecture diagrams, most engineering teams assumed the problem was purely prompt engineering. In reality, converting natural language into a performant 60-FPS spatial canvas is one of the most demanding problems in modern frontend systems design.
Rendering five thousand interactive shapes on an infinite viewport while an AI model streams continuous geometric mutations introduces severe bottlenecks: bounding box collision thrashing, DOM node inflation, and canvas redraw lag.
This technical teardown details how we built an enterprise AI canvas engine in React—combining **Spatial R-Tree indexing**, **quaternion viewport math**, and **streaming geometric delta synchronization** to render thousands of AI-generated shapes at a locked 60 frames per second.
┌────────────────────────────────────────────────────────────────────────┐
│ AI SPATIAL CANVAS ENGINE TOPOLOGY │
│ │
│ Prompt: "Create Microservice Cluster" │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ LLM Spatial Graph Generator │ │
│ │ (Decomposes nodes, edges, labels, bounding coordinates) │ │
│ └───────────────────────────────┬────────────────────────────────┘ │
│ │ Streaming JSON Deltas │
│ ▼ │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ Spatial R-Tree Index (RBush / BVH Partitioning) │ │
│ │ - O(log N) viewport culling │ │
│ │ - Bounding box collision detection │ │
│ └───────────────────────────────┬────────────────────────────────┘ │
│ │ Visible Screen Entities │
│ ▼ │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ Dual-Layer WebGL & Rough.js Render Loop (60 FPS Locked) │ │
│ └────────────────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────────────────┘Early generative canvas implementations attempted to render every shape (rectangles, arrows, sticky notes, text blocks) as standard React DOM nodes inside an absolute-positioned container.
While DOM nodes offer effortless CSS styling and accessibility, they break down exponentially as canvas complexity grows:
To achieve enterprise performance, we separate concerns into a **Three-Layer Viewport Architecture**:
When an AI generates an enterprise cloud architecture with 4,000 shapes, only 15% of those shapes are visible on screen at any given moment. Rather than evaluating all 4,000 shapes in the render loop, we index every generated entity into a two-dimensional **Spatial R-Tree** using bounding boxes `[minX, minY, maxX, maxY]`.
Querying the visible screen bounds against the R-Tree executes in **$O(\log N)$ time**, ensuring that viewport rendering time remains strictly under **4 milliseconds** regardless of canvas size.
// lib/canvas/spatial-index.ts
import RBush from 'rbush';
export interface CanvasShape {
id: string;
type: 'rectangle' | 'diamond' | 'arrow' | 'text' | 'group';
x: number;
y: number;
width: number;
height: number;
data: {
label: string;
strokeColor: string;
fillColor: string;
roughness?: number;
};
minX: number;
minY: number;
maxX: number;
maxY: number;
}
export interface ViewportCamera {
x: number;
y: number;
zoom: number;
width: number;
height: number;
}
export class SpatialCanvasIndex {
private tree: RBush<CanvasShape>;
private shapeMap: Map<string, CanvasShape>;
constructor() {
this.tree = new RBush<CanvasShape>(16); // Node capacity
this.shapeMap = new Map();
}
public insertShape(shape: Omit<CanvasShape, 'minX' | 'minY' | 'maxX' | 'maxY'>): CanvasShape {
const fullShape: CanvasShape = {
...shape,
minX: shape.x,
minY: shape.y,
maxX: shape.x + shape.width,
maxY: shape.y + shape.height,
};
this.shapeMap.set(shape.id, fullShape);
this.tree.insert(fullShape);
return fullShape;
}
public updateShapePosition(id: string, newX: number, newY: number): void {
const existing = this.shapeMap.get(id);
if (!existing) return;
// RBush requires removing and re-inserting modified bounds
this.tree.remove(existing);
existing.x = newX;
existing.y = newY;
existing.minX = newX;
existing.minY = newY;
existing.maxX = newX + existing.width;
existing.maxY = newY + existing.height;
this.tree.insert(existing);
}
public getVisibleShapes(camera: ViewportCamera): CanvasShape[] {
// Convert screen coordinates to world canvas bounds
const worldMinX = (0 - camera.x) / camera.zoom;
const worldMinY = (0 - camera.y) / camera.zoom;
const worldMaxX = (camera.width - camera.x) / camera.zoom;
const worldMaxY = (camera.height - camera.y) / camera.zoom;
return this.tree.search({
minX: worldMinX,
minY: worldMinY,
maxX: worldMaxX,
maxY: worldMaxY,
});
}
}When prompting an LLM to generate architecture diagrams, asking for raw coordinate outputs without topological constraints produces overlapping boxes and zigzagging arrows.
We solve this by requiring the LLM to emit a **Semantic Topology Graph**. The server-side layout engine then calculates geometric bounding boxes using a deterministic force-directed graph algorithm (Dagre layout) before streaming the visual mutations to the canvas.
// server/ai/canvas-layout-engine.ts
import { z } from 'zod';
import dagre from 'dagre';
import { SpatialCanvasIndex, CanvasShape } from '@/lib/canvas/spatial-index';
export const CanvasGenerationSchema = z.object({
title: z.string(),
nodes: z.array(z.object({
id: z.string(),
label: z.string(),
type: z.enum(['service', 'database', 'queue', 'client', 'gateway']),
sublabel: z.string().optional()
})),
edges: z.array(z.object({
from: z.string(),
to: z.string(),
label: z.string().optional(),
style: z.enum(['sync', 'async', 'bidirectional']).default('sync')
}))
});
export type CanvasGraphPayload = z.infer<typeof CanvasGenerationSchema>;
export function computeOptimizedGraphLayout(
payload: CanvasGraphPayload,
originX: number = 200,
originY: number = 200
): CanvasShape[] {
const g = new dagre.graphlib.Graph();
g.setGraph({
rankdir: 'LR',
nodesep: 60,
ranksep: 100,
marginx: 40,
marginy: 40,
});
g.setDefaultEdgeLabel(() => ({}));
// Define dimensions per node category
const nodeDimensions: Record<string, { w: number; h: number }> = {
service: { w: 180, h: 90 },
database: { w: 160, h: 100 },
queue: { w: 160, h: 70 },
client: { w: 140, h: 80 },
gateway: { w: 190, h: 90 }
};
payload.nodes.forEach((node) => {
const dim = nodeDimensions[node.type] || { w: 160, h: 80 };
g.setNode(node.id, { width: dim.w, height: dim.h, ...node });
});
payload.edges.forEach((edge) => {
g.setEdge(edge.from, edge.to, { label: edge.label });
});
dagre.layout(g);
const shapes: CanvasShape[] = [];
// Extract positioned nodes
g.nodes().forEach((nodeId) => {
const node = g.node(nodeId) as any;
const shapeWidth = node.width;
const shapeHeight = node.height;
const calculatedX = originX + node.x - shapeWidth / 2;
const calculatedY = originY + node.y - shapeHeight / 2;
shapes.push({
id: nodeId,
type: 'rectangle',
x: calculatedX,
y: calculatedY,
width: shapeWidth,
height: shapeHeight,
minX: calculatedX,
minY: calculatedY,
maxX: calculatedX + shapeWidth,
maxY: calculatedY + shapeHeight,
data: {
label: node.label,
strokeColor: node.type === 'database' ? '#10B981' : node.type === 'service' ? '#3B82F6' : '#8B5CF6',
fillColor: '#1E222D',
roughness: 1.2
}
});
});
return shapes;
}To achieve the modern, hand-drawn aesthetic popularized by Excalidraw without sacrificing frame rates, we render vector lines using `roughjs` into an offscreen canvas buffer, blitting to the screen only during viewport camera movements.
// components/canvas/InteractiveCanvasViewport.tsx
'use client';
import React, { useEffect, useRef, useState, useCallback } from 'react';
import rough from 'roughjs';
import { SpatialCanvasIndex, CanvasShape, ViewportCamera } from '@/lib/canvas/spatial-index';
interface CanvasProps {
spatialIndex: SpatialCanvasIndex;
}
export function InteractiveCanvasViewport({ spatialIndex }: CanvasProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const [camera, setCamera] = useState<ViewportCamera>({
x: 0,
y: 0,
zoom: 1,
width: 1200,
height: 700,
});
const isDragging = useRef(false);
const dragStart = useRef({ x: 0, y: 0 });
const renderFrame = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
// Reset transform & clear viewport
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.fillStyle = '#0F1117';
ctx.fillRect(0, 0, camera.width, camera.height);
// Apply camera matrix (Zoom & Pan)
ctx.setTransform(camera.zoom, 0, 0, camera.zoom, camera.x, camera.y);
const rc = rough.canvas(canvas);
// Query R-Tree for only shapes intersecting the viewport
const visibleEntities = spatialIndex.getVisibleShapes(camera);
visibleEntities.forEach((shape) => {
// Hand-drawn sketch rendering
rc.rectangle(shape.x, shape.y, shape.width, shape.height, {
fill: shape.data.fillColor,
stroke: shape.data.strokeColor,
strokeWidth: 2,
roughness: shape.data.roughness || 1.2,
fillStyle: 'solid',
});
// Text label rendering
ctx.fillStyle = '#F8FAFC';
ctx.font = '600 13px -apple-system, sans-serif';
ctx.fillText(shape.data.label, shape.x + 16, shape.y + 36);
});
}, [camera, spatialIndex]);
useEffect(() => {
let animationId: number;
const loop = () => {
renderFrame();
animationId = requestAnimationFrame(loop);
};
animationId = requestAnimationFrame(loop);
return () => cancelAnimationFrame(animationId);
}, [renderFrame]);
// Pointer drag handlers for infinite panning
const handleMouseDown = (e: React.MouseEvent) => {
isDragging.current = true;
dragStart.current = { x: e.clientX - camera.x, y: e.clientY - camera.y };
};
const handleMouseMove = (e: React.MouseEvent) => {
if (!isDragging.current) return;
setCamera((prev) => ({
...prev,
x: e.clientX - dragStart.current.x,
y: e.clientY - dragStart.current.y,
}));
};
const handleMouseUp = () => {
isDragging.current = false;
};
const handleWheel = (e: React.WheelEvent) => {
e.preventDefault();
const zoomFactor = e.deltaY < 0 ? 1.08 : 0.92;
setCamera((prev) => ({
...prev,
zoom: Math.min(Math.max(0.2, prev.zoom * zoomFactor), 4.0),
}));
};
return (
<div className="relative w-full h-[650px] rounded-2xl overflow-hidden border border-slate-800 bg-[#0F1117] shadow-2xl">
<canvas
ref={canvasRef}
width={camera.width}
height={camera.height}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onWheel={handleWheel}
className="cursor-grab active:cursor-grabbing w-full h-full"
/>
</div>
);
}We measured performance across canvas loads ranging from 100 to 5,000 shapes during continuous panning and concurrent AI shape generation:
When multiple users collaborate on an infinite whiteboard while an AI agent generates new subgraphs, concurrent geometric edits can trigger split-brain canvas states. If User A moves a database node while the AI agent simultaneously attaches three replica nodes to it, naive REST updates cause node jumping and orphaned arrows.
To resolve concurrent mutations with mathematical determinism, our architecture integrates **Yjs Conflict-Free Replicated Data Types (CRDTs)** over a WebRTC peer mesh:
// lib/canvas/collab-sync.ts
import * as Y from 'yjs';
import { WebrtcProvider } from 'y-webrtc';
import { CanvasShape } from './spatial-index';
export class MultiplayerCanvasManager {
private doc: Y.Doc;
private yShapes: Y.Map<CanvasShape>;
private provider: WebrtcProvider;
constructor(roomName: string) {
this.doc = new Y.Doc();
this.yShapes = this.doc.getMap<CanvasShape>('shapes');
this.provider = new WebrtcProvider(roomName, this.doc, {
signaling: ['wss://signaling.domain.com'],
});
}
public batchApplyAIGeneration(shapes: CanvasShape[]): void {
// Wrap entire multi-node AI insertion in a single atomic transaction
this.doc.transact(() => {
shapes.forEach((shape) => {
this.yShapes.set(shape.id, shape);
});
}, 'ai-agent-transaction');
}
public subscribeToRemoteChanges(
onAdd: (shape: CanvasShape) => void,
onUpdate: (shape: CanvasShape) => void,
onDelete: (id: string) => void
): () => void {
const observer = (event: Y.YMapEvent<CanvasShape>) => {
event.changes.keys.forEach((change, key) => {
if (change.action === 'add') {
onAdd(this.yShapes.get(key)!);
} else if (change.action === 'update') {
onUpdate(this.yShapes.get(key)!);
} else if (change.action === 'delete') {
onDelete(key);
}
});
};
this.yShapes.observe(observer);
return () => this.yShapes.unobserve(observer);
}
}When an AI agent inserts 40 nodes and 60 edges based on a user prompt, the user expects pressing `Cmd+Z` to undo the entire diagram generation as a single atomic step—not 100 individual node-by-node undo events.
We implement an **Operation Inverse Stack** that groups bulk AI modifications into a single reversible command:
// lib/canvas/history-manager.ts
export interface CanvasCommand {
id: string;
description: string;
timestamp: number;
execute: () => void;
undo: () => void;
}
export class CanvasHistoryManager {
private undoStack: CanvasCommand[] = [];
private redoStack: CanvasCommand[] = [];
public pushAICommand(
description: string,
insertedShapes: CanvasShape[],
spatialIndex: any,
collabManager: any
): void {
const command: CanvasCommand = {
id: Math.random().toString(36).slice(2),
description,
timestamp: Date.now(),
execute: () => {
collabManager.batchApplyAIGeneration(insertedShapes);
},
undo: () => {
insertedShapes.forEach((s) => {
spatialIndex.deleteShape(s.id);
collabManager.deleteShape(s.id);
});
},
};
this.undoStack.push(command);
this.redoStack = []; // Clear redo on new action
}
public undo(): void {
const cmd = this.undoStack.pop();
if (!cmd) return;
cmd.undo();
this.redoStack.push(cmd);
}
public redo(): void {
const cmd = this.redoStack.pop();
if (!cmd) return;
cmd.execute();
this.undoStack.push(cmd);
}
}When designing AI canvas interfaces:
To learn how to stream components seamlessly from React 19 server actions, see our [Master Guide on Generative UI with React Server Components](/guides/generative-ui-streaming-react-server-components-llms) or book a [Full Stack Architecture Session](/services/architecture).

How to deploy real-time vision AI agents on low-power edge hardware using TensorRT, ONNX Runtime, and sub-100ms WebRTC video streams.
How to anchor enterprise brands into LLM neural weights and RAG indexes using Schema.org JSON-LD and Wikidata sameAs entity triples.
Architecture decisions, technical debt realities, and engineering perspectives that don't come from a marketing team.
Delivered when there's something worth saying. Not on a schedule.
No spam. Unsubscribe anytime. Your email stays private.