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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "servicemap",
3
- "version": "0.1.40",
3
+ "version": "0.1.42",
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,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 connection to its parent.
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: pointer; color: inherit; font: inherit; text-align: left; }
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 = selectedId === node.id || selectedId === node.parentId;
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 activeIds = useMemo(() => {
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 === selectedId) ids.add(target);
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 = THREE.MathUtils.clamp(currentDistance * factor, 35, 1200);
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 endpointId(serviceLink.source) === selectedId || endpointId(serviceLink.target) === selectedId ? '#7ee2b8' : '#17211c';
817
+ return activeTreeLinkIds.has(serviceLink.id) ? '#7ee2b8' : '#17211c';
773
818
  }}
774
819
  linkWidth={(link) => {
775
820
  const serviceLink = graphLink(link as GraphLink3D);
776
- return endpointId(serviceLink.source) === selectedId || endpointId(serviceLink.target) === selectedId ? 2.2 : selectedId ? 0.35 : 0.8;
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 endpointId(serviceLink.source) === selectedId || endpointId(serviceLink.target) === selectedId ? '#9ff0cb' : '#506158';
828
+ return activeTreeLinkIds.has(serviceLink.id) ? '#9ff0cb' : '#506158';
784
829
  }}
785
830
  linkDirectionalParticles={(link) => {
786
831
  const serviceLink = graphLink(link as GraphLink3D);
787
- return endpointId(serviceLink.source) === selectedId || endpointId(serviceLink.target) === selectedId ? 3 : 0;
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(0.78)} title="Zoom in" aria-label="Zoom in">+</button>
807
- <button type="button" onClick={() => zoom(1.28)} title="Zoom out" aria-label="Zoom out">−</button>
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">{Math.max(activeIds.size - 1, 0)} direct connections highlighted</div>}
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 zoomIn = () => setZoom((current) => Math.min(1.45, Number((current + 0.12).toFixed(2))));
840
- const zoomOut = () => setZoom((current) => Math.max(0.7, Number((current - 0.12).toFixed(2))));
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
- <div className="hub"><div className="hub-inner"><ProjectMark /></div></div>
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 = selectedId === service.parentId || selected?.parentId === service.id;
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
- onClick={() => setSelectedId(service.id)}
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>