Skip to content

Commit 7db5295

Browse files
committed
perf: draw only visible boxes, and stop measuring style per frame
The overlay loop existed to save frame time and was spending it: - getComputedStyle per frame for a static class literal on the same element, forcing a style recalculation right after TransformPane dirties style by writing the pane transform. Read once per element. - clientWidth/clientHeight per frame, forcing layout for a canvas that is absolute inset-0 size-full and only changes with the window. - A redraw every frame regardless of whether anything moved. Nothing drawn can change without the camera or node geometry changing, so an idle frame now does nothing. - getBoxes() materialised every node so drawNodeBoxes could discard the off-screen ones - O(all) to draw O(visible), and at 3000 nodes about 180k short-lived objects a second inside the pan loop. It now takes the visible rect and queries the index spatially. Also narrows the index's entries() to a PositionedEntry type so the call site no longer asserts bounds are non-null, which mattered because query() deliberately appends the unpositioned ids - making 'include them in entries() too' a plausible change that would have compiled and then thrown inside the draw loop. And clamps fitToViewInstant above the threshold. It has 24 call sites, each of which could otherwise land in simplified mode where no [data-node-id] exists: locators time out and count()===0 assertions pass for the wrong reason, with nothing in the symptom pointing back to LOD.
1 parent 6c6b908 commit 7db5295

4 files changed

Lines changed: 94 additions & 18 deletions

File tree

browser_tests/fixtures/utils/fitToView.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,25 @@ export async function fitToViewInstant(
9595

9696
const canvas = app.canvas
9797
canvas.ds.fitToBounds(bounds, { zoom })
98+
99+
// Fitting a large graph can land below the level-of-detail threshold,
100+
// where Vue nodes are drawn as canvas boxes and no `[data-node-id]`
101+
// element exists. A spec that then queries one gets a bare locator
102+
// timeout, or a `count() === 0` assertion that passes for the wrong
103+
// reason - and neither symptom points back here. Clamp so fitting always
104+
// leaves nodes addressable; specs that want the simplified mode should
105+
// set the zoom themselves.
106+
const minFontSize = canvas.min_font_size_for_lod ?? 0
107+
if (minFontSize > 0) {
108+
const dprAdjustment = Math.sqrt(window.devicePixelRatio || 1)
109+
// Matches getLowQualityThreshold, plus the composable's hysteresis.
110+
const lodThreshold = minFontSize / (14 * dprAdjustment)
111+
const minimumInteractiveZoom = lodThreshold * 1.2
112+
if (canvas.ds.scale < minimumInteractiveZoom) {
113+
canvas.ds.changeScale(minimumInteractiveZoom)
114+
}
115+
}
116+
98117
canvas.setDirty(true, true)
99118
},
100119
{ bounds, zoom }

