@sleetdrop/dsh-plugin-topology 0.3.1 → 0.5.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.
package/lib/client.js CHANGED
@@ -4392,6 +4392,7 @@ const render_parameter_0$schema = union([
4392
4392
  literal("svg")
4393
4393
  ]);
4394
4394
  const render_parameter_1$schema = union([literal("TB"), literal("LR")]);
4395
+ const render_parameter_2$schema = union([literal("light"), literal("dark")]).optional();
4395
4396
  const render_result$schema = string();
4396
4397
  const TYPERT_REMOTE = {
4397
4398
  package: "@sleetdrop/dsh-plugin-topology",
@@ -4405,7 +4406,7 @@ const TYPERT_REMOTE = {
4405
4406
  result: {
4406
4407
  mode: "strict",
4407
4408
  typeSymbol: "@sleetdrop/dsh-plugin-topology/types#TopologyAnalysis",
4408
- schema: analyze_result$schema
4409
+ create: () => analyze_result$schema
4409
4410
  }
4410
4411
  }, {
4411
4412
  id: "@sleetdrop/dsh-plugin-topology#pluginTopology/render",
@@ -4413,29 +4414,43 @@ const TYPERT_REMOTE = {
4413
4414
  namespace: "pluginTopology",
4414
4415
  method: "render",
4415
4416
  invocation: { kind: "direct" },
4416
- parameters: [{
4417
- name: "format",
4418
- wire: "format",
4419
- source: "json",
4420
- codec: {
4421
- mode: "strict",
4422
- typeSymbol: "@sleetdrop/dsh-plugin-topology/types#RenderFormat",
4423
- schema: render_parameter_0$schema
4424
- }
4425
- }, {
4426
- name: "rankdir",
4427
- wire: "rankdir",
4428
- source: "json",
4429
- codec: {
4430
- mode: "strict",
4431
- typeSymbol: "@sleetdrop/dsh-plugin-topology/types#Rankdir",
4432
- schema: render_parameter_1$schema
4417
+ parameters: [
4418
+ {
4419
+ name: "format",
4420
+ wire: "format",
4421
+ source: "json",
4422
+ codec: {
4423
+ mode: "strict",
4424
+ typeSymbol: "@sleetdrop/dsh-plugin-topology/types#RenderFormat",
4425
+ create: () => render_parameter_0$schema
4426
+ }
4427
+ },
4428
+ {
4429
+ name: "rankdir",
4430
+ wire: "rankdir",
4431
+ source: "json",
4432
+ codec: {
4433
+ mode: "strict",
4434
+ typeSymbol: "@sleetdrop/dsh-plugin-topology/types#Rankdir",
4435
+ create: () => render_parameter_1$schema
4436
+ }
4437
+ },
4438
+ {
4439
+ name: "theme",
4440
+ wire: "theme",
4441
+ source: "json",
4442
+ acceptsUndefined: true,
4443
+ codec: {
4444
+ mode: "strict",
4445
+ typeSymbol: "@sleetdrop/dsh-plugin-topology/types#Theme",
4446
+ create: () => render_parameter_2$schema
4447
+ }
4433
4448
  }
4434
- }],
4449
+ ],
4435
4450
  result: {
4436
4451
  mode: "strict",
4437
4452
  typeSymbol: "@sleetdrop/dsh-plugin-topology#pluginTopology/render:result",
4438
- schema: render_result$schema
4453
+ create: () => render_result$schema
4439
4454
  }
4440
4455
  }]
4441
4456
  };
@@ -4627,13 +4642,13 @@ function OutlineButton({ children, onClick, title, icon }) {
4627
4642
 
4628
4643
  //#endregion
4629
4644
  //#region \0dsh-css:/Users/jiangyuan/Documents/side-work/dsh-plugin-topology/src/client/TopologyGraphView.module.css.mjs
4630
- const css$2 = ".s3EGUG_graphBox{flex-direction:column;min-height:0;display:flex;position:relative}.s3EGUG_graphViewport{cursor:grab;border:1px solid var(--dsw-border,#cbd5e1);background:#fff;border-radius:8px;flex:1;min-height:0;position:relative;overflow:hidden}.s3EGUG_graphViewport:active{cursor:grabbing}.s3EGUG_graphImg{transform-origin:0 0;user-select:none;pointer-events:none;position:absolute;top:0;left:0}.s3EGUG_zoomControls{border:1px solid var(--dsw-border,#cbd5e1);background:var(--dsw-surface,#fff);border-radius:8px;align-items:center;gap:2px;padding:4px;display:flex;position:absolute;bottom:12px;right:12px;box-shadow:0 6px 16px #00000014}.s3EGUG_zoomControls button{color:var(--dsw-text-secondary,#64748b);cursor:pointer;background:0 0;border:1px solid #0000;border-radius:6px;justify-content:center;align-items:center;padding:0;display:inline-flex}.s3EGUG_zoomControls button:hover{color:var(--dsw-text-primary,#0f172a);border-color:var(--dsw-border,#cbd5e1)}.s3EGUG_zoomControls .s3EGUG_iconButton{width:24px;height:24px}.s3EGUG_zoomControls .s3EGUG_zoomButton{transition:opacity .15s,width .15s}.s3EGUG_zoomLevel{text-align:center;min-width:46px;height:22px;color:var(--dsw-text-secondary,#64748b);padding:0 4px;font-size:11px;line-height:1;transition:opacity .15s,width .15s}.s3EGUG_zoomLevel:hover{color:var(--dsw-text-primary,#0f172a)}.s3EGUG_zoomControls.s3EGUG_collapsed .s3EGUG_zoomButton,.s3EGUG_zoomControls.s3EGUG_collapsed .s3EGUG_zoomLevel{opacity:0;pointer-events:none;border:0;width:0;min-width:0;height:0;margin:0;padding:0;overflow:hidden}";
4631
- const tagId$2 = "dsh-plugin-topology/TopologyGraphView.module.css";
4632
- if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId$2) + "]") === null) {
4645
+ const css$3 = ".s3EGUG_graphBox{flex-direction:column;min-height:0;display:flex;position:relative}.s3EGUG_graphViewport{cursor:grab;background:var(--dsw-alias-bg-layer-1,#fff);border:1px solid var(--dsw-alias-border-l2,#0000001a);border-radius:8px;flex:1;min-height:0;position:relative;overflow:hidden}.s3EGUG_graphViewport:active{cursor:grabbing}.s3EGUG_graphImg{transform-origin:0 0;user-select:none;width:max-content;position:absolute;top:0;left:0}.s3EGUG_graphImg .topo-hover-node>ellipse,.s3EGUG_graphImg .topo-hover-node>polygon{stroke:var(--dsw-alias-state-business-primary,#4176e6);stroke-opacity:.5;stroke-width:1.6px}.s3EGUG_graphImg .topo-selected-node>ellipse,.s3EGUG_graphImg .topo-selected-node>polygon,.s3EGUG_graphImg .topo-hl-node>ellipse,.s3EGUG_graphImg .topo-hl-node>polygon{stroke:var(--dsw-alias-state-business-primary,#4176e6);stroke-opacity:1;stroke-width:2.2px}.s3EGUG_graphImg .topo-hl-neighbor>ellipse,.s3EGUG_graphImg .topo-hl-neighbor>polygon{stroke:var(--dsw-alias-state-business-primary,#4176e6);stroke-opacity:.45;stroke-width:1.4px}.s3EGUG_graphImg .topo-hl-edge path{fill:none;stroke:var(--dsw-alias-state-business-primary,#4176e6);stroke-opacity:1;stroke-width:1.4px}.s3EGUG_graphImg .topo-hl-edge polygon{stroke:var(--dsw-alias-state-business-primary,#4176e6);stroke-opacity:1;fill:var(--dsw-alias-state-business-primary,#4176e6);fill-opacity:1}.s3EGUG_graphImg .topo-hl-edge text{fill:var(--dsw-alias-state-business-primary,#4176e6);fill-opacity:1}.s3EGUG_graphImg .topo-dimmed{opacity:.12}.s3EGUG_graphImg .topo-hover-node,.s3EGUG_graphImg .topo-selected-node,.s3EGUG_graphImg .topo-hl-node,.s3EGUG_graphImg .topo-hl-neighbor,.s3EGUG_graphImg .topo-hl-edge,.s3EGUG_graphImg .topo-dimmed{transition-property:opacity,stroke,stroke-opacity,stroke-width,fill,fill-opacity;transition-duration:.12s;transition-timing-function:ease}.s3EGUG_zoomControls{border:1px solid var(--dsw-alias-border-l2,#0000001a);background:var(--dsw-alias-bg-layer-2,#fff);border-radius:8px;align-items:center;gap:2px;padding:4px;display:flex;position:absolute;bottom:12px;right:12px;box-shadow:0 6px 16px #00000014}.s3EGUG_zoomControls button{color:var(--dsw-alias-label-secondary,#61666b);cursor:pointer;background:0 0;border:1px solid #0000;border-radius:6px;justify-content:center;align-items:center;padding:0;display:inline-flex}.s3EGUG_zoomControls button:hover{color:var(--dsw-alias-label-primary,#0f1115);border-color:var(--dsw-alias-border-l2,#0000001a)}.s3EGUG_zoomControls .s3EGUG_iconButton{width:24px;height:24px}.s3EGUG_zoomControls .s3EGUG_zoomButton{transition:opacity .15s,width .15s}.s3EGUG_zoomLevel{text-align:center;min-width:46px;height:22px;color:var(--dsw-alias-label-secondary,#61666b);padding:0 4px;font-size:11px;line-height:1;transition:opacity .15s,width .15s}.s3EGUG_zoomLevel:hover{color:var(--dsw-alias-label-primary,#0f1115)}.s3EGUG_zoomControls.s3EGUG_collapsed .s3EGUG_zoomButton,.s3EGUG_zoomControls.s3EGUG_collapsed .s3EGUG_zoomLevel{opacity:0;pointer-events:none;border:0;width:0;min-width:0;height:0;margin:0;padding:0;overflow:hidden}";
4646
+ const tagId$3 = "dsh-plugin-topology/TopologyGraphView.module.css";
4647
+ if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId$3) + "]") === null) {
4633
4648
  const tag = document.createElement("style");
4634
4649
  tag.dataset.plugin = "dsh-plugin-topology";
4635
- tag.dataset.pluginCss = tagId$2;
4636
- tag.textContent = css$2;
4650
+ tag.dataset.pluginCss = tagId$3;
4651
+ tag.textContent = css$3;
4637
4652
  document.head.appendChild(tag);
4638
4653
  }
