@3plate/graph-core 0.1.5 → 0.1.6

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
@@ -30,6 +30,11 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
30
30
  // src/index.ts
31
31
  var index_exports = {};
32
32
  __export(index_exports, {
33
+ FileSource: () => FileSource,
34
+ FileSystemSource: () => FileSystemSource,
35
+ Ingest: () => Ingest,
36
+ Playground: () => Playground,
37
+ WebSocketSource: () => WebSocketSource,
33
38
  default: () => index_default,
34
39
  graph: () => graph
35
40
  });
@@ -3557,26 +3562,50 @@ var Updater = class _Updater {
3557
3562
  this.update.addNodes.push(node);
3558
3563
  return this;
3559
3564
  }
3565
+ addNodes(...nodes) {
3566
+ this.update.addNodes.push(...nodes);
3567
+ return this;
3568
+ }
3560
3569
  deleteNode(node) {
3561
3570
  this.update.removeNodes.push(node);
3562
3571
  return this;
3563
3572
  }
3573
+ deleteNodes(...nodes) {
3574
+ this.update.removeNodes.push(...nodes);
3575
+ return this;
3576
+ }
3564
3577
  updateNode(node) {
3565
3578
  this.update.updateNodes.push(node);
3566
3579
  return this;
3567
3580
  }
3581
+ updateNodes(...nodes) {
3582
+ this.update.updateNodes.push(...nodes);
3583
+ return this;
3584
+ }
3568
3585
  addEdge(edge) {
3569
3586
  this.update.addEdges.push(edge);
3570
3587
  return this;
3571
3588
  }
3589
+ addEdges(...edges) {
3590
+ this.update.addEdges.push(...edges);
3591
+ return this;
3592
+ }
3572
3593
  deleteEdge(edge) {
3573
3594
  this.update.removeEdges.push(edge);
3574
3595
  return this;
3575
3596
  }
3597
+ deleteEdges(...edges) {
3598
+ this.update.removeEdges.push(...edges);
3599
+ return this;
3600
+ }
3576
3601
  updateEdge(edge) {
3577
3602
  this.update.updateEdges.push(edge);
3578
3603
  return this;
3579
3604
  }
