@3plate/graph-core 0.1.6 → 0.1.9

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/dist/index.js CHANGED
@@ -1338,15 +1338,16 @@ var Layout = class _Layout {
1338
1338
 
1339
1339
  // src/canvas/marker.tsx
1340
1340
  import { jsx } from "jsx-dom/jsx-runtime";
1341
- function arrow(size, reverse = false) {
1341
+ function arrow(size, reverse = false, prefix = "") {
1342
1342
  const h = size / 1.5;
1343
1343
  const w = size;
1344
1344
  const ry = h / 2;
1345
1345
  const suffix = reverse ? "-reverse" : "";
1346
+ const id = prefix ? `${prefix}-g3p-marker-arrow${suffix}` : `g3p-marker-arrow${suffix}`;
1346
1347
  return /* @__PURE__ */ jsx(
1347
1348
  "marker",
1348
1349
  {
1349
- id: `g3p-marker-arrow${suffix}`,
1350
+ id,
1350
1351
  className: "g3p-marker g3p-marker-arrow",
1351
1352
  markerWidth: size,
1352
1353
  markerHeight: size,
@@ -1358,14 +1359,15 @@ function arrow(size, reverse = false) {
1358
1359
  }
1359
1360
  );
1360
1361
  }
1361
- function circle(size, reverse = false) {
1362
+ function circle(size, reverse = false, prefix = "") {
1362
1363
  const r = size / 3;
1363
1364
  const cy = size / 2;
1364
1365
  const suffix = reverse ? "-reverse" : "";
1366
+ const id = prefix ? `${prefix}-g3p-marker-circle${suffix}` : `g3p-marker-circle${suffix}`;
1365
1367
  return /* @__PURE__ */ jsx(
1366
1368
  "marker",
1367
1369
  {
1368
- id: `g3p-marker-circle${suffix}`,
1370
+ id,
1369
1371
  className: "g3p-marker g3p-marker-circle",
1370
1372
  markerWidth: size,
1371
1373
  markerHeight: size,
@@ -1377,15 +1379,16 @@ function circle(size, reverse = false) {
1377
1379
  }
1378
1380
  );
1379
1381
  }
1380
- function diamond(size, reverse = false) {
1382
+ function diamond(size, reverse = false, prefix = "") {
1381
1383
  const w = size * 0.7;
1382
1384
  const h = size / 2;
1383
1385
  const cy = size / 2;
1384
1386
  const suffix = reverse ? "-reverse" : "";
1387
+ const id = prefix ? `${prefix}-g3p-marker-diamond${suffix}` : `g3p-marker-diamond${suffix}`;
1385
1388
  return /* @__PURE__ */ jsx(
1386
1389
  "marker",
1387
1390
  {
1388
- id: `g3p-marker-diamond${suffix}`,
1391
+ id,
1389
1392
  className: "g3p-marker g3p-marker-diamond",
1390
1393
  markerWidth: size,
1391
1394
  markerHeight: size,
@@ -1397,14 +1400,15 @@ function diamond(size, reverse = false) {
1397
1400
  }
1398
1401
  );
1399
1402
  }
1400
- function bar(size, reverse = false) {
1403
+ function bar(size, reverse = false, prefix = "") {
1401
1404
  const h = size * 0.6;
1402
1405
  const cy = size / 2;
1403
1406
  const suffix = reverse ? "-reverse" : "";
1407
+ const id = prefix ? `${prefix}-g3p-marker-bar${suffix}` : `g3p-marker-bar${suffix}`;
1404
1408
  return /* @__PURE__ */ jsx(
1405
1409
  "marker",
1406
1410
  {
1407
- id: `g3p-marker-bar${suffix}`,
1411
+ id,
1408
1412
  className: "g3p-marker g3p-marker-bar",
1409
1413
  markerWidth: size,
1410
1414
  markerHeight: size,
@@ -1416,7 +1420,7 @@ function bar(size, reverse = false) {
1416
1420
  }
1417
1421
  );
1418
1422
  }
1419
- function none(size, reverse = false) {
1423
+ function none(size, reverse = false, prefix = "") {
1420
1424
  return void 0;
1421
1425
  }
1422
1426
  function normalize(data) {
@@ -2198,6 +2202,9 @@ var Seg2 = class {
2198
2202
  if (this.source.isDummy) source = void 0;
2199
2203
  if (this.target.isDummy) target = void 0;
2200
2204
  const typeClass = this.type ? `g3p-edge-type-${this.type}` : "";
2205
+ const prefix = this.canvas.markerPrefix;
2206
+ const markerStartId = source ? prefix ? `${prefix}-g3p-marker-${source}-reverse` : `g3p-marker-${source}-reverse` : void 0;
2207
+ const markerEndId = target ? prefix ? `${prefix}-g3p-marker-${target}` : `g3p-marker-${target}` : void 0;
2201
2208
  return /* @__PURE__ */ jsxs2(
2202
2209
  "g",
2203
2210
  {
@@ -2212,8 +2219,8 @@ var Seg2 = class {
2212
2219
  d: this.svg,
2213
2220
  fill: "none",
2214
2221
  className: "g3p-seg-line",
2215
- markerStart: source ? `url(#g3p-marker-${source}-reverse)` : void 0,
2216
- markerEnd: target ? `url(#g3p-marker-${target})` : void 0
2222
+ markerStart: markerStartId ? `url(#${markerStartId})` : void 0,
2223
+ markerEnd: markerEndId ? `url(#${markerEndId})` : void 0
2217
2224
  }
2218
2225
  ),
2219
2226
  /* @__PURE__ */ jsx3(
@@ -2798,6 +2805,10 @@ var Canvas = class {
2798
2805
  curNodes;
2799
2806
  curSegs;
2800
2807
  updating;
2808
+ // Unique marker ID prefix for this canvas instance
2809
+ markerPrefix;
2810
+ // Dynamic style element for this instance (for cleanup)
2811
+ dynamicStyleEl;
2801
2812
  // Pan-zoom state
2802
2813
  panScale = null;
2803
2814
  zoomControls;
@@ -2811,6 +2822,13 @@ var Canvas = class {
2811
2822
  constructor(api, options) {
2812
2823
  Object.assign(this, options);
2813
2824
  this.api = api;
2825
+ this.reset();
2826
+ this.markerPrefix = api.root.replace(/[^a-zA-Z0-9-_]/g, "-");
2827
+ this.createMeasurementContainer();
2828
+ this.createCanvasContainer();
2829
+ if (this.panZoom) this.setupPanZoom();
2830
+ }
2831
+ reset() {
2814
2832
  this.allNodes = /* @__PURE__ */ new Map();
2815
2833
  this.curNodes = /* @__PURE__ */ new Map();
2816
2834
  this.curSegs = /* @__PURE__ */ new Map();
@@ -2819,9 +2837,7 @@ var Canvas = class {
2819
2837
  this.transform = { x: 0, y: 0, scale: 1 };
2820
2838
  this.editMode = new EditMode();
2821
2839
  this.editMode.editable = this.editable;
2822
- this.createMeasurementContainer();
2823
- this.createCanvasContainer();
2824
- if (this.panZoom) this.setupPanZoom();
2840
+ if (this.group) this.group.innerHTML = "";
2825
2841
  }
2826
2842
  createMeasurementContainer() {
2827
2843
  this.measurement = document.createElement("div");
@@ -3006,12 +3022,13 @@ var Canvas = class {
3006
3022
  }
3007
3023
  generateDynamicStyles() {
3008
3024
  let css = "";
3009
- css += themeToCSS(this.theme, `.g3p-canvas-container`);
3025
+ const scope = `[data-g3p-instance="${this.markerPrefix}"]`;
3026
+ css += themeToCSS(this.theme, scope);
3010
3027
  for (const [type, vars] of Object.entries(this.nodeTypes)) {
3011
- css += themeToCSS(vars, `.g3p-node-type-${type}`, "node");
3028
+ css += themeToCSS(vars, `${scope} .g3p-node-type-${type}`, "node");
3012
3029
  }
3013
3030
  for (const [type, vars] of Object.entries(this.edgeTypes)) {
3014
- css += themeToCSS(vars, `.g3p-edge-type-${type}`);
3031
+ css += themeToCSS(vars, `${scope} .g3p-edge-type-${type}`);
3015
3032
  }
3016
3033
  return css;
3017
3034
  }
@@ -3024,15 +3041,18 @@ var Canvas = class {
3024
3041
  }
3025
3042
  const dynamicStyles = this.generateDynamicStyles();
3026
3043
  if (dynamicStyles) {
3027
- const dynamicStyleEl = document.createElement("style");
3028
- dynamicStyleEl.textContent = dynamicStyles;
3029
- document.head.appendChild(dynamicStyleEl);
3044
+ this.dynamicStyleEl?.remove();
3045
+ this.dynamicStyleEl = document.createElement("style");
3046
+ this.dynamicStyleEl.id = `g3p-styles-${this.markerPrefix}`;
3047
+ this.dynamicStyleEl.textContent = dynamicStyles;
3048
+ document.head.appendChild(this.dynamicStyleEl);
3030
3049
  }
3031
3050
  const colorModeClass = this.colorMode !== "system" ? `g3p-${this.colorMode}` : "";
3032
3051
  this.container = /* @__PURE__ */ jsx6(
3033
3052
  "div",
3034
3053
  {
3035
3054
  className: `g3p-canvas-container ${colorModeClass}`.trim(),
3055
+ "data-g3p-instance": this.markerPrefix,
3036
3056
  ref: (el) => this.container = el,
3037
3057
  onContextMenu: this.onContextMenu.bind(this),
3038
3058
  children: /* @__PURE__ */ jsxs5(
@@ -3048,8 +3068,8 @@ var Canvas = class {
3048
3068
  onDblClick: this.onDoubleClick.bind(this),
3049
3069
  children: [
3050
3070
  /* @__PURE__ */ jsxs5("defs", { children: [
3051
- Object.values(markerDefs).map((marker) => marker(this.markerSize, false)),
3052
- Object.values(markerDefs).map((marker) => marker(this.markerSize, true))
3071
+ Object.values(markerDefs).map((marker) => marker(this.markerSize, false, this.markerPrefix)),
3072
+ Object.values(markerDefs).map((marker) => marker(this.markerSize, true, this.markerPrefix))
3053
3073
  ] }),
3054
3074
  /* @__PURE__ */ jsx6(
3055
3075
  "g",
@@ -3406,6 +3426,40 @@ var Canvas = class {
3406
3426
  }
3407
3427
  return { type: "canvas" };
3408
3428
  }
3429
+ /** Update theme and type styles dynamically */
3430
+ updateStyles(options) {
3431
+ if (options.theme !== void 0) this.theme = options.theme;
3432
+ if (options.nodeTypes !== void 0) this.nodeTypes = options.nodeTypes;
3433
+ if (options.edgeTypes !== void 0) this.edgeTypes = options.edgeTypes;
3434
+ const dynamicStyles = this.generateDynamicStyles();
3435
+ if (dynamicStyles) {
3436
+ if (!this.dynamicStyleEl) {
3437
+ this.dynamicStyleEl = document.createElement("style");
3438
+ this.dynamicStyleEl.id = `g3p-styles-${this.markerPrefix}`;
3439
+ document.head.appendChild(this.dynamicStyleEl);
3440
+ }
3441
+ this.dynamicStyleEl.textContent = dynamicStyles;
3442
+ } else if (this.dynamicStyleEl) {
3443
+ this.dynamicStyleEl.remove();
3444
+ this.dynamicStyleEl = void 0;
3445
+ }
3446
+ }
3447
+ /** Update color mode without recreating the canvas */
3448
+ setColorMode(colorMode) {
3449
+ if (!this.container) return;
3450
+ this.colorMode = colorMode;
3451
+ this.container.classList.remove("g3p-light", "g3p-dark");
3452
+ if (colorMode !== "system") {
3453
+ this.container.classList.add(`g3p-${colorMode}`);
3454
+ }
3455
+ }
3456
+ /** Cleanup resources when the canvas is destroyed */
3457
+ destroy() {
3458
+ this.dynamicStyleEl?.remove();
3459
+ this.dynamicStyleEl = void 0;
3460
+ this.measurement?.remove();
3461
+ this.measurement = void 0;
3462
+ }
3409
3463
  };
3410
3464
  var themeVarMap = {
3411
3465
  // Canvas
@@ -3572,6 +3626,225 @@ var Updater = class _Updater {
3572
3626
  }
3573
3627
  };
3574
3628
 
3629
+ // src/api/ingest.ts
3630
+ var Ingest = class {
3631
+ constructor(api) {
3632
+ this.api = api;
3633
+ }
3634
+ /**
3635
+ * Apply an incoming ingest message to the API.
3636
+ * - snapshot: rebuild state from nodes/edges (clears prior history)
3637
+ * - update: apply incremental update
3638
+ * - history: initialize from a set of frames (clears prior history)
3639
+ */
3640
+ async apply(msg) {
3641
+ switch (msg.type) {
3642
+ case "snapshot": {
3643
+ await this.api.replaceSnapshot(msg.nodes, msg.edges, msg.description);
3644
+ break;
3645
+ }
3646
+ case "update": {
3647
+ await this.api.update((u) => {
3648
+ if (msg.addNodes) u.addNodes(...msg.addNodes);
3649
+ if (msg.removeNodes) u.deleteNodes(...msg.removeNodes);
3650
+ if (msg.updateNodes) u.updateNodes(...msg.updateNodes);
3651
+ if (msg.addEdges) u.addEdges(...msg.addEdges);
3652
+ if (msg.removeEdges) u.deleteEdges(...msg.removeEdges);
3653
+ if (msg.updateEdges) u.updateEdges(...msg.updateEdges);
3654
+ if (msg.description) u.describe(msg.description);
3655
+ });
3656
+ break;
3657
+ }
3658
+ case "history": {
3659
+ await this.api.replaceHistory(msg.frames);
3660
+ break;
3661
+ }
3662
+ }
3663
+ }
3664
+ };
3665
+
3666
+ // src/api/sources/WebSocketSource.ts
3667
+ var WebSocketSource = class {
3668
+ url;
3669
+ ws = null;
3670
+ onMessage;
3671
+ onStatus;
3672
+ reconnectMs;
3673
+ closedByUser = false;
3674
+ connectStartTime = null;
3675
+ totalTimeoutMs = 1e4;
3676
+ totalTimeoutTimer = null;
3677
+ constructor(url, onMessage, onStatus, reconnectMs = 1500) {
3678
+ this.url = url;
3679
+ this.onMessage = onMessage;
3680
+ this.onStatus = onStatus;
3681
+ this.reconnectMs = reconnectMs;
3682
+ }
3683
+ connect() {
3684
+ this.closedByUser = false;
3685
+ this.connectStartTime = Date.now();
3686
+ this.startTotalTimeout();
3687
+ this.open();
3688
+ }
3689
+ disconnect() {
3690
+ this.closedByUser = true;
3691
+ this.clearTotalTimeout();
3692
+ if (this.ws) {
3693
+ try {
3694
+ this.ws.close();
3695
+ } catch {
3696
+ }
3697
+ this.ws = null;
3698
+ }
3699
+ this.onStatus?.("closed");
3700
+ }
3701
+ startTotalTimeout() {
3702
+ this.clearTotalTimeout();
3703
+ this.totalTimeoutTimer = window.setTimeout(() => {
3704
+ if (!this.closedByUser && this.ws?.readyState !== WebSocket.OPEN) {
3705
+ this.closedByUser = true;
3706
+ if (this.ws) {
3707
+ try {
3708
+ this.ws.close();
3709
+ } catch {
3710
+ }
3711
+ this.ws = null;
3712
+ }
3713
+ this.clearTotalTimeout();
3714
+ this.onStatus?.("error", new Error("Connection timeout after 10 seconds"));
3715
+ }
3716
+ }, this.totalTimeoutMs);
3717
+ }
3718
+ clearTotalTimeout() {
3719
+ if (this.totalTimeoutTimer !== null) {
3720
+ clearTimeout(this.totalTimeoutTimer);
3721
+ this.totalTimeoutTimer = null;
3722
+ }
3723
+ this.connectStartTime = null;
3724
+ }
3725
+ open() {
3726
+ if (this.connectStartTime && Date.now() - this.connectStartTime >= this.totalTimeoutMs) {
3727
+ if (!this.closedByUser) {
3728
+ this.closedByUser = true;
3729
+ this.clearTotalTimeout();
3730
+ this.onStatus?.("error", new Error("Connection timeout after 10 seconds"));
3731
+ }
3732
+ return;
3733
+ }
3734
+ this.onStatus?.(this.ws ? "reconnecting" : "connecting");
3735
+ const ws = new WebSocket(this.url);
3736
+ this.ws = ws;
3737
+ ws.onopen = () => {
3738
+ this.clearTotalTimeout();
3739
+ this.onStatus?.("connected");
3740
+ };
3741
+ ws.onerror = (e) => {
3742
+ this.onStatus?.("error", e);
3743
+ };
3744
+ ws.onclose = () => {
3745
+ if (this.closedByUser) {
3746
+ this.onStatus?.("closed");
3747
+ return;
3748
+ }
3749
+ if (this.connectStartTime && Date.now() - this.connectStartTime >= this.totalTimeoutMs) {
3750
+ this.closedByUser = true;
3751
+ this.clearTotalTimeout();
3752
+ this.onStatus?.("error", new Error("Connection timeout after 10 seconds"));
3753
+ return;
3754
+ }
3755
+ this.onStatus?.("reconnecting");
3756
+ setTimeout(() => this.open(), this.reconnectMs);
3757
+ };
3758
+ ws.onmessage = (ev) => {
3759
+ const data = typeof ev.data === "string" ? ev.data : "";
3760
+ const lines = data.split("\n").map((l) => l.trim()).filter(Boolean);
3761
+ for (const line of lines) {
3762
+ try {
3763
+ const obj = JSON.parse(line);
3764
+ this.onMessage(obj);
3765
+ } catch {
3766
+ }
3767
+ }
3768
+ };
3769
+ }
3770
+ };
3771
+
3772
+ // src/api/sources/FileSource.ts
3773
+ var FileSource = class {
3774
+ url;
3775
+ onMessage;
3776
+ onStatus;
3777
+ timer = null;
3778
+ lastETag = null;
3779
+ lastContent = "";
3780
+ intervalMs = 1e3;
3781
+ closed = false;
3782
+ constructor(url, onMessage, onStatus, intervalMs = 1e3) {
3783
+ this.url = url;
3784
+ this.onMessage = onMessage;
3785
+ this.onStatus = onStatus;
3786
+ this.intervalMs = intervalMs;
3787
+ }
3788
+ async connect() {
3789
+ this.closed = false;
3790
+ this.lastETag = null;
3791
+ this.lastContent = "";
3792
+ this.onStatus?.("opened");
3793
+ this.startPolling();
3794
+ }
3795
+ close() {
3796
+ this.closed = true;
3797
+ if (this.timer) {
3798
+ window.clearInterval(this.timer);
3799
+ this.timer = null;
3800
+ }
3801
+ this.onStatus?.("closed");
3802
+ }
3803
+ startPolling() {
3804
+ if (this.timer) window.clearInterval(this.timer);
3805
+ this.timer = window.setInterval(() => this.poll(), this.intervalMs);
3806
+ this.poll();
3807
+ }
3808
+ async poll() {
3809
+ if (this.closed) return;
3810
+ try {
3811
+ this.onStatus?.("reading");
3812
+ const headers = {};
3813
+ if (this.lastETag) {
3814
+ headers["If-None-Match"] = this.lastETag;
3815
+ }
3816
+ const response = await fetch(this.url, { headers });
3817
+ if (response.status === 304) {
3818
+ return;
3819
+ }
3820
+ if (!response.ok) {
3821
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
3822
+ }
3823
+ const etag = response.headers.get("ETag");
3824
+ if (etag) {
3825
+ this.lastETag = etag;
3826
+ }
3827
+ const content = await response.text();
3828
+ if (content === this.lastContent) {
3829
+ return;
3830
+ }
3831
+ const lines = content.split("\n").map((l) => l.trim()).filter(Boolean);
3832
+ const lastContentLines = this.lastContent.split("\n").map((l) => l.trim()).filter(Boolean);
3833
+ const newLines = lines.slice(lastContentLines.length);
3834
+ for (const line of newLines) {
3835
+ try {
3836
+ const obj = JSON.parse(line);
3837
+ this.onMessage(obj);
3838
+ } catch {
3839
+ }
3840
+ }
3841
+ this.lastContent = content;
3842
+ } catch (e) {
3843
+ this.onStatus?.("error", e);
3844
+ }
3845
+ }
3846
+ };
3847
+
3575
3848
  // src/api/api.ts
3576
3849
  var log11 = logger("api");
3577
3850
  var API = class {
@@ -3590,11 +3863,16 @@ var API = class {
3590
3863
  nextNodeId;
3591
3864
  nextEdgeId;
3592
3865
  events;
3866
+ ingest;
3867
+ ingestionSource;
3868
+ ingestionConfig;
3869
+ prevProps = {};
3593
3870
  root;
3594
3871
  constructor(args) {
3595
3872
  this.root = args.root;
3596
3873
  this.options = applyDefaults(args.options);
3597
3874
  this.events = args.events || {};
3875
+ this.ingestionConfig = args.ingestion;
3598
3876
  this.reset();
3599
3877
  this.canvas = new Canvas(this, {
3600
3878
  ...this.options.canvas,
@@ -3608,6 +3886,15 @@ var API = class {
3608
3886
  } else {
3609
3887
  this.history = [];
3610
3888
  }
3889
+ this.prevProps = {
3890
+ nodes: args.nodes,
3891
+ edges: args.edges,
3892
+ history: args.history,
3893
+ options: args.options
3894
+ };
3895
+ if (this.ingestionConfig) {
3896
+ this.ingest = new Ingest(this);
3897
+ }
3611
3898
  }
3612
3899
  reset() {
3613
3900
  let graph2 = new Graph({ options: this.options.graph });
@@ -3622,6 +3909,7 @@ var API = class {
3622
3909
  this.nodeFields = /* @__PURE__ */ new Map();
3623
3910
  this.nextNodeId = 1;
3624
3911
  this.nextEdgeId = 1;
3912
+ this.canvas?.reset?.();
3625
3913
  }
3626
3914
  /** Initialize the API */
3627
3915
  async init() {
@@ -3629,6 +3917,49 @@ var API = class {
3629
3917
  if (!root) throw new Error("root element not found");
3630
3918
  root.appendChild(this.canvas.container);
3631
3919
  await this.applyHistory();
3920
+ if (this.ingestionConfig && this.ingest) {
3921
+ this.connectIngestion();
3922
+ }
3923
+ if (this.events.onInit) {
3924
+ this.events.onInit();
3925
+ }
3926
+ }
3927
+ /** Connect to the configured ingestion source */
3928
+ connectIngestion() {
3929
+ if (!this.ingestionConfig || !this.ingest) return;
3930
+ const handleMessage = (msg) => {
3931
+ this.ingest.apply(msg);
3932
+ };
3933
+ switch (this.ingestionConfig.type) {
3934
+ case "websocket":
3935
+ this.ingestionSource = new WebSocketSource(
3936
+ this.ingestionConfig.url,
3937
+ handleMessage,
3938
+ void 0,
3939
+ this.ingestionConfig.reconnectMs
3940
+ );
3941
+ this.ingestionSource.connect();
3942
+ break;
3943
+ case "file":
3944
+ this.ingestionSource = new FileSource(
3945
+ this.ingestionConfig.url,
3946
+ handleMessage,
3947
+ void 0,
3948
+ this.ingestionConfig.intervalMs
3949
+ );
3950
+ this.ingestionSource.connect();
3951
+ break;
3952
+ }
3953
+ }
3954
+ /** Disconnect from the ingestion source */
3955
+ disconnectIngestion() {
3956
+ if (!this.ingestionSource) return;
3957
+ if (this.ingestionSource instanceof WebSocketSource) {
3958
+ this.ingestionSource.disconnect();
3959
+ } else if (this.ingestionSource instanceof FileSource) {
3960
+ this.ingestionSource.close();
3961
+ }
3962
+ this.ingestionSource = void 0;
3632
3963
  }
3633
3964
  async applyHistory() {
3634
3965
  for (const update of this.history)
@@ -4084,150 +4415,125 @@ var API = class {
4084
4415
  else
4085
4416
  await this.deleteEdge(edge.data);
4086
4417
  }
4087
- };
4088
-
4089
- // src/api/ingest.ts
4090
- var Ingest = class {
4091
- constructor(api) {
4092
- this.api = api;
4418
+ /** Update theme and type styles dynamically */
4419
+ updateStyles(options) {
4420
+ this.canvas?.updateStyles(options);
4421
+ }
4422
+ /** Update color mode without recreating the canvas */
4423
+ setColorMode(colorMode) {
4424
+ this.canvas?.setColorMode(colorMode);
4093
4425
  }
4094
4426
  /**
4095
- * Apply an incoming ingest message to the API.
4096
- * - snapshot: rebuild state from nodes/edges (clears prior history)
4097
- * - update: apply incremental update
4098
- * - history: initialize from a set of frames (clears prior history)
4427
+ * Apply prop changes by diffing against previously applied props.
4428
+ * This is a convenience method for framework wrappers that centralizes
4429
+ * the logic for detecting and applying changes to nodes, edges, history, and options.
4430
+ * The API stores the previous props internally, so you just pass the new props.
4431
+ *
4432
+ * @param props - The new props to apply
4099
4433
  */
4100
- async apply(msg) {
4101
- switch (msg.type) {
4102
- case "snapshot": {
4103
- await this.api.replaceSnapshot(msg.nodes, msg.edges, msg.description);
4104
- break;
4105
- }
4106
- case "update": {
4107
- await this.api.update((u) => {
4108
- if (msg.addNodes) u.addNodes(...msg.addNodes);
4109
- if (msg.removeNodes) u.deleteNodes(...msg.removeNodes);
4110
- if (msg.updateNodes) u.updateNodes(...msg.updateNodes);
4111
- if (msg.addEdges) u.addEdges(...msg.addEdges);
4112
- if (msg.removeEdges) u.deleteEdges(...msg.removeEdges);
4113
- if (msg.updateEdges) u.updateEdges(...msg.updateEdges);
4114
- if (msg.description) u.describe(msg.description);
4115
- });
4116
- break;
4117
- }
4118
- case "history": {
4119
- await this.api.replaceHistory(msg.frames);
4120
- break;
4434
+ applyProps(props) {
4435
+ const prev = this.prevProps;
4436
+ const nodesChanged = !shallowEqualArray(props.nodes, prev.nodes);
4437
+ const edgesChanged = !shallowEqualArray(props.edges, prev.edges);
4438
+ if (nodesChanged || edgesChanged) {
4439
+ if (props.nodes) {
4440
+ this.replaceSnapshot(props.nodes, props.edges || [], void 0);
4121
4441
  }
4122
4442
  }
4123
- }
4124
- };
4125
-
4126
- // src/api/sources/WebSocketSource.ts
4127
- var WebSocketSource = class {
4128
- url;
4129
- ws = null;
4130
- onMessage;
4131
- onStatus;
4132
- reconnectMs;
4133
- closedByUser = false;
4134
- connectStartTime = null;
4135
- totalTimeoutMs = 1e4;
4136
- totalTimeoutTimer = null;
4137
- constructor(url, onMessage, onStatus, reconnectMs = 1500) {
4138
- this.url = url;
4139
- this.onMessage = onMessage;
4140
- this.onStatus = onStatus;
4141
- this.reconnectMs = reconnectMs;
4142
- }
4143
- connect() {
4144
- this.closedByUser = false;
4145
- this.connectStartTime = Date.now();
4146
- this.startTotalTimeout();
4147
- this.open();
4148
- }
4149
- disconnect() {
4150
- this.closedByUser = true;
4151
- this.clearTotalTimeout();
4152
- if (this.ws) {
4153
- try {
4154
- this.ws.close();
4155
- } catch {
4443
+ if (!nodesChanged && !edgesChanged && props.history !== prev.history) {
4444
+ if (props.history === void 0) {
4445
+ if (props.nodes) {
4446
+ this.replaceSnapshot(props.nodes, props.edges || [], void 0);
4447
+ }
4448
+ } else if (prev.history && isHistoryPrefix(prev.history, props.history)) {
4449
+ const prevLength = prev.history.length;
4450
+ const newFrames = props.history.slice(prevLength);
4451
+ for (const frame of newFrames) {
4452
+ this.update((u) => {
4453
+ if (frame.addNodes) u.addNodes(...frame.addNodes);
4454
+ if (frame.removeNodes) u.deleteNodes(...frame.removeNodes);
4455
+ if (frame.updateNodes) u.updateNodes(...frame.updateNodes);
4456
+ if (frame.addEdges) u.addEdges(...frame.addEdges);
4457
+ if (frame.removeEdges) u.deleteEdges(...frame.removeEdges);
4458
+ if (frame.updateEdges) u.updateEdges(...frame.updateEdges);
4459
+ if (frame.description) u.describe(frame.description);
4460
+ });
4461
+ }
4462
+ } else {
4463
+ this.replaceHistory(props.history);
4156
4464
  }
4157
- this.ws = null;
4158
4465
  }
4159
- this.onStatus?.("closed");
4466
+ const prevCanvas = prev.options?.canvas;
4467
+ const currCanvas = props.options?.canvas;
4468
+ const colorModeChanged = prevCanvas?.colorMode !== currCanvas?.colorMode;
4469
+ if (colorModeChanged && currCanvas?.colorMode) {
4470
+ this.setColorMode(currCanvas.colorMode);
4471
+ }
4472
+ const themeChanged = prevCanvas?.theme !== currCanvas?.theme;
4473
+ const nodeTypesChanged = prevCanvas?.nodeTypes !== currCanvas?.nodeTypes;
4474
+ const edgeTypesChanged = prevCanvas?.edgeTypes !== currCanvas?.edgeTypes;
4475
+ if (themeChanged || nodeTypesChanged || edgeTypesChanged) {
4476
+ this.updateStyles({
4477
+ theme: currCanvas?.theme,
4478
+ nodeTypes: currCanvas?.nodeTypes,
4479
+ edgeTypes: currCanvas?.edgeTypes
4480
+ });
4481
+ }
4482
+ this.prevProps = {
4483
+ nodes: props.nodes,
4484
+ edges: props.edges,
4485
+ history: props.history,
4486
+ options: props.options
4487
+ };
4160
4488
  }
4161
- startTotalTimeout() {
4162
- this.clearTotalTimeout();
4163
- this.totalTimeoutTimer = window.setTimeout(() => {
4164
- if (!this.closedByUser && this.ws?.readyState !== WebSocket.OPEN) {
4165
- this.closedByUser = true;
4166
- if (this.ws) {
4167
- try {
4168
- this.ws.close();
4169
- } catch {
4170
- }
4171
- this.ws = null;
4489
+ /** Cleanup resources when the graph is destroyed */
4490
+ destroy() {
4491
+ this.disconnectIngestion();
4492
+ this.canvas?.destroy();
4493
+ }
4494
+ };
4495
+ function shallowEqualArray(a, b) {
4496
+ if (a === b) return true;
4497
+ if (!a || !b) return false;
4498
+ if (a.length !== b.length) return false;
4499
+ for (let i = 0; i < a.length; i++) {
4500
+ if (a[i] !== b[i]) {
4501
+ if (typeof a[i] === "object" && a[i] !== null && typeof b[i] === "object" && b[i] !== null) {
4502
+ const aObj = a[i];
4503
+ const bObj = b[i];
4504
+ const aKeys = Object.keys(aObj);
4505
+ const bKeys = Object.keys(bObj);
4506
+ if (aKeys.length !== bKeys.length) return false;
4507
+ for (const key of aKeys) {
4508
+ if (aObj[key] !== bObj[key]) return false;
4172
4509
  }
4173
- this.clearTotalTimeout();
4174
- this.onStatus?.("error", new Error("Connection timeout after 10 seconds"));
4510
+ } else {
4511
+ return false;
4175
4512
  }
4176
- }, this.totalTimeoutMs);
4177
- }
4178
- clearTotalTimeout() {
4179
- if (this.totalTimeoutTimer !== null) {
4180
- clearTimeout(this.totalTimeoutTimer);
4181
- this.totalTimeoutTimer = null;
4182
4513
  }
4183
- this.connectStartTime = null;
4184
4514
  }
4185
- open() {
4186
- if (this.connectStartTime && Date.now() - this.connectStartTime >= this.totalTimeoutMs) {
4187
- if (!this.closedByUser) {
4188
- this.closedByUser = true;
4189
- this.clearTotalTimeout();
4190
- this.onStatus?.("error", new Error("Connection timeout after 10 seconds"));
4191
- }
4192
- return;
4515
+ return true;
4516
+ }
4517
+ function isHistoryPrefix(oldHistory, newHistory) {
4518
+ if (newHistory.length < oldHistory.length) return false;
4519
+ for (let i = 0; i < oldHistory.length; i++) {
4520
+ if (!shallowEqualUpdate(oldHistory[i], newHistory[i])) {
4521
+ return false;
4193
4522
  }
4194
- this.onStatus?.(this.ws ? "reconnecting" : "connecting");
4195
- const ws = new WebSocket(this.url);
4196
- this.ws = ws;
4197
- ws.onopen = () => {
4198
- this.clearTotalTimeout();
4199
- this.onStatus?.("connected");
4200
- };
4201
- ws.onerror = (e) => {
4202
- this.onStatus?.("error", e);
4203
- };
4204
- ws.onclose = () => {
4205
- if (this.closedByUser) {
4206
- this.onStatus?.("closed");
4207
- return;
4208
- }
4209
- if (this.connectStartTime && Date.now() - this.connectStartTime >= this.totalTimeoutMs) {
4210
- this.closedByUser = true;
4211
- this.clearTotalTimeout();
4212
- this.onStatus?.("error", new Error("Connection timeout after 10 seconds"));
4213
- return;
4214
- }
4215
- this.onStatus?.("reconnecting");
4216
- setTimeout(() => this.open(), this.reconnectMs);
4217
- };
4218
- ws.onmessage = (ev) => {
4219
- const data = typeof ev.data === "string" ? ev.data : "";
4220
- const lines = data.split("\n").map((l) => l.trim()).filter(Boolean);
4221
- for (const line of lines) {
4222
- try {
4223
- const obj = JSON.parse(line);
4224
- this.onMessage(obj);
4225
- } catch {
4226
- }
4227
- }
4228
- };
4229
4523
  }
4230
- };
4524
+ return true;
4525
+ }
4526
+ function shallowEqualUpdate(a, b) {
4527
+ if (a === b) return true;
4528
+ if (a.description !== b.description) return false;
4529
+ if (!shallowEqualArray(a.addNodes, b.addNodes)) return false;
4530
+ if (!shallowEqualArray(a.removeNodes, b.removeNodes)) return false;
4531
+ if (!shallowEqualArray(a.updateNodes, b.updateNodes)) return false;
4532
+ if (!shallowEqualArray(a.addEdges, b.addEdges)) return false;
4533
+ if (!shallowEqualArray(a.removeEdges, b.removeEdges)) return false;
4534
+ if (!shallowEqualArray(a.updateEdges, b.updateEdges)) return false;
4535
+ return true;
4536
+ }
4231
4537
 
4232
4538
  // src/api/sources/FileSystemSource.ts
4233
4539
  var FileSystemSource = class {
@@ -4291,88 +4597,13 @@ var FileSystemSource = class {
4291
4597
  }
4292
4598
  };
4293
4599
 
4294
- // src/api/sources/FileSource.ts
4295
- var FileSource = class {
4296
- url;
4297
- onMessage;
4298
- onStatus;
4299
- timer = null;
4300
- lastETag = null;
4301
- lastContent = "";
4302
- intervalMs = 1e3;
4303
- closed = false;
4304
- constructor(url, onMessage, onStatus, intervalMs = 1e3) {
4305
- this.url = url;
4306
- this.onMessage = onMessage;
4307
- this.onStatus = onStatus;
4308
- this.intervalMs = intervalMs;
4309
- }
4310
- async connect() {
4311
- this.closed = false;
4312
- this.lastETag = null;
4313
- this.lastContent = "";
4314
- this.onStatus?.("opened");
4315
- this.startPolling();
4316
- }
4317
- close() {
4318
- this.closed = true;
4319
- if (this.timer) {
4320
- window.clearInterval(this.timer);
4321
- this.timer = null;
4322
- }
4323
- this.onStatus?.("closed");
4324
- }
4325
- startPolling() {
4326
- if (this.timer) window.clearInterval(this.timer);
4327
- this.timer = window.setInterval(() => this.poll(), this.intervalMs);
4328
- this.poll();
4329
- }
4330
- async poll() {
4331
- if (this.closed) return;
4332
- try {
4333
- this.onStatus?.("reading");
4334
- const headers = {};
4335
- if (this.lastETag) {
4336
- headers["If-None-Match"] = this.lastETag;
4337
- }
4338
- const response = await fetch(this.url, { headers });
4339
- if (response.status === 304) {
4340
- return;
4341
- }
4342
- if (!response.ok) {
4343
- throw new Error(`HTTP ${response.status}: ${response.statusText}`);
4344
- }
4345
- const etag = response.headers.get("ETag");
4346
- if (etag) {
4347
- this.lastETag = etag;
4348
- }
4349
- const content = await response.text();
4350
- if (content === this.lastContent) {
4351
- return;
4352
- }
4353
- const lines = content.split("\n").map((l) => l.trim()).filter(Boolean);
4354
- const lastContentLines = this.lastContent.split("\n").map((l) => l.trim()).filter(Boolean);
4355
- const newLines = lines.slice(lastContentLines.length);
4356
- for (const line of newLines) {
4357
- try {
4358
- const obj = JSON.parse(line);
4359
- this.onMessage(obj);
4360
- } catch {
4361
- }
4362
- }
4363
- this.lastContent = content;
4364
- } catch (e) {
4365
- this.onStatus?.("error", e);
4366
- }
4367
- }
4368
- };
4369
-
4370
4600
  // src/playground/playground.ts
4371
4601
  import styles2 from "./styles.css?raw";
4372
4602
  var Playground = class {
4373
4603
  options;
4374
4604
  rootElement;
4375
4605
  currentExample;
4606
+ examples;
4376
4607
  currentGraph = null;
4377
4608
  ingest = null;
4378
4609
  isEditable = false;
@@ -4390,7 +4621,8 @@ var Playground = class {
4390
4621
  graphContainerId;
4391
4622
  constructor(options) {
4392
4623
  this.options = options;
4393
- this.exampleList = Object.keys(options.examples);
4624
+ this.examples = { ...options.examples };
4625
+ this.exampleList = Object.keys(this.examples);
4394
4626
  this.currentExample = options.defaultExample || this.exampleList[0];
4395
4627
  this.graphContainerId = `playground-graph-${Math.random().toString(36).substr(2, 9)}`;
4396
4628
  if (typeof options.root === "string") {
@@ -4421,7 +4653,7 @@ var Playground = class {
4421
4653
  }
4422
4654
  createDOM() {
4423
4655
  const exampleList = this.exampleList.map((key, i) => {
4424
- const example = this.options.examples[key];
4656
+ const example = this.examples[key];
4425
4657
  const isActive = i === 0 || key === this.currentExample;
4426
4658
  return `
4427
4659
  <button class="example-btn ${isActive ? "active" : ""}" data-example="${key}">
@@ -4507,7 +4739,14 @@ var Playground = class {
4507
4739
  });
4508
4740
  });
4509
4741
  this.rootElement.querySelectorAll(".options select, .options input").forEach((el) => {
4510
- el.addEventListener("change", () => this.renderGraph());
4742
+ el.addEventListener("change", () => {
4743
+ if (el.id === "colorMode" && this.currentGraph) {
4744
+ const mode = el.value;
4745
+ this.currentGraph.setColorMode(mode);
4746
+ } else {
4747
+ this.renderGraph();
4748
+ }
4749
+ });
4511
4750
  });
4512
4751
  this.rootElement.querySelector("#nav-first")?.addEventListener("click", () => {
4513
4752
  this.currentGraph?.nav("first");
@@ -4594,8 +4833,10 @@ var Playground = class {
4594
4833
  async renderGraph() {
4595
4834
  const container = this.rootElement.querySelector(`#${this.graphContainerId}`);
4596
4835
  if (!container) return;
4836
+ this.currentGraph?.destroy();
4837
+ this.currentGraph = null;
4597
4838
  container.innerHTML = "";
4598
- const example = this.options.examples[this.currentExample];
4839
+ const example = this.examples[this.currentExample];
4599
4840
  const options = this.getOptions(example.options);
4600
4841
  try {
4601
4842
  this.currentGraph = await graph({
@@ -4626,7 +4867,7 @@ var Playground = class {
4626
4867
  }
4627
4868
  }
4628
4869
  connectExampleSource() {
4629
- const example = this.options.examples[this.currentExample];
4870
+ const example = this.examples[this.currentExample];
4630
4871
  if (!example.source) {
4631
4872
  this.disconnectAllSources();
4632
4873
  return;
@@ -4969,7 +5210,7 @@ var Playground = class {
4969
5210
  `;
4970
5211
  }
4971
5212
  } else if (this.activeSourceType === "file") {
4972
- const example = this.options.examples[this.currentExample];
5213
+ const example = this.examples[this.currentExample];
4973
5214
  const filePath = example.source?.type === "file" ? example.source.path : "";
4974
5215
  if (this.fileStatus === "connecting") {
4975
5216
  statusDiv.innerHTML = `
@@ -5067,6 +5308,58 @@ var Playground = class {
5067
5308
  this.fsSource?.close();
5068
5309
  this.updateSourceModal();
5069
5310
  }
5311
+ /**
5312
+ * Add or update an example
5313
+ */
5314
+ addExample(key, example) {
5315
+ this.examples[key] = example;
5316
+ this.updateExampleList();
5317
+ if (this.currentExample === key) {
5318
+ this.renderGraph();
5319
+ this.connectExampleSource();
5320
+ }
5321
+ }
5322
+ /**
5323
+ * Remove an example
5324
+ */
5325
+ removeExample(key) {
5326
+ delete this.examples[key];
5327
+ if (this.currentExample === key) {
5328
+ this.exampleList = Object.keys(this.examples);
5329
+ this.currentExample = this.exampleList[0] || "";
5330
+ if (this.currentExample) {
5331
+ this.renderGraph();
5332
+ this.connectExampleSource();
5333
+ }
5334
+ }
5335
+ this.updateExampleList();
5336
+ }
5337
+ /**
5338
+ * Update the example list in the DOM
5339
+ */
5340
+ updateExampleList() {
5341
+ this.exampleList = Object.keys(this.examples);
5342
+ const exampleListEl = this.rootElement.querySelector(".example-list");
5343
+ if (!exampleListEl) return;
5344
+ exampleListEl.innerHTML = this.exampleList.map((key, i) => {
5345
+ const example = this.examples[key];
5346
+ const isActive = key === this.currentExample;
5347
+ return `
5348
+ <button class="example-btn ${isActive ? "active" : ""}" data-example="${key}">
5349
+ ${example.name}
5350
+ </button>
5351
+ `;
5352
+ }).join("");
5353
+ exampleListEl.querySelectorAll(".example-btn").forEach((btn) => {
5354
+ btn.addEventListener("click", () => {
5355
+ this.rootElement.querySelectorAll(".example-btn").forEach((b) => b.classList.remove("active"));
5356
+ btn.classList.add("active");
5357
+ this.currentExample = btn.getAttribute("data-example") || this.exampleList[0];
5358
+ this.renderGraph();
5359
+ this.connectExampleSource();
5360
+ });
5361
+ });
5362
+ }
5070
5363
  };
5071
5364
 
5072
5365
  // src/index.ts
@@ -5081,6 +5374,7 @@ export {
5081
5374
  FileSystemSource,
5082
5375
  Ingest,
5083
5376
  Playground,
5377
+ Updater,
5084
5378
  WebSocketSource,
5085
5379
  index_default as default,
5086
5380
  graph