4639
4654
  var TopologyGraphView_module_css_default = {
@@ -4649,6 +4664,178 @@ var TopologyGraphView_module_css_default = {
4649
4664
 
4650
4665
  //#endregion
4651
4666
  //#region src/client/TopologyGraphView.tsx
4667
+ /**
4668
+ * Classes owned and toggled by the highlight layer. The hover ring is managed
4669
+ * separately by the pointer listener so a selection repaint cannot drop it.
4670
+ */
4671
+ const HIGHLIGHT_CLASSES = [
4672
+ "topo-selected-node",
4673
+ "topo-hl-node",
4674
+ "topo-hl-neighbor",
4675
+ "topo-hl-edge",
4676
+ "topo-dimmed"
4677
+ ];
4678
+ /** Read the DOT node id and display label from a Graphviz node group. */
4679
+ function nodeRefFromElement(element) {
4680
+ if (!(element instanceof Element)) return null;
4681
+ const group = element.closest("g.node");
4682
+ if (group === null) return null;
4683
+ const dotId = group.querySelector(":scope > title")?.textContent;
4684
+ if (dotId === null || dotId === void 0) return null;
4685
+ const label = group.querySelector(":scope > text")?.textContent ?? dotId;
4686
+ return {
4687
+ ref: {
4688
+ key: group.id,
4689
+ dotId,
4690
+ label
4691
+ },
4692
+ group
4693
+ };
4694
+ }
4695
+ /**
4696
+ * The nearest ancestor `<g>` that wraps one whole Graphviz layout (main or
4697
+ * isolated). `composeGraphvizSvgs` places each layout in its own translated
4698
+ * group, so this is also the isolation boundary: `m:N` ids are only unique
4699
+ * inside one layout and must not be matched across both.
4700
+ */
4701
+ function graphScope(node) {
4702
+ let current = node;
4703
+ while (current.parentElement !== null && current.parentElement.tagName.toLowerCase() !== "svg") current = current.parentElement;
4704
+ return current;
4705
+ }
4706
+ /** Split a Graphviz edge `<title>` (`source-&gt;target`) into its endpoints. */
4707
+ function edgeEndpoints(edge) {
4708
+ const title = edge.querySelector(":scope > title")?.textContent;
4709
+ if (title === null || title === void 0) return null;
4710
+ const arrowIdx = title.indexOf("->");
4711
+ if (arrowIdx < 0) return null;
4712
+ return {
4713
+ source: title.slice(0, arrowIdx),
4714
+ target: title.slice(arrowIdx + 2)
4715
+ };
4716
+ }
4717
+ /** Count the drawn outgoing/incoming edges of a node inside one layout scope. */
4718
+ function edgeStats(scope, dotId) {
4719
+ let out = 0;
4720
+ let incoming = 0;
4721
+ for (const edge of scope.querySelectorAll("g.edge")) {
4722
+ const endpoints = edgeEndpoints(edge);
4723
+ if (endpoints === null) continue;
4724
+ if (endpoints.source === dotId) out += 1;
4725
+ if (endpoints.target === dotId) incoming += 1;
4726
+ }
4727
+ return {
4728
+ out,
4729
+ in: incoming
4730
+ };
4731
+ }
4732
+ /** Find the node group with the given stable element id. */
4733
+ function findNodeGroup(container, key) {
4734
+ for (const group of container.querySelectorAll("g.node")) if (group.id === key) return group;
4735
+ return null;
4736
+ }
4737
+ /** Drop every highlight class this module owns. */
4738
+ function clearHighlight(container) {
4739
+ const selector = HIGHLIGHT_CLASSES.map((name) => `.${name}`).join(",");
4740
+ for (const element of container.querySelectorAll(selector)) element.classList.remove(...HIGHLIGHT_CLASSES);
4741
+ }
4742
+ /**
4743
+ * Paint the selection: the selected node always gets a marker ring; when a
4744
+ * direction is active, only that direction's edges and their far-end nodes stay
4745
+ * lit while the rest of the same layout dims. The other layout (isolated
4746
+ * plugins) is left untouched, since it has no edges to follow.
4747
+ */
4748
+ function applyHighlight(container, selection) {
4749
+ clearHighlight(container);
4750
+ if (selection === null) return;
4751
+ const group = findNodeGroup(container, selection.key);
4752
+ if (group === null) return;
4753
+ group.classList.add("topo-selected-node");
4754
+ if (selection.direction === null) return;
4755
+ const scope = graphScope(group);
4756
+ const lit = new Set([selection.dotId]);
4757
+ const connectedEdges = /* @__PURE__ */ new Set();
4758
+ for (const edge of scope.querySelectorAll("g.edge")) {
4759
+ const endpoints = edgeEndpoints(edge);
4760
+ if (endpoints === null) continue;
4761
+ if (!(selection.direction === "out" ? endpoints.source === selection.dotId : endpoints.target === selection.dotId)) continue;
4762
+ connectedEdges.add(edge);
4763
+ lit.add(endpoints.source);
4764
+ lit.add(endpoints.target);
4765
+ }
4766
+ group.classList.add("topo-hl-node");
4767
+ for (const node of scope.querySelectorAll("g.node")) {
4768
+ if (node === group) continue;
4769
+ const dotId = node.querySelector(":scope > title")?.textContent;
4770
+ if (dotId !== null && dotId !== void 0 && lit.has(dotId)) node.classList.add("topo-hl-neighbor");
4771
+ else node.classList.add("topo-dimmed");
4772
+ }
4773
+ for (const edge of scope.querySelectorAll("g.edge")) edge.classList.add(connectedEdges.has(edge) ? "topo-hl-edge" : "topo-dimmed");
4774
+ }
4775
+ /**
4776
+ * Inline SVG renderer. Encapsulates the raw HTML injection behind a meaningful
4777
+ * component name so readers are not startled by the React API at call sites.
4778
+ * Hover and highlight are applied as classes on the Graphviz DOM, which React
4779
+ * does not own; the exported SVG string itself stays free of listeners.
4780
+ */
4781
+ function SvgCanvas({ svg, alt, fitted, view, selectedKey, selectedDotId, direction }) {
4782
+ const containerRef = (0, react.useRef)(null);
4783
+ /** The group currently carrying the hover ring, to avoid redundant DOM work. */
4784
+ const hoveredRef = (0, react.useRef)(null);
4785
+ (0, react.useEffect)(() => {
4786
+ const container = containerRef.current;
4787
+ if (container === null) return;
4788
+ applyHighlight(container, selectedKey !== null && selectedDotId !== null ? {
4789
+ key: selectedKey,
4790
+ dotId: selectedDotId,
4791
+ direction
4792
+ } : null);
4793
+ }, [
4794
+ selectedKey,
4795
+ selectedDotId,
4796
+ direction,
4797
+ svg
4798
+ ]);
4799
+ (0, react.useEffect)(() => {
4800
+ const container = containerRef.current;
4801
+ if (container === null) return;
4802
+ hoveredRef.current = null;
4803
+ const onMouseOver = (event) => {
4804
+ const found = nodeRefFromElement(event.target);
4805
+ if (found === null || found.group === hoveredRef.current) return;
4806
+ hoveredRef.current?.classList.remove("topo-hover-node");
4807
+ hoveredRef.current = found.group;
4808
+ found.group.classList.add("topo-hover-node");
4809
+ container.style.cursor = "pointer";
4810
+ };
4811
+ const onMouseOut = (event) => {
4812
+ const hovered = hoveredRef.current;
4813
+ if (hovered === null) return;
4814
+ const related = event.relatedTarget;
4815
+ if (related !== null && hovered.contains(related)) return;
4816
+ hovered.classList.remove("topo-hover-node");
4817
+ hoveredRef.current = null;
4818
+ container.style.cursor = "";
4819
+ };
4820
+ container.addEventListener("mouseover", onMouseOver);
4821
+ container.addEventListener("mouseout", onMouseOut);
4822
+ return () => {
4823
+ container.removeEventListener("mouseover", onMouseOver);
4824
+ container.removeEventListener("mouseout", onMouseOut);
4825
+ };
4826
+ }, [svg]);
4827
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4828
+ ref: containerRef,
4829
+ className: TopologyGraphView_module_css_default.graphImg,
4830
+ role: "img",
4831
+ "aria-label": alt,
4832
+ style: {
4833
+ opacity: fitted ? 1 : 0,
4834
+ transform: `translate(${view.x}px, ${view.y}px) scale(${view.k})`
4835
+ },
4836
+ dangerouslySetInnerHTML: { __html: svg }
4837
+ });
4838
+ }
4652
4839
  const MIN_K = .05;
