servicemap 0.1.41 → 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
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); }
|
|
@@ -86,6 +88,8 @@ 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
95
|
button.service-node { cursor: grab; touch-action: none; user-select: none; color: inherit; font: inherit; text-align: left; }
|
|
@@ -111,6 +115,10 @@ function pageCss() {
|
|
|
111
115
|
.zoom-button:last-child { border-bottom: 0; font-size: 18px; }
|
|
112
116
|
.zoom-button:hover { background: rgba(101,240,173,0.1); color: #d8f8e8; }
|
|
113
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%); }
|
|
114
122
|
.scan-foot { position: absolute; right: 32px; bottom: 32px; z-index: 20; color: #53665d; text-align: right; font-size: 12px; line-height: 1.6; }
|
|
115
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; }
|
|
116
124
|
.drawer-scrim { position: absolute; inset: 0; z-index: 18; background: rgba(0,0,0,0.58); pointer-events: none; }
|
|
@@ -311,9 +319,36 @@ function initials(name: string) {
|
|
|
311
319
|
return name.split(/\\s+/).filter(Boolean).slice(0, 2).map((part) => part[0]).join('').toUpperCase();
|
|
312
320
|
}
|
|
313
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
|
+
|
|
314
348
|
function Connections({ nodes, selectedId }: { nodes: ServiceNode[]; selectedId: string | null }) {
|
|
315
349
|
const ref = useRef<SVGSVGElement>(null);
|
|
316
350
|
const [size, setSize] = useState({ width: 0, height: 0, nodeHalfWidth: 125 });
|
|
351
|
+
const descendantIds = useMemo(() => collectDescendantIds(nodes, selectedId), [nodes, selectedId]);
|
|
317
352
|
useEffect(() => {
|
|
318
353
|
if (!ref.current) return;
|
|
319
354
|
const observer = new ResizeObserver(([entry]) => {
|
|
@@ -337,7 +372,7 @@ function Connections({ nodes, selectedId }: { nodes: ServiceNode[]; selectedId:
|
|
|
337
372
|
const dy = Math.sign(y2 - y1);
|
|
338
373
|
const radius = Math.min(5, Math.abs(x2 - x1) / 4, Math.abs(y2 - y1) / 2);
|
|
339
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}\`;
|
|
340
|
-
const active =
|
|
375
|
+
const active = isActiveTreeEdge(node.parentId ?? 'project', node.id, selectedId, descendantIds);
|
|
341
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' : ''}\`} />;
|
|
342
377
|
})}
|
|
343
378
|
</svg>;
|
|
@@ -640,10 +675,12 @@ function AddServiceModal({
|
|
|
640
675
|
function ServiceGraph3D({
|
|
641
676
|
nodes,
|
|
642
677
|
selectedId,
|
|
678
|
+
resetKey,
|
|
643
679
|
onSelect,
|
|
644
680
|
}: {
|
|
645
681
|
nodes: ServiceNode[];
|
|
646
682
|
selectedId: string | null;
|
|
683
|
+
resetKey: number;
|
|
647
684
|
onSelect: (id: string | null) => void;
|
|
648
685
|
}) {
|
|
649
686
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
@@ -676,19 +713,16 @@ function ServiceGraph3D({
|
|
|
676
713
|
label: node.confidence === 'high' ? relationshipLabel(node.parentId) : \`may \${relationshipLabel(node.parentId)}\`,
|
|
677
714
|
})),
|
|
678
715
|
}), [nodes]);
|
|
679
|
-
|
|
680
|
-
const
|
|
716
|
+
const descendantIds = useMemo(() => collectDescendantIds(nodes, selectedId), [nodes, selectedId]);
|
|
717
|
+
const activeTreeLinkIds = useMemo(() => {
|
|
681
718
|
const ids = new Set<string>();
|
|
682
|
-
if (!selectedId) return ids;
|
|
683
|
-
ids.add(selectedId);
|
|
684
719
|
graphData.links.forEach((link) => {
|
|
685
720
|
const source = endpointId(link.source);
|
|
686
721
|
const target = endpointId(link.target);
|
|
687
|
-
if (source
|
|
688
|
-
if (target === selectedId) ids.add(source);
|
|
722
|
+
if (isActiveTreeEdge(source, target, selectedId, descendantIds)) ids.add(link.id);
|
|
689
723
|
});
|
|
690
724
|
return ids;
|
|
691
|
-
}, [graphData.links, selectedId]);
|
|
725
|
+
}, [descendantIds, graphData.links, selectedId]);
|
|
692
726
|
|
|
693
727
|
useEffect(() => {
|
|
694
728
|
const element = containerRef.current;
|
|
@@ -723,6 +757,11 @@ function ServiceGraph3D({
|
|
|
723
757
|
};
|
|
724
758
|
}, [configureForces, graphData.nodes.length, graphData.links.length]);
|
|
725
759
|
|
|
760
|
+
useEffect(() => {
|
|
761
|
+
if (!resetKey) return;
|
|
762
|
+
graphRef.current?.zoomToFit(700, 130);
|
|
763
|
+
}, [resetKey]);
|
|
764
|
+
|
|
726
765
|
const focusNode = useCallback((node: NodeObject) => {
|
|
727
766
|
const serviceNode = graphNode(node);
|
|
728
767
|
onSelect(serviceNode.id);
|
|
@@ -745,7 +784,11 @@ function ServiceGraph3D({
|
|
|
745
784
|
const offset = camera.position.clone().sub(target);
|
|
746
785
|
const currentDistance = offset.length();
|
|
747
786
|
if (currentDistance === 0) return;
|
|
748
|
-
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
|
+
}
|
|
749
792
|
const nextPosition = target.clone().add(offset.multiplyScalar(nextDistance / currentDistance));
|
|
750
793
|
graphInstance.cameraPosition(nextPosition, target, 240);
|
|
751
794
|
}, []);
|
|
@@ -771,22 +814,22 @@ function ServiceGraph3D({
|
|
|
771
814
|
linkColor={(link) => {
|
|
772
815
|
const serviceLink = graphLink(link as GraphLink3D);
|
|
773
816
|
if (!selectedId) return '#415149';
|
|
774
|
-
return
|
|
817
|
+
return activeTreeLinkIds.has(serviceLink.id) ? '#7ee2b8' : '#17211c';
|
|
775
818
|
}}
|
|
776
819
|
linkWidth={(link) => {
|
|
777
820
|
const serviceLink = graphLink(link as GraphLink3D);
|
|
778
|
-
return
|
|
821
|
+
return activeTreeLinkIds.has(serviceLink.id) ? 2.2 : selectedId ? 0.35 : 0.8;
|
|
779
822
|
}}
|
|
780
823
|
linkOpacity={0.8}
|
|
781
824
|
linkDirectionalArrowLength={3.5}
|
|
782
825
|
linkDirectionalArrowRelPos={0.8}
|
|
783
826
|
linkDirectionalArrowColor={(link) => {
|
|
784
827
|
const serviceLink = graphLink(link as GraphLink3D);
|
|
785
|
-
return
|
|
828
|
+
return activeTreeLinkIds.has(serviceLink.id) ? '#9ff0cb' : '#506158';
|
|
786
829
|
}}
|
|
787
830
|
linkDirectionalParticles={(link) => {
|
|
788
831
|
const serviceLink = graphLink(link as GraphLink3D);
|
|
789
|
-
return
|
|
832
|
+
return activeTreeLinkIds.has(serviceLink.id) ? 3 : 0;
|
|
790
833
|
}}
|
|
791
834
|
linkDirectionalParticleWidth={1.8}
|
|
792
835
|
linkDirectionalParticleSpeed={0.006}
|
|
@@ -805,11 +848,11 @@ function ServiceGraph3D({
|
|
|
805
848
|
}}
|
|
806
849
|
/>
|
|
807
850
|
<div className="graph-3d__zoom" aria-label="3D zoom controls">
|
|
808
|
-
<button type="button" onClick={() => zoom(
|
|
809
|
-
<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>
|
|
810
853
|
</div>
|
|
811
854
|
<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">{
|
|
855
|
+
{selectedId && <div className="graph-3d__focus" aria-hidden="true">{activeTreeLinkIds.size} child connections highlighted</div>}
|
|
813
856
|
</div>
|
|
814
857
|
);
|
|
815
858
|
}
|
|
@@ -843,6 +886,7 @@ export default function InternalServiceMapPage() {
|
|
|
843
886
|
const [pan, setPan] = useState({ x: 0, y: 0 });
|
|
844
887
|
const [panStart, setPanStart] = useState<{ pointerId: number; x: number; y: number; panX: number; panY: number } | null>(null);
|
|
845
888
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
889
|
+
const [reset3DKey, setReset3DKey] = useState(0);
|
|
846
890
|
const startNodeDrag = (event: PointerEvent<HTMLButtonElement>, node: ServiceNode) => {
|
|
847
891
|
if (event.button !== 0) return;
|
|
848
892
|
event.stopPropagation();
|
|
@@ -886,12 +930,20 @@ export default function InternalServiceMapPage() {
|
|
|
886
930
|
},
|
|
887
931
|
])));
|
|
888
932
|
const selected = nodes.find((service) => service.id === selectedId) ?? null;
|
|
889
|
-
const
|
|
890
|
-
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);
|
|
891
936
|
const resetZoom = () => {
|
|
892
937
|
setZoom(1);
|
|
893
938
|
setPan({ x: 0, y: 0 });
|
|
894
939
|
};
|
|
940
|
+
const centerMap = () => {
|
|
941
|
+
if (mode === '3d') {
|
|
942
|
+
setReset3DKey((current) => current + 1);
|
|
943
|
+
return;
|
|
944
|
+
}
|
|
945
|
+
resetZoom();
|
|
946
|
+
};
|
|
895
947
|
const startPan = (event: PointerEvent<HTMLElement>) => {
|
|
896
948
|
if (mode !== '2d' || event.button !== 0 || (event.target as HTMLElement).closest('button, a, input, textarea, select')) return;
|
|
897
949
|
event.currentTarget.setPointerCapture(event.pointerId);
|
|
@@ -957,11 +1009,12 @@ export default function InternalServiceMapPage() {
|
|
|
957
1009
|
</div>
|
|
958
1010
|
<div className="top-actions">
|
|
959
1011
|
<span>{nodes.length} services</span>
|
|
960
|
-
<button type="button" className="add-service-button" onClick={() => setIsAddingService(true)}>+ Add service</button>
|
|
961
1012
|
<div className="mode-toggle" aria-label="View mode">
|
|
962
1013
|
<button type="button" className={\`mode-button \${mode === '2d' ? 'is-active' : ''}\`} onClick={() => setMode('2d')}>2D</button>
|
|
963
1014
|
<button type="button" className={\`mode-button \${mode === '3d' ? 'is-active' : ''}\`} onClick={() => setMode('3d')}>3D</button>
|
|
964
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>
|
|
965
1018
|
</div>
|
|
966
1019
|
</header>
|
|
967
1020
|
|
|
@@ -974,15 +1027,15 @@ export default function InternalServiceMapPage() {
|
|
|
974
1027
|
>
|
|
975
1028
|
<div className="intro">
|
|
976
1029
|
<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>
|
|
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>
|
|
978
1031
|
</div>
|
|
979
1032
|
{mode === '2d' ? (
|
|
980
1033
|
<>
|
|
981
1034
|
<div className="map-plane" style={{ '--zoom': zoom, '--pan-x': \`\${pan.x}px\`, '--pan-y': \`\${pan.y}px\` } as CSSProperties}>
|
|
982
1035
|
<Connections nodes={nodes} selectedId={selectedId} />
|
|
983
|
-
<
|
|
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>
|
|
984
1037
|
{nodes.map((service) => {
|
|
985
|
-
const isRelated =
|
|
1038
|
+
const isRelated = descendantIds.has(service.id) || selected?.parentId === service.id;
|
|
986
1039
|
return (
|
|
987
1040
|
<button
|
|
988
1041
|
type="button"
|
|
@@ -1016,7 +1069,7 @@ export default function InternalServiceMapPage() {
|
|
|
1016
1069
|
</div>
|
|
1017
1070
|
</>
|
|
1018
1071
|
) : (
|
|
1019
|
-
<ServiceGraph3D nodes={nodes} selectedId={selectedId} onSelect={setSelectedId} />
|
|
1072
|
+
<ServiceGraph3D nodes={nodes} selectedId={selectedId} resetKey={reset3DKey} onSelect={setSelectedId} />
|
|
1020
1073
|
)}
|
|
1021
1074
|
<div className="zoom-controls" aria-label="Map zoom controls">
|
|
1022
1075
|
<button type="button" className="zoom-button" onClick={zoomIn} aria-label="Zoom in">+</button>
|