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

graphty-org / graphty-element / 20514590651

26 Dec 2025 02:37AM UTC coverage: 70.559% (-0.3%) from 70.836%
20514590651

push

github

apowers313
ci: fix npm ci

9591 of 13363 branches covered (71.77%)

Branch coverage included in aggregate %.

25136 of 35854 relevant lines covered (70.11%)

6233.71 hits per line

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

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

3
import type {CameraManager} from "../cameras/CameraManager";
4
import {Edge} from "../Edge";
14✔
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 {
14✔
38
    private needsZoomToFit = false;
14✔
39
    private hasZoomedToFit = false;
14✔
40
    private config: Required<UpdateManagerConfig>;
41
    private layoutStepCount = 0;
14✔
42
    private minLayoutStepsBeforeZoom = 10;
14✔
43
    private lastZoomStep = 0;
14✔
44
    private wasSettled = false;
14✔
45

46
    /**
47
     * Creates a new update manager
48
     * @param eventManager - Event manager for emitting update events
49
     * @param statsManager - Stats manager for performance tracking
50
     * @param layoutManager - Layout manager for graph layout
51
     * @param dataManager - Data manager for nodes and edges
52
     * @param styleManager - Style manager for styling
53
     * @param camera - Camera manager for view control
54
     * @param graphContext - Graph context for accessing shared resources
55
     * @param config - Optional configuration
56
     */
57
    constructor(
14✔
58
        private eventManager: EventManager,
991✔
59
        private statsManager: StatsManager,
991✔
60
        private layoutManager: LayoutManager,
991✔
61
        private dataManager: DataManager,
991✔
62
        private styleManager: StyleManager,
991✔
63
        private camera: CameraManager,
991✔
64
        private graphContext: GraphContext,
991✔
65
        config: UpdateManagerConfig = {},
991✔
66
    ) {
991✔
67
        this.config = {
991✔
68
            layoutStepMultiplier: config.layoutStepMultiplier ?? 1,
991!
69
            autoZoomToFit: config.autoZoomToFit ?? true,
991!
70
            minBoundingBoxSize: config.minBoundingBoxSize ?? 0.1,
991✔
71
        };
991✔
72
    }
991✔
73

74
    /**
75
     * Initialize the update manager
76
     * @returns Promise that resolves when initialization is complete
77
     */
78
    async init(): Promise<void> {
14✔
79
        // UpdateManager doesn't need async initialization
80
        return Promise.resolve();
919✔
81
    }
919✔
82

83
    /**
84
     * Dispose the update manager
85
     */
86
    dispose(): void {
14✔
87
        // UpdateManager doesn't hold resources to dispose
88
    }
779✔
89

90
    /**
91
     * Enable zoom to fit on next update
92
     */
93
    enableZoomToFit(): void {
14✔
94
        this.needsZoomToFit = true;
4,843✔
95
        // Only reset the layout step count if we haven't zoomed yet
96
        // This prevents the counter from being reset when enableZoomToFit is called multiple times
97
        if (!this.hasZoomedToFit) {
4,843✔
98
            this.layoutStepCount = 0;
4,472✔
99
            this.lastZoomStep = 0;
4,472✔
100
            this.wasSettled = false;
4,472✔
101
        }
4,472✔
102
    }
4,843✔
103

104
    /**
105
     * Disable zoom to fit
106
     */
107
    disableZoomToFit(): void {
14✔
108
        this.needsZoomToFit = false;
×
109
    }
×
110

111
    /**
112
     * Get current zoom to fit state
113
     * @returns True if zoom to fit is enabled
114
     */
115
    isZoomToFitEnabled(): boolean {
14✔
116
        return this.needsZoomToFit;
4✔
117
    }
4✔
118

119
    /**
120
     * Get the current render frame count
121
     * @returns Total number of frames rendered
122
     */
123
    getRenderFrameCount(): number {
14✔
124
        return this.frameCount;
4✔
125
    }
4✔
126

127
    /**
128
     * Render a fixed number of frames (for testing)
129
     * This ensures deterministic rendering similar to Babylon.js testing approach
130
     * @param count - Number of frames to render
131
     */
132
    renderFixedFrames(count: number): void {
14✔
133
        for (let i = 0; i < count; i++) {
2✔
134
            this.update();
10✔
135
        }
10✔
136
    }
2✔
137

138
    /**
139
     * Main update method - called by RenderManager each frame
140
     */
141
    private frameCount = 0;
14✔
142

143
    /**
144
     * Update the graph for the current frame
145
     */
146
    update(): void {
14✔
147
        this.frameCount++;
27,750✔
148

149
        // Always update camera
150
        this.camera.update();
27,750✔
151

152
        // Check if layout is running
153
        if (!this.layoutManager.running) {
27,750✔
154
            // Even if layout is not running, we still need to:
155
            // 1. Update edges (for manual node dragging)
156
            // 2. Handle zoom if requested
157

158
            // Always update edges to handle manual node dragging
159
            // Edges have built-in dirty tracking, so they won't do unnecessary work
160
            this.updateEdges();
17,751✔
161

162
            // Handle zoom to fit if requested
163
            if (this.needsZoomToFit && !this.hasZoomedToFit) {
17,751✔
164
                // Check if we have nodes to calculate bounds from
165
                const nodeCount = Array.from(this.layoutManager.nodes).length;
417✔
166
                if (nodeCount > 0) {
417✔
167
                    // Calculate bounding box and update nodes
168
                    const {boundingBoxMin, boundingBoxMax} = this.updateNodes();
417✔
169

170
                    // Update edges (also expands bounding box for edge labels)
171
                    this.updateEdges(boundingBoxMin, boundingBoxMax);
417✔
172

173
                    // Handle zoom to fit
174
                    this.handleZoomToFit(boundingBoxMin, boundingBoxMax);
417✔
175

176
                    // Update statistics
177
                    this.updateStatistics();
417✔
178
                }
417✔
179
            }
417✔
180

181
            return;
17,751✔
182
        }
17,751✔
183

184
        // Update layout engine (step the force-directed algorithm)
185
        this.updateLayout();
9,999✔
186

187
        // Update nodes and edges
188
        const {boundingBoxMin, boundingBoxMax} = this.updateNodes();
9,999✔
189

190
        // Update edges (also expands bounding box for edge labels)
191
        this.updateEdges(boundingBoxMin, boundingBoxMax);
9,999✔
192

193
        // Handle zoom to fit if needed
194
        this.handleZoomToFit(boundingBoxMin, boundingBoxMax);
9,999✔
195

196
        // Update statistics
197
        this.updateStatistics();
9,999✔
198
    }
27,750✔
199

200
    /**
201
     * Update the layout engine
202
     */
203
    private updateLayout(): void {
14✔
204
        this.statsManager.step();
9,999✔
205
        this.statsManager.graphStep.beginMonitoring();
9,999✔
206

207
        const {stepMultiplier} = this.styleManager.getStyles().config.behavior.layout;
9,999✔
208
        for (let i = 0; i < stepMultiplier; i++) {
9,999✔
209
            this.layoutManager.step();
9,999✔
210
            this.layoutStepCount++;
9,999✔
211
        }
9,999✔
212

213
        this.statsManager.graphStep.endMonitoring();
9,999✔
214
    }
9,999✔
215

216
    /**
217
     * Update all nodes and calculate bounding box
218
     * @returns Object containing minimum and maximum bounding box vectors
219
     */
220
    private updateNodes(): {boundingBoxMin?: Vector3, boundingBoxMax?: Vector3} {
14✔
221
        let boundingBoxMin: Vector3 | undefined;
10,416✔
222
        let boundingBoxMax: Vector3 | undefined;
10,416✔
223

224
        this.statsManager.nodeUpdate.beginMonitoring();
10,416✔
225

226
        for (const node of this.layoutManager.nodes) {
10,416✔
227
            node.update();
140,038✔
228

229
            // The mesh position is already updated by node.update()
230

231
            // Update bounding box
232
            const pos = node.mesh.getAbsolutePosition();
140,038✔
233
            const sz = node.size;
140,038✔
234

235
            if (!boundingBoxMin || !boundingBoxMax) {
140,038✔
236
                boundingBoxMin = pos.clone();
7,870✔
237
                boundingBoxMax = pos.clone();
7,870✔
238
            }
7,870✔
239

240
            this.updateBoundingBoxAxis(pos, boundingBoxMin, boundingBoxMax, sz, "x");
140,038✔
241
            this.updateBoundingBoxAxis(pos, boundingBoxMin, boundingBoxMax, sz, "y");
140,038✔
242
            this.updateBoundingBoxAxis(pos, boundingBoxMin, boundingBoxMax, sz, "z");
140,038✔
243

244
            // Include node label in bounding box
245
            if (node.label?.labelMesh) {
140,038!
246
                this.expandBoundingBoxForLabel(node.label.labelMesh, boundingBoxMin, boundingBoxMax);
44,374✔
247
            }
44,374✔
248
        }
140,038✔
249

250
        this.statsManager.nodeUpdate.endMonitoring();
10,416✔
251

252
        return {boundingBoxMin, boundingBoxMax};
10,416✔
253
    }
10,416✔
254

255
    /**
256
     * Update bounding box for a single axis
257
     * @param pos - Position vector
258
     * @param min - Minimum bounds vector
259
     * @param max - Maximum bounds vector
260
     * @param size - Node size
261
     * @param axis - Axis to update (x, y, or z)
262
     */
263
    private updateBoundingBoxAxis(
14✔
264
        pos: Vector3,
420,114✔
265
        min: Vector3,
420,114✔
266
        max: Vector3,
420,114✔
267
        size: number,
420,114✔
268
        axis: "x" | "y" | "z",
420,114✔
269
    ): void {
420,114✔
270
        const value = pos[axis];
420,114✔
271
        const halfSize = size / 2;
420,114✔
272

273
        min[axis] = Math.min(min[axis], value - halfSize);
420,114✔
274
        max[axis] = Math.max(max[axis], value + halfSize);
420,114✔
275
    }
420,114✔
276

277
    /**
278
     * Expand bounding box to include a label mesh
279
     * @param labelMesh - The label mesh to include
280
     * @param min - Minimum bounds vector
281
     * @param max - Maximum bounds vector
282
     */
283
    private expandBoundingBoxForLabel(labelMesh: Mesh, min: Vector3, max: Vector3): void {
14✔
284
        const labelBoundingInfo = labelMesh.getBoundingInfo();
44,472✔
285
        const labelMin = labelBoundingInfo.boundingBox.minimumWorld;
44,472✔
286
        const labelMax = labelBoundingInfo.boundingBox.maximumWorld;
44,472✔
287

288
        min.x = Math.min(min.x, labelMin.x);
44,472✔
289
        min.y = Math.min(min.y, labelMin.y);
44,472✔
290
        min.z = Math.min(min.z, labelMin.z);
44,472✔
291
        max.x = Math.max(max.x, labelMax.x);
44,472✔
292
        max.y = Math.max(max.y, labelMax.y);
44,472✔
293
        max.z = Math.max(max.z, labelMax.z);
44,472✔
294
    }
44,472✔
295

296
    /**
297
     * Update all edges and expand bounding box for edge labels
298
     * @param boundingBoxMin - Minimum bounds (optional)
299
     * @param boundingBoxMax - Maximum bounds (optional)
300
     */
301
    private updateEdges(boundingBoxMin?: Vector3, boundingBoxMax?: Vector3): void {
14✔
302
        this.statsManager.edgeUpdate.beginMonitoring();
28,167✔
303

304
        // Update rays for all edges (static method on Edge class)
305
        Edge.updateRays(this.graphContext);
28,167✔
306

307
        // Update individual edges
308
        for (const edge of this.layoutManager.edges) {
28,167✔
309
            edge.update();
430,236✔
310

311
            // Include edge labels in bounding box if we have one
312
            if (boundingBoxMin && boundingBoxMax) {
430,236✔
313
                // Edge label (at midpoint)
314
                if (edge.label?.labelMesh) {
232,202!
315
                    this.expandBoundingBoxForLabel(edge.label.labelMesh, boundingBoxMin, boundingBoxMax);
42✔
316
                }
42✔
317

318
                // Arrow head text label
319
                if (edge.arrowHeadText?.labelMesh) {
232,202!
320
                    this.expandBoundingBoxForLabel(edge.arrowHeadText.labelMesh, boundingBoxMin, boundingBoxMax);
54✔
321
                }
54✔
322

323
                // Arrow tail text label
324
                if (edge.arrowTailText?.labelMesh) {
232,202!
325
                    this.expandBoundingBoxForLabel(edge.arrowTailText.labelMesh, boundingBoxMin, boundingBoxMax);
2✔
326
                }
2✔
327
            }
232,202✔
328
        }
430,236✔
329

330
        this.statsManager.edgeUpdate.endMonitoring();
28,167✔
331
    }
28,167✔
332

333
    /**
334
     * Handle zoom to fit logic
335
     * @param boundingBoxMin - Minimum bounds (optional)
336
     * @param boundingBoxMax - Maximum bounds (optional)
337
     */
338
    private handleZoomToFit(boundingBoxMin?: Vector3, boundingBoxMax?: Vector3): void {
14✔
339
        if (!this.needsZoomToFit) {
10,416!
340
            return;
20✔
341
        }
20✔
342

343
        if (!boundingBoxMin || !boundingBoxMax) {
10,416✔
344
            return;
2,546✔
345
        }
2,546✔
346

347
        // Check if we should zoom:
348
        // 1. Wait for minimum steps on first zoom
349
        // 2. Zoom every N steps during layout (based on zoomStepInterval)
350
        // 3. Zoom when layout settles
351
        const isSettled = this.layoutManager.layoutEngine?.isSettled ?? false;
10,416!
352
        const {zoomStepInterval} = this.styleManager.getStyles().config.behavior.layout;
10,416✔
353
        const shouldZoomPeriodically = this.layoutStepCount > 0 &&
10,416✔
354
                                      this.layoutStepCount >= this.lastZoomStep + zoomStepInterval;
7,433✔
355
        const justSettled = isSettled && !this.wasSettled && this.layoutStepCount > 0;
10,416✔
356

357
        if (!this.hasZoomedToFit && this.layoutManager.running && this.layoutStepCount < this.minLayoutStepsBeforeZoom) {
10,416✔
358
            // First zoom - wait for minimum steps
359
            return;
1,988✔
360
        } else if (!this.layoutManager.running && !this.hasZoomedToFit && this.layoutStepCount === 0) {
7,990✔
361
            // Layout not running and no steps taken - allow immediate zoom
362
        } else if (!shouldZoomPeriodically && !justSettled) {
5,859✔
363
            // Not time for periodic zoom and didn't just settle
364
            return;
262✔
365
        }
262✔
366

367
        // Update settled state for next frame
368
        this.wasSettled = isSettled;
5,600✔
369

370
        const size = boundingBoxMax.subtract(boundingBoxMin);
5,600✔
371

372
        if (size.length() > this.config.minBoundingBoxSize) {
5,600✔
373
            this.camera.zoomToBoundingBox(boundingBoxMin, boundingBoxMax);
5,600✔
374

375
            this.hasZoomedToFit = true;
5,600✔
376
            this.lastZoomStep = this.layoutStepCount;
5,600✔
377

378
            // Only clear needsZoomToFit if layout is settled
379
            if (isSettled) {
5,600✔
380
                this.needsZoomToFit = false;
568✔
381
            }
568✔
382

383
            // Emit zoom complete event
384
            this.eventManager.emitGraphEvent("zoom-to-fit-complete", {
5,600✔
385
                boundingBoxMin,
5,600✔
386
                boundingBoxMax,
5,600✔
387
            });
5,600✔
388
        }
5,600✔
389
    }
10,416✔
390

391
    /**
392
     * Update statistics
393
     */
394
    private updateStatistics(): void {
14✔
395
        this.statsManager.updateCounts(
10,416✔
396
            this.dataManager.nodeCache.size,
10,416✔
397
            this.dataManager.edgeCache.size,
10,416✔
398
        );
10,416✔
399

400
        // Update mesh cache stats
401
        const meshCache = this.graphContext.getMeshCache();
10,416✔
402
        this.statsManager.updateCacheStats(meshCache.hits, meshCache.misses);
10,416✔
403
    }
10,416✔
404

405
    /**
406
     * Check if zoom to fit has been completed
407
     * @returns True if zoom to fit has completed at least once
408
     */
409
    get zoomToFitCompleted(): boolean {
14✔
410
        return this.hasZoomedToFit;
2✔
411
    }
2✔
412

413
    /**
414
     * Update configuration
415
     * @param config - Partial configuration to merge
416
     */
417
    updateConfig(config: Partial<UpdateManagerConfig>): void {
14✔
418
        Object.assign(this.config, config);
1✔
419
    }
1✔
420
}
14✔
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