4653
4840
  /** A fit never shrinks below this scale, so the default and reset land at actual size. */
4654
4841
  const MIN_FIT_K = 1;
@@ -4673,13 +4860,16 @@ function fitView(containerWidth, containerHeight, naturalWidth, naturalHeight) {
4673
4860
  };
4674
4861
  }
4675
4862
  /**
4676
- * Pan/zoom SVG viewer: fit-and-center on load, wheel zooms toward the cursor,
4677
- * drag pans, double-click zooms toward the cursor, and a compact floating pill
4678
- * (fit / zoom-out / zoom-level / zoom-in) collapses to just the fit control at
4679
- * rest. The transform is written back to the parent store on every change so it
4680
- * survives panel close/reopen.
4863
+ * Pan/zoom SVG viewer with drill-down interaction. Fit-and-center on load,
4864
+ * wheel zooms toward the cursor, drag pans, double-click zooms toward the
4865
+ * cursor, and a compact floating pill (fit / zoom-out / zoom-level / zoom-in)
4866
+ * collapses to just the fit control at rest.
4867
+ *
4868
+ * Hovering a node raises a ring; tapping it fires onNodeClick with viewport
4869
+ * coordinates and the drawn deg⁺/deg⁻ counts. Tapping empty space fires
4870
+ * onEmptyClick. `direction` lights only the chosen dependency direction.
4681
4871
  */
4682
- function TopologyGraphView({ svg, alt, height, transform: transform$1, onTransformChange, zoomInLabel, zoomOutLabel, resetViewLabel, zoomLevelLabel }) {
4872
+ function TopologyGraphView({ svg, alt, height, transform: transform$1, onTransformChange, zoomInLabel, zoomOutLabel, resetViewLabel, zoomLevelLabel, onNodeClick, onEmptyClick, selectedKey, selectedDotId, direction }) {
4683
4873
  const containerRef = (0, react.useRef)(null);
4684
4874
  const naturalRef = (0, react.useRef)(null);
4685
4875
  const dragRef = (0, react.useRef)(null);
@@ -4750,15 +4940,23 @@ function TopologyGraphView({ svg, alt, height, transform: transform$1, onTransfo
4750
4940
  });
4751
4941
  setAtFit(false);
4752
4942
  }, [onTransformChange]);
4753
- const onLoad = (event) => {
4754
- const img = event.currentTarget;
4755
- naturalRef.current = {
4756
- width: img.naturalWidth,
4757
- height: img.naturalHeight
4758
- };
4759
- if (transform$1 === null) fit();
4760
- setFitted(true);
4761
- };
4943
+ (0, react.useEffect)(() => {
4944
+ const match = svg.match(/viewBox="[\d.]+ [\d.]+ ([\d.]+) ([\d.]+)"/);
4945
+ if (match !== null) {
4946
+ naturalRef.current = {
4947
+ width: parseFloat(match[1] ?? "0"),
4948
+ height: parseFloat(match[2] ?? "0")
4949
+ };
4950
+ if (transform$1 === null) requestAnimationFrame(() => {
4951
+ fit();
4952
+ });
4953
+ setFitted(true);
4954
+ }
4955
+ }, [
4956
+ svg,
4957
+ transform$1,
4958
+ fit
4959
+ ]);
4762
4960
  (0, react.useEffect)(() => {
4763
4961
  const element = containerRef.current;
4764
4962
  if (element === null) return;
@@ -4860,22 +5058,46 @@ function TopologyGraphView({ svg, alt, height, transform: transform$1, onTransfo
4860
5058
  startX: event.clientX,
4861
5059
  startY: event.clientY,
4862
5060
  baseX: view.x,
4863
- baseY: view.y
5061
+ baseY: view.y,
5062
+ moved: false
4864
5063
  };
4865
5064
  event.currentTarget.setPointerCapture(event.pointerId);
4866
5065
  };
4867
5066
  const onPointerMove = (event) => {
4868
5067
  const drag = dragRef.current;
4869
5068
  if (drag === null || drag.pointerId !== event.pointerId) return;
5069
+ const dx = event.clientX - drag.startX;
5070
+ const dy = event.clientY - drag.startY;
5071
+ if (!drag.moved && Math.abs(dx) < 3 && Math.abs(dy) < 3) return;
5072
+ drag.moved = true;
4870
5073
  commit({
4871
5074
  ...view,
4872
- x: drag.baseX + (event.clientX - drag.startX),
4873
- y: drag.baseY + (event.clientY - drag.startY)
5075
+ x: drag.baseX + dx,
5076
+ y: drag.baseY + dy
4874
5077
  });
4875
5078
  setAtFit(false);
4876
5079
  };
4877
5080
  const onPointerEnd = (event) => {
4878
- if (dragRef.current?.pointerId === event.pointerId) dragRef.current = null;
5081
+ const drag = dragRef.current;
5082
+ if (drag === null || drag.pointerId !== event.pointerId) return;
5083
+ if (!drag.moved) {
5084
+ const found = nodeRefFromElement(document.elementFromPoint(event.clientX, event.clientY));
5085
+ const container = containerRef.current;
5086
+ if (found !== null && container !== null && onNodeClick !== void 0) {
5087
+ const rect = container.getBoundingClientRect();
5088
+ const stats = edgeStats(graphScope(found.group), found.ref.dotId);
5089
+ onNodeClick({
5090
+ key: found.ref.key,
5091
+ nodeId: found.ref.dotId,
5092
+ label: found.ref.label,
5093
+ anchorX: (event.clientX - rect.left - view.x) / view.k,
5094
+ anchorY: (event.clientY - rect.top - view.y) / view.k,
5095
+ outCount: stats.out,
5096
+ inCount: stats.in
5097
+ });
5098
+ } else if (found === null && onEmptyClick !== void 0) onEmptyClick();
5099
+ }
5100
+ dragRef.current = null;
4879
5101
  };
4880
5102
  const onDoubleClick = (event) => {
4881
5103
  const element = containerRef.current;
@@ -4898,7 +5120,6 @@ function TopologyGraphView({ svg, alt, height, transform: transform$1, onTransfo
4898
5120
  } else fit();
4899
5121
  };
4900
5122
  const collapsed = atFit && !panelHovered;
4901
- const src = `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`;
4902
5123
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4903
5124
  className: TopologyGraphView_module_css_default.graphBox,
4904
5125
  style: { height },
@@ -4916,16 +5137,14 @@ function TopologyGraphView({ svg, alt, height, transform: transform$1, onTransfo
4916
5137
  onPointerLeave: () => {
4917
5138
  setViewportHovered(false);
4918
5139
  },
4919
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("img", {
4920
- className: TopologyGraphView_module_css_default.graphImg,
4921
- src,
5140
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SvgCanvas, {
5141
+ svg,
4922
5142
  alt,
4923
- draggable: false,
4924
- onLoad,
4925
- style: {
4926
- opacity: fitted ? 1 : 0,
4927
- transform: `translate(${view.x}px, ${view.y}px) scale(${view.k})`
4928
- }
5143
+ fitted,
5144
+ view,
5145
+ selectedKey: selectedKey ?? null,
5146
+ selectedDotId: selectedDotId ?? null,
5147
+ direction: direction ?? null
4929
5148
  })
4930
5149
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4931
5150
  className: collapsed ? `${TopologyGraphView_module_css_default.zoomControls} ${TopologyGraphView_module_css_default.collapsed}` : TopologyGraphView_module_css_default.zoomControls,
@@ -4977,9 +5196,215 @@ function TopologyGraphView({ svg, alt, height, transform: transform$1, onTransfo
4977
5196
  });
4978
5197
  }
4979
5198
 
5199
+ //#endregion
5200
+ //#region \0dsh-css:/Users/jiangyuan/Documents/side-work/dsh-plugin-topology/src/client/NodePopover.module.css.mjs
5201
+ const css$2 = ".iw1Z3a_popover{z-index:10;border:1px solid var(--dsw-alias-border-l3,#0000001f);background:var(--dsw-alias-bg-layer-2,#fff);pointer-events:auto;border-radius:8px;flex-direction:column;gap:6px;min-width:180px;max-width:280px;padding:10px 12px;font-size:12px;display:flex;position:absolute;box-shadow:0 8px 24px #0000001f}.iw1Z3a_header{justify-content:space-between;align-items:flex-start;gap:8px;display:flex}.iw1Z3a_nameLink{color:var(--dsw-alias-state-business-primary,#4176e6);word-break:break-word;font-size:13px;font-weight:600;line-height:1.3;text-decoration:none}.iw1Z3a_nameLink:hover{text-decoration:underline}.iw1Z3a_namePlain{color:var(--dsw-alias-label-primary,#0f172a);word-break:break-word;font-size:13px;font-weight:600;line-height:1.3}.iw1Z3a_closeButton{width:18px;height:18px;color:var(--dsw-alias-label-secondary,#61666b);cursor:pointer;background:0 0;border:none;border-radius:4px;flex-shrink:0;justify-content:center;align-items:center;padding:0;display:inline-flex}.iw1Z3a_closeButton:hover{background:var(--dsw-alias-interactive-bg-hover,#2631480f);color:var(--dsw-alias-label-primary,#0f1115)}.iw1Z3a_metaRow{align-items:center;gap:6px;display:flex}.iw1Z3a_stateBadge{border-radius:4px;width:fit-content;padding:1px 6px;font-size:10px;font-weight:500;line-height:1.5;display:inline-block}.iw1Z3a_stateActive{background:var(--dsw-alias-state-business-tertiary,#e4edfd);color:var(--dsw-alias-state-business-primary,#4176e6)}.iw1Z3a_stateFailed{background:var(--dsw-alias-interactive-bg-hover-danger,#ec13130d);color:var(--dsw-alias-state-error-primary,#ec1313)}.iw1Z3a_stateOther{background:var(--dsw-alias-interactive-bg-hover,#2631480f);color:var(--dsw-alias-label-secondary,#61666b)}.iw1Z3a_row{align-items:baseline;gap:6px;display:flex}.iw1Z3a_rowLabel{color:var(--dsw-alias-label-secondary,#61666b);white-space:nowrap;font-size:11px}.iw1Z3a_rowValue{color:var(--dsw-alias-label-primary,#0f1115);word-break:break-all;font-size:11px}.iw1Z3a_sourceValue{font-family:var(--ds-font-family-code,monospace);font-size:10px}.iw1Z3a_depPills{gap:6px;margin-top:2px;display:flex}.iw1Z3a_depPill{border:1px solid var(--dsw-alias-border-l2,#0000001a);background:var(--dsw-alias-interactive-bg-hover,#2631480f);color:var(--dsw-alias-label-secondary,#61666b);cursor:pointer;border-radius:999px;align-items:center;gap:5px;padding:2px 9px;transition:background .12s,border-color .12s,color .12s;display:inline-flex}.iw1Z3a_depPill:hover:not(:disabled){border-color:var(--dsw-alias-state-business-primary,#4176e6);color:var(--dsw-alias-state-business-primary,#4176e6)}.iw1Z3a_depPill:disabled{opacity:.4;cursor:default}.iw1Z3a_depPillActive,.iw1Z3a_depPillActive:hover:not(:disabled){border-color:var(--dsw-alias-state-business-primary,#4176e6);background:color-mix(in srgb, var(--dsw-alias-state-business-primary,#4176e6) 14%, transparent);color:var(--dsw-alias-state-business-primary,#4176e6)}.iw1Z3a_degMark{font-size:11px;line-height:1}.iw1Z3a_degValue{font-variant-numeric:tabular-nums;font-size:11px;font-weight:600;line-height:1}.iw1Z3a_unresolvedBadge{background:var(--dsw-alias-interactive-bg-hover-danger,#ec13130d);color:var(--dsw-alias-state-error-primary,#ec1313);border-radius:3px;padding:1px 5px;font-size:10px;font-weight:500;display:inline-block}";
5202
+ const tagId$2 = "dsh-plugin-topology/NodePopover.module.css";
5203
+ if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId$2) + "]") === null) {
5204
+ const tag = document.createElement("style");
5205
+ tag.dataset.plugin = "dsh-plugin-topology";
5206
+ tag.dataset.pluginCss = tagId$2;
5207
+ tag.textContent = css$2;
5208
+ document.head.appendChild(tag);
5209
+ }
5210
+ var NodePopover_module_css_default = {
5211
+ "closeButton": "iw1Z3a_closeButton",
5212
+ "degMark": "iw1Z3a_degMark",
5213
+ "degValue": "iw1Z3a_degValue",
5214
+ "depPill": "iw1Z3a_depPill",
5215
+ "depPillActive": "iw1Z3a_depPillActive",
5216
+ "depPills": "iw1Z3a_depPills",
5217
+ "header": "iw1Z3a_header",
5218
+ "metaRow": "iw1Z3a_metaRow",
5219
+ "nameLink": "iw1Z3a_nameLink",
5220
+ "namePlain": "iw1Z3a_namePlain",
5221
+ "popover": "iw1Z3a_popover",
5222
+ "row": "iw1Z3a_row",
5223
+ "rowLabel": "iw1Z3a_rowLabel",
5224
+ "rowValue": "iw1Z3a_rowValue",
5225
+ "sourceValue": "iw1Z3a_sourceValue",
5226
+ "stateActive": "iw1Z3a_stateActive",
5227
+ "stateBadge": "iw1Z3a_stateBadge",
5228
+ "stateFailed": "iw1Z3a_stateFailed",
5229
+ "stateOther": "iw1Z3a_stateOther",
5230
+ "unresolvedBadge": "iw1Z3a_unresolvedBadge"
5231
+ };
5232
+
5233
+ //#endregion
5234
+ //#region src/client/NodePopover.tsx
5235
+ /** Gap between the anchor and the card's top-left corner, in px. */
5236
+ const OFFSET = 8;
5237
+ /** Build an npm registry URL from a source specifier, or null for local paths. */
5238
+ function npmUrl(source) {
5239
+ if (source === void 0) return null;
5240
+ if (source.startsWith("/") || source.startsWith(".") || source.includes(":\\")) return null;
5241
+ return `https://www.npmjs.com/package/${encodeURIComponent(source)}`;
5242
+ }
5243
+ function stateBadgeClass(state) {
5244
+ if (state === "ACTIVE") return `${NodePopover_module_css_default.stateBadge} ${NodePopover_module_css_default.stateActive}`;
5245
+ if (state === "FAILED") return `${NodePopover_module_css_default.stateBadge} ${NodePopover_module_css_default.stateFailed}`;
5246
+ return `${NodePopover_module_css_default.stateBadge} ${NodePopover_module_css_default.stateOther}`;
5247
+ }
5248
+ /** The worst lifecycle state in a group, so a broken instance is never hidden. */
5249
+ function worstState(states) {
5250
+ if (states.includes("FAILED")) return "FAILED";
5251
+ if (states.includes("PENDING")) return "PENDING";
5252
+ return states[0] ?? "ACTIVE";
5253
+ }
5254
+ /**
5255
+ * Resolve the runtime facts the panel shows. Single instances map straight to a
5256
+ * snapshot node; merged render nodes (`m:N`, several same-named instances drawn
5257
+ * as one) are recovered from the ordinals in the rendered label.
5258
+ */
5259
+ function resolveNode(nodeId, svgLabel, analysis) {
5260
+ const direct = analysis.collapsed.nodes.find((node) => node.id === nodeId);
5261
+ if (direct !== void 0) return {
5262
+ node: direct,
5263
+ unresolved: analysis.graph.unresolved.filter((dep) => dep.plugin === nodeId).length
5264
+ };
5265
+ const bracket = svgLabel.match(/\[([^\]]*)\]\s*$/);
5266
+ const baseName = svgLabel.replace(/\s*\[[^\]]*\]\s*$/, "").trim();
5267
+ const ordinals = bracket === null ? [] : bracket[1].split(",").map((part) => part.trim()).filter((part) => part !== "" && part !== "…");
5268
+ let members = analysis.collapsed.nodes.filter((node) => node.label === baseName);
5269
+ if (ordinals.length > 0) {
5270
+ const wanted = new Set(ordinals.map((ordinal) => `p:${ordinal}`));
5271
+ const matched = members.filter((node) => wanted.has(node.id));
5272
+ if (matched.length > 0) members = matched;
5273
+ }
5274
+ const source = members.find((member) => member.source !== void 0)?.source;
5275
+ return {
5276
+ node: {
5277
+ id: nodeId,
5278
+ kind: "plugin",
5279
+ label: baseName === "" ? svgLabel : baseName,
5280
+ state: worstState(members.map((member) => member.state)),
5281
+ parent: null,
5282
+ inject: [],
5283
+ ...source === void 0 ? {} : { source }
5284
+ },
5285
+ unresolved: members.reduce((total, member) => total + analysis.graph.unresolved.filter((dep) => dep.plugin === member.id).length, 0)
5286
+ };
5287
+ }
5288
+ /**
5289
+ * Floating detail card anchored to a clicked graph node. It shows only facts
5290
+ * that need the running process to know (state, load source, drawn in/out
5291
+ * degree) and links the name out to npm for everything else. The deg⁺/deg⁻
5292
+ * pills drive the graph highlight, so the panel itself stays a one-screen read.
5293
+ */
5294
+ function NodePopover({ popover, analysis, direction, onToggleDirection, onClose, t }) {
5295
+ const ref = (0, react.useRef)(null);
5296
+ const { node, unresolved } = resolveNode(popover.nodeId, popover.label, analysis);
5297
+ const url = npmUrl(node.source);
5298
+ (0, react.useEffect)(() => {
5299
+ const el = ref.current;
5300
+ if (el === null) return;
5301
+ const parent = el.parentElement;
5302
+ if (parent === null) return;
5303
+ const bounds = parent.getBoundingClientRect();
5304
+ const own = el.getBoundingClientRect();
5305
+ const maxLeft = Math.max(OFFSET, bounds.width - own.width - OFFSET);
5306
+ const maxTop = Math.max(OFFSET, bounds.height - own.height - OFFSET);
5307
+ el.style.left = `${Math.min(Math.max(OFFSET, popover.x + OFFSET), maxLeft)}px`;
5308
+ el.style.top = `${Math.min(Math.max(OFFSET, popover.y + OFFSET), maxTop)}px`;
5309
+ }, [popover.x, popover.y]);
5310
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5311
+ ref,
5312
+ className: NodePopover_module_css_default.popover,
5313
+ style: {
5314
+ left: popover.x + OFFSET,
5315
+ top: popover.y + OFFSET
5316
+ },
5317
+ role: "dialog",
5318
+ "aria-label": node.label,
5319
+ children: [
5320
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5321
+ className: NodePopover_module_css_default.header,
5322
+ children: [url !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("a", {
5323
+ className: NodePopover_module_css_default.nameLink,
5324
+ href: url,
5325
+ target: "_blank",
5326
+ rel: "noopener noreferrer",
5327
+ children: node.label
5328
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5329
+ className: NodePopover_module_css_default.namePlain,
5330
+ children: node.label
5331
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
5332
+ type: "button",
5333
+ className: NodePopover_module_css_default.closeButton,
5334
+ onClick: onClose,
5335
+ "aria-label": t("popoverClose"),
5336
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconCloseOutline16, { size: 12 })
5337
+ })]
5338
+ }),
5339
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5340
+ className: NodePopover_module_css_default.metaRow,
5341
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5342
+ className: stateBadgeClass(node.state),
5343
+ children: node.state
5344
+ }), unresolved > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
5345
+ className: NodePopover_module_css_default.unresolvedBadge,
5346
+ children: [
5347
+ t("popoverUnresolved"),
5348
+ " ",
5349
+ unresolved
5350
+ ]
5351
+ })]
5352
+ }),
5353
+ node.source !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5354
+ className: NodePopover_module_css_default.row,
5355
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5356
+ className: NodePopover_module_css_default.rowLabel,
5357
+ children: t("popoverSource")
5358
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5359
+ className: `${NodePopover_module_css_default.rowValue} ${NodePopover_module_css_default.sourceValue}`,
5360
+ children: node.source
5361
+ })]
5362
+ }),
5363
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5364
+ className: NodePopover_module_css_default.depPills,
5365
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
5366
+ type: "button",
5367
+ className: direction === "out" ? `${NodePopover_module_css_default.depPill} ${NodePopover_module_css_default.depPillActive}` : NodePopover_module_css_default.depPill,
5368
+ "aria-pressed": direction === "out",
5369
+ "aria-label": t("popoverOutgoing"),
5370
+ title: t("popoverOutgoing"),
5371
+ disabled: popover.outCount === 0,
5372
+ onClick: () => {
5373
+ onToggleDirection("out");
5374
+ },
5375
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5376
+ className: NodePopover_module_css_default.degMark,
5377
+ children: "deg⁺"
5378
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5379
+ className: NodePopover_module_css_default.degValue,
5380
+ children: popover.outCount
5381
+ })]
5382
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
5383
+ type: "button",
5384
+ className: direction === "in" ? `${NodePopover_module_css_default.depPill} ${NodePopover_module_css_default.depPillActive}` : NodePopover_module_css_default.depPill,
5385
+ "aria-pressed": direction === "in",
5386
+ "aria-label": t("popoverIncoming"),
5387
+ title: t("popoverIncoming"),
5388
+ disabled: popover.inCount === 0,
5389
+ onClick: () => {
5390
+ onToggleDirection("in");
5391
+ },
5392
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5393
+ className: NodePopover_module_css_default.degMark,
5394
+ children: "deg⁻"
5395
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5396
+ className: NodePopover_module_css_default.degValue,
5397
+ children: popover.inCount
5398
+ })]
5399
+ })]
5400
+ })
5401
+ ]
5402
+ });
5403
+ }
5404
+
4980
5405
  //#endregion
