@sleetdrop/dsh-plugin-topology 0.4.0 → 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/NEXT-STEPS.md +14 -7
- package/README.md +26 -5
- package/lib/client/NodeDetailPanel.d.ts +25 -0
- package/lib/client/NodeDetailPanel.js +32 -0
- package/lib/client/NodePopover.d.ts +39 -0
- package/lib/client/NodePopover.js +96 -0
- package/lib/client/TopologyGraphView.d.ts +49 -6
- package/lib/client/TopologyGraphView.js +244 -27
- package/lib/client/TopologyView.d.ts +2 -2
- package/lib/client/TopologyView.js +84 -10
- package/lib/client/index.js +2 -2
- package/lib/client/locales.d.ts +1 -1
- package/lib/client/locales.js +10 -0
- package/lib/client/remote-client.js +12 -0
- package/lib/client.js +586 -70
- package/lib/client.js.map +1 -1
- package/lib/index.d.ts +1 -1
- package/lib/index.js +7 -6
- package/lib/render.d.ts +22 -5
- package/lib/render.js +48 -19
- package/lib/typert.d.ts +12 -0
- package/lib/typert.js +11 -0
- package/package.json +20 -20
|
@@ -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->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
|
|
30
|
-
* drag pans, double-click zooms toward the
|
|
31
|
-
* (fit / zoom-out / zoom-level / zoom-in)
|
|
32
|
-
*
|
|
33
|
-
*
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
if (
|
|
96
|
-
|
|
97
|
-
|
|
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
|
|
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 +
|
|
205
|
-
y: drag.baseY +
|
|
400
|
+
x: drag.baseX + dx,
|
|
401
|
+
y: drag.baseY + dy,
|
|
206
402
|
});
|
|
207
403
|
setAtFit(false);
|
|
208
404
|
};
|
|
209
405
|
const onPointerEnd = (event) => {
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/lib/client/index.js
CHANGED
|
@@ -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
|
}
|
package/lib/client/locales.d.ts
CHANGED
|
@@ -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>;
|
package/lib/client/locales.js
CHANGED
|
@@ -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',
|
|
@@ -136,6 +137,17 @@ export const TYPERT_REMOTE = {
|
|
|
136
137
|
create: () => render_parameter_1$schema,
|
|
137
138
|
},
|
|
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,
|
|
149
|
+
},
|
|
150
|
+
},
|
|
139
151
|
],
|
|
140
152
|
result: {
|
|
141
153
|
mode: 'strict',
|