src/components/graph/GraphCanvas.vue

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,7 @@ import { useChainCallback } from '@/composables/functional/useChainCallback'
168168
import { useGroupContextMenu } from '@/composables/graph/useGroupContextMenu'
169169
import { installErrorClearingHooks } from '@/composables/graph/useErrorClearingHooks'
170170
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
171+
import type { Bounds, NodeId } from '@/renderer/core/layout/types'
171172
import {
172173
findNodesOptedOutOfCulling,
173174
findNodesWithLiveState
@@ -319,11 +320,11 @@ const nodeColors = computed(() => {
319320
return colors
320321
})
321322
322-
function getNodeBoxes() {
323+
function getNodeBoxes(viewport: Bounds) {
323324
const colors = nodeColors.value
324325
return cullingIndex
325-
.entries()
326-
.map((entry) => ({ bounds: entry.bounds!, color: colors.get(entry.id) }))
326+
.queryEntries(viewport)
327+
.map((entry) => ({ bounds: entry.bounds, color: colors.get(entry.id) }))
327328
}
328329
329330
const { isLowQuality } = useLowQualityRendering(

src/renderer/core/spatial/nodeCullingIndex.ts

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ export interface CullingIndexEntry {
2323
bounds: Bounds | null
2424
}
2525

26+
/** An entry known to have bounds, so callers need no assertion. */
27+
export type PositionedEntry = CullingIndexEntry & { bounds: Bounds }
28+
2629
interface NodeCullingIndexOptions {
2730
/**
2831
* Changes whenever any node's position or size changes. Entries must be
@@ -45,10 +48,11 @@ export function createNodeCullingIndex({
4548
let unpositioned: NodeId[] = []
4649

4750
/** Retained so callers can iterate bounds without rebuilding them. */
48-
let positionedEntries: CullingIndexEntry[] = []
51+
let positionedEntries: PositionedEntry[] = []
52+
let entriesById = new Map<NodeId, PositionedEntry>()
4953

5054
function rebuild(): void {
51-
const positioned: { id: NodeId; bounds: Bounds }[] = []
55+
const positioned: PositionedEntry[] = []
5256
const pending: NodeId[] = []
5357

5458
let minX = Infinity
@@ -71,6 +75,7 @@ export function createNodeCullingIndex({
7175

7276
unpositioned = pending
7377
positionedEntries = positioned
78+
entriesById = new Map(positioned.map((entry) => [entry.id, entry]))
7479

7580
if (positioned.length === 0) {
7681
tree = null
@@ -123,11 +128,25 @@ export function createNodeCullingIndex({
123128
* rather than query a region. Shares the version-keyed cache, so reading
124129
* it each frame does not rebuild anything.
125130
*/
126-
entries(): readonly CullingIndexEntry[] {
131+
entries(): readonly PositionedEntry[] {
127132
ensureFresh()
128133
return positionedEntries
129134
},
130135

136+
/**
137+
* Positioned entries intersecting the rect. Lets a draw pass be O(visible)
138+
* rather than materialising the whole graph and discarding most of it.
139+
*/
140+
queryEntries(bounds: Bounds): PositionedEntry[] {
141+
ensureFresh()
142+
const found: PositionedEntry[] = []
143+
for (const id of tree ? tree.query(bounds) : []) {
144+
const entry = entriesById.get(id)
145+
if (entry) found.push(entry)
146+
}
147+
return found
148+
},
149+
131150
/** Forces a rebuild on next query, for changes the version does not cover. */
132151
invalidate(): void {
133152
built = false

src/renderer/extensions/vueNodes/components/NodeBoxOverlay.vue

Lines changed: 49 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,50 @@
11
<script setup lang="ts">
2-
import { useRafFn } from '@vueuse/core'
3-
import { useTemplateRef } from 'vue'
2+
import { useEventListener, useRafFn } from '@vueuse/core'
3+
import { useTemplateRef, watch } from 'vue'
44
55
import { drawNodeBoxes } from '@/renderer/core/canvas/nodeBoxRenderer'
66
import type { NodeBox } from '@/renderer/core/canvas/nodeBoxRenderer'
7+
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
78
import { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
89
import type { Bounds } from '@/renderer/core/layout/types'
910
1011
const { getBoxes } = defineProps<{
11-
/** Boxes to draw, in graph space. Called once per frame. */
12-
getBoxes: () => Iterable<NodeBox>
12+
/**
13+
* Boxes to draw, in graph space. Receives the visible rect so the caller can
14+
* return only what intersects it rather than the whole graph.
15+
*/
16+
getBoxes: (viewport: Bounds) => Iterable<NodeBox>
1317
}>()
1418
1519
const { camera } = useTransformState()
1620
const canvasRef = useTemplateRef<HTMLCanvasElement>('canvasRef')
1721
22+
// Read once per element rather than per frame: it is a static class literal on
23+
// this canvas, and getComputedStyle forces a style recalculation in a loop
24+
// whose entire justification is frame time - with TransformPane having just
25+
// dirtied style by writing the pane transform.
26+
let boxColor = ''
27+
// Element size likewise: clientWidth forces layout, and this canvas is
28+
// `absolute inset-0 size-full`, so it only changes when the window does.
29+
let elementWidth = 0
30+
let elementHeight = 0
31+
32+
function measureElement(el: HTMLCanvasElement): void {
33+
boxColor =
34+
getComputedStyle(el).getPropertyValue('--node-box-color').trim() ||
35+
'#353535'
36+
elementWidth = el.clientWidth
37+
elementHeight = el.clientHeight
38+
}
39+
40+
useEventListener(window, 'resize', () => {
41+
if (canvasRef.value) measureElement(canvasRef.value)
42+
})
43+
44+
watch(canvasRef, (el) => {
45+
if (el) measureElement(el)
46+
})
47+
1848
/** Visible region in graph space, from the inverse of the camera transform. */
1949
function viewportBounds(width: number, height: number): Bounds {
2050
const scale = camera.z || 1
@@ -26,15 +56,23 @@ function viewportBounds(width: number, height: number): Bounds {
2656
}
2757
}
2858
59+
// Nothing drawn here can change without the camera moving or node geometry
60+
// changing, so an idle frame does no work at all.
61+
let lastSignature = ''
62+
2963
useRafFn(() => {
3064
const el = canvasRef.value
3165
if (!el) return
3266
3367
const dpr = window.devicePixelRatio || 1
34-
const width = el.clientWidth
35-
const height = el.clientHeight
68+
const width = elementWidth
69+
const height = elementHeight
3670
if (!width || !height) return
3771
72+
const signature = `${camera.x},${camera.y},${camera.z},${layoutStore.nodeGeometryVersion},${width}x${height}`
73+
if (signature === lastSignature) return
74+
lastSignature = signature
75+
3876
const pixelWidth = Math.round(width * dpr)
3977
const pixelHeight = Math.round(height * dpr)
4078
if (el.width !== pixelWidth || el.height !== pixelHeight) {
@@ -48,12 +86,11 @@ useRafFn(() => {
4886
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
4987
ctx.clearRect(0, 0, width, height)
5088
51-
drawNodeBoxes(ctx, getBoxes(), camera, viewportBounds(width, height), {
52-
// Matches the node surface colour so a zoomed-out graph reads the same as
53-
// a zoomed-in one.
54-
defaultColor:
55-
getComputedStyle(el).getPropertyValue('--node-box-color').trim() ||
56-
'#353535'
89+
// Matches the node surface colour so a zoomed-out graph reads the same as a
90+
// zoomed-in one.
91+
const viewport = viewportBounds(width, height)
92+
drawNodeBoxes(ctx, getBoxes(viewport), camera, viewport, {
93+
defaultColor: boxColor
5794
})
5895
})
5996
</script>

0 commit comments

Comments
 (0)