4981
5406
  //#region \0dsh-css:/Users/jiangyuan/Documents/side-work/dsh-plugin-topology/src/client/TopologyView.module.css.mjs
4982
- const css$1 = ".rve-MG_root{box-sizing:border-box;width:100%;height:100%;min-height:0;padding:var(--dsw-space-4,16px);gap:var(--dsw-space-3,12px);flex-direction:column;display:flex;overflow:hidden}.rve-MG_status{color:var(--dsw-text-secondary,#64748b);margin:0}.rve-MG_failure{align-items:flex-start;gap:var(--dsw-space-2,8px);flex-direction:column;display:flex}.rve-MG_errorDetail{background:var(--dsw-surface-hover,#f1f5f9);max-width:100%;font-family:var(--ds-font-family-code,monospace);color:var(--dsw-alias-state-error-primary,#dc2626);white-space:pre-wrap;word-break:break-all;border-radius:6px;margin:0;padding:8px 10px;font-size:11px;line-height:1.5;overflow:auto}.rve-MG_toolbar{justify-content:space-between;align-items:center;gap:var(--dsw-space-3,12px);display:flex}.rve-MG_summaryGroup{align-items:center;gap:6px;min-width:0;display:flex}.rve-MG_actions{justify-content:flex-end;align-items:center;gap:var(--dsw-space-2,8px);flex-wrap:wrap;flex-shrink:0;display:flex}.rve-MG_rankdirGroup{border:1px solid var(--dsw-border,#cbd5e1);background:var(--dsw-surface,#fff);border-radius:6px;align-items:center;gap:0;padding:2px;display:inline-flex}.rve-MG_rankdirOption{min-width:32px;height:22px;color:var(--dsw-text-secondary,#64748b);text-align:center;cursor:pointer;background:0 0;border:none;border-radius:4px;padding:0 8px;font-size:11px}.rve-MG_rankdirOption[aria-pressed=true]{background:var(--dsw-alias-state-business-primary,#3b82f6);color:#fff;font-weight:500}.rve-MG_metricStrip{flex-wrap:wrap;align-items:center;gap:6px 20px;padding:2px 0;display:flex}.rve-MG_stat{align-items:center;gap:6px;display:inline-flex}.rve-MG_statLabel{color:var(--dsw-text-secondary,#64748b);font-size:12px}.rve-MG_statValue{font-variant-numeric:tabular-nums;color:var(--dsw-text-primary,#0f172a);font-size:15px;font-weight:600;line-height:1}.rve-MG_statAlert .rve-MG_statValue{color:var(--dsw-alias-state-error-primary,#dc2626)}.rve-MG_statToggle{color:inherit;font:inherit;cursor:pointer;background:0 0;border:none;align-items:center;gap:4px;padding:0;display:inline-flex}.rve-MG_statChevron{color:var(--dsw-text-secondary,#64748b);transition:transform .15s}.rve-MG_statChevronOpen{transform:rotate(180deg)}.rve-MG_unresolvedPanel{border:1px solid var(--dsw-border,#cbd5e1);background:var(--dsw-surface,#fff);border-radius:8px;overflow:hidden}.rve-MG_unresolvedScroll{max-height:260px;overflow-y:auto}.rve-MG_unresolvedTable{table-layout:fixed;border-collapse:collapse;width:100%;font-size:12px}.rve-MG_unresolvedTable th,.rve-MG_unresolvedTable td{text-align:left;padding:8px 12px}.rve-MG_unresolvedTable thead th{z-index:1;background:var(--dsw-surface,#fff);color:var(--dsw-text-secondary,#64748b);border-bottom:1px solid var(--dsw-border,#cbd5e1);font-size:11px;font-weight:500;position:sticky;top:0}.rve-MG_unresolvedTable tbody tr+tr td{border-top:1px solid var(--dsw-border,#cbd5e1)}.rve-MG_unresolvedTable .rve-MG_unresolvedColPlugin{width:180px}.rve-MG_unresolvedTable .rve-MG_unresolvedColState{width:110px}.rve-MG_unresolvedPlugin{font-family:var(--ds-font-family-code,monospace);color:var(--dsw-text-primary,#0f172a);text-overflow:ellipsis;white-space:nowrap;font-weight:500;overflow:hidden}.rve-MG_unresolvedState{color:var(--dsw-text-secondary,#64748b);white-space:nowrap;font-size:11px}.rve-MG_unresolvedService{font-family:var(--ds-font-family-code,monospace);color:var(--dsw-alias-state-error-primary,#dc2626);overflow-wrap:anywhere}.rve-MG_legend{color:var(--dsw-text-secondary,#64748b);flex-wrap:wrap;align-items:center;gap:6px 16px;font-size:11px;display:flex}.rve-MG_legendItem{align-items:center;gap:6px;display:inline-flex}.rve-MG_legendSwatch{flex:none;display:inline-block}.rve-MG_legendUnresolvedBox{border:1.5px solid var(--dsw-alias-state-error-primary,#dc2626);background:var(--dsw-surface,#fff);border-radius:50%;width:12px;height:12px}.rve-MG_legendIsolatedBox{background:#f8fafc;border:1px dashed #94a3b8;border-radius:2px;width:12px;height:12px}.rve-MG_legendIdSample{font-family:var(--ds-font-family-code,monospace);color:var(--dsw-text-secondary,#64748b)}.rve-MG_body{flex:1;min-height:0}";
5407
+ const css$1 = ".rve-MG_root{box-sizing:border-box;flex-direction:column;gap:12px;width:100%;height:100%;min-height:0;padding:16px;display:flex;overflow:hidden}.rve-MG_status{color:var(--dsw-alias-label-secondary,#61666b);margin:0}.rve-MG_failure{flex-direction:column;align-items:flex-start;gap:8px;display:flex}.rve-MG_errorDetail{background:var(--dsw-alias-interactive-bg-hover,#2631480f);max-width:100%;font-family:var(--ds-font-family-code,monospace);color:var(--dsw-alias-state-error-primary,#dc2626);white-space:pre-wrap;word-break:break-all;border-radius:6px;margin:0;padding:8px 10px;font-size:11px;line-height:1.5;overflow:auto}.rve-MG_toolbar{justify-content:space-between;align-items:center;gap:12px;display:flex}.rve-MG_summaryGroup{align-items:center;gap:6px;min-width:0;display:flex}.rve-MG_actions{flex-wrap:wrap;flex-shrink:0;justify-content:flex-end;align-items:center;gap:8px;display:flex}.rve-MG_rankdirGroup{border:1px solid var(--dsw-alias-border-l3,#0000001f);background:var(--dsw-alias-bg-layer-2,#fff);border-radius:6px;align-items:center;gap:0;padding:2px;display:inline-flex}.rve-MG_rankdirOption{min-width:32px;height:22px;color:var(--dsw-alias-label-secondary,#61666b);text-align:center;cursor:pointer;background:0 0;border:none;border-radius:4px;padding:0 8px;font-size:11px}.rve-MG_rankdirOption[aria-pressed=true]{background:var(--dsw-alias-state-business-primary,#3b82f6);color:var(--dsw-alias-label-primary-foreground,#fff);font-weight:500}.rve-MG_metricStrip{flex-wrap:wrap;align-items:center;gap:6px 20px;padding:2px 0;display:flex}.rve-MG_stat{align-items:center;gap:6px;display:inline-flex}.rve-MG_statLabel{color:var(--dsw-alias-label-secondary,#61666b);font-size:12px}.rve-MG_statValue{font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-primary,#0f1115);font-size:15px;font-weight:600;line-height:1}.rve-MG_statAlert .rve-MG_statValue{color:var(--dsw-alias-state-error-primary,#dc2626)}.rve-MG_statToggle{color:inherit;font:inherit;cursor:pointer;background:0 0;border:none;align-items:center;gap:4px;padding:0;display:inline-flex}.rve-MG_statChevron{color:var(--dsw-alias-label-secondary,#61666b);transition:transform .15s}.rve-MG_statChevronOpen{transform:rotate(180deg)}.rve-MG_unresolvedPanel{border:1px solid var(--dsw-alias-border-l2,#0000001a);background:var(--dsw-alias-bg-layer-2,#fff);border-radius:8px;overflow:hidden}.rve-MG_unresolvedScroll{max-height:260px;overflow-y:auto}.rve-MG_unresolvedTable{table-layout:fixed;border-collapse:collapse;width:100%;font-size:12px}.rve-MG_unresolvedTable th,.rve-MG_unresolvedTable td{text-align:left;padding:8px 12px}.rve-MG_unresolvedTable thead th{z-index:1;background:var(--dsw-alias-bg-layer-2,#fff);color:var(--dsw-alias-label-secondary,#61666b);border-bottom:1px solid var(--dsw-alias-border-l2,#0000001a);font-size:11px;font-weight:500;position:sticky;top:0}.rve-MG_unresolvedTable tbody tr+tr td{border-top:1px solid var(--dsw-alias-border-l2,#0000001a)}.rve-MG_unresolvedTable .rve-MG_unresolvedColPlugin{width:180px}.rve-MG_unresolvedTable .rve-MG_unresolvedColState{width:110px}.rve-MG_unresolvedPlugin{font-family:var(--ds-font-family-code,monospace);color:var(--dsw-alias-label-primary,#0f1115);text-overflow:ellipsis;white-space:nowrap;font-weight:500;overflow:hidden}.rve-MG_unresolvedState{color:var(--dsw-alias-label-secondary,#61666b);white-space:nowrap;font-size:11px}.rve-MG_unresolvedService{font-family:var(--ds-font-family-code,monospace);color:var(--dsw-alias-state-error-primary,#dc2626);overflow-wrap:anywhere}.rve-MG_legend{color:var(--dsw-alias-label-secondary,#61666b);flex-wrap:wrap;align-items:center;gap:6px 16px;font-size:11px;display:flex}.rve-MG_legendItem{align-items:center;gap:6px;display:inline-flex}.rve-MG_legendSwatch{flex:none;display:inline-block}.rve-MG_legendUnresolvedBox{border:1.5px solid var(--dsw-alias-state-error-primary,#dc2626);background:var(--dsw-alias-bg-layer-2,#fff);border-radius:50%;width:12px;height:12px}.rve-MG_legendIsolatedBox{border:1px dashed var(--dsw-alias-label-caption,#94a3b8);background:var(--dsw-alias-bg-base,#f8fafc);border-radius:2px;width:12px;height:12px}.rve-MG_legendIdSample{font-family:var(--ds-font-family-code,monospace);color:var(--dsw-alias-label-secondary,#61666b)}.rve-MG_body{flex:1;min-height:0;position:relative}";
4983
5408
  const tagId$1 = "dsh-plugin-topology/TopologyView.module.css";
