• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

graphty-org / graphty-element / 20450758399

23 Dec 2025 03:22AM UTC coverage: 82.556% (+0.1%) from 82.436%
20450758399

push

github

apowers313
ci: fix race condition in ci tests

5193 of 6117 branches covered (84.89%)

Branch coverage included in aggregate %.

24879 of 30309 relevant lines covered (82.08%)

6461.5 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

98.07
/src/managers/UpdateManager.ts
1
import type {Mesh, Vector3} from "@babylonjs/core";
2

3
import type {CameraManager} from "../cameras/CameraManager";
4
import {Edge} from "../Edge";
2✔
5
import type {DataManager} from "./DataManager";
6
import type {EventManager} from "./EventManager";
7
import type {GraphContext} from "./GraphContext";
8
import type {Manager} from "./interfaces";
9
import type {LayoutManager} from "./LayoutManager";
10
import type {StatsManager} from "./StatsManager";
11
import type {StyleManager} from "./StyleManager";
12

13
/**
14
 * Configuration for the UpdateManager
15
 */
16
export interface UpdateManagerConfig {
17
    /**
18
     * Number of layout steps to perform per update
19
     */
20
    layoutStepMultiplier?: number;
21

22
    /**
23
     * Whether to automatically zoom to fit on first load
24
     */
25
    autoZoomToFit?: boolean;
26

27
    /**
28
     * Minimum bounding box size to trigger zoom to fit
29
     */
30
    minBoundingBoxSize?: number;
31
}
32

33
/**
34
 * Manages the update loop logic for the graph
35
 * Coordinates updates across nodes, edges, layout, and camera
36
 */
