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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "servicemap",
3
- "version": "0.1.41",
3
+ "version": "0.1.43",
4
4
  "description": "Generate an internal service map page or desktop app for a software project.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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 connection to its parent.
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: 5 5; animation: servicemapDash .5s linear infinite; filter: drop-shadow(0 0 5px rgba(126,226,184,.55)); opacity: 1; }
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 { from { stroke-dashoffset: 10; } to { stroke-dashoffset: 0; } }
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 = selectedId === node.id || selectedId === node.parentId;
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 node</label>
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 activeIds = useMemo(() => {
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 === selectedId) ids.add(target);
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 = THREE.MathUtils.clamp(currentDistance * factor, 35, 1200);
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 endpointId(serviceLink.source) === selectedId || endpointId(serviceLink.target) === selectedId ? '#7ee2b8' : '#17211c';
831
+ return activeTreeLinkIds.has(serviceLink.id) ? '#7ee2b8' : '#17211c';
775
832
  }}
776
833
  linkWidth={(link) => {
777
834
  const serviceLink = graphLink(link as GraphLink3D);
778
- return endpointId(serviceLink.source) === selectedId || endpointId(serviceLink.target) === selectedId ? 2.2 : selectedId ? 0.35 : 0.8;
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 endpointId(serviceLink.source) === selectedId || endpointId(serviceLink.target) === selectedId ? '#9ff0cb' : '#506158';
842
+ return activeTreeLinkIds.has(serviceLink.id) ? '#9ff0cb' : '#506158';
786
843
  }}
787
844
  linkDirectionalParticles={(link) => {
788
845
  const serviceLink = graphLink(link as GraphLink3D);
789
- return endpointId(serviceLink.source) === selectedId || endpointId(serviceLink.target) === selectedId ? 3 : 0;
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(0.78)} title="Zoom in" aria-label="Zoom in">+</button>
809
- <button type="button" onClick={() => zoom(1.28)} title="Zoom out" aria-label="Zoom out">−</button>
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">{Math.max(activeIds.size - 1, 0)} direct connections highlighted</div>}
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 zoomIn = () => setZoom((current) => Math.min(1.45, Number((current + 0.12).toFixed(2))));
890
- const zoomOut = () => setZoom((current) => Math.max(0.7, Number((current - 0.12).toFixed(2))));
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
- <div className="hub"><div className="hub-inner"><ProjectMark /></div></div>
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 isRelated = selectedId === service.parentId || selected?.parentId === service.id;
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>