4984
5409
  if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId$1) + "]") === null) {
4985
5410
  const tag = document.createElement("style");
@@ -5039,11 +5464,22 @@ const DOWNLOAD_FORMATS = [
5039
5464
  labelKey: "downloadJson"
5040
5465
  }
5041
5466
  ];
5467
+ /** Detect the current DSH theme from the document root's data attribute or class. */
5468
+ function detectTheme() {
5469
+ const dataTheme = document.documentElement.getAttribute("data-theme");
5470
+ if (dataTheme === "dark") return "dark";
5471
+ if (dataTheme === "light") return "light";
5472
+ const bg = getComputedStyle(document.body).backgroundColor;
5473
+ if (bg !== "" && bg !== "rgba(0, 0, 0, 0)" && bg !== "transparent") {
5474
+ const match = bg.match(/(\d+)/g);
5475
+ if (match !== null && match.length >= 3) return (parseInt(match[0] ?? "255") * 299 + parseInt(match[1] ?? "255") * 587 + parseInt(match[2] ?? "255") * 114) / 1e3 < 128 ? "dark" : "light";
5476
+ }
5477
+ if (typeof matchMedia !== "undefined" && matchMedia("(prefers-color-scheme: dark)").matches) {}
5478
+ return "light";
5479
+ }
5042
5480
  /**
5043
5481
  * The health-oriented metrics: scale, the broken-dependency signal, and the
5044
- * three coupling/depth/fragmentation facts. Cycle invariants (isDag, SCC) and
5045
- * descriptive counts (average path, source/sink) are omitted — the former is
5046
- * constant on an inject/provide graph, the latter carry no health signal.
5482
+ * three coupling/depth/fragmentation facts.
5047
5483
  */
