servicemap 0.1.31 → 0.1.38

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.38",
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';
@@ -60,22 +69,29 @@ function pageCss() {
60
69
  .brand h1 { margin: 0; font-size: 20px; line-height: 1; letter-spacing: 0; }
61
70
  .brand p, .top-meta { margin: 6px 0 0; color: #8aa097; font-size: 14px; }
62
71
  .top-actions { display: flex; align-items: center; gap: 12px; color: #8da59a; font-size: 14px; white-space: nowrap; }
72
+ .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); }
63
73
  .mode-toggle { display: inline-flex; gap: 4px; padding: 4px; border-radius: 10px; background: #0b130f; }
64
74
  .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
75
  .mode-button.is-active { background: #276f53; color: #c9f9df; box-shadow: 0 0 18px rgba(101,240,173,0.16); }
66
76
  .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; }
77
+ .map.is-panning { cursor: grabbing; user-select: none; }
78
+ .map-plane { position: absolute; inset: 0; z-index: 20; transform: translate3d(var(--pan-x, 0px), var(--pan-y, 0px), 0) scale(var(--zoom, 1)); transform-origin: 50% 50%; transition: transform 160ms ease; }
79
+ .map.is-panning .map-plane { transition: none; }
67
80
  .intro { position: absolute; left: 32px; top: 32px; z-index: 20; }
68
81
  .intro strong { display: block; color: #779286; font-size: 12px; letter-spacing: 0.22em; text-transform: uppercase; }
69
82
  .intro span { display: block; margin-top: 8px; color: #53665d; font-size: 14px; }
70
83
  .edges { position: absolute; inset: 0; z-index: 26; width: 100%; height: 100%; pointer-events: none; }
71
84
  .edge-line { transition: opacity 180ms ease, stroke 180ms ease; }
72
- .edge-line.is-active { stroke: #65f0ad !important; stroke-width: 0.28; stroke-linecap: round; stroke-dasharray: 0.72 0.58; animation: servicemapDash 1.1s linear infinite; filter: drop-shadow(0 0 6px rgba(101,240,173,0.72)); opacity: 1; }
73
- .edge-line.is-muted { opacity: 0.24; }
85
+ .edge-line.is-active { stroke: #65f0ad !important; stroke-width: 3px; stroke-linecap: round; stroke-dasharray: 10 8; animation: servicemapDash .74s linear infinite; filter: drop-shadow(0 0 9px rgba(101,240,173,0.9)) drop-shadow(0 0 18px rgba(101,240,173,0.45)); opacity: 1; }
86
+ .edge-line.is-muted { opacity: 0.12; }
74
87
  .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); }
75
88
  .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); }
76
89
  .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); }
77
90
  button.service-node { cursor: pointer; color: inherit; font: inherit; text-align: left; }
78
91
  .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); }
92
+ .map.has-selection .service-node { opacity: 0.16; }
93
+ .map.has-selection .service-node.is-related { opacity: 0.48; }
94
+ .map.has-selection .service-node.is-selected { opacity: 1; }
79
95
  .service-node.is-selected { z-index: 27; }