37
export class UpdateManager implements Manager {
2✔
38
    private needsZoomToFit = false;
2✔
39
    private hasZoomedToFit = false;
2✔
40
    private config: Required<UpdateManagerConfig>;
41
    private layoutStepCount = 0;
2✔
42
    private minLayoutStepsBeforeZoom = 10;
2✔
43
    private lastZoomStep = 0;
2✔
44
    private wasSettled = false;
2✔
45

46
    constructor(
2✔
47
        private eventManager: EventManager,
816✔
48
        private statsManager: StatsManager,
816✔
49
        private layoutManager: LayoutManager,
816✔
50
        private dataManager: DataManager,
816✔
51
        private styleManager: StyleManager,
816✔
52
        private camera: CameraManager,
816✔
53
        private graphContext: GraphContext,
816✔
54
        config: UpdateManagerConfig = {},
816✔
55
    ) {
816✔
56
        this.config = {
816✔
57
            layoutStepMultiplier: config.layoutStepMultiplier ?? 1,
816!
58
            autoZoomToFit: config.autoZoomToFit ?? true,
816!
59
            minBoundingBoxSize: config.minBoundingBoxSize ?? 0.1,
816✔
60
        };
816✔
61
    }
816✔
62

63
    async init(): Promise<void> {
2✔
64
        // UpdateManager doesn't need async initialization
65
        return Promise.resolve();
745✔
66
    }
745✔
67

68
    dispose(): void {
2✔
69
        // UpdateManager doesn't hold resources to dispose
70
    }
590✔
71

72
    /**
73
     * Enable zoom to fit on next update
74
     */
75
    enableZoomToFit(): void {
2✔
76
        this.needsZoomToFit = true;
4,041✔
77
        // Only reset the layout step count if we haven't zoomed yet
78
        // This prevents the counter from being reset when enableZoomToFit is called multiple times
79
        if (!this.hasZoomedToFit) {
4,041✔
80
            this.layoutStepCount = 0;
3,778✔
81
            this.lastZoomStep = 0;
3,778✔
82
            this.wasSettled = false;
3,778✔
83
        }
3,778✔
84
    }
4,041✔
85

86
    /**
87
     * Disable zoom to fit
88
     */
89
    disableZoomToFit(): void {
2✔
90
        this.needsZoomToFit = false;
×
91
    }
×
92

93
    /**
94
     * Get current zoom to fit state
95
     */
96
    isZoomToFitEnabled(): boolean {
2✔
97
        return this.needsZoomToFit;
4✔
98
    }
4✔
99

100
    /**
101
     * Get the current render frame count
102
     */
103
    getRenderFrameCount(): number {
2✔
104
        return this.frameCount;
4✔
105
    }
4✔
106

107
    /**
108
     * Render a fixed number of frames (for testing)
109
     * This ensures deterministic rendering similar to Babylon.js testing approach
110
     */
111
    renderFixedFrames(count: number): void {
2✔
112
        for (let i = 0; i < count; i++) {
2✔
113
            this.update();
10✔
114
        }
10✔
115
    }
2✔
116

117
    /**
118
     * Main update method - called by RenderManager each frame
119
     */
120
    private frameCount = 0;
2✔
121

122
    update(): void {
2✔
123
        this.frameCount++;
21,672✔
124

125
        // Always update camera
126
        this.camera.update();
21,672✔
127

128
        // Check if layout is running
129
        if (!this.layoutManager.running) {
21,672✔
130
            // Even if layout is not running, we still need to:
131
            // 1. Update edges (for manual node dragging)
132
            // 2. Handle zoom if requested
133

134
            // Always update edges to handle manual node dragging
135
            // Edges have built-in dirty tracking, so they won't do unnecessary work
136
            this.updateEdges();
14,388✔
137

138
            // Handle zoom to fit if requested
139
            if (this.needsZoomToFit && !this.hasZoomedToFit) {
14,388✔
140
                // Check if we have nodes to calculate bounds from
141
                const nodeCount = Array.from(this.layoutManager.nodes).length;
336✔
142
                if (nodeCount > 0) {
336✔
143
                    // Calculate bounding box and update nodes
144
                    const {boundingBoxMin, boundingBoxMax} = this.updateNodes();
336✔
145

146
                    // Update edges (also expands bounding box for edge labels)
147
                    this.updateEdges(boundingBoxMin, boundingBoxMax);
336✔
148

149
                    // Handle zoom to fit
150
                    this.handleZoomToFit(boundingBoxMin, boundingBoxMax);
336✔
151

152
                    // Update statistics
153
                    this.updateStatistics();
336✔
154
                }
336✔
155
            }
336✔
156

157
            return;
14,388✔
158
        }
14,388✔
159

160
        // Update layout engine (step the force-directed algorithm)
161
        this.updateLayout();
7,284✔
162

163
        // Update nodes and edges
164
        const {boundingBoxMin, boundingBoxMax} = this.updateNodes();
7,284✔
165

166
        // Update edges (also expands bounding box for edge labels)
167
        this.updateEdges(boundingBoxMin, boundingBoxMax);
7,284✔
168

169
        // Handle zoom to fit if needed
170
        this.handleZoomToFit(boundingBoxMin, boundingBoxMax);
7,284✔
171

172
        // Update statistics
173
        this.updateStatistics();
7,284✔
174
    }
21,672✔
175

176
    /**
177
     * Update the layout engine
178
     */
179
    private updateLayout(): void {
2✔
180
        this.statsManager.step();
7,284✔
181
        this.statsManager.graphStep.beginMonitoring();
7,284✔
182

183
        const {stepMultiplier} = this.styleManager.getStyles().config.behavior.layout;
7,284✔
184
        for (let i = 0; i < stepMultiplier; i++) {
7,284✔
185
            this.layoutManager.step();
7,284✔
186
            this.layoutStepCount++;
7,284✔
187
        }
7,284✔
188

189
        this.statsManager.graphStep.endMonitoring();
7,284✔
190
    }
7,284✔
191

192
    /**
193
     * Update all nodes and calculate bounding box
194
     */
195
    private updateNodes(): {boundingBoxMin?: Vector3, boundingBoxMax?: Vector3} {
2✔
196
        let boundingBoxMin: Vector3 | undefined;
7,620✔
197
        let boundingBoxMax: Vector3 | undefined;
7,620✔
198

199
        this.statsManager.nodeUpdate.beginMonitoring();
7,620✔
200

201
        for (const node of this.layoutManager.nodes) {
7,620✔
202
            node.update();
92,427✔
203

204
            // The mesh position is already updated by node.update()
205

206
            // Update bounding box
207
            const pos = node.mesh.getAbsolutePosition();
92,427✔
208
            const sz = node.size;
92,427✔
209

210
            if (!boundingBoxMin || !boundingBoxMax) {
92,427✔
211
                boundingBoxMin = pos.clone();
5,404✔
212
                boundingBoxMax = pos.clone();
5,404✔
213
            }
5,404✔
214

215
            this.updateBoundingBoxAxis(pos, boundingBoxMin, boundingBoxMax, sz, "x");
92,427✔
216
            this.updateBoundingBoxAxis(pos, boundingBoxMin, boundingBoxMax, sz, "y");
92,427✔
217
            this.updateBoundingBoxAxis(pos, boundingBoxMin, boundingBoxMax, sz, "z");
92,427✔
218

219
            // Include node label in bounding box
220
            if (node.label?.labelMesh) {
92,427✔
221
                this.expandBoundingBoxForLabel(node.label.labelMesh, boundingBoxMin, boundingBoxMax);
6,404✔
222
            }
6,404✔
223
        }
92,427✔
224

225
        this.statsManager.nodeUpdate.endMonitoring();
7,620✔
226

227
        return {boundingBoxMin, boundingBoxMax};
7,620✔
228
    }
7,620✔
229

230
    /**
231
     * Update bounding box for a single axis
232
     */
233
    private updateBoundingBoxAxis(
2✔
234
        pos: Vector3,
277,281✔
235
        min: Vector3,
277,281✔
236
        max: Vector3,
277,281✔
237
        size: number,
277,281✔
238
        axis: "x" | "y" | "z",
277,281✔
239
    ): void {
277,281✔
240
        const value = pos[axis];
277,281✔
241
        const halfSize = size / 2;
277,281✔
242

243
        min[axis] = Math.min(min[axis], value - halfSize);
277,281✔
244
        max[axis] = Math.max(max[axis], value + halfSize);
277,281✔
245
    }
277,281✔
246

247
    /**
248
     * Expand bounding box to include a label mesh
249
     */
250
    private expandBoundingBoxForLabel(labelMesh: Mesh, min: Vector3, max: Vector3): void {
2✔
251
        const labelBoundingInfo = labelMesh.getBoundingInfo();
6,502✔
252
        const labelMin = labelBoundingInfo.boundingBox.minimumWorld;
6,502✔
253
        const labelMax = labelBoundingInfo.boundingBox.maximumWorld;
6,502✔
254

255
        min.x = Math.min(min.x, labelMin.x);
6,502✔
256
        min.y = Math.min(min.y, labelMin.y);
6,502✔
257
        min.z = Math.min(min.z, labelMin.z);
6,502✔
258
        max.x = Math.max(max.x, labelMax.x);
6,502✔
259
        max.y = Math.max(max.y, labelMax.y);
6,502✔
260
        max.z = Math.max(max.z, labelMax.z);
6,502✔
261
    }
6,502✔
262

263
    /**
264
     * Update all edges and expand bounding box for edge labels
265
     */
266
    private updateEdges(boundingBoxMin?: Vector3, boundingBoxMax?: Vector3): void {
2✔
267
        this.statsManager.edgeUpdate.beginMonitoring();
22,008✔
268

269
        // Update rays for all edges (static method on Edge class)
270
        Edge.updateRays(this.graphContext);
22,008✔
271

272
        // Update individual edges
273
        for (const edge of this.layoutManager.edges) {
22,008✔
274
            edge.update();
302,742✔
275

276
            // Include edge labels in bounding box if we have one
277
            if (boundingBoxMin && boundingBoxMax) {
302,742✔
278
                // Edge label (at midpoint)
279
                if (edge.label?.labelMesh) {
162,228✔
280
                    this.expandBoundingBoxForLabel(edge.label.labelMesh, boundingBoxMin, boundingBoxMax);
42✔
281
                }
42✔
282

283
                // Arrow head text label
284
                if (edge.arrowHeadText?.labelMesh) {
162,228✔
285
                    this.expandBoundingBoxForLabel(edge.arrowHeadText.labelMesh, boundingBoxMin, boundingBoxMax);
54✔
286
                }
54✔
287

288
                // Arrow tail text label
289
                if (edge.arrowTailText?.labelMesh) {
162,228✔
290
                    this.expandBoundingBoxForLabel(edge.arrowTailText.labelMesh, boundingBoxMin, boundingBoxMax);
2✔
291
                }
2✔
292
            }
162,228✔
293
        }
302,742✔
294

295
        this.statsManager.edgeUpdate.endMonitoring();
22,008✔
296
    }
22,008✔
297

298
    /**
299
     * Handle zoom to fit logic
300
     */
301
    private handleZoomToFit(boundingBoxMin?: Vector3, boundingBoxMax?: Vector3): void {
2✔
302
        if (!this.needsZoomToFit) {
7,620✔
303
            return;
5✔
304
        }
5✔
305

306
        if (!boundingBoxMin || !boundingBoxMax) {
7,620✔
307
            return;
2,216✔
308
        }
2,216✔
309

310
        // Check if we should zoom:
311
        // 1. Wait for minimum steps on first zoom
312
        // 2. Zoom every N steps during layout (based on zoomStepInterval)
313
        // 3. Zoom when layout settles
314
        const isSettled = this.layoutManager.layoutEngine?.isSettled ?? false;
7,620!
315
        const {zoomStepInterval} = this.styleManager.getStyles().config.behavior.layout;
7,620✔
316
        const shouldZoomPeriodically = this.layoutStepCount > 0 &&
7,620✔
317
                                      this.layoutStepCount >= this.lastZoomStep + zoomStepInterval;
5,063✔
318
        const justSettled = isSettled && !this.wasSettled && this.layoutStepCount > 0;
7,620✔
319

320
        if (!this.hasZoomedToFit && this.layoutManager.running && this.layoutStepCount < this.minLayoutStepsBeforeZoom) {
7,620✔
321
            // First zoom - wait for minimum steps
322
            return;
1,879✔
323
        } else if (!this.layoutManager.running && !this.hasZoomedToFit && this.layoutStepCount === 0) {
5,591✔
324
            // Layout not running and no steps taken - allow immediate zoom
325
        } else if (!shouldZoomPeriodically && !justSettled) {
3,517✔
326
            // Not time for periodic zoom and didn't just settle
327
            return;
212✔
328
        }
212✔
329

330
        // Update settled state for next frame
331
        this.wasSettled = isSettled;
3,308✔
332

333
        const size = boundingBoxMax.subtract(boundingBoxMin);
3,308✔
334

335
        if (size.length() > this.config.minBoundingBoxSize) {
3,308✔
336
            this.camera.zoomToBoundingBox(boundingBoxMin, boundingBoxMax);
3,308✔
337

338
            this.hasZoomedToFit = true;
3,308✔
339
            this.lastZoomStep = this.layoutStepCount;
3,308✔
340

341
            // Only clear needsZoomToFit if layout is settled
342
            if (isSettled) {
3,308✔
343
                this.needsZoomToFit = false;
463✔
344
            }
463✔
345

346
            // Emit zoom complete event
347
            this.eventManager.emitGraphEvent("zoom-to-fit-complete", {
3,308✔
348
                boundingBoxMin,
3,308✔
349
                boundingBoxMax,
3,308✔
350
            });
3,308✔
351
        }
3,308✔
352
    }
7,620✔
353

354
    /**
355
     * Update statistics
356
     */
357
    private updateStatistics(): void {
2✔
358
        this.statsManager.updateCounts(
7,620✔
359
            this.dataManager.nodeCache.size,
7,620✔
360
            this.dataManager.edgeCache.size,
7,620✔
361
        );
7,620✔
362

363
        // Update mesh cache stats
364
        const meshCache = this.graphContext.getMeshCache();
7,620✔
365
        this.statsManager.updateCacheStats(meshCache.hits, meshCache.misses);
7,620✔
366
    }
7,620✔
367

368
    /**
369
     * Check if zoom to fit has been completed
370
     */
371
    get zoomToFitCompleted(): boolean {
2✔
372
        return this.hasZoomedToFit;
2✔
373
    }
2✔
374

375
    /**
376
     * Update configuration
377
     */
378
    updateConfig(config: Partial<UpdateManagerConfig>): void {
2✔
379
        Object.assign(this.config, config);
1✔
380
    }
1✔
381
}
2✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc