libpetri 2.0.0 → 2.1.0

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.
@@ -178,6 +178,447 @@ function directChild(parent, tagName) {
178
178
  return null;
179
179
  }
180
180
 
181
+ // src/viewer/c0-annotations.ts
182
+ function ownTitle(g) {
183
+ for (const child of Array.from(g.children)) {
184
+ if (child.tagName === "title") return child.textContent ?? "";
185
+ }
186
+ return "";
187
+ }
188
+ function enclosingClusterShortName(node) {
189
+ const cluster = node.parentElement?.closest("g.cluster");
190
+ if (!cluster) return "";
191
+ return ownTitle(cluster).replace(/^cluster_/, "");
192
+ }
193
+ function annotateSvgForC0(svg) {
194
+ const nodes = Array.from(svg.querySelectorAll("g.node"));
195
+ for (const node of nodes) {
196
+ const id = ownTitle(node);
197
+ if (!id) continue;
198
+ node.setAttribute("data-id", id);
199
+ const replicaM = /__rep__([A-Za-z0-9_]+)$/.exec(id);
200
+ if (replicaM) {
201
+ node.setAttribute("data-instance", replicaM[1]);
202
+ } else if (!node.hasAttribute("data-instance")) {
203
+ const inst = enclosingClusterShortName(node);
204
+ if (inst) node.setAttribute("data-instance", inst);
205
+ }
206
+ }
207
+ const nodeIndex = /* @__PURE__ */ new Map();
208
+ for (const n of nodes) {
209
+ const id = n.getAttribute("data-id");
210
+ if (id) nodeIndex.set(id, n);
211
+ }
212
+ for (const edge of Array.from(svg.querySelectorAll("g.edge"))) {
213
+ const title = ownTitle(edge);
214
+ const arrow = title.indexOf("->");
215
+ if (arrow <= 0) continue;
216
+ const src = title.slice(0, arrow).trim();
217
+ const dst = title.slice(arrow + 2).trim();
218
+ edge.setAttribute("data-src", src);
219
+ edge.setAttribute("data-dst", dst);
220
+ const srcInst = nodeIndex.get(src)?.getAttribute("data-instance") ?? "";
221
+ const dstInst = nodeIndex.get(dst)?.getAttribute("data-instance") ?? "";
222
+ edge.setAttribute("data-src-cluster", srcInst);
223
+ edge.setAttribute("data-dst-cluster", dstInst);
224
+ edge.classList.remove("intra-cluster", "cross-cluster");
225
+ if (srcInst !== "" && srcInst === dstInst) {
226
+ edge.classList.add("intra-cluster");
227
+ edge.setAttribute("data-cluster", srcInst);
228
+ } else {
229
+ edge.classList.add("cross-cluster");
230
+ edge.removeAttribute("data-cluster");
231
+ }
232
+ }
233
+ }
234
+
235
+ // src/viewer/chrome/sidebar.ts
236
+ var SIDEBAR_CLASS = "libpetri-sidebar";
237
+ var CHIP_CLASS = "libpetri-sidebar-chip";
238
+ var CHIP_OFF_CLASS = "is-off";
239
+ function mountSidebar(container, clusters, callbacks) {
240
+ const visibleClusters = /* @__PURE__ */ new Set();
241
+ for (const prefix of clusters.keys()) visibleClusters.add(prefix);
242
+ let includeSharedPlaces = true;
243
+ let highlightMode = true;
244
+ const emit = () => {
245
+ callbacks.onVisibilityChange({
246
+ visibleClusters: new Set(visibleClusters),
247
+ includeSharedPlaces
248
+ });
249
+ };
250
+ const root = document.createElement("aside");
251
+ root.className = SIDEBAR_CLASS;
252
+ const title = document.createElement("h4");
253
+ title.className = "libpetri-sidebar-title";
254
+ title.textContent = `Subnets (${clusters.size})`;
255
+ root.appendChild(title);
256
+ const actions = document.createElement("div");
257
+ actions.className = "libpetri-sidebar-actions";
258
+ const showAll = document.createElement("button");
259
+ showAll.type = "button";
260
+ showAll.textContent = "show all";
261
+ const hideAll = document.createElement("button");
262
+ hideAll.type = "button";
263
+ hideAll.textContent = "hide all";
264
+ actions.appendChild(showAll);
265
+ actions.appendChild(hideAll);
266
+ root.appendChild(actions);
267
+ const sharedLabel = document.createElement("label");
268
+ sharedLabel.className = "libpetri-sidebar-checkbox";
269
+ const sharedCb = document.createElement("input");
270
+ sharedCb.type = "checkbox";
271
+ sharedCb.checked = includeSharedPlaces;
272
+ sharedLabel.appendChild(sharedCb);
273
+ sharedLabel.appendChild(document.createTextNode(" Shared places"));
274
+ sharedCb.addEventListener("change", () => {
275
+ includeSharedPlaces = sharedCb.checked;
276
+ emit();
277
+ });
278
+ root.appendChild(sharedLabel);
279
+ const hlLabel = document.createElement("label");
280
+ hlLabel.className = "libpetri-sidebar-checkbox";
281
+ const hlCb = document.createElement("input");
282
+ hlCb.type = "checkbox";
283
+ hlCb.checked = highlightMode;
284
+ hlLabel.appendChild(hlCb);
285
+ hlLabel.appendChild(document.createTextNode(" Click node \u2192 highlight"));
286
+ hlCb.addEventListener("change", () => {
287
+ highlightMode = hlCb.checked;
288
+ callbacks.onHighlightModeChange(highlightMode);
289
+ });
290
+ root.appendChild(hlLabel);
291
+ const hint = document.createElement("div");
292
+ hint.className = "libpetri-sidebar-hint";
293
+ hint.innerHTML = "Click chip: toggle.<br>Shift-click: isolate.<br>Cmd/Ctrl-click: multi-select.<br>Click empty: clear highlight.";
294
+ root.appendChild(hint);
295
+ const chipList = document.createElement("ul");
296
+ chipList.className = "libpetri-sidebar-chips";
297
+ const chipMap = /* @__PURE__ */ new Map();
298
+ for (const cluster of clusters.values()) {
299
+ const li = document.createElement("li");
300
+ li.className = CHIP_CLASS;
301
+ li.style.borderLeftColor = cluster.color;
302
+ li.dataset.prefix = cluster.prefix;
303
+ const dot = document.createElement("span");
304
+ dot.className = "libpetri-sidebar-chip-dot";
305
+ dot.style.background = cluster.color;
306
+ const name = document.createElement("span");
307
+ name.className = "libpetri-sidebar-chip-name";
308
+ name.textContent = cluster.prefix;
309
+ const count = document.createElement("span");
310
+ count.className = "libpetri-sidebar-chip-count";
311
+ count.textContent = String(cluster.nodeCount);
312
+ li.appendChild(dot);
313
+ li.appendChild(name);
314
+ li.appendChild(count);
315
+ li.addEventListener("click", (ev) => {
316
+ const prefix = cluster.prefix;
317
+ if (ev.shiftKey) {
318
+ const allOff = [...clusters.keys()].every(
319
+ (k) => k === prefix ? visibleClusters.has(k) : !visibleClusters.has(k)
320
+ );
321
+ visibleClusters.clear();
322
+ if (allOff) for (const k of clusters.keys()) visibleClusters.add(k);
323
+ else visibleClusters.add(prefix);
324
+ } else if (ev.ctrlKey || ev.metaKey) {
325
+ const anyHidden = [...clusters.keys()].some((k) => !visibleClusters.has(k));
326
+ if (!anyHidden) {
327
+ visibleClusters.clear();
328
+ visibleClusters.add(prefix);
329
+ } else if (visibleClusters.has(prefix)) {
330
+ visibleClusters.delete(prefix);
331
+ } else {
332
+ visibleClusters.add(prefix);
333
+ }
334
+ } else {
335
+ if (visibleClusters.has(prefix)) visibleClusters.delete(prefix);
336
+ else visibleClusters.add(prefix);
337
+ }
338
+ refreshChips();
339
+ emit();
340
+ });
341
+ chipList.appendChild(li);
342
+ chipMap.set(cluster.prefix, li);
343
+ }
344
+ root.appendChild(chipList);
345
+ function refreshChips() {
346
+ for (const [prefix, li] of chipMap) {
347
+ li.classList.toggle(CHIP_OFF_CLASS, !visibleClusters.has(prefix));
348
+ }
349
+ }
350
+ showAll.addEventListener("click", () => {
351
+ visibleClusters.clear();
352
+ for (const k of clusters.keys()) visibleClusters.add(k);
353
+ includeSharedPlaces = true;
354
+ sharedCb.checked = true;
355
+ refreshChips();
356
+ emit();
357
+ });
358
+ hideAll.addEventListener("click", () => {
359
+ visibleClusters.clear();
360
+ includeSharedPlaces = false;
361
+ sharedCb.checked = false;
362
+ refreshChips();
363
+ emit();
364
+ });
365
+ container.appendChild(root);
366
+ emit();
367
+ return {
368
+ root,
369
+ dispose() {
370
+ if (root.parentNode === container) container.removeChild(root);
371
+ }
372
+ };
373
+ }
374
+
375
+ // src/viewer/highlight.ts
376
+ var HIGHLIGHT_CLASSES = ["is-highlight", "is-neighbor", "is-faded"];
377
+ function clearHighlightClasses(svg) {
378
+ for (const cls of HIGHLIGHT_CLASSES) {
379
+ for (const el of Array.from(svg.querySelectorAll("." + cls))) {
380
+ el.classList.remove(cls);
381
+ }
382
+ }
383
+ }
384
+ function findAllCopies(svg, focusId) {
385
+ const focus = svg.querySelector(`g.node[data-id="${cssEscape2(focusId)}"]`);
386
+ if (!focus) return /* @__PURE__ */ new Set([focusId]);
387
+ const copies = /* @__PURE__ */ new Set([focusId]);
388
+ const origLabel = focus.getAttribute("data-replica-of") ?? (focusId.startsWith("p_") ? focusId.slice(2) : null);
389
+ if (origLabel) {
390
+ const orig = svg.querySelector(`g.node[data-id="${cssEscape2("p_" + origLabel)}"]`);
391
+ if (orig) {
392
+ const id = orig.getAttribute("data-id");
393
+ if (id) copies.add(id);
394
+ }
395
+ for (const sibling of Array.from(
396
+ svg.querySelectorAll(`g.node[data-replica-of="${cssEscape2(origLabel)}"]`)
397
+ )) {
398
+ const id = sibling.getAttribute("data-id");
399
+ if (id) copies.add(id);
400
+ }
401
+ }
402
+ return copies;
403
+ }
404
+ function applyHighlight(svg, focusId) {
405
+ clearHighlightClasses(svg);
406
+ if (focusId === null) return;
407
+ const copies = findAllCopies(svg, focusId);
408
+ for (const id of copies) {
409
+ const n = svg.querySelector(`g.node[data-id="${cssEscape2(id)}"]`);
410
+ if (n && !n.classList.contains("is-hidden")) n.classList.add("is-highlight");
411
+ }
412
+ const neighbors = /* @__PURE__ */ new Set();
413
+ const pathJunctions = /* @__PURE__ */ new Set();
414
+ const pathEdges = [];
415
+ const queue = Array.from(copies);
416
+ const visited = new Set(copies);
417
+ const allEdges = Array.from(svg.querySelectorAll("g.edge"));
418
+ while (queue.length > 0) {
419
+ const nodeId = queue.shift();
420
+ for (const e of allEdges) {
421
+ if (e.classList.contains("is-hidden")) continue;
422
+ const s = e.getAttribute("data-src");
423
+ const d = e.getAttribute("data-dst");
424
+ if (s !== nodeId && d !== nodeId) continue;
425
+ const otherId = s === nodeId ? d : s;
426
+ if (!otherId) continue;
427
+ pathEdges.push(e);
428
+ if (visited.has(otherId)) continue;
429
+ visited.add(otherId);
430
+ if (otherId.startsWith("j_")) {
431
+ pathJunctions.add(otherId);
432
+ queue.push(otherId);
433
+ } else {
434
+ neighbors.add(otherId);
435
+ }
436
+ }
437
+ }
438
+ for (const e of pathEdges) e.classList.add("is-highlight");
439
+ for (const e of allEdges) {
440
+ if (e.classList.contains("is-hidden") || e.classList.contains("is-highlight")) continue;
441
+ e.classList.add("is-faded");
442
+ }
443
+ for (const n of Array.from(svg.querySelectorAll("g.node"))) {
444
+ if (n.classList.contains("is-hidden")) continue;
445
+ const id = n.getAttribute("data-id") ?? "";
446
+ if (copies.has(id)) continue;
447
+ if (neighbors.has(id)) n.classList.add("is-neighbor");
448
+ else if (pathJunctions.has(id)) continue;
449
+ else n.classList.add("is-faded");
450
+ }
451
+ for (const c of Array.from(
452
+ svg.querySelectorAll("g.cluster:not(.cluster-orchestrator)")
453
+ )) {
454
+ if (c.classList.contains("is-hidden")) continue;
455
+ c.classList.add("is-faded");
456
+ }
457
+ }
458
+ function cssEscape2(value) {
459
+ return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
460
+ }
461
+
462
+ // src/viewer/replica-tagging.ts
463
+ var REPLICA_RE = /^(p_[A-Za-z0-9_]+)__rep__/;
464
+ function semanticName(placeId) {
465
+ return placeId.replace(/^p_/, "");
466
+ }
467
+ function tagReplicas(svg) {
468
+ const originalsWithCopies = /* @__PURE__ */ new Set();
469
+ const allNodes = Array.from(svg.querySelectorAll("g.node"));
470
+ for (const g of allNodes) {
471
+ const title = g.querySelector("title")?.textContent ?? "";
472
+ const m = REPLICA_RE.exec(title);
473
+ if (m) originalsWithCopies.add(m[1]);
474
+ }
475
+ for (const g of allNodes) {
476
+ const title = g.querySelector("title")?.textContent ?? "";
477
+ const replicaMatch = REPLICA_RE.exec(title);
478
+ let semantic = null;
479
+ if (replicaMatch) {
480
+ semantic = semanticName(replicaMatch[1]);
481
+ } else if (originalsWithCopies.has(title)) {
482
+ semantic = semanticName(title);
483
+ }
484
+ if (semantic === null) continue;
485
+ g.classList.add("petri-replica");
486
+ g.setAttribute("data-replica-of", semantic);
487
+ appendSharedGlyph(g);
488
+ }
489
+ }
490
+ var SVG_NS = "http://www.w3.org/2000/svg";
491
+ var GLYPH_CLASS = "libpetri-replica-glyph";
492
+ function appendSharedGlyph(g) {
493
+ if (g.querySelector(`text.${GLYPH_CLASS}`)) return;
494
+ const shape = g.querySelector("ellipse, circle");
495
+ if (!shape) return;
496
+ let cx = 0, cy = 0, rx = 0, ry = 0;
497
+ if (shape.tagName === "ellipse") {
498
+ cx = parseFloat(shape.getAttribute("cx") ?? "0");
499
+ cy = parseFloat(shape.getAttribute("cy") ?? "0");
500
+ rx = parseFloat(shape.getAttribute("rx") ?? "0");
501
+ ry = parseFloat(shape.getAttribute("ry") ?? "0");
502
+ } else {
503
+ cx = parseFloat(shape.getAttribute("cx") ?? "0");
504
+ cy = parseFloat(shape.getAttribute("cy") ?? "0");
505
+ rx = parseFloat(shape.getAttribute("r") ?? "0");
506
+ ry = rx;
507
+ }
508
+ const text = document.createElementNS(SVG_NS, "text");
509
+ text.setAttribute("class", GLYPH_CLASS);
510
+ text.setAttribute("x", (cx + rx + 2).toFixed(2));
511
+ text.setAttribute("y", (cy - ry + 2).toFixed(2));
512
+ text.setAttribute("text-anchor", "start");
513
+ text.setAttribute("font-size", "9");
514
+ text.textContent = "\u21C4";
515
+ g.appendChild(text);
516
+ }
517
+
518
+ // src/viewer/visibility.ts
519
+ function computeVisibleOrphans(svg, visibleClusters) {
520
+ if (visibleClusters.size === 0) return /* @__PURE__ */ new Set();
521
+ const orphanNodes = Array.from(
522
+ svg.querySelectorAll("g.node:not([data-instance])")
523
+ );
524
+ const orphanIds = /* @__PURE__ */ new Set();
525
+ for (const n of orphanNodes) {
526
+ const id = n.getAttribute("data-id");
527
+ if (id) orphanIds.add(id);
528
+ }
529
+ const fwdAdj = /* @__PURE__ */ new Map();
530
+ const bwdAdj = /* @__PURE__ */ new Map();
531
+ const link = (m, k, v) => {
532
+ let s = m.get(k);
533
+ if (!s) {
534
+ s = /* @__PURE__ */ new Set();
535
+ m.set(k, s);
536
+ }
537
+ s.add(v);
538
+ };
539
+ for (const e of Array.from(svg.querySelectorAll("g.edge"))) {
540
+ const s = e.getAttribute("data-src") ?? "";
541
+ const d = e.getAttribute("data-dst") ?? "";
542
+ if (orphanIds.has(s) && orphanIds.has(d)) {
543
+ link(fwdAdj, s, d);
544
+ link(bwdAdj, d, s);
545
+ }
546
+ }
547
+ const seedUpstream = /* @__PURE__ */ new Set();
548
+ const seedDownstream = /* @__PURE__ */ new Set();
549
+ for (const e of Array.from(
550
+ svg.querySelectorAll("g.edge.cross-cluster")
551
+ )) {
552
+ const s = e.getAttribute("data-src") ?? "";
553
+ const d = e.getAttribute("data-dst") ?? "";
554
+ const sCl = e.getAttribute("data-src-cluster") ?? "";
555
+ const dCl = e.getAttribute("data-dst-cluster") ?? "";
556
+ if (orphanIds.has(s) && visibleClusters.has(dCl)) seedUpstream.add(s);
557
+ if (orphanIds.has(d) && visibleClusters.has(sCl)) seedDownstream.add(d);
558
+ }
559
+ const visible = /* @__PURE__ */ new Set();
560
+ const bfs = (seeds, adj) => {
561
+ const q = [];
562
+ for (const s of seeds) {
563
+ visible.add(s);
564
+ q.push(s);
565
+ }
566
+ while (q.length) {
567
+ const cur = q.shift();
568
+ const next = adj.get(cur);
569
+ if (!next) continue;
570
+ for (const x of next) {
571
+ if (!visible.has(x)) {
572
+ visible.add(x);
573
+ q.push(x);
574
+ }
575
+ }
576
+ }
577
+ };
578
+ bfs(seedUpstream, bwdAdj);
579
+ bfs(seedDownstream, fwdAdj);
580
+ return visible;
581
+ }
582
+ function applyVisibility(svg, state) {
583
+ annotateSvgForC0(svg);
584
+ const { visibleClusters, includeSharedPlaces } = state;
585
+ for (const c of Array.from(
586
+ svg.querySelectorAll("g.cluster:not(.cluster-orchestrator)")
587
+ )) {
588
+ const titleEl = c.querySelector(":scope > title");
589
+ const shortName = (titleEl?.textContent ?? "").replace(/^cluster_/, "");
590
+ c.classList.toggle("is-hidden", !visibleClusters.has(shortName));
591
+ }
592
+ for (const n of Array.from(svg.querySelectorAll("g.node[data-instance]"))) {
593
+ const inst = n.getAttribute("data-instance") ?? "";
594
+ n.classList.toggle("is-hidden", !visibleClusters.has(inst));
595
+ }
596
+ const visOrphans = computeVisibleOrphans(svg, visibleClusters);
597
+ for (const n of Array.from(
598
+ svg.querySelectorAll("g.node:not([data-instance])")
599
+ )) {
600
+ const id = n.getAttribute("data-id") ?? "";
601
+ n.classList.toggle("is-hidden", !visOrphans.has(id));
602
+ }
603
+ if (!includeSharedPlaces) {
604
+ for (const n of Array.from(svg.querySelectorAll("g.node.petri-replica"))) {
605
+ n.classList.add("is-hidden");
606
+ }
607
+ }
608
+ const nodeById = /* @__PURE__ */ new Map();
609
+ for (const n of Array.from(svg.querySelectorAll("g.node"))) {
610
+ const id = n.getAttribute("data-id");
611
+ if (id) nodeById.set(id, n);
612
+ }
613
+ for (const e of Array.from(svg.querySelectorAll("g.edge"))) {
614
+ const s = e.getAttribute("data-src") ?? "";
615
+ const d = e.getAttribute("data-dst") ?? "";
616
+ const sHidden = nodeById.get(s)?.classList.contains("is-hidden") ?? false;
617
+ const dHidden = nodeById.get(d)?.classList.contains("is-hidden") ?? false;
618
+ e.classList.toggle("is-hidden", sHidden || dHidden);
619
+ }
620
+ }
621
+
181
622
  // src/viewer/styles.ts
182
623
  var VIEWER_CSS_VARIABLES = [
183
624
  "--lpv-bg",
@@ -194,7 +635,24 @@ var VIEWER_CSS_VARIABLES = [
194
635
  "--lpv-active-filter-outline",
195
636
  "--lpv-legend-bg",
196
637
  "--lpv-chip-bg",
197
- "--lpv-chip-active-bg"
638
+ "--lpv-chip-active-bg",
639
+ // C0 sidebar + replica + highlight (Stage 5)
640
+ "--lpv-sidebar-bg",
641
+ "--lpv-sidebar-border",
642
+ "--lpv-sidebar-text",
643
+ "--lpv-sidebar-muted",
644
+ "--lpv-sidebar-chip-bg",
645
+ "--lpv-sidebar-chip-hover-bg",
646
+ "--lpv-sidebar-chip-off-opacity",
647
+ "--lpv-shared-glyph-color",
648
+ "--lpv-replica-fill",
649
+ "--lpv-replica-stroke",
650
+ "--lpv-highlight-stroke",
651
+ "--lpv-highlight-glow",
652
+ "--lpv-neighbor-stroke",
653
+ "--lpv-faded-node-opacity",
654
+ "--lpv-faded-edge-opacity",
655
+ "--lpv-faded-cluster-opacity"
198
656
  ];
199
657
 
200
658
  // src/viewer/index.ts
@@ -213,8 +671,9 @@ async function mount(dotSource, container, opts = {}) {
213
671
  }
214
672
  svg = existing;
215
673
  } else {
216
- const { renderDotToSvg } = await import("../render-P6GROU7J.js");
217
- svg = await renderDotToSvg(dotSource);
674
+ const renderModule = await import("../render-QK57X4TP.js");
675
+ const useElk = (opts.layout ?? "elk") === "elk";
676
+ svg = useElk ? await renderModule.renderDotToSvgWithElkLayout(dotSource) : await renderModule.renderDotToSvg(dotSource);
218
677
  container.innerHTML = "";
219
678
  container.appendChild(svg);
220
679
  }
@@ -222,10 +681,18 @@ async function mount(dotSource, container, opts = {}) {
222
681
  const panzoomInstance = attachPanzoom(svg, opts.panzoom);
223
682
  const clusters = discoverClusters(svg);
224
683
  paintClusterBorders(clusters);
684
+ const layoutMode = opts.layout ?? "elk";
685
+ if (layoutMode === "elk") {
686
+ tagReplicas(svg);
687
+ annotateSvgForC0(svg);
688
+ }
225
689
  const collapsedPrefixes = /* @__PURE__ */ new Set();
226
690
  let activeFilter = null;
691
+ let highlightedNodeId = null;
227
692
  let disposed = false;
228
693
  let chromeRoot = null;
694
+ let sidebarHandle = null;
695
+ let highlightModeEnabled = true;
229
696
  function ensureChrome() {
230
697
  if (!opts.chrome) return;
231
698
  if (chromeRoot && chromeRoot.parentNode === container) return;
@@ -234,16 +701,6 @@ async function mount(dotSource, container, opts = {}) {
234
701
  chromeRoot.style.position = "absolute";
235
702
  chromeRoot.style.inset = "0";
236
703
  chromeRoot.style.pointerEvents = "none";
237
- const legend = document.createElement("div");
238
- legend.className = "diagram-legend";
239
- legend.style.pointerEvents = "auto";
240
- legend.innerHTML = '<div class="legend-title">Clusters</div>';
241
- chromeRoot.appendChild(legend);
242
- const strip = document.createElement("div");
243
- strip.className = "diagram-filter-strip";
244
- strip.style.pointerEvents = "auto";
245
- strip.innerHTML = '<span class="filter-strip-label">Show only:</span>';
246
- chromeRoot.appendChild(strip);
247
704
  const controls = document.createElement("div");
248
705
  controls.className = "diagram-controls";
249
706
  controls.style.pointerEvents = "auto";
@@ -266,73 +723,21 @@ async function mount(dotSource, container, opts = {}) {
266
723
  container.style.position = "relative";
267
724
  }
268
725
  container.appendChild(chromeRoot);
269
- renderLegend(legend);
270
- renderFilterStrip(strip);
726
+ if (layoutMode === "elk" && clusters.size > 0) {
727
+ sidebarHandle = mountSidebar(container, clusters, {
728
+ onVisibilityChange: (state) => handle.setVisibility(state),
729
+ onHighlightModeChange: (enabled) => {
730
+ highlightModeEnabled = enabled;
731
+ if (!enabled) handle.highlight(null);
732
+ }
733
+ });
734
+ }
271
735
  }
272
736
  function toggleFullscreen(host, btn) {
273
737
  const isFs = host.classList.toggle("diagram-fullscreen");
274
738
  btn.textContent = isFs ? "Exit fullscreen" : "Fullscreen";
275
739
  requestAnimationFrame(() => handle.fit());
276
740
  }
277
- function renderLegend(legend) {
278
- legend.innerHTML = '<div class="legend-title">Clusters</div>';
279
- for (const cluster of clusters.values()) {
280
- const row = document.createElement("div");
281
- row.className = "legend-row";
282
- row.innerHTML = '<span class="legend-ribbon" style="background:' + cluster.color + '"></span><span class="legend-label"></span><span class="legend-count"></span>';
283
- row.querySelector(".legend-label").textContent = cluster.prefix;
284
- row.querySelector(".legend-count").textContent = String(cluster.nodeCount);
285
- row.addEventListener("click", () => {
286
- if (collapsedPrefixes.has(cluster.prefix)) {
287
- handle.expand(cluster.prefix);
288
- } else {
289
- handle.collapse(cluster.prefix);
290
- }
291
- });
292
- legend.appendChild(row);
293
- }
294
- }
295
- function renderFilterStrip(strip) {
296
- strip.innerHTML = '<span class="filter-strip-label">Show only:</span>';
297
- const allChip = document.createElement("button");
298
- allChip.type = "button";
299
- allChip.className = "filter-chip filter-chip-all filter-chip-active";
300
- allChip.textContent = "all";
301
- allChip.addEventListener("click", () => {
302
- handle.filter(null);
303
- });
304
- strip.appendChild(allChip);
305
- for (const cluster of clusters.values()) {
306
- const chip = document.createElement("button");
307
- chip.type = "button";
308
- chip.className = "filter-chip";
309
- chip.style.borderColor = cluster.color;
310
- chip.dataset.prefix = cluster.prefix;
311
- chip.innerHTML = '<span class="chip-dot" style="background:' + cluster.color + '"></span>';
312
- chip.append(document.createTextNode(cluster.prefix));
313
- chip.addEventListener("click", () => {
314
- if (activeFilter === cluster.prefix) {
315
- handle.filter(null);
316
- } else {
317
- handle.filter(cluster.prefix);
318
- }
319
- });
320
- strip.appendChild(chip);
321
- }
322
- }
323
- function refreshChromeActiveStates() {
324
- if (!chromeRoot) return;
325
- const strip = chromeRoot.querySelector(".diagram-filter-strip");
326
- if (!strip) return;
327
- strip.querySelectorAll(".filter-chip").forEach((chip) => {
328
- chip.classList.remove("filter-chip-active");
329
- });
330
- if (activeFilter === null) {
331
- strip.querySelector(".filter-chip-all")?.classList.add("filter-chip-active");
332
- } else {
333
- strip.querySelector(`.filter-chip[data-prefix="${activeFilter}"]`)?.classList.add("filter-chip-active");
334
- }
335
- }
336
741
  const handle = {
337
742
  svg,
338
743
  panzoom: panzoomInstance,
@@ -388,7 +793,6 @@ async function mount(dotSource, container, opts = {}) {
388
793
  activeFilter = prefix;
389
794
  applyFilter(svg, prefix);
390
795
  opts.onClusterFilter?.(prefix);
391
- refreshChromeActiveStates();
392
796
  },
393
797
  resetZoom() {
394
798
  if (disposed) return;
@@ -409,6 +813,20 @@ async function mount(dotSource, container, opts = {}) {
409
813
  isInsideCollapsedCluster(graphId) {
410
814
  return isInsideCollapsedCluster(svg, clusters, collapsedPrefixes, graphId);
411
815
  },
816
+ get highlightedNodeId() {
817
+ return highlightedNodeId;
818
+ },
819
+ highlight(nodeId) {
820
+ if (disposed) return;
821
+ if (layoutMode !== "elk") return;
822
+ highlightedNodeId = nodeId;
823
+ applyHighlight(svg, nodeId);
824
+ },
825
+ setVisibility(state) {
826
+ if (disposed) return;
827
+ if (layoutMode !== "elk") return;
828
+ applyVisibility(svg, state);
829
+ },
412
830
  dispose() {
413
831
  if (disposed) return;
414
832
  disposed = true;
@@ -416,6 +834,8 @@ async function mount(dotSource, container, opts = {}) {
416
834
  panzoomInstance.dispose();
417
835
  } catch {
418
836
  }
837
+ sidebarHandle?.dispose();
838
+ sidebarHandle = null;
419
839
  if (chromeRoot && chromeRoot.parentNode === container) {
420
840
  container.removeChild(chromeRoot);
421
841
  }
@@ -428,6 +848,21 @@ async function mount(dotSource, container, opts = {}) {
428
848
  if (preservedFilter !== null) {
429
849
  handle.filter(preservedFilter);
430
850
  }
851
+ if (layoutMode === "elk") {
852
+ svg.addEventListener("click", (ev) => {
853
+ if (!highlightModeEnabled) return;
854
+ const target = ev.target;
855
+ if (!(target instanceof Element)) return;
856
+ const nodeG = target.closest("g.node");
857
+ if (!nodeG) {
858
+ handle.highlight(null);
859
+ return;
860
+ }
861
+ const id = nodeG.getAttribute("data-id");
862
+ if (!id) return;
863
+ handle.highlight(id === highlightedNodeId ? null : id);
864
+ });
865
+ }
431
866
  ensureChrome();
432
867
  requestAnimationFrame(() => handle.fit());
433
868
  return handle;