Skip to content

Commit 51f0279

Browse files
authored
feat: Improved performance (#101)
* feat: Improved performance by optimizing the HitTest service * fix(ReactBlock): fix unnecessary re-renders * fix(Layer): use current devicePixelRation in Layer(#89) * fix(Camera): fix tracking trackpad on pan event with browser zoom * feat(BatchPath2D): spliting Path2DGroup to chunks to faster rerendering * feat(BatchPath2D): add isPathVisible method to Path2DGroup to improve path visibility handling in BatchPath2D * refactor(Zoom): optimize zoomToViewPort method to be asynchronous and improve usability
1 parent e8f3a1a commit 51f0279

File tree

30 files changed

+849
-275
lines changed

30 files changed

+849
-275
lines changed

docs/system/graph-settings.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,8 +140,8 @@ Constants control the sizing, spacing, and other numerical values used throughou
140140
| Constant | Default | Description |
141141
|----------|---------|-------------|
142142
| `GRID_SIZE` | `16` | Base grid size for layout calculations |
143-
| `PIXEL_RATIO` | `window.devicePixelRatio \|\| 1` | Device pixel ratio for rendering |
144143
| `USABLE_RECT_GAP` | `400` | Gap around the usable rect area |
144+
| `CAMERA_VIEWPORT_TRESHOLD` | `0.5` | Threshold for camera viewport |
145145

146146
### Camera Constants
147147

src/api/PublicGraphApi.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,17 @@ export class PublicGraphApi {
2828
this.zoomToRect(blocksRect, zoomConfig);
2929
}
3030

31+
/**
32+
* Zooms to fit all blocks in the viewport. This method is asynchronous and waits
33+
* for the usableRect to be ready before performing the zoom operation.
34+
*
35+
* @param zoomConfig - Configuration for zoom transition and padding
36+
* @returns Promise that resolves when zoom operation is complete
37+
*/
3138
public zoomToViewPort(zoomConfig?: ZoomConfig) {
32-
const blocksRect = this.graph.rootStore.blocksList.getUsableRect();
33-
34-
this.zoomToRect(blocksRect, zoomConfig);
39+
this.graph.hitTest.waitUsableRectUpdate((rect) => {
40+
this.zoomToRect(rect, zoomConfig);
41+
});
3542
}
3643

3744
public zoomToRect(rect: TRect, zoomConfig?: ZoomConfig) {
@@ -165,7 +172,7 @@ export class PublicGraphApi {
165172
}
166173

167174
public getUsableRect() {
168-
return this.graph.rootStore.blocksList.getUsableRect();
175+
return this.graph.hitTest.getUsableRect();
169176
}
170177

171178
public unsetSelection() {

src/components/canvas/GraphComponent/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ export class GraphComponent<
5959
if (onDragStart?.(event) === false) {
6060
return;
6161
}
62+
this.context.graph.getGraphLayer().captureEvents(this);
6263
const xy = getXY(this.context.canvas, event);
6364
startDragCoords = this.context.camera.applyToPoint(xy[0], xy[1]);
6465
})
@@ -75,6 +76,7 @@ export class GraphComponent<
7576
startDragCoords = currentCoords;
7677
})
7778
.on(EVENTS.DRAG_END, (_event: MouseEvent) => {
79+
this.context.graph.getGraphLayer().releaseCapture();
7880
startDragCoords = undefined;
7981
onDrop?.(_event);
8082
});

src/components/canvas/blocks/Block.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -448,6 +448,7 @@ export class Block<T extends TBlock = TBlock, Props extends TBlockProps = TBlock
448448
public setHiddenBlock(hidden: boolean) {
449449
if (this.hidden !== hidden) {
450450
this.hidden = hidden;
451+
this.shouldRender = !hidden;
451452
this.performRender();
452453
}
453454
}
@@ -458,6 +459,10 @@ export class Block<T extends TBlock = TBlock, Props extends TBlockProps = TBlock
458459
}
459460

