@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.
@@ -2,6 +2,192 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useCallback, useEffect, useRef, useState } from 'react';
3
3
  import { IconFullscreenOutline16, IconMinusOutline16, IconPlusOutline16 } from "./icons.js";
4
4
  import css from './TopologyGraphView.module.css';
5
+ /**
6
+ * Classes owned and toggled by the highlight layer. The hover ring is managed
7
+ * separately by the pointer listener so a selection repaint cannot drop it.
8
+ */
9
+ const HIGHLIGHT_CLASSES = [
10
+ 'topo-selected-node',
11
+ 'topo-hl-node',
12
+ 'topo-hl-neighbor',
13
+ 'topo-hl-edge',
14
+ 'topo-dimmed',
15
+ ];
16
+ /** Read the DOT node id and display label from a Graphviz node group. */
17
+ function nodeRefFromElement(element) {
18
+ if (!(element instanceof Element))
19
+ return null;
20
+ const group = element.closest('g.node');
21
+ if (group === null)
22
+ return null;
23
+ const dotId = group.querySelector(':scope > title')?.textContent;
24
+ if (dotId === null || dotId === undefined)
25
+ return null;
26
+ const label = group.querySelector(':scope > text')?.textContent ?? dotId;
27
+ return { ref: { key: group.id, dotId, label }, group };
28
+ }
29
+ /**
30
+ * The nearest ancestor `<g>` that wraps one whole Graphviz layout (main or
31
+ * isolated). `composeGraphvizSvgs` places each layout in its own translated
32
+ * group, so this is also the isolation boundary: `m:N` ids are only unique
33
+ * inside one layout and must not be matched across both.
34
+ */
35
+ function graphScope(node) {
36
+ let current = node;
37
+ while (current.parentElement !== null && current.parentElement.tagName.toLowerCase() !== 'svg') {
38
+ current = current.parentElement;
39
+ }
40
+ return current;
41
+ }
42
+ /** Split a Graphviz edge `<title>` (`source-&gt;target`) into its endpoints. */
43
+ function edgeEndpoints(edge) {
44
+ const title = edge.querySelector(':scope > title')?.textContent;
45
+ if (title === null || title === undefined)
46
+ return null;
47
+ const arrowIdx = title.indexOf('->');
48
+ if (arrowIdx < 0)
49
+ return null;
50
+ return { source: title.slice(0, arrowIdx), target: title.slice(arrowIdx + 2) };
51
+ }
52
+ /** Count the drawn outgoing/incoming edges of a node inside one layout scope. */
53
+ function edgeStats(scope, dotId) {
54
+ let out = 0;
55
+ let incoming = 0;
56
+ for (const edge of scope.querySelectorAll('g.edge')) {
57
+ const endpoints = edgeEndpoints(edge);
58
+ if (endpoints === null)
59
+ continue;
60
+ if (endpoints.source === dotId)
61
+ out += 1;
62
+ if (endpoints.target === dotId)
63
+ incoming += 1;
64
+ }
65
+ return { out, in: incoming };
66
+ }
67
+ /** Find the node group with the given stable element id. */
68
+ function findNodeGroup(container, key) {
69
+ for (const group of container.querySelectorAll('g.node')) {
70
+ if (group.id === key)
71
+ return group;
72
+ }
73
+ return null;
74
+ }
75
+ /** Drop every highlight class this module owns. */
76
+ function clearHighlight(container) {
77
+ const selector = HIGHLIGHT_CLASSES.map(name => `.${name}`).join(',');
78
+ for (const element of container.querySelectorAll(selector)) {
79
+ element.classList.remove(...HIGHLIGHT_CLASSES);
80
+ }
81
+ }
82
+ /**
83
+ * Paint the selection: the selected node always gets a marker ring; when a
84
+ * direction is active, only that direction's edges and their far-end nodes stay
85
+ * lit while the rest of the same layout dims. The other layout (isolated
86
+ * plugins) is left untouched, since it has no edges to follow.
87
+ */
88
+ function applyHighlight(container, selection) {
89
+ clearHighlight(container);
90
+ if (selection === null)
91
+ return;
92
+ const group = findNodeGroup(container, selection.key);
93
+ if (group === null)
94
+ return;
95
+ group.classList.add('topo-selected-node');
96
+ if (selection.direction === null)
97
+ return;
98
+ const scope = graphScope(group);
99
+ const lit = new Set([selection.dotId]);
100
+ const connectedEdges = new Set();
101
+ for (const edge of scope.querySelectorAll('g.edge')) {
102
+ const endpoints = edgeEndpoints(edge);
103
+ if (endpoints === null)
104
+ continue;
105
+ const matches = selection.direction === 'out'
106
+ ? endpoints.source === selection.dotId
107
+ : endpoints.target === selection.dotId;
108
+ if (!matches)
109
+ continue;
110
+ connectedEdges.add(edge);
111
+ lit.add(endpoints.source);
112
+ lit.add(endpoints.target);
113
+ }
114
+ group.classList.add('topo-hl-node');
115
+ for (const node of scope.querySelectorAll('g.node')) {
116
+ if (node === group)
117
+ continue;
118
+ const dotId = node.querySelector(':scope > title')?.textContent;
119
+ if (dotId !== null && dotId !== undefined && lit.has(dotId)) {
120
+ node.classList.add('topo-hl-neighbor');
121
+ }
122
+ else {
123
+ node.classList.add('topo-dimmed');
124
+ }
125
+ }
126
+ for (const edge of scope.querySelectorAll('g.edge')) {
127
+ edge.classList.add(connectedEdges.has(edge) ? 'topo-hl-edge' : 'topo-dimmed');
128
+ }
129
+ }
130
+ /**
131
+ * Inline SVG renderer. Encapsulates the raw HTML injection behind a meaningful
132
+ * component name so readers are not startled by the React API at call sites.
133
+ * Hover and highlight are applied as classes on the Graphviz DOM, which React
134
+ * does not own; the exported SVG string itself stays free of listeners.
135
+ */
136
+ function SvgCanvas({ svg, alt, fitted, view, selectedKey, selectedDotId, direction }) {
137
+ const containerRef = useRef(null);
138
+ /** The group currently carrying the hover ring, to avoid redundant DOM work. */
139
+ const hoveredRef = useRef(null);
140
+ // Repaint the persistent selection after each render or when it changes.
141
+ useEffect(() => {
142
+ const container = containerRef.current;
143
+ if (container === null)
144
+ return;
145
+ applyHighlight(container, selectedKey !== null && selectedDotId !== null
146
+ ? { key: selectedKey, dotId: selectedDotId, direction }
147
+ : null);
148
+ }, [selectedKey, selectedDotId, direction, svg]);
149
+ // A light hover ring only: no dimming, so sweeping the pointer across the
150
+ // graph does not flash the whole canvas between focus states.
151
+ useEffect(() => {
152
+ const container = containerRef.current;
153
+ if (container === null)
154
+ return;
155
+ // A new SVG string replaces the Graphviz DOM, so any tracked group is stale.
156
+ hoveredRef.current = null;
157
+ const onMouseOver = (event) => {
158
+ const found = nodeRefFromElement(event.target);
159
+ if (found === null || found.group === hoveredRef.current)
160
+ return;
161
+ hoveredRef.current?.classList.remove('topo-hover-node');
162
+ hoveredRef.current = found.group;
163
+ found.group.classList.add('topo-hover-node');
164
+ container.style.cursor = 'pointer';
165
+ };
166
+ const onMouseOut = (event) => {
167
+ const hovered = hoveredRef.current;
168
+ if (hovered === null)
169
+ return;
170
+ const related = event.relatedTarget;
171
+ if (related !== null && hovered.contains(related))
172
+ return;
173
+ hovered.classList.remove('topo-hover-node');
174
+ hoveredRef.current = null;
175
+ container.style.cursor = '';
176
+ };
177
+ container.addEventListener('mouseover', onMouseOver);
178
+ container.addEventListener('mouseout', onMouseOut);
179
+ return () => {
180
+ container.removeEventListener('mouseover', onMouseOver);
181
+ container.removeEventListener('mouseout', onMouseOut);
182
+ };
183
+ }, [svg]);
184
+ return (_jsx("div", { ref: containerRef, className: css.graphImg, role: "img", "aria-label": alt, style: {
185
+ opacity: fitted ? 1 : 0,
186
+ transform: `translate(${view.x}px, ${view.y}px) scale(${view.k})`,
187
+ },
188
+ // eslint-disable-next-line react/no-danger -- SVG is generated by our own server-side Graphviz renderer; trusted source.
189
+ dangerouslySetInnerHTML: { __html: svg } }));
190
+ }
5
191
  const MIN_K = 0.05;
