@3plate/graph-core 0.1.5 → 0.1.7

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
@@ -3520,26 +3574,50 @@ var Updater = class _Updater {
3520
3574
  this.update.addNodes.push(node);
3521
3575
  return this;
3522
3576
  }
3577
+ addNodes(...nodes) {
3578
+ this.update.addNodes.push(...nodes);
3579
+ return this;
3580
+ }
3523
3581
  deleteNode(node) {
3524
3582
  this.update.removeNodes.push(node);
3525
3583
  return this;
3526
3584
  }
3585
+ deleteNodes(...nodes) {
3586
+ this.update.removeNodes.push(...nodes);
3587
+ return this;
3588
+ }
3527
3589
  updateNode(node) {
3528
3590
  this.update.updateNodes.push(node);
3529
3591
  return this;
3530
3592
  }
3593
+ updateNodes(...nodes) {
3594
+ this.update.updateNodes.push(...nodes);
3595
+ return this;
3596
+ }
3531
3597
  addEdge(edge) {
3532
3598
  this.update.addEdges.push(edge);
3533
3599
  return this;
3534
3600
  }
3601
+ addEdges(...edges) {
3602
+ this.update.addEdges.push(...edges);
3603
+ return this;
3604
+ }
3535
3605
  deleteEdge(edge) {
3536
3606
  this.update.removeEdges.push(edge);
3537
3607
  return this;
3538
3608
  }
3609
+ deleteEdges(...edges) {
3610
+ this.update.removeEdges.push(...edges);
3611
+ return this;
3612
+ }
3539
3613
  updateEdge(edge) {
3540
3614
  this.update.updateEdges.push(edge);
3541
3615
  return this;
3542
3616
  }