460461
protected render() {
462+
if (this.hidden) {
463+
return;
464+
}
465+
461466
const scaleLevel = this.context.graph.cameraService.getCameraBlockScaleLevel();
462467

463468
switch (scaleLevel) {

src/components/canvas/blocks/controllers/BlockController.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,12 +57,14 @@ export class BlockController {
5757

5858
dragListener(block.context.ownerDocument)
5959
.on(EVENTS.DRAG_START, (_event: MouseEvent) => {
60+
block.context.graph.getGraphLayer().captureEvents(this);
6061
dispatchEvents(selectedBlocksComponents, createCustomDragEvent(EVENTS.DRAG_START, _event));
6162
})
6263
.on(EVENTS.DRAG_UPDATE, (_event: MouseEvent) => {
6364
dispatchEvents(selectedBlocksComponents, createCustomDragEvent(EVENTS.DRAG_UPDATE, _event));
6465
})
6566
.on(EVENTS.DRAG_END, (_event: MouseEvent) => {
67+
block.context.graph.getGraphLayer().releaseCapture();
6668
dispatchEvents(selectedBlocksComponents, createCustomDragEvent(EVENTS.DRAG_END, _event));
6769
});
6870
},

src/components/canvas/connections/BaseConnection.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,13 +96,13 @@ export class BaseConnection<
9696
this.connectionPoints[1].x,
9797
this.anchorsPoints?.[0].x || Infinity,
9898
this.anchorsPoints?.[1].x || Infinity,
99-
];
99+
].filter(Number.isFinite);
100100
const y = [
101101
this.connectionPoints[0].y,
102102
this.connectionPoints[1].y,
103103
this.anchorsPoints?.[0].y || Infinity,
104104
this.anchorsPoints?.[1].y || Infinity,
105-
];
105+
].filter(Number.isFinite);
106106

107107
this.bBox = [Math.min(...x), Math.min(...y), Math.max(...x), Math.max(...y)];
108108

src/components/canvas/connections/BatchPath2D/index.tsx

Lines changed: 112 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -9,69 +9,126 @@ export interface Path2DRenderInstance {
99
getPath(): Path2D | undefined | null;
1010
style(ctx: CanvasRenderingContext2D): Path2DRenderStyleResult | undefined;
1111
afterRender?(ctx: CanvasRenderingContext2D): void;
12+
isPathVisible?(): boolean;
1213
}
1314

