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
39 changes: 39 additions & 0 deletions packages/core/src/canvas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -331,3 +331,42 @@ export function removeEdge(canvas: CanvasData, edgeId: string): CanvasData {
const edges = (canvas.edges ?? []).filter((e) => e.id !== edgeId)
return { ...canvas, edges }
}
/**
* Returns only the nodes that intersect the current viewport (canvas-space),
* expanded by `margin` pixels on every side so nodes fade in before they
* reach the visible edge.
*
* Groups use a full-rect intersection so a large group whose top-left corner
* is off-screen is never culled while its interior (and children) remain
* visible.
*
* Returns the input array unchanged when it is empty or when
* `containerWidth`/`containerHeight` are zero (not yet measured).
*
* @param nodes Resolved nodes, including any drag/resize overrides.
* @param viewport d3-zoom transform { x, y, zoom }.
* @param containerWidth Screen width of the SVG container in px.
* @param containerHeight Screen height of the SVG container in px.
* @param margin Extra canvas-space buffer beyond the viewport edges (default 200 px).
*/
export function cullNodes(
nodes: ResolvedNode[],
viewport: { x: number; y: number; zoom: number },
containerWidth: number,
containerHeight: number,
margin = 200,
): ResolvedNode[] {
if (nodes.length === 0) return nodes
if (containerWidth <= 0 || containerHeight <= 0) return nodes

const { x: vx, y: vy, zoom } = viewport
// Convert screen bounds to canvas space, then expand by margin.
const left = (-vx / zoom) - margin
const top = (-vy / zoom) - margin
const right = ((containerWidth - vx) / zoom) + margin
const bottom = ((containerHeight - vy) / zoom) + margin

return nodes.filter(
(n) => n.x < right && n.x + n.width > left && n.y < bottom && n.y + n.height > top
)
}
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ export {
addEdge,
updateEdge,
removeEdge,
cullNodes,
} from './canvas.js'

export { snapToGrid } from './grid.js'
Expand Down
1 change: 1 addition & 0 deletions packages/react/src/components/SystemCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1730,6 +1730,7 @@ export const SystemCanvas = forwardRef<SystemCanvasHandle, SystemCanvasProps>(
alignmentGuides={editable ? alignmentGuides : undefined}
dimmedNodeIds={dimmedIds}
highlightedNodeIds={matchingIds}
viewportState={collaboratorViewport}
/>

{/* Collaborators overlay — cursors, selection halos, conflict flashes */}
Expand Down
52 changes: 48 additions & 4 deletions packages/react/src/components/Viewport.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import type {
NodeUpdate,
EdgeUpdate,
} from 'system-canvas'
import { computeEdgeMidpoint, screenToCanvas, buildParallelEdgeGroups } from 'system-canvas'
import { computeEdgeMidpoint, screenToCanvas, buildParallelEdgeGroups, cullNodes } from 'system-canvas'
import { useViewport } from '../hooks/useViewport.js'
import { NodeRenderer } from './NodeRenderer.js'
import { EdgeRenderer } from './EdgeRenderer.js'
Expand Down Expand Up @@ -156,6 +156,14 @@ interface ViewportProps {
dimmedNodeIds?: Set<string>
/** Node ids that should render with a highlight ring (search match). */
highlightedNodeIds?: Set<string>
/**
* Current viewport transform, kept in sync by the parent via React state.
* When provided, nodes outside the visible canvas area (plus a margin buffer)
* are omitted from the SVG — they are never added to the DOM. Hit-testing
* and interaction hooks always operate on the full node list; only the
* visual rendering layer is affected.
*/
viewportState?: ViewportState
}

export interface ViewportHandle {
Expand Down Expand Up @@ -228,6 +236,7 @@ export const Viewport = forwardRef<ViewportHandle, ViewportProps>(
alignmentGuides,
dimmedNodeIds,
highlightedNodeIds,
viewportState,
autoFit = 'canvas-change',
canvasRef,
handoffTransform,
Expand Down Expand Up @@ -320,6 +329,23 @@ export const Viewport = forwardRef<ViewportHandle, ViewportProps>(
getCursorScreenPos: () => cursorPosRef.current,
}))

// Track SVG container dimensions for viewport culling. Updated lazily
// via ResizeObserver; a stale value from the previous frame is acceptable
// because the next pan/zoom re-render will pick up the current size.
const containerSizeRef = useRef({ w: 0, h: 0 })
useEffect(() => {
const el = svgRef.current
if (!el) return
// Seed immediately so the first render has non-zero dimensions.
containerSizeRef.current = { w: el.clientWidth, h: el.clientHeight }
const ro = new ResizeObserver(([entry]) => {
const r = entry.contentRect
containerSizeRef.current = { w: r.width, h: r.height }
})
ro.observe(el)
return () => ro.disconnect()
}, [])

// Apply drag + resize overrides to nodes before rendering so edges route correctly.
const renderNodes = useMemo(() => {
const hasDrag = dragOverrides && dragOverrides.size > 0
Expand All @@ -346,6 +372,24 @@ export const Viewport = forwardRef<ViewportHandle, ViewportProps>(
return m
}, [renderNodes, nodeMap, dragOverrides, resizeOverrides])

// Viewport-culled node list for SVG rendering only. Nodes outside the
// visible canvas area are dropped from the DOM entirely, which reduces
// slot/accessor computation and SVG paint work on large canvases.
//
// Culling is skipped when:
// - No viewportState prop is provided (opt-in from parent).
// - Drags or resizes are in-flight — a node being moved may travel
// outside the initial cull bounds before the viewport catches up.
// - Container dimensions haven't been measured yet (first render).
const visibleNodes = useMemo(() => {
const hasDrag = dragOverrides && dragOverrides.size > 0
const hasResize = resizeOverrides && resizeOverrides.size > 0
if (!viewportState || hasDrag || hasResize) return renderNodes
const { w, h } = containerSizeRef.current
if (w <= 0 || h <= 0) return renderNodes
return cullNodes(renderNodes, viewportState, w, h)
}, [renderNodes, viewportState, dragOverrides, resizeOverrides])

// Keep a ref to the latest nodes so auto-fit effects can read them
// without taking `nodes` as a dependency (which would re-fit on every edit).
const latestNodesRef = useRef(nodes)
Expand Down Expand Up @@ -593,7 +637,7 @@ export const Viewport = forwardRef<ViewportHandle, ViewportProps>(

{/* Groups first (behind everything) */}
<NodeRenderer
nodes={renderNodes}
nodes={visibleNodes}
theme={theme}
onClick={onNodeClick}
onDoubleClick={onNodeDoubleClick}
Expand Down Expand Up @@ -632,7 +676,7 @@ export const Viewport = forwardRef<ViewportHandle, ViewportProps>(

{/* Non-group nodes on top (+ resize handles) */}
<NodeRenderer
nodes={renderNodes}
nodes={visibleNodes}
theme={theme}
onClick={onNodeClick}
onDoubleClick={onNodeDoubleClick}
Expand All @@ -655,7 +699,7 @@ export const Viewport = forwardRef<ViewportHandle, ViewportProps>(
"none" globally; consumers needing clickable reveals can
opt back in inside a `kind: 'custom'` renderer. */}
<RevealsLayer
nodes={renderNodes}
nodes={visibleNodes}
theme={theme}
canvases={canvases}
getViewport={getViewport}
Expand Down
Loading