80
96
  .logo { width: 48px; height: 48px; flex: 0 0 auto; border-radius: 8px; display: grid; place-items: center; overflow: hidden; color: #fff; font-weight: 700; }
81
97
  .logo img { width: 28px; height: 28px; object-fit: contain; filter: invert(1); }
@@ -84,12 +100,17 @@ function pageCss() {
84
100
  .service-copy span { display: block; margin-top: 5px; color: #7f9188; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
85
101
  .status-dot { margin-left: auto; width: 8px; height: 8px; flex: 0 0 auto; border-radius: 999px; box-shadow: 0 0 14px currentColor; }
86
102
  .status-high { color: #65f0ad; background: #65f0ad; }
87
- .status-medium, .status-low { color: #f1c85b; background: #f1c85b; }
103
+ .status-medium, .status-low, .status-manual { color: #f1c85b; background: #f1c85b; }
88
104
  .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
105
  .legend i { display: inline-block; width: 8px; height: 8px; margin-right: 8px; border-radius: 999px; }
106
+ .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); }
107
+ .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; }
108
+ .zoom-button:last-child { border-bottom: 0; font-size: 18px; }
109
+ .zoom-button:hover { background: rgba(101,240,173,0.1); color: #d8f8e8; }
110
+ .fit-icon { display: inline-block; width: 15px; height: 15px; border: 2px solid currentColor; border-radius: 3px; }
90
111
  .scan-foot { position: absolute; right: 32px; bottom: 32px; z-index: 20; color: #53665d; text-align: right; font-size: 12px; line-height: 1.6; }
91
112
  .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
- .drawer-scrim { position: absolute; inset: 0; z-index: 24; background: rgba(0,0,0,0.58); pointer-events: none; }
113
+ .drawer-scrim { position: absolute; inset: 0; z-index: 18; background: rgba(0,0,0,0.58); pointer-events: none; }
93
114
  .details-panel { position: absolute; right: 22px; top: 64px; bottom: 26px; z-index: 30; width: min(492px, calc(100vw - 44px)); overflow: hidden; border: 1px solid #294034; border-radius: 24px; background: linear-gradient(180deg, rgba(21,31,25,0.98), rgba(8,17,12,0.98)); box-shadow: 0 24px 80px rgba(0,0,0,0.52); }
94
115
  .details-head { display: flex; align-items: center; gap: 16px; padding: 26px 26px 22px; border-bottom: 1px solid rgba(255,255,255,0.08); }
95
116
  .details-title { min-width: 0; flex: 1; }
@@ -114,17 +135,25 @@ function pageCss() {
114
135
  .delete-button { color: #ff6b57; background: transparent; font-weight: 800; }
115
136
  .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
137
  .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; }
138
+ .modal-card { position: absolute; left: 50%; top: 50%; z-index: 31; width: min(440px, calc(100vw - 44px)); transform: translate(-50%, -50%); border: 1px solid #294034; border-radius: 20px; background: linear-gradient(180deg, rgba(21,31,25,0.99), rgba(8,17,12,0.99)); box-shadow: 0 24px 80px rgba(0,0,0,0.56); overflow: hidden; }
139
+ .modal-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 22px 24px; border-bottom: 1px solid rgba(255,255,255,0.08); }
140
+ .modal-head h2 { margin: 0; color: #f4fbf6; font-size: 20px; letter-spacing: 0; }
141
+ .modal-body { padding: 22px 24px 24px; }
142
+ .modal-actions { display: flex; justify-content: flex-end; gap: 12px; margin-top: 22px; }
143
+ .secondary-button { min-height: 44px; border: 1px solid #2a3c33; border-radius: 10px; background: rgba(255,255,255,0.04); color: #c8d8d0; padding: 0 16px; cursor: pointer; font: inherit; font-weight: 800; }
144
+ .graph-3d { position: absolute; inset: 0; overflow: hidden; }
145
+ .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%); }
146
+ .graph-3d canvas { display: block; cursor: grab; }
147
+ .graph-3d canvas:active { cursor: grabbing; }
148
+ .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; }
149
+ .graph-3d__help { left: 50%; transform: translateX(-50%); display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-radius: 999px; white-space: nowrap; }
150
+ .graph-3d__help span { width: 3px; height: 3px; border-radius: 50%; background: #4a5a52; }
151
+ .graph-3d__focus { left: 22px; padding: 8px 10px; border-radius: 8px; color: #91bba7; }
152
+ .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); }
153
+ .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; }
154
+ .graph-3d__zoom button + button { border-top: 1px solid rgba(255,255,255,.08); }
155
+ .graph-3d__zoom button:hover { color: #fff; background: rgba(255,255,255,.08); }
156
+ .graph-3d__loading { position: absolute; inset: 0; display: grid; place-items: center; color: #75857c; font-size: 9px; }
128
157
  @keyframes servicemapDash { to { stroke-dashoffset: -1.3; } }
129
158
  @keyframes servicemapLinkFlow { to { background-position: 22px 0; } }
130
159
  @media (max-width: 920px) {
@@ -245,18 +274,28 @@ ${htmlBody(graph)}
245
274
  function interactiveTsxPage(graph) {
246
275
  return `'use client';
247
276
 
248
- import { useMemo, useState, type CSSProperties } from 'react';
277
+ import dynamic from 'next/dynamic';
278
+ import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type PointerEvent } from 'react';
279
+ import type { ForceGraphMethods, NodeObject } from 'react-force-graph-3d';
280
+ import * as THREE from 'three';
281
+ import SpriteText from 'three-spritetext';
249
282
 
250
283
  const css = ${JSON.stringify(pageCss())};
251
284
  type Evidence = { type?: string; value?: string; confidence?: string };
252
- type RawService = { id: string; name: string; key?: string; category?: string; confidence?: string; evidence?: Evidence[]; iconUrl?: string | null };
285
+ type RawService = { id: string; name: string; key?: string; category?: string; confidence?: string; evidence?: Evidence[]; iconUrl?: string | null; parentId?: string | null };
253
286
  type ServiceNode = RawService & { color: string; x: number; y: number };
254
287
  type DraftDetails = { subtitle: string; use: string; account: string; passwordLocation: string };
288
+ type NewServiceDraft = { name: string; category: string; parentId: string };
255
289
  type ServiceLink = { label: string; url: string };
290
+ type GraphNode3D = { id: string; name: string; category: string; color: string; iconUrl?: string | null; monogram: string; isProduct: boolean; x?: number; y?: number; z?: number };
291
+ type GraphLink3D = { id: string; source: string | GraphNode3D; target: string | GraphNode3D; label: string };
292
+ type AdjustableForce = { strength?: (value: number) => unknown; distance?: (value: number) => unknown };
293
+ type OrbitControlsLike = { target?: THREE.Vector3 };
256
294
 
257
295
  const graph = ${JSON.stringify(graph)} as { generatedAt: string; project?: { name?: string; logoUrl?: string }; services?: RawService[] };
258
296
  const palette = ${JSON.stringify(palette)} as string[];
259
297
  const layout = ${JSON.stringify(layout)} as Array<{ x: number; y: number }>;
298
+ const ForceGraph3D = dynamic(() => import('react-force-graph-3d'), { ssr: false });
260
299
 
261
300
  function categoryLabel(value?: string | null) {
262
301
  return String(value ?? 'service').replace(/-/g, ' ');
@@ -267,75 +306,116 @@ function initials(name: string) {
267
306
  }
268
307
 
269
308
  function edgePath(node: ServiceNode) {
309
+ return edgePathBetween({ x: 50, y: 50 }, node);
310
+ }
311
+
312
+ function edgePathBetween(parent: { x: number; y: number }, node: ServiceNode) {
270
313
  const isLeft = node.x < 50;
271
- const fromX = isLeft ? node.x + 8 : node.x - 8;
272
- const endX = isLeft ? 47 : 53;
273
- const midX = isLeft ? Math.max(fromX + 7, 42) : Math.min(fromX - 7, 58);
274
- return \`M \${fromX} \${node.y} H \${midX} V 50 H \${endX}\`;
275
- }
276
-
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
- };
314
+ const fromX = parent.x;
315
+ const fromY = parent.y;
316
+ const endX = isLeft ? node.x + 8 : node.x - 8;
317
+ const midX = (fromX + endX) / 2;
318
+ return \`M \${fromX} \${fromY} H \${midX} V \${node.y} H \${endX}\`;
314
319
  }
315
320
 
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;
321
+ function primaryEvidence(service: RawService) {
322
+ const evidence = service.evidence?.[0];
323
+ return evidence ? \`\${evidence.type}: \${evidence.value}\` : 'No evidence captured yet.';
325
324
  }
326
325
 
327
- function threeLinkStyle(index: number): CSSProperties {
328
- const position = threePosition(index);
326
+ function endpointId(endpoint: string | number | GraphNode3D) {
327
+ return typeof endpoint === 'object' ? endpoint.id : String(endpoint);
328
+ }
329
+
330
+ function graphNode(node: NodeObject) {
331
+ return node as GraphNode3D;
332
+ }
333
+
334
+ function graphLink(link: GraphLink3D) {
335
+ return link;
336
+ }
337
+
338
+ function slugify(value: string) {
339
+ return value.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'service';
340
+ }
341
+
342
+ function servicePosition(index: number) {
343
+ const point = layout[index];
344
+ if (point) return point;
345
+ const angle = (index - layout.length) * 2.399963229728653;
346
+ const radius = 34 + ((index - layout.length) % 4) * 4;
329
347
  return {
330
- '--angle': \`\${position.angle}deg\`,
331
- '--length': \`\${position.length}px\`,
332
- '--z': \`\${position.z}px\`,
333
- } as CSSProperties;
348
+ x: 50 + Math.cos(angle) * radius,
349
+ y: 50 + Math.sin(angle) * radius * 0.72,
350
+ };
334
351
  }
335
352
 
336
- function primaryEvidence(service: RawService) {
337
- const evidence = service.evidence?.[0];
338
- return evidence ? \`\${evidence.type}: \${evidence.value}\` : 'No evidence captured yet.';
353
+ function relationshipLabel(parentId?: string | null) {
354
+ return parentId ? 'depends on' : 'uses';
355
+ }
356
+
357
+ function iconObject(node: GraphNode3D, selected: boolean) {
358
+ const group = new THREE.Group();
359
+ const radius = node.isProduct ? 10 : 7;
360
+ const shell = new THREE.Mesh(
361
+ new THREE.SphereGeometry(radius, 32, 32),
362
+ new THREE.MeshStandardMaterial({
363
+ color: new THREE.Color(node.color),
364
+ emissive: new THREE.Color(node.color),
365
+ emissiveIntensity: selected ? 0.65 : 0.23,
366
+ metalness: 0.18,
367
+ roughness: 0.48,
368
+ }),
369
+ );
370
+ group.add(shell);
371
+
372
+ const halo = new THREE.Mesh(
373
+ new THREE.SphereGeometry(radius + (selected ? 2.2 : 1.25), 24, 24),
374
+ new THREE.MeshBasicMaterial({ color: selected ? '#9ff0cb' : node.color, transparent: true, opacity: selected ? 0.18 : 0.07, side: THREE.BackSide }),
375
+ );
376
+ group.add(halo);
377
+
378
+ const fallback = new SpriteText(node.monogram.slice(0, 3));
379
+ fallback.color = '#ffffff';
380
+ fallback.textHeight = node.isProduct ? 4.2 : 3.2;
381
+ fallback.fontSize = 180;
382
+ fallback.fontWeight = '700';
383
+ fallback.renderOrder = 10;
384
+ fallback.material.depthTest = false;
385
+ fallback.material.depthWrite = false;
386
+ group.add(fallback);
387
+
388
+ if (node.iconUrl) {
389
+ new THREE.TextureLoader().load(node.iconUrl, (texture) => {
390
+ texture.colorSpace = THREE.SRGBColorSpace;
391
+ texture.anisotropy = 16;
392
+ texture.minFilter = THREE.LinearMipmapLinearFilter;
393
+ texture.magFilter = THREE.LinearFilter;
394
+ texture.generateMipmaps = true;
395
+ texture.needsUpdate = true;
396
+ const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: texture, transparent: true, depthTest: false, depthWrite: false, alphaTest: 0.015 }));
397
+ const size = node.isProduct ? 12 : 8.5;
398
+ sprite.scale.set(size, size, 1);
399
+ sprite.renderOrder = 10;
400
+ group.add(sprite);
401
+ fallback.visible = false;
402
+ });
403
+ }
404
+
405
+ const label = new SpriteText(node.name);
406
+ label.color = selected ? '#b9f6da' : '#dfeae4';
407
+ label.textHeight = selected ? 4.1 : 3.2;
408
+ label.fontSize = 220;
409
+ label.fontWeight = selected ? '700' : '600';
410
+ label.backgroundColor = 'rgba(5, 8, 7, .72)';
411
+ label.padding = 1.5;
412
+ label.borderRadius = 2;
413
+ label.position.y = -(radius + 6.8);
414
+ label.renderOrder = 11;
415
+ label.material.depthTest = false;
416
+ label.material.depthWrite = false;
417
+ group.add(label);
418
+ return group;
339
419
  }
340
420
 
341
421
  function serviceLinks(service: RawService): ServiceLink[] {
@@ -468,15 +548,257 @@ function DetailsPanel({
468
548
  );
469
549
  }
470
550
 
551
+ function AddServiceModal({
552
+ draft,
553
+ nodes,
554
+ onChange,
555
+ onClose,
556
+ onSubmit,
557
+ }: {
558
+ draft: NewServiceDraft;
559
+ nodes: ServiceNode[];
560
+ onChange: (draft: NewServiceDraft) => void;
561
+ onClose: () => void;
562
+ onSubmit: () => void;
563
+ }) {
564
+ return (
565
+ <>
566
+ <div className="drawer-scrim" />
567
+ <section className="modal-card" aria-label="Add service">
568
+ <div className="modal-head">
569
+ <h2>Add service</h2>
570
+ <button type="button" className="close-button" onClick={onClose} aria-label="Close add service">×</button>
571
+ </div>
572
+ <div className="modal-body">
573
+ <label className="field-label" htmlFor="new-service-name">Service name</label>
574
+ <input
575
+ id="new-service-name"
576
+ className="details-input"
577
+ value={draft.name}
578
+ onChange={(event) => onChange({ ...draft, name: event.target.value })}
579
+ placeholder="Stripe, Vercel, Google Cloud"
580
+ autoFocus
581
+ />
582
+
583
+ <label className="field-label" htmlFor="new-service-category">Use</label>
584
+ <input
585
+ id="new-service-category"
586
+ className="details-input"
587
+ value={draft.category}
588
+ onChange={(event) => onChange({ ...draft, category: event.target.value })}
589
+ placeholder="billing, hosting, analytics"
590
+ />
591
+
592
+ <label className="field-label" htmlFor="new-service-parent">Parent node</label>
593
+ <select
594
+ id="new-service-parent"
595
+ className="details-input"
596
+ value={draft.parentId}
597
+ onChange={(event) => onChange({ ...draft, parentId: event.target.value })}
598
+ >
599
+ <option value="product">{graph.project?.name ?? 'Product'}</option>
600
+ {nodes.map((node) => (
601
+ <option key={node.id} value={node.id}>{node.name}</option>
602
+ ))}
603
+ </select>
604
+
605
+ <div className="modal-actions">
606
+ <button type="button" className="secondary-button" onClick={onClose}>Cancel</button>
607
+ <button type="button" className="save-button" onClick={onSubmit}>Add service</button>
608
+ </div>
609
+ </div>
610
+ </section>
611
+ </>
612
+ );
613
+ }
614
+
615
+ function ServiceGraph3D({
616
+ nodes,
617
+ selectedId,
618
+ onSelect,
619
+ }: {
620
+ nodes: ServiceNode[];
621
+ selectedId: string | null;
622
+ onSelect: (id: string | null) => void;
623
+ }) {
624
+ const containerRef = useRef<HTMLDivElement>(null);
625
+ const graphRef = useRef<ForceGraphMethods<GraphNode3D, GraphLink3D> | undefined>(undefined);
626
+ const [size, setSize] = useState({ width: 900, height: 700 });
627
+ const hasFramed = useRef(false);
628
+ const hasConfiguredForces = useRef(false);
629
+ const graphData = useMemo(() => ({
630
+ nodes: [{
631
+ id: 'project',
632
+ name: graph.project?.name ?? 'Project',
633
+ category: 'product',
634
+ color: '#8be4b2',
635
+ iconUrl: graph.project?.logoUrl,
636
+ monogram: initials(graph.project?.name ?? 'Project'),
637
+ isProduct: true,
638
+ }, ...nodes.map((node): GraphNode3D => ({
639
+ id: node.id,
640
+ name: node.name,
641
+ category: categoryLabel(node.category),
642
+ color: node.color,
643
+ iconUrl: node.iconUrl,
644
+ monogram: initials(node.name),
645
+ isProduct: node.category === 'product',
646
+ }))],
647
+ links: nodes.map((node): GraphLink3D => ({
648
+ id: \`\${node.parentId ?? 'project'}-\${node.id}\`,
649
+ source: node.parentId ?? 'project',
650
+ target: node.id,
651
+ label: node.confidence === 'high' ? relationshipLabel(node.parentId) : \`may \${relationshipLabel(node.parentId)}\`,
652
+ })),
653
+ }), [nodes]);
654
+
655
+ const activeIds = useMemo(() => {
656
+ const ids = new Set<string>();
657
+ if (!selectedId) return ids;
658
+ ids.add(selectedId);
659
+ graphData.links.forEach((link) => {
660
+ const source = endpointId(link.source);
661
+ const target = endpointId(link.target);
662
+ if (source === selectedId) ids.add(target);
663
+ if (target === selectedId) ids.add(source);
664
+ });
665
+ return ids;
666
+ }, [graphData.links, selectedId]);
667
+
668
+ useEffect(() => {
669
+ const element = containerRef.current;
670
+ if (!element) return;
671
+ const observer = new ResizeObserver(([entry]) => {
672
+ setSize({ width: Math.max(1, entry.contentRect.width), height: Math.max(1, entry.contentRect.height) });
673
+ });
674
+ observer.observe(element);
675
+ return () => observer.disconnect();
676
+ }, []);
677
+
678
+ const configureForces = useCallback(() => {
679
+ const graphInstance = graphRef.current;
680
+ if (!graphInstance || hasConfiguredForces.current) return;
681
+ const charge = graphInstance.d3Force('charge') as AdjustableForce | undefined;
682
+ const link = graphInstance.d3Force('link') as AdjustableForce | undefined;
683
+ graphInstance.renderer().setPixelRatio(Math.min(window.devicePixelRatio * 1.5, 3));
684
+ charge?.strength?.(-720);
685
+ link?.distance?.(210);
686
+ graphInstance.d3ReheatSimulation();
687
+ hasConfiguredForces.current = true;
688
+ }, []);
689
+
690
+ useEffect(() => {
691
+ hasFramed.current = false;
692
+ hasConfiguredForces.current = false;
693
+ const frame = window.requestAnimationFrame(configureForces);
694
+ return () => window.cancelAnimationFrame(frame);
695
+ }, [configureForces, graphData.nodes.length, graphData.links.length]);
696
+
697
+ const focusNode = useCallback((node: NodeObject) => {
698
+ const serviceNode = graphNode(node);
699
+ onSelect(serviceNode.id);
700
+ if (serviceNode.x === undefined || serviceNode.y === undefined || serviceNode.z === undefined) return;
701
+ const distance = Math.hypot(serviceNode.x, serviceNode.y, serviceNode.z) || 1;
702
+ const ratio = 1 + 105 / distance;
703
+ graphRef.current?.cameraPosition(
704
+ { x: serviceNode.x * ratio, y: serviceNode.y * ratio, z: serviceNode.z * ratio },
705
+ { x: serviceNode.x, y: serviceNode.y, z: serviceNode.z },
706
+ 850,
707
+ );
708
+ }, [onSelect]);
709
+
710
+ const zoom = useCallback((factor: number) => {
711
+ const graphInstance = graphRef.current;
712
+ if (!graphInstance) return;
713
+ const camera = graphInstance.camera();
714
+ const controls = graphInstance.controls() as OrbitControlsLike;
715
+ const target = controls.target?.clone() ?? new THREE.Vector3();
716
+ const offset = camera.position.clone().sub(target);
717
+ const currentDistance = offset.length();
718
+ if (currentDistance === 0) return;
719
+ const nextDistance = THREE.MathUtils.clamp(currentDistance * factor, 35, 1200);
720
+ const nextPosition = target.clone().add(offset.multiplyScalar(nextDistance / currentDistance));
721
+ graphInstance.cameraPosition(nextPosition, target, 240);
722
+ }, []);
723
+
724
+ return (
725
+ <div className="graph-3d" ref={containerRef}>
726
+ <ForceGraph3D
727
+ ref={graphRef as never}
728
+ width={size.width}
729
+ height={size.height}
730
+ graphData={graphData}
731
+ backgroundColor="rgba(0,0,0,0)"
732
+ showNavInfo={false}
733
+ nodeThreeObject={(node) => {
734
+ const serviceNode = graphNode(node);
735
+ return iconObject(serviceNode, serviceNode.id === selectedId);
736
+ }}
737
+ nodeLabel={(node) => {
738
+ const serviceNode = graphNode(node);
739
+ return \`\${serviceNode.name} · \${serviceNode.category}\`;
740
+ }}
741
+ linkLabel={(link) => graphLink(link as GraphLink3D).label}
742
+ linkColor={(link) => {
743
+ const serviceLink = graphLink(link as GraphLink3D);
744
+ if (!selectedId) return '#415149';
745
+ return endpointId(serviceLink.source) === selectedId || endpointId(serviceLink.target) === selectedId ? '#7ee2b8' : '#17211c';
746
+ }}
747
+ linkWidth={(link) => {
748
+ const serviceLink = graphLink(link as GraphLink3D);
749
+ return endpointId(serviceLink.source) === selectedId || endpointId(serviceLink.target) === selectedId ? 2.2 : selectedId ? 0.35 : 0.8;
750
+ }}
751
+ linkOpacity={0.8}
752
+ linkDirectionalArrowLength={3.5}
753
+ linkDirectionalArrowRelPos={0.8}
754
+ linkDirectionalArrowColor={(link) => {
755
+ const serviceLink = graphLink(link as GraphLink3D);
756
+ return endpointId(serviceLink.source) === selectedId || endpointId(serviceLink.target) === selectedId ? '#9ff0cb' : '#506158';
757
+ }}
758
+ linkDirectionalParticles={(link) => {
759
+ const serviceLink = graphLink(link as GraphLink3D);
760
+ return endpointId(serviceLink.source) === selectedId || endpointId(serviceLink.target) === selectedId ? 3 : 0;
761
+ }}
762
+ linkDirectionalParticleWidth={1.8}
763
+ linkDirectionalParticleSpeed={0.006}
764
+ linkDirectionalParticleColor={() => '#b9f6da'}
765
+ d3AlphaDecay={0.026}
766
+ d3VelocityDecay={0.18}
767
+ warmupTicks={90}
768
+ cooldownTicks={260}
769
+ onNodeClick={focusNode}
770
+ onBackgroundClick={() => onSelect(null)}
771
+ onEngineTick={configureForces}
772
+ onEngineStop={() => {
773
+ if (hasFramed.current) return;
774
+ hasFramed.current = true;
775
+ graphRef.current?.zoomToFit(700, 130);
776
+ }}
777
+ />
778
+ <div className="graph-3d__zoom" aria-label="3D zoom controls">
779
+ <button type="button" onClick={() => zoom(0.78)} title="Zoom in" aria-label="Zoom in">+</button>
780
+ <button type="button" onClick={() => zoom(1.28)} title="Zoom out" aria-label="Zoom out">−</button>
781
+ </div>
782
+ <div className="graph-3d__help" aria-hidden="true">Drag to rotate <span /> Scroll to zoom <span /> Click a service</div>
783
+ {selectedId && <div className="graph-3d__focus" aria-hidden="true">{Math.max(activeIds.size - 1, 0)} direct connections highlighted</div>}
784
+ </div>
785
+ );
786
+ }
787
+
471
788
  export default function InternalServiceMapPage() {
472
- const nodes = useMemo<ServiceNode[]>(() => (graph.services ?? []).slice(0, layout.length).map((service, index) => ({
789
+ const [services, setServices] = useState<RawService[]>(() => graph.services ?? []);
790
+ const nodes = useMemo<ServiceNode[]>(() => services.map((service, index) => ({
473
791
  ...service,
474
- ...layout[index],
792
+ ...servicePosition(index),
475
793
  color: palette[index % palette.length],
476
- })), []);
794
+ })), [services]);
477
795
  const [mode, setMode] = useState<'2d' | '3d'>('2d');
478
- const [rotation, setRotation] = useState({ x: 14, z: 0 });
796
+ const [zoom, setZoom] = useState(1);
797
+ const [pan, setPan] = useState({ x: 0, y: 0 });
798
+ const [panStart, setPanStart] = useState<{ pointerId: number; x: number; y: number; panX: number; panY: number } | null>(null);
479
799
  const [selectedId, setSelectedId] = useState<string | null>(null);
800
+ const [isAddingService, setIsAddingService] = useState(false);
801
+ const [newService, setNewService] = useState<NewServiceDraft>({ name: '', category: '', parentId: 'product' });
480
802
  const [drafts, setDrafts] = useState<Record<string, DraftDetails>>(() => Object.fromEntries(nodes.map((service) => [
481
803
  service.id,
482
804
  {
@@ -487,6 +809,64 @@ export default function InternalServiceMapPage() {
487
809
  },
488
810
  ])));
489
811
  const selected = nodes.find((service) => service.id === selectedId) ?? null;
812
+ const nodeById = useMemo(() => new Map(nodes.map((node) => [node.id, node])), [nodes]);
813
+ const zoomIn = () => setZoom((current) => Math.min(1.45, Number((current + 0.12).toFixed(2))));
814
+ const zoomOut = () => setZoom((current) => Math.max(0.7, Number((current - 0.12).toFixed(2))));
815
+ const resetZoom = () => {
816
+ setZoom(1);
817
+ setPan({ x: 0, y: 0 });
818
+ };
819
+ const startPan = (event: PointerEvent<HTMLElement>) => {
820
+ if (mode !== '2d' || event.button !== 0 || (event.target as HTMLElement).closest('button, a, input, textarea, select')) return;
821
+ event.currentTarget.setPointerCapture(event.pointerId);
822
+ setPanStart({ pointerId: event.pointerId, x: event.clientX, y: event.clientY, panX: pan.x, panY: pan.y });
823
+ };
824
+ const movePan = (event: PointerEvent<HTMLElement>) => {
825
+ if (!panStart || panStart.pointerId !== event.pointerId) return;
826
+ setPan({
827
+ x: panStart.panX + event.clientX - panStart.x,
828
+ y: panStart.panY + event.clientY - panStart.y,
829
+ });
830
+ };
831
+ const stopPan = (event: PointerEvent<HTMLElement>) => {
832
+ if (panStart?.pointerId !== event.pointerId) return;
833
+ setPanStart(null);
834
+ };
835
+ const addService = () => {
836
+ const name = newService.name.trim();
837
+ if (!name) return;
838
+ const baseId = slugify(name);
839
+ const existingIds = new Set(services.map((service) => service.id));
840
+ let id = baseId;
841
+ let suffix = 2;
842
+ while (existingIds.has(id)) {
843
+ id = \`\${baseId}-\${suffix}\`;
844
+ suffix += 1;
845
+ }
846
+ const service: RawService = {
847
+ id,
848
+ key: id,
849
+ name,
850
+ category: newService.category.trim() || 'service',
851
+ confidence: 'manual',
852
+ evidence: [{ type: 'manual', value: 'Added in servicemap UI.', confidence: 'manual' }],
853
+ iconUrl: null,
854
+ parentId: newService.parentId === 'product' ? null : newService.parentId,
855
+ };
856
+ setServices((current) => [...current, service]);
857
+ setDrafts((current) => ({
858
+ ...current,
859
+ [id]: {
860
+ subtitle: categoryLabel(service.category),
861
+ use: primaryEvidence(service),
862
+ account: '',
863
+ passwordLocation: '',
864
+ },
865
+ }));
866
+ setSelectedId(id);
867
+ setNewService({ name: '', category: '', parentId: 'product' });
868
+ setIsAddingService(false);
869
+ };
490
870
 
491
871
  return (
492
872
  <main className="servicemap-shell">
@@ -501,6 +881,7 @@ export default function InternalServiceMapPage() {
501
881
  </div>
502
882
  <div className="top-actions">
503
883
  <span>{nodes.length} services</span>
884
+ <button type="button" className="add-service-button" onClick={() => setIsAddingService(true)}>+ Add service</button>
504
885
  <div className="mode-toggle" aria-label="View mode">
505
886
  <button type="button" className={\`mode-button \${mode === '2d' ? 'is-active' : ''}\`} onClick={() => setMode('2d')}>2D</button>
506
887
  <button type="button" className={\`mode-button \${mode === '3d' ? 'is-active' : ''}\`} onClick={() => setMode('3d')}>3D</button>
@@ -508,45 +889,60 @@ export default function InternalServiceMapPage() {
508
889
  </div>
509
890
  </header>
510
891
 
511
- <section className="map">
892
+ <section
893
+ className={\`map \${panStart ? 'is-panning' : ''} \${selectedId ? 'has-selection' : ''}\`}
894
+ onPointerDown={startPan}
895
+ onPointerMove={movePan}
896
+ onPointerUp={stopPan}
897
+ onPointerCancel={stopPan}
898
+ >
512
899
  <div className="intro">
513
900
  <strong>{graph.project?.name ?? 'Project'} infrastructure</strong>
514
901
  <span>{selected ? \`Tracing \${selected.name}\` : mode === '3d' ? 'Rotate and explore your service relationships' : 'Select a service to trace its dependencies'}</span>
515
902
  </div>
516
903
  {mode === '2d' ? (
517
904
  <>
518
- <svg className="edges" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
519
- {nodes.map((service) => (
520
- <path
905
+ <div className="map-plane" style={{ '--zoom': zoom, '--pan-x': \`\${pan.x}px\`, '--pan-y': \`\${pan.y}px\` } as CSSProperties}>
906
+ <svg className="edges" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
907
+ {nodes.map((service) => {
908
+ const parent = service.parentId ? nodeById.get(service.parentId) : null;
909
+ const isActive = selectedId === service.id || selectedId === service.parentId;
910
+ return (
911
+ <path
912
+ key={service.id}
913
+ className={\`edge-line \${isActive ? 'is-active' : selectedId ? 'is-muted' : ''}\`}
914
+ d={parent ? edgePathBetween(parent, service) : edgePath(service)}
915
+ fill="none"
916
+ stroke={isActive ? '#65f0ad' : 'rgba(103, 130, 118, 0.48)'}
917
+ strokeWidth={isActive ? '3' : '0.16'}
918
+ strokeDasharray={isActive ? '10 8' : undefined}
919
+ strokeLinecap={isActive ? 'round' : undefined}
920
+ vectorEffect="non-scaling-stroke"
921
+ />
922
+ );
923
+ })}
924
+ </svg>
925
+ <div className="hub"><div className="hub-inner"><ProjectMark /></div></div>
926
+ {nodes.map((service) => {
927
+ const isRelated = selectedId === service.parentId || selected?.parentId === service.id;
928
+ return (
929
+ <button
930
+ type="button"
521
931
  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
- />
531
- ))}
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
- ))}
932
+ className={\`service-node \${selectedId === service.id ? 'is-selected' : ''} \${isRelated ? 'is-related' : ''}\`}
933
+ style={{ left: \`\${service.x}%\`, top: \`\${service.y}%\` }}
934
+ onClick={() => setSelectedId(service.id)}
935
+ >
936
+ <ServiceLogo service={service} />
937
+ <div className="service-copy">
938
+ <strong>{service.name}</strong>
939
+ <span>{categoryLabel(service.category)}</span>
940
+ </div>
941
+ <i className={\`status-dot status-\${service.confidence}\`} />
942
+ </button>
943
+ );
944
+ })}
945
+ </div>
550
946
  <div className="legend">
551
947
  <span><i style={{ background: '#4a7cff' }} />Cloud</span>
552
948
  <span><i style={{ background: '#8be4b2' }} />Product</span>
@@ -554,39 +950,13 @@ export default function InternalServiceMapPage() {
554
950
  </div>
555
951
  </>
556
952
  ) : (
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>
953
+ <ServiceGraph3D nodes={nodes} selectedId={selectedId} onSelect={setSelectedId} />
589
954
  )}
955
+ <div className="zoom-controls" aria-label="Map zoom controls">
956
+ <button type="button" className="zoom-button" onClick={zoomIn} aria-label="Zoom in">+</button>
957
+ <button type="button" className="zoom-button" onClick={zoomOut} aria-label="Zoom out">−</button>
958
+ <button type="button" className="zoom-button" onClick={resetZoom} aria-label="Reset zoom"><span className="fit-icon" aria-hidden="true" /></button>
959
+ </div>
590
960
  <footer className="scan-foot">
591
961
  <div>Generated {new Date(graph.generatedAt).toLocaleString()}</div>
592
962
  <div>{nodes.length} services detected</div>
@@ -599,6 +969,15 @@ export default function InternalServiceMapPage() {
599
969
  onChange={(nextDetails) => setDrafts((current) => ({ ...current, [selected.id]: nextDetails }))}
600
970
  />
601
971
  ) : null}
972
+ {isAddingService ? (
973
+ <AddServiceModal
974
+ draft={newService}
975
+ nodes={nodes}
976
+ onChange={setNewService}
977
+ onClose={() => setIsAddingService(false)}
978
+ onSubmit={addService}
979
+ />
980
+ ) : null}
602
981
  </section>
603
982
  </main>
604
983
  );
@@ -675,6 +1054,54 @@ async function prepareProjectLogo(graph, outputRoot) {
675
1054
  graph.project.logoUrl = `/internalservicemap-assets/project-logo${extension}`;
676
1055
  }
677
1056
 
1057
+ function run(command, args, options = {}) {
1058
+ return new Promise((resolvePromise, reject) => {
1059
+ const child = spawn(command, args, { stdio: 'inherit', ...options });
1060
+ child.on('error', reject);
1061
+ child.on('exit', (code) => {
1062
+ if (code === 0) resolvePromise();
1063
+ else reject(new Error(`${command} ${args.join(' ')} exited with code ${code}`));
1064
+ });
1065
+ });
1066
+ }
1067
+
1068
+ async function ensureInteractivePageDependencies(root, routeKind) {
1069
+ if (routeKind === 'static' || routeKind === 'astro') return;
1070
+ const packageJsonPath = join(root, 'package.json');
1071
+ const packageJson = await readJsonOrNull(packageJsonPath);
1072
+ if (!packageJson) return;
1073
+
1074
+ const dependencies = {
1075
+ 'react-force-graph-3d': '^1.29.1',
1076
+ three: '^0.185.1',
1077
+ 'three-spritetext': '^1.10.0',
1078
+ };
1079
+ const devDependencies = {
1080
+ '@types/three': '^0.185.4',
1081
+ };
1082
+ let changed = false;
1083
+
1084
+ packageJson.dependencies ??= {};
1085
+ for (const [name, version] of Object.entries(dependencies)) {
1086
+ if (packageJson.dependencies[name] || packageJson.devDependencies?.[name]) continue;
1087
+ packageJson.dependencies[name] = version;
1088
+ changed = true;
1089
+ }
1090
+
1091
+ packageJson.devDependencies ??= {};
1092
+ for (const [name, version] of Object.entries(devDependencies)) {
1093
+ if (packageJson.dependencies?.[name] || packageJson.devDependencies[name]) continue;
1094
+ packageJson.devDependencies[name] = version;
1095
+ changed = true;
1096
+ }
1097
+
1098
+ if (!changed) return;
1099
+ await writeFile(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`);
1100
+ if (existsSync(join(root, 'package-lock.json'))) {
1101
+ await run('npm', ['install'], { cwd: root });
1102
+ }
1103
+ }
1104
+
678
1105
  async function main() {
679
1106
  const options = parseArgs();
680
1107
  const graph = await readJson(options.input);
@@ -684,6 +1111,7 @@ async function main() {
684
1111
 
685
1112
  await mkdir(dirname(route.path), { recursive: true });
686
1113
  await writeFile(route.path, contents);
1114
+ await ensureInteractivePageDependencies(options.root, route.kind);
687
1115
  console.log(`Wrote /internalservicemap page to ${route.path}`);
688
1116
  }
689
1117
 
@@ -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));