5048
5484
  function metricRows(analysis, t) {
5049
5485
  const m = analysis.graphMetrics;
@@ -5078,8 +5514,8 @@ function metricRows(analysis, t) {
5078
5514
  ];
5079
5515
  }
5080
5516
  /** Trigger a browser download of the rendered document. */
5081
- async function download(format, rankdir, render) {
5082
- const content = await render(format, rankdir);
5517
+ async function download(format, rankdir, theme, render) {
5518
+ const content = await render(format, rankdir, theme);
5083
5519
  const mime = format === "json" ? "application/json" : format === "dot" ? "text/vnd.graphviz" : "image/svg+xml";
5084
5520
  const blob = new Blob([content], { type: mime });
5085
5521
  const url = URL.createObjectURL(blob);
@@ -5091,7 +5527,7 @@ async function download(format, rankdir, render) {
5091
5527
  anchor.remove();
5092
5528
  URL.revokeObjectURL(url);
5093
5529
  }
5094
- /** Group unresolved dependencies per plugin, so one row carries every missing service. */
5530
+ /** Group unresolved dependencies per plugin. */
5095
5531
  function unresolvedRows(analysis) {
5096
5532
  const labels = new Map(analysis.graph.nodes.filter((node) => node.kind === "plugin").map((node) => [node.id, node.label]));
5097
5533
  const groups = /* @__PURE__ */ new Map();
@@ -5122,10 +5558,40 @@ function TopologyView({ useStore, actions, analyze, render, t }) {
5122
5558
  const [state, setState] = (0, react.useState)({ status: "loading" });
5123
5559
  const [unresolvedOpen, setUnresolvedOpen] = (0, react.useState)(false);
5124
5560
  const [rankdir, setRankdir] = (0, react.useState)("LR");
5561
+ const [theme, setTheme] = (0, react.useState)(detectTheme);
5562
+ const [anchor, setAnchor] = (0, react.useState)(null);
5563
+ const [direction, setDirection] = (0, react.useState)(null);
5125
5564
  const transform$1 = useStore((store) => store.transform);
5565
+ const view = transform$1 ?? {
5566
+ x: 0,
5567
+ y: 0,
5568
+ k: 1
5569
+ };
5570
+ const popover = anchor === null ? null : {
5571
+ ...anchor,
5572
+ x: view.x + anchor.anchorX * view.k,
5573
+ y: view.y + anchor.anchorY * view.k
5574
+ };
5575
+ (0, react.useEffect)(() => {
5576
+ const observer = new MutationObserver(() => {
5577
+ const next = detectTheme();
5578
+ setTheme((prev) => prev === next ? prev : next);
5579
+ });
5580
+ observer.observe(document.documentElement, {
5581
+ attributes: true,
5582
+ attributeFilter: [
5583
+ "data-theme",
5584
+ "class",
5585
+ "style"
5586
+ ]
5587
+ });
5588
+ return () => {
5589
+ observer.disconnect();
5590
+ };
5591
+ }, []);
5126
5592
  (0, react.useEffect)(() => {
5127
5593
  let current = true;
5128
- Promise.all([analyze(), render("svg", rankdir)]).then(([analysis, svg]) => {
5594
+ Promise.all([analyze(), render("svg", rankdir, theme)]).then(([analysis, svg]) => {
5129
5595
  if (current) setState({
5130
5596
  status: "ready",
5131
5597
  analysis,
@@ -5145,6 +5611,7 @@ function TopologyView({ useStore, actions, analyze, render, t }) {
5145
5611
  }, [
5146
5612
  request,
5147
5613
  rankdir,
5614
+ theme,
5148
5615
  analyze,
5149
5616
  render
5150
5617
  ]);
@@ -5160,6 +5627,33 @@ function TopologyView({ useStore, actions, analyze, render, t }) {
5160
5627
  actions.setTransform(null);
5161
5628
  setRankdir(next);
5162
5629
  };
5630
+ const onNodeClick = (0, react.useCallback)((info) => {
5631
+ setDirection(null);
5632
+ setAnchor((prev) => {
5633
+ if (prev !== null && prev.nodeId === info.nodeId) return null;
5634
+ return {
5635
+ key: info.key,
5636
+ nodeId: info.nodeId,
5637
+ label: info.label,
5638
+ anchorX: info.anchorX,
5639
+ anchorY: info.anchorY,
5640
+ outCount: info.outCount,
5641
+ inCount: info.inCount
5642
+ };
5643
+ });
5644
+ }, []);
5645
+ const onEmptyClick = (0, react.useCallback)(() => {
5646
+ setAnchor(null);
5647
+ setDirection(null);
5648
+ }, []);
5649
+ const closePopover = (0, react.useCallback)(() => {
5650
+ setAnchor(null);
5651
+ setDirection(null);
5652
+ }, []);
5653
+ /** Tapping the active pill again drops back to plain selection. */
5654
+ const toggleDirection = (0, react.useCallback)((next) => {
5655
+ setDirection((current) => current === next ? null : next);
5656
+ }, []);
5163
5657
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5164
5658
  className: TopologyView_module_css_default.root,
5165
5659
  "data-plugin-topology-view": "",
@@ -5241,7 +5735,7 @@ function TopologyView({ useStore, actions, analyze, render, t }) {
5241
5735
  }), DOWNLOAD_FORMATS.map(({ format, labelKey }) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(OutlineButton, {
5242
5736
  icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconDownloadOutline16, { size: 14 }),
5243
5737
  onClick: () => {
5244
- download(format, rankdir, render);
5738
+ download(format, rankdir, theme, render);
5245
5739
  },
5246
5740
  children: t(labelKey)
5247
5741
  }, format))]
@@ -5313,9 +5807,9 @@ function TopologyView({ useStore, actions, analyze, render, t }) {
5313
5807
  })
5314
5808
  ]
5315
5809
  }),
5316
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
5810
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5317
5811
  className: TopologyView_module_css_default.body,
5318
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TopologyGraphView, {
5812
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(TopologyGraphView, {
5319
5813
  svg: state.svg,
5320
5814
  alt: t("title"),
5321
5815
  height: "100%",
@@ -5324,8 +5818,20 @@ function TopologyView({ useStore, actions, analyze, render, t }) {
5324
5818
  zoomInLabel: t("zoomIn"),
5325
5819
  zoomOutLabel: t("zoomOut"),
5326
5820
  resetViewLabel: t("resetView"),
5327
- zoomLevelLabel: t("zoomLevel")
5328
- })
5821
+ zoomLevelLabel: t("zoomLevel"),
5822
+ onNodeClick,
5823
+ onEmptyClick,
5824
+ selectedKey: popover?.key ?? null,
5825
+ selectedDotId: popover?.nodeId ?? null,
5826
+ direction
5827
+ }), popover !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(NodePopover, {
5828
+ popover,
5829
+ analysis: state.analysis,
5830
+ direction,
5831
+ onToggleDirection: toggleDirection,
5832
+ onClose: closePopover,
5833
+ t
5834
+ })]
5329
5835
  })
5330
5836
  ] }) : null
