servicemap 0.1.41 → 0.1.43
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/package.json +1 -1
- package/servicemap/SKILL.md +1 -1
- package/servicemap/scripts/generate-page.mjs +101 -29
package/package.json
CHANGED
package/servicemap/SKILL.md
CHANGED
|
@@ -25,7 +25,7 @@ If no website can be found, create a desktop app fallback at `servicemap-desktop
|
|
|
25
25
|
8. Adapt the generated surface to the project's existing design system only when the route is clear and the edits are low-risk.
|
|
26
26
|
9. Run the project's relevant build, lint, and focused tests.
|
|
27
27
|
Open the generated map in a browser and verify both 2D and 3D modes.
|
|
28
|
-
Click a service and verify its details panel and animated illuminated dashed
|
|
28
|
+
Click a product or service and verify its details panel and animated illuminated dashed child-branch connections.
|
|
29
29
|
Drag a service and verify that only that node moves and its connections follow, including after zooming.
|
|
30
30
|
After testing 3D, return the delivered preview to 2D and close any open details panel.
|
|
31
31
|
Always initialize new maps in 2D; do not persist the last selected view mode.
|
|
@@ -71,6 +71,8 @@ function pageCss() {
|
|
|
71
71
|
.brand p, .top-meta { margin: 6px 0 0; color: #8aa097; font-size: 14px; }
|
|
72
72
|
.top-actions { display: flex; align-items: center; gap: 12px; color: #8da59a; font-size: 14px; white-space: nowrap; }
|
|
73
73
|
.add-service-button { min-height: 40px; border: 0; border-radius: 10px; background: #287a5a; color: #effbf5; padding: 0 14px; cursor: pointer; font: inherit; font-weight: 800; box-shadow: 0 10px 28px rgba(54,180,126,0.16); }
|
|
74
|
+
.center-button { width: 48px; height: 48px; border: 1px solid rgba(255,255,255,0.1); border-radius: 14px; background: #0b130f; color: #b4c9bf; display: grid; place-items: center; cursor: pointer; box-shadow: 0 0 0 7px rgba(47,128,91,.16); }
|
|
75
|
+
.center-button:hover { border-color: rgba(101,240,173,.42); color: #d9f8e8; box-shadow: 0 0 0 7px rgba(47,128,91,.2), 0 0 22px rgba(101,240,173,.14); }
|
|
74
76
|
.mode-toggle { display: inline-flex; gap: 4px; padding: 4px; border-radius: 10px; background: #0b130f; }
|
|
75
77
|
.mode-button { min-height: 36px; border: 0; border-radius: 8px; background: transparent; color: #789086; padding: 0 12px; cursor: pointer; font: inherit; font-weight: 800; }
|
|
76
78
|
.mode-button.is-active { background: #276f53; color: #c9f9df; box-shadow: 0 0 18px rgba(101,240,173,0.16); }
|
|
@@ -83,18 +85,21 @@ function pageCss() {
|
|
|
83
85
|
.intro span { display: block; margin-top: 8px; color: #53665d; font-size: 14px; }
|
|
84
86
|
.edges { position: absolute; inset: 0; z-index: 26; width: 100%; height: 100%; pointer-events: none; }
|
|
85
87
|
.edge-line { transition: opacity 180ms ease, stroke 180ms ease; }
|
|
86
|
-
.edge-line.is-active { stroke: #7ee2b8; stroke-width: 2.4px; stroke-dasharray:
|
|
88
|
+
.edge-line.is-active { stroke: #7ee2b8; stroke-width: 2.4px; stroke-dasharray: 1 9; stroke-linecap: round; animation: servicemapDash 640ms linear infinite; filter: drop-shadow(0 0 5px rgba(126,226,184,.9)) drop-shadow(0 0 16px rgba(93,224,165,.42)); opacity: 1; }
|
|
87
89
|
.edge-line.is-muted { opacity: 0.12; }
|
|
88
90
|
.hub { position: absolute; left: 50%; top: 50%; z-index: 20; width: 132px; height: 132px; transform: translate(-50%, -50%); border-radius: 24px; border: 1px solid #315c47; background: #09120d; display: grid; place-items: center; box-shadow: 0 0 0 9px rgba(79,183,128,0.12), 0 0 42px rgba(100,255,174,0.16); }
|
|
91
|
+
button.hub { cursor: pointer; }
|
|
92
|
+
.hub.is-selected, button.hub:hover { border-color: #66efae; box-shadow: 0 0 0 9px rgba(79,183,128,0.12), 0 0 0 2px rgba(101,240,173,0.42), 0 0 52px rgba(100,255,174,0.24); }
|
|
89
93
|
.hub-inner { width: 92px; height: 92px; border-radius: 18px; background: #fbfbf7; color: #111612; display: grid; place-items: center; font-size: 40px; box-shadow: inset 0 0 0 1px rgba(0,0,0,0.08); }
|
|
90
94
|
.service-node { position: absolute; z-index: 10; width: 250px; height: 74px; transform: translate(-50%, -50%); display: flex; align-items: center; gap: 16px; padding: 0 16px; border-radius: 8px; border: 1px solid #274036; background: rgba(16,24,19,0.95); box-shadow: 0 0 0 1px rgba(140,255,190,0.05), 0 18px 44px rgba(0,0,0,0.38); }
|
|
91
95
|
button.service-node { cursor: grab; touch-action: none; user-select: none; color: inherit; font: inherit; text-align: left; }
|
|
92
96
|
button.service-node:active { cursor: grabbing; }
|
|
93
97
|
.service-node img { pointer-events: none; }
|
|
94
|
-
.service-node:hover, .service-node.is-selected { border-color: #66efae; box-shadow: 0 0 0 2px rgba(101,240,173,0.42), 0 18px 44px rgba(0,0,0,0.38); }
|
|
98
|
+
.service-node:hover, .service-node.is-selected, .service-node.is-highlighted-child { border-color: #66efae; box-shadow: 0 0 0 2px rgba(101,240,173,0.42), 0 18px 44px rgba(0,0,0,0.38); }
|
|
99
|
+
.service-node.is-highlighted-child { box-shadow: 0 0 0 1px rgba(126,226,184,.36), 0 18px 44px rgba(0,0,0,0.38), 0 0 34px rgba(126,226,184,.18); }
|
|
95
100
|
.map.has-selection .service-node { opacity: 0.16; }
|
|
96
101
|
.map.has-selection .service-node.is-related { opacity: 0.48; }
|
|
97
|
-
.map.has-selection .service-node.is-selected { opacity: 1; }
|
|
102
|
+
.map.has-selection .service-node.is-selected, .map.has-selection .service-node.is-highlighted-child { opacity: 1; }
|
|
98
103
|
.service-node.is-selected { z-index: 27; }
|
|
99
104
|
.logo { width: 48px; height: 48px; flex: 0 0 auto; border-radius: 8px; display: grid; place-items: center; overflow: hidden; color: #fff; font-weight: 700; }
|
|
100
105
|
.logo img { width: 28px; height: 28px; object-fit: contain; filter: invert(1); }
|
|
@@ -111,6 +116,10 @@ function pageCss() {
|
|
|
111
116
|
.zoom-button:last-child { border-bottom: 0; font-size: 18px; }
|
|
112
117
|
.zoom-button:hover { background: rgba(101,240,173,0.1); color: #d8f8e8; }
|
|
113
118
|
.fit-icon { display: inline-block; width: 15px; height: 15px; border: 2px solid currentColor; border-radius: 3px; }
|
|
119
|
+
.center-icon { position: relative; width: 21px; height: 21px; border: 2px solid currentColor; border-radius: 999px; }
|
|
120
|
+
.center-icon::before, .center-icon::after { content: ''; position: absolute; background: currentColor; }
|
|
121
|
+
.center-icon::before { left: 50%; top: -5px; bottom: -5px; width: 2px; transform: translateX(-50%); }
|
|
122
|
+
.center-icon::after { top: 50%; left: -5px; right: -5px; height: 2px; transform: translateY(-50%); }
|
|
114
123
|
.scan-foot { position: absolute; right: 32px; bottom: 32px; z-index: 20; color: #53665d; text-align: right; font-size: 12px; line-height: 1.6; }
|
|
115
124
|
.access-warning { position: absolute; left: 32px; right: 32px; bottom: 88px; z-index: 25; max-width: 720px; border: 1px solid rgba(241,200,91,0.42); border-radius: 8px; background: rgba(31,27,12,0.92); color: #f7daa0; padding: 12px 14px; font-size: 14px; line-height: 1.45; }
|
|
116
125
|
.drawer-scrim { position: absolute; inset: 0; z-index: 18; background: rgba(0,0,0,0.58); pointer-events: none; }
|
|
@@ -157,7 +166,7 @@ function pageCss() {
|
|
|
157
166
|
.graph-3d__zoom button + button { border-top: 1px solid rgba(255,255,255,.08); }
|
|
158
167
|
.graph-3d__zoom button:hover { color: #fff; background: rgba(255,255,255,.08); }
|
|
159
168
|
.graph-3d__loading { position: absolute; inset: 0; display: grid; place-items: center; color: #75857c; font-size: 9px; }
|
|
160
|
-
@keyframes servicemapDash {
|
|
169
|
+
@keyframes servicemapDash { to { stroke-dashoffset: -10; } }
|
|
161
170
|
@media (prefers-reduced-motion: reduce) { .edge-line.is-active { animation: none; } }
|
|
162
171
|
@keyframes servicemapLinkFlow { to { background-position: 22px 0; } }
|
|
163
172
|
@media (max-width: 920px) {
|
|
@@ -311,9 +320,48 @@ function initials(name: string) {
|
|
|
311
320
|
return name.split(/\\s+/).filter(Boolean).slice(0, 2).map((part) => part[0]).join('').toUpperCase();
|
|
312
321
|
}
|
|
313
322
|
|
|
323
|
+
function collectDescendantIds(nodes: ServiceNode[], selectedId: string | null) {
|
|
324
|
+
const ids = new Set<string>();
|
|
325
|
+
if (!selectedId) return ids;
|
|
326
|
+
const childrenByParent = new Map<string, ServiceNode[]>();
|
|
327
|
+
nodes.forEach((node) => {
|
|
328
|
+
const parentId = node.parentId ?? 'project';
|
|
329
|
+
const siblings = childrenByParent.get(parentId) ?? [];
|
|
330
|
+
siblings.push(node);
|
|
331
|
+
childrenByParent.set(parentId, siblings);
|
|
332
|
+
});
|
|
333
|
+
const queue = [...(childrenByParent.get(selectedId) ?? [])];
|
|
334
|
+
while (queue.length) {
|
|
335
|
+
const node = queue.shift()!;
|
|
336
|
+
if (ids.has(node.id)) continue;
|
|
337
|
+
ids.add(node.id);
|
|
338
|
+
queue.push(...(childrenByParent.get(node.id) ?? []));
|
|
339
|
+
}
|
|
340
|
+
return ids;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function collectParentIds(nodes: ServiceNode[], selectedId: string | null) {
|
|
344
|
+
const ids = new Set<string>();
|
|
345
|
+
if (!selectedId) return ids;
|
|
346
|
+
nodes.forEach((node) => {
|
|
347
|
+
const parentId = node.parentId ?? 'project';
|
|
348
|
+
if (node.id === selectedId) ids.add(parentId);
|
|
349
|
+
});
|
|
350
|
+
return ids;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function isActiveTreeEdge(sourceId: string, targetId: string, selectedId: string | null, parentIds: Set<string>, descendantIds: Set<string>) {
|
|
354
|
+
if (!selectedId) return false;
|
|
355
|
+
if (sourceId === selectedId && descendantIds.has(targetId)) return true;
|
|
356
|
+
if (descendantIds.has(sourceId) && descendantIds.has(targetId)) return true;
|
|
357
|
+
return parentIds.has(sourceId) && targetId === selectedId;
|
|
358
|
+
}
|
|
359
|
+
|
|
314
360
|
function Connections({ nodes, selectedId }: { nodes: ServiceNode[]; selectedId: string | null }) {
|
|
315
361
|
const ref = useRef<SVGSVGElement>(null);
|
|
316
362
|
const [size, setSize] = useState({ width: 0, height: 0, nodeHalfWidth: 125 });
|
|
363
|
+
const parentIds = useMemo(() => collectParentIds(nodes, selectedId), [nodes, selectedId]);
|
|
364
|
+
const descendantIds = useMemo(() => collectDescendantIds(nodes, selectedId), [nodes, selectedId]);
|
|
317
365
|
useEffect(() => {
|
|
318
366
|
if (!ref.current) return;
|
|
319
367
|
const observer = new ResizeObserver(([entry]) => {
|
|
@@ -337,7 +385,7 @@ function Connections({ nodes, selectedId }: { nodes: ServiceNode[]; selectedId:
|
|
|
337
385
|
const dy = Math.sign(y2 - y1);
|
|
338
386
|
const radius = Math.min(5, Math.abs(x2 - x1) / 4, Math.abs(y2 - y1) / 2);
|
|
339
387
|
const path = \`M \${x1} \${y1} H \${middle - dx * radius} Q \${middle} \${y1} \${middle} \${y1 + dy * radius} V \${y2 - dy * radius} Q \${middle} \${y2} \${middle + dx * radius} \${y2} H \${x2}\`;
|
|
340
|
-
const active =
|
|
388
|
+
const active = isActiveTreeEdge(node.parentId ?? 'project', node.id, selectedId, parentIds, descendantIds);
|
|
341
389
|
return <path key={node.id} d={path} fill="none" stroke="rgba(103,130,118,.48)" strokeWidth="1.2" className={\`edge-line \${active ? 'is-active' : selectedId ? 'is-muted' : ''}\`} />;
|
|
342
390
|
})}
|
|
343
391
|
</svg>;
|
|
@@ -614,7 +662,7 @@ function AddServiceModal({
|
|
|
614
662
|
placeholder="billing, hosting, analytics"
|
|
615
663
|
/>
|
|
616
664
|
|
|
617
|
-
<label className="field-label" htmlFor="new-service-parent">Parent
|
|
665
|
+
<label className="field-label" htmlFor="new-service-parent">Parent service</label>
|
|
618
666
|
<select
|
|
619
667
|
id="new-service-parent"
|
|
620
668
|
className="details-input"
|
|
@@ -640,10 +688,12 @@ function AddServiceModal({
|
|
|
640
688
|
function ServiceGraph3D({
|
|
641
689
|
nodes,
|
|
642
690
|
selectedId,
|
|
691
|
+
resetKey,
|
|
643
692
|
onSelect,
|
|
644
693
|
}: {
|
|
645
694
|
nodes: ServiceNode[];
|
|
646
695
|
selectedId: string | null;
|
|
696
|
+
resetKey: number;
|
|
647
697
|
onSelect: (id: string | null) => void;
|
|
648
698
|
}) {
|
|
649
699
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
@@ -676,19 +726,17 @@ function ServiceGraph3D({
|
|
|
676
726
|
label: node.confidence === 'high' ? relationshipLabel(node.parentId) : \`may \${relationshipLabel(node.parentId)}\`,
|
|
677
727
|
})),
|
|
678
728
|
}), [nodes]);
|
|
679
|
-
|
|
680
|
-
const
|
|
729
|
+
const parentIds = useMemo(() => collectParentIds(nodes, selectedId), [nodes, selectedId]);
|
|
730
|
+
const descendantIds = useMemo(() => collectDescendantIds(nodes, selectedId), [nodes, selectedId]);
|
|
731
|
+
const activeTreeLinkIds = useMemo(() => {
|
|
681
732
|
const ids = new Set<string>();
|
|
682
|
-
if (!selectedId) return ids;
|
|
683
|
-
ids.add(selectedId);
|
|
684
733
|
graphData.links.forEach((link) => {
|
|
685
734
|
const source = endpointId(link.source);
|
|
686
735
|
const target = endpointId(link.target);
|
|
687
|
-
if (source
|
|
688
|
-
if (target === selectedId) ids.add(source);
|
|
736
|
+
if (isActiveTreeEdge(source, target, selectedId, parentIds, descendantIds)) ids.add(link.id);
|
|
689
737
|
});
|
|
690
738
|
return ids;
|
|
691
|
-
}, [graphData.links, selectedId]);
|
|
739
|
+
}, [descendantIds, graphData.links, parentIds, selectedId]);
|
|
692
740
|
|
|
693
741
|
useEffect(() => {
|
|
694
742
|
const element = containerRef.current;
|
|
@@ -723,6 +771,11 @@ function ServiceGraph3D({
|
|
|
723
771
|
};
|
|
724
772
|
}, [configureForces, graphData.nodes.length, graphData.links.length]);
|
|
725
773
|
|
|
774
|
+
useEffect(() => {
|
|
775
|
+
if (!resetKey) return;
|
|
776
|
+
graphRef.current?.zoomToFit(700, 130);
|
|
777
|
+
}, [resetKey]);
|
|
778
|
+
|
|
726
779
|
const focusNode = useCallback((node: NodeObject) => {
|
|
727
780
|
const serviceNode = graphNode(node);
|
|
728
781
|
onSelect(serviceNode.id);
|
|
@@ -745,7 +798,11 @@ function ServiceGraph3D({
|
|
|
745
798
|
const offset = camera.position.clone().sub(target);
|
|
746
799
|
const currentDistance = offset.length();
|
|
747
800
|
if (currentDistance === 0) return;
|
|
748
|
-
const nextDistance =
|
|
801
|
+
const nextDistance = Math.max(35, currentDistance * factor);
|
|
802
|
+
if (camera instanceof THREE.PerspectiveCamera && nextDistance * 4 > camera.far) {
|
|
803
|
+
camera.far = nextDistance * 4;
|
|
804
|
+
camera.updateProjectionMatrix();
|
|
805
|
+
}
|
|
749
806
|
const nextPosition = target.clone().add(offset.multiplyScalar(nextDistance / currentDistance));
|
|
750
807
|
graphInstance.cameraPosition(nextPosition, target, 240);
|
|
751
808
|
}, []);
|
|
@@ -771,22 +828,22 @@ function ServiceGraph3D({
|
|
|
771
828
|
linkColor={(link) => {
|
|
772
829
|
const serviceLink = graphLink(link as GraphLink3D);
|
|
773
830
|
if (!selectedId) return '#415149';
|
|
774
|
-
return
|
|
831
|
+
return activeTreeLinkIds.has(serviceLink.id) ? '#7ee2b8' : '#17211c';
|
|
775
832
|
}}
|
|
776
833
|
linkWidth={(link) => {
|
|
777
834
|
const serviceLink = graphLink(link as GraphLink3D);
|
|
778
|
-
return
|
|
835
|
+
return activeTreeLinkIds.has(serviceLink.id) ? 2.2 : selectedId ? 0.35 : 0.8;
|
|
779
836
|
}}
|
|
780
837
|
linkOpacity={0.8}
|
|
781
838
|
linkDirectionalArrowLength={3.5}
|
|
782
839
|
linkDirectionalArrowRelPos={0.8}
|
|
783
840
|
linkDirectionalArrowColor={(link) => {
|
|
784
841
|
const serviceLink = graphLink(link as GraphLink3D);
|
|
785
|
-
return
|
|
842
|
+
return activeTreeLinkIds.has(serviceLink.id) ? '#9ff0cb' : '#506158';
|
|
786
843
|
}}
|
|
787
844
|
linkDirectionalParticles={(link) => {
|
|
788
845
|
const serviceLink = graphLink(link as GraphLink3D);
|
|
789
|
-
return
|
|
846
|
+
return activeTreeLinkIds.has(serviceLink.id) ? 3 : 0;
|
|
790
847
|
}}
|
|
791
848
|
linkDirectionalParticleWidth={1.8}
|
|
792
849
|
linkDirectionalParticleSpeed={0.006}
|
|
@@ -805,11 +862,11 @@ function ServiceGraph3D({
|
|
|
805
862
|
}}
|
|
806
863
|
/>
|
|
807
864
|
<div className="graph-3d__zoom" aria-label="3D zoom controls">
|
|
808
|
-
<button type="button" onClick={() => zoom(
|
|
809
|
-
<button type="button" onClick={() => zoom(1.
|
|
865
|
+
<button type="button" onClick={() => zoom(1 / 1.5)} title="Zoom in" aria-label="Zoom in">+</button>
|
|
866
|
+
<button type="button" onClick={() => zoom(1.5)} title="Zoom out" aria-label="Zoom out">−</button>
|
|
810
867
|
</div>
|
|
811
868
|
<div className="graph-3d__help" aria-hidden="true">Drag to rotate <span /> Scroll to zoom <span /> Click a service</div>
|
|
812
|
-
{selectedId && <div className="graph-3d__focus" aria-hidden="true">{
|
|
869
|
+
{selectedId && <div className="graph-3d__focus" aria-hidden="true">{activeTreeLinkIds.size} connections highlighted</div>}
|
|
813
870
|
</div>
|
|
814
871
|
);
|
|
815
872
|
}
|
|
@@ -843,6 +900,7 @@ export default function InternalServiceMapPage() {
|
|
|
843
900
|
const [pan, setPan] = useState({ x: 0, y: 0 });
|
|
844
901
|
const [panStart, setPanStart] = useState<{ pointerId: number; x: number; y: number; panX: number; panY: number } | null>(null);
|
|
845
902
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
903
|
+
const [reset3DKey, setReset3DKey] = useState(0);
|
|
846
904
|
const startNodeDrag = (event: PointerEvent<HTMLButtonElement>, node: ServiceNode) => {
|
|
847
905
|
if (event.button !== 0) return;
|
|
848
906
|
event.stopPropagation();
|
|
@@ -886,12 +944,21 @@ export default function InternalServiceMapPage() {
|
|
|
886
944
|
},
|
|
887
945
|
])));
|
|
888
946
|
const selected = nodes.find((service) => service.id === selectedId) ?? null;
|
|
889
|
-
const
|
|
890
|
-
const
|
|
947
|
+
const descendantIds = useMemo(() => collectDescendantIds(nodes, selectedId), [nodes, selectedId]);
|
|
948
|
+
const highlightedChildIds = useMemo(() => selectedId === 'project' ? descendantIds : new Set<string>(), [descendantIds, selectedId]);
|
|
949
|
+
const zoomIn = () => setZoom((current) => Math.min(1.45, current * 1.5));
|
|
950
|
+
const zoomOut = () => setZoom((current) => current / 1.5);
|
|
891
951
|
const resetZoom = () => {
|
|
892
952
|
setZoom(1);
|
|
893
953
|
setPan({ x: 0, y: 0 });
|
|
894
954
|
};
|
|
955
|
+
const centerMap = () => {
|
|
956
|
+
if (mode === '3d') {
|
|
957
|
+
setReset3DKey((current) => current + 1);
|
|
958
|
+
return;
|
|
959
|
+
}
|
|
960
|
+
resetZoom();
|
|
961
|
+
};
|
|
895
962
|
const startPan = (event: PointerEvent<HTMLElement>) => {
|
|
896
963
|
if (mode !== '2d' || event.button !== 0 || (event.target as HTMLElement).closest('button, a, input, textarea, select')) return;
|
|
897
964
|
event.currentTarget.setPointerCapture(event.pointerId);
|
|
@@ -957,11 +1024,15 @@ export default function InternalServiceMapPage() {
|
|
|
957
1024
|
</div>
|
|
958
1025
|
<div className="top-actions">
|
|
959
1026
|
<span>{nodes.length} services</span>
|
|
960
|
-
<button type="button" className="add-service-button" onClick={() => setIsAddingService(true)}>+ Add service</button>
|
|
961
1027
|
<div className="mode-toggle" aria-label="View mode">
|
|
962
1028
|
<button type="button" className={\`mode-button \${mode === '2d' ? 'is-active' : ''}\`} onClick={() => setMode('2d')}>2D</button>
|
|
963
1029
|
<button type="button" className={\`mode-button \${mode === '3d' ? 'is-active' : ''}\`} onClick={() => setMode('3d')}>3D</button>
|
|
964
1030
|
</div>
|
|
1031
|
+
<button type="button" className="center-button" onClick={centerMap} aria-label="Center graph" title="Center graph"><span className="center-icon" aria-hidden="true" /></button>
|
|
1032
|
+
<button type="button" className="add-service-button" onClick={() => {
|
|
1033
|
+
setNewService((current) => ({ ...current, parentId: selectedId && selectedId !== 'project' ? selectedId : 'product' }));
|
|
1034
|
+
setIsAddingService(true);
|
|
1035
|
+
}}>+ Add service</button>
|
|
965
1036
|
</div>
|
|
966
1037
|
</header>
|
|
967
1038
|
|
|
@@ -974,20 +1045,21 @@ export default function InternalServiceMapPage() {
|
|
|
974
1045
|
>
|
|
975
1046
|
<div className="intro">
|
|
976
1047
|
<strong>{graph.project?.name ?? 'Project'} infrastructure</strong>
|
|
977
|
-
<span>{selected ? \`Tracing \${selected.name}\` : mode === '3d' ? 'Rotate and explore your service relationships' : 'Select a service to trace its dependencies'}</span>
|
|
1048
|
+
<span>{selected ? \`Tracing \${selected.name}\` : selectedId === 'project' ? \`Tracing \${graph.project?.name ?? 'Project'}\` : mode === '3d' ? 'Rotate and explore your service relationships' : 'Select a service to trace its dependencies'}</span>
|
|
978
1049
|
</div>
|
|
979
1050
|
{mode === '2d' ? (
|
|
980
1051
|
<>
|
|
981
1052
|
<div className="map-plane" style={{ '--zoom': zoom, '--pan-x': \`\${pan.x}px\`, '--pan-y': \`\${pan.y}px\` } as CSSProperties}>
|
|
982
1053
|
<Connections nodes={nodes} selectedId={selectedId} />
|
|
983
|
-
<
|
|
1054
|
+
<button type="button" className={\`hub \${selectedId === 'project' ? 'is-selected' : ''}\`} onClick={() => setSelectedId('project')} aria-label={\`Trace \${graph.project?.name ?? 'Project'} child services\`}><div className="hub-inner"><ProjectMark /></div></button>
|
|
984
1055
|
{nodes.map((service) => {
|
|
985
|
-
const
|
|
1056
|
+
const isHighlightedChild = highlightedChildIds.has(service.id);
|
|
1057
|
+
const isRelated = descendantIds.has(service.id) || selected?.parentId === service.id || isHighlightedChild;
|
|
986
1058
|
return (
|
|
987
1059
|
<button
|
|
988
1060
|
type="button"
|
|
989
1061
|
key={service.id}
|
|
990
|
-
className={\`service-node \${selectedId === service.id ? 'is-selected' : ''} \${isRelated ? 'is-related' : ''}\`}
|
|
1062
|
+
className={\`service-node \${selectedId === service.id ? 'is-selected' : ''} \${isRelated ? 'is-related' : ''} \${isHighlightedChild ? 'is-highlighted-child' : ''}\`}
|
|
991
1063
|
style={{ left: \`\${service.x}%\`, top: \`\${service.y}%\` }}
|
|
992
1064
|
onPointerDown={(event) => startNodeDrag(event, service)}
|
|
993
1065
|
onPointerMove={moveNodeDrag}
|
|
@@ -1016,7 +1088,7 @@ export default function InternalServiceMapPage() {
|
|
|
1016
1088
|
</div>
|
|
1017
1089
|
</>
|
|
1018
1090
|
) : (
|
|
1019
|
-
<ServiceGraph3D nodes={nodes} selectedId={selectedId} onSelect={setSelectedId} />
|
|
1091
|
+
<ServiceGraph3D nodes={nodes} selectedId={selectedId} resetKey={reset3DKey} onSelect={setSelectedId} />
|
|
1020
1092
|
)}
|
|
1021
1093
|
<div className="zoom-controls" aria-label="Map zoom controls">
|
|
1022
1094
|
<button type="button" className="zoom-button" onClick={zoomIn} aria-label="Zoom in">+</button>
|