6
192
  /** A fit never shrinks below this scale, so the default and reset land at actual size. */
7
193
  const MIN_FIT_K = 1.0;
@@ -26,13 +212,16 @@ function fitView(containerWidth, containerHeight, naturalWidth, naturalHeight) {
26
212
  };
27
213
  }
28
214
  /**
29
- * Pan/zoom SVG viewer: fit-and-center on load, wheel zooms toward the cursor,
30
- * drag pans, double-click zooms toward the cursor, and a compact floating pill
31
- * (fit / zoom-out / zoom-level / zoom-in) collapses to just the fit control at
32
- * rest. The transform is written back to the parent store on every change so it
33
- * survives panel close/reopen.
215
+ * Pan/zoom SVG viewer with drill-down interaction. Fit-and-center on load,
216
+ * wheel zooms toward the cursor, drag pans, double-click zooms toward the
217
+ * cursor, and a compact floating pill (fit / zoom-out / zoom-level / zoom-in)
218
+ * collapses to just the fit control at rest.
219
+ *
220
+ * Hovering a node raises a ring; tapping it fires onNodeClick with viewport
221
+ * coordinates and the drawn deg⁺/deg⁻ counts. Tapping empty space fires
222
+ * onEmptyClick. `direction` lights only the chosen dependency direction.
34
223
  */
35
- export function TopologyGraphView({ svg, alt, height, transform, onTransformChange, zoomInLabel, zoomOutLabel, resetViewLabel, zoomLevelLabel, }) {
224
+ export function TopologyGraphView({ svg, alt, height, transform, onTransformChange, zoomInLabel, zoomOutLabel, resetViewLabel, zoomLevelLabel, onNodeClick, onEmptyClick, selectedKey, selectedDotId, direction, }) {
36
225
  const containerRef = useRef(null);
37
226
  const naturalRef = useRef(null);
38
227
  const dragRef = useRef(null);
@@ -89,16 +278,18 @@ export function TopologyGraphView({ svg, alt, height, transform, onTransformChan
89
278
  });
90
279
  setAtFit(false);
91
280
  }, [onTransformChange]);
