@elicdavis/node-flow 0.1.16 → 0.1.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@elicdavis/node-flow",
3
3
  "description": "build node graphs",
4
4
  "author": "Eli C Davis",
5
- "version": "0.1.16",
5
+ "version": "0.1.17",
6
6
  "license": "MIT",
7
7
  "keywords": [
8
8
  "nodes",
package/src/graph.ts CHANGED
@@ -10,7 +10,7 @@ import { Clamp01 } from "./utils/math";
10
10
  import { GraphSubsystem, RenderResults } from './graphSubsystem';
11
11
  import { FlowNote } from "./notes/note";
12
12
  import { NoteAddedCallback, NoteDragStartCallback, NoteDragStopCallback, NoteRemovedCallback, NoteSubsystem, NoteSubsystemConfig } from "./notes/subsystem";
13
- import { ConnectionRendererConfiguration, NodeAddedCallback, NodeRemovedCallback, NodeSubsystem } from "./nodes/subsystem";
13
+ import { ConnectionRendererConfiguration, DanglingConnection, InternalConnection, NodeAddedCallback, NodeRemovedCallback, NodeSubsystem } from "./nodes/subsystem";
14
14
  import { Connection } from './connection';
15
15
  import { Publisher } from './nodes/publisher';
16
16
  import { VectorPool } from './types/pool';
@@ -18,6 +18,8 @@ import { Camera, CameraOrientation } from './camera';
18
18
  export { CameraOrientation };
19
19
  import { PassSubsystem } from './pass/subsystem';
20
20
  import { QuickMenu } from './quickMenu';
21
+ import { onRenderRequested, renderContinuously, requestRender } from './render_scheduler';
22
+ import { Minimap, MinimapConfig } from './minimap';
21
23
 
22
24
  export type GraphRenderer = (canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D, position: Vector2, scale: number) => void;
23
25
 
@@ -49,6 +51,10 @@ function BuildBackgroundRenderer(backgroundColor: string): GraphRenderer {
49
51
 
50
52
  const contextMenuGroup = "graph-context-menu";
51
53
 
54
+ export type CopyCallback = (nodes: Array<FlowNode>, connections: Array<InternalConnection>) => void;
55
+
56
+ export type PasteCallback = (position: Vector2) => void;
57
+
52
58
  export interface FlowNodeGraphConfiguration {
53
59
  backgroundRenderer?: GraphRenderer;
54
60
  backgroundColor?: string;
@@ -56,6 +62,7 @@ export interface FlowNodeGraphConfiguration {
56
62
  contextMenu?: ContextMenuConfig
57
63
  nodes?: NodeFactoryConfig
58
64
  board?: NoteSubsystemConfig
65
+ minimap?: MinimapConfig
59
66
  }
60
67
 
61
68
  interface OpenContextMenu {
@@ -221,7 +228,15 @@ export class NodeFlowGraph {
221
228
  this.#backgroundRenderer = BuildBackgroundRenderer(backgroundColor);
222
229
  }
223
230
 
224
- window.requestAnimationFrame(this.#render.bind(this));
231
+ this.#minimap = new Minimap(config?.minimap);
232
+
233
+ this.#mainNodeSubsystem.setConnectionReleasedHandler((dangling) => {
234
+ this.#openScopedMenu(dangling);
235
+ });
236
+
237
+ this.#watchCanvasSize();
238
+ this.#stopRendering = onRenderRequested(this.#render.bind(this));
239
+ requestRender();
225
240
 
226
241
  new MouseObserver(this.#canvas,
227
242
  this.#mouseDragEvent.bind(this),
@@ -240,45 +255,115 @@ export class NodeFlowGraph {
240
255
  document.addEventListener(
241
256
  "keydown",
242
257
  (e) => {
243
- if (document.activeElement) {
244
- if (["INPUT", "TEXTAREA"].includes(document.activeElement.tagName)) return;
245
- }
258
+ this.#keyDown(e);
259
+ requestRender();
260
+ }
261
+ );
262
+ }
246
263
 
247
- if (this.#openQuickMenu) {
248
- if (e.code === "Escape") {
249
- this.#openQuickMenu = null;
250
- return;
251
- }
264
+ #copyCallbacks: Array<CopyCallback> = [];
252
265
 
253
- if (e.code === "Enter") {
254
- this.#openQuickMenu.Menu.execute();
255
- this.#openQuickMenu = null;
256
- return;
257
- }
266
+ #pasteCallbacks: Array<PasteCallback> = [];
258
267
 
259
- this.#openQuickMenu.Menu.keyboardEvent(e);
260
- return
261
- }
268
+ public addCopyListener(callback: CopyCallback): void {
269
+ this.#copyCallbacks.push(callback);
270
+ }
271
+
272
+ public addPasteListener(callback: PasteCallback): void {
273
+ this.#pasteCallbacks.push(callback);
274
+ }
275
+
276
+ #raiseCopy(): void {
277
+ const nodes = this.#mainNodeSubsystem.getSelectedNodes();
278
+ if (nodes.length === 0) {
279
+ return;
280
+ }
281
+
282
+ const connections = this.#mainNodeSubsystem.connectionsWithin(nodes);
283
+ this.#copyCallbacks.forEach((callback) => callback(nodes, connections));
284
+ }
285
+
286
+ #raisePaste(): void {
287
+ const position = this.#mousePosition === undefined
288
+ ? { x: 0, y: 0 }
289
+ : this.#sceenPositionToGraphPosition(this.#mousePosition);
290
+
291
+ this.#pasteCallbacks.forEach((callback) => callback(position));
292
+ }
293
+
294
+ #deleteKey(e: KeyboardEvent): boolean {
295
+ return e.code === "Delete" || e.code === "Backspace" || e.key === "Delete" || e.key === "Backspace";
296
+ }
297
+
298
+ #keyDown(e: KeyboardEvent): void {
299
+ if (document.activeElement) {
300
+ if (["INPUT", "TEXTAREA"].includes(document.activeElement.tagName)) return;
301
+ }
302
+
303
+ if (this.#deleteKey(e)) {
304
+ if (this.#mainNodeSubsystem.getSelectedNodes().length > 0) {
305
+ e.preventDefault();
306
+ this.#mainNodeSubsystem.removeSelectedNodes();
307
+ return;
308
+ }
309
+ }
310
+
311
+ if (e.ctrlKey || e.metaKey) {
312
+ const letter = e.code.startsWith("Key")
313
+ ? e.code.substring(3).toLowerCase()
314
+ : (e.key ?? "").toLowerCase();
262
315
 
263
- const spacePressed = e.key == " " || e.code == "Space";
264
- if (!spacePressed) {
316
+ switch (letter) {
317
+ case "c":
318
+ this.#raiseCopy();
265
319
  return;
266
- }
267
320
 
268
- if (!this.#mousePosition) {
321
+ case "v":
322
+ this.#raisePaste();
323
+ return;
324
+
325
+ // duplicate
326
+ case "d":
327
+ e.preventDefault();
328
+ this.#raiseCopy();
329
+ this.#raisePaste();
269
330
  return;
270
- }
271
- const contextMenuPosition = this.#sceenPositionToGraphPosition(this.#mousePosition);
272
-
273
- let items = this.#mainNodeSubsystem.nodeFactory().newNodeSubmenus(this.#mainNodeSubsystem, contextMenuPosition);
274
- this.#openQuickMenu = {
275
- Menu: new QuickMenu({
276
- subMenus: items,
277
- }),
278
- Position: contextMenuPosition
279
- }
280
331
  }
281
- );
332
+ }
333
+
334
+ if (this.#openQuickMenu) {
335
+ if (e.code === "Escape") {
336
+ this.#openQuickMenu = null;
337
+ return;
338
+ }
339
+
340
+ if (e.code === "Enter") {
341
+ this.#openQuickMenu.Menu.execute();
342
+ this.#openQuickMenu = null;
343
+ return;
344
+ }
345
+
346
+ this.#openQuickMenu.Menu.keyboardEvent(e);
347
+ return
348
+ }
349
+
350
+ const spacePressed = e.key == " " || e.code == "Space";
351
+ if (!spacePressed) {
352
+ return;
353
+ }
354
+
355
+ if (!this.#mousePosition) {
356
+ return;
357
+ }
358
+ const contextMenuPosition = this.#sceenPositionToGraphPosition(this.#mousePosition);
359
+
360
+ let items = this.#mainNodeSubsystem.nodeFactory().newNodeSubmenus(this.#mainNodeSubsystem, contextMenuPosition);
361
+ this.#openQuickMenu = {
362
+ Menu: new QuickMenu({
363
+ subMenus: items,
364
+ }),
365
+ Position: contextMenuPosition
366
+ }
282
367
  }
