@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.cjs CHANGED
@@ -34,6 +34,7 @@ __export(index_exports, {
34
34
  FileSystemSource: () => FileSystemSource,
35
35
  Ingest: () => Ingest,
36
36
  Playground: () => Playground,
37
+ Updater: () => Updater,
37
38
  WebSocketSource: () => WebSocketSource,
38
39
  default: () => index_default,
39
40
  graph: () => graph
@@ -1380,15 +1381,16 @@ var Layout = class _Layout {
1380
1381
 
1381
1382
  // src/canvas/marker.tsx
1382
1383
  var import_jsx_runtime = require("jsx-dom/jsx-runtime");
1383
- function arrow(size, reverse = false) {
1384
+ function arrow(size, reverse = false, prefix = "") {
1384
1385
  const h = size / 1.5;
1385
1386
  const w = size;
1386
1387
  const ry = h / 2;
1387
1388
  const suffix = reverse ? "-reverse" : "";
1389
+ const id = prefix ? `${prefix}-g3p-marker-arrow${suffix}` : `g3p-marker-arrow${suffix}`;
1388
1390
  return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1389
1391
  "marker",
1390
1392
  {
1391
- id: `g3p-marker-arrow${suffix}`,
1393
+ id,
1392
1394
  className: "g3p-marker g3p-marker-arrow",
1393
1395
  markerWidth: size,
1394
1396
  markerHeight: size,
@@ -1400,14 +1402,15 @@ function arrow(size, reverse = false) {
1400
1402
  }
1401
1403
  );
1402
1404
  }
1403
- function circle(size, reverse = false) {
1405
+ function circle(size, reverse = false, prefix = "") {
1404
1406
  const r = size / 3;
1405
1407
  const cy = size / 2;
1406
1408
  const suffix = reverse ? "-reverse" : "";
1409
+ const id = prefix ? `${prefix}-g3p-marker-circle${suffix}` : `g3p-marker-circle${suffix}`;
1407
1410
  return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1408
1411
  "marker",
1409
1412
  {
1410
- id: `g3p-marker-circle${suffix}`,
1413
+ id,
1411
1414
  className: "g3p-marker g3p-marker-circle",
1412
1415
  markerWidth: size,
1413
1416
  markerHeight: size,
@@ -1419,15 +1422,16 @@ function circle(size, reverse = false) {
1419
1422
  }
1420
1423
  );
1421
1424
  }
1422
- function diamond(size, reverse = false) {
1425
+ function diamond(size, reverse = false, prefix = "") {
1423
1426
  const w = size * 0.7;
1424
1427
  const h = size / 2;
1425
1428
  const cy = size / 2;
1426
1429
  const suffix = reverse ? "-reverse" : "";
1430
+ const id = prefix ? `${prefix}-g3p-marker-diamond${suffix}` : `g3p-marker-diamond${suffix}`;
1427
1431
  return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1428
1432
  "marker",
1429
1433
  {
1430
- id: `g3p-marker-diamond${suffix}`,
1434
+ id,
1431
1435
  className: "g3p-marker g3p-marker-diamond",
1432
1436
  markerWidth: size,
1433
1437
  markerHeight: size,
@@ -1439,14 +1443,15 @@ function diamond(size, reverse = false) {
1439
1443
  }
1440
1444
  );
1441
1445
  }
1442
- function bar(size, reverse = false) {
1446
+ function bar(size, reverse = false, prefix = "") {
1443
1447
  const h = size * 0.6;
1444
1448
  const cy = size / 2;
1445
1449
  const suffix = reverse ? "-reverse" : "";
1450
+ const id = prefix ? `${prefix}-g3p-marker-bar${suffix}` : `g3p-marker-bar${suffix}`;
1446
1451
  return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1447
1452
  "marker",
1448
1453
  {
1449
- id: `g3p-marker-bar${suffix}`,
1454
+ id,
1450
1455
  className: "g3p-marker g3p-marker-bar",
1451
1456
  markerWidth: size,
1452
1457
  markerHeight: size,
@@ -1458,7 +1463,7 @@ function bar(size, reverse = false) {
1458
1463
  }
1459
1464
  );
1460
1465
  }
1461
- function none(size, reverse = false) {
1466
+ function none(size, reverse = false, prefix = "") {
1462
1467
  return void 0;
1463
1468
  }
1464
1469
  function normalize(data) {
@@ -2240,6 +2245,9 @@ var Seg2 = class {
2240
2245
  if (this.source.isDummy) source = void 0;
2241
2246
  if (this.target.isDummy) target = void 0;
2242
2247
  const typeClass = this.type ? `g3p-edge-type-${this.type}` : "";
2248
+ const prefix = this.canvas.markerPrefix;
2249
+ const markerStartId = source ? prefix ? `${prefix}-g3p-marker-${source}-reverse` : `g3p-marker-${source}-reverse` : void 0;
2250
+ const markerEndId = target ? prefix ? `${prefix}-g3p-marker-${target}` : `g3p-marker-${target}` : void 0;
2243
2251
  return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(
2244
2252
  "g",
2245
2253
  {
@@ -2254,8 +2262,8 @@ var Seg2 = class {
2254
2262
  d: this.svg,
2255
2263
  fill: "none",
2256
2264
  className: "g3p-seg-line",
2257
- markerStart: source ? `url(#g3p-marker-${source}-reverse)` : void 0,
2258
- markerEnd: target ? `url(#g3p-marker-${target})` : void 0
2265
+ markerStart: markerStartId ? `url(#${markerStartId})` : void 0,
2266
+ markerEnd: markerEndId ? `url(#${markerEndId})` : void 0
2259
2267
  }
2260
2268
  ),
2261
2269
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
@@ -2840,6 +2848,10 @@ var Canvas = class {
2840
2848
  curNodes;
2841
2849
  curSegs;
2842
2850
  updating;
2851
+ // Unique marker ID prefix for this canvas instance
2852
+ markerPrefix;
2853
+ // Dynamic style element for this instance (for cleanup)
2854
+ dynamicStyleEl;
2843
2855
  // Pan-zoom state
2844
2856
  panScale = null;
2845
2857
  zoomControls;
@@ -2853,6 +2865,13 @@ var Canvas = class {
2853
2865
  constructor(api, options) {
2854
2866
  Object.assign(this, options);
2855
2867
  this.api = api;
2868
+ this.reset();
2869
+ this.markerPrefix = api.root.replace(/[^a-zA-Z0-9-_]/g, "-");
2870
+ this.createMeasurementContainer();
2871
+ this.createCanvasContainer();
2872
+ if (this.panZoom) this.setupPanZoom();
2873
+ }
2874
+ reset() {
2856
2875
  this.allNodes = /* @__PURE__ */ new Map();
2857
2876
  this.curNodes = /* @__PURE__ */ new Map();
2858
2877
  this.curSegs = /* @__PURE__ */ new Map();
@@ -2861,9 +2880,7 @@ var Canvas = class {
2861
2880
  this.transform = { x: 0, y: 0, scale: 1 };
2862
2881
  this.editMode = new EditMode();
2863
2882
  this.editMode.editable = this.editable;
2864
- this.createMeasurementContainer();
2865
- this.createCanvasContainer();
2866
- if (this.panZoom) this.setupPanZoom();
2883
+ if (this.group) this.group.innerHTML = "";
2867
2884
  }
2868
2885
  createMeasurementContainer() {
2869
2886
  this.measurement = document.createElement("div");
@@ -3048,12 +3065,13 @@ var Canvas = class {
3048
3065
  }
3049
3066
  generateDynamicStyles() {
3050
3067
  let css = "";
3051
- css += themeToCSS(this.theme, `.g3p-canvas-container`);
3068
+ const scope = `[data-g3p-instance="${this.markerPrefix}"]`;
3069
+ css += themeToCSS(this.theme, scope);
3052
3070
  for (const [type, vars] of Object.entries(this.nodeTypes)) {
3053
- css += themeToCSS(vars, `.g3p-node-type-${type}`, "node");
3071
+ css += themeToCSS(vars, `${scope} .g3p-node-type-${type}`, "node");
3054
3072
  }
3055
3073
  for (const [type, vars] of Object.entries(this.edgeTypes)) {
3056
- css += themeToCSS(vars, `.g3p-edge-type-${type}`);
3074
+ css += themeToCSS(vars, `${scope} .g3p-edge-type-${type}`);
3057
3075
  }
3058
3076
  return css;
3059
3077
  }
@@ -3066,15 +3084,18 @@ var Canvas = class {
3066
3084
  }
3067
3085
  const dynamicStyles = this.generateDynamicStyles();
3068
3086
  if (dynamicStyles) {
3069
- const dynamicStyleEl = document.createElement("style");
3070
- dynamicStyleEl.textContent = dynamicStyles;
3071
- document.head.appendChild(dynamicStyleEl);
3087
+ this.dynamicStyleEl?.remove();
3088
+ this.dynamicStyleEl = document.createElement("style");
3089
+ this.dynamicStyleEl.id = `g3p-styles-${this.markerPrefix}`;
3090
+ this.dynamicStyleEl.textContent = dynamicStyles;
3091
+ document.head.appendChild(this.dynamicStyleEl);
3072
3092
  }
3073
3093
  const colorModeClass = this.colorMode !== "system" ? `g3p-${this.colorMode}` : "";
3074
3094
  this.container = /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(
3075
3095
  "div",
3076
3096
  {
3077
3097
  className: `g3p-canvas-container ${colorModeClass}`.trim(),
3098
+ "data-g3p-instance": this.markerPrefix,
3078
3099
  ref: (el) => this.container = el,
3079
3100
  onContextMenu: this.onContextMenu.bind(this),
3080
3101
  children: /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)(
@@ -3090,8 +3111,8 @@ var Canvas = class {
3090
3111
  onDblClick: this.onDoubleClick.bind(this),
3091
3112
  children: [
3092
3113
  /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("defs", { children: [
3093
- Object.values(markerDefs).map((marker) => marker(this.markerSize, false)),
3094
- Object.values(markerDefs).map((marker) => marker(this.markerSize, true))
3114
+ Object.values(markerDefs).map((marker) => marker(this.markerSize, false, this.markerPrefix)),
3115
+ Object.values(markerDefs).map((marker) => marker(this.markerSize, true, this.markerPrefix))
3095
3116
  ] }),
3096
3117
  /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(
3097
3118
  "g",
@@ -3448,6 +3469,40 @@ var Canvas = class {
3448
3469
  }
3449
3470
  return { type: "canvas" };
3450
3471
  }
3472
+ /** Update theme and type styles dynamically */
3473
+ updateStyles(options) {
3474
+ if (options.theme !== void 0) this.theme = options.theme;
3475
+ if (options.nodeTypes !== void 0) this.nodeTypes = options.nodeTypes;
3476
+ if (options.edgeTypes !== void 0) this.edgeTypes = options.edgeTypes;
3477
+ const dynamicStyles = this.generateDynamicStyles();
3478
+ if (dynamicStyles) {
3479
+ if (!this.dynamicStyleEl) {
3480
+ this.dynamicStyleEl = document.createElement("style");
3481
+ this.dynamicStyleEl.id = `g3p-styles-${this.markerPrefix}`;
3482
+ document.head.appendChild(this.dynamicStyleEl);
3483
+ }
3484
+ this.dynamicStyleEl.textContent = dynamicStyles;
3485
+ } else if (this.dynamicStyleEl) {
3486
+ this.dynamicStyleEl.remove();
3487
+ this.dynamicStyleEl = void 0;
3488
+ }
3489
+ }
3490
+ /** Update color mode without recreating the canvas */
3491
+ setColorMode(colorMode) {
3492
+ if (!this.container) return;
3493
+ this.colorMode = colorMode;
3494
+ this.container.classList.remove("g3p-light", "g3p-dark");
3495
+ if (colorMode !== "system") {
3496
+ this.container.classList.add(`g3p-${colorMode}`);
3497
+ }
3498
+ }
3499
+ /** Cleanup resources when the canvas is destroyed */
3500
+ destroy() {
3501
+ this.dynamicStyleEl?.remove();
3502
+ this.dynamicStyleEl = void 0;
3503
+ this.measurement?.remove();
3504
+ this.measurement = void 0;
3505
+ }
3451
3506
  };
3452
3507
  var themeVarMap = {
3453
3508
  // Canvas
@@ -3614,6 +3669,225 @@ var Updater = class _Updater {
3614
3669
  }
3615
3670
  };
3616
3671
 
3672
+ // src/api/ingest.ts
3673
+ var Ingest = class {
3674
+ constructor(api) {
3675
+ this.api = api;
3676
+ }
3677
+ /**
3678
+ * Apply an incoming ingest message to the API.
3679
+ * - snapshot: rebuild state from nodes/edges (clears prior history)
3680
+ * - update: apply incremental update
3681
+ * - history: initialize from a set of frames (clears prior history)
3682
+ */
3683
+ async apply(msg) {
3684
+ switch (msg.type) {
3685
+ case "snapshot": {
3686
+ await this.api.replaceSnapshot(msg.nodes, msg.edges, msg.description);
3687
+ break;
3688
+ }
3689
+ case "update": {
3690
+ await this.api.update((u) => {
3691
+ if (msg.addNodes) u.addNodes(...msg.addNodes);
3692
+ if (msg.removeNodes) u.deleteNodes(...msg.removeNodes);
3693
+ if (msg.updateNodes) u.updateNodes(...msg.updateNodes);
3694
+ if (msg.addEdges) u.addEdges(...msg.addEdges);
3695
+ if (msg.removeEdges) u.deleteEdges(...msg.removeEdges);
3696
+ if (msg.updateEdges) u.updateEdges(...msg.updateEdges);
3697
+ if (msg.description) u.describe(msg.description);
3698
+ });
3699
+ break;
3700
+ }
3701
+ case "history": {
3702
+ await this.api.replaceHistory(msg.frames);
3703
+ break;
3704
+ }
3705
+ }
3706
+ }
3707
+ };
3708
+
3709
+ // src/api/sources/WebSocketSource.ts
3710
+ var WebSocketSource = class {
3711
+ url;
3712
+ ws = null;
3713
+ onMessage;
3714
+ onStatus;
3715
+ reconnectMs;
3716
+ closedByUser = false;
3717
+ connectStartTime = null;
3718
+ totalTimeoutMs = 1e4;
3719
+ totalTimeoutTimer = null;
3720
+ constructor(url, onMessage, onStatus, reconnectMs = 1500) {
3721
+ this.url = url;
3722
+ this.onMessage = onMessage;
3723
+ this.onStatus = onStatus;
3724
+ this.reconnectMs = reconnectMs;
3725
+ }
3726
+ connect() {
3727
+ this.closedByUser = false;
3728
+ this.connectStartTime = Date.now();
3729
+ this.startTotalTimeout();
3730
+ this.open();
3731
+ }
3732
+ disconnect() {
3733
+ this.closedByUser = true;
3734
+ this.clearTotalTimeout();
3735
+ if (this.ws) {
3736
+ try {
3737
+ this.ws.close();
3738
+ } catch {
3739
+ }
3740
+ this.ws = null;
3741
+ }
3742
+ this.onStatus?.("closed");
3743
+ }
3744
+ startTotalTimeout() {
3745
+ this.clearTotalTimeout();
3746
+ this.totalTimeoutTimer = window.setTimeout(() => {
3747
+ if (!this.closedByUser && this.ws?.readyState !== WebSocket.OPEN) {
3748
+ this.closedByUser = true;
3749
+ if (this.ws) {
3750
+ try {
3751
+ this.ws.close();
3752
+ } catch {
3753
+ }
3754
+ this.ws = null;
3755
+ }
3756
+ this.clearTotalTimeout();
3757
+ this.onStatus?.("error", new Error("Connection timeout after 10 seconds"));
3758
+ }
3759
+ }, this.totalTimeoutMs);
3760
+ }
3761
+ clearTotalTimeout() {
3762
+ if (this.totalTimeoutTimer !== null) {
3763
+ clearTimeout(this.totalTimeoutTimer);
3764
+ this.totalTimeoutTimer = null;
3765
+ }
3766
+ this.connectStartTime = null;
3767
+ }
3768
+ open() {
3769
+ if (this.connectStartTime && Date.now() - this.connectStartTime >= this.totalTimeoutMs) {
3770
+ if (!this.closedByUser) {
3771
+ this.closedByUser = true;
3772
+ this.clearTotalTimeout();
3773
+ this.onStatus?.("error", new Error("Connection timeout after 10 seconds"));
3774
+ }
3775
+ return;
3776
+ }
3777
+ this.onStatus?.(this.ws ? "reconnecting" : "connecting");
3778
+ const ws = new WebSocket(this.url);
3779
+ this.ws = ws;
3780
+ ws.onopen = () => {
3781
+ this.clearTotalTimeout();
3782
+ this.onStatus?.("connected");
3783
+ };
3784
+ ws.onerror = (e) => {
3785
+ this.onStatus?.("error", e);
3786
+ };
3787
+ ws.onclose = () => {
3788
+ if (this.closedByUser) {
3789
+ this.onStatus?.("closed");
3790
+ return;
3791
+ }
3792
+ if (this.connectStartTime && Date.now() - this.connectStartTime >= this.totalTimeoutMs) {
3793
+ this.closedByUser = true;
3794
+ this.clearTotalTimeout();
3795
+ this.onStatus?.("error", new Error("Connection timeout after 10 seconds"));
3796
+ return;
3797
+ }
3798
+ this.onStatus?.("reconnecting");
3799
+ setTimeout(() => this.open(), this.reconnectMs);
3800
+ };
3801
+ ws.onmessage = (ev) => {
3802
+ const data = typeof ev.data === "string" ? ev.data : "";
3803
+ const lines = data.split("\n").map((l) => l.trim()).filter(Boolean);
3804
+ for (const line of lines) {
3805
+ try {
3806
+ const obj = JSON.parse(line);
3807
+ this.onMessage(obj);
3808
+ } catch {
3809
+ }
3810
+ }
3811
+ };
3812
+ }
3813
+ };
3814
+
3815
+ // src/api/sources/FileSource.ts
3816
+ var FileSource = class {
3817
+ url;
3818
+ onMessage;
3819
+ onStatus;
3820
+ timer = null;
3821
+ lastETag = null;
3822
+ lastContent = "";
3823
+ intervalMs = 1e3;
3824
+ closed = false;
3825
+ constructor(url, onMessage, onStatus, intervalMs = 1e3) {
3826
+ this.url = url;
3827
+ this.onMessage = onMessage;
3828
+ this.onStatus = onStatus;
3829
+ this.intervalMs = intervalMs;
3830
+ }
3831
+ async connect() {
3832
+ this.closed = false;
3833
+ this.lastETag = null;
3834
+ this.lastContent = "";
3835
+ this.onStatus?.("opened");
3836
+ this.startPolling();
3837
+ }
3838
+ close() {
3839
+ this.closed = true;
3840
+ if (this.timer) {
3841
+ window.clearInterval(this.timer);
3842
+ this.timer = null;
3843
+ }
3844
+ this.onStatus?.("closed");
3845
+ }
3846
+ startPolling() {
3847
+ if (this.timer) window.clearInterval(this.timer);
3848
+ this.timer = window.setInterval(() => this.poll(), this.intervalMs);
3849
+ this.poll();
3850
+ }
3851
+ async poll() {
3852
+ if (this.closed) return;
3853
+ try {
3854
+ this.onStatus?.("reading");
3855
+ const headers = {};
3856
+ if (this.lastETag) {
3857
+ headers["If-None-Match"] = this.lastETag;
3858
+ }
3859
+ const response = await fetch(this.url, { headers });
3860
+ if (response.status === 304) {
3861
+ return;
3862
+ }
3863
+ if (!response.ok) {
3864
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
3865
+ }
3866
+ const etag = response.headers.get("ETag");
3867
+ if (etag) {
3868
+ this.lastETag = etag;
3869
+ }
3870
+ const content = await response.text();
3871
+ if (content === this.lastContent) {
3872
+ return;
3873
+ }
3874
+ const lines = content.split("\n").map((l) => l.trim()).filter(Boolean);
3875
+ const lastContentLines = this.lastContent.split("\n").map((l) => l.trim()).filter(Boolean);
3876
+ const newLines = lines.slice(lastContentLines.length);
3877
+ for (const line of newLines) {
3878
+ try {
3879
+ const obj = JSON.parse(line);
3880
+ this.onMessage(obj);
3881
+ } catch {
3882
+ }
3883
+ }
3884
+ this.lastContent = content;
3885
+ } catch (e) {
3886
+ this.onStatus?.("error", e);
3887
+ }
3888
+ }
3889
+ };
3890
+
3617
3891
  // src/api/api.ts
3618
3892
  var log11 = logger("api");
3619
3893
  var API = class {
@@ -3632,11 +3906,16 @@ var API = class {
3632
3906
  nextNodeId;
3633
3907
  nextEdgeId;
3634
3908
  events;
3909
+ ingest;
3910
+ ingestionSource;
3911
+ ingestionConfig;
3912
+ prevProps = {};
3635
3913
  root;
3636
3914
  constructor(args) {
3637
3915
  this.root = args.root;
3638
3916
  this.options = applyDefaults(args.options);
3639
3917
  this.events = args.events || {};
3918
+ this.ingestionConfig = args.ingestion;
3640
3919
  this.reset();
3641
3920
  this.canvas = new Canvas(this, {
3642
3921
  ...this.options.canvas,
@@ -3650,6 +3929,15 @@ var API = class {
3650
3929
  } else {
3651
3930
  this.history = [];
3652
3931
  }
3932
+ this.prevProps = {
3933
+ nodes: args.nodes,
3934
+ edges: args.edges,
3935
+ history: args.history,
3936
+ options: args.options
3937
+ };
3938
+ if (this.ingestionConfig) {
3939
+ this.ingest = new Ingest(this);
3940
+ }
3653
3941
  }
3654
3942
  reset() {
3655
3943
  let graph2 = new Graph({ options: this.options.graph });
@@ -3664,6 +3952,7 @@ var API = class {
3664
3952
  this.nodeFields = /* @__PURE__ */ new Map();
3665
3953
  this.nextNodeId = 1;
3666
3954
  this.nextEdgeId = 1;
3955
+ this.canvas?.reset?.();
3667
3956
  }
3668
3957
  /** Initialize the API */
3669
3958
  async init() {
@@ -3671,6 +3960,49 @@ var API = class {
3671
3960
  if (!root) throw new Error("root element not found");
3672
3961
  root.appendChild(this.canvas.container);
3673
3962
  await this.applyHistory();
3963
+ if (this.ingestionConfig && this.ingest) {
3964
+ this.connectIngestion();
3965
+ }
3966
+ if (this.events.onInit) {
3967
+ this.events.onInit();
3968
+ }
3969
+ }
3970
+ /** Connect to the configured ingestion source */
3971
+ connectIngestion() {
3972
+ if (!this.ingestionConfig || !this.ingest) return;
3973
+ const handleMessage = (msg) => {
3974
+ this.ingest.apply(msg);
3975
+ };
3976
+ switch (this.ingestionConfig.type) {
3977
+ case "websocket":
3978
+ this.ingestionSource = new WebSocketSource(
3979
+ this.ingestionConfig.url,
3980
+ handleMessage,
3981
+ void 0,
3982
+ this.ingestionConfig.reconnectMs
3983
+ );
3984
+ this.ingestionSource.connect();
3985
+ break;
3986
+ case "file":
3987
+ this.ingestionSource = new FileSource(
3988
+ this.ingestionConfig.url,
3989
+ handleMessage,
3990
+ void 0,
3991
+ this.ingestionConfig.intervalMs
3992
+ );
3993
+ this.ingestionSource.connect();
3994
+ break;
3995
+ }
3996
+ }
3997
+ /** Disconnect from the ingestion source */
3998
+ disconnectIngestion() {
3999
+ if (!this.ingestionSource) return;
4000
+ if (this.ingestionSource instanceof WebSocketSource) {
4001
+ this.ingestionSource.disconnect();
4002
+ } else if (this.ingestionSource instanceof FileSource) {
4003
+ this.ingestionSource.close();
4004
+ }
4005
+ this.ingestionSource = void 0;
3674
4006
  }
3675
4007
  async applyHistory() {
3676
4008
  for (const update of this.history)
@@ -4126,150 +4458,125 @@ var API = class {
4126
4458
  else
4127
4459
  await this.deleteEdge(edge.data);
4128
4460
  }
4129
- };
4130
-
4131
- // src/api/ingest.ts
4132
- var Ingest = class {
4133
- constructor(api) {
4134
- this.api = api;
4461
+ /** Update theme and type styles dynamically */
4462
+ updateStyles(options) {
4463
+ this.canvas?.updateStyles(options);
4464
+ }
4465
+ /** Update color mode without recreating the canvas */
4466
+ setColorMode(colorMode) {
4467
+ this.canvas?.setColorMode(colorMode);
4135
4468
  }
4136
4469
  /**
4137
- * Apply an incoming ingest message to the API.
4138
- * - snapshot: rebuild state from nodes/edges (clears prior history)
4139
- * - update: apply incremental update
4140
- * - history: initialize from a set of frames (clears prior history)
4470
+ * Apply prop changes by diffing against previously applied props.
4471
+ * This is a convenience method for framework wrappers that centralizes
4472
+ * the logic for detecting and applying changes to nodes, edges, history, and options.
4473
+ * The API stores the previous props internally, so you just pass the new props.
4474
+ *
4475
+ * @param props - The new props to apply
4141
4476
  */
4142
- async apply(msg) {
4143
- switch (msg.type) {
4144
- case "snapshot": {
4145
- await this.api.replaceSnapshot(msg.nodes, msg.edges, msg.description);
4146
- break;
4147
- }
4148
- case "update": {
4149
- await this.api.update((u) => {
4150
- if (msg.addNodes) u.addNodes(...msg.addNodes);
4151
- if (msg.removeNodes) u.deleteNodes(...msg.removeNodes);
4152
- if (msg.updateNodes) u.updateNodes(...msg.updateNodes);
4153
- if (msg.addEdges) u.addEdges(...msg.addEdges);
4154
- if (msg.removeEdges) u.deleteEdges(...msg.removeEdges);
4155
- if (msg.updateEdges) u.updateEdges(...msg.updateEdges);
4156
- if (msg.description) u.describe(msg.description);
4157
- });
4158
- break;
4159
- }
4160
- case "history": {
4161
- await this.api.replaceHistory(msg.frames);
4162
- break;
4477
+ applyProps(props) {
4478
+ const prev = this.prevProps;
4479
+ const nodesChanged = !shallowEqualArray(props.nodes, prev.nodes);
4480
+ const edgesChanged = !shallowEqualArray(props.edges, prev.edges);
4481
+ if (nodesChanged || edgesChanged) {
4482
+ if (props.nodes) {
4483
+ this.replaceSnapshot(props.nodes, props.edges || [], void 0);
4163
4484
  }
4164
4485
  }
4165
- }
4166
- };
4167
-
4168
- // src/api/sources/WebSocketSource.ts
4169
- var WebSocketSource = class {
4170
- url;
4171
- ws = null;
4172
- onMessage;
4173
- onStatus;
4174
- reconnectMs;
4175
- closedByUser = false;
4176
- connectStartTime = null;
4177
- totalTimeoutMs = 1e4;
4178
- totalTimeoutTimer = null;
4179
- constructor(url, onMessage, onStatus, reconnectMs = 1500) {
4180
- this.url = url;
4181
- this.onMessage = onMessage;
4182
- this.onStatus = onStatus;
4183
- this.reconnectMs = reconnectMs;
4184
- }
4185
- connect() {
4186
- this.closedByUser = false;
4187
- this.connectStartTime = Date.now();
4188
- this.startTotalTimeout();
4189
- this.open();
4190
- }
4191
- disconnect() {
4192
- this.closedByUser = true;
4193
- this.clearTotalTimeout();
4194
- if (this.ws) {
4195
- try {
4196
- this.ws.close();
4197
- } catch {
4486
+ if (!nodesChanged && !edgesChanged && props.history !== prev.history) {
4487
+ if (props.history === void 0) {
4488
+ if (props.nodes) {
4489
+ this.replaceSnapshot(props.nodes, props.edges || [], void 0);
4490
+ }
4491
+ } else if (prev.history && isHistoryPrefix(prev.history, props.history)) {
4492
+ const prevLength = prev.history.length;
4493
+ const newFrames = props.history.slice(prevLength);
4494
+ for (const frame of newFrames) {
4495
+ this.update((u) => {
4496
+ if (frame.addNodes) u.addNodes(...frame.addNodes);
4497
+ if (frame.removeNodes) u.deleteNodes(...frame.removeNodes);
4498
+ if (frame.updateNodes) u.updateNodes(...frame.updateNodes);
4499
+ if (frame.addEdges) u.addEdges(...frame.addEdges);
4500
+ if (frame.removeEdges) u.deleteEdges(...frame.removeEdges);
4501
+ if (frame.updateEdges) u.updateEdges(...frame.updateEdges);
4502
+ if (frame.description) u.describe(frame.description);
4503
+ });
4504
+ }
4505
+ } else {
4506
+ this.replaceHistory(props.history);
4198
4507
  }
4199
- this.ws = null;
4200
4508
  }
4201
- this.onStatus?.("closed");
4509
+ const prevCanvas = prev.options?.canvas;
4510
+ const currCanvas = props.options?.canvas;
4511
+ const colorModeChanged = prevCanvas?.colorMode !== currCanvas?.colorMode;
4512
+ if (colorModeChanged && currCanvas?.colorMode) {
4513
+ this.setColorMode(currCanvas.colorMode);
4514
+ }
4515
+ const themeChanged = prevCanvas?.theme !== currCanvas?.theme;
4516
+ const nodeTypesChanged = prevCanvas?.nodeTypes !== currCanvas?.nodeTypes;
4517
+ const edgeTypesChanged = prevCanvas?.edgeTypes !== currCanvas?.edgeTypes;
4518
+ if (themeChanged || nodeTypesChanged || edgeTypesChanged) {
4519
+ this.updateStyles({
4520
+ theme: currCanvas?.theme,
4521
+ nodeTypes: currCanvas?.nodeTypes,
4522
+ edgeTypes: currCanvas?.edgeTypes
4523
+ });
4524
+ }
4525
+ this.prevProps = {
4526
+ nodes: props.nodes,
4527
+ edges: props.edges,
4528
+ history: props.history,
4529
+ options: props.options
4530
+ };
4202
4531
  }
4203
- startTotalTimeout() {
4204
- this.clearTotalTimeout();
4205
- this.totalTimeoutTimer = window.setTimeout(() => {
4206
- if (!this.closedByUser && this.ws?.readyState !== WebSocket.OPEN) {
4207
- this.closedByUser = true;
4208
- if (this.ws) {
4209
- try {
4210
- this.ws.close();
4211
- } catch {
4212
- }
4213
- this.ws = null;
4532
+ /** Cleanup resources when the graph is destroyed */
4533
+ destroy() {
4534
+ this.disconnectIngestion();
4535
+ this.canvas?.destroy();
4536
+ }
4537
+ };
4538
+ function shallowEqualArray(a, b) {
4539
+ if (a === b) return true;
4540
+ if (!a || !b) return false;
4541
+ if (a.length !== b.length) return false;
4542
+ for (let i = 0; i < a.length; i++) {
4543
+ if (a[i] !== b[i]) {
4544
+ if (typeof a[i] === "object" && a[i] !== null && typeof b[i] === "object" && b[i] !== null) {
4545
+ const aObj = a[i];
4546
+ const bObj = b[i];
4547
+ const aKeys = Object.keys(aObj);
4548
+ const bKeys = Object.keys(bObj);
4549
+ if (aKeys.length !== bKeys.length) return false;
4550
+ for (const key of aKeys) {
4551
+ if (aObj[key] !== bObj[key]) return false;
4214
4552
  }
4215
- this.clearTotalTimeout();
4216
- this.onStatus?.("error", new Error("Connection timeout after 10 seconds"));
4553
+ } else {
4554
+ return false;
4217
4555
  }
4218
- }, this.totalTimeoutMs);
4219
- }
4220
- clearTotalTimeout() {
4221
- if (this.totalTimeoutTimer !== null) {
4222
- clearTimeout(this.totalTimeoutTimer);
4223
- this.totalTimeoutTimer = null;
4224
4556
  }
4225
- this.connectStartTime = null;
4226
4557
  }
4227
- open() {
4228
- if (this.connectStartTime && Date.now() - this.connectStartTime >= this.totalTimeoutMs) {
4229
- if (!this.closedByUser) {
4230
- this.closedByUser = true;
4231
- this.clearTotalTimeout();
4232
- this.onStatus?.("error", new Error("Connection timeout after 10 seconds"));
4233
- }
4234
- return;
4558
+ return true;
4559
+ }
4560
+ function isHistoryPrefix(oldHistory, newHistory) {
4561
+ if (newHistory.length < oldHistory.length) return false;
4562
+ for (let i = 0; i < oldHistory.length; i++) {
4563
+ if (!shallowEqualUpdate(oldHistory[i], newHistory[i])) {
4564
+ return false;
4235
4565
  }
4236
- this.onStatus?.(this.ws ? "reconnecting" : "connecting");
4237
- const ws = new WebSocket(this.url);
4238
- this.ws = ws;
4239
- ws.onopen = () => {
4240
- this.clearTotalTimeout();
4241
- this.onStatus?.("connected");
4242
- };
4243
- ws.onerror = (e) => {
4244
- this.onStatus?.("error", e);
4245
- };
4246
- ws.onclose = () => {
4247
- if (this.closedByUser) {
4248
- this.onStatus?.("closed");
4249
- return;
4250
- }
4251
- if (this.connectStartTime && Date.now() - this.connectStartTime >= this.totalTimeoutMs) {
4252
- this.closedByUser = true;
4253
- this.clearTotalTimeout();
4254
- this.onStatus?.("error", new Error("Connection timeout after 10 seconds"));
4255
- return;
4256
- }
4257
- this.onStatus?.("reconnecting");
4258
- setTimeout(() => this.open(), this.reconnectMs);
4259
- };
4260
- ws.onmessage = (ev) => {
4261
- const data = typeof ev.data === "string" ? ev.data : "";
4262
- const lines = data.split("\n").map((l) => l.trim()).filter(Boolean);
4263
- for (const line of lines) {
4264
- try {
4265
- const obj = JSON.parse(line);
4266
- this.onMessage(obj);
4267
- } catch {
4268
- }
4269
- }
4270
- };
4271
4566
  }
4272
- };
4567
+ return true;
4568
+ }
4569
+ function shallowEqualUpdate(a, b) {
4570
+ if (a === b) return true;
4571
+ if (a.description !== b.description) return false;
4572
+ if (!shallowEqualArray(a.addNodes, b.addNodes)) return false;
4573
+ if (!shallowEqualArray(a.removeNodes, b.removeNodes)) return false;
4574
+ if (!shallowEqualArray(a.updateNodes, b.updateNodes)) return false;
4575
+ if (!shallowEqualArray(a.addEdges, b.addEdges)) return false;
4576
+ if (!shallowEqualArray(a.removeEdges, b.removeEdges)) return false;
4577
+ if (!shallowEqualArray(a.updateEdges, b.updateEdges)) return false;
4578
+ return true;
4579
+ }
4273
4580
 
4274
4581
  // src/api/sources/FileSystemSource.ts
4275
4582
  var FileSystemSource = class {
@@ -4333,88 +4640,13 @@ var FileSystemSource = class {
4333
4640
  }
4334
4641
  };
4335
4642
 
4336
- // src/api/sources/FileSource.ts
4337
- var FileSource = class {
4338
- url;
4339
- onMessage;
4340
- onStatus;
4341
- timer = null;
4342
- lastETag = null;
4343
- lastContent = "";
4344
- intervalMs = 1e3;
4345
- closed = false;
4346
- constructor(url, onMessage, onStatus, intervalMs = 1e3) {
4347
- this.url = url;
4348
- this.onMessage = onMessage;
4349
- this.onStatus = onStatus;
4350
- this.intervalMs = intervalMs;
4351
- }
4352
- async connect() {
4353
- this.closed = false;
4354
- this.lastETag = null;
4355
- this.lastContent = "";
4356
- this.onStatus?.("opened");
4357
- this.startPolling();
4358
- }
4359
- close() {
4360
- this.closed = true;
4361
- if (this.timer) {
4362
- window.clearInterval(this.timer);
4363
- this.timer = null;
4364
- }
4365
- this.onStatus?.("closed");
4366
- }
4367
- startPolling() {
4368
- if (this.timer) window.clearInterval(this.timer);
4369
- this.timer = window.setInterval(() => this.poll(), this.intervalMs);
4370
- this.poll();
4371
- }
4372
- async poll() {
4373
- if (this.closed) return;
4374
- try {
4375
- this.onStatus?.("reading");
4376
- const headers = {};
4377
- if (this.lastETag) {
4378
- headers["If-None-Match"] = this.lastETag;
4379
- }
4380
- const response = await fetch(this.url, { headers });
4381
- if (response.status === 304) {
4382
- return;
4383
- }
4384
- if (!response.ok) {
4385
- throw new Error(`HTTP ${response.status}: ${response.statusText}`);
4386
- }
4387
- const etag = response.headers.get("ETag");
4388
- if (etag) {
4389
- this.lastETag = etag;
4390
- }
4391
- const content = await response.text();
4392
- if (content === this.lastContent) {
4393
- return;
4394
- }
4395
- const lines = content.split("\n").map((l) => l.trim()).filter(Boolean);
4396
- const lastContentLines = this.lastContent.split("\n").map((l) => l.trim()).filter(Boolean);
4397
- const newLines = lines.slice(lastContentLines.length);
4398
- for (const line of newLines) {
4399
- try {
4400
- const obj = JSON.parse(line);
4401
- this.onMessage(obj);
4402
- } catch {
4403
- }
4404
- }
4405
- this.lastContent = content;
4406
- } catch (e) {
4407
- this.onStatus?.("error", e);
4408
- }
4409
- }
4410
- };
4411
-
4412
4643
  // src/playground/playground.ts
4413
4644
  var import_styles2 = __toESM(require("./styles.css?raw"), 1);
4414
4645
  var Playground = class {
4415
4646
  options;
4416
4647
  rootElement;
4417
4648
  currentExample;
4649
+ examples;
4418
4650
  currentGraph = null;
4419
4651
  ingest = null;
4420
4652
  isEditable = false;
@@ -4432,7 +4664,8 @@ var Playground = class {
4432
4664
  graphContainerId;
4433
4665
  constructor(options) {
4434
4666
  this.options = options;
4435
- this.exampleList = Object.keys(options.examples);
4667
+ this.examples = { ...options.examples };
4668
+ this.exampleList = Object.keys(this.examples);
4436
4669
  this.currentExample = options.defaultExample || this.exampleList[0];
4437
4670
  this.graphContainerId = `playground-graph-${Math.random().toString(36).substr(2, 9)}`;
4438
4671
  if (typeof options.root === "string") {
@@ -4463,7 +4696,7 @@ var Playground = class {
4463
4696
  }
4464
4697
  createDOM() {
4465
4698
  const exampleList = this.exampleList.map((key, i) => {
4466
- const example = this.options.examples[key];
4699
+ const example = this.examples[key];
4467
4700
  const isActive = i === 0 || key === this.currentExample;
4468
4701
  return `
4469
4702
  <button class="example-btn ${isActive ? "active" : ""}" data-example="${key}">
@@ -4549,7 +4782,14 @@ var Playground = class {
4549
4782
  });
4550
4783
  });
4551
4784
  this.rootElement.querySelectorAll(".options select, .options input").forEach((el) => {
4552
- el.addEventListener("change", () => this.renderGraph());
4785
+ el.addEventListener("change", () => {
4786
+ if (el.id === "colorMode" && this.currentGraph) {
4787
+ const mode = el.value;
4788
+ this.currentGraph.setColorMode(mode);
4789
+ } else {
4790
+ this.renderGraph();
4791
+ }
4792
+ });
4553
4793
  });
4554
4794
  this.rootElement.querySelector("#nav-first")?.addEventListener("click", () => {
4555
4795
  this.currentGraph?.nav("first");
@@ -4636,8 +4876,10 @@ var Playground = class {
4636
4876
  async renderGraph() {
4637
4877
  const container = this.rootElement.querySelector(`#${this.graphContainerId}`);
4638
4878
  if (!container) return;
4879
+ this.currentGraph?.destroy();
4880
+ this.currentGraph = null;
4639
4881
  container.innerHTML = "";
4640
- const example = this.options.examples[this.currentExample];
4882
+ const example = this.examples[this.currentExample];
4641
4883
  const options = this.getOptions(example.options);
4642
4884
  try {
4643
4885
  this.currentGraph = await graph({
@@ -4668,7 +4910,7 @@ var Playground = class {
4668
4910
  }
4669
4911
  }
4670
4912
  connectExampleSource() {
4671
- const example = this.options.examples[this.currentExample];
4913
+ const example = this.examples[this.currentExample];
4672
4914
  if (!example.source) {
4673
4915
  this.disconnectAllSources();
4674
4916
  return;
@@ -5011,7 +5253,7 @@ var Playground = class {
5011
5253
  `;
5012
5254
  }
5013
5255
  } else if (this.activeSourceType === "file") {
5014
- const example = this.options.examples[this.currentExample];
5256
+ const example = this.examples[this.currentExample];
5015
5257
  const filePath = example.source?.type === "file" ? example.source.path : "";
5016
5258
  if (this.fileStatus === "connecting") {
5017
5259
  statusDiv.innerHTML = `
@@ -5109,6 +5351,58 @@ var Playground = class {
5109
5351
  this.fsSource?.close();
5110
5352
  this.updateSourceModal();
5111
5353
  }
5354
+ /**
5355
+ * Add or update an example
5356
+ */
5357
+ addExample(key, example) {
5358
+ this.examples[key] = example;
5359
+ this.updateExampleList();
5360
+ if (this.currentExample === key) {
5361
+ this.renderGraph();
5362
+ this.connectExampleSource();
5363
+ }
5364
+ }
5365
+ /**
5366
+ * Remove an example
5367
+ */
5368
+ removeExample(key) {
5369
+ delete this.examples[key];
5370
+ if (this.currentExample === key) {
5371
+ this.exampleList = Object.keys(this.examples);
5372
+ this.currentExample = this.exampleList[0] || "";
5373
+ if (this.currentExample) {
5374
+ this.renderGraph();
5375
+ this.connectExampleSource();
5376
+ }
5377
+ }
5378
+ this.updateExampleList();
5379
+ }
5380
+ /**
5381
+ * Update the example list in the DOM
5382
+ */
5383
+ updateExampleList() {
5384
+ this.exampleList = Object.keys(this.examples);
5385
+ const exampleListEl = this.rootElement.querySelector(".example-list");
5386
+ if (!exampleListEl) return;
5387
+ exampleListEl.innerHTML = this.exampleList.map((key, i) => {
5388
+ const example = this.examples[key];
5389
+ const isActive = key === this.currentExample;
5390
+ return `
5391
+ <button class="example-btn ${isActive ? "active" : ""}" data-example="${key}">
5392
+ ${example.name}
5393
+ </button>
5394
+ `;
5395
+ }).join("");
5396
+ exampleListEl.querySelectorAll(".example-btn").forEach((btn) => {
5397
+ btn.addEventListener("click", () => {
5398
+ this.rootElement.querySelectorAll(".example-btn").forEach((b) => b.classList.remove("active"));
5399
+ btn.classList.add("active");
5400
+ this.currentExample = btn.getAttribute("data-example") || this.exampleList[0];
5401
+ this.renderGraph();
5402
+ this.connectExampleSource();
5403
+ });
5404
+ });
5405
+ }
5112
5406
  };
5113
5407
 
5114
5408
  // src/index.ts
@@ -5124,6 +5418,7 @@ var index_default = graph;
5124
5418
  FileSystemSource,
5125
5419
  Ingest,
5126
5420
  Playground,
5421
+ Updater,
5127
5422
  WebSocketSource,
5128
5423
  graph
5129
5424
  });