@elicdavis/node-flow 0.1.12 → 0.1.14

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.12",
5
+ "version": "0.1.14",
6
6
  "license": "MIT",
7
7
  "keywords": [
8
8
  "nodes",
package/src/camera.ts CHANGED
@@ -1,5 +1,11 @@
1
1
  import { Vector2, Zero } from "./types/vector2";
2
2
 
3
+ /** Serializable camera orientation for save/restore. */
4
+ export interface CameraOrientation {
5
+ position: Vector2;
6
+ zoom: number;
7
+ }
8
+
3
9
  export class Camera {
4
10
 
5
11
  zoom: number;
@@ -22,9 +28,25 @@ export class Camera {
22
28
  out.y = this.position.y + (graphPosition.y * this.zoom);
23
29
  }
24
30
 
31
+ getOrientation(): CameraOrientation {
32
+ return {
33
+ position: {
34
+ x: this.position.x,
35
+ y: this.position.y,
36
+ },
37
+ zoom: this.zoom,
38
+ };
39
+ }
40
+
41
+ setOrientation(orientation: CameraOrientation): void {
42
+ this.position.x = orientation.position.x;
43
+ this.position.y = orientation.position.y;
44
+ this.zoom = orientation.zoom;
45
+ }
46
+
25
47
  reset(): void {
26
48
  this.zoom = 1;
27
49
  this.position.x = 0;
28
50
  this.position.y = 0;
29
51
  }
30
- }
52
+ }
package/src/connection.ts CHANGED
@@ -2,6 +2,7 @@ import { FlowNode } from "./node";
2
2
  import { Port } from "./port";
3
3
  import { BoxCenter, InBox } from "./types/box";
4
4
  import { Vector2, Zero } from "./types/vector2";
5
+ import { Clamp01 } from "./utils/math";
5
6
 