283
368
 
284
369
  #fileDrop(file: File): void {
@@ -362,9 +447,33 @@ export class NodeFlowGraph {
362
447
  this.#contextMenuEntryHovering = null;
363
448
 
364
449
  this.#mousePosition = mousePosition;
450
+
451
+ if (this.#minimap.contains(mousePosition)) {
452
+ this.#lookAtFromMinimap(mousePosition);
453
+ this.#draggingMinimap = true;
454
+ return;
455
+ }
456
+
365
457
  this.currentView().clickStart(mousePosition, this.#camera, ctrlKey);
366
458
  }
367
459
 
460
+ #draggingMinimap: boolean = false;
461
+
462
+ #lookAtFromMinimap(mousePosition: Vector2): void {
463
+ const target: Vector2 = { x: 0, y: 0 };
464
+ this.#minimap.graphPositionOf(mousePosition, target);
465
+
466
+ // Centre the viewport on it rather than putting it in the corner.
467
+ this.#camera.position.x = -(target.x * this.#camera.zoom) + (this.#canvas.width / 2);
468
+ this.#camera.position.y = -(target.y * this.#camera.zoom) + (this.#canvas.height / 2);
469
+ requestRender();
470
+ }
471
+
472
+ /** The minimap, so it can be turned on and sized by the host. */
473
+ public minimap(): Minimap {
474
+ return this.#minimap;
475
+ }
476
+
368
477
  currentView(): GraphView {
369
478
  return this.#views[this.#currentView];
370
479
  }
