servicemap 0.1.31 → 0.1.34

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.31",
3
+ "version": "0.1.34",
4
4
  "description": "Generate an internal service map page or desktop app for a software project.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { existsSync } from 'node:fs';
3
3
  import { copyFile, mkdir, readFile, writeFile } from 'node:fs/promises';
4
+ import { spawn } from 'node:child_process';
4
5
  import { dirname, extname, isAbsolute, join, resolve } from 'node:path';
5
6
 
6
7
  const defaultInput = '.servicemap/internal-servicemap.json';
@@ -21,6 +22,14 @@ async function readJson(path) {
21
22
  return JSON.parse(await readFile(path, 'utf8'));
22
23
  }
23
24
 
25
+ async function readJsonOrNull(path) {
26
+ try {
27
+ return JSON.parse(await readFile(path, 'utf8'));
28
+ } catch {
29
+ return null;
30
+ }
31
+ }
32
+
24
33
  function routeFor(root, framework) {
25
34
  if (framework === 'next-app-router') {
26
35
  const base = existsSync(join(root, 'src/app')) ? 'src/app' : 'app';
@@ -64,6 +73,7 @@ function pageCss() {
64
73
  .mode-button { min-height: 36px; border: 0; border-radius: 8px; background: transparent; color: #789086; padding: 0 12px; cursor: pointer; font: inherit; font-weight: 800; }
65
74
  .mode-button.is-active { background: #276f53; color: #c9f9df; box-shadow: 0 0 18px rgba(101,240,173,0.16); }
66
75
  .map { position: relative; height: calc(100vh - 88px); min-height: 760px; background: radial-gradient(circle at 50% 52%, rgba(41, 96, 69, 0.22), transparent 24%), radial-gradient(circle at 1px 1px, rgba(88, 160, 120, 0.2) 1px, transparent 1px), #020806; background-size: auto, 28px 28px, auto; }
76
+ .map-plane { position: absolute; inset: 0; transform: scale(var(--zoom, 1)); transform-origin: 50% 50%; transition: transform 160ms ease; }
67
77
  .intro { position: absolute; left: 32px; top: 32px; z-index: 20; }
68
78
  .intro strong { display: block; color: #779286; font-size: 12px; letter-spacing: 0.22em; text-transform: uppercase; }
69
79
  .intro span { display: block; margin-top: 8px; color: #53665d; font-size: 14px; }
@@ -87,6 +97,11 @@ function pageCss() {
87
97
  .status-medium, .status-low { color: #f1c85b; background: #f1c85b; }
88
98
  .legend { position: absolute; left: 50%; bottom: 32px; z-index: 20; transform: translateX(-50%); display: flex; align-items: center; gap: 20px; border: 1px solid rgba(255,255,255,0.1); border-radius: 999px; background: rgba(11,20,15,0.9); color: #789086; padding: 8px 20px; font-size: 12px; }
89
99
  .legend i { display: inline-block; width: 8px; height: 8px; margin-right: 8px; border-radius: 999px; }
100
+ .zoom-controls { position: absolute; left: 28px; bottom: 26px; z-index: 29; display: grid; overflow: hidden; border: 1px solid rgba(255,255,255,0.12); border-radius: 10px; background: rgba(7,14,10,0.9); box-shadow: 0 12px 32px rgba(0,0,0,0.34); }
101
+ .zoom-button { width: 38px; height: 36px; border: 0; border-bottom: 1px solid rgba(255,255,255,0.1); background: transparent; color: #a8bbb2; cursor: pointer; font: inherit; font-size: 22px; line-height: 1; }
102
+ .zoom-button:last-child { border-bottom: 0; font-size: 18px; }
103
+ .zoom-button:hover { background: rgba(101,240,173,0.1); color: #d8f8e8; }
104
+ .fit-icon { display: inline-block; width: 15px; height: 15px; border: 2px solid currentColor; border-radius: 3px; }
90
105
  .scan-foot { position: absolute; right: 32px; bottom: 32px; z-index: 20; color: #53665d; text-align: right; font-size: 12px; line-height: 1.6; }
91
106
  .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; }
92
107
  .drawer-scrim { position: absolute; inset: 0; z-index: 24; background: rgba(0,0,0,0.58); pointer-events: none; }
@@ -114,17 +129,19 @@ function pageCss() {
114
129
  .delete-button { color: #ff6b57; background: transparent; font-weight: 800; }
115
130
  .save-button { min-height: 48px; border-radius: 10px; background: #287a5a; color: #effbf5; padding: 0 18px; font-weight: 800; box-shadow: 0 10px 30px rgba(54,180,126,0.18); }
116
131
  .helper-text { margin: 10px 0 0; color: #687c72; font-size: 12px; line-height: 1.45; }
117
- .three-scene { position: absolute; inset: 0; perspective: 1200px; overflow: hidden; cursor: grab; touch-action: none; }
118
- .three-scene:active { cursor: grabbing; }
119
- .three-space { position: absolute; left: 50%; top: 52%; width: min(1040px, 78vw); height: min(700px, 64vh); transform-style: preserve-3d; transform: translate(-50%, -50%) rotateX(var(--rx, 14deg)) rotateZ(var(--rz, 0deg)); transition: transform 140ms ease; pointer-events: none; }
120
- .three-link { position: absolute; left: 50%; top: 50%; width: var(--length); height: 1px; transform-origin: 0 0; transform: rotateZ(var(--angle)); background: linear-gradient(90deg, rgba(101,240,173,0.28), rgba(101,240,173,0.05)); opacity: 0.72; }
121
- .three-link.is-active { height: 2px; background: repeating-linear-gradient(90deg, #65f0ad 0 8px, transparent 8px 14px); box-shadow: 0 0 14px rgba(101,240,173,0.68); animation: servicemapLinkFlow 1s linear infinite; }
122
- .three-node { position: absolute; left: 50%; top: 50%; width: 124px; min-height: 72px; transform: translate3d(var(--x), var(--y), var(--z)) translate(-50%, -50%) scale(var(--scale)); border: 0; border-radius: 999px; background: transparent; color: #eaf8f1; cursor: pointer; font: inherit; text-align: center; pointer-events: auto; }
123
- .three-orb { display: grid; place-items: center; width: 30px; height: 30px; margin: 0 auto 6px; border-radius: 999px; background: var(--color); color: #fff; box-shadow: 0 0 28px color-mix(in srgb, var(--color), transparent 46%); font-size: 10px; font-weight: 900; }
124
- .three-node span { display: block; max-width: 124px; margin: 0 auto; color: #e5f1ea; font-size: 10px; font-weight: 800; line-height: 1.12; text-shadow: 0 2px 8px #000; }
125
- .three-node.is-selected .three-orb { outline: 2px solid #65f0ad; outline-offset: 4px; box-shadow: 0 0 34px rgba(101,240,173,0.72); }
126
- .three-core { position: absolute; left: 50%; top: 50%; display: grid; place-items: center; width: 54px; height: 54px; transform: translate(-50%, -50%); border-radius: 999px; background: #f7faf7; color: #101611; font-size: 22px; box-shadow: 0 0 0 9px rgba(101,240,173,0.12), 0 0 46px rgba(101,240,173,0.2); }
127
- .three-hint { position: absolute; left: 50%; bottom: 30px; z-index: 21; transform: translateX(-50%); border: 1px solid rgba(255,255,255,0.1); border-radius: 999px; background: rgba(11,20,15,0.9); color: #789086; padding: 10px 18px; font-size: 12px; }
132
+ .graph-3d { position: absolute; inset: 0; overflow: hidden; }
133
+ .graph-3d::before { content: ''; position: absolute; z-index: 1; inset: 0; pointer-events: none; background: radial-gradient(circle at 50% 50%, transparent 38%, rgba(2,5,4,.46) 100%); }
134
+ .graph-3d canvas { display: block; cursor: grab; }
135
+ .graph-3d canvas:active { cursor: grabbing; }
136
+ .graph-3d__help, .graph-3d__focus { position: absolute; z-index: 3; bottom: 20px; border: 1px solid rgba(255,255,255,.07); background: rgba(9,14,11,.74); color: #718078; font-size: 8px; backdrop-filter: blur(14px); pointer-events: none; }
137
+ .graph-3d__help { left: 50%; transform: translateX(-50%); display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-radius: 999px; white-space: nowrap; }
138
+ .graph-3d__help span { width: 3px; height: 3px; border-radius: 50%; background: #4a5a52; }
139
+ .graph-3d__focus { left: 22px; padding: 8px 10px; border-radius: 8px; color: #91bba7; }
140
+ .graph-3d__zoom { position: absolute; z-index: 4; right: 22px; bottom: 20px; display: flex; flex-direction: column; overflow: hidden; border: 1px solid rgba(255,255,255,.1); border-radius: 10px; background: rgba(9,14,11,.82); box-shadow: 0 10px 28px rgba(0,0,0,.3); backdrop-filter: blur(14px); }
141
+ .graph-3d__zoom button { width: 36px; height: 34px; padding: 0; border: 0; display: grid; place-items: center; background: transparent; color: #aebbb4; cursor: pointer; transition: color .18s ease, background .18s ease; font-size: 20px; }
142
+ .graph-3d__zoom button + button { border-top: 1px solid rgba(255,255,255,.08); }
143
+ .graph-3d__zoom button:hover { color: #fff; background: rgba(255,255,255,.08); }
144
+ .graph-3d__loading { position: absolute; inset: 0; display: grid; place-items: center; color: #75857c; font-size: 9px; }
128
145
  @keyframes servicemapDash { to { stroke-dashoffset: -1.3; } }
129
146
  @keyframes servicemapLinkFlow { to { background-position: 22px 0; } }
130
147
  @media (max-width: 920px) {
@@ -245,7 +262,11 @@ ${htmlBody(graph)}
245
262
  function interactiveTsxPage(graph) {
246
263
  return `'use client';
247
264
 
248
- import { useMemo, useState, type CSSProperties } from 'react';
265
+ import dynamic from 'next/dynamic';
266
+ import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
267
+ import type { ForceGraphMethods, NodeObject } from 'react-force-graph-3d';
268
+ import * as THREE from 'three';
269
+ import SpriteText from 'three-spritetext';
249
270
 
250
271
  const css = ${JSON.stringify(pageCss())};
251
272
  type Evidence = { type?: string; value?: string; confidence?: string };
@@ -253,10 +274,15 @@ type RawService = { id: string; name: string; key?: string; category?: string; c
253
274
  type ServiceNode = RawService & { color: string; x: number; y: number };
254
275
  type DraftDetails = { subtitle: string; use: string; account: string; passwordLocation: string };
255
276
  type ServiceLink = { label: string; url: string };
277
+ type GraphNode3D = { id: string; name: string; category: string; color: string; iconUrl?: string | null; monogram: string; isProduct: boolean; x?: number; y?: number; z?: number };
278
+ type GraphLink3D = { id: string; source: string | GraphNode3D; target: string | GraphNode3D; label: string };
279
+ type AdjustableForce = { strength?: (value: number) => unknown; distance?: (value: number) => unknown };
280
+ type OrbitControlsLike = { target?: THREE.Vector3 };
256
281
 
257
282
  const graph = ${JSON.stringify(graph)} as { generatedAt: string; project?: { name?: string; logoUrl?: string }; services?: RawService[] };
258
283
  const palette = ${JSON.stringify(palette)} as string[];
259
284
  const layout = ${JSON.stringify(layout)} as Array<{ x: number; y: number }>;
285
+ const ForceGraph3D = dynamic(() => import('react-force-graph-3d'), { ssr: false });
260
286
 
261
287
  function categoryLabel(value?: string | null) {
262
288
  return String(value ?? 'service').replace(/-/g, ' ');
@@ -274,68 +300,85 @@ function edgePath(node: ServiceNode) {
274
300
  return \`M \${fromX} \${node.y} H \${midX} V 50 H \${endX}\`;
275
301
  }
276
302
 
277
- function threePosition(index: number) {
278
- const constellation = [
279
- { x: 0, y: -240, z: 46 },
280
- { x: 230, y: -135, z: 24 },
281
- { x: -220, y: -120, z: 10 },
282
- { x: 165, y: 88, z: 16 },
283
- { x: -170, y: 118, z: -10 },
284
- { x: 365, y: 42, z: 34 },
285
- { x: -360, y: 52, z: -18 },
286
- { x: 265, y: 250, z: -32 },
287
- { x: -265, y: -270, z: 28 },
288
- { x: 430, y: -190, z: -20 },
289
- { x: -430, y: 232, z: 22 },
290
- { x: 40, y: 312, z: -38 },
291
- { x: -60, y: -350, z: 18 },
292
- { x: 520, y: 126, z: 8 },
293
- { x: -520, y: -36, z: -26 },
294
- { x: 350, y: -330, z: 30 },
295
- ];
296
- const fallbackAngle = index * 2.399963229728653;
297
- const fallbackRadius = 250 + (index % 5) * 52;
298
- const point = constellation[index] ?? {
299
- x: Math.cos(fallbackAngle) * fallbackRadius,
300
- y: Math.sin(fallbackAngle) * fallbackRadius * 0.72,
301
- z: ((index % 7) - 3) * 14,
302
- };
303
- const angle = Math.atan2(point.y, point.x);
304
- const length = Math.hypot(point.x, point.y);
305
- const scale = 0.74 + Math.max(-36, Math.min(48, point.z)) / 220;
306
- return {
307
- x: point.x,
308
- y: point.y,
309
- z: point.z,
310
- angle: (angle * 180) / Math.PI,
311
- length,
312
- scale,
313
- };
303
+ function primaryEvidence(service: RawService) {
304
+ const evidence = service.evidence?.[0];
305
+ return evidence ? \`\${evidence.type}: \${evidence.value}\` : 'No evidence captured yet.';
314
306
  }
315
307
 
316
- function threeNodeStyle(index: number, color: string): CSSProperties {
317
- const position = threePosition(index);
318
- return {
319
- '--x': \`\${position.x}px\`,
320
- '--y': \`\${position.y}px\`,
321
- '--z': \`\${position.z}px\`,
322
- '--scale': String(position.scale),
323
- '--color': color,
324
- } as CSSProperties;
308
+ function endpointId(endpoint: string | number | GraphNode3D) {
309
+ return typeof endpoint === 'object' ? endpoint.id : String(endpoint);
325
310
  }
326
311
 
327
- function threeLinkStyle(index: number): CSSProperties {
328
- const position = threePosition(index);
329
- return {
330
- '--angle': \`\${position.angle}deg\`,
331
- '--length': \`\${position.length}px\`,
332
- '--z': \`\${position.z}px\`,
333
- } as CSSProperties;
312
+ function graphNode(node: NodeObject) {
313
+ return node as GraphNode3D;
334
314
  }
335
315
 
336
- function primaryEvidence(service: RawService) {
337
- const evidence = service.evidence?.[0];
338
- return evidence ? \`\${evidence.type}: \${evidence.value}\` : 'No evidence captured yet.';
316
+ function graphLink(link: GraphLink3D) {
317
+ return link;
318
+ }
319
+
320
+ function iconObject(node: GraphNode3D, selected: boolean) {
321
+ const group = new THREE.Group();
322
+ const radius = node.isProduct ? 10 : 7;
323
+ const shell = new THREE.Mesh(
324
+ new THREE.SphereGeometry(radius, 32, 32),
325
+ new THREE.MeshStandardMaterial({
326
+ color: new THREE.Color(node.color),
327
+ emissive: new THREE.Color(node.color),
328
+ emissiveIntensity: selected ? 0.65 : 0.23,
329
+ metalness: 0.18,
330
+ roughness: 0.48,
331
+ }),
332
+ );
333
+ group.add(shell);
334
+
335
+ const halo = new THREE.Mesh(
336
+ new THREE.SphereGeometry(radius + (selected ? 2.2 : 1.25), 24, 24),
337
+ new THREE.MeshBasicMaterial({ color: selected ? '#9ff0cb' : node.color, transparent: true, opacity: selected ? 0.18 : 0.07, side: THREE.BackSide }),
338
+ );
339
+ group.add(halo);
340
+
341
+ const fallback = new SpriteText(node.monogram.slice(0, 3));
342
+ fallback.color = '#ffffff';
343
+ fallback.textHeight = node.isProduct ? 4.2 : 3.2;
344
+ fallback.fontSize = 180;
345
+ fallback.fontWeight = '700';
346
+ fallback.renderOrder = 10;
347
+ fallback.material.depthTest = false;
348
+ fallback.material.depthWrite = false;
349
+ group.add(fallback);
350
+
351
+ if (node.iconUrl) {
352
+ new THREE.TextureLoader().load(node.iconUrl, (texture) => {
353
+ texture.colorSpace = THREE.SRGBColorSpace;
354
+ texture.anisotropy = 16;
355
+ texture.minFilter = THREE.LinearMipmapLinearFilter;
356
+ texture.magFilter = THREE.LinearFilter;
357
+ texture.generateMipmaps = true;
358
+ texture.needsUpdate = true;
359
+ const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: texture, transparent: true, depthTest: false, depthWrite: false, alphaTest: 0.015 }));
360
+ const size = node.isProduct ? 12 : 8.5;
361
+ sprite.scale.set(size, size, 1);
362
+ sprite.renderOrder = 10;
363
+ group.add(sprite);
364
+ fallback.visible = false;
365
+ });
366
+ }
367
+
368
+ const label = new SpriteText(node.name);
369
+ label.color = selected ? '#b9f6da' : '#dfeae4';
370
+ label.textHeight = selected ? 5.4 : 4.7;
371
+ label.fontSize = 220;
372
+ label.fontWeight = selected ? '700' : '600';
373
+ label.backgroundColor = 'rgba(5, 8, 7, .72)';
374
+ label.padding = 1.5;
375
+ label.borderRadius = 2;
376
+ label.position.y = -(radius + 6.8);
377
+ label.renderOrder = 11;
378
+ label.material.depthTest = false;
379
+ label.material.depthWrite = false;
380
+ group.add(label);
381
+ return group;
339
382
  }
340
383
 
341
384
  function serviceLinks(service: RawService): ServiceLink[] {
@@ -468,6 +511,170 @@ function DetailsPanel({
468
511
  );
469
512
  }
470
513
 
514
+ function ServiceGraph3D({
515
+ nodes,
516
+ selectedId,
517
+ onSelect,
518
+ }: {
519
+ nodes: ServiceNode[];
520
+ selectedId: string | null;
521
+ onSelect: (id: string | null) => void;
522
+ }) {
523
+ const containerRef = useRef<HTMLDivElement>(null);
524
+ const graphRef = useRef<ForceGraphMethods<GraphNode3D, GraphLink3D> | undefined>(undefined);
525
+ const [size, setSize] = useState({ width: 900, height: 700 });
526
+ const hasFramed = useRef(false);
527
+ const graphData = useMemo(() => ({
528
+ nodes: [{
529
+ id: 'project',
530
+ name: graph.project?.name ?? 'Project',
531
+ category: 'product',
532
+ color: '#8be4b2',
533
+ iconUrl: graph.project?.logoUrl,
534
+ monogram: initials(graph.project?.name ?? 'Project'),
535
+ isProduct: true,
536
+ }, ...nodes.map((node): GraphNode3D => ({
537
+ id: node.id,
538
+ name: node.name,
539
+ category: categoryLabel(node.category),
540
+ color: node.color,
541
+ iconUrl: node.iconUrl,
542
+ monogram: initials(node.name),
543
+ isProduct: node.category === 'product',
544
+ }))],
545
+ links: nodes.map((node): GraphLink3D => ({
546
+ id: \`project-\${node.id}\`,
547
+ source: 'project',
548
+ target: node.id,
549
+ label: node.confidence === 'high' ? 'uses' : 'may use',
550
+ })),
551
+ }), [nodes]);
552
+
553
+ const activeIds = useMemo(() => {
554
+ const ids = new Set<string>();
555
+ if (!selectedId) return ids;
556
+ ids.add(selectedId);
557
+ graphData.links.forEach((link) => {
558
+ const source = endpointId(link.source);
559
+ const target = endpointId(link.target);
560
+ if (source === selectedId) ids.add(target);
561
+ if (target === selectedId) ids.add(source);
562
+ });
563
+ return ids;
564
+ }, [graphData.links, selectedId]);
565
+
566
+ useEffect(() => {
567
+ const element = containerRef.current;
568
+ if (!element) return;
569
+ const observer = new ResizeObserver(([entry]) => {
570
+ setSize({ width: Math.max(1, entry.contentRect.width), height: Math.max(1, entry.contentRect.height) });
571
+ });
572
+ observer.observe(element);
573
+ return () => observer.disconnect();
574
+ }, []);
575
+
576
+ useEffect(() => {
577
+ const graphInstance = graphRef.current;
578
+ if (!graphInstance) return;
579
+ const charge = graphInstance.d3Force('charge') as AdjustableForce | undefined;
580
+ const link = graphInstance.d3Force('link') as AdjustableForce | undefined;
581
+ graphInstance.renderer().setPixelRatio(Math.min(window.devicePixelRatio * 1.5, 3));
582
+ charge?.strength?.(-230);
583
+ link?.distance?.(92);
584
+ graphInstance.d3ReheatSimulation();
585
+ hasFramed.current = false;
586
+ }, [graphData.nodes.length, graphData.links.length]);
587
+
588
+ const focusNode = useCallback((node: NodeObject) => {
589
+ const serviceNode = graphNode(node);
590
+ onSelect(serviceNode.id);
591
+ if (serviceNode.x === undefined || serviceNode.y === undefined || serviceNode.z === undefined) return;
592
+ const distance = Math.hypot(serviceNode.x, serviceNode.y, serviceNode.z) || 1;
593
+ const ratio = 1 + 105 / distance;
594
+ graphRef.current?.cameraPosition(
595
+ { x: serviceNode.x * ratio, y: serviceNode.y * ratio, z: serviceNode.z * ratio },
596
+ { x: serviceNode.x, y: serviceNode.y, z: serviceNode.z },
597
+ 850,
598
+ );
599
+ }, [onSelect]);
600
+
601
+ const zoom = useCallback((factor: number) => {
602
+ const graphInstance = graphRef.current;
603
+ if (!graphInstance) return;
604
+ const camera = graphInstance.camera();
605
+ const controls = graphInstance.controls() as OrbitControlsLike;
606
+ const target = controls.target?.clone() ?? new THREE.Vector3();
607
+ const offset = camera.position.clone().sub(target);
608
+ const currentDistance = offset.length();
609
+ if (currentDistance === 0) return;
610
+ const nextDistance = THREE.MathUtils.clamp(currentDistance * factor, 35, 1200);
611
+ const nextPosition = target.clone().add(offset.multiplyScalar(nextDistance / currentDistance));
612
+ graphInstance.cameraPosition(nextPosition, target, 240);
613
+ }, []);
614
+
615
+ return (
616
+ <div className="graph-3d" ref={containerRef}>
617
+ <ForceGraph3D
618
+ ref={graphRef as never}
619
+ width={size.width}
620
+ height={size.height}
621
+ graphData={graphData}
622
+ backgroundColor="rgba(0,0,0,0)"
623
+ showNavInfo={false}
624
+ nodeThreeObject={(node) => {
625
+ const serviceNode = graphNode(node);
626
+ return iconObject(serviceNode, serviceNode.id === selectedId);
627
+ }}
628
+ nodeLabel={(node) => {
629
+ const serviceNode = graphNode(node);
630
+ return \`\${serviceNode.name} · \${serviceNode.category}\`;
631
+ }}
632
+ linkLabel={(link) => graphLink(link as GraphLink3D).label}
633
+ linkColor={(link) => {
634
+ const serviceLink = graphLink(link as GraphLink3D);
635
+ if (!selectedId) return '#415149';
636
+ return endpointId(serviceLink.source) === selectedId || endpointId(serviceLink.target) === selectedId ? '#7ee2b8' : '#17211c';
637
+ }}
638
+ linkWidth={(link) => {
639
+ const serviceLink = graphLink(link as GraphLink3D);
640
+ return endpointId(serviceLink.source) === selectedId || endpointId(serviceLink.target) === selectedId ? 2.2 : selectedId ? 0.35 : 0.8;
641
+ }}
642
+ linkOpacity={0.8}
643
+ linkDirectionalArrowLength={3.5}
644
+ linkDirectionalArrowRelPos={0.8}
645
+ linkDirectionalArrowColor={(link) => {
646
+ const serviceLink = graphLink(link as GraphLink3D);
647
+ return endpointId(serviceLink.source) === selectedId || endpointId(serviceLink.target) === selectedId ? '#9ff0cb' : '#506158';
648
+ }}
649
+ linkDirectionalParticles={(link) => {
650
+ const serviceLink = graphLink(link as GraphLink3D);
651
+ return endpointId(serviceLink.source) === selectedId || endpointId(serviceLink.target) === selectedId ? 3 : 0;
652
+ }}
653
+ linkDirectionalParticleWidth={1.8}
654
+ linkDirectionalParticleSpeed={0.006}
655
+ linkDirectionalParticleColor={() => '#b9f6da'}
656
+ d3AlphaDecay={0.035}
657
+ d3VelocityDecay={0.24}
658
+ warmupTicks={50}
659
+ cooldownTicks={160}
660
+ onNodeClick={focusNode}
661
+ onBackgroundClick={() => onSelect(null)}
662
+ onEngineStop={() => {
663
+ if (hasFramed.current) return;
664
+ hasFramed.current = true;
665
+ graphRef.current?.zoomToFit(700, 72);
666
+ }}
667
+ />
668
+ <div className="graph-3d__zoom" aria-label="3D zoom controls">
669
+ <button type="button" onClick={() => zoom(0.78)} title="Zoom in" aria-label="Zoom in">+</button>
670
+ <button type="button" onClick={() => zoom(1.28)} title="Zoom out" aria-label="Zoom out">−</button>
671
+ </div>
672
+ <div className="graph-3d__help" aria-hidden="true">Drag to rotate <span /> Scroll to zoom <span /> Click a service</div>
673
+ {selectedId && <div className="graph-3d__focus" aria-hidden="true">{Math.max(activeIds.size - 1, 0)} direct connections highlighted</div>}
674
+ </div>
675
+ );
676
+ }
677
+
471
678
  export default function InternalServiceMapPage() {
472
679
  const nodes = useMemo<ServiceNode[]>(() => (graph.services ?? []).slice(0, layout.length).map((service, index) => ({
473
680
  ...service,
@@ -475,7 +682,7 @@ export default function InternalServiceMapPage() {
475
682
  color: palette[index % palette.length],
476
683
  })), []);
477
684
  const [mode, setMode] = useState<'2d' | '3d'>('2d');
478
- const [rotation, setRotation] = useState({ x: 14, z: 0 });
685
+ const [zoom, setZoom] = useState(1);
479
686
  const [selectedId, setSelectedId] = useState<string | null>(null);
480
687
  const [drafts, setDrafts] = useState<Record<string, DraftDetails>>(() => Object.fromEntries(nodes.map((service) => [
481
688
  service.id,
@@ -487,6 +694,9 @@ export default function InternalServiceMapPage() {
487
694
  },
488
695
  ])));
489
696
  const selected = nodes.find((service) => service.id === selectedId) ?? null;
697
+ const zoomIn = () => setZoom((current) => Math.min(1.45, Number((current + 0.12).toFixed(2))));
698
+ const zoomOut = () => setZoom((current) => Math.max(0.7, Number((current - 0.12).toFixed(2))));
699
+ const resetZoom = () => setZoom(1);
490
700
 
491
701
  return (
492
702
  <main className="servicemap-shell">
@@ -515,38 +725,40 @@ export default function InternalServiceMapPage() {
515
725
  </div>
516
726
  {mode === '2d' ? (
517
727
  <>
518
- <svg className="edges" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
728
+ <div className="map-plane" style={{ '--zoom': zoom } as CSSProperties}>
729
+ <svg className="edges" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
730
+ {nodes.map((service) => (
731
+ <path
732
+ key={service.id}
733
+ className={\`edge-line \${selectedId === service.id ? 'is-active' : selectedId ? 'is-muted' : ''}\`}
734
+ d={edgePath(service)}
735
+ fill="none"
736
+ stroke={selectedId === service.id ? '#65f0ad' : 'rgba(103, 130, 118, 0.48)'}
737
+ strokeWidth={selectedId === service.id ? '0.28' : '0.16'}
738
+ strokeDasharray={selectedId === service.id ? '0.72 0.58' : undefined}
739
+ strokeLinecap={selectedId === service.id ? 'round' : undefined}
740
+ vectorEffect="non-scaling-stroke"
741
+ />
742
+ ))}
743
+ </svg>
744
+ <div className="hub"><div className="hub-inner"><ProjectMark /></div></div>
519
745
  {nodes.map((service) => (
520
- <path
746
+ <button
747
+ type="button"
521
748
  key={service.id}
522
- className={\`edge-line \${selectedId === service.id ? 'is-active' : selectedId ? 'is-muted' : ''}\`}
523
- d={edgePath(service)}
524
- fill="none"
525
- stroke={selectedId === service.id ? '#65f0ad' : 'rgba(103, 130, 118, 0.48)'}
526
- strokeWidth={selectedId === service.id ? '0.28' : '0.16'}
527
- strokeDasharray={selectedId === service.id ? '0.72 0.58' : undefined}
528
- strokeLinecap={selectedId === service.id ? 'round' : undefined}
529
- vectorEffect="non-scaling-stroke"
530
- />
749
+ className={\`service-node \${selectedId === service.id ? 'is-selected' : ''}\`}
750
+ style={{ left: \`\${service.x}%\`, top: \`\${service.y}%\` }}
751
+ onClick={() => setSelectedId(service.id)}
752
+ >
753
+ <ServiceLogo service={service} />
754
+ <div className="service-copy">
755
+ <strong>{service.name}</strong>
756
+ <span>{categoryLabel(service.category)}</span>
757
+ </div>
758
+ <i className={\`status-dot status-\${service.confidence}\`} />
759
+ </button>
531
760
  ))}
532
- </svg>
533
- <div className="hub"><div className="hub-inner"><ProjectMark /></div></div>
534
- {nodes.map((service) => (
535
- <button
536
- type="button"
537
- key={service.id}
538
- className={\`service-node \${selectedId === service.id ? 'is-selected' : ''}\`}
539
- style={{ left: \`\${service.x}%\`, top: \`\${service.y}%\` }}
540
- onClick={() => setSelectedId(service.id)}
541
- >
542
- <ServiceLogo service={service} />
543
- <div className="service-copy">
544
- <strong>{service.name}</strong>
545
- <span>{categoryLabel(service.category)}</span>
546
- </div>
547
- <i className={\`status-dot status-\${service.confidence}\`} />
548
- </button>
549
- ))}
761
+ </div>
550
762
  <div className="legend">
551
763
  <span><i style={{ background: '#4a7cff' }} />Cloud</span>
552
764
  <span><i style={{ background: '#8be4b2' }} />Product</span>
@@ -554,39 +766,13 @@ export default function InternalServiceMapPage() {
554
766
  </div>
555
767
  </>
556
768
  ) : (
557
- <div className="three-scene" aria-label="3D service graph">
558
- <div className="three-space" style={{ '--rx': \`\${rotation.x}deg\`, '--rz': \`\${rotation.z}deg\` } as CSSProperties}>
559
- {nodes.map((service, index) => (
560
- <span
561
- key={\`\${service.id}-link\`}
562
- className={\`three-link \${selectedId === service.id ? 'is-active' : ''}\`}
563
- style={threeLinkStyle(index)}
564
- />
565
- ))}
566
- <div className="three-core"><ProjectMark /></div>
567
- {nodes.map((service, index) => (
568
- <button
569
- type="button"
570
- key={service.id}
571
- className={\`three-node \${selectedId === service.id ? 'is-selected' : ''}\`}
572
- style={threeNodeStyle(index, service.color)}
573
- onPointerMove={(event) => {
574
- if (event.buttons !== 1) return;
575
- setRotation((current) => ({
576
- x: Math.max(-8, Math.min(34, current.x - event.movementY * 0.18)),
577
- z: current.z + event.movementX * 0.16,
578
- }));
579
- }}
580
- onClick={() => setSelectedId(service.id)}
581
- >
582
- <span className="three-orb">{initials(service.name)}</span>
583
- <span>{service.name}</span>
584
- </button>
585
- ))}
586
- </div>
587
- <div className="three-hint">Drag a service to rotate / Click for details</div>
588
- </div>
769
+ <ServiceGraph3D nodes={nodes} selectedId={selectedId} onSelect={setSelectedId} />
589
770
  )}
771
+ <div className="zoom-controls" aria-label="Map zoom controls">
772
+ <button type="button" className="zoom-button" onClick={zoomIn} aria-label="Zoom in">+</button>
773
+ <button type="button" className="zoom-button" onClick={zoomOut} aria-label="Zoom out">−</button>
774
+ <button type="button" className="zoom-button" onClick={resetZoom} aria-label="Reset zoom"><span className="fit-icon" aria-hidden="true" /></button>
775
+ </div>
590
776
  <footer className="scan-foot">
591
777
  <div>Generated {new Date(graph.generatedAt).toLocaleString()}</div>
592
778
  <div>{nodes.length} services detected</div>
@@ -675,6 +861,54 @@ async function prepareProjectLogo(graph, outputRoot) {
675
861
  graph.project.logoUrl = `/internalservicemap-assets/project-logo${extension}`;
676
862
  }
677
863
 
864
+ function run(command, args, options = {}) {
865
+ return new Promise((resolvePromise, reject) => {
866
+ const child = spawn(command, args, { stdio: 'inherit', ...options });
867
+ child.on('error', reject);
868
+ child.on('exit', (code) => {
869
+ if (code === 0) resolvePromise();
870
+ else reject(new Error(`${command} ${args.join(' ')} exited with code ${code}`));
871
+ });
872
+ });
873
+ }
874
+
875
+ async function ensureInteractivePageDependencies(root, routeKind) {
876
+ if (routeKind === 'static' || routeKind === 'astro') return;
877
+ const packageJsonPath = join(root, 'package.json');
878
+ const packageJson = await readJsonOrNull(packageJsonPath);
879
+ if (!packageJson) return;
880
+
881
+ const dependencies = {
882
+ 'react-force-graph-3d': '^1.29.1',
883
+ three: '^0.185.1',
884
+ 'three-spritetext': '^1.10.0',
885
+ };
886
+ const devDependencies = {
887
+ '@types/three': '^0.185.4',
888
+ };
889
+ let changed = false;
890
+
891
+ packageJson.dependencies ??= {};
892
+ for (const [name, version] of Object.entries(dependencies)) {
893
+ if (packageJson.dependencies[name] || packageJson.devDependencies?.[name]) continue;
894
+ packageJson.dependencies[name] = version;
895
+ changed = true;
896
+ }
897
+
898
+ packageJson.devDependencies ??= {};
899
+ for (const [name, version] of Object.entries(devDependencies)) {
900
+ if (packageJson.dependencies?.[name] || packageJson.devDependencies[name]) continue;
901
+ packageJson.devDependencies[name] = version;
902
+ changed = true;
903
+ }
904
+
905
+ if (!changed) return;
906
+ await writeFile(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`);
907
+ if (existsSync(join(root, 'package-lock.json'))) {
908
+ await run('npm', ['install'], { cwd: root });
909
+ }
910
+ }
911
+
678
912
  async function main() {
679
913
  const options = parseArgs();
680
914
  const graph = await readJson(options.input);
@@ -684,6 +918,7 @@ async function main() {
684
918
 
685
919
  await mkdir(dirname(route.path), { recursive: true });
686
920
  await writeFile(route.path, contents);
921
+ await ensureInteractivePageDependencies(options.root, route.kind);
687
922
  console.log(`Wrote /internalservicemap page to ${route.path}`);
688
923
  }
689
924
 
@@ -194,6 +194,36 @@ function buildGeneratedRules(database) {
194
194
  }));
195
195
  }
196
196
 
197
+ function normalizeLookup(value) {
198
+ return String(value ?? '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '');
199
+ }
200
+
201
+ function buildDatabaseLookup(database) {
202
+ const lookup = new Map();
203
+ for (const entry of database) {
204
+ const values = [entry.key, entry.name, ...(entry.aliases ?? [])];
205
+ for (const value of values) {
206
+ const normalized = normalizeLookup(value);
207
+ if (normalized && !lookup.has(normalized)) lookup.set(normalized, entry);
208
+ }
209
+ }
210
+ return lookup;
211
+ }
212
+
213
+ function enrichDetectedServices(services, database) {
214
+ const lookup = buildDatabaseLookup(database);
215
+ return services.map((service) => {
216
+ const databaseEntry = lookup.get(normalizeLookup(service.key)) ?? lookup.get(normalizeLookup(service.name));
217
+ if (!databaseEntry) return service;
218
+ return {
219
+ ...service,
220
+ key: service.key ?? databaseEntry.key,
221
+ category: service.category ?? databaseEntry.category,
222
+ iconUrl: service.iconUrl ?? databaseEntry.iconUrl ?? null,
223
+ };
224
+ });
225
+ }
226
+
197
227
  function matchServices({ dependencies, imports, envNames, hosts, configFiles, database }) {
198
228
  const serviceMap = new Map();
199
229
  const databaseByKey = new Map(database.map((entry) => [entry.key, entry]));
@@ -226,7 +256,7 @@ function matchServices({ dependencies, imports, envNames, hosts, configFiles, da
226
256
  if (configFiles.includes('vercel.json')) addEvidence(serviceMap, 'vercel', enrichedManualRules.find((rule) => rule.key === 'vercel'), { type: 'config-file', value: 'vercel.json', confidence: 'high' });
227
257
  if (configFiles.includes('wrangler.toml')) addEvidence(serviceMap, 'cloudflare', enrichedManualRules.find((rule) => rule.key === 'cloudflare'), { type: 'config-file', value: 'wrangler.toml', confidence: 'high' });
228
258
 
229
- return [...serviceMap.values()].map((service) => ({
259
+ return enrichDetectedServices([...serviceMap.values()], database).map((service) => ({
230
260
  ...service,
231
261
  evidence: service.evidence.slice(0, 8),
232
262
  })).sort((a, b) => a.name.localeCompare(b.name));