servicemap 0.1.28 → 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.28",
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,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import { existsSync } from 'node:fs';
3
- import { mkdir, readFile, writeFile } from 'node:fs/promises';
4
- import { dirname, join, resolve } from 'node:path';
3
+ import { copyFile, mkdir, readFile, writeFile } from 'node:fs/promises';
4
+ import { spawn } from 'node:child_process';
5
+ import { dirname, extname, isAbsolute, join, resolve } from 'node:path';
5
6
 
6
7
  const defaultInput = '.servicemap/internal-servicemap.json';
7
8
 
@@ -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';
@@ -56,6 +65,7 @@ function pageCss() {
56
65
  .servicemap-topbar { height: 88px; display: flex; align-items: center; justify-content: space-between; gap: 18px; padding: 0 28px; border-bottom: 1px solid rgba(255,255,255,0.08); background: #222823; }
57
66
  .brand { display: flex; align-items: center; gap: 16px; min-width: 0; }
58
67
  .brand-mark { width: 48px; height: 48px; border-radius: 10px; border: 1px solid #2a7958; background: #164d39; color: #7cf1b5; display: grid; place-items: center; box-shadow: 0 0 26px rgba(82,255,166,0.16); font-size: 22px; }
68
+ .brand-mark img, .hub-inner img, .three-core img { width: 100%; height: 100%; object-fit: cover; border-radius: inherit; }
59
69
  .brand h1 { margin: 0; font-size: 20px; line-height: 1; letter-spacing: 0; }
60
70
  .brand p, .top-meta { margin: 6px 0 0; color: #8aa097; font-size: 14px; }
61
71
  .top-actions { display: flex; align-items: center; gap: 12px; color: #8da59a; font-size: 14px; white-space: nowrap; }
@@ -63,15 +73,20 @@ function pageCss() {
63
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; }
64
74
  .mode-button.is-active { background: #276f53; color: #c9f9df; box-shadow: 0 0 18px rgba(101,240,173,0.16); }
65
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; }
66
77
  .intro { position: absolute; left: 32px; top: 32px; z-index: 20; }
67
78
  .intro strong { display: block; color: #779286; font-size: 12px; letter-spacing: 0.22em; text-transform: uppercase; }
68
79
  .intro span { display: block; margin-top: 8px; color: #53665d; font-size: 14px; }
69
- .edges { position: absolute; inset: 0; width: 100%; height: 100%; }
80
+ .edges { position: absolute; inset: 0; z-index: 26; width: 100%; height: 100%; pointer-events: none; }
81
+ .edge-line { transition: opacity 180ms ease, stroke 180ms ease; }
82
+ .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; }
83
+ .edge-line.is-muted { opacity: 0.24; }
70
84
  .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); }
71
85
  .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); }
72
86
  .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); }
73
87
  button.service-node { cursor: pointer; color: inherit; font: inherit; text-align: left; }
74
88
  .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); }