14-
class Path2DGroup {
15+
class Path2DChunk {
1516
protected items: Set<Path2DRenderInstance> = new Set();
1617

18+
protected visibleItems = cache(() => {
19+
return Array.from(this.items).filter((item) => item.isPathVisible?.() ?? true);
20+
});
21+
1722
protected path = cache(() => {
1823
const path = new Path2D();
1924
path.moveTo(0, 0);
20-
return Array.from(this.items).reduce((path, item) => {
25+
// Use already filtered visibleItems - no need for additional visibility checks
26+
for (const item of this.visibleItems.get()) {
2127
const subPath = item.getPath();
2228
if (subPath) {
2329
path.addPath(subPath);
2430
}
25-
return path;
26-
}, path);
31+
}
32+
return path;
2733
});
2834

29-
protected applyStyles(ctx) {
30-
const val = Array.from(this.items)[0];
31-
return val.style(ctx);
35+
protected applyStyles(ctx: CanvasRenderingContext2D) {
36+
// Style comes from first visible item
37+
const first = this.visibleItems.get()[0];
38+
return first?.style(ctx);
3239
}
3340

3441
public add(item: Path2DRenderInstance) {
3542
this.items.add(item);
36-
this.path.reset();
43+
this.reset();
3744
}
3845

39-
public delete(item) {
46+
public delete(item: Path2DRenderInstance) {
4047
this.items.delete(item);
48+
this.reset();
49+
}
50+
51+
public reset() {
4152
this.path.reset();
53+
this.visibleItems.reset();
4254
}
4355

4456
public render(ctx: CanvasRenderingContext2D) {
45-
if (this.items.size) {
46-
ctx.save();
47-
48-
const result = this.applyStyles(ctx);
49-
if (result) {
50-
switch (result.type) {
51-
case "fill": {
52-
ctx.fill(this.path.get(), result.fillRule);
53-
break;
54-
}
55-
case "stroke": {
56-
ctx.stroke(this.path.get());
57-
break;
58-
}
59-
case "both": {
60-
ctx.fill(this.path.get(), result.fillRule);
61-
ctx.stroke(this.path.get());
62-
}
63-
}
57+
const vis = this.visibleItems.get();
58+
if (!vis.length) return;
59+
60+
ctx.save();
61+
const style = this.applyStyles(ctx);
62+
if (style) {
63+
const p = this.path.get();
64+
if (style.type === "fill" || style.type === "both") {
65+
ctx.fill(p, style.fillRule);
6466
}
65-
ctx.restore();
66-
for (const item of this.items) {
67-
item.afterRender?.(ctx);
67+
if (style.type === "stroke" || style.type === "both") {
68+
ctx.stroke(p);
6869
}
6970
}
71+
ctx.restore();
72+
73+
for (const item of vis) {
74+
item.afterRender?.(ctx);
75+
}
76+
}
77+
78+
public get size() {
79+
return this.items.size;
80+
}
81+
}
82+
83+
class Path2DGroup {
84+
protected chunks: Path2DChunk[] = [];
85+
protected itemToChunk: Map<Path2DRenderInstance, Path2DChunk> = new Map();
86+
87+
constructor(private chunkSize: number) {
88+
this.chunks.push(new Path2DChunk());
89+
}
90+
91+
public add(item: Path2DRenderInstance) {
92+
let lastChunk = this.chunks[this.chunks.length - 1];
93+
if (lastChunk.size >= this.chunkSize) {
94+
lastChunk = new Path2DChunk();
95+
this.chunks.push(lastChunk);
96+
}
97+
lastChunk.add(item);
98+
this.itemToChunk.set(item, lastChunk);
99+
}
100+
101+
public delete(item: Path2DRenderInstance) {
102+
const chunk = this.itemToChunk.get(item);
103+
if (chunk) {
104+
chunk.delete(item);
105+
this.itemToChunk.delete(item);
106+
if (chunk.size === 0 && this.chunks.length > 1) {
107+
const index = this.chunks.indexOf(chunk);
108+
if (index > -1) {
109+
this.chunks.splice(index, 1);
110+
}
111+
}
112+
}
113+
}
114+
115+
public resetItem(item: Path2DRenderInstance) {
116+
const chunk = this.itemToChunk.get(item);
117+
chunk?.reset();
118+
}
119+
120+
public render(ctx: CanvasRenderingContext2D) {
121+
for (const chunk of this.chunks) {
122+
chunk.render(ctx);
123+
}
70124
}
71125
}
72126

73127
export class BatchPath2DRenderer {
74-
constructor(protected onChange: () => void) {}
128+
constructor(
129+
protected onChange: () => void,
130+
private chunkSize: number = 100
131+
) {}
75132

76133
protected indexes: Map<number, Map<string, Path2DGroup>> = new Map();
77134

@@ -86,14 +143,25 @@ export class BatchPath2DRenderer {
86143
}, [] satisfies Path2DGroup[]);
87144
});
88145

146+
protected requestRender = () => {
147+
this.onChange?.();
148+
}; /* debounce(
149+
() => {
150+
this.onChange?.();
151+
},
152+
{
153+
priority: ESchedulerPriority.HIGHEST,
154+
}
155+
); */
156+
89157
protected getGroup(zIndex: number, group: string) {
90158
if (!this.indexes.has(zIndex)) {
91159
this.indexes.set(zIndex, new Map());
92160
}
93161
const index = this.indexes.get(zIndex);
94162

95163
if (!index.has(group)) {
96-
index.set(group, new Path2DGroup());
164+
index.set(group, new Path2DGroup(this.chunkSize));
97165
}
98166

99167
return index.get(group);
@@ -107,7 +175,7 @@ export class BatchPath2DRenderer {
107175
bucket.add(item);
108176
this.itemParams.set(item, params);
109177
this.orderedPaths.reset();
110-
this.onChange?.();
178+
this.requestRender();
111179
}
112180

113181
public update(item: Path2DRenderInstance, params: { zIndex: number; group: string }) {
@@ -124,6 +192,15 @@ export class BatchPath2DRenderer {
124192
bucket.delete(item);
125193
this.itemParams.delete(item);
126194
this.orderedPaths.reset();
127-
this.onChange?.();
195+
this.requestRender();
196+
}
197+
198+
public markDirty(item: Path2DRenderInstance) {
199+
const params = this.itemParams.get(item);
200+
if (params) {
201+
const group = this.getGroup(params.zIndex, params.group);
202+
group.resetItem(item);
203+
this.requestRender();
204+
}
128205
}
129206
}

src/components/canvas/connections/BlockConnections.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,14 @@ export class BlockConnections extends Component<CoreComponentProps, TComponentSt
1818

1919
protected readonly unsubscribe: (() => void)[];
2020

21-
protected batch = new BatchPath2DRenderer(() => this.performRender());
21+
protected batch: BatchPath2DRenderer;
2222

2323
constructor(props: {}, parent: Component) {
2424
super(props, parent);
25+
this.batch = new BatchPath2DRenderer(
26+
() => this.performRender(),
27+
this.context.constants.connection.PATH2D_CHUNK_SIZE || 100
28+
);
2529
this.unsubscribe = this.subscribe();
2630
this.setContext({
2731
batch: this.batch,

0 commit comments

Comments
 (0)