@@ -389,6 +498,14 @@ export class NodeFlowGraph {
389
498
  return this.#mainNodeSubsystem.getSelectedNodes();
390
499
  }
391
500
 
501
+ public unselectAllNodes(): void {
502
+ this.#mainNodeSubsystem.unselectAllNodes();
503
+ }
504
+
505
+ public removeSelectedNodes(): Array<FlowNode> {
506
+ return this.#mainNodeSubsystem.removeSelectedNodes();
507
+ }
508
+
392
509
  /**
393
510
  * Returns a copy of the current camera orientation (screen-space pan + zoom).
394
511
  */
@@ -533,11 +650,42 @@ export class NodeFlowGraph {
533
650
  };
534
651
  }
535
652
 
653
+ #openScopedMenu(dangling: DanglingConnection): void {
654
+ if (this.#mousePosition === undefined) {
655
+ return;
656
+ }
657
+
658
+ const position = this.#sceenPositionToGraphPosition(this.#mousePosition);
659
+ const menu = this.#mainNodeSubsystem.nodeFactory().openMenu(
660
+ this.#mainNodeSubsystem,
661
+ position,
662
+ {
663
+ needs: dangling.needs,
664
+ types: dangling.types,
665
+ onCreated: dangling.connectTo,
666
+ },
667
+ );
668
+
669
+ this.#openedContextMenu = {
670
+ Menu: new ContextMenu(menu),
671
+ Position: position,
672
+ };
673
+ requestRender();
674
+ }
675
+
536
676
  #clickEnd(): void {
677
+ this.#draggingMinimap = false;
537
678
  this.currentView().clickEnd();
538
679
  }
539
680
 
540
681
  #mouseDragEvent(delta: Vector2): void {