89
+ .service-node.is-selected { z-index: 27; }
75
90
  .logo { width: 48px; height: 48px; flex: 0 0 auto; border-radius: 8px; display: grid; place-items: center; overflow: hidden; color: #fff; font-weight: 700; }
76
91
  .logo img { width: 28px; height: 28px; object-fit: contain; filter: invert(1); }
77
92
  .service-copy { min-width: 0; }
@@ -82,6 +97,11 @@ function pageCss() {
82
97
  .status-medium, .status-low { color: #f1c85b; background: #f1c85b; }
83
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; }
84
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; }
85
105
  .scan-foot { position: absolute; right: 32px; bottom: 32px; z-index: 20; color: #53665d; text-align: right; font-size: 12px; line-height: 1.6; }
86
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; }
87
107
  .drawer-scrim { position: absolute; inset: 0; z-index: 24; background: rgba(0,0,0,0.58); pointer-events: none; }
@@ -109,16 +129,21 @@ function pageCss() {
109
129
  .delete-button { color: #ff6b57; background: transparent; font-weight: 800; }
110
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); }
111
131
  .helper-text { margin: 10px 0 0; color: #687c72; font-size: 12px; line-height: 1.45; }
112
- .three-scene { position: absolute; inset: 0; perspective: 920px; overflow: hidden; cursor: grab; touch-action: none; }
113
- .three-scene:active { cursor: grabbing; }
114
- .three-space { position: absolute; left: 50%; top: 50%; width: 620px; height: 620px; transform-style: preserve-3d; transform: translate(-50%, -50%) rotateX(var(--rx, 58deg)) rotateZ(var(--rz, -28deg)); transition: transform 140ms ease; pointer-events: none; }
115
- .three-link { position: absolute; left: 50%; top: 50%; width: var(--length); height: 1px; transform-origin: 0 0; transform: translate3d(0, 0, 0) rotateZ(var(--angle)) translateY(var(--z)); background: linear-gradient(90deg, rgba(101,240,173,0.42), rgba(101,240,173,0.05)); }
116
- .three-node { position: absolute; left: 50%; top: 50%; width: 82px; height: 82px; transform: translate3d(var(--x), var(--y), var(--z)) translate(-50%, -50%) rotateZ(28deg) rotateX(-58deg); border: 0; border-radius: 999px; background: transparent; color: #eaf8f1; cursor: pointer; font: inherit; text-align: center; pointer-events: auto; }
117
- .three-orb { display: grid; place-items: center; width: 38px; height: 38px; margin: 0 auto 7px; border-radius: 999px; background: var(--color); color: #fff; box-shadow: 0 0 28px color-mix(in srgb, var(--color), transparent 44%); font-size: 12px; font-weight: 900; }
118
- .three-node span { display: block; font-size: 10px; line-height: 1.15; text-shadow: 0 2px 8px #000; }
119
- .three-node.is-selected .three-orb { outline: 2px solid #65f0ad; outline-offset: 4px; }
120
- .three-core { position: absolute; left: 50%; top: 50%; display: grid; place-items: center; width: 72px; height: 72px; transform: translate(-50%, -50%); border-radius: 999px; background: #f7faf7; color: #101611; font-size: 30px; box-shadow: 0 0 0 10px rgba(101,240,173,0.14), 0 0 46px rgba(101,240,173,0.22); }
121
- .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; }
145
+ @keyframes servicemapDash { to { stroke-dashoffset: -1.3; } }
146
+ @keyframes servicemapLinkFlow { to { background-position: 22px 0; } }
122
147
  @media (max-width: 920px) {
123
148
  .map { min-height: 1120px; }
124
149
  .service-node { width: 220px; }
@@ -147,6 +172,11 @@ function initials(name) {
147
172
  return name.split(/\s+/).filter(Boolean).slice(0, 2).map((part) => part[0]).join('').toUpperCase();
148
173
  }
149
174
 
175
+ function projectMarkHtml(graph) {
176
+ const logoUrl = graph.project?.logoUrl;
177
+ return logoUrl ? `<img src="${escapeHtml(logoUrl)}" alt="${escapeHtml(graph.project?.name ?? 'Project')} logo">` : '⌘';
178
+ }
179
+
150
180
  function htmlBody(graph) {
151
181
  const services = graph.services ?? [];
152
182
  const access = graph.access ?? {};
@@ -160,6 +190,7 @@ function htmlBody(graph) {
160
190
  color: palette[index % palette.length],
161
191
  }));
162
192
  const edges = nodes.map((node) => `<path d="${edgePath(node)}" fill="none" stroke="rgba(103, 130, 118, 0.48)" stroke-width="0.16" vector-effect="non-scaling-stroke"></path>`).join('');
193
+ const projectMark = projectMarkHtml(graph);
163
194
  const serviceNodes = nodes.map((service) => `
164
195
  <article class="service-node" style="left:${service.x}%;top:${service.y}%">
165
196
  <div class="logo" style="background:${service.color}">${service.iconUrl ? `<img src="${escapeHtml(service.iconUrl)}" alt="">` : escapeHtml(initials(service.name))}</div>
@@ -175,7 +206,7 @@ function htmlBody(graph) {
175
206
  <main class="servicemap-shell">
176
207
  <header class="servicemap-topbar">
177
208
  <div class="brand">
178
- <div class="brand-mark">⌘</div>
209
+ <div class="brand-mark">${projectMark}</div>
179
210
  <div>
180
211
  <h1>servicemap</h1>
181
212
  <p>${escapeHtml(graph.project?.name ?? 'Project')} command center</p>
@@ -195,7 +226,7 @@ function htmlBody(graph) {
195
226
  <span>Select a service to trace its dependencies</span>
196
227
  </div>
197
228
  <svg class="edges" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">${edges}</svg>
198
- <div class="hub"><div class="hub-inner">⌘</div></div>
229
+ <div class="hub"><div class="hub-inner">${projectMark}</div></div>
199
230
  ${serviceNodes || '<div class="intro"><span>No external services were detected yet.</span></div>'}
200
231
  ${accessNotice}
201
232
  <div class="legend">
@@ -231,7 +262,11 @@ ${htmlBody(graph)}
231
262
  function interactiveTsxPage(graph) {
232
263
  return `'use client';
233
264
 
234
- 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';
235
270
 
236
271
  const css = ${JSON.stringify(pageCss())};
237
272
  type Evidence = { type?: string; value?: string; confidence?: string };
@@ -239,10 +274,15 @@ type RawService = { id: string; name: string; key?: string; category?: string; c
239
274
  type ServiceNode = RawService & { color: string; x: number; y: number };
240
275
  type DraftDetails = { subtitle: string; use: string; account: string; passwordLocation: string };
241
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 };
242
281
 
243
- const graph = ${JSON.stringify(graph)} as { generatedAt: string; project?: { name?: string }; services?: RawService[] };
282
+ const graph = ${JSON.stringify(graph)} as { generatedAt: string; project?: { name?: string; logoUrl?: string }; services?: RawService[] };
244
283
  const palette = ${JSON.stringify(palette)} as string[];
245
284
  const layout = ${JSON.stringify(layout)} as Array<{ x: number; y: number }>;
285
+ const ForceGraph3D = dynamic(() => import('react-force-graph-3d'), { ssr: false });
246
286
 
247
287
  function categoryLabel(value?: string | null) {
248
288
  return String(value ?? 'service').replace(/-/g, ' ');
@@ -260,41 +300,85 @@ function edgePath(node: ServiceNode) {
260
300
  return \`M \${fromX} \${node.y} H \${midX} V 50 H \${endX}\`;
261
301
  }
262
302
 
263
- function threePosition(index: number, count: number) {
264
- const angle = (index / Math.max(count, 1)) * Math.PI * 2 - Math.PI / 2;
265
- const radius = index % 3 === 0 ? 270 : index % 3 === 1 ? 215 : 320;
266
- const z = ((index % 5) - 2) * 32;
267
- return {
268
- x: Math.cos(angle) * radius,
269
- y: Math.sin(angle) * radius,
270
- z,
271
- angle: (angle * 180) / Math.PI,
272
- length: radius,
273
- };
303
+ function primaryEvidence(service: RawService) {
304
+ const evidence = service.evidence?.[0];
305
+ return evidence ? \`\${evidence.type}: \${evidence.value}\` : 'No evidence captured yet.';
274
306
  }
275
307
 
276
- function threeNodeStyle(index: number, count: number, color: string): CSSProperties {
277
- const position = threePosition(index, count);
278
- return {
279
- '--x': \`\${position.x}px\`,
280
- '--y': \`\${position.y}px\`,
281
- '--z': \`\${position.z}px\`,
282
- '--color': color,
283
- } as CSSProperties;
308
+ function endpointId(endpoint: string | number | GraphNode3D) {
309
+ return typeof endpoint === 'object' ? endpoint.id : String(endpoint);
284
310
  }
285
311
 
286
- function threeLinkStyle(index: number, count: number): CSSProperties {
287
- const position = threePosition(index, count);
288
- return {
289
- '--angle': \`\${position.angle}deg\`,
290
- '--length': \`\${position.length}px\`,
291
- '--z': \`\${position.z}px\`,
292
- } as CSSProperties;
312
+ function graphNode(node: NodeObject) {
313
+ return node as GraphNode3D;
293
314
  }
294
315
 
295
- function primaryEvidence(service: RawService) {
296
- const evidence = service.evidence?.[0];
297
- 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;
298
382
  }
299
383
 
300
384
  function serviceLinks(service: RawService): ServiceLink[] {
@@ -325,6 +409,15 @@ function ServiceLogo({ service }: { service: RawService & { color: string } }) {
325
409
  );
326
410
  }
327
411
 
412
+ function ProjectMark() {
413
+ return graph.project?.logoUrl ? (
414
+ // eslint-disable-next-line @next/next/no-img-element
415
+ <img src={graph.project.logoUrl} alt={\`\${graph.project?.name ?? 'Project'} logo\`} />
416
+ ) : (
417
+ <>⌘</>
418
+ );
419
+ }
420
+
328
421
  function DetailsPanel({
329
422
  service,
330
423
  details,
@@ -418,6 +511,170 @@ function DetailsPanel({
418
511
  );
419
512
  }
420
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
+
421
678
  export default function InternalServiceMapPage() {
422
679
  const nodes = useMemo<ServiceNode[]>(() => (graph.services ?? []).slice(0, layout.length).map((service, index) => ({
423
680
  ...service,
@@ -425,7 +682,7 @@ export default function InternalServiceMapPage() {
425
682
  color: palette[index % palette.length],
426
683
  })), []);
427
684
  const [mode, setMode] = useState<'2d' | '3d'>('2d');
428
- const [rotation, setRotation] = useState({ x: 58, z: -28 });
685
+ const [zoom, setZoom] = useState(1);
429
686
  const [selectedId, setSelectedId] = useState<string | null>(null);
430
687
  const [drafts, setDrafts] = useState<Record<string, DraftDetails>>(() => Object.fromEntries(nodes.map((service) => [
431
688
  service.id,
@@ -437,13 +694,16 @@ export default function InternalServiceMapPage() {
437
694
  },
438
695
  ])));
439
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);
440
700
 
441
701
  return (
442
702
  <main className="servicemap-shell">
443
703
  <style dangerouslySetInnerHTML={{ __html: css }} />
444
704
  <header className="servicemap-topbar">
445
705
  <div className="brand">
446
- <div className="brand-mark">⌘</div>
706
+ <div className="brand-mark"><ProjectMark /></div>
447
707
  <div>
448
708
  <h1>servicemap</h1>
449
709
  <p>{graph.project?.name ?? 'Project'} command center</p>
@@ -465,64 +725,54 @@ export default function InternalServiceMapPage() {
465
725
  </div>
466
726
  {mode === '2d' ? (
467
727
  <>
468
- <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>
469
745
  {nodes.map((service) => (
470
- <path key={service.id} d={edgePath(service)} fill="none" stroke="rgba(103, 130, 118, 0.48)" strokeWidth="0.16" vectorEffect="non-scaling-stroke" />
471
- ))}
472
- </svg>
473
- <div className="hub"><div className="hub-inner">⌘</div></div>
474
- {nodes.map((service) => (
475
- <button
476
- type="button"
477
- key={service.id}
478
- className={\`service-node \${selectedId === service.id ? 'is-selected' : ''}\`}
479
- style={{ left: \`\${service.x}%\`, top: \`\${service.y}%\` }}
480
- onClick={() => setSelectedId(service.id)}
481
- >
482
- <ServiceLogo service={service} />
483
- <div className="service-copy">
484
- <strong>{service.name}</strong>
485
- <span>{categoryLabel(service.category)}</span>
486
- </div>
487
- <i className={\`status-dot status-\${service.confidence}\`} />
488
- </button>
489
- ))}
490
- <div className="legend">
491
- <span><i style={{ background: '#4a7cff' }} />Cloud</span>
492
- <span><i style={{ background: '#8be4b2' }} />Product</span>
493
- <span><i style={{ background: '#65f0ad' }} />Active path</span>
494
- </div>
495
- </>
496
- ) : (
497
- <div className="three-scene" aria-label="3D service graph">
498
- <div className="three-space" style={{ '--rx': \`\${rotation.x}deg\`, '--rz': \`\${rotation.z}deg\` } as CSSProperties}>
499
- {nodes.map((service, index) => (
500
- <span key={\`\${service.id}-link\`} className="three-link" style={threeLinkStyle(index, nodes.length)} />
501
- ))}
502
- <div className="three-core">⌘</div>
503
- {nodes.map((service, index) => (
504
746
  <button
505
747
  type="button"
506
748
  key={service.id}
507
- className={\`three-node \${selectedId === service.id ? 'is-selected' : ''}\`}
508
- style={threeNodeStyle(index, nodes.length, service.color)}
509
- onPointerMove={(event) => {
510
- if (event.buttons !== 1) return;
511
- setRotation((current) => ({
512
- x: Math.max(24, Math.min(72, current.x - event.movementY * 0.25)),
513
- z: current.z + event.movementX * 0.25,
514
- }));
515
- }}
749
+ className={\`service-node \${selectedId === service.id ? 'is-selected' : ''}\`}
750
+ style={{ left: \`\${service.x}%\`, top: \`\${service.y}%\` }}
516
751
  onClick={() => setSelectedId(service.id)}
517
752
  >
518
- <span className="three-orb">{initials(service.name)}</span>
519
- <span>{service.name}</span>
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}\`} />
520
759
  </button>
521
760
  ))}
522
761
  </div>
523
- <div className="three-hint">Drag to rotate / Scroll to zoom / Click a service</div>
524
- </div>
762
+ <div className="legend">
763
+ <span><i style={{ background: '#4a7cff' }} />Cloud</span>
764
+ <span><i style={{ background: '#8be4b2' }} />Product</span>
765
+ <span><i style={{ background: '#65f0ad' }} />Active path</span>
766
+ </div>
767
+ </>
768
+ ) : (
769
+ <ServiceGraph3D nodes={nodes} selectedId={selectedId} onSelect={setSelectedId} />
525
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>
526
776
  <footer className="scan-foot">
527
777
  <div>Generated {new Date(graph.generatedAt).toLocaleString()}</div>
528
778
  <div>{nodes.length} services detected</div>
@@ -594,14 +844,81 @@ const html = ${JSON.stringify(htmlBody(graph))};
594
844
  `;
595
845
  }
596
846
 
847
+ async function prepareProjectLogo(graph, outputRoot) {
848
+ const logoPath = graph.project?.logo?.path;
849
+ if (!logoPath) return;
850
+
851
+ const sourceRoot = graph.project?.root ? resolve(graph.project.root) : outputRoot;
852
+ const sourcePath = isAbsolute(logoPath) ? logoPath : resolve(sourceRoot, logoPath);
853
+ if (!existsSync(sourcePath)) return;
854
+
855
+ const extension = extname(sourcePath) || '.png';
856
+ const assetDir = join(outputRoot, 'public', 'internalservicemap-assets');
857
+ const assetPath = join(assetDir, `project-logo${extension}`);
858
+ await mkdir(assetDir, { recursive: true });
859
+ await copyFile(sourcePath, assetPath);
860
+
861
+ graph.project.logoUrl = `/internalservicemap-assets/project-logo${extension}`;
862
+ }
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
+
597
912
  async function main() {
598
913
  const options = parseArgs();
599
914
  const graph = await readJson(options.input);
915
+ await prepareProjectLogo(graph, options.root);
600
916
  const route = routeFor(options.root, graph.project?.framework);
601
917
  const contents = route.kind === 'astro' ? astroPage(graph) : route.kind === 'static' ? staticHtml(graph) : tsxPage(graph);
602
918
 
603
919
  await mkdir(dirname(route.path), { recursive: true });
604
920
  await writeFile(route.path, contents);
921
+ await ensureInteractivePageDependencies(options.root, route.kind);
605
922
  console.log(`Wrote /internalservicemap page to ${route.path}`);
606
923
  }
607
924
 
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { existsSync } from 'node:fs';
3
- import { readFile, readdir, stat, writeFile } from 'node:fs/promises';
3
+ import { readFile, readdir, writeFile } from 'node:fs/promises';
4
4
  import { basename, dirname, extname, join, relative, resolve } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
 
@@ -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));
@@ -240,6 +270,59 @@ function buildEdges(projectName, services) {
240
270
  }));
241
271
  }
242
272
 
273
+ async function findProjectLogo(root, files) {
274
+ const appIconManifests = files
275
+ .filter((file) => file.endsWith('AppIcon.appiconset/Contents.json'))
276
+ .sort((a, b) => scoreLogoManifest(b) - scoreLogoManifest(a));
277
+
278
+ for (const manifestPath of appIconManifests) {
279
+ const manifest = await readJson(manifestPath);
280
+ const images = Array.isArray(manifest?.images) ? manifest.images : [];
281
+ const candidates = images
282
+ .filter((image) => image?.filename)
283
+ .map((image) => ({
284
+ file: join(dirname(manifestPath), image.filename),
285
+ score: scoreAppIconImage(image),
286
+ }))
287
+ .filter((candidate) => existsSync(candidate.file))
288
+ .sort((a, b) => b.score - a.score);
289
+
290
+ if (candidates[0]) {
291
+ return {
292
+ path: relative(root, candidates[0].file),
293
+ source: 'asset-catalog-app-icon',
294
+ };
295
+ }
296
+ }
297
+
298
+ const namedLogo = files.find((file) => /(^|[/\\])(app-?icon|logo|favicon)\.(png|jpg|jpeg|webp|svg|ico)$/i.test(file));
299
+ if (namedLogo) {
300
+ return {
301
+ path: relative(root, namedLogo),
302
+ source: 'named-image-file',
303
+ };
304
+ }
305
+
306
+ return null;
307
+ }
308
+
309
+ function scoreLogoManifest(path) {
310
+ let score = 0;
311
+ if (/Assets\.xcassets[/\\]AppIcon\.appiconset/i.test(path)) score += 10;
312
+ if (/Assets 2\.xcassets/i.test(path)) score += 20;
313
+ return score;
314
+ }
315
+
316
+ function scoreAppIconImage(image) {
317
+ const size = Number.parseInt(String(image.size ?? '0').split('x')[0], 10) || 0;
318
+ let score = size;
319
+ const filename = String(image.filename ?? '').toLowerCase();
320
+ if (filename.includes('dark') || filename.includes('darker')) score += 100;
321
+ if (filename.includes('tinted')) score -= 50;
322
+ if (image.platform === 'ios') score += 25;
323
+ return score;
324
+ }
325
+
243
326
  async function main() {
244
327
  const options = parseArgs();
245
328
  const packageJsonPath = join(options.root, 'package.json');
@@ -273,6 +356,7 @@ async function main() {
273
356
  configFiles: configFiles.map((file) => basename(file)),
274
357
  database,
275
358
  });
359
+ const logo = await findProjectLogo(options.root, files);
276
360
 
277
361
  const graph = {
278
362
  generatedAt: new Date().toISOString(),
@@ -280,6 +364,7 @@ async function main() {
280
364
  name: projectName,
281
365
  root: options.root,
282
366
  framework: detectFramework(options.root, packageJson, files),
367
+ logo,
283
368
  },
284
369
  access: {
285
370
  allowedEmails: options.allowedEmails,