servicemap 0.1.40 → 0.1.42
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 +4 -1
- package/servicemap/scripts/generate-page.mjs +137 -26
package/package.json
CHANGED
package/servicemap/SKILL.md
CHANGED
|
@@ -25,7 +25,10 @@ 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
|
+
Drag a service and verify that only that node moves and its connections follow, including after zooming.
|
|
30
|
+
After testing 3D, return the delivered preview to 2D and close any open details panel.
|
|
31
|
+
Always initialize new maps in 2D; do not persist the last selected view mode.
|
|
29
32
|
Desktop output uses the same interactive component as website output; missing interactions are generator bugs to fix, not controls to remove.
|
|
30
33
|
10. If a website page was created, tell the user it is only local until deployed and ask whether they want to deploy it now.
|
|
31
34
|
Do not deploy unless the user explicitly says yes.
|
|
@@ -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); }
|
|
@@ -86,9 +88,13 @@ function pageCss() {
|
|
|
86
88
|
.edge-line.is-active { stroke: #7ee2b8; stroke-width: 2.4px; stroke-dasharray: 5 5; animation: servicemapDash .5s linear infinite; filter: drop-shadow(0 0 5px rgba(126,226,184,.55)); 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
|
-
button.service-node { cursor:
|
|
95
|
+
button.service-node { cursor: grab; touch-action: none; user-select: none; color: inherit; font: inherit; text-align: left; }
|
|
96
|
+
button.service-node:active { cursor: grabbing; }
|
|
97
|
+
.service-node img { pointer-events: none; }
|
|
92
98
|
.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); }
|
|
93
99
|
.map.has-selection .service-node { opacity: 0.16; }
|
|
94
100
|
.map.has-selection .service-node.is-related { opacity: 0.48; }
|
|
@@ -109,6 +115,10 @@ function pageCss() {
|
|
|
109
115
|
.zoom-button:last-child { border-bottom: 0; font-size: 18px; }
|
|
110
116
|
.zoom-button:hover { background: rgba(101,240,173,0.1); color: #d8f8e8; }
|
|
111
117
|
.fit-icon { display: inline-block; width: 15px; height: 15px; border: 2px solid currentColor; border-radius: 3px; }
|
|
118
|
+
.center-icon { position: relative; width: 21px; height: 21px; border: 2px solid currentColor; border-radius: 999px; }
|
|
119
|
+
.center-icon::before, .center-icon::after { content: ''; position: absolute; background: currentColor; }
|
|
120
|
+
.center-icon::before { left: 50%; top: -5px; bottom: -5px; width: 2px; transform: translateX(-50%); }
|
|
121
|
+
.center-icon::after { top: 50%; left: -5px; right: -5px; height: 2px; transform: translateY(-50%); }
|
|
112
122
|
.scan-foot { position: absolute; right: 32px; bottom: 32px; z-index: 20; color: #53665d; text-align: right; font-size: 12px; line-height: 1.6; }
|
|
113
123
|
.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; }
|
|
114
124
|
.drawer-scrim { position: absolute; inset: 0; z-index: 18; background: rgba(0,0,0,0.58); pointer-events: none; }
|
|
@@ -309,9 +319,36 @@ function initials(name: string) {
|
|
|
309
319
|
return name.split(/\\s+/).filter(Boolean).slice(0, 2).map((part) => part[0]).join('').toUpperCase();
|
|
310
320
|
}
|
|
311
321
|
|
|
322
|
+
function collectDescendantIds(nodes: ServiceNode[], selectedId: string | null) {
|
|
323
|
+
const ids = new Set<string>();
|
|
324
|
+
if (!selectedId) return ids;
|
|
325
|
+
const childrenByParent = new Map<string, ServiceNode[]>();
|
|
326
|
+
nodes.forEach((node) => {
|
|
327
|
+
const parentId = node.parentId ?? 'project';
|
|
328
|
+
const siblings = childrenByParent.get(parentId) ?? [];
|
|
329
|
+
siblings.push(node);
|
|
330
|
+
childrenByParent.set(parentId, siblings);
|
|
331
|
+
});
|
|
332
|
+
const queue = [...(childrenByParent.get(selectedId) ?? [])];
|
|
333
|
+
while (queue.length) {
|
|
334
|
+
const node = queue.shift()!;
|
|
335
|
+
if (ids.has(node.id)) continue;
|
|
336
|
+
ids.add(node.id);
|
|
337
|
+
queue.push(...(childrenByParent.get(node.id) ?? []));
|
|
338
|
+
}
|
|
339
|
+
return ids;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function isActiveTreeEdge(sourceId: string, targetId: string, selectedId: string | null, descendantIds: Set<string>) {
|
|
343
|
+
if (!selectedId) return false;
|
|
344
|
+
if (sourceId === selectedId && descendantIds.has(targetId)) return true;
|
|
345
|
+
return descendantIds.has(sourceId) && descendantIds.has(targetId);
|
|
346
|
+
}
|
|
347
|
+
|
|
312
348
|
function Connections({ nodes, selectedId }: { nodes: ServiceNode[]; selectedId: string | null }) {
|
|
313
349
|
const ref = useRef<SVGSVGElement>(null);
|
|
314
350
|
const [size, setSize] = useState({ width: 0, height: 0, nodeHalfWidth: 125 });
|
|
351
|
+
const descendantIds = useMemo(() => collectDescendantIds(nodes, selectedId), [nodes, selectedId]);
|
|
315
352
|
useEffect(() => {
|
|
316
353
|
if (!ref.current) return;
|
|
317
354
|
const observer = new ResizeObserver(([entry]) => {
|
|
@@ -335,7 +372,7 @@ function Connections({ nodes, selectedId }: { nodes: ServiceNode[]; selectedId:
|
|
|
335
372
|
const dy = Math.sign(y2 - y1);
|
|
336
373
|
const radius = Math.min(5, Math.abs(x2 - x1) / 4, Math.abs(y2 - y1) / 2);
|
|
337
374
|
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}\`;
|
|
338
|
-
const active =
|
|
375
|
+
const active = isActiveTreeEdge(node.parentId ?? 'project', node.id, selectedId, descendantIds);
|
|
339
376
|
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' : ''}\`} />;
|
|
340
377
|
})}
|
|
341
378
|
</svg>;
|
|
@@ -638,10 +675,12 @@ function AddServiceModal({
|
|
|
638
675
|
function ServiceGraph3D({
|
|
639
676
|
nodes,
|
|
640
677
|
selectedId,
|
|
678
|
+
resetKey,
|
|
641
679
|
onSelect,
|
|
642
680
|
}: {
|
|
643
681
|
nodes: ServiceNode[];
|
|
644
682
|
selectedId: string | null;
|
|
683
|
+
resetKey: number;
|
|
645
684
|
onSelect: (id: string | null) => void;
|
|
646
685
|
}) {
|
|
647
686
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
@@ -674,19 +713,16 @@ function ServiceGraph3D({
|
|
|
674
713
|
label: node.confidence === 'high' ? relationshipLabel(node.parentId) : \`may \${relationshipLabel(node.parentId)}\`,
|
|
675
714
|
})),
|
|
676
715
|
}), [nodes]);
|
|
677
|
-
|
|
678
|
-
const
|
|
716
|
+
const descendantIds = useMemo(() => collectDescendantIds(nodes, selectedId), [nodes, selectedId]);
|
|
717
|
+
const activeTreeLinkIds = useMemo(() => {
|
|
679
718
|
const ids = new Set<string>();
|
|
680
|
-
if (!selectedId) return ids;
|
|
681
|
-
ids.add(selectedId);
|
|
682
719
|
graphData.links.forEach((link) => {
|
|
683
720
|
const source = endpointId(link.source);
|
|
684
721
|
const target = endpointId(link.target);
|
|
685
|
-
if (source
|
|
686
|
-
if (target === selectedId) ids.add(source);
|
|
722
|
+
if (isActiveTreeEdge(source, target, selectedId, descendantIds)) ids.add(link.id);
|
|
687
723
|
});
|
|
688
724
|
return ids;
|
|
689
|
-
}, [graphData.links, selectedId]);
|
|
725
|
+
}, [descendantIds, graphData.links, selectedId]);
|
|
690
726
|
|
|
691
727
|
useEffect(() => {
|
|
692
728
|
const element = containerRef.current;
|
|
@@ -721,6 +757,11 @@ function ServiceGraph3D({
|
|
|
721
757
|
};
|
|
722
758
|
}, [configureForces, graphData.nodes.length, graphData.links.length]);
|
|
723
759
|
|
|
760
|
+
useEffect(() => {
|
|
761
|
+
if (!resetKey) return;
|
|
762
|
+
graphRef.current?.zoomToFit(700, 130);
|
|
763
|
+
}, [resetKey]);
|
|
764
|
+
|
|
724
765
|
const focusNode = useCallback((node: NodeObject) => {
|
|
725
766
|
const serviceNode = graphNode(node);
|
|
726
767
|
onSelect(serviceNode.id);
|
|
@@ -743,7 +784,11 @@ function ServiceGraph3D({
|
|
|
743
784
|
const offset = camera.position.clone().sub(target);
|
|
744
785
|
const currentDistance = offset.length();
|
|
745
786
|
if (currentDistance === 0) return;
|
|
746
|
-
const nextDistance =
|
|
787
|
+
const nextDistance = Math.max(35, currentDistance * factor);
|
|
788
|
+
if (camera instanceof THREE.PerspectiveCamera && nextDistance * 4 > camera.far) {
|
|
789
|
+
camera.far = nextDistance * 4;
|
|
790
|
+
camera.updateProjectionMatrix();
|
|
791
|
+
}
|
|
747
792
|
const nextPosition = target.clone().add(offset.multiplyScalar(nextDistance / currentDistance));
|
|
748
793
|
graphInstance.cameraPosition(nextPosition, target, 240);
|
|
749
794
|
}, []);
|
|
@@ -769,22 +814,22 @@ function ServiceGraph3D({
|
|
|
769
814
|
linkColor={(link) => {
|
|
770
815
|
const serviceLink = graphLink(link as GraphLink3D);
|
|
771
816
|
if (!selectedId) return '#415149';
|
|
772
|
-
return
|
|
817
|
+
return activeTreeLinkIds.has(serviceLink.id) ? '#7ee2b8' : '#17211c';
|
|
773
818
|
}}
|
|
774
819
|
linkWidth={(link) => {
|
|
775
820
|
const serviceLink = graphLink(link as GraphLink3D);
|
|
776
|
-
return
|
|
821
|
+
return activeTreeLinkIds.has(serviceLink.id) ? 2.2 : selectedId ? 0.35 : 0.8;
|
|
777
822
|
}}
|
|
778
823
|
linkOpacity={0.8}
|
|
779
824
|
linkDirectionalArrowLength={3.5}
|
|
780
825
|
linkDirectionalArrowRelPos={0.8}
|
|
781
826
|
linkDirectionalArrowColor={(link) => {
|
|
782
827
|
const serviceLink = graphLink(link as GraphLink3D);
|
|
783
|
-
return
|
|
828
|
+
return activeTreeLinkIds.has(serviceLink.id) ? '#9ff0cb' : '#506158';
|
|
784
829
|
}}
|
|
785
830
|
linkDirectionalParticles={(link) => {
|
|
786
831
|
const serviceLink = graphLink(link as GraphLink3D);
|
|
787
|
-
return
|
|
832
|
+
return activeTreeLinkIds.has(serviceLink.id) ? 3 : 0;
|
|
788
833
|
}}
|
|
789
834
|
linkDirectionalParticleWidth={1.8}
|
|
790
835
|
linkDirectionalParticleSpeed={0.006}
|
|
@@ -803,27 +848,76 @@ function ServiceGraph3D({
|
|
|
803
848
|
}}
|
|
804
849
|
/>
|
|
805
850
|
<div className="graph-3d__zoom" aria-label="3D zoom controls">
|
|
806
|
-
<button type="button" onClick={() => zoom(
|
|
807
|
-
<button type="button" onClick={() => zoom(1.
|
|
851
|
+
<button type="button" onClick={() => zoom(1 / 1.5)} title="Zoom in" aria-label="Zoom in">+</button>
|
|
852
|
+
<button type="button" onClick={() => zoom(1.5)} title="Zoom out" aria-label="Zoom out">−</button>
|
|
808
853
|
</div>
|
|
809
854
|
<div className="graph-3d__help" aria-hidden="true">Drag to rotate <span /> Scroll to zoom <span /> Click a service</div>
|
|
810
|
-
{selectedId && <div className="graph-3d__focus" aria-hidden="true">{
|
|
855
|
+
{selectedId && <div className="graph-3d__focus" aria-hidden="true">{activeTreeLinkIds.size} child connections highlighted</div>}
|
|
811
856
|
</div>
|
|
812
857
|
);
|
|
813
858
|
}
|
|
814
859
|
|
|
815
860
|
export default function InternalServiceMapPage() {
|
|
816
861
|
const [services, setServices] = useState<RawService[]>(() => graph.services ?? []);
|
|
862
|
+
const [positions, setPositions] = useState<Record<string, { x: number; y: number }>>({});
|
|
863
|
+
const positionsRef = useRef(positions);
|
|
864
|
+
const layoutKey = 'servicemap:positions:' + (graph.project?.name ?? 'project');
|
|
865
|
+
const nodeDrag = useRef<{ id: string; pointerId: number; clientX: number; clientY: number; x: number; y: number; width: number; height: number; moved: boolean } | null>(null);
|
|
866
|
+
const suppressClick = useRef(false);
|
|
867
|
+
useEffect(() => {
|
|
868
|
+
try {
|
|
869
|
+
const stored = JSON.parse(localStorage.getItem(layoutKey) ?? '{}');
|
|
870
|
+
const valid = Object.fromEntries(Object.entries(stored).filter(([, value]) => {
|
|
871
|
+
const point = value as { x?: number; y?: number } | null;
|
|
872
|
+
return point && Number.isFinite(point.x) && Number.isFinite(point.y);
|
|
873
|
+
})) as Record<string, { x: number; y: number }>;
|
|
874
|
+
positionsRef.current = valid;
|
|
875
|
+
setPositions(valid);
|
|
876
|
+
} catch { /* Layout remains usable when storage is unavailable. */ }
|
|
877
|
+
}, [layoutKey]);
|
|
817
878
|
const nodes = useMemo<ServiceNode[]>(() => services.map((service, index) => ({
|
|
818
879
|
...service,
|
|
819
880
|
...servicePosition(index),
|
|
881
|
+
...positions[service.id],
|
|
820
882
|
color: palette[index % palette.length],
|
|
821
|
-
})), [services]);
|
|
883
|
+
})), [services, positions]);
|
|
822
884
|
const [mode, setMode] = useState<'2d' | '3d'>('2d');
|
|
823
885
|
const [zoom, setZoom] = useState(1);
|
|
824
886
|
const [pan, setPan] = useState({ x: 0, y: 0 });
|
|
825
887
|
const [panStart, setPanStart] = useState<{ pointerId: number; x: number; y: number; panX: number; panY: number } | null>(null);
|
|
826
888
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
889
|
+
const [reset3DKey, setReset3DKey] = useState(0);
|
|
890
|
+
const startNodeDrag = (event: PointerEvent<HTMLButtonElement>, node: ServiceNode) => {
|
|
891
|
+
if (event.button !== 0) return;
|
|
892
|
+
event.stopPropagation();
|
|
893
|
+
const plane = event.currentTarget.parentElement!.getBoundingClientRect();
|
|
894
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
895
|
+
suppressClick.current = false;
|
|
896
|
+
nodeDrag.current = { id: node.id, pointerId: event.pointerId, clientX: event.clientX, clientY: event.clientY, x: node.x, y: node.y, width: plane.width, height: plane.height, moved: false };
|
|
897
|
+
};
|
|
898
|
+
const moveNodeDrag = (event: PointerEvent<HTMLButtonElement>) => {
|
|
899
|
+
const drag = nodeDrag.current;
|
|
900
|
+
if (!drag || drag.pointerId !== event.pointerId) return;
|
|
901
|
+
event.stopPropagation();
|
|
902
|
+
const dx = event.clientX - drag.clientX;
|
|
903
|
+
const dy = event.clientY - drag.clientY;
|
|
904
|
+
if (!drag.moved && Math.hypot(dx, dy) < 4) return;
|
|
905
|
+
drag.moved = true;
|
|
906
|
+
suppressClick.current = true;
|
|
907
|
+
const next = { ...positionsRef.current, [drag.id]: { x: drag.x + dx / drag.width * 100, y: drag.y + dy / drag.height * 100 } };
|
|
908
|
+
positionsRef.current = next;
|
|
909
|
+
setPositions(next);
|
|
910
|
+
};
|
|
911
|
+
const stopNodeDrag = (event: PointerEvent<HTMLButtonElement>) => {
|
|
912
|
+
const drag = nodeDrag.current;
|
|
913
|
+
if (!drag || drag.pointerId !== event.pointerId) return;
|
|
914
|
+
event.stopPropagation();
|
|
915
|
+
nodeDrag.current = null;
|
|
916
|
+
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
|
|
917
|
+
if (drag.moved) {
|
|
918
|
+
try { localStorage.setItem(layoutKey, JSON.stringify(positionsRef.current)); } catch { /* Keep the current layout in memory. */ }
|
|
919
|
+
}
|
|
920
|
+
};
|
|
827
921
|
const [isAddingService, setIsAddingService] = useState(false);
|
|
828
922
|
const [newService, setNewService] = useState<NewServiceDraft>({ name: '', category: '', parentId: 'product' });
|
|
829
923
|
const [drafts, setDrafts] = useState<Record<string, DraftDetails>>(() => Object.fromEntries(nodes.map((service) => [
|
|
@@ -836,12 +930,20 @@ export default function InternalServiceMapPage() {
|
|
|
836
930
|
},
|
|
837
931
|
])));
|
|
838
932
|
const selected = nodes.find((service) => service.id === selectedId) ?? null;
|
|
839
|
-
const
|
|
840
|
-
const
|
|
933
|
+
const descendantIds = useMemo(() => collectDescendantIds(nodes, selectedId), [nodes, selectedId]);
|
|
934
|
+
const zoomIn = () => setZoom((current) => Math.min(1.45, current * 1.5));
|
|
935
|
+
const zoomOut = () => setZoom((current) => current / 1.5);
|
|
841
936
|
const resetZoom = () => {
|
|
842
937
|
setZoom(1);
|
|
843
938
|
setPan({ x: 0, y: 0 });
|
|
844
939
|
};
|
|
940
|
+
const centerMap = () => {
|
|
941
|
+
if (mode === '3d') {
|
|
942
|
+
setReset3DKey((current) => current + 1);
|
|
943
|
+
return;
|
|
944
|
+
}
|
|
945
|
+
resetZoom();
|
|
946
|
+
};
|
|
845
947
|
const startPan = (event: PointerEvent<HTMLElement>) => {
|
|
846
948
|
if (mode !== '2d' || event.button !== 0 || (event.target as HTMLElement).closest('button, a, input, textarea, select')) return;
|
|
847
949
|
event.currentTarget.setPointerCapture(event.pointerId);
|
|
@@ -907,11 +1009,12 @@ export default function InternalServiceMapPage() {
|
|
|
907
1009
|
</div>
|
|
908
1010
|
<div className="top-actions">
|
|
909
1011
|
<span>{nodes.length} services</span>
|
|
910
|
-
<button type="button" className="add-service-button" onClick={() => setIsAddingService(true)}>+ Add service</button>
|
|
911
1012
|
<div className="mode-toggle" aria-label="View mode">
|
|
912
1013
|
<button type="button" className={\`mode-button \${mode === '2d' ? 'is-active' : ''}\`} onClick={() => setMode('2d')}>2D</button>
|
|
913
1014
|
<button type="button" className={\`mode-button \${mode === '3d' ? 'is-active' : ''}\`} onClick={() => setMode('3d')}>3D</button>
|
|
914
1015
|
</div>
|
|
1016
|
+
<button type="button" className="center-button" onClick={centerMap} aria-label="Center graph" title="Center graph"><span className="center-icon" aria-hidden="true" /></button>
|
|
1017
|
+
<button type="button" className="add-service-button" onClick={() => setIsAddingService(true)}>+ Add service</button>
|
|
915
1018
|
</div>
|
|
916
1019
|
</header>
|
|
917
1020
|
|
|
@@ -924,22 +1027,30 @@ export default function InternalServiceMapPage() {
|
|
|
924
1027
|
>
|
|
925
1028
|
<div className="intro">
|
|
926
1029
|
<strong>{graph.project?.name ?? 'Project'} infrastructure</strong>
|
|
927
|
-
<span>{selected ? \`Tracing \${selected.name}\` : mode === '3d' ? 'Rotate and explore your service relationships' : 'Select a service to trace its dependencies'}</span>
|
|
1030
|
+
<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>
|
|
928
1031
|
</div>
|
|
929
1032
|
{mode === '2d' ? (
|
|
930
1033
|
<>
|
|
931
1034
|
<div className="map-plane" style={{ '--zoom': zoom, '--pan-x': \`\${pan.x}px\`, '--pan-y': \`\${pan.y}px\` } as CSSProperties}>
|
|
932
1035
|
<Connections nodes={nodes} selectedId={selectedId} />
|
|
933
|
-
<
|
|
1036
|
+
<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>
|
|
934
1037
|
{nodes.map((service) => {
|
|
935
|
-
const isRelated =
|
|
1038
|
+
const isRelated = descendantIds.has(service.id) || selected?.parentId === service.id;
|
|
936
1039
|
return (
|
|
937
1040
|
<button
|
|
938
1041
|
type="button"
|
|
939
1042
|
key={service.id}
|
|
940
1043
|
className={\`service-node \${selectedId === service.id ? 'is-selected' : ''} \${isRelated ? 'is-related' : ''}\`}
|
|
941
1044
|
style={{ left: \`\${service.x}%\`, top: \`\${service.y}%\` }}
|
|
942
|
-
|
|
1045
|
+
onPointerDown={(event) => startNodeDrag(event, service)}
|
|
1046
|
+
onPointerMove={moveNodeDrag}
|
|
1047
|
+
onPointerUp={stopNodeDrag}
|
|
1048
|
+
onPointerCancel={stopNodeDrag}
|
|
1049
|
+
onLostPointerCapture={stopNodeDrag}
|
|
1050
|
+
onClick={(event) => {
|
|
1051
|
+
if (event.detail !== 0 && suppressClick.current) { suppressClick.current = false; return; }
|
|
1052
|
+
setSelectedId(service.id);
|
|
1053
|
+
}}
|
|
943
1054
|
>
|
|
944
1055
|
<ServiceLogo service={service} />
|
|
945
1056
|
<div className="service-copy">
|
|
@@ -958,7 +1069,7 @@ export default function InternalServiceMapPage() {
|
|
|
958
1069
|
</div>
|
|
959
1070
|
</>
|
|
960
1071
|
) : (
|
|
961
|
-
<ServiceGraph3D nodes={nodes} selectedId={selectedId} onSelect={setSelectedId} />
|
|
1072
|
+
<ServiceGraph3D nodes={nodes} selectedId={selectedId} resetKey={reset3DKey} onSelect={setSelectedId} />
|
|
962
1073
|
)}
|
|
963
1074
|
<div className="zoom-controls" aria-label="Map zoom controls">
|
|
964
1075
|
<button type="button" className="zoom-button" onClick={zoomIn} aria-label="Zoom in">+</button>
|