682
+ if (this.#draggingMinimap) {
683
+ if (this.#mousePosition !== undefined) {
684
+ this.#lookAtFromMinimap(this.#mousePosition);
685
+ }
686
+ return;
687
+ }
688
+
541
689
  let draggingSomething = this.currentView().mouseDragEvent(delta, this.#camera.zoom);
542
690
  if (!draggingSomething) {
543
691
  this.#camera.position.x += delta.x;
@@ -549,14 +697,57 @@ export class NodeFlowGraph {
549
697
 
550
698
  #cursor: CursorStyle;
551
699
 
552
- #render(): void {
553
- if (this.#canvas.parentNode !== null) {
554
- // Stupid as any because typescript doesn't think it exists
555
- var rect = (this.#canvas.parentNode as any).getBoundingClientRect();
556
- this.#canvas.width = rect.width;
557
- this.#canvas.height = rect.height;
700
+ #minimap: Minimap;
701
+
702
+ #stopRendering: () => void;
703
+
704
+ #sizeObserver: ResizeObserver | undefined;
705
+
706
+ #matchCanvasToParent(): void {
707
+ const parent = this.#canvas.parentNode as HTMLElement | null;
708
+ if (parent === null) {
709
+ return;
710
+ }
711
+
712
+ const rect = parent.getBoundingClientRect();
713
+ if (this.#canvas.width === rect.width && this.#canvas.height === rect.height) {
714
+ return;
558
715
  }
559
716
 
717
+ this.#canvas.width = rect.width;
718
+ this.#canvas.height = rect.height;
719
+ }
720
+
721
+ #watchCanvasSize(): void {
722
+ this.#matchCanvasToParent();
723
+
724
+ const parent = this.#canvas.parentNode as HTMLElement | null;
725
+ if (parent === null || typeof ResizeObserver === "undefined") {
726
+ return;
727
+ }
728
+
729
+ this.#sizeObserver = new ResizeObserver(() => {
730
+ this.#matchCanvasToParent();
731
+ requestRender();
732
+ });
733
+ this.#sizeObserver.observe(parent);
734
+ }
735
+
736
+ public requestRender(): void {
737
+ requestRender();
738
+ }
739
+
740
+ public renderContinuously(): () => void {
741
+ return renderContinuously();
742
+ }
743
+
744
+ public dispose(): void {
745
+ this.#stopRendering();
746
+ this.#sizeObserver?.disconnect();
747
+ }
748
+
749
+ #render(): void {
750
+ this.#matchCanvasToParent();
560
751
 
561
752
  this.#cursor = CursorStyle.Default;
562
753
 
@@ -569,6 +760,10 @@ export class NodeFlowGraph {
569
760
  }
570
761
  });
571
762
 
