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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "servicemap",
3
- "version": "0.1.41",
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,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); }
@@ -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 = selectedId === node.id || selectedId === node.parentId;
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 activeIds = useMemo(() => {
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 === selectedId) ids.add(target);
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 = 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
+ }
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 endpointId(serviceLink.source) === selectedId || endpointId(serviceLink.target) === selectedId ? '#7ee2b8' : '#17211c';
817
+ return activeTreeLinkIds.has(serviceLink.id) ? '#7ee2b8' : '#17211c';
775
818
  }}
776
819
  linkWidth={(link) => {
777
820
  const serviceLink = graphLink(link as GraphLink3D);
778
- 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;
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 endpointId(serviceLink.source) === selectedId || endpointId(serviceLink.target) === selectedId ? '#9ff0cb' : '#506158';
828
+ return activeTreeLinkIds.has(serviceLink.id) ? '#9ff0cb' : '#506158';
786
829
  }}
787
830
  linkDirectionalParticles={(link) => {
788
831
  const serviceLink = graphLink(link as GraphLink3D);
789
- return endpointId(serviceLink.source) === selectedId || endpointId(serviceLink.target) === selectedId ? 3 : 0;
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(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>
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">{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>}
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 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))));
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
- <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>
984
1037
  {nodes.map((service) => {
985
- const isRelated = selectedId === service.parentId || selected?.parentId === service.id;
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>