3617
+ updateEdges(...edges) {
3618
+ this.update.updateEdges.push(...edges);
3619
+ return this;
3620
+ }
3543
3621
  static add(nodes, edges) {
3544
3622
  const updater = new _Updater();
3545
3623
  updater.update.addNodes = nodes;
@@ -3570,19 +3648,8 @@ var API = class {
3570
3648
  constructor(args) {
3571
3649
  this.root = args.root;
3572
3650
  this.options = applyDefaults(args.options);
3573
- let graph2 = new Graph({ options: this.options.graph });
3574
- this.state = { graph: graph2, update: null };
3575
3651
  this.events = args.events || {};
3576
- this.seq = [this.state];
3577
- this.index = 0;
3578
- this.nodeIds = /* @__PURE__ */ new Map();
3579
- this.edgeIds = /* @__PURE__ */ new Map();
3580
- this.nodeVersions = /* @__PURE__ */ new Map();
3581
- this.nodeOverrides = /* @__PURE__ */ new Map();
3582
- this.edgeOverrides = /* @__PURE__ */ new Map();
3583
- this.nodeFields = /* @__PURE__ */ new Map();
3584
- this.nextNodeId = 1;
3585
- this.nextEdgeId = 1;
3652
+ this.reset();
3586
3653
  this.canvas = new Canvas(this, {
3587
3654
  ...this.options.canvas,
3588
3655
  dummyNodeSize: this.options.graph.dummyNodeSize,
@@ -3596,6 +3663,35 @@ var API = class {
3596
3663
  this.history = [];
3597
3664
  }
3598
3665
  }
3666
+ reset() {
3667
+ let graph2 = new Graph({ options: this.options.graph });
3668
+ this.state = { graph: graph2, update: null };
3669
+ this.seq = [this.state];
3670
+ this.index = 0;
3671
+ this.nodeIds = /* @__PURE__ */ new Map();
3672
+ this.edgeIds = /* @__PURE__ */ new Map();
3673
+ this.nodeVersions = /* @__PURE__ */ new Map();
3674
+ this.nodeOverrides = /* @__PURE__ */ new Map();
3675
+ this.edgeOverrides = /* @__PURE__ */ new Map();
3676
+ this.nodeFields = /* @__PURE__ */ new Map();
3677
+ this.nextNodeId = 1;
3678
+ this.nextEdgeId = 1;
3679
+ this.canvas?.reset?.();
3680
+ }
3681
+ /** Initialize the API */
3682
+ async init() {
3683
+ const root = document.getElementById(this.root);
3684
+ if (!root) throw new Error("root element not found");
3685
+ root.appendChild(this.canvas.container);
3686
+ await this.applyHistory();
3687
+ if (this.events.onInit) {
3688
+ this.events.onInit();
3689
+ }
3690
+ }
3691
+ async applyHistory() {
3692
+ for (const update of this.history)
3693
+ await this.applyUpdate(update);
3694
+ }
3599
3695
  /** Current history index (0-based) */
3600
3696
  getHistoryIndex() {
3601
3697
  return this.index;
@@ -3608,17 +3704,25 @@ var API = class {
3608
3704
  setEditable(editable) {
3609
3705
  this.canvas.editMode.editable = editable;
3610
3706
  }
3707
+ /** Replace entire history (clears prior) */
3708
+ async replaceHistory(frames) {
3709
+ this.reset();
3710
+ this.history = frames;
3711
+ await this.applyHistory();
3712
+ }
3713
+ /** Rebuild from snapshot (nodes/edges) */
3714
+ async replaceSnapshot(nodes, edges, description) {
3715
+ this.reset();
3716
+ this.history = [{
3717
+ addNodes: nodes,
3718
+ addEdges: edges,
3719
+ description
3720
+ }];
3721
+ await this.applyHistory();
3722
+ }
3611
3723
  get graph() {
3612
3724
  return this.state.graph;
3613
3725
  }
3614
- /** Initialize the API */
3615
- async init() {
3616
- const root = document.getElementById(this.root);
3617
- if (!root) throw new Error("root element not found");
3618
- root.appendChild(this.canvas.container);
3619
- for (const update of this.history)
3620
- await this.applyUpdate(update);
3621
- }
3622
3726
  /** Navigate to a different state */
3623
3727
  nav(nav) {
3624
3728
  let newIndex;
@@ -4038,6 +4142,1064 @@ var API = class {
4038
4142
  else
4039
4143
  await this.deleteEdge(edge.data);
4040
4144
  }
4145
+ /** Update theme and type styles dynamically */
4146
+ updateStyles(options) {
4147
+ this.canvas?.updateStyles(options);
4148
+ }
4149
+ /** Update color mode without recreating the canvas */
4150
+ setColorMode(colorMode) {
4151
+ this.canvas?.setColorMode(colorMode);
4152
+ }
4153
+ /** Cleanup resources when the graph is destroyed */
4154
+ destroy() {
4155
+ this.canvas?.destroy();
4156
+ }
4157
+ };
4158
+
4159
+ // src/api/ingest.ts
4160
+ var Ingest = class {
4161
+ constructor(api) {
4162
+ this.api = api;
4163
+ }
4164
+ /**
4165
+ * Apply an incoming ingest message to the API.
4166
+ * - snapshot: rebuild state from nodes/edges (clears prior history)
4167
+ * - update: apply incremental update
4168
+ * - history: initialize from a set of frames (clears prior history)
4169
+ */
4170
+ async apply(msg) {
4171
+ switch (msg.type) {
4172
+ case "snapshot": {
4173
+ await this.api.replaceSnapshot(msg.nodes, msg.edges, msg.description);
4174
+ break;
4175
+ }
4176
+ case "update": {
4177
+ await this.api.update((u) => {
4178
+ if (msg.addNodes) u.addNodes(...msg.addNodes);
4179
+ if (msg.removeNodes) u.deleteNodes(...msg.removeNodes);
4180
+ if (msg.updateNodes) u.updateNodes(...msg.updateNodes);
4181
+ if (msg.addEdges) u.addEdges(...msg.addEdges);
4182
+ if (msg.removeEdges) u.deleteEdges(...msg.removeEdges);
4183
+ if (msg.updateEdges) u.updateEdges(...msg.updateEdges);
4184
+ if (msg.description) u.describe(msg.description);
4185
+ });
4186
+ break;
4187
+ }
4188
+ case "history": {
4189
+ await this.api.replaceHistory(msg.frames);
4190
+ break;
4191
+ }
4192
+ }
4193
+ }
4194
+ };
4195
+
4196
+ // src/api/sources/WebSocketSource.ts
4197
+ var WebSocketSource = class {
4198
+ url;
4199
+ ws = null;
4200
+ onMessage;
4201
+ onStatus;
4202
+ reconnectMs;
4203
+ closedByUser = false;
4204
+ connectStartTime = null;
4205
+ totalTimeoutMs = 1e4;
4206
+ totalTimeoutTimer = null;
4207
+ constructor(url, onMessage, onStatus, reconnectMs = 1500) {
4208
+ this.url = url;
4209
+ this.onMessage = onMessage;
4210
+ this.onStatus = onStatus;
4211
+ this.reconnectMs = reconnectMs;
4212
+ }
4213
+ connect() {
4214
+ this.closedByUser = false;
4215
+ this.connectStartTime = Date.now();
4216
+ this.startTotalTimeout();
4217
+ this.open();
4218
+ }
4219
+ disconnect() {
4220
+ this.closedByUser = true;
4221
+ this.clearTotalTimeout();
4222
+ if (this.ws) {
4223
+ try {
4224
+ this.ws.close();
4225
+ } catch {
4226
+ }
4227
+ this.ws = null;
4228
+ }
4229
+ this.onStatus?.("closed");
4230
+ }
4231
+ startTotalTimeout() {
4232
+ this.clearTotalTimeout();
4233
+ this.totalTimeoutTimer = window.setTimeout(() => {
4234
+ if (!this.closedByUser && this.ws?.readyState !== WebSocket.OPEN) {
4235
+ this.closedByUser = true;
4236
+ if (this.ws) {
4237
+ try {
4238
+ this.ws.close();
4239
+ } catch {
4240
+ }
4241
+ this.ws = null;
4242
+ }
4243
+ this.clearTotalTimeout();
4244
+ this.onStatus?.("error", new Error("Connection timeout after 10 seconds"));
4245
+ }
4246
+ }, this.totalTimeoutMs);
4247
+ }
4248
+ clearTotalTimeout() {
4249
+ if (this.totalTimeoutTimer !== null) {
4250
+ clearTimeout(this.totalTimeoutTimer);
4251
+ this.totalTimeoutTimer = null;
4252
+ }
4253
+ this.connectStartTime = null;
4254
+ }
4255
+ open() {
4256
+ if (this.connectStartTime && Date.now() - this.connectStartTime >= this.totalTimeoutMs) {
4257
+ if (!this.closedByUser) {
4258
+ this.closedByUser = true;
4259
+ this.clearTotalTimeout();
4260
+ this.onStatus?.("error", new Error("Connection timeout after 10 seconds"));
4261
+ }
4262
+ return;
4263
+ }
4264
+ this.onStatus?.(this.ws ? "reconnecting" : "connecting");
4265
+ const ws = new WebSocket(this.url);
4266
+ this.ws = ws;
4267
+ ws.onopen = () => {
4268
+ this.clearTotalTimeout();
4269
+ this.onStatus?.("connected");
4270
+ };
4271
+ ws.onerror = (e) => {
4272
+ this.onStatus?.("error", e);
4273
+ };
4274
+ ws.onclose = () => {
4275
+ if (this.closedByUser) {
4276
+ this.onStatus?.("closed");
4277
+ return;
4278
+ }
4279
+ if (this.connectStartTime && Date.now() - this.connectStartTime >= this.totalTimeoutMs) {
4280
+ this.closedByUser = true;
4281
+ this.clearTotalTimeout();
4282
+ this.onStatus?.("error", new Error("Connection timeout after 10 seconds"));
4283
+ return;
4284
+ }
4285
+ this.onStatus?.("reconnecting");
4286
+ setTimeout(() => this.open(), this.reconnectMs);
4287
+ };
4288
+ ws.onmessage = (ev) => {
4289
+ const data = typeof ev.data === "string" ? ev.data : "";
4290
+ const lines = data.split("\n").map((l) => l.trim()).filter(Boolean);
4291
+ for (const line of lines) {
4292
+ try {
4293
+ const obj = JSON.parse(line);
4294
+ this.onMessage(obj);
4295
+ } catch {
4296
+ }
4297
+ }
4298
+ };
4299
+ }
4300
+ };
4301
+
4302
+ // src/api/sources/FileSystemSource.ts
4303
+ var FileSystemSource = class {
4304
+ handle = null;
4305
+ onMessage;
4306
+ onStatus;
4307
+ timer = null;
4308
+ lastSize = 0;
4309
+ filename;
4310
+ intervalMs;
4311
+ constructor(onMessage, onStatus, filename = "graph.ndjson", intervalMs = 1e3) {
4312
+ this.onMessage = onMessage;
4313
+ this.onStatus = onStatus;
4314
+ this.filename = filename;
4315
+ this.intervalMs = intervalMs;
4316
+ }
4317
+ async openDirectory() {
4318
+ try {
4319
+ const dir = await window.showDirectoryPicker?.();
4320
+ if (!dir) throw new Error("File System Access not supported or cancelled");
4321
+ const handle = await dir.getFileHandle(this.filename, { create: false });
4322
+ this.handle = handle;
4323
+ this.onStatus?.("opened", { file: this.filename });
4324
+ this.lastSize = 0;
4325
+ this.startPolling();
4326
+ } catch (e) {
4327
+ this.onStatus?.("error", e);
4328
+ }
4329
+ }
4330
+ close() {
4331
+ if (this.timer) {
4332
+ window.clearInterval(this.timer);
4333
+ this.timer = null;
4334
+ }
4335
+ this.handle = null;
4336
+ this.onStatus?.("closed");
4337
+ }
4338
+ startPolling() {
4339
+ if (this.timer) window.clearInterval(this.timer);
4340
+ this.timer = window.setInterval(() => this.readNewLines(), this.intervalMs);
4341
+ }
4342
+ async readNewLines() {
4343
+ try {
4344
+ if (!this.handle) return;
4345
+ this.onStatus?.("reading");
4346
+ const file = await this.handle.getFile();
4347
+ if (file.size === this.lastSize) return;
4348
+ const slice = await file.slice(this.lastSize).text();
4349
+ this.lastSize = file.size;
4350
+ const lines = slice.split("\n").map((l) => l.trim()).filter(Boolean);
4351
+ for (const line of lines) {
4352
+ try {
4353
+ const obj = JSON.parse(line);
4354
+ this.onMessage(obj);
4355
+ } catch {
4356
+ }
4357
+ }
4358
+ } catch (e) {
4359
+ this.onStatus?.("error", e);
4360
+ }
4361
+ }
4362
+ };
4363
+
4364
+ // src/api/sources/FileSource.ts
4365
+ var FileSource = class {
4366
+ url;
4367
+ onMessage;
4368
+ onStatus;
4369
+ timer = null;
4370
+ lastETag = null;
4371
+ lastContent = "";
4372
+ intervalMs = 1e3;
4373
+ closed = false;
4374
+ constructor(url, onMessage, onStatus, intervalMs = 1e3) {
4375
+ this.url = url;
4376
+ this.onMessage = onMessage;
4377
+ this.onStatus = onStatus;
4378
+ this.intervalMs = intervalMs;
4379
+ }
4380
+ async connect() {
4381
+ this.closed = false;
4382
+ this.lastETag = null;
4383
+ this.lastContent = "";
4384
+ this.onStatus?.("opened");
4385
+ this.startPolling();
4386
+ }
4387
+ close() {
4388
+ this.closed = true;
4389
+ if (this.timer) {
4390
+ window.clearInterval(this.timer);
4391
+ this.timer = null;
4392
+ }
4393
+ this.onStatus?.("closed");
4394
+ }
4395
+ startPolling() {
4396
+ if (this.timer) window.clearInterval(this.timer);
4397
+ this.timer = window.setInterval(() => this.poll(), this.intervalMs);
4398
+ this.poll();
4399
+ }
4400
+ async poll() {
4401
+ if (this.closed) return;
4402
+ try {
4403
+ this.onStatus?.("reading");
4404
+ const headers = {};
4405
+ if (this.lastETag) {
4406
+ headers["If-None-Match"] = this.lastETag;
4407
+ }
4408
+ const response = await fetch(this.url, { headers });
4409
+ if (response.status === 304) {
4410
+ return;
4411
+ }
4412
+ if (!response.ok) {
4413
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
4414
+ }
4415
+ const etag = response.headers.get("ETag");
4416
+ if (etag) {
4417
+ this.lastETag = etag;
4418
+ }
4419
+ const content = await response.text();
4420
+ if (content === this.lastContent) {
4421
+ return;
4422
+ }
4423
+ const lines = content.split("\n").map((l) => l.trim()).filter(Boolean);
4424
+ const lastContentLines = this.lastContent.split("\n").map((l) => l.trim()).filter(Boolean);
4425
+ const newLines = lines.slice(lastContentLines.length);
4426
+ for (const line of newLines) {
4427
+ try {
4428
+ const obj = JSON.parse(line);
4429
+ this.onMessage(obj);
4430
+ } catch {
4431
+ }
4432
+ }
4433
+ this.lastContent = content;
4434
+ } catch (e) {
4435
+ this.onStatus?.("error", e);
4436
+ }
4437
+ }
4438
+ };
4439
+
4440
+ // src/playground/playground.ts
4441
+ import styles2 from "./styles.css?raw";
4442
+ var Playground = class {
4443
+ options;
4444
+ rootElement;
4445
+ currentExample;
4446
+ examples;
4447
+ currentGraph = null;
4448
+ ingest = null;
4449
+ isEditable = false;
4450
+ wsSource = null;
4451
+ fsSource = null;
4452
+ fileSource = null;
4453
+ wsStatus = "disconnected";
4454
+ fsStatus = "disconnected";
4455
+ fileStatus = "disconnected";
4456
+ activeSourceType = null;
4457
+ wsUrl = "ws://localhost:8787";
4458
+ sourceModal = null;
4459
+ helpOverlay = null;
4460
+ exampleList;
4461
+ graphContainerId;
4462
+ constructor(options) {
4463
+ this.options = options;
4464
+ this.examples = { ...options.examples };
4465
+ this.exampleList = Object.keys(this.examples);
4466
+ this.currentExample = options.defaultExample || this.exampleList[0];
4467
+ this.graphContainerId = `playground-graph-${Math.random().toString(36).substr(2, 9)}`;
4468
+ if (typeof options.root === "string") {
4469
+ const el = document.getElementById(options.root);
4470
+ if (!el) throw new Error(`Element with id "${options.root}" not found`);
4471
+ this.rootElement = el;
4472
+ } else {
4473
+ this.rootElement = options.root;
4474
+ }
4475
+ }
4476
+ async init() {
4477
+ this.injectStyles();
4478
+ this.createDOM();
4479
+ this.setupEventListeners();
4480
+ await this.renderGraph();
4481
+ this.updateSourceIcon();
4482
+ this.connectExampleSource();
4483
+ const rebuildBtn = this.rootElement.querySelector("#rebuild");
4484
+ if (rebuildBtn) rebuildBtn.disabled = !this.isEditable;
4485
+ }
4486
+ injectStyles() {
4487
+ if (!document.getElementById("g3p-playground-styles")) {
4488
+ const styleEl = document.createElement("style");
4489
+ styleEl.id = "g3p-playground-styles";
4490
+ styleEl.textContent = styles2;
4491
+ document.head.appendChild(styleEl);
4492
+ }
4493
+ }
4494
+ createDOM() {
4495
+ const exampleList = this.exampleList.map((key, i) => {
4496
+ const example = this.examples[key];
4497
+ const isActive = i === 0 || key === this.currentExample;
4498
+ return `
4499
+ <button class="example-btn ${isActive ? "active" : ""}" data-example="${key}">
4500
+ ${example.name}
4501
+ </button>
4502
+ `;
4503
+ }).join("");
4504
+ this.rootElement.innerHTML = `
4505
+ <main class="playground">
4506
+ <div class="sidebar">
4507
+ <h2>Examples</h2>
4508
+ <div class="example-list">
4509
+ ${exampleList}
4510
+ </div>
4511
+
4512
+ <h2>Options</h2>
4513
+ <div class="options">
4514
+ <div class="option-group">
4515
+ <label>Orientation</label>
4516
+ <select id="orientation">
4517
+ <option value="TB">Top to Bottom</option>
4518
+ <option value="BT">Bottom to Top</option>
4519
+ <option value="LR">Left to Right</option>
4520
+ <option value="RL">Right to Left</option>
4521
+ </select>
4522
+ </div>
4523
+
4524
+ <div class="option-group">
4525
+ <label>Port Style</label>
4526
+ <select id="portStyle">
4527
+ <option value="outside">Outside</option>
4528
+ <option value="inside">Inside</option>
4529
+ </select>
4530
+ </div>
4531
+
4532
+ <div class="option-group">
4533
+ <label>
4534
+ <input type="checkbox" id="portLabelRotate" />
4535
+ Rotate Port Labels
4536
+ </label>
4537
+ </div>
4538
+
4539
+ <div class="option-group">
4540
+ <label>Theme</label>
4541
+ <select id="colorMode">
4542
+ <option value="system">System</option>
4543
+ <option value="light">Light</option>
4544
+ <option value="dark">Dark</option>
4545
+ </select>
4546
+ </div>
4547
+ </div>
4548
+ </div>
4549
+
4550
+ <div class="graph-area">
4551
+ <div class="graph-toolbar">
4552
+ <div class="nav-controls">
4553
+ <button class="nav-btn" id="nav-first" title="First (Home)">\u23EE</button>
4554
+ <button class="nav-btn" id="nav-prev" title="Previous (\u2190)">\u25C0</button>
4555
+ <span id="history-label" style="min-width: 4rem; text-align: center; display: inline-flex; align-items: center; justify-content: center; height: 2.25rem;">\u2014 / \u2014</span>
4556
+ <button class="nav-btn" id="nav-next" title="Next (\u2192)">\u25B6</button>
4557
+ <button class="nav-btn" id="nav-last" title="Last (End)">\u23ED</button>
4558
+ </div>
4559
+ <div class="connect-controls" style="display:flex; gap:.5rem; align-items:center;">
4560
+ <button class="nav-btn source-icon-btn" id="source-icon" title="Data Source Connection">\u{1F4E1}</button>
4561
+ </div>
4562
+ <button class="nav-btn" id="help-btn" title="How to edit">\u2753</button>
4563
+ <button class="nav-btn" id="edit-toggle" title="Toggle edit mode">\u270E Edit</button>
4564
+ <button class="nav-btn" id="rebuild" title="Rebuild graph from scratch">\u{1F504} Rebuild</button>
4565
+ </div>
4566
+ <div class="graph-container" id="${this.graphContainerId}"></div>
4567
+ </div>
4568
+ </main>
4569
+ `;
4570
+ }
4571
+ setupEventListeners() {
4572
+ this.rootElement.querySelectorAll(".example-btn").forEach((btn) => {
4573
+ btn.addEventListener("click", () => {
4574
+ this.rootElement.querySelectorAll(".example-btn").forEach((b) => b.classList.remove("active"));
4575
+ btn.classList.add("active");
4576
+ this.currentExample = btn.getAttribute("data-example") || this.exampleList[0];
4577
+ this.renderGraph();
4578
+ this.connectExampleSource();
4579
+ });
4580
+ });
4581
+ this.rootElement.querySelectorAll(".options select, .options input").forEach((el) => {
4582
+ el.addEventListener("change", () => {
4583
+ if (el.id === "colorMode" && this.currentGraph) {
4584
+ const mode = el.value;
4585
+ this.currentGraph.setColorMode(mode);
4586
+ } else {
4587
+ this.renderGraph();
4588
+ }
4589
+ });
4590
+ });
4591
+ this.rootElement.querySelector("#nav-first")?.addEventListener("click", () => {
4592
+ this.currentGraph?.nav("first");
4593
+ this.updateHistoryLabel();
4594
+ });
4595
+ this.rootElement.querySelector("#nav-prev")?.addEventListener("click", () => {
4596
+ this.currentGraph?.nav("prev");
4597
+ this.updateHistoryLabel();
4598
+ });
4599
+ this.rootElement.querySelector("#nav-next")?.addEventListener("click", () => {
4600
+ this.currentGraph?.nav("next");
4601
+ this.updateHistoryLabel();
4602
+ });
4603
+ this.rootElement.querySelector("#nav-last")?.addEventListener("click", () => {
4604
+ this.currentGraph?.nav("last");
4605
+ this.updateHistoryLabel();
4606
+ });
4607
+ this.rootElement.querySelector("#rebuild")?.addEventListener("click", () => {
4608
+ this.currentGraph?.rebuild();
4609
+ });
4610
+ this.rootElement.querySelector("#edit-toggle")?.addEventListener("click", () => {
4611
+ this.isEditable = !this.isEditable;
4612
+ const btn = this.rootElement.querySelector("#edit-toggle");
4613
+ if (btn) btn.textContent = this.isEditable ? "\u2713 Done" : "\u270E Edit";
4614
+ const rebuildBtn = this.rootElement.querySelector("#rebuild");
4615
+ if (rebuildBtn) rebuildBtn.disabled = !this.isEditable;
4616
+ try {
4617
+ this.currentGraph?.setEditable?.(this.isEditable);
4618
+ } catch {
4619
+ }
4620
+ });
4621
+ this.rootElement.querySelector("#help-btn")?.addEventListener("click", () => this.openHelp());
4622
+ const sourceIconBtn = this.rootElement.querySelector("#source-icon");
4623
+ if (sourceIconBtn) {
4624
+ sourceIconBtn.addEventListener("click", (e) => {
4625
+ e.preventDefault();
4626
+ e.stopPropagation();
4627
+ this.openSourceModal();
4628
+ });
4629
+ }
4630
+ document.addEventListener("keydown", (e) => {
4631
+ if (!this.currentGraph) return;
4632
+ if (e.target instanceof HTMLInputElement || e.target instanceof HTMLSelectElement) return;
4633
+ switch (e.key) {
4634
+ case "Home":
4635
+ this.currentGraph.nav("first");
4636
+ this.updateHistoryLabel();
4637
+ break;
4638
+ case "End":
4639
+ this.currentGraph.nav("last");
4640
+ this.updateHistoryLabel();
4641
+ break;
4642
+ case "ArrowLeft":
4643
+ this.currentGraph.nav("prev");
4644
+ this.updateHistoryLabel();
4645
+ break;
4646
+ case "ArrowRight":
4647
+ this.currentGraph.nav("next");
4648
+ this.updateHistoryLabel();
4649
+ break;
4650
+ }
4651
+ });
4652
+ }
4653
+ getResolvedColorMode() {
4654
+ const mode = this.rootElement.querySelector("#colorMode")?.value;
4655
+ if (mode === "system") {
4656
+ return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
4657
+ }
4658
+ return mode;
4659
+ }
4660
+ getOptions(exampleOptions) {
4661
+ const orientation = this.rootElement.querySelector("#orientation")?.value;
4662
+ return {
4663
+ graph: { orientation },
4664
+ canvas: {
4665
+ width: "100%",
4666
+ height: "100%",
4667
+ colorMode: this.getResolvedColorMode(),
4668
+ editable: this.isEditable,
4669
+ ...exampleOptions?.canvas
4670
+ }
4671
+ };
4672
+ }
4673
+ async renderGraph() {
4674
+ const container = this.rootElement.querySelector(`#${this.graphContainerId}`);
4675
+ if (!container) return;
4676
+ this.currentGraph?.destroy();
4677
+ this.currentGraph = null;
4678
+ container.innerHTML = "";
4679
+ const example = this.examples[this.currentExample];
4680
+ const options = this.getOptions(example.options);
4681
+ try {
4682
+ this.currentGraph = await graph({
4683
+ root: this.graphContainerId,
4684
+ nodes: example.nodes,
4685
+ edges: example.edges,
4686
+ options,
4687
+ events: {
4688
+ historyChange: () => this.updateHistoryLabel()
4689
+ }
4690
+ });
4691
+ this.ingest = new Ingest(this.currentGraph);
4692
+ this.updateHistoryLabel();
4693
+ } catch (e) {
4694
+ console.error("Failed to render graph:", e);
4695
+ container.innerHTML = '<p style="padding: 2rem; color: #ef4444;">Failed to load graph</p>';
4696
+ }
4697
+ }
4698
+ updateHistoryLabel() {
4699
+ const label = this.rootElement.querySelector("#history-label");
4700
+ if (!label || !this.currentGraph) return;
4701
+ try {
4702
+ const idx = this.currentGraph.getHistoryIndex?.() ?? 0;
4703
+ const len = this.currentGraph.getHistoryLength?.() ?? 1;
4704
+ label.textContent = `${idx + 1} / ${len}`;
4705
+ } catch {
4706
+ label.textContent = "\u2014 / \u2014";
4707
+ }
4708
+ }
4709
+ connectExampleSource() {
4710
+ const example = this.examples[this.currentExample];
4711
+ if (!example.source) {
4712
+ this.disconnectAllSources();
4713
+ return;
4714
+ }
4715
+ this.disconnectAllSources();
4716
+ if (example.source.type === "websocket") {
4717
+ this.wsUrl = example.source.url;
4718
+ this.wsSource = new WebSocketSource(example.source.url, this.handleIngestMessage.bind(this), this.updateWsStatus);
4719
+ this.wsSource.connect();
4720
+ } else if (example.source.type === "file") {
4721
+ this.fileSource = new FileSource(example.source.path, this.handleIngestMessage.bind(this), this.updateFileStatus);
4722
+ this.fileSource.connect();
4723
+ }
4724
+ }
4725
+ disconnectAllSources() {
4726
+ this.wsSource?.disconnect();
4727
+ this.fsSource?.close();
4728
+ this.fileSource?.close();
4729
+ this.wsSource = null;
4730
+ this.fsSource = null;
4731
+ this.fileSource = null;
4732
+ this.activeSourceType = null;
4733
+ this.wsStatus = "disconnected";
4734
+ this.fsStatus = "disconnected";
4735
+ this.fileStatus = "disconnected";
4736
+ this.updateSourceIcon();
4737
+ }
4738
+ openHelp() {
4739
+ if (!this.helpOverlay) {
4740
+ this.helpOverlay = document.createElement("div");
4741
+ this.helpOverlay.className = "modal-overlay";
4742
+ this.helpOverlay.innerHTML = `
4743
+ <div class="modal" role="dialog" aria-modal="true" aria-labelledby="help-title">
4744
+ <div class="modal-header">
4745
+ <h3 id="help-title">Editing the Graph</h3>
4746
+ <button class="modal-close" title="Close" aria-label="Close">\xD7</button>
4747
+ </div>
4748
+ <div class="modal-body">
4749
+ <p>Here's how to edit the graph:</p>
4750
+ <ul>
4751
+ <li><strong>Enable editing</strong>: Click "Edit"</li>
4752
+ <li><strong>Add a node</strong>: Double\u2011click an empty area</li>
4753
+ <li><strong>Edit a node</strong>: Double\u2011click a node</li>
4754
+ <li><strong>Edit an edge</strong>: Double\u2011click an edge</li>
4755
+ <li><strong>Create an edge</strong>: Click and drag from a node (or its port) onto another node; press Esc to cancel</li>
4756
+ <li><strong>Pan</strong>: Drag on canvas or edges; <strong>Zoom</strong>: Mouse wheel or controls</li>
4757
+ <li><strong>Rebuild</strong>: Use "Rebuild" to re-layout from scratch (enabled in edit mode)</li>
4758
+ </ul>
4759
+ <p>When you're done, click "Done" to lock the canvas.</p>
4760
+ </div>
4761
+ </div>
4762
+ `;
4763
+ document.body.appendChild(this.helpOverlay);
4764
+ this.helpOverlay.addEventListener("click", (e) => {
4765
+ const target = e.target;
4766
+ if (target.classList.contains("modal-overlay") || target.classList.contains("modal-close")) {
4767
+ this.closeHelp();
4768
+ }
4769
+ });
4770
+ }
4771
+ this.helpOverlay.style.display = "flex";
4772
+ }
4773
+ closeHelp() {
4774
+ if (this.helpOverlay) this.helpOverlay.style.display = "none";
4775
+ }
4776
+ handleIngestMessage = async (msg) => {
4777
+ if (!this.ingest) return;
4778
+ await this.ingest.apply(msg);
4779
+ };
4780
+ updateSourceIcon() {
4781
+ const iconBtn = this.rootElement.querySelector("#source-icon");
4782
+ if (!iconBtn) return;
4783
+ iconBtn.classList.remove("active", "connecting", "error");
4784
+ const isConnected = this.activeSourceType === "ws" && this.wsStatus === "connected" || this.activeSourceType === "folder" && this.fsStatus === "connected" || this.activeSourceType === "file" && this.fileStatus === "connected";
4785
+ const isConnecting = this.activeSourceType === "ws" && this.wsStatus === "connecting" || this.activeSourceType === "folder" && this.fsStatus === "opening" || this.activeSourceType === "file" && this.fileStatus === "connecting";
4786
+ const hasError = this.activeSourceType === "ws" && this.wsStatus === "error" || this.activeSourceType === "folder" && this.fsStatus === "error" || this.activeSourceType === "file" && this.fileStatus === "error";
4787
+ let icon = "\u{1F4E1}";
4788
+ if (this.activeSourceType === "folder") {
4789
+ icon = "\u{1F4C1}";
4790
+ } else if (this.activeSourceType === "file") {
4791
+ icon = "\u{1F4C4}";
4792
+ }
4793
+ if (isConnected) {
4794
+ iconBtn.classList.add("active");
4795
+ iconBtn.textContent = icon;
4796
+ } else if (isConnecting) {
4797
+ iconBtn.classList.add("connecting");
4798
+ iconBtn.textContent = icon;
4799
+ } else if (hasError) {
4800
+ iconBtn.classList.add("error");
4801
+ iconBtn.textContent = icon;
4802
+ } else {
4803
+ iconBtn.textContent = "\u{1F4E1}";
4804
+ }
4805
+ }
4806
+ updateWsStatus = (status, detail) => {
4807
+ if (status === "connecting" || status === "reconnecting") {
4808
+ this.wsStatus = "connecting";
4809
+ this.activeSourceType = "ws";
4810
+ } else if (status === "connected") {
4811
+ this.wsStatus = "connected";
4812
+ this.activeSourceType = "ws";
4813
+ if (this.fsSource && this.fsStatus === "connected") {
4814
+ this.fsSource.close();
4815
+ }
4816
+ if (this.fileSource && this.fileStatus === "connected") {
4817
+ this.fileSource.close();
4818
+ }
4819
+ } else if (status === "error") {
4820
+ this.wsStatus = "error";
4821
+ } else {
4822
+ this.wsStatus = "disconnected";
4823
+ if (this.activeSourceType === "ws") {
4824
+ this.activeSourceType = null;
4825
+ }
4826
+ }
4827
+ this.updateSourceIcon();
4828
+ this.updateSourceModal();
4829
+ };
4830
+ updateFsStatus = (status, detail) => {
4831
+ if (status === "opened") {
4832
+ this.fsStatus = "opening";
4833
+ this.activeSourceType = "folder";
4834
+ if (this.wsSource && this.wsStatus === "connected") {
4835
+ this.wsSource.disconnect();
4836
+ }
4837
+ } else if (status === "reading") {
4838
+ this.fsStatus = "connected";
4839
+ this.activeSourceType = "folder";
4840
+ } else if (status === "error") {
4841
+ this.fsStatus = "error";
4842
+ } else if (status === "closed") {
4843
+ this.fsStatus = "disconnected";
4844
+ if (this.activeSourceType === "folder") {
4845
+ this.activeSourceType = null;
4846
+ }
4847
+ } else {
4848
+ this.fsStatus = "disconnected";
4849
+ }
4850
+ this.updateSourceIcon();
4851
+ this.updateSourceModal();
4852
+ };
4853
+ updateFileStatus = (status, detail) => {
4854
+ if (status === "opened") {
4855
+ this.fileStatus = "connecting";
4856
+ this.activeSourceType = "file";
4857
+ if (this.wsSource && this.wsStatus === "connected") {
4858
+ this.wsSource.disconnect();
4859
+ }
4860
+ if (this.fsSource && this.fsStatus === "connected") {
4861
+ this.fsSource.close();
4862
+ }
4863
+ } else if (status === "reading") {
4864
+ this.fileStatus = "connected";
4865
+ this.activeSourceType = "file";
4866
+ } else if (status === "error") {
4867
+ this.fileStatus = "error";
4868
+ } else if (status === "closed") {
4869
+ this.fileStatus = "disconnected";
4870
+ if (this.activeSourceType === "file") {
4871
+ this.activeSourceType = null;
4872
+ }
4873
+ } else {
4874
+ this.fileStatus = "disconnected";
4875
+ }
4876
+ this.updateSourceIcon();
4877
+ this.updateSourceModal();
4878
+ };
4879
+ createSourceModal() {
4880
+ if (this.sourceModal) return this.sourceModal;
4881
+ this.sourceModal = document.createElement("div");
4882
+ this.sourceModal.className = "modal-overlay";
4883
+ this.sourceModal.style.display = "none";
4884
+ this.sourceModal.innerHTML = `
4885
+ <div class="modal" role="dialog" aria-modal="true" aria-labelledby="source-modal-title">
4886
+ <div class="modal-header">
4887
+ <h3 id="source-modal-title">Data Source Connection</h3>
4888
+ <button class="modal-close" title="Close" aria-label="Close">\xD7</button>
4889
+ </div>
4890
+ <div class="modal-body">
4891
+ <div class="source-type-selector">
4892
+ <button class="source-type-option" data-source="ws">\u{1F4E1} WebSocket</button>
4893
+ <button class="source-type-option" data-source="folder">\u{1F4C1} Folder</button>
4894
+ </div>
4895
+
4896
+ <div class="source-controls" data-source="ws">
4897
+ <div class="form-group">
4898
+ <label for="source-modal-url">WebSocket URL</label>
4899
+ <input type="text" id="source-modal-url" value="${this.wsUrl}" />
4900
+ </div>
4901
+ <div class="button-group">
4902
+ <button id="source-modal-connect-ws" class="primary">Connect</button>
4903
+ <button id="source-modal-disconnect-ws">Disconnect</button>
4904
+ <button id="source-modal-change-ws">Change Connection</button>
4905
+ </div>
4906
+ </div>
4907
+
4908
+ <div class="source-controls" data-source="folder">
4909
+ <div class="form-group">
4910
+ <label>File System Source</label>
4911
+ <p style="font-size: 0.875rem; color: var(--color-text-muted); margin-top: 0.25rem;">
4912
+ Select a directory containing a graph.ndjson file to watch for changes.
4913
+ </p>
4914
+ </div>
4915
+ <div class="button-group">
4916
+ <button id="source-modal-connect-folder" class="primary">Open Folder</button>
4917
+ <button id="source-modal-disconnect-folder">Disconnect</button>
4918
+ </div>
4919
+ </div>
4920
+
4921
+ <div id="source-modal-status"></div>
4922
+ </div>
4923
+ </div>
4924
+ `;
4925
+ document.body.appendChild(this.sourceModal);
4926
+ this.sourceModal.addEventListener("click", (e) => {
4927
+ const target = e.target;
4928
+ if (target.classList.contains("modal-overlay") || target.classList.contains("modal-close")) {
4929
+ this.closeSourceModal();
4930
+ }
4931
+ });
4932
+ this.sourceModal.querySelectorAll(".source-type-option").forEach((btn) => {
4933
+ btn.addEventListener("click", () => {
4934
+ const sourceType = btn.getAttribute("data-source");
4935
+ if (sourceType) {
4936
+ this.selectSourceType(sourceType);
4937
+ }
4938
+ });
4939
+ });
4940
+ document.getElementById("source-modal-connect-ws")?.addEventListener("click", () => this.handleConnect());
4941
+ document.getElementById("source-modal-disconnect-ws")?.addEventListener("click", () => this.handleDisconnect());
4942
+ document.getElementById("source-modal-change-ws")?.addEventListener("click", () => this.handleChangeConnection());
4943
+ document.getElementById("source-modal-connect-folder")?.addEventListener("click", () => this.handleOpenFolder());
4944
+ document.getElementById("source-modal-disconnect-folder")?.addEventListener("click", () => this.handleCloseFolder());
4945
+ document.getElementById("source-modal-url")?.addEventListener("keydown", (e) => {
4946
+ if (e.key === "Enter" && this.wsStatus !== "connected") {
4947
+ this.handleConnect();
4948
+ }
4949
+ });
4950
+ return this.sourceModal;
4951
+ }
4952
+ selectSourceType(type, skipUpdate = false) {
4953
+ if (!this.sourceModal) return;
4954
+ this.sourceModal.querySelectorAll(".source-type-option").forEach((btn) => {
4955
+ if (btn.getAttribute("data-source") === type) {
4956
+ btn.classList.add("active");
4957
+ } else {
4958
+ btn.classList.remove("active");
4959
+ }
4960
+ });
4961
+ this.sourceModal.querySelectorAll(".source-controls").forEach((controls) => {
4962
+ if (controls.getAttribute("data-source") === type) {
4963
+ controls.classList.add("active");
4964
+ } else {
4965
+ controls.classList.remove("active");
4966
+ }
4967
+ });
4968
+ if (!skipUpdate) {
4969
+ this.updateSourceModalContent();
4970
+ }
4971
+ }
4972
+ updateSourceModalContent() {
4973
+ if (!this.sourceModal) return;
4974
+ const urlInput = document.getElementById("source-modal-url");
4975
+ const connectWsBtn = document.getElementById("source-modal-connect-ws");
4976
+ const disconnectWsBtn = document.getElementById("source-modal-disconnect-ws");
4977
+ const changeWsBtn = document.getElementById("source-modal-change-ws");
4978
+ if (urlInput && connectWsBtn && disconnectWsBtn && changeWsBtn) {
4979
+ const isWsConnected = this.wsStatus === "connected";
4980
+ const isWsConnecting = this.wsStatus === "connecting";
4981
+ connectWsBtn.disabled = isWsConnected || isWsConnecting;
4982
+ disconnectWsBtn.disabled = !isWsConnected || isWsConnecting;
4983
+ changeWsBtn.disabled = !isWsConnected || isWsConnecting;
4984
+ urlInput.disabled = isWsConnecting;
4985
+ }
4986
+ const connectFolderBtn = document.getElementById("source-modal-connect-folder");
4987
+ const disconnectFolderBtn = document.getElementById("source-modal-disconnect-folder");
4988
+ if (connectFolderBtn && disconnectFolderBtn) {
4989
+ const isFolderConnected = this.fsStatus === "connected";
4990
+ const isFolderOpening = this.fsStatus === "opening";
4991
+ connectFolderBtn.disabled = isFolderConnected || isFolderOpening;
4992
+ disconnectFolderBtn.disabled = !isFolderConnected || isFolderOpening;
4993
+ }
4994
+ const statusDiv = document.getElementById("source-modal-status");
4995
+ if (!statusDiv) return;
4996
+ const currentUrl = urlInput?.value || this.wsUrl;
4997
+ statusDiv.innerHTML = "";
4998
+ if (this.activeSourceType === "ws") {
4999
+ if (this.wsStatus === "connecting") {
5000
+ statusDiv.innerHTML = `
5001
+ <div class="status-message info">
5002
+ <span class="loading-spinner"></span>
5003
+ Connecting to ${currentUrl}...
5004
+ </div>
5005
+ `;
5006
+ } else if (this.wsStatus === "connected") {
5007
+ statusDiv.innerHTML = `
5008
+ <div class="status-message success">
5009
+ \u2713 Connected to ${currentUrl}
5010
+ </div>
5011
+ `;
5012
+ } else if (this.wsStatus === "error") {
5013
+ statusDiv.innerHTML = `
5014
+ <div class="status-message error">
5015
+ \u2717 Connection error. Please check the URL and try again.
5016
+ </div>
5017
+ `;
5018
+ } else {
5019
+ statusDiv.innerHTML = `
5020
+ <div class="status-message info">
5021
+ Not connected
5022
+ </div>
5023
+ `;
5024
+ }
5025
+ } else if (this.activeSourceType === "folder") {
5026
+ if (this.fsStatus === "opening") {
5027
+ statusDiv.innerHTML = `
5028
+ <div class="status-message info">
5029
+ <span class="loading-spinner"></span>
5030
+ Opening folder...
5031
+ </div>
5032
+ `;
5033
+ } else if (this.fsStatus === "connected") {
5034
+ statusDiv.innerHTML = `
5035
+ <div class="status-message success">
5036
+ \u2713 Folder connected and watching for changes
5037
+ </div>
5038
+ `;
5039
+ } else if (this.fsStatus === "error") {
5040
+ statusDiv.innerHTML = `
5041
+ <div class="status-message error">
5042
+ \u2717 Error opening folder. Please try again.
5043
+ </div>
5044
+ `;
5045
+ } else {
5046
+ statusDiv.innerHTML = `
5047
+ <div class="status-message info">
5048
+ Not connected
5049
+ </div>
5050
+ `;
5051
+ }
5052
+ } else if (this.activeSourceType === "file") {
5053
+ const example = this.examples[this.currentExample];
5054
+ const filePath = example.source?.type === "file" ? example.source.path : "";
5055
+ if (this.fileStatus === "connecting") {
5056
+ statusDiv.innerHTML = `
5057
+ <div class="status-message info">
5058
+ <span class="loading-spinner"></span>
5059
+ Connecting to ${filePath}...
5060
+ </div>
5061
+ `;
5062
+ } else if (this.fileStatus === "connected") {
5063
+ statusDiv.innerHTML = `
5064
+ <div class="status-message success">
5065
+ \u2713 Connected to ${filePath}
5066
+ </div>
5067
+ `;
5068
+ } else if (this.fileStatus === "error") {
5069
+ statusDiv.innerHTML = `
5070
+ <div class="status-message error">
5071
+ \u2717 Error loading file. Please check the path and try again.
5072
+ </div>
5073
+ `;
5074
+ } else {
5075
+ statusDiv.innerHTML = `
5076
+ <div class="status-message info">
5077
+ Not connected
5078
+ </div>
5079
+ `;
5080
+ }
5081
+ } else {
5082
+ statusDiv.innerHTML = `
5083
+ <div class="status-message info">
5084
+ Select a source type to connect
5085
+ </div>
5086
+ `;
5087
+ }
5088
+ }
5089
+ updateSourceModal() {
5090
+ if (!this.sourceModal) return;
5091
+ const activeType = (this.activeSourceType === "file" ? "ws" : this.activeSourceType) || "ws";
5092
+ this.selectSourceType(activeType, true);
5093
+ this.updateSourceModalContent();
5094
+ }
5095
+ openSourceModal() {
5096
+ this.createSourceModal();
5097
+ if (this.sourceModal) {
5098
+ const urlInput = document.getElementById("source-modal-url");
5099
+ if (urlInput) {
5100
+ urlInput.value = this.wsUrl;
5101
+ }
5102
+ const activeType = (this.activeSourceType === "file" ? "ws" : this.activeSourceType) || "ws";
5103
+ this.selectSourceType(activeType);
5104
+ this.updateSourceModal();
5105
+ this.sourceModal.style.display = "flex";
5106
+ }
5107
+ }
5108
+ closeSourceModal() {
5109
+ if (this.sourceModal) {
5110
+ this.sourceModal.style.display = "none";
5111
+ }
5112
+ }
5113
+ handleConnect() {
5114
+ const urlInput = document.getElementById("source-modal-url");
5115
+ if (!urlInput) return;
5116
+ const url = urlInput.value.trim() || "ws://localhost:8787";
5117
+ this.wsUrl = url;
5118
+ if (this.wsSource) {
5119
+ this.wsSource.disconnect();
5120
+ }
5121
+ this.wsSource = new WebSocketSource(url, this.handleIngestMessage.bind(this), this.updateWsStatus);
5122
+ this.wsSource.connect();
5123
+ this.updateSourceModal();
5124
+ }
5125
+ handleDisconnect() {
5126
+ this.wsSource?.disconnect();
5127
+ this.updateSourceModal();
5128
+ }
5129
+ handleChangeConnection() {
5130
+ if (this.wsSource) {
5131
+ this.wsSource.disconnect();
5132
+ }
5133
+ const urlInput = document.getElementById("source-modal-url");
5134
+ if (urlInput) {
5135
+ urlInput.focus();
5136
+ urlInput.select();
5137
+ }
5138
+ this.updateSourceModal();
5139
+ }
5140
+ async handleOpenFolder() {
5141
+ if (!this.fsSource) {
5142
+ this.fsSource = new FileSystemSource(this.handleIngestMessage.bind(this), this.updateFsStatus);
5143
+ }
5144
+ this.updateSourceModal();
5145
+ await this.fsSource.openDirectory();
5146
+ }
5147
+ handleCloseFolder() {
5148
+ this.fsSource?.close();
5149
+ this.updateSourceModal();
5150
+ }
5151
+ /**
5152
+ * Add or update an example
5153
+ */
5154
+ addExample(key, example) {
5155
+ this.examples[key] = example;
5156
+ this.updateExampleList();
5157
+ if (this.currentExample === key) {
5158
+ this.renderGraph();
5159
+ this.connectExampleSource();
5160
+ }
5161
+ }
5162
+ /**
5163
+ * Remove an example
5164
+ */
5165
+ removeExample(key) {
5166
+ delete this.examples[key];
5167
+ if (this.currentExample === key) {
5168
+ this.exampleList = Object.keys(this.examples);
5169
+ this.currentExample = this.exampleList[0] || "";
5170
+ if (this.currentExample) {
5171
+ this.renderGraph();
5172
+ this.connectExampleSource();
5173
+ }
5174
+ }
5175
+ this.updateExampleList();
5176
+ }
5177
+ /**
5178
+ * Update the example list in the DOM
5179
+ */
5180
+ updateExampleList() {
5181
+ this.exampleList = Object.keys(this.examples);
5182
+ const exampleListEl = this.rootElement.querySelector(".example-list");
5183
+ if (!exampleListEl) return;
5184
+ exampleListEl.innerHTML = this.exampleList.map((key, i) => {
5185
+ const example = this.examples[key];
5186
+ const isActive = key === this.currentExample;
5187
+ return `
5188
+ <button class="example-btn ${isActive ? "active" : ""}" data-example="${key}">
5189
+ ${example.name}
5190
+ </button>
5191
+ `;
5192
+ }).join("");
5193
+ exampleListEl.querySelectorAll(".example-btn").forEach((btn) => {
5194
+ btn.addEventListener("click", () => {
5195
+ this.rootElement.querySelectorAll(".example-btn").forEach((b) => b.classList.remove("active"));
5196
+ btn.classList.add("active");
5197
+ this.currentExample = btn.getAttribute("data-example") || this.exampleList[0];
5198
+ this.renderGraph();
5199
+ this.connectExampleSource();
5200
+ });
5201
+ });
5202
+ }
4041
5203
  };
4042
5204
 
4043
5205
  // src/index.ts
@@ -4048,6 +5210,12 @@ async function graph(args = { root: "app" }) {
4048
5210
  }
4049
5211
  var index_default = graph;
4050
5212
  export {
5213
+ FileSource,
5214
+ FileSystemSource,
5215
+ Ingest,
5216
+ Playground,
5217
+ Updater,
5218
+ WebSocketSource,
4051
5219
  index_default as default,
4052
5220
  graph
4053
5221
  };