763
+ TimeExecution("Render_Minimap", () => {
764
+ this.#minimap.render(this.#ctx, this.#canvas, this.#camera, this.#mainNodeSubsystem.getNodes());
765
+ });
766
+
572
767
  TimeExecution("Render_Context", this.#renderContextMenu.bind(this));
573
768
 
574
769
  TimeExecution("Render_QuickMenu", this.#renderQuickMenu.bind(this))
@@ -579,8 +774,6 @@ export class NodeFlowGraph {
579
774
  this.#canvas.style.cursor = this.#cursor;
580
775
  }
581
776
  this.#lastFrameCursor = this.#cursor;
582
-
583
- window.requestAnimationFrame(this.#render.bind(this));
584
777
  }
585
778
 
586
779
  #renderBackground(): void {
package/src/index.ts CHANGED
@@ -26,6 +26,22 @@ export {
26
26
  import { Port, PortConfig, PortStyle, PortType, portsCompatible } from './port';
27
27
  export { Port, PortConfig, PortStyle, PortType, portsCompatible };
28
28
 
29
+ // Rendering
30
+ import { renderContinuously, requestRender } from './render_scheduler';
31
+ export { renderContinuously, requestRender };
32
+
33
+ import { Minimap, MinimapConfig } from './minimap';
34
+ export { Minimap, MinimapConfig };
35
+
36
+ import { DanglingConnection, InternalConnection } from './nodes/subsystem';
37
+ export { DanglingConnection, InternalConnection };
38
+
39
+ import { NodeMenuFilter } from './nodes/publisher';
40
+ export { NodeMenuFilter };
41
+
42
+ import { portConfigTypes, typeSetsCompatible } from './port';
43
+ export { portConfigTypes, typeSetsCompatible };
44
+
29
45
  // Connections
30
46
  import {
31
47
  Connection, ConnectionRenderer, ConnectionRendererParams, DefaultConnectionRenderer
package/src/input.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { requestRender } from "./render_scheduler";
1
2
  import { CopyVector2, Distance, SubVector2, Vector2, Zero } from "./types/vector2";
2
3
 
3
4
  const LONG_PRESS_MS = 500;
@@ -49,19 +50,25 @@ export class MouseObserver {
49
50
 
50
51
  ele.style.touchAction = "none";
51
52
 
52
- ele.addEventListener("mousedown", this.#down.bind(this), false);
53
- ele.addEventListener("touchstart", this.#touchStart.bind(this), { passive: false });
53
+ const redraws = <T extends Event>(handler: (event: T) => void) =>
54
+ (event: T): void => {
55
+ handler(event);
56
+ requestRender();
57
+ };
54
58
 
55
- document.addEventListener("mouseup", this.#up.bind(this), false);
56
- document.addEventListener("touchend", this.#touchEnd.bind(this), false);
57
- document.addEventListener("touchcancel", this.#touchEnd.bind(this), false);
59
+ ele.addEventListener("mousedown", redraws(this.#down.bind(this)), false);
60
+ ele.addEventListener("touchstart", redraws(this.#touchStart.bind(this)), { passive: false });
58
61
 
59
- ele.addEventListener("mousemove", this.#move.bind(this), false);
60
- ele.addEventListener("touchmove", this.#touchMove.bind(this), { passive: false });
62
+ document.addEventListener("mouseup", redraws(this.#up.bind(this)), false);
63
+ document.addEventListener("touchend", redraws(this.#touchEnd.bind(this)), false);
64
+ document.addEventListener("touchcancel", redraws(this.#touchEnd.bind(this)), false);
61
65
 
62
- ele.addEventListener("wheel", this.#wheel.bind(this), { passive: false });
66
+ ele.addEventListener("mousemove", redraws(this.#move.bind(this)), false);
67
+ ele.addEventListener("touchmove", redraws(this.#touchMove.bind(this)), { passive: false });
63
68
 
64
- ele.addEventListener("drop", (ev) => {
69
+ ele.addEventListener("wheel", redraws(this.#wheel.bind(this)), { passive: false });
70
+
71
+ ele.addEventListener("drop", redraws((ev: DragEvent) => {
65
72
  ev.preventDefault();
66
73
  if (ev.dataTransfer?.items) {
67
74
  [...ev.dataTransfer.items].forEach((item, i) => {
@@ -73,21 +80,21 @@ export class MouseObserver {
73
80
  }
74
81
  });
75
82
  }
76
- });
83
+ }));
77
84
 
78
- ele.addEventListener("dragover", (ev) => {
85
+ ele.addEventListener("dragover", redraws((ev: DragEvent) => {
79
86
  ev.preventDefault();
80
87
  this.#moveCallback(this.#mousePos(ev));
81
- });
88
+ }));
82
89
 
83
- ele.addEventListener("contextmenu", (evt) => {
90
+ ele.addEventListener("contextmenu", redraws((evt: MouseEvent) => {
84
91
  evt.preventDefault();
85
92
  // Touch uses long-press; ignore synthetic browser contextmenu after touch.
86
93
  if (Date.now() - this.#lastTouchAt < SUPPRESS_MOUSE_MS) {
87
94
  return;
88
95
  }
89
96
  this.#contextMenu(this.#mousePos(evt));
90
- }, false);
97
+ }), false);
91
98
  }
92
99
 
93
100
  #mousePos(event: MouseEvent | DragEvent | WheelEvent): Vector2 {
@@ -198,6 +205,7 @@ export class MouseObserver {
198
205
  this.#clicked = false;
199
206
  this.#clickStop();
200
207
  this.#contextMenu({ x, y });
208
+ requestRender();
201
209
  }, LONG_PRESS_MS);
202
210
 
203
211
  this.#clicked = true;
@@ -1,7 +1,8 @@
1
+ import { requestRender } from "../render_scheduler";
1
2
  import { Theme } from "../theme";
2
3
  import { List } from "../types/list";
3
4
  import { Text } from "../types/text";
4
- import { CopyVector2, Vector2, Zero } from "../types/vector2";
5
+ import { Vector2, Zero } from "../types/vector2";
5
6
 
6
7
  export interface MarkdownEntry {
7
8
  render(ctx: CanvasRenderingContext2D, position: Vector2, scale: number, maxWidth: number): number
@@ -11,7 +12,10 @@ function onFontsLoaded(callback: () => void): void {
11
12
  if (typeof document === "undefined" || document.fonts === undefined) {
12
13
  return;
13
14
  }
14
- document.fonts.addEventListener("loadingdone", () => callback());
15
+ document.fonts.addEventListener("loadingdone", () => {
16
+ callback();
17
+ requestRender();
18
+ });
15
19
  }
16
20
 
17
21
  export class CodeBlockEntry {
package/src/minimap.ts ADDED
@@ -0,0 +1,178 @@
1
+ import { Camera } from "./camera";
2
+ import { FlowNode } from "./node";
3
+ import { Theme } from "./theme";
4
+ import { Box, InBox } from "./types/box";
5
+ import { Vector2 } from "./types/vector2";
6
+
7
+ export interface MinimapConfig {
8
+ enabled?: boolean;
9
+ width?: number;
10
+ height?: number;
11
+ margin?: number;
12
+
13
+ /** Overrides Theme.Minimap.BackgroundColor for this minimap alone. */
14
+ backgroundColor?: string;
15
+
16
+ /** Overrides Theme.Minimap.BorderColor for this minimap alone. */
17
+ borderColor?: string;
18
+
19
+ /** Overrides Theme.Minimap.NodeColor for this minimap alone. */
20
+ nodeColor?: string;
21
+
22
+ /** Overrides Theme.Minimap.ViewportColor for this minimap alone. */
23
+ viewportColor?: string;
24
+ }
25
+
26
+ export class Minimap {
27
+
28
+ #enabled: boolean;
29
+
30
+ #width: number;
31
+
32
+ #height: number;
33
+
34
+ #margin: number;
35
+
36
+ #backgroundColor?: string;
37
+
38
+ #borderColor?: string;
39
+
40
+ #nodeColor?: string;
41
+
42
+ #viewportColor?: string;
43
+
44
+ #box: Box = { Position: { x: 0, y: 0 }, Size: { x: 0, y: 0 } };
45
+
46
+ #scale: number = 1;
47
+
48
+ #originX: number = 0;
49
+
50
+ #originY: number = 0;
51
+
52
+ // The padding the last frame was laid out with, so a click maps back
53
+ // through the same geometry it was drawn with.
54
+ #padding: number = Theme.Minimap.Padding;
55
+
56
+ #drawn: boolean = false;
57
+
58
+ constructor(config?: MinimapConfig) {
59
+ this.#enabled = config?.enabled === true;
60
+ this.#width = config?.width === undefined ? 200 : config.width;
61
+ this.#height = config?.height === undefined ? 150 : config.height;
62
+ this.#margin = config?.margin === undefined ? 16 : config.margin;
63
+ this.#backgroundColor = config?.backgroundColor;
64
+ this.#borderColor = config?.borderColor;
65
+ this.#nodeColor = config?.nodeColor;
66
+ this.#viewportColor = config?.viewportColor;
67
+ }
68
+
69
+ enabled(): boolean {
70
+ return this.#enabled;
71
+ }
72
+
73
+ setEnabled(enabled: boolean): void {
74
+ this.#enabled = enabled;
75
+ this.#drawn = false;
76
+ }
77
+
78
+ contains(position: Vector2): boolean {
79
+ return this.#enabled && this.#drawn && InBox(this.#box, position);
80
+ }
81
+
82
+ graphPositionOf(position: Vector2, out: Vector2): void {
83
+ out.x = (position.x - this.#box.Position.x - this.#padding) / this.#scale + this.#originX;
84
+ out.y = (position.y - this.#box.Position.y - this.#padding) / this.#scale + this.#originY;
85
+ }
86
+
87
+ render(ctx: CanvasRenderingContext2D, canvas: HTMLCanvasElement, camera: Camera, nodes: Array<FlowNode>): void {
88
+ if (!this.#enabled) {
89
+ this.#drawn = false;
90
+ return;
91
+ }
92
+
93
+ // Bounds are taken with a fresh camera so they come back in graph
94
+ // space rather than wherever the real camera happens to be looking.
95
+ const flat = new Camera();
96
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
97
+
98
+ const boxes: Array<Box> = [];
99
+ for (let i = 0; i < nodes.length; i++) {
100
+ const bounds = nodes[i].calculateBounds(ctx, flat);
101
+ boxes.push(bounds);
102
+ minX = Math.min(minX, bounds.Position.x);
103
+ minY = Math.min(minY, bounds.Position.y);
104
+ maxX = Math.max(maxX, bounds.Position.x + bounds.Size.x);
105
+ maxY = Math.max(maxY, bounds.Position.y + bounds.Size.y);
106
+ }
107
+
108
+ // What the camera can currently see, so the graph never scales out
109
+ // from under the viewport box.
110
+ const topLeft: Vector2 = { x: 0, y: 0 };
111
+ const bottomRight: Vector2 = { x: 0, y: 0 };
112
+ camera.screenSpaceToGraphSpace({ x: 0, y: 0 }, topLeft);
113
+ camera.screenSpaceToGraphSpace({ x: canvas.width, y: canvas.height }, bottomRight);
114
+
115
+ minX = Math.min(minX, topLeft.x);
116
+ minY = Math.min(minY, topLeft.y);
117
+ maxX = Math.max(maxX, bottomRight.x);
118
+ maxY = Math.max(maxY, bottomRight.y);
119
+
120
+ if (!isFinite(minX) || maxX <= minX || maxY <= minY) {
121
+ this.#drawn = false;
122
+ return;
123
+ }
124
+
125
+ this.#box.Size.x = this.#width;
126
+ this.#box.Size.y = this.#height;
127
+ this.#box.Position.x = canvas.width - this.#width - this.#margin;
128
+ this.#box.Position.y = canvas.height - this.#height - this.#margin;
129
+
130
+ this.#padding = Theme.Minimap.Padding;
131
+
132
+ const usableWidth = this.#width - (this.#padding * 2);
133
+ const usableHeight = this.#height - (this.#padding * 2);
134
+ this.#scale = Math.min(usableWidth / (maxX - minX), usableHeight / (maxY - minY));
135
+ this.#originX = minX;
136
+ this.#originY = minY;
137
+
138
+ const left = this.#box.Position.x + this.#padding;
139
+ const top = this.#box.Position.y + this.#padding;
140
+ const toMapX = (x: number) => left + ((x - minX) * this.#scale);
141
+ const toMapY = (y: number) => top + ((y - minY) * this.#scale);
142
+
143
+ ctx.fillStyle = this.#backgroundColor ?? Theme.Minimap.BackgroundColor;
144
+ ctx.strokeStyle = this.#borderColor ?? Theme.Minimap.BorderColor;
145
+ ctx.lineWidth = 1;
146
+ ctx.beginPath();
147
+ ctx.roundRect(
148
+ this.#box.Position.x,
149
+ this.#box.Position.y,
150
+ this.#width,
151
+ this.#height,
152
+ Theme.Minimap.BorderRadius,
153
+ );
154
+ ctx.fill();
155
+ ctx.stroke();
156
+
157
+ ctx.fillStyle = this.#nodeColor ?? Theme.Minimap.NodeColor;
158
+ for (let i = 0; i < boxes.length; i++) {
159
+ const b = boxes[i];
160
+ ctx.fillRect(
161
+ toMapX(b.Position.x),
162
+ toMapY(b.Position.y),
163
+ Math.max(1, b.Size.x * this.#scale),
164
+ Math.max(1, b.Size.y * this.#scale),
165
+ );
166
+ }
167
+
168
+ ctx.fillStyle = this.#viewportColor ?? Theme.Minimap.ViewportColor;
169
+ ctx.fillRect(
170
+ toMapX(topLeft.x),
171
+ toMapY(topLeft.y),
172
+ Math.max(1, (bottomRight.x - topLeft.x) * this.#scale),
173
+ Math.max(1, (bottomRight.y - topLeft.y) * this.#scale),
174
+ );
175
+
176
+ this.#drawn = true;
177
+ }
178
+ }