3605
+ updateEdges(...edges) {
3606
+ this.update.updateEdges.push(...edges);
3607
+ return this;
3608
+ }
3580
3609
  static add(nodes, edges) {
3581
3610
  const updater = new _Updater();
3582
3611
  updater.update.addNodes = nodes;
@@ -3607,19 +3636,8 @@ var API = class {
3607
3636
  constructor(args) {
3608
3637
  this.root = args.root;
3609
3638
  this.options = applyDefaults(args.options);
3610
- let graph2 = new Graph({ options: this.options.graph });
3611
- this.state = { graph: graph2, update: null };
3612
3639
  this.events = args.events || {};
3613
- this.seq = [this.state];
3614
- this.index = 0;
3615
- this.nodeIds = /* @__PURE__ */ new Map();
3616
- this.edgeIds = /* @__PURE__ */ new Map();
3617
- this.nodeVersions = /* @__PURE__ */ new Map();
3618
- this.nodeOverrides = /* @__PURE__ */ new Map();
3619
- this.edgeOverrides = /* @__PURE__ */ new Map();
3620
- this.nodeFields = /* @__PURE__ */ new Map();
3621
- this.nextNodeId = 1;
3622
- this.nextEdgeId = 1;
3640
+ this.reset();
3623
3641
  this.canvas = new Canvas(this, {
3624
3642
  ...this.options.canvas,
3625
3643
  dummyNodeSize: this.options.graph.dummyNodeSize,
@@ -3633,6 +3651,31 @@ var API = class {
3633
3651
  this.history = [];
3634
3652
  }
3635
3653
  }
3654
+ reset() {
3655
+ let graph2 = new Graph({ options: this.options.graph });
3656
+ this.state = { graph: graph2, update: null };
3657
+ this.seq = [this.state];
3658
+ this.index = 0;
3659
+ this.nodeIds = /* @__PURE__ */ new Map();
3660
+ this.edgeIds = /* @__PURE__ */ new Map();
3661
+ this.nodeVersions = /* @__PURE__ */ new Map();
3662
+ this.nodeOverrides = /* @__PURE__ */ new Map();
3663
+ this.edgeOverrides = /* @__PURE__ */ new Map();
3664
+ this.nodeFields = /* @__PURE__ */ new Map();
3665
+ this.nextNodeId = 1;
3666
+ this.nextEdgeId = 1;
3667
+ }
3668
+ /** Initialize the API */
3669
+ async init() {
3670
+ const root = document.getElementById(this.root);
3671
+ if (!root) throw new Error("root element not found");
3672
+ root.appendChild(this.canvas.container);
3673
+ await this.applyHistory();
3674
+ }
3675
+ async applyHistory() {
3676
+ for (const update of this.history)
3677
+ await this.applyUpdate(update);
3678
+ }
3636
3679
  /** Current history index (0-based) */
3637
3680
  getHistoryIndex() {
3638
3681
  return this.index;
@@ -3645,17 +3688,25 @@ var API = class {
3645
3688
  setEditable(editable) {
3646
3689
  this.canvas.editMode.editable = editable;
3647
3690
  }
3691
+ /** Replace entire history (clears prior) */
3692
+ async replaceHistory(frames) {
3693
+ this.reset();
3694
+ this.history = frames;
3695
+ await this.applyHistory();
3696
+ }
3697
+ /** Rebuild from snapshot (nodes/edges) */
3698
+ async replaceSnapshot(nodes, edges, description) {
3699
+ this.reset();
3700
+ this.history = [{
3701
+ addNodes: nodes,
3702
+ addEdges: edges,
3703
+ description
3704
+ }];
3705
+ await this.applyHistory();
3706
+ }
3648
3707
  get graph() {
3649
3708
  return this.state.graph;
3650
3709
  }
3651
- /** Initialize the API */
3652
- async init() {
3653
- const root = document.getElementById(this.root);
3654
- if (!root) throw new Error("root element not found");
3655
- root.appendChild(this.canvas.container);
3656
- for (const update of this.history)
3657
- await this.applyUpdate(update);
3658
- }
3659
3710
  /** Navigate to a different state */
3660
3711
  nav(nav) {
3661
3712
  let newIndex;
@@ -4077,6 +4128,989 @@ var API = class {
4077
4128
  }
4078
4129
  };
4079
4130
 
4131
+ // src/api/ingest.ts
4132
+ var Ingest = class {
4133
+ constructor(api) {
4134
+ this.api = api;
4135
+ }
4136
+ /**
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)
4141
+ */
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;
4163
+ }
4164
+ }
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 {
4198
+ }
4199
+ this.ws = null;
4200
+ }
4201
+ this.onStatus?.("closed");
4202
+ }
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;
4214
+ }
4215
+ this.clearTotalTimeout();
4216
+ this.onStatus?.("error", new Error("Connection timeout after 10 seconds"));
4217
+ }
4218
+ }, this.totalTimeoutMs);
4219
+ }
4220
+ clearTotalTimeout() {
4221
+ if (this.totalTimeoutTimer !== null) {
4222
+ clearTimeout(this.totalTimeoutTimer);
4223
+ this.totalTimeoutTimer = null;
4224
+ }
4225
+ this.connectStartTime = null;
4226
+ }
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;
4235
+ }
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
+ }
4272
+ };
4273
+
4274
+ // src/api/sources/FileSystemSource.ts
4275
+ var FileSystemSource = class {
4276
+ handle = null;
4277
+ onMessage;
4278
+ onStatus;
4279
+ timer = null;
4280
+ lastSize = 0;
4281
+ filename;
4282
+ intervalMs;
4283
+ constructor(onMessage, onStatus, filename = "graph.ndjson", intervalMs = 1e3) {
4284
+ this.onMessage = onMessage;
4285
+ this.onStatus = onStatus;
4286
+ this.filename = filename;
4287
+ this.intervalMs = intervalMs;
4288
+ }
4289
+ async openDirectory() {
4290
+ try {
4291
+ const dir = await window.showDirectoryPicker?.();
4292
+ if (!dir) throw new Error("File System Access not supported or cancelled");
4293
+ const handle = await dir.getFileHandle(this.filename, { create: false });
4294
+ this.handle = handle;
4295
+ this.onStatus?.("opened", { file: this.filename });
4296
+ this.lastSize = 0;
4297
+ this.startPolling();
4298
+ } catch (e) {
4299
+ this.onStatus?.("error", e);
4300
+ }
4301
+ }
4302
+ close() {
4303
+ if (this.timer) {
4304
+ window.clearInterval(this.timer);
4305
+ this.timer = null;
4306
+ }
4307
+ this.handle = null;
4308
+ this.onStatus?.("closed");
4309
+ }
4310
+ startPolling() {
4311
+ if (this.timer) window.clearInterval(this.timer);
4312
+ this.timer = window.setInterval(() => this.readNewLines(), this.intervalMs);
4313
+ }
4314
+ async readNewLines() {
4315
+ try {
4316
+ if (!this.handle) return;
4317
+ this.onStatus?.("reading");
4318
+ const file = await this.handle.getFile();
4319
+ if (file.size === this.lastSize) return;
4320
+ const slice = await file.slice(this.lastSize).text();
4321
+ this.lastSize = file.size;
4322
+ const lines = slice.split("\n").map((l) => l.trim()).filter(Boolean);
4323
+ for (const line of lines) {
4324
+ try {
4325
+ const obj = JSON.parse(line);
4326
+ this.onMessage(obj);
4327
+ } catch {
4328
+ }
4329
+ }
4330
+ } catch (e) {
4331
+ this.onStatus?.("error", e);
4332
+ }
4333
+ }
4334
+ };
4335
+
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
+ // src/playground/playground.ts
4413
+ var import_styles2 = __toESM(require("./styles.css?raw"), 1);
4414
+ var Playground = class {
4415
+ options;
4416
+ rootElement;
4417
+ currentExample;
4418
+ currentGraph = null;
4419
+ ingest = null;
4420
+ isEditable = false;
4421
+ wsSource = null;
4422
+ fsSource = null;
4423
+ fileSource = null;
4424
+ wsStatus = "disconnected";
4425
+ fsStatus = "disconnected";
4426
+ fileStatus = "disconnected";
4427
+ activeSourceType = null;
4428
+ wsUrl = "ws://localhost:8787";
4429
+ sourceModal = null;
4430
+ helpOverlay = null;
4431
+ exampleList;
4432
+ graphContainerId;
4433
+ constructor(options) {
4434
+ this.options = options;
4435
+ this.exampleList = Object.keys(options.examples);
4436
+ this.currentExample = options.defaultExample || this.exampleList[0];
4437
+ this.graphContainerId = `playground-graph-${Math.random().toString(36).substr(2, 9)}`;
4438
+ if (typeof options.root === "string") {
4439
+ const el = document.getElementById(options.root);
4440
+ if (!el) throw new Error(`Element with id "${options.root}" not found`);
4441
+ this.rootElement = el;
4442
+ } else {
4443
+ this.rootElement = options.root;
4444
+ }
4445
+ }
4446
+ async init() {
4447
+ this.injectStyles();
4448
+ this.createDOM();
4449
+ this.setupEventListeners();
4450
+ await this.renderGraph();
4451
+ this.updateSourceIcon();
4452
+ this.connectExampleSource();
4453
+ const rebuildBtn = this.rootElement.querySelector("#rebuild");
4454
+ if (rebuildBtn) rebuildBtn.disabled = !this.isEditable;
4455
+ }
4456
+ injectStyles() {
4457
+ if (!document.getElementById("g3p-playground-styles")) {
4458
+ const styleEl = document.createElement("style");
4459
+ styleEl.id = "g3p-playground-styles";
4460
+ styleEl.textContent = import_styles2.default;
4461
+ document.head.appendChild(styleEl);
4462
+ }
4463
+ }
4464
+ createDOM() {
4465
+ const exampleList = this.exampleList.map((key, i) => {
4466
+ const example = this.options.examples[key];
4467
+ const isActive = i === 0 || key === this.currentExample;
4468
+ return `
4469
+ <button class="example-btn ${isActive ? "active" : ""}" data-example="${key}">
4470
+ ${example.name}
4471
+ </button>
4472
+ `;
4473
+ }).join("");
4474
+ this.rootElement.innerHTML = `
4475
+ <main class="playground">
4476
+ <div class="sidebar">
4477
+ <h2>Examples</h2>
4478
+ <div class="example-list">
4479
+ ${exampleList}
4480
+ </div>
4481
+
4482
+ <h2>Options</h2>
4483
+ <div class="options">
4484
+ <div class="option-group">
4485
+ <label>Orientation</label>
4486
+ <select id="orientation">
4487
+ <option value="TB">Top to Bottom</option>
4488
+ <option value="BT">Bottom to Top</option>
4489
+ <option value="LR">Left to Right</option>
4490
+ <option value="RL">Right to Left</option>
4491
+ </select>
4492
+ </div>
4493
+
4494
+ <div class="option-group">
4495
+ <label>Port Style</label>
4496
+ <select id="portStyle">
4497
+ <option value="outside">Outside</option>
4498
+ <option value="inside">Inside</option>
4499
+ </select>
4500
+ </div>
4501
+
4502
+ <div class="option-group">
4503
+ <label>
4504
+ <input type="checkbox" id="portLabelRotate" />
4505
+ Rotate Port Labels
4506
+ </label>
4507
+ </div>
4508
+
4509
+ <div class="option-group">
4510
+ <label>Theme</label>
4511
+ <select id="colorMode">
4512
+ <option value="system">System</option>
4513
+ <option value="light">Light</option>
4514
+ <option value="dark">Dark</option>
4515
+ </select>
4516
+ </div>
4517
+ </div>
4518
+ </div>
4519
+
4520
+ <div class="graph-area">
4521
+ <div class="graph-toolbar">
4522
+ <div class="nav-controls">
4523
+ <button class="nav-btn" id="nav-first" title="First (Home)">\u23EE</button>
4524
+ <button class="nav-btn" id="nav-prev" title="Previous (\u2190)">\u25C0</button>
4525
+ <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>
4526
+ <button class="nav-btn" id="nav-next" title="Next (\u2192)">\u25B6</button>
4527
+ <button class="nav-btn" id="nav-last" title="Last (End)">\u23ED</button>
4528
+ </div>
4529
+ <div class="connect-controls" style="display:flex; gap:.5rem; align-items:center;">
4530
+ <button class="nav-btn source-icon-btn" id="source-icon" title="Data Source Connection">\u{1F4E1}</button>
4531
+ </div>
4532
+ <button class="nav-btn" id="help-btn" title="How to edit">\u2753</button>
4533
+ <button class="nav-btn" id="edit-toggle" title="Toggle edit mode">\u270E Edit</button>
4534
+ <button class="nav-btn" id="rebuild" title="Rebuild graph from scratch">\u{1F504} Rebuild</button>
4535
+ </div>
4536
+ <div class="graph-container" id="${this.graphContainerId}"></div>
4537
+ </div>
4538
+ </main>
4539
+ `;
4540
+ }
4541
+ setupEventListeners() {
4542
+ this.rootElement.querySelectorAll(".example-btn").forEach((btn) => {
4543
+ btn.addEventListener("click", () => {
4544
+ this.rootElement.querySelectorAll(".example-btn").forEach((b) => b.classList.remove("active"));
4545
+ btn.classList.add("active");
4546
+ this.currentExample = btn.getAttribute("data-example") || this.exampleList[0];
4547
+ this.renderGraph();
4548
+ this.connectExampleSource();
4549
+ });
4550
+ });
4551
+ this.rootElement.querySelectorAll(".options select, .options input").forEach((el) => {
4552
+ el.addEventListener("change", () => this.renderGraph());
4553
+ });
4554
+ this.rootElement.querySelector("#nav-first")?.addEventListener("click", () => {
4555
+ this.currentGraph?.nav("first");
4556
+ this.updateHistoryLabel();
4557
+ });
4558
+ this.rootElement.querySelector("#nav-prev")?.addEventListener("click", () => {
4559
+ this.currentGraph?.nav("prev");
4560
+ this.updateHistoryLabel();
4561
+ });
4562
+ this.rootElement.querySelector("#nav-next")?.addEventListener("click", () => {
4563
+ this.currentGraph?.nav("next");
4564
+ this.updateHistoryLabel();
4565
+ });
4566
+ this.rootElement.querySelector("#nav-last")?.addEventListener("click", () => {
4567
+ this.currentGraph?.nav("last");
4568
+ this.updateHistoryLabel();
4569
+ });
4570
+ this.rootElement.querySelector("#rebuild")?.addEventListener("click", () => {
4571
+ this.currentGraph?.rebuild();
4572
+ });
4573
+ this.rootElement.querySelector("#edit-toggle")?.addEventListener("click", () => {
4574
+ this.isEditable = !this.isEditable;
4575
+ const btn = this.rootElement.querySelector("#edit-toggle");
4576
+ if (btn) btn.textContent = this.isEditable ? "\u2713 Done" : "\u270E Edit";
4577
+ const rebuildBtn = this.rootElement.querySelector("#rebuild");
4578
+ if (rebuildBtn) rebuildBtn.disabled = !this.isEditable;
4579
+ try {
4580
+ this.currentGraph?.setEditable?.(this.isEditable);
4581
+ } catch {
4582
+ }
4583
+ });
4584
+ this.rootElement.querySelector("#help-btn")?.addEventListener("click", () => this.openHelp());
4585
+ const sourceIconBtn = this.rootElement.querySelector("#source-icon");
4586
+ if (sourceIconBtn) {
4587
+ sourceIconBtn.addEventListener("click", (e) => {
4588
+ e.preventDefault();
4589
+ e.stopPropagation();
4590
+ this.openSourceModal();
4591
+ });
4592
+ }
4593
+ document.addEventListener("keydown", (e) => {
4594
+ if (!this.currentGraph) return;
4595
+ if (e.target instanceof HTMLInputElement || e.target instanceof HTMLSelectElement) return;
4596
+ switch (e.key) {
4597
+ case "Home":
4598
+ this.currentGraph.nav("first");
4599
+ this.updateHistoryLabel();
4600
+ break;
4601
+ case "End":
4602
+ this.currentGraph.nav("last");
4603
+ this.updateHistoryLabel();
4604
+ break;
4605
+ case "ArrowLeft":
4606
+ this.currentGraph.nav("prev");
4607
+ this.updateHistoryLabel();
4608
+ break;
4609
+ case "ArrowRight":
4610
+ this.currentGraph.nav("next");
4611
+ this.updateHistoryLabel();
4612
+ break;
4613
+ }
4614
+ });
4615
+ }
4616
+ getResolvedColorMode() {
4617
+ const mode = this.rootElement.querySelector("#colorMode")?.value;
4618
+ if (mode === "system") {
4619
+ return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
4620
+ }
4621
+ return mode;
4622
+ }
4623
+ getOptions(exampleOptions) {
4624
+ const orientation = this.rootElement.querySelector("#orientation")?.value;
4625
+ return {
4626
+ graph: { orientation },
4627
+ canvas: {
4628
+ width: "100%",
4629
+ height: "100%",
4630
+ colorMode: this.getResolvedColorMode(),
4631
+ editable: this.isEditable,
4632
+ ...exampleOptions?.canvas
4633
+ }
4634
+ };
4635
+ }
4636
+ async renderGraph() {
4637
+ const container = this.rootElement.querySelector(`#${this.graphContainerId}`);
4638
+ if (!container) return;
4639
+ container.innerHTML = "";
4640
+ const example = this.options.examples[this.currentExample];
4641
+ const options = this.getOptions(example.options);
4642
+ try {
4643
+ this.currentGraph = await graph({
4644
+ root: this.graphContainerId,
4645
+ nodes: example.nodes,
4646
+ edges: example.edges,
4647
+ options,
4648
+ events: {
4649
+ historyChange: () => this.updateHistoryLabel()
4650
+ }
4651
+ });
4652
+ this.ingest = new Ingest(this.currentGraph);
4653
+ this.updateHistoryLabel();
4654
+ } catch (e) {
4655
+ console.error("Failed to render graph:", e);
4656
+ container.innerHTML = '<p style="padding: 2rem; color: #ef4444;">Failed to load graph</p>';
4657
+ }
4658
+ }
4659
+ updateHistoryLabel() {
4660
+ const label = this.rootElement.querySelector("#history-label");
4661
+ if (!label || !this.currentGraph) return;
4662
+ try {
4663
+ const idx = this.currentGraph.getHistoryIndex?.() ?? 0;
4664
+ const len = this.currentGraph.getHistoryLength?.() ?? 1;
4665
+ label.textContent = `${idx + 1} / ${len}`;
4666
+ } catch {
4667
+ label.textContent = "\u2014 / \u2014";
4668
+ }
4669
+ }
4670
+ connectExampleSource() {
4671
+ const example = this.options.examples[this.currentExample];
4672
+ if (!example.source) {
4673
+ this.disconnectAllSources();
4674
+ return;
4675
+ }
4676
+ this.disconnectAllSources();
4677
+ if (example.source.type === "websocket") {
4678
+ this.wsUrl = example.source.url;
4679
+ this.wsSource = new WebSocketSource(example.source.url, this.handleIngestMessage.bind(this), this.updateWsStatus);
4680
+ this.wsSource.connect();
4681
+ } else if (example.source.type === "file") {
4682
+ this.fileSource = new FileSource(example.source.path, this.handleIngestMessage.bind(this), this.updateFileStatus);
4683
+ this.fileSource.connect();
4684
+ }
4685
+ }
4686
+ disconnectAllSources() {
4687
+ this.wsSource?.disconnect();
4688
+ this.fsSource?.close();
4689
+ this.fileSource?.close();
4690
+ this.wsSource = null;
4691
+ this.fsSource = null;
4692
+ this.fileSource = null;
4693
+ this.activeSourceType = null;
4694
+ this.wsStatus = "disconnected";
4695
+ this.fsStatus = "disconnected";
4696
+ this.fileStatus = "disconnected";
4697
+ this.updateSourceIcon();
4698
+ }
4699
+ openHelp() {
4700
+ if (!this.helpOverlay) {
4701
+ this.helpOverlay = document.createElement("div");
4702
+ this.helpOverlay.className = "modal-overlay";
4703
+ this.helpOverlay.innerHTML = `
4704
+ <div class="modal" role="dialog" aria-modal="true" aria-labelledby="help-title">
4705
+ <div class="modal-header">
4706
+ <h3 id="help-title">Editing the Graph</h3>
4707
+ <button class="modal-close" title="Close" aria-label="Close">\xD7</button>
4708
+ </div>
4709
+ <div class="modal-body">
4710
+ <p>Here's how to edit the graph:</p>
4711
+ <ul>
4712
+ <li><strong>Enable editing</strong>: Click "Edit"</li>
4713
+ <li><strong>Add a node</strong>: Double\u2011click an empty area</li>
4714
+ <li><strong>Edit a node</strong>: Double\u2011click a node</li>
4715
+ <li><strong>Edit an edge</strong>: Double\u2011click an edge</li>
4716
+ <li><strong>Create an edge</strong>: Click and drag from a node (or its port) onto another node; press Esc to cancel</li>
4717
+ <li><strong>Pan</strong>: Drag on canvas or edges; <strong>Zoom</strong>: Mouse wheel or controls</li>
4718
+ <li><strong>Rebuild</strong>: Use "Rebuild" to re-layout from scratch (enabled in edit mode)</li>
4719
+ </ul>
4720
+ <p>When you're done, click "Done" to lock the canvas.</p>
4721
+ </div>
4722
+ </div>
4723
+ `;
4724
+ document.body.appendChild(this.helpOverlay);
4725
+ this.helpOverlay.addEventListener("click", (e) => {
4726
+ const target = e.target;
4727
+ if (target.classList.contains("modal-overlay") || target.classList.contains("modal-close")) {
4728
+ this.closeHelp();
4729
+ }
4730
+ });
4731
+ }
4732
+ this.helpOverlay.style.display = "flex";
4733
+ }
4734
+ closeHelp() {
4735
+ if (this.helpOverlay) this.helpOverlay.style.display = "none";
4736
+ }
4737
+ handleIngestMessage = async (msg) => {
4738
+ if (!this.ingest) return;
4739
+ await this.ingest.apply(msg);
4740
+ };
4741
+ updateSourceIcon() {
4742
+ const iconBtn = this.rootElement.querySelector("#source-icon");
4743
+ if (!iconBtn) return;
4744
+ iconBtn.classList.remove("active", "connecting", "error");
4745
+ const isConnected = this.activeSourceType === "ws" && this.wsStatus === "connected" || this.activeSourceType === "folder" && this.fsStatus === "connected" || this.activeSourceType === "file" && this.fileStatus === "connected";
4746
+ const isConnecting = this.activeSourceType === "ws" && this.wsStatus === "connecting" || this.activeSourceType === "folder" && this.fsStatus === "opening" || this.activeSourceType === "file" && this.fileStatus === "connecting";
4747
+ const hasError = this.activeSourceType === "ws" && this.wsStatus === "error" || this.activeSourceType === "folder" && this.fsStatus === "error" || this.activeSourceType === "file" && this.fileStatus === "error";
4748
+ let icon = "\u{1F4E1}";
4749
+ if (this.activeSourceType === "folder") {
4750
+ icon = "\u{1F4C1}";
4751
+ } else if (this.activeSourceType === "file") {
4752
+ icon = "\u{1F4C4}";
4753
+ }
4754
+ if (isConnected) {
4755
+ iconBtn.classList.add("active");
4756
+ iconBtn.textContent = icon;
4757
+ } else if (isConnecting) {
4758
+ iconBtn.classList.add("connecting");
4759
+ iconBtn.textContent = icon;
4760
+ } else if (hasError) {
4761
+ iconBtn.classList.add("error");
4762
+ iconBtn.textContent = icon;
4763
+ } else {
4764
+ iconBtn.textContent = "\u{1F4E1}";
4765
+ }
4766
+ }
4767
+ updateWsStatus = (status, detail) => {
4768
+ if (status === "connecting" || status === "reconnecting") {
4769
+ this.wsStatus = "connecting";
4770
+ this.activeSourceType = "ws";
4771
+ } else if (status === "connected") {
4772
+ this.wsStatus = "connected";
4773
+ this.activeSourceType = "ws";
4774
+ if (this.fsSource && this.fsStatus === "connected") {
4775
+ this.fsSource.close();
4776
+ }
4777
+ if (this.fileSource && this.fileStatus === "connected") {
4778
+ this.fileSource.close();
4779
+ }
4780
+ } else if (status === "error") {
4781
+ this.wsStatus = "error";
4782
+ } else {
4783
+ this.wsStatus = "disconnected";
4784
+ if (this.activeSourceType === "ws") {
4785
+ this.activeSourceType = null;
4786
+ }
4787
+ }
4788
+ this.updateSourceIcon();
4789
+ this.updateSourceModal();
4790
+ };
4791
+ updateFsStatus = (status, detail) => {
4792
+ if (status === "opened") {
4793
+ this.fsStatus = "opening";
4794
+ this.activeSourceType = "folder";
4795
+ if (this.wsSource && this.wsStatus === "connected") {
4796
+ this.wsSource.disconnect();
4797
+ }
4798
+ } else if (status === "reading") {
4799
+ this.fsStatus = "connected";
4800
+ this.activeSourceType = "folder";
4801
+ } else if (status === "error") {
4802
+ this.fsStatus = "error";
4803
+ } else if (status === "closed") {
4804
+ this.fsStatus = "disconnected";
4805
+ if (this.activeSourceType === "folder") {
4806
+ this.activeSourceType = null;
4807
+ }
4808
+ } else {
4809
+ this.fsStatus = "disconnected";
4810
+ }
4811
+ this.updateSourceIcon();
4812
+ this.updateSourceModal();
4813
+ };
4814
+ updateFileStatus = (status, detail) => {
4815
+ if (status === "opened") {
4816
+ this.fileStatus = "connecting";
4817
+ this.activeSourceType = "file";
4818
+ if (this.wsSource && this.wsStatus === "connected") {
4819
+ this.wsSource.disconnect();
4820
+ }
4821
+ if (this.fsSource && this.fsStatus === "connected") {
4822
+ this.fsSource.close();
4823
+ }
4824
+ } else if (status === "reading") {
4825
+ this.fileStatus = "connected";
4826
+ this.activeSourceType = "file";
4827
+ } else if (status === "error") {
4828
+ this.fileStatus = "error";
4829
+ } else if (status === "closed") {
4830
+ this.fileStatus = "disconnected";
4831
+ if (this.activeSourceType === "file") {
4832
+ this.activeSourceType = null;
4833
+ }
4834
+ } else {
4835
+ this.fileStatus = "disconnected";
4836
+ }
4837
+ this.updateSourceIcon();
4838
+ this.updateSourceModal();
4839
+ };
4840
+ createSourceModal() {
4841
+ if (this.sourceModal) return this.sourceModal;
4842
+ this.sourceModal = document.createElement("div");
4843
+ this.sourceModal.className = "modal-overlay";
4844
+ this.sourceModal.style.display = "none";
4845
+ this.sourceModal.innerHTML = `
4846
+ <div class="modal" role="dialog" aria-modal="true" aria-labelledby="source-modal-title">
4847
+ <div class="modal-header">
4848
+ <h3 id="source-modal-title">Data Source Connection</h3>
4849
+ <button class="modal-close" title="Close" aria-label="Close">\xD7</button>
4850
+ </div>
4851
+ <div class="modal-body">
4852
+ <div class="source-type-selector">
4853
+ <button class="source-type-option" data-source="ws">\u{1F4E1} WebSocket</button>
4854
+ <button class="source-type-option" data-source="folder">\u{1F4C1} Folder</button>
4855
+ </div>
4856
+
4857
+ <div class="source-controls" data-source="ws">
4858
+ <div class="form-group">
4859
+ <label for="source-modal-url">WebSocket URL</label>
4860
+ <input type="text" id="source-modal-url" value="${this.wsUrl}" />
4861
+ </div>
4862
+ <div class="button-group">
4863
+ <button id="source-modal-connect-ws" class="primary">Connect</button>
4864
+ <button id="source-modal-disconnect-ws">Disconnect</button>
4865
+ <button id="source-modal-change-ws">Change Connection</button>
4866
+ </div>
4867
+ </div>
4868
+
4869
+ <div class="source-controls" data-source="folder">
4870
+ <div class="form-group">
4871
+ <label>File System Source</label>
4872
+ <p style="font-size: 0.875rem; color: var(--color-text-muted); margin-top: 0.25rem;">
4873
+ Select a directory containing a graph.ndjson file to watch for changes.
4874
+ </p>
4875
+ </div>
4876
+ <div class="button-group">
4877
+ <button id="source-modal-connect-folder" class="primary">Open Folder</button>
4878
+ <button id="source-modal-disconnect-folder">Disconnect</button>
4879
+ </div>
4880
+ </div>
4881
+
4882
+ <div id="source-modal-status"></div>
4883
+ </div>
4884
+ </div>
4885
+ `;
4886
+ document.body.appendChild(this.sourceModal);
4887
+ this.sourceModal.addEventListener("click", (e) => {
4888
+ const target = e.target;
4889
+ if (target.classList.contains("modal-overlay") || target.classList.contains("modal-close")) {
4890
+ this.closeSourceModal();
4891
+ }
4892
+ });
4893
+ this.sourceModal.querySelectorAll(".source-type-option").forEach((btn) => {
4894
+ btn.addEventListener("click", () => {
4895
+ const sourceType = btn.getAttribute("data-source");
4896
+ if (sourceType) {
4897
+ this.selectSourceType(sourceType);
4898
+ }
4899
+ });
4900
+ });
4901
+ document.getElementById("source-modal-connect-ws")?.addEventListener("click", () => this.handleConnect());
4902
+ document.getElementById("source-modal-disconnect-ws")?.addEventListener("click", () => this.handleDisconnect());
4903
+ document.getElementById("source-modal-change-ws")?.addEventListener("click", () => this.handleChangeConnection());
4904
+ document.getElementById("source-modal-connect-folder")?.addEventListener("click", () => this.handleOpenFolder());
4905
+ document.getElementById("source-modal-disconnect-folder")?.addEventListener("click", () => this.handleCloseFolder());
4906
+ document.getElementById("source-modal-url")?.addEventListener("keydown", (e) => {
4907
+ if (e.key === "Enter" && this.wsStatus !== "connected") {
4908
+ this.handleConnect();
4909
+ }
4910
+ });
4911
+ return this.sourceModal;
4912
+ }
4913
+ selectSourceType(type, skipUpdate = false) {
4914
+ if (!this.sourceModal) return;
4915
+ this.sourceModal.querySelectorAll(".source-type-option").forEach((btn) => {
4916
+ if (btn.getAttribute("data-source") === type) {
4917
+ btn.classList.add("active");
4918
+ } else {
4919
+ btn.classList.remove("active");
4920
+ }
4921
+ });
4922
+ this.sourceModal.querySelectorAll(".source-controls").forEach((controls) => {
4923
+ if (controls.getAttribute("data-source") === type) {
4924
+ controls.classList.add("active");
4925
+ } else {
4926
+ controls.classList.remove("active");
4927
+ }
4928
+ });
4929
+ if (!skipUpdate) {
4930
+ this.updateSourceModalContent();
4931
+ }
4932
+ }
4933
+ updateSourceModalContent() {
4934
+ if (!this.sourceModal) return;
4935
+ const urlInput = document.getElementById("source-modal-url");
4936
+ const connectWsBtn = document.getElementById("source-modal-connect-ws");
4937
+ const disconnectWsBtn = document.getElementById("source-modal-disconnect-ws");
4938
+ const changeWsBtn = document.getElementById("source-modal-change-ws");
4939
+ if (urlInput && connectWsBtn && disconnectWsBtn && changeWsBtn) {
4940
+ const isWsConnected = this.wsStatus === "connected";
4941
+ const isWsConnecting = this.wsStatus === "connecting";
4942
+ connectWsBtn.disabled = isWsConnected || isWsConnecting;
4943
+ disconnectWsBtn.disabled = !isWsConnected || isWsConnecting;
4944
+ changeWsBtn.disabled = !isWsConnected || isWsConnecting;
4945
+ urlInput.disabled = isWsConnecting;
4946
+ }
4947
+ const connectFolderBtn = document.getElementById("source-modal-connect-folder");
4948
+ const disconnectFolderBtn = document.getElementById("source-modal-disconnect-folder");
4949
+ if (connectFolderBtn && disconnectFolderBtn) {
4950
+ const isFolderConnected = this.fsStatus === "connected";
4951
+ const isFolderOpening = this.fsStatus === "opening";
4952
+ connectFolderBtn.disabled = isFolderConnected || isFolderOpening;
4953
+ disconnectFolderBtn.disabled = !isFolderConnected || isFolderOpening;
4954
+ }
4955
+ const statusDiv = document.getElementById("source-modal-status");
4956
+ if (!statusDiv) return;
4957
+ const currentUrl = urlInput?.value || this.wsUrl;
4958
+ statusDiv.innerHTML = "";
4959
+ if (this.activeSourceType === "ws") {
4960
+ if (this.wsStatus === "connecting") {
4961
+ statusDiv.innerHTML = `
4962
+ <div class="status-message info">
4963
+ <span class="loading-spinner"></span>
4964
+ Connecting to ${currentUrl}...
4965
+ </div>
4966
+ `;
4967
+ } else if (this.wsStatus === "connected") {
4968
+ statusDiv.innerHTML = `
4969
+ <div class="status-message success">
4970
+ \u2713 Connected to ${currentUrl}
4971
+ </div>
4972
+ `;
4973
+ } else if (this.wsStatus === "error") {
4974
+ statusDiv.innerHTML = `
4975
+ <div class="status-message error">
4976
+ \u2717 Connection error. Please check the URL and try again.
4977
+ </div>
4978
+ `;
4979
+ } else {
4980
+ statusDiv.innerHTML = `
4981
+ <div class="status-message info">
4982
+ Not connected
4983
+ </div>
4984
+ `;
4985
+ }
4986
+ } else if (this.activeSourceType === "folder") {
4987
+ if (this.fsStatus === "opening") {
4988
+ statusDiv.innerHTML = `
4989
+ <div class="status-message info">
4990
+ <span class="loading-spinner"></span>
4991
+ Opening folder...
4992
+ </div>
4993
+ `;
4994
+ } else if (this.fsStatus === "connected") {
4995
+ statusDiv.innerHTML = `
4996
+ <div class="status-message success">
4997
+ \u2713 Folder connected and watching for changes
4998
+ </div>
4999
+ `;
5000
+ } else if (this.fsStatus === "error") {
5001
+ statusDiv.innerHTML = `
5002
+ <div class="status-message error">
5003
+ \u2717 Error opening folder. Please try again.
5004
+ </div>
5005
+ `;
5006
+ } else {
5007
+ statusDiv.innerHTML = `
5008
+ <div class="status-message info">
5009
+ Not connected
5010
+ </div>
5011
+ `;
5012
+ }
5013
+ } else if (this.activeSourceType === "file") {
5014
+ const example = this.options.examples[this.currentExample];
5015
+ const filePath = example.source?.type === "file" ? example.source.path : "";
5016
+ if (this.fileStatus === "connecting") {
5017
+ statusDiv.innerHTML = `
5018
+ <div class="status-message info">
5019
+ <span class="loading-spinner"></span>
5020
+ Connecting to ${filePath}...
5021
+ </div>
5022
+ `;
5023
+ } else if (this.fileStatus === "connected") {
5024
+ statusDiv.innerHTML = `
5025
+ <div class="status-message success">
5026
+ \u2713 Connected to ${filePath}
5027
+ </div>
5028
+ `;
5029
+ } else if (this.fileStatus === "error") {
5030
+ statusDiv.innerHTML = `
5031
+ <div class="status-message error">
5032
+ \u2717 Error loading file. Please check the path and try again.
5033
+ </div>
5034
+ `;
5035
+ } else {
5036
+ statusDiv.innerHTML = `
5037
+ <div class="status-message info">
5038
+ Not connected
5039
+ </div>
5040
+ `;
5041
+ }
5042
+ } else {
5043
+ statusDiv.innerHTML = `
5044
+ <div class="status-message info">
5045
+ Select a source type to connect
5046
+ </div>
5047
+ `;
5048
+ }
5049
+ }
5050
+ updateSourceModal() {
5051
+ if (!this.sourceModal) return;
5052
+ const activeType = (this.activeSourceType === "file" ? "ws" : this.activeSourceType) || "ws";
5053
+ this.selectSourceType(activeType, true);
5054
+ this.updateSourceModalContent();
5055
+ }
5056
+ openSourceModal() {
5057
+ this.createSourceModal();
5058
+ if (this.sourceModal) {
5059
+ const urlInput = document.getElementById("source-modal-url");
5060
+ if (urlInput) {
5061
+ urlInput.value = this.wsUrl;
5062
+ }
5063
+ const activeType = (this.activeSourceType === "file" ? "ws" : this.activeSourceType) || "ws";
5064
+ this.selectSourceType(activeType);
5065
+ this.updateSourceModal();
5066
+ this.sourceModal.style.display = "flex";
5067
+ }
5068
+ }
5069
+ closeSourceModal() {
5070
+ if (this.sourceModal) {
5071
+ this.sourceModal.style.display = "none";
5072
+ }
5073
+ }
5074
+ handleConnect() {
5075
+ const urlInput = document.getElementById("source-modal-url");
5076
+ if (!urlInput) return;
5077
+ const url = urlInput.value.trim() || "ws://localhost:8787";
5078
+ this.wsUrl = url;
5079
+ if (this.wsSource) {
5080
+ this.wsSource.disconnect();
5081
+ }
5082
+ this.wsSource = new WebSocketSource(url, this.handleIngestMessage.bind(this), this.updateWsStatus);
5083
+ this.wsSource.connect();
5084
+ this.updateSourceModal();
5085
+ }
5086
+ handleDisconnect() {
5087
+ this.wsSource?.disconnect();
5088
+ this.updateSourceModal();
5089
+ }
5090
+ handleChangeConnection() {
5091
+ if (this.wsSource) {
5092
+ this.wsSource.disconnect();
5093
+ }
5094
+ const urlInput = document.getElementById("source-modal-url");
5095
+ if (urlInput) {
5096
+ urlInput.focus();
5097
+ urlInput.select();
5098
+ }
5099
+ this.updateSourceModal();
5100
+ }
5101
+ async handleOpenFolder() {
5102
+ if (!this.fsSource) {
5103
+ this.fsSource = new FileSystemSource(this.handleIngestMessage.bind(this), this.updateFsStatus);
5104
+ }
5105
+ this.updateSourceModal();
5106
+ await this.fsSource.openDirectory();
5107
+ }
5108
+ handleCloseFolder() {
5109
+ this.fsSource?.close();
5110
+ this.updateSourceModal();
5111
+ }
5112
+ };
5113
+
4080
5114
  // src/index.ts
4081
5115
  async function graph(args = { root: "app" }) {
4082
5116
  const api = new API(args);
@@ -4086,5 +5120,10 @@ async function graph(args = { root: "app" }) {
4086
5120
  var index_default = graph;
4087
5121
  // Annotate the CommonJS export names for ESM import in node:
4088
5122
  0 && (module.exports = {
5123
+ FileSource,
5124
+ FileSystemSource,
5125
+ Ingest,
5126
+ Playground,
5127
+ WebSocketSource,
4089
5128
  graph
4090
5129
  });