92
- const onLoad = (event) => {
93
- const img = event.currentTarget;
94
- naturalRef.current = { width: img.naturalWidth, height: img.naturalHeight };
95
- if (transform === null)
96
- fit();
97
- setFitted(true);
98
- };
281
+ // Extract natural dimensions from the SVG viewBox on first render.
282
+ useEffect(() => {
283
+ const match = svg.match(/viewBox="[\d.]+ [\d.]+ ([\d.]+) ([\d.]+)"/);
284
+ if (match !== null) {
285
+ naturalRef.current = { width: parseFloat(match[1] ?? '0'), height: parseFloat(match[2] ?? '0') };
286
+ if (transform === null) {
287
+ requestAnimationFrame(() => { fit(); });
288
+ }
289
+ setFitted(true);
290
+ }
291
+ }, [svg, transform, fit]);
99
292
  // Track the container size so the zoom-level "actual size" (100%) stays centered.
100
- // ResizeObserver is unavailable in jsdom; the initial size already comes from
101
- // getBoundingClientRect, so the center just won't react to resizes there.
102
293
  useEffect(() => {
103
294
  const element = containerRef.current;
104
295
  if (element === null)
@@ -137,8 +328,7 @@ export function TopologyGraphView({ svg, alt, height, transform, onTransformChan
137
328
  element.addEventListener('wheel', onWheel, { passive: false });
138
329
  return () => { element.removeEventListener('wheel', onWheel); };
139
330
  }, [onTransformChange]);
140
- // Keyboard shortcuts while the pointer is over the canvas: +/− zoom, 0/F reset-to-fit,
141
- // arrow keys pan. Ignored while a text field has focus.
331
+ // Keyboard shortcuts while the pointer is over the canvas.
142
332
  useEffect(() => {
143
333
  if (!viewportHovered)
144
334
  return;
@@ -192,6 +382,7 @@ export function TopologyGraphView({ svg, alt, height, transform, onTransformChan
192
382
  startY: event.clientY,
193
383
  baseX: view.x,
194
384
  baseY: view.y,
385
+ moved: false,
195
386
  };
196
387
  event.currentTarget.setPointerCapture(event.pointerId);
197
388
  };
@@ -199,16 +390,47 @@ export function TopologyGraphView({ svg, alt, height, transform, onTransformChan
199
390
  const drag = dragRef.current;
200
391
  if (drag === null || drag.pointerId !== event.pointerId)
201
392
  return;
393
+ const dx = event.clientX - drag.startX;
394
+ const dy = event.clientY - drag.startY;
395
+ if (!drag.moved && Math.abs(dx) < 3 && Math.abs(dy) < 3)
396
+ return;
397
+ drag.moved = true;
202
398
  commit({
203
399
  ...view,
204
- x: drag.baseX + (event.clientX - drag.startX),
205
- y: drag.baseY + (event.clientY - drag.startY),
400
+ x: drag.baseX + dx,
401
+ y: drag.baseY + dy,
206
402
  });
207
403
  setAtFit(false);
208
404
  };
209
405
  const onPointerEnd = (event) => {
210
- if (dragRef.current?.pointerId === event.pointerId)
211
- dragRef.current = null;
406
+ const drag = dragRef.current;
407
+ if (drag === null || drag.pointerId !== event.pointerId)
408
+ return;
409
+ // A pointer that stayed put is a tap, not a pan. setPointerCapture has
410
+ // retargeted the event to this container, so hit-test the real element.
411
+ if (!drag.moved) {
412
+ const realTarget = document.elementFromPoint(event.clientX, event.clientY);
413
+ const found = nodeRefFromElement(realTarget);
414
+ const container = containerRef.current;
415
+ if (found !== null && container !== null && onNodeClick !== undefined) {
416
+ const rect = container.getBoundingClientRect();
417
+ const stats = edgeStats(graphScope(found.group), found.ref.dotId);
418
+ onNodeClick({
419
+ key: found.ref.key,
420
+ nodeId: found.ref.dotId,
421
+ label: found.ref.label,
422
+ // Invert the viewport transform so the anchor survives later pan/zoom.
423
+ anchorX: (event.clientX - rect.left - view.x) / view.k,
424
+ anchorY: (event.clientY - rect.top - view.y) / view.k,
425
+ outCount: stats.out,
426
+ inCount: stats.in,
427
+ });
428
+ }
429
+ else if (found === null && onEmptyClick !== undefined) {
430
+ onEmptyClick();
431
+ }
432
+ }
433
+ dragRef.current = null;
212
434
  };
213
435
  const onDoubleClick = (event) => {
214
436
  const element = containerRef.current;
@@ -219,7 +441,6 @@ export function TopologyGraphView({ svg, alt, height, transform, onTransformChan
219
441
  const py = event.clientY - rect.top;
220
442
  zoomAround(px, py, event.shiftKey ? 1 / ZOOM_FACTOR : ZOOM_FACTOR);
221
443
  };
222
- // Clicking the zoom level toggles between the reset-to-fit transform and 100% (k = 1).
223
444
  const onZoomLevelClick = () => {
224
445
  if (atFit) {
225
446
  const size = containerSize;
@@ -238,9 +459,5 @@ export function TopologyGraphView({ svg, alt, height, transform, onTransformChan
238
459
  }
239
460
  };
240
461
  const collapsed = atFit && !panelHovered;
241
- const src = `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`;
242
- return (_jsxs("div", { className: css.graphBox, style: { height }, children: [_jsx("div", { ref: containerRef, className: css.graphViewport, onPointerDown: onPointerDown, onPointerMove: onPointerMove, onPointerUp: onPointerEnd, onPointerCancel: onPointerEnd, onDoubleClick: onDoubleClick, onPointerEnter: () => { setViewportHovered(true); }, onPointerLeave: () => { setViewportHovered(false); }, children: _jsx("img", { className: css.graphImg, src: src, alt: alt, draggable: false, onLoad: onLoad, style: {
243
- opacity: fitted ? 1 : 0,
244
- transform: `translate(${view.x}px, ${view.y}px) scale(${view.k})`,
245
- } }) }), _jsxs("div", { className: collapsed ? `${css.zoomControls} ${css.collapsed}` : css.zoomControls, onPointerEnter: () => { setPanelHovered(true); }, onPointerLeave: () => { setPanelHovered(false); }, children: [_jsx("button", { type: "button", className: `${css.iconButton} ${css.fitButton}`, onClick: fit, "aria-label": resetViewLabel, title: resetViewLabel, children: _jsx(IconFullscreenOutline16, {}) }), _jsx("button", { type: "button", className: `${css.iconButton} ${css.zoomButton}`, onClick: () => { zoomAtCenter(1 / ZOOM_FACTOR); }, "aria-label": zoomOutLabel, title: zoomOutLabel, children: _jsx(IconMinusOutline16, {}) }), _jsx("button", { type: "button", className: css.zoomLevel, onClick: onZoomLevelClick, "aria-label": zoomLevelLabel, title: zoomLevelLabel, children: formatZoom(view.k) }), _jsx("button", { type: "button", className: `${css.iconButton} ${css.zoomButton}`, onClick: () => { zoomAtCenter(ZOOM_FACTOR); }, "aria-label": zoomInLabel, title: zoomInLabel, children: _jsx(IconPlusOutline16, {}) })] })] }));
462
+ return (_jsxs("div", { className: css.graphBox, style: { height }, children: [_jsx("div", { ref: containerRef, className: css.graphViewport, onPointerDown: onPointerDown, onPointerMove: onPointerMove, onPointerUp: onPointerEnd, onPointerCancel: onPointerEnd, onDoubleClick: onDoubleClick, onPointerEnter: () => { setViewportHovered(true); }, onPointerLeave: () => { setViewportHovered(false); }, children: _jsx(SvgCanvas, { svg: svg, alt: alt, fitted: fitted, view: view, selectedKey: selectedKey ?? null, selectedDotId: selectedDotId ?? null, direction: direction ?? null }) }), _jsxs("div", { className: collapsed ? `${css.zoomControls} ${css.collapsed}` : css.zoomControls, onPointerEnter: () => { setPanelHovered(true); }, onPointerLeave: () => { setPanelHovered(false); }, children: [_jsx("button", { type: "button", className: `${css.iconButton} ${css.fitButton}`, onClick: fit, "aria-label": resetViewLabel, title: resetViewLabel, children: _jsx(IconFullscreenOutline16, {}) }), _jsx("button", { type: "button", className: `${css.iconButton} ${css.zoomButton}`, onClick: () => { zoomAtCenter(1 / ZOOM_FACTOR); }, "aria-label": zoomOutLabel, title: zoomOutLabel, children: _jsx(IconMinusOutline16, {}) }), _jsx("button", { type: "button", className: css.zoomLevel, onClick: onZoomLevelClick, "aria-label": zoomLevelLabel, title: zoomLevelLabel, children: formatZoom(view.k) }), _jsx("button", { type: "button", className: `${css.iconButton} ${css.zoomButton}`, onClick: () => { zoomAtCenter(ZOOM_FACTOR); }, "aria-label": zoomInLabel, title: zoomInLabel, children: _jsx(IconPlusOutline16, {}) })] })] }));
246
463
  }
@@ -7,8 +7,8 @@ import { type TopologyTransform } from './stores.ts';
7
7
  export interface TopologyViewInjected {
8
8
  /** Read the current Host plugin topology analysis for the metrics. */
9
9
  analyze: () => Promise<TopologyAnalysis>;
10
- /** Serialize the current topology in one format along the given layout direction. */
11
- render: (format: RenderFormat, rankdir: Rankdir) => Promise<string>;
10
+ /** Serialize the current topology in one format along the given layout direction and theme. */
11
+ render: (format: RenderFormat, rankdir: Rankdir, theme?: 'light' | 'dark') => Promise<string>;
12
12
  }
13
13
  /** Viewer state selector: the remembered transform (null until the first fit). */
14
14
  export type ViewerStateSelector = SnapshotSelectorHook<{
@@ -1,7 +1,8 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { useEffect, useState } from 'react';
2
+ import { useCallback, useEffect, useState } from 'react';
3
3
  import { IconChevronDownOutline14, IconDownloadOutline16, OutlineButton } from "./icons.js";
4
4
  import { TopologyGraphView } from "./TopologyGraphView.js";
5
+ import { NodePopover } from "./NodePopover.js";
5
6
  import { createTopologyViewStore } from "./stores.js";
6
7
  import css from './TopologyView.module.css';
7
8
  const DOWNLOAD_FORMATS = [
@@ -9,11 +10,34 @@ const DOWNLOAD_FORMATS = [
9
10
  { format: 'dot', labelKey: 'downloadDot' },
10
11
  { format: 'json', labelKey: 'downloadJson' },
11
12
  ];
13
+ /** Detect the current DSH theme from the document root's data attribute or class. */
14
+ function detectTheme() {
15
+ const root = document.documentElement;
16
+ // DSH sets data-theme or a class on <html>; check common patterns.
17
+ const dataTheme = root.getAttribute('data-theme');
18
+ if (dataTheme === 'dark')
19
+ return 'dark';
20
+ if (dataTheme === 'light')
21
+ return 'light';
22
+ // Fallback: check computed background color luminance of <body> (more reliable than <html>).
23
+ const bg = getComputedStyle(document.body).backgroundColor;
24
+ if (bg !== '' && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent') {
25
+ const match = bg.match(/(\d+)/g);
26
+ if (match !== null && match.length >= 3) {
27
+ const luminance = (parseInt(match[0] ?? '255') * 299 + parseInt(match[1] ?? '255') * 587 + parseInt(match[2] ?? '255') * 114) / 1000;
28
+ return luminance < 128 ? 'dark' : 'light';
29
+ }
30
+ }
31
+ // Also check the resolved color-scheme via matchMedia.
32
+ if (typeof matchMedia !== 'undefined' && matchMedia('(prefers-color-scheme: dark)').matches) {
33
+ // Only trust this if DSH preference is 'system'; otherwise DSH overrides it.
34
+ // Since we can't read DSH's preference here, default to light (safer for most users).
35
+ }
36
+ return 'light';
37
+ }
12
38
  /**
13
39
  * The health-oriented metrics: scale, the broken-dependency signal, and the
14
- * three coupling/depth/fragmentation facts. Cycle invariants (isDag, SCC) and
15
- * descriptive counts (average path, source/sink) are omitted — the former is
16
- * constant on an inject/provide graph, the latter carry no health signal.
40
+ * three coupling/depth/fragmentation facts.
17
41
  */
18
42
  function metricRows(analysis, t) {
19
43
  const m = analysis.graphMetrics;
@@ -28,8 +52,8 @@ function metricRows(analysis, t) {
28
52
  ];
29
53
  }
30
54
  /** Trigger a browser download of the rendered document. */
31
- async function download(format, rankdir, render) {
32
- const content = await render(format, rankdir);
55
+ async function download(format, rankdir, theme, render) {
56
+ const content = await render(format, rankdir, theme);
33
57
  const mime = format === 'json' ? 'application/json' : format === 'dot' ? 'text/vnd.graphviz' : 'image/svg+xml';
34
58
  const blob = new Blob([content], { type: mime });
35
59
  const url = URL.createObjectURL(blob);
@@ -41,7 +65,7 @@ async function download(format, rankdir, render) {
41
65
  anchor.remove();
42
66
  URL.revokeObjectURL(url);
43
67
  }
44
- /** Group unresolved dependencies per plugin, so one row carries every missing service. */
68
+ /** Group unresolved dependencies per plugin. */
45
69
  function unresolvedRows(analysis) {
46
70
  const labels = new Map(analysis.graph.nodes
47
71
  .filter(node => node.kind === 'plugin')
@@ -69,10 +93,29 @@ export function TopologyView({ useStore, actions, analyze, render, t }) {
69
93
  const [state, setState] = useState({ status: 'loading' });
70
94
  const [unresolvedOpen, setUnresolvedOpen] = useState(false);
71
95
  const [rankdir, setRankdir] = useState('LR');
96
+ const [theme, setTheme] = useState(detectTheme);
97
+ const [anchor, setAnchor] = useState(null);
98
+ const [direction, setDirection] = useState(null);
72
99
  const transform = useStore(store => store.transform);
100
+ const view = transform ?? { x: 0, y: 0, k: 1 };
101
+ const popover = anchor === null ? null : {
102
+ ...anchor,
103
+ x: view.x + anchor.anchorX * view.k,
104
+ y: view.y + anchor.anchorY * view.k,
105
+ };
106
+ // Watch for theme changes via MutationObserver on <html> attributes.
107
+ useEffect(() => {
108
+ const observer = new MutationObserver(() => {
109
+ const next = detectTheme();
110
+ setTheme(prev => prev === next ? prev : next);
111
+ });
112
+ observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme', 'class', 'style'] });
113
+ return () => { observer.disconnect(); };
114
+ }, []);
115
+ // Load analysis + SVG, re-fetching when theme or layout changes.
73
116
  useEffect(() => {
74
117
  let current = true;
75
- void Promise.all([analyze(), render('svg', rankdir)]).then(([analysis, svg]) => { if (current)
118
+ void Promise.all([analyze(), render('svg', rankdir, theme)]).then(([analysis, svg]) => { if (current)
76
119
  setState({ status: 'ready', analysis, svg }); }, (error) => {
77
120
  const message = error instanceof Error ? error.message : String(error);
78
121
  console.error('[plugin-topology] load failed:', error);
@@ -80,7 +123,7 @@ export function TopologyView({ useStore, actions, analyze, render, t }) {
80
123
  setState({ status: 'error', message });
81
124
  });
82
125
  return () => { current = false; };
83
- }, [request, rankdir, analyze, render]);
126
+ }, [request, rankdir, theme, analyze, render]);
84
127
  const retry = () => {
85
128
  setState({ status: 'loading' });
86
129
  setRequest(value => value + 1);
@@ -92,5 +135,36 @@ export function TopologyView({ useStore, actions, analyze, render, t }) {
92
135
  actions.setTransform(null);
93
136
  setRankdir(next);
94
137
  };
95
- return (_jsxs("div", { className: css.root, "data-plugin-topology-view": "", children: [state.status === 'loading' ? _jsx("p", { className: css.status, children: t('loading') }) : null, state.status === 'error' ? (_jsxs("div", { className: css.failure, children: [_jsx("p", { className: css.status, role: "alert", children: t('error') }), _jsx("pre", { className: css.errorDetail, children: state.message }), _jsx(OutlineButton, { onClick: retry, children: t('retry') })] })) : null, state.status === 'ready' ? (_jsxs(_Fragment, { children: [_jsxs("div", { className: css.toolbar, children: [_jsx("div", { className: css.summaryGroup, children: _jsx("div", { className: css.metricStrip, children: metricRows(state.analysis, t).map(row => (_jsxs("span", { className: row.warning === true ? `${css.stat} ${css.statAlert}` : css.stat, children: [_jsx("span", { className: css.statLabel, children: row.label }), row.expandable === true ? (_jsxs("button", { type: "button", className: css.statToggle, "aria-expanded": unresolvedOpen, onClick: () => { setUnresolvedOpen(value => !value); }, children: [_jsx("span", { className: css.statValue, children: row.value }), _jsx(IconChevronDownOutline14, { className: unresolvedOpen ? `${css.statChevron} ${css.statChevronOpen}` : css.statChevron })] })) : (_jsx("span", { className: css.statValue, children: row.value }))] }, row.label))) }) }), _jsxs("div", { className: css.actions, children: [_jsxs("div", { className: css.rankdirGroup, role: "group", "aria-label": t('rankdirToggle'), children: [_jsx("button", { type: "button", className: css.rankdirOption, "aria-pressed": rankdir === 'TB', onClick: () => { onRankdirChange('TB'); }, children: "TD" }), _jsx("button", { type: "button", className: css.rankdirOption, "aria-pressed": rankdir === 'LR', onClick: () => { onRankdirChange('LR'); }, children: "LR" })] }), DOWNLOAD_FORMATS.map(({ format, labelKey }) => (_jsx(OutlineButton, { icon: _jsx(IconDownloadOutline16, { size: 14 }), onClick: () => { void download(format, rankdir, render); }, children: t(labelKey) }, format)))] })] }), unresolvedOpen ? (_jsx("div", { className: css.unresolvedPanel, children: _jsx("div", { className: css.unresolvedScroll, children: _jsxs("table", { className: css.unresolvedTable, children: [_jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { scope: "col", className: css.unresolvedColPlugin, children: t('unresolvedPlugin') }), _jsx("th", { scope: "col", className: css.unresolvedColState, children: t('unresolvedState') }), _jsx("th", { scope: "col", children: t('unresolvedService') })] }) }), _jsx("tbody", { children: unresolvedRows(state.analysis).map(row => (_jsxs("tr", { children: [_jsx("td", { className: css.unresolvedPlugin, children: row.plugin }), _jsx("td", { className: css.unresolvedState, children: row.state }), _jsx("td", { className: css.unresolvedService, children: row.reasons.join(' · ') })] }, row.key))) })] }) }) })) : null, _jsxs("div", { className: css.legend, role: "note", "aria-label": t('legendLabel'), children: [_jsxs("span", { className: css.legendItem, children: [_jsx("span", { className: `${css.legendSwatch} ${css.legendIsolatedBox}`, "aria-hidden": true }), t('legendIsolated')] }), _jsxs("span", { className: css.legendItem, children: [_jsx("span", { className: `${css.legendSwatch} ${css.legendUnresolvedBox}`, "aria-hidden": true }), t('legendUnresolved')] }), _jsxs("span", { className: css.legendItem, children: [_jsx("code", { className: css.legendIdSample, children: "[42]" }), t('legendId')] })] }), _jsx("div", { className: css.body, children: _jsx(TopologyGraphView, { svg: state.svg, alt: t('title'), height: "100%", transform: transform, onTransformChange: onTransformChange, zoomInLabel: t('zoomIn'), zoomOutLabel: t('zoomOut'), resetViewLabel: t('resetView'), zoomLevelLabel: t('zoomLevel') }) })] })) : null] }));
138
+ const onNodeClick = useCallback((info) => {
139
+ // Always start from a clean highlight: switching nodes must not carry the
140
+ // previous node's direction over to the new one.
141
+ setDirection(null);
142
+ setAnchor(prev => {
143
+ // Toggle: clicking the same node closes the popover.
144
+ if (prev !== null && prev.nodeId === info.nodeId)
145
+ return null;
146
+ return {
147
+ key: info.key,
148
+ nodeId: info.nodeId,
149
+ label: info.label,
150
+ anchorX: info.anchorX,
151
+ anchorY: info.anchorY,
152
+ outCount: info.outCount,
153
+ inCount: info.inCount,
154
+ };
155
+ });
156
+ }, []);
157
+ const onEmptyClick = useCallback(() => {
158
+ setAnchor(null);
159
+ setDirection(null);
160
+ }, []);
161
+ const closePopover = useCallback(() => {
162
+ setAnchor(null);
163
+ setDirection(null);
164
+ }, []);
165
+ /** Tapping the active pill again drops back to plain selection. */
166
+ const toggleDirection = useCallback((next) => {
167
+ setDirection(current => current === next ? null : next);
168
+ }, []);
169
+ return (_jsxs("div", { className: css.root, "data-plugin-topology-view": "", children: [state.status === 'loading' ? _jsx("p", { className: css.status, children: t('loading') }) : null, state.status === 'error' ? (_jsxs("div", { className: css.failure, children: [_jsx("p", { className: css.status, role: "alert", children: t('error') }), _jsx("pre", { className: css.errorDetail, children: state.message }), _jsx(OutlineButton, { onClick: retry, children: t('retry') })] })) : null, state.status === 'ready' ? (_jsxs(_Fragment, { children: [_jsxs("div", { className: css.toolbar, children: [_jsx("div", { className: css.summaryGroup, children: _jsx("div", { className: css.metricStrip, children: metricRows(state.analysis, t).map(row => (_jsxs("span", { className: row.warning === true ? `${css.stat} ${css.statAlert}` : css.stat, children: [_jsx("span", { className: css.statLabel, children: row.label }), row.expandable === true ? (_jsxs("button", { type: "button", className: css.statToggle, "aria-expanded": unresolvedOpen, onClick: () => { setUnresolvedOpen(value => !value); }, children: [_jsx("span", { className: css.statValue, children: row.value }), _jsx(IconChevronDownOutline14, { className: unresolvedOpen ? `${css.statChevron} ${css.statChevronOpen}` : css.statChevron })] })) : (_jsx("span", { className: css.statValue, children: row.value }))] }, row.label))) }) }), _jsxs("div", { className: css.actions, children: [_jsxs("div", { className: css.rankdirGroup, role: "group", "aria-label": t('rankdirToggle'), children: [_jsx("button", { type: "button", className: css.rankdirOption, "aria-pressed": rankdir === 'TB', onClick: () => { onRankdirChange('TB'); }, children: "TD" }), _jsx("button", { type: "button", className: css.rankdirOption, "aria-pressed": rankdir === 'LR', onClick: () => { onRankdirChange('LR'); }, children: "LR" })] }), DOWNLOAD_FORMATS.map(({ format, labelKey }) => (_jsx(OutlineButton, { icon: _jsx(IconDownloadOutline16, { size: 14 }), onClick: () => { void download(format, rankdir, theme, render); }, children: t(labelKey) }, format)))] })] }), unresolvedOpen ? (_jsx("div", { className: css.unresolvedPanel, children: _jsx("div", { className: css.unresolvedScroll, children: _jsxs("table", { className: css.unresolvedTable, children: [_jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { scope: "col", className: css.unresolvedColPlugin, children: t('unresolvedPlugin') }), _jsx("th", { scope: "col", className: css.unresolvedColState, children: t('unresolvedState') }), _jsx("th", { scope: "col", children: t('unresolvedService') })] }) }), _jsx("tbody", { children: unresolvedRows(state.analysis).map(row => (_jsxs("tr", { children: [_jsx("td", { className: css.unresolvedPlugin, children: row.plugin }), _jsx("td", { className: css.unresolvedState, children: row.state }), _jsx("td", { className: css.unresolvedService, children: row.reasons.join(' · ') })] }, row.key))) })] }) }) })) : null, _jsxs("div", { className: css.legend, role: "note", "aria-label": t('legendLabel'), children: [_jsxs("span", { className: css.legendItem, children: [_jsx("span", { className: `${css.legendSwatch} ${css.legendIsolatedBox}`, "aria-hidden": true }), t('legendIsolated')] }), _jsxs("span", { className: css.legendItem, children: [_jsx("span", { className: `${css.legendSwatch} ${css.legendUnresolvedBox}`, "aria-hidden": true }), t('legendUnresolved')] }), _jsxs("span", { className: css.legendItem, children: [_jsx("code", { className: css.legendIdSample, children: "[42]" }), t('legendId')] })] }), _jsxs("div", { className: css.body, children: [_jsx(TopologyGraphView, { svg: state.svg, alt: t('title'), height: "100%", transform: transform, onTransformChange: onTransformChange, zoomInLabel: t('zoomIn'), zoomOutLabel: t('zoomOut'), resetViewLabel: t('resetView'), zoomLevelLabel: t('zoomLevel'), onNodeClick: onNodeClick, onEmptyClick: onEmptyClick, selectedKey: popover?.key ?? null, selectedDotId: popover?.nodeId ?? null, direction: direction }), popover !== null && (_jsx(NodePopover, { popover: popover, analysis: state.analysis, direction: direction, onToggleDirection: toggleDirection, onClose: closePopover, t: t }))] })] })) : null] }));
96
170
  }
@@ -68,8 +68,8 @@ export function apply(ctx) {
68
68
  }
69
69
  return result.value;
70
70
  },
71
- render: async (format, rankdir) => {
72
- const result = await namespaceOf().render(format, rankdir);
71
+ render: async (format, rankdir, theme) => {
72
+ const result = await namespaceOf().render(format, rankdir, theme);
73
73
  if (!result.ok) {
74
74
  throw new Error(`pluginTopology.render failed: ${result.error.code}: ${result.error.message}`);
75
75
  }
@@ -1,4 +1,4 @@
1
1
  export declare const NS = "pluginTopology";
2
- export type PluginTopologyLocaleKey = 'title' | 'loading' | 'error' | 'retry' | 'zoomIn' | 'zoomOut' | 'resetView' | 'zoomLevel' | 'downloadSvg' | 'downloadDot' | 'downloadJson' | 'metricPlugins' | 'metricDependencies' | 'metricUnresolved' | 'metricDensity' | 'metricWeakComponents' | 'metricDiameter' | 'rankdirToggle' | 'unresolvedPlugin' | 'unresolvedService' | 'unresolvedState' | 'legendLabel' | 'legendUnresolved' | 'legendIsolated' | 'legendId';
2
+ export type PluginTopologyLocaleKey = 'title' | 'loading' | 'error' | 'retry' | 'zoomIn' | 'zoomOut' | 'resetView' | 'zoomLevel' | 'downloadSvg' | 'downloadDot' | 'downloadJson' | 'metricPlugins' | 'metricDependencies' | 'metricUnresolved' | 'metricDensity' | 'metricWeakComponents' | 'metricDiameter' | 'rankdirToggle' | 'unresolvedPlugin' | 'unresolvedService' | 'unresolvedState' | 'legendLabel' | 'legendUnresolved' | 'legendIsolated' | 'legendId' | 'popoverClose' | 'popoverSource' | 'popoverOutgoing' | 'popoverIncoming' | 'popoverUnresolved';
3
3
  export declare const en: Record<PluginTopologyLocaleKey, string>;
4
4
  export declare const zh: Record<PluginTopologyLocaleKey, string>;
@@ -25,6 +25,11 @@ export const en = {
25
25
  legendUnresolved: 'Unresolved dependency',
26
26
  legendIsolated: 'Isolated plugin',
27
27
  legendId: 'Instance creation ordinal within this run',
28
+ popoverClose: 'Close',
29
+ popoverSource: 'Source',
30
+ popoverOutgoing: 'Highlight outgoing dependencies (deg⁺)',
31
+ popoverIncoming: 'Highlight incoming dependents (deg⁻)',
32
+ popoverUnresolved: 'unresolved',
28
33
  };
29
34
  export const zh = {
30
35
  title: '插件拓扑',
@@ -52,4 +57,9 @@ export const zh = {
52
57
  legendUnresolved: '存在未解析依赖',
53
58
  legendIsolated: '孤立插件(无依赖边)',
54
59
  legendId: '本次运行内的创建序号',
60
+ popoverClose: '关闭',
61
+ popoverSource: '来源',
62
+ popoverOutgoing: '高亮它依赖的插件(出度 deg⁺)',
63
+ popoverIncoming: '高亮依赖它的插件(入度 deg⁻)',
64
+ popoverUnresolved: '未解析',
55
65
  };
@@ -92,6 +92,7 @@ const analyze_result$schema = z.object({
92
92
  });
93
93
  const render_parameter_0$schema = z.union([z.literal('json'), z.literal('dot'), z.literal('svg')]);
94
94
  const render_parameter_1$schema = z.union([z.literal('TB'), z.literal('LR')]);
95
+ const render_parameter_2$schema = z.union([z.literal('light'), z.literal('dark')]).optional();
95
96
  const render_result$schema = z.string();
96
97
  export const TYPERT_REMOTE = {
97
98
  package: '@sleetdrop/dsh-plugin-topology',
@@ -106,7 +107,7 @@ export const TYPERT_REMOTE = {
106
107
  result: {
107
108
  mode: 'strict',
108
109
  typeSymbol: '@sleetdrop/dsh-plugin-topology/types#TopologyAnalysis',
109
- schema: analyze_result$schema,
110
+ create: () => analyze_result$schema,
110
111
  },
111
112
  },
112
113
  {
@@ -123,7 +124,7 @@ export const TYPERT_REMOTE = {
123
124
  codec: {
124
125
  mode: 'strict',
125
126
  typeSymbol: '@sleetdrop/dsh-plugin-topology/types#RenderFormat',
126
- schema: render_parameter_0$schema,
127
+ create: () => render_parameter_0$schema,
127
128
  },
128
129
  },
129
130
  {
@@ -133,14 +134,25 @@ export const TYPERT_REMOTE = {
133
134
  codec: {
134
135
  mode: 'strict',
135
136
  typeSymbol: '@sleetdrop/dsh-plugin-topology/types#Rankdir',
136
- schema: render_parameter_1$schema,
137
+ create: () => render_parameter_1$schema,
138
+ },
139
+ },
140
+ {
141
+ name: 'theme',
142
+ wire: 'theme',
143
+ source: 'json',
144
+ acceptsUndefined: true,
145
+ codec: {
146
+ mode: 'strict',
147
+ typeSymbol: '@sleetdrop/dsh-plugin-topology/types#Theme',
148
+ create: () => render_parameter_2$schema,
137
149
  },
138
150
  },
139
151
  ],
140
152
  result: {
141
153
  mode: 'strict',
142
154
  typeSymbol: '@sleetdrop/dsh-plugin-topology#pluginTopology/render:result',
143
- schema: render_result$schema,
155
+ create: () => render_result$schema,
144
156
  },
145
157
  },
146
158
  ],