6
7
  export interface ConnectionRendererParams {
7
8
  ctx: CanvasRenderingContext2D;
@@ -18,6 +19,16 @@ export interface ConnectionRendererParams {
18
19
 
19
20
  export type ConnectionRenderer = (params: ConnectionRendererParams) => void
20
21
 
22
+ /** Horizontal stub (screen px) at full strength on each port when output is right of input. */
23
+ const BACKWARD_PORT_STUB = 30;
24
+ /** Screen px of output-over-input overlap over which stubs ramp from 0 to full. */
25
+ const BACKWARD_STUB_RAMP = 80;
26
+
27
+ function smoothstep(t: number): number {
28
+ const x = Clamp01(t);
29
+ return x * x * (3 - 2 * x);
30
+ }
31
+
21
32
  export function DefaultConnectionRenderer(connectionSize: number, connectionColor: string | undefined, mouseOverSize: number, mouseOverColor: string | undefined): ConnectionRenderer {
22
33
  return (params: ConnectionRendererParams) => {
23
34
 
@@ -47,11 +58,29 @@ export function DefaultConnectionRenderer(connectionSize: number, connectionColo
47
58
  params.ctx.strokeStyle = color;
48
59
  params.ctx.lineWidth = lineSize;
49
60
 
50
- // Draw
61
+ // start = input port, end = output port
62
+ const inX = params.start.x;
63
+ const inY = params.start.y;
64
+ const outX = params.end.x;
65
+ const outY = params.end.y;
66
+
67
+ const overlap = outX - inX;
68
+ const fullStub = BACKWARD_PORT_STUB * params.graphScale;
69
+ const ramp = BACKWARD_STUB_RAMP * params.graphScale;
70
+ const stub = overlap <= 0 ? 0 : fullStub * smoothstep(overlap / ramp);
71
+ const curveStartX = inX - stub;
72
+ const curveEndX = outX + stub;
73
+
51
74
  params.ctx.beginPath();
52
- params.ctx.moveTo(params.start.x, params.start.y);
53
- const midX = (params.start.x + params.end.x) / 2;
54
- params.ctx.bezierCurveTo(midX, params.start.y, midX, params.end.y, params.end.x, params.end.y);
75
+ params.ctx.moveTo(inX, inY);
76
+ if (stub > 0) {
77
+ params.ctx.lineTo(curveStartX, inY);
78
+ }
79
+ const midX = (curveStartX + curveEndX) / 2;
80
+ params.ctx.bezierCurveTo(midX, inY, midX, outY, curveEndX, outY);
81
+ if (stub > 0) {
82
+ params.ctx.lineTo(outX, outY);
83
+ }
55
84
  params.ctx.stroke();
56
85
  params.ctx.shadowBlur = 0;
57
86
  }
@@ -8,11 +8,24 @@ import { CopyVector2, Vector2, Zero } from './types/vector2';
8
8
  const contextEntryHeight = 30;
9
9
  const contextEntryWidth = 250;
10
10
 
11
+ export enum ContextMenuItemState {
12
+ Enabled = 0,
13
+ Disabled = 1,
14
+ Hidden = 2,
15
+ }
16
+
11
17
  export interface ContextMenuItemConfig {
12
18
  name?: string;
13
19
  textStyle?: TextStyleConfig;
14
20
  group?: string;
15
21
 
22
+ /**
23
+ * Evaluated when the menu is opened. Defaults to Enabled when omitted.
24
+ * Hidden items are omitted from the menu; Disabled items are shown muted
25
+ * and are not clickable.
26
+ */
27
+ enabled?: () => ContextMenuItemState;
28
+
16
29
  callback?: () => void;
17
30
  }
18
31
 
@@ -33,6 +46,8 @@ export class ContextMenuItem {
33
46
 
34
47
  #textStyle: TextStyle;
35
48
 
49
+ #state: ContextMenuItemState;
50
+
36
51
  public group?: string;
37
52
 
38
53
  constructor(config?: ContextMenuItemConfig) {
@@ -40,14 +55,21 @@ export class ContextMenuItem {
40
55
  this.#callback = config?.callback;
41
56
  this.#textStyle = new TextStyle(config?.textStyle);
42
57
  this.group = config?.group;
58
+ this.#state = config?.enabled === undefined
59
+ ? ContextMenuItemState.Enabled
60
+ : config.enabled();
43
61
  }
44
62
 
45
63
  getName(): string {
46
64
  return this.#name;
47
65
  }
48
66
 
67
+ state(): ContextMenuItemState {
68
+ return this.#state;
69
+ }
70
+
49
71
  execute(): void {
50
- if (this.#callback === undefined) {
72
+ if (this.#state !== ContextMenuItemState.Enabled || this.#callback === undefined) {
51
73
  return;
52
74
  }
53
75
  this.#callback();
@@ -136,7 +158,11 @@ export class ContextMenu {
136
158
 
137
159
  if (config?.items !== undefined) {
138
160
  for (let i = 0; i < config?.items.length; i++) {
139
- this.#items.push(new ContextMenuItem(config?.items[i]));
161
+ const item = new ContextMenuItem(config?.items[i]);
162
+ if (item.state() === ContextMenuItemState.Hidden) {
163
+ continue;
164
+ }
165
+ this.#items.push(item);
140
166
  }
141
167
  }
142
168
 
@@ -312,7 +338,10 @@ export class ContextMenu {
312
338
  }
313
339
  }
314
340
 
315
- if (entryMousedOver || (this.#openSubMenu !== undefined && entry.subMenu === this.#openSubMenu)) {
341
+ const itemDisabled = entry.item !== undefined
342
+ && entry.item.state() === ContextMenuItemState.Disabled;
343
+
344
+ if (!itemDisabled && (entryMousedOver || (this.#openSubMenu !== undefined && entry.subMenu === this.#openSubMenu))) {
316
345
  ctx.fillStyle = Theme.ContextMenu.HighlightColor;
317
346
  ctx.beginPath();
318
347
  ctx.roundRect(
@@ -326,6 +355,9 @@ export class ContextMenu {
326
355
  }
327
356
 
328
357
  this.#textStyle.setupStyle(ctx, menuScale);
358
+ if (itemDisabled) {
359
+ ctx.fillStyle = Theme.ContextMenu.DisabledFontColor;
360
+ }
329
361
  ctx.fillText(entry.text, position.x + (scaledEntryHeight / 5), this.#tempBox.Position.y + (scaledEntryHeight / 2))
330
362
 
331
363
  // Render arrows
package/src/graph.ts CHANGED
@@ -14,7 +14,8 @@ import { ConnectionRendererConfiguration, NodeAddedCallback, NodeRemovedCallback
14
14
  import { Connection } from './connection';
15
15
  import { Publisher } from './nodes/publisher';
16
16
  import { VectorPool } from './types/pool';
17
- import { Camera } from './camera';
17
+ import { Camera, CameraOrientation } from './camera';
18
+ export { CameraOrientation };
18
19
  import { PassSubsystem } from './pass/subsystem';
19
20
  import { QuickMenu } from './quickMenu';
20
21
 
@@ -195,9 +196,9 @@ export class NodeFlowGraph {
195
196
  this.#contextMenuConfig = CombineContextMenus({
196
197
  items: [
197
198
  {
198
- name: "Reset View",
199
+ name: "Center on Graph",
199
200
  group: contextMenuGroup,
200
- callback: this.#camera.reset.bind(this.#camera)
201
+ callback: this.centerOnGraph.bind(this)
201
202
  },
202
203
  ],
203
204
  }, config?.contextMenu);
@@ -222,23 +223,18 @@ export class NodeFlowGraph {
222
223
 
223
224
  window.requestAnimationFrame(this.#render.bind(this));
224
225
 
225
- this.#canvas.addEventListener('wheel', (event) => {
226
- event.preventDefault();
227
- this.zoom(Math.sign(event.deltaY));
228
- }, false);
229
-
230
226
  new MouseObserver(this.#canvas,
231
227
  this.#mouseDragEvent.bind(this),
232
-
233
- // Mouse move event
234
228
  (mousePosition) => {
235
229
  this.#mousePosition = mousePosition;
236
230
  },
237
-
238
231
  this.#clickStart.bind(this),
239
232
  this.#clickEnd.bind(this),
240
233
  this.#openContextMenu.bind(this),
241
- this.#fileDrop.bind(this)
234
+ this.#fileDrop.bind(this),
235
+ (amount, anchor) => {
236
+ this.zoom(amount, anchor);
237
+ }
242
238
  );
243
239
 
244
240
  document.addEventListener(
@@ -331,20 +327,25 @@ export class NodeFlowGraph {
331
327
  }
332
328
 
333
329
 
334
- zoom(amount: number): void {
330
+ zoom(amount: number, anchor?: Vector2): void {
331
+ const anchorPos = anchor ?? this.#mousePosition;
335
332
 
336
333
  let oldPos: Vector2 | undefined = undefined;
337
- if (this.#mousePosition) {
338
- oldPos = this.#sceenPositionToGraphPosition(this.#mousePosition);;
334
+ if (anchorPos !== undefined) {
335
+ oldPos = this.#sceenPositionToGraphPosition(anchorPos);
339
336
  }
340
337
 
341
338
  this.#camera.zoom += amount * this.#camera.zoom * 0.05;
342
339
 
343
- if (!oldPos || !this.#mousePosition) {
340
+ if (!oldPos || anchorPos === undefined) {
344
341
  return;
345
342
  }
346
- // Attempt zoom around where the current mouse is.
347
- const newPos = this.#sceenPositionToGraphPosition(this.#mousePosition);
343
+
344
+ if (anchor !== undefined) {
345
+ this.#mousePosition = anchor;
346
+ }
347
+
348
+ const newPos = this.#sceenPositionToGraphPosition(anchorPos);
348
349
  this.#camera.position.x += (newPos.x - oldPos.x) * this.#camera.zoom;
349
350
  this.#camera.position.y += (newPos.y - oldPos.y) * this.#camera.zoom;
350
351
  }
@@ -380,6 +381,84 @@ export class NodeFlowGraph {
380
381
  return this.#mainNodeSubsystem.getNodes();
381
382
  }
382
383
 
384
+ /**
385
+ * Returns currently selected nodes in stable graph order (same selection
386
+ * state used by Ctrl+click and box-select styling via FlowNode.selected()).
387
+ */
388
+ getSelectedNodes(): Array<FlowNode> {
389
+ return this.#mainNodeSubsystem.getSelectedNodes();
390
+ }
391
+
392
+ /**
393
+ * Returns a copy of the current camera orientation (screen-space pan + zoom).
394
+ */
395
+ getCamera(): CameraOrientation {
396
+ return this.#camera.getOrientation();
397
+ }
398
+
399
+ /**
400
+ * Restores a previously saved camera orientation.
401
+ */
402
+ setCamera(orientation: CameraOrientation): void {
403
+ this.#camera.setOrientation(orientation);
404
+ }
405
+
406
+ /**
407
+ * Adjusts camera position and zoom so all nodes fit in view (~10% padding).
408
+ * Falls back to camera.reset() when there are no nodes.
409
+ */
410
+ centerOnGraph(): void {
411
+ const nodes = this.getNodes();
412
+ if (nodes.length === 0) {
413
+ this.#camera.reset();
414
+ return;
415
+ }
416
+
417
+ // Identity camera so calculateBounds returns graph-space sizes.
418
+ const measureCamera = new Camera();
419
+ let minX = Infinity;
420
+ let minY = Infinity;
421
+ let maxX = -Infinity;
422
+ let maxY = -Infinity;
423
+
424
+ for (let i = 0; i < nodes.length; i++) {
425
+ const bounds = nodes[i].calculateBounds(this.#ctx, measureCamera);
426
+ minX = Math.min(minX, bounds.Position.x);
427
+ minY = Math.min(minY, bounds.Position.y);
428
+ maxX = Math.max(maxX, bounds.Position.x + bounds.Size.x);
429
+ maxY = Math.max(maxY, bounds.Position.y + bounds.Size.y);
430
+ }
431
+
432
+ let width = maxX - minX;
433
+ let height = maxY - minY;
434
+ if (width <= 0 || height <= 0) {
435
+ this.#camera.reset();
436
+ return;
437
+ }
438
+
439
+ const padX = width * 0.05;
440
+ const padY = height * 0.05;
441
+ minX -= padX;
442
+ minY -= padY;
443
+ width += padX * 2;
444
+ height += padY * 2;
445
+
446
+ const canvasWidth = this.#canvas.clientWidth;
447
+ const canvasHeight = this.#canvas.clientHeight;
448
+ if (canvasWidth <= 0 || canvasHeight <= 0) {
449
+ this.#camera.reset();
450
+ return;
451
+ }
452
+
453
+ const zoom = Math.min(canvasWidth / width, canvasHeight / height);
454
+ const centerX = minX + width / 2;
455
+ const centerY = minY + height / 2;
456
+
457
+ this.#camera.zoom = zoom;
458
+ this.#camera.position.x = canvasWidth / 2 - centerX * zoom;
459
+ this.#camera.position.y = canvasHeight / 2 - centerY * zoom;
460
+ }
461
+
383
462
  connectedInputsNodeReferences(nodeIndex: number): Array<FlowNode> {
384
463
  return this.#mainNodeSubsystem.connectedInputsNodeReferencesByIndex(nodeIndex);
385
464
  }
@@ -447,26 +526,6 @@ export class NodeFlowGraph {
447
526
  };
448
527
  }
449
528
 
450
-
451
- // Somethings wrong here. Needs more testing
452
- // #fitView(): void {
453
- // if (this.#nodes.length === 0) {
454
- // return;
455
- // }
456
-
457
- // const curBox: Box = {
458
- // Position: { x: 0, y: 0 },
459
- // Size: { x: 0, y: 0 }
460
- // };
461
- // CopyBox(curBox, this.#nodes[0].calculateBounds(this.#ctx, this.#graphState.position, this.#graphState.scale));
462
-
463
- // for (let i = 1; i < this.#nodes.length; i++) {
464
- // ExpandBox(curBox, this.#nodes[i].calculateBounds(this.#ctx, this.#graphState.position, this.#graphState.scale));
465
- // }
466
-
467
- // CopyVector2(this.#graphState.position, curBox.Position);
468
- // }
469
-
470
529
  #clickEnd(): void {
471
530
  this.currentView().clickEnd();
472
531
  }
package/src/index.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  export * from './graph';
2
2
  export * from './node';
3
3
 
4
+ import { ContextMenuConfig, ContextMenuItemConfig, ContextMenuItemState } from './contextMenu';
5
+ export { ContextMenuConfig, ContextMenuItemConfig, ContextMenuItemState };
6
+
4
7
  import { Theme } from './theme';
5
8
  export { Theme };
6
9