5331
5837
  ]
@@ -5334,7 +5840,7 @@ function TopologyView({ useStore, actions, analyze, render, t }) {
5334
5840
 
5335
5841
  //#endregion
5336
5842
  //#region \0dsh-css:/Users/jiangyuan/Documents/side-work/dsh-plugin-topology/src/client/panel.module.css.mjs
5337
- const css = ".F01v1a_layer{align-items:center;width:100%;display:flex}.F01v1a_rail{justify-content:center}.F01v1a_trigger{box-sizing:border-box;cursor:pointer;width:calc(100% + 4px);height:42px;color:var(--dsw-alias-label-primary,#0f172a);background:0 0;border:none;border-radius:12px;flex:none;align-items:center;gap:8px;margin:4px -2px;padding:0 10px 0 8px;font-family:inherit;font-size:14px;line-height:22px;display:flex;overflow:hidden}.F01v1a_trigger:hover{background:var(--dsw-alias-interactive-bg-hover,#f1f5f9)}.F01v1a_triggerLabel{white-space:nowrap;overflow:hidden}.F01v1a_panel{border:1px solid var(--dsw-border,#cbd5e1);background:var(--dsw-surface,#fff);z-index:30;border-radius:12px;flex-direction:column;width:min(840px,100vw - 48px);height:min(680px,100vh - 48px);display:flex;position:fixed;top:50%;left:50%;overflow:hidden;transform:translate(-50%,-50%);box-shadow:0 18px 48px #0000002e}.F01v1a_maximized{width:calc(100vw - 32px);height:calc(100vh - 32px)}.F01v1a_header{border-bottom:1px solid var(--dsw-border,#cbd5e1);background:var(--dsw-surface,#fff);justify-content:space-between;align-items:center;gap:8px;padding:10px 14px;display:flex}.F01v1a_headerActions{align-items:center;gap:4px;display:inline-flex}.F01v1a_title{color:var(--dsw-text-primary,#0f172a);font-size:14px;font-weight:600}.F01v1a_close{width:24px;height:24px;color:var(--dsw-text-secondary,#64748b);cursor:pointer;background:0 0;border:none;border-radius:6px;justify-content:center;align-items:center;padding:0;display:inline-flex}.F01v1a_close:hover{color:var(--dsw-text-primary,#0f172a);background:var(--dsw-surface-hover,#f1f5f9)}.F01v1a_body{flex-direction:column;flex:1;min-height:0;display:flex}.pluginTopologyButton{border:1px solid var(--dsw-border,#cbd5e1);background:var(--dsw-surface,#fff);height:26px;color:var(--dsw-text-primary,#0f172a);cursor:pointer;border-radius:6px;align-items:center;gap:6px;padding:0 10px;font-family:inherit;font-size:12px;line-height:1;display:inline-flex}.pluginTopologyButton:hover{border-color:var(--dsw-alias-state-business-primary,#3b82f6)}";
5843
+ const css = ".F01v1a_layer{align-items:center;width:100%;display:flex}.F01v1a_rail{justify-content:center}.F01v1a_trigger{box-sizing:border-box;cursor:pointer;width:calc(100% + 4px);height:42px;color:var(--dsw-alias-label-primary,#0f172a);background:0 0;border:none;border-radius:12px;flex:none;align-items:center;gap:8px;margin:4px -2px;padding:0 10px 0 8px;font-family:inherit;font-size:14px;line-height:22px;display:flex;overflow:hidden}.F01v1a_trigger:hover{background:var(--dsw-alias-interactive-bg-hover,#f1f5f9)}.F01v1a_triggerLabel{white-space:nowrap;overflow:hidden}.F01v1a_panel{border:1px solid var(--dsw-alias-border-l3,#0000001f);background:var(--dsw-alias-bg-layer-2,#fff);z-index:30;border-radius:12px;flex-direction:column;width:min(840px,100vw - 48px);height:min(680px,100vh - 48px);display:flex;position:fixed;top:50%;left:50%;overflow:hidden;transform:translate(-50%,-50%);box-shadow:0 18px 48px #0000002e}.F01v1a_maximized{width:calc(100vw - 32px);height:calc(100vh - 32px)}.F01v1a_header{border-bottom:1px solid var(--dsw-alias-border-l2,#0000001a);background:var(--dsw-alias-bg-layer-2,#fff);justify-content:space-between;align-items:center;gap:8px;padding:10px 14px;display:flex}.F01v1a_headerActions{align-items:center;gap:4px;display:inline-flex}.F01v1a_title{color:var(--dsw-alias-label-primary,#0f1115);font-size:14px;font-weight:600}.F01v1a_close{width:24px;height:24px;color:var(--dsw-alias-label-secondary,#61666b);cursor:pointer;background:0 0;border:none;border-radius:6px;justify-content:center;align-items:center;padding:0;display:inline-flex}.F01v1a_close:hover{color:var(--dsw-alias-label-primary,#0f1115);background:var(--dsw-alias-interactive-bg-hover,#2631480f)}.F01v1a_body{flex-direction:column;flex:1;min-height:0;display:flex}.pluginTopologyButton{border:1px solid var(--dsw-alias-border-l3,#0000001f);background:var(--dsw-alias-bg-layer-2,#fff);height:26px;color:var(--dsw-alias-label-primary,#0f1115);cursor:pointer;border-radius:6px;align-items:center;gap:6px;padding:0 10px;font-family:inherit;font-size:12px;line-height:1;display:inline-flex}.pluginTopologyButton:hover{border-color:var(--dsw-alias-state-business-primary,#3b82f6)}";
5338
5844
  const tagId = "dsh-plugin-topology/panel.module.css";
5339
5845
  if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
5340
5846
  const tag = document.createElement("style");
@@ -5476,7 +5982,12 @@ const en = {
5476
5982
  legendLabel: "Legend",
5477
5983
  legendUnresolved: "Unresolved dependency",
5478
5984
  legendIsolated: "Isolated plugin",
5479
- legendId: "Instance creation ordinal within this run"
5985
+ legendId: "Instance creation ordinal within this run",
5986
+ popoverClose: "Close",
5987
+ popoverSource: "Source",
5988
+ popoverOutgoing: "Highlight outgoing dependencies (deg⁺)",
5989
+ popoverIncoming: "Highlight incoming dependents (deg⁻)",
5990
+ popoverUnresolved: "unresolved"
5480
5991
  };
5481
5992
  const zh = {
5482
5993
  title: "插件拓扑",
@@ -5503,7 +6014,12 @@ const zh = {
5503
6014
  legendLabel: "图例",
5504
6015
  legendUnresolved: "存在未解析依赖",
5505
6016
  legendIsolated: "孤立插件(无依赖边)",
5506
- legendId: "本次运行内的创建序号"
6017
+ legendId: "本次运行内的创建序号",
6018
+ popoverClose: "关闭",
6019
+ popoverSource: "来源",
6020
+ popoverOutgoing: "高亮它依赖的插件(出度 deg⁺)",
6021
+ popoverIncoming: "高亮依赖它的插件(入度 deg⁻)",
6022
+ popoverUnresolved: "未解析"
5507
6023
  };
5508
6024
 
5509
6025
  //#endregion
@@ -5542,8 +6058,8 @@ function apply(ctx) {
5542
6058
  if (!result.ok) throw new Error(`pluginTopology.analyze failed: ${result.error.code}: ${result.error.message}`);
5543
6059
  return result.value;
5544
6060
  },
5545
- render: async (format, rankdir) => {
5546
- const result = await namespaceOf().render(format, rankdir);
6061
+ render: async (format, rankdir, theme) => {
6062
+ const result = await namespaceOf().render(format, rankdir, theme);
5547
6063
  if (!result.ok) throw new Error(`pluginTopology.render failed: ${result.error.code}: ${result.error.message}`);
5548
6064
  return result.value;
5549
6065
  }