servicemap 0.1.34 → 0.1.40

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.34",
3
+ "version": "0.1.40",
4
4
  "description": "Generate an internal service map page or desktop app for a software project.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -24,6 +24,9 @@ If no website can be found, create a desktop app fallback at `servicemap-desktop
24
24
  7. If the scan reports `framework: static` or no website route can be safely attached, run `scripts/generate-desktop-app.mjs --root <project-root>` to create `servicemap-desktop`.
25
25
  8. Adapt the generated surface to the project's existing design system only when the route is clear and the edits are low-risk.
26
26
  9. Run the project's relevant build, lint, and focused tests.
27
+ Open the generated map in a browser and verify both 2D and 3D modes.
28
+ Click a service and verify its details panel and animated illuminated dashed connection to its parent.
29
+ Desktop output uses the same interactive component as website output; missing interactions are generator bugs to fix, not controls to remove.
27
30
  10. If a website page was created, tell the user it is only local until deployed and ask whether they want to deploy it now.
28
31
  Do not deploy unless the user explicitly says yes.
29
32
  If they approve, use the project's existing deployment path, preferring `npm run deploy` when present, then provider-specific project configuration such as Vercel or Netlify.
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
3
3
  import { dirname, join, resolve } from 'node:path';
4
+ import { interactiveTsxPage, prepareProjectLogo } from './generate-page.mjs';
4
5
 
5
6
  const defaultInput = '.servicemap/internal-servicemap.json';
6
7
  const defaultOutput = 'servicemap-desktop';
@@ -48,6 +49,9 @@ function packageJson(graph) {
48
49
  typescript: '^5.0.0',
49
50
  react: '^19.0.0',
50
51
  'react-dom': '^19.0.0',
52
+ 'react-force-graph-3d': '^1.29.1',
53
+ three: '^0.185.1',
54
+ 'three-spritetext': '^1.10.0',
51
55
  '@tauri-apps/api': '^2.0.0',
52
56
  },
53
57
  devDependencies: {
@@ -86,498 +90,6 @@ function indexHtml() {
86
90
  `;
87
91
  }
88
92
 
89
- function mainJsx() {
90
- return `import React from 'react';
91
- import { createRoot } from 'react-dom/client';
92
- import graph from './servicemap-data.json';
93
- import './styles.css';
94
-
95
- function strongestEvidence(service) {
96
- const evidence = service.evidence?.[0];
97
- return evidence ? \`\${evidence.type}: \${evidence.value}\` : 'No evidence captured';
98
- }
99
-
100
- function categoryLabel(value) {
101
- return String(value ?? 'service').replace(/-/g, ' ');
102
- }
103
-
104
- function categoryClass(value) {
105
- return String(value ?? 'service').replace(/[^a-z0-9]+/gi, '-').toLowerCase();
106
- }
107
-
108
- function layoutServices(services) {
109
- const left = [];
110
- const right = [];
111
- services.forEach((service, index) => {
112
- if (index % 2 === 0) left.push(service);
113
- else right.push(service);
114
- });
115
-
116
- const place = (items, side) => items.map((service, index) => {
117
- const count = Math.max(items.length, 1);
118
- const y = 15 + ((index + 0.5) * 70) / count;
119
- const x = side === 'left' ? 10 + (index % 2) * 13 : 71 + (index % 2) * 10;
120
- return { service, x, y, side };
121
- });
122
-
123
- return [...place(left, 'left'), ...place(right, 'right')];
124
- }
125
-
126
- function App() {
127
- const services = graph.services ?? [];
128
- const highConfidence = services.filter((service) => service.confidence === 'high').length;
129
- const nodes = layoutServices(services);
130
-
131
- return (
132
- <main className="appShell">
133
- <header className="topbar">
134
- <div className="brand">
135
- <div className="brandIcon" aria-hidden="true">⌘</div>
136
- <div>
137
- <h1>service-map</h1>
138
- <p>{graph.project?.name ?? 'Internal'} command center</p>
139
- </div>
140
- </div>
141
- <div className="toolbar" aria-label="Service map actions">
142
- <span className="identity">{graph.project?.framework ?? 'static'}</span>
143
- <button type="button">2D</button>
144
- <button type="button" className="ghost">3D</button>
145
- <button type="button" className="ghost">Import</button>
146
- <button type="button" className="primary">+ Add service</button>
147
- </div>
148
- </header>
149
-
150
- <section className="mapCanvas" aria-label="Detected service map">
151
- <div className="sectionLabel">
152
- <span>{(graph.project?.name ?? 'project').toUpperCase()} INFRASTRUCTURE</span>
153
- <small>{services.length} services · {highConfidence} high confidence</small>
154
- </div>
155
-
156
- <svg className="edges" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
157
- {nodes.map((node) => {
158
- const startX = node.side === 'left' ? node.x + 13 : node.x;
159
- const endX = 50;
160
- const midX = node.side === 'left' ? 38 : 62;
161
- return (
162
- <path
163
- key={node.service.id}
164
- d={\`M \${startX} \${node.y} H \${midX} V 50 H \${endX}\`}
165
- className={node.service.confidence === 'high' ? 'edge edgeHigh' : 'edge'}
166
- />
167
- );
168
- })}
169
- </svg>
170
-
171
- <div className="centerNode" aria-label="Project">
172
- <div className="coreIcon" aria-hidden="true">⌬</div>
173
- </div>
174
-
175
- {nodes.map(({ service, x, y }) => (
176
- <article
177
- className={\`serviceNode \${service.confidence === 'high' ? 'isHigh' : ''}\`}
178
- key={service.id}
179
- style={{ left: \`\${x}%\`, top: \`\${y}%\` }}
180
- title={strongestEvidence(service)}
181
- >
182
- <div className={\`logo logo-\${categoryClass(service.category)}\`} aria-hidden="true">
183
- {service.iconUrl ? <img src={service.iconUrl} alt="" /> : service.name.slice(0, 2).toUpperCase()}
184
- </div>
185
- <div className="nameBlock">
186
- <strong>{service.name}</strong>
187
- <span>{categoryLabel(service.category)}</span>
188
- </div>
189
- <span className={\`statusDot \${service.confidence === 'high' ? 'active' : 'pending'}\`} />
190
- </article>
191
- ))}
192
-
193
- {!services.length && (
194
- <div className="empty">
195
- <strong>No external services detected</strong>
196
- <span>Run the scanner again after adding integrations.</span>
197
- </div>
198
- )}
199
-
200
- <div className="legend">
201
- <span><b className="blue" /> Cloud</span>
202
- <span><b className="green" /> Active path</span>
203
- <span><b className="yellow" /> Lower confidence</span>
204
- </div>
205
- </section>
206
- </main>
207
- );
208
- }
209
-
210
- createRoot(document.getElementById('root')).render(<App />);
211
- `;
212
- }
213
-
214
- function stylesCss() {
215
- return `:root {
216
- color: #eef7f2;
217
- background: #020806;
218
- font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
219
- }
220
-
221
- * {
222
- box-sizing: border-box;
223
- }
224
-
225
- body {
226
- margin: 0;
227
- min-width: 320px;
228
- min-height: 100vh;
229
- background: #020806;
230
- }
231
-
232
- button {
233
- border: 0;
234
- border-radius: 8px;
235
- min-height: 42px;
236
- padding: 0 16px;
237
- color: #e8f5ee;
238
- background: #101a16;
239
- font: inherit;
240
- font-weight: 700;
241
- }
242
-
243
- .appShell {
244
- min-height: 100vh;
245
- background: #020806;
246
- }
247
-
248
- .topbar {
249
- height: 86px;
250
- padding: 18px 28px;
251
- display: flex;
252
- align-items: center;
253
- justify-content: space-between;
254
- gap: 20px;
255
- background: #202622;
256
- border-bottom: 1px solid rgba(255, 255, 255, 0.08);
257
- }
258
-
259
- .brand {
260
- display: flex;
261
- align-items: center;
262
- gap: 14px;
263
- min-width: 0;
264
- }
265
-
266
- .brandIcon {
267
- width: 48px;
268
- height: 48px;
269
- border-radius: 12px;
270
- display: grid;
271
- place-items: center;
272
- background: #1f704f;
273
- border: 1px solid #38c987;
274
- box-shadow: 0 0 24px rgba(56, 201, 135, 0.25);
275
- color: #c9f8df;
276
- font-size: 25px;
277
- }
278
-
279
- h1 {
280
- margin: 0 0 3px;
281
- font-size: 22px;
282
- line-height: 1;
283
- letter-spacing: 0;
284
- }
285
-
286
- p {
287
- margin: 0;
288
- }
289
-
290
- .brand p,
291
- .nameBlock span,
292
- .sectionLabel,
293
- .legend {
294
- color: rgba(238, 247, 242, 0.54);
295
- }
296
-
297
- .toolbar {
298
- display: flex;
299
- align-items: center;
300
- gap: 10px;
301
- min-width: 0;
302
- }
303
-
304
- .identity {
305
- color: #93c8ac;
306
- font-size: 13px;
307
- font-weight: 800;
308
- margin-right: 10px;
309
- white-space: nowrap;
310
- }
311
-
312
- .toolbar .primary {
313
- background: #247b58;
314
- box-shadow: inset 0 0 0 1px rgba(104, 255, 179, 0.25);
315
- }
316
-
317
- .toolbar .ghost {
318
- color: rgba(238, 247, 242, 0.72);
319
- }
320
-
321
- .mapCanvas {
322
- position: relative;
323
- min-height: calc(100vh - 86px);
324
- overflow: hidden;
325
- background:
326
- radial-gradient(circle at center, rgba(37, 128, 88, 0.16), transparent 34%),
327
- radial-gradient(circle at 1px 1px, rgba(115, 255, 183, 0.16) 1px, transparent 1px),
328
- #020806;
329
- background-size: auto, 28px 28px, auto;
330
- }
331
-
332
- .sectionLabel {
333
- position: absolute;
334
- top: 28px;
335
- left: 32px;
336
- right: 32px;
337
- display: flex;
338
- justify-content: space-between;
339
- gap: 18px;
340
- font-size: 13px;
341
- font-weight: 800;
342
- letter-spacing: 0.16em;
343
- text-transform: uppercase;
344
- }
345
-
346
- .sectionLabel small {
347
- letter-spacing: 0.08em;
348
- }
349
-
350
- .edges {
351
- position: absolute;
352
- inset: 0;
353
- width: 100%;
354
- height: 100%;
355
- pointer-events: none;
356
- }
357
-
358
- .edge {
359
- fill: none;
360
- stroke: rgba(121, 158, 140, 0.34);
361
- stroke-width: 0.12;
362
- }
363
-
364
- .edgeHigh {
365
- stroke: rgba(115, 255, 183, 0.5);
366
- }
367
-
368
- .centerNode {
369
- position: absolute;
370
- left: 50%;
371
- top: 50%;
372
- width: 112px;
373
- height: 112px;
374
- transform: translate(-50%, -50%);
375
- display: grid;
376
- place-items: center;
377
- border-radius: 26px;
378
- background: rgba(15, 31, 24, 0.88);
379
- border: 1px solid rgba(114, 255, 183, 0.28);
380
- box-shadow: 0 0 0 9px rgba(56, 201, 135, 0.08), 0 0 45px rgba(56, 201, 135, 0.18);
381
- }
382
-
383
- .coreIcon {
384
- width: 78px;
385
- height: 78px;
386
- display: grid;
387
- place-items: center;
388
- border-radius: 20px;
389
- background: #f7fbf7;
390
- color: #101513;
391
- font-size: 38px;
392
- }
393
-
394
- .serviceNode {
395
- position: absolute;
396
- width: clamp(190px, 16vw, 260px);
397
- min-height: 70px;
398
- transform: translateY(-50%);
399
- display: grid;
400
- grid-template-columns: 48px minmax(0, 1fr) 12px;
401
- align-items: center;
402
- gap: 12px;
403
- padding: 12px;
404
- border-radius: 14px;
405
- background: linear-gradient(180deg, rgba(22, 33, 27, 0.98), rgba(12, 20, 16, 0.96));
406
- border: 1px solid rgba(158, 202, 176, 0.24);
407
- box-shadow: 0 12px 26px rgba(0, 0, 0, 0.34), inset 0 0 0 1px rgba(255, 255, 255, 0.03);
408
- }
409
-
410
- .serviceNode.isHigh {
411
- box-shadow: 0 12px 26px rgba(0, 0, 0, 0.34), 0 0 24px rgba(56, 201, 135, 0.08);
412
- }
413
-
414
- .logo {
415
- width: 48px;
416
- height: 48px;
417
- border-radius: 12px;
418
- background: #24302b;
419
- color: #fff;
420
- display: grid;
421
- place-items: center;
422
- overflow: hidden;
423
- flex: 0 0 auto;
424
- font-size: 12px;
425
- font-weight: 800;
426
- }
427
-
428
- .logo img {
429
- width: 100%;
430
- height: 100%;
431
- object-fit: contain;
432
- padding: 7px;
433
- filter: brightness(0) invert(1);
434
- }
435
-
436
- .logo-cloud {
437
- background: #2d72f6;
438
- }
439
-
440
- .logo-developer-tools {
441
- background: #202833;
442
- }
443
-
444
- .logo-billing {
445
- background: #635bff;
446
- }
447
-
448
- .logo-email,
449
- .logo-collaboration {
450
- background: #f45d48;
451
- }
452
-
453
- .logo-ai {
454
- background: #1f7a55;
455
- }
456
-
457
- .logo-monitoring {
458
- background: #6b4bd8;
459
- }
460
-
461
- .logo-auth {
462
- background: #28a37a;
463
- }
464
-
465
- .logo-database {
466
- background: #f57c00;
467
- }
468
-
469
- .nameBlock {
470
- min-width: 0;
471
- }
472
-
473
- .nameBlock strong {
474
- display: block;
475
- overflow: hidden;
476
- text-overflow: ellipsis;
477
- white-space: nowrap;
478
- font-size: 15px;
479
- }
480
-
481
- .nameBlock span {
482
- display: block;
483
- margin-top: 4px;
484
- overflow: hidden;
485
- text-overflow: ellipsis;
486
- white-space: nowrap;
487
- font-size: 12px;
488
- text-transform: capitalize;
489
- }
490
-
491
- .statusDot {
492
- width: 8px;
493
- height: 8px;
494
- border-radius: 999px;
495
- background: #eac94f;
496
- box-shadow: 0 0 12px rgba(234, 201, 79, 0.9);
497
- }
498
-
499
- .statusDot.active {
500
- background: #69f6b1;
501
- box-shadow: 0 0 12px rgba(105, 246, 177, 0.9);
502
- }
503
-
504
- .empty {
505
- position: absolute;
506
- left: 50%;
507
- top: 50%;
508
- transform: translate(-50%, 90px);
509
- display: grid;
510
- gap: 6px;
511
- text-align: center;
512
- color: rgba(238, 247, 242, 0.74);
513
- }
514
-
515
- .legend {
516
- position: absolute;
517
- left: 50%;
518
- bottom: 28px;
519
- transform: translateX(-50%);
520
- display: flex;
521
- gap: 20px;
522
- padding: 8px 16px;
523
- border-radius: 999px;
524
- background: rgba(8, 15, 12, 0.82);
525
- border: 1px solid rgba(158, 202, 176, 0.18);
526
- font-size: 12px;
527
- font-weight: 800;
528
- }
529
-
530
- .legend span {
531
- display: inline-flex;
532
- align-items: center;
533
- gap: 7px;
534
- white-space: nowrap;
535
- }
536
-
537
- .legend b {
538
- width: 8px;
539
- height: 8px;
540
- border-radius: 999px;
541
- }
542
-
543
- .legend .blue {
544
- background: #3d8cff;
545
- }
546
-
547
- .legend .green {
548
- background: #69f6b1;
549
- }
550
-
551
- .legend .yellow {
552
- background: #eac94f;
553
- }
554
-
555
- @media (max-width: 880px) {
556
- .topbar {
557
- height: auto;
558
- align-items: flex-start;
559
- flex-direction: column;
560
- }
561
-
562
- .toolbar {
563
- flex-wrap: wrap;
564
- }
565
-
566
- .mapCanvas {
567
- min-height: 980px;
568
- }
569
-
570
- .serviceNode {
571
- width: 210px;
572
- }
573
-
574
- .sectionLabel {
575
- align-items: flex-start;
576
- flex-direction: column;
577
- }
578
- }
579
- `;
580
- }
581
93
 
582
94
  function tauriConfig(graph) {
583
95
  return `${JSON.stringify({
@@ -657,11 +169,17 @@ async function main() {
657
169
  const graph = await readJson(options.input);
658
170
 
659
171
  await mkdir(options.output, { recursive: true });
172
+ await prepareProjectLogo(graph, options.output);
660
173
  await writeProjectFile(options.output, 'package.json', packageJson(graph));
661
174
  await writeProjectFile(options.output, 'index.html', indexHtml());
662
175
  await writeProjectFile(options.output, 'vite.config.js', viteConfig());
663
- await writeProjectFile(options.output, 'src/main.jsx', mainJsx());
664
- await writeProjectFile(options.output, 'src/styles.css', stylesCss());
176
+ await writeProjectFile(options.output, 'src/main.jsx', `import React from 'react';
177
+ import { createRoot } from 'react-dom/client';
178
+ import ServiceMap from './ServiceMap';
179
+ createRoot(document.getElementById('root')).render(<ServiceMap />);
180
+ `);
181
+ await writeProjectFile(options.output, 'src/ServiceMap.tsx', interactiveTsxPage(graph, { desktop: true }));
182
+ await writeProjectFile(options.output, '.gitignore', 'node_modules/\ndist/\nsrc-tauri/target/\n');
665
183
  await writeProjectFile(options.output, 'src/servicemap-data.json', `${JSON.stringify(graph, null, 2)}\n`);
666
184
  await writeProjectFile(options.output, 'src-tauri/tauri.conf.json', tauriConfig(graph));
667
185
  await writeProjectFile(options.output, 'src-tauri/Cargo.toml', cargoToml());
@@ -2,6 +2,7 @@
2
2
  import { existsSync } from 'node:fs';
3
3
  import { copyFile, mkdir, readFile, writeFile } from 'node:fs/promises';
4
4
  import { spawn } from 'node:child_process';
5
+ import { pathToFileURL } from 'node:url';
5
6
  import { dirname, extname, isAbsolute, join, resolve } from 'node:path';
6
7
 
7
8
  const defaultInput = '.servicemap/internal-servicemap.json';
@@ -69,23 +70,29 @@ function pageCss() {
69
70
  .brand h1 { margin: 0; font-size: 20px; line-height: 1; letter-spacing: 0; }
70
71
  .brand p, .top-meta { margin: 6px 0 0; color: #8aa097; font-size: 14px; }
71
72
  .top-actions { display: flex; align-items: center; gap: 12px; color: #8da59a; font-size: 14px; white-space: nowrap; }
73
+ .add-service-button { min-height: 40px; border: 0; border-radius: 10px; background: #287a5a; color: #effbf5; padding: 0 14px; cursor: pointer; font: inherit; font-weight: 800; box-shadow: 0 10px 28px rgba(54,180,126,0.16); }
72
74
  .mode-toggle { display: inline-flex; gap: 4px; padding: 4px; border-radius: 10px; background: #0b130f; }
73
75
  .mode-button { min-height: 36px; border: 0; border-radius: 8px; background: transparent; color: #789086; padding: 0 12px; cursor: pointer; font: inherit; font-weight: 800; }
74
76
  .mode-button.is-active { background: #276f53; color: #c9f9df; box-shadow: 0 0 18px rgba(101,240,173,0.16); }
75
77
  .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; }
78
+ .map.is-panning { cursor: grabbing; user-select: none; }
79
+ .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; }
80
+ .map.is-panning .map-plane { transition: none; }
77
81
  .intro { position: absolute; left: 32px; top: 32px; z-index: 20; }
78
82
  .intro strong { display: block; color: #779286; font-size: 12px; letter-spacing: 0.22em; text-transform: uppercase; }
79
83
  .intro span { display: block; margin-top: 8px; color: #53665d; font-size: 14px; }
80
84
  .edges { position: absolute; inset: 0; z-index: 26; width: 100%; height: 100%; pointer-events: none; }
81
85
  .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; }
86
+ .edge-line.is-active { stroke: #7ee2b8; stroke-width: 2.4px; stroke-dasharray: 5 5; animation: servicemapDash .5s linear infinite; filter: drop-shadow(0 0 5px rgba(126,226,184,.55)); opacity: 1; }
87
+ .edge-line.is-muted { opacity: 0.12; }
84
88
  .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); }
85
89
  .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); }
86
90
  .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); }
87
91
  button.service-node { cursor: pointer; color: inherit; font: inherit; text-align: left; }
88
92
  .service-node:hover, .service-node.is-selected { border-color: #66efae; box-shadow: 0 0 0 2px rgba(101,240,173,0.42), 0 18px 44px rgba(0,0,0,0.38); }
93
+ .map.has-selection .service-node { opacity: 0.16; }
94
+ .map.has-selection .service-node.is-related { opacity: 0.48; }
95
+ .map.has-selection .service-node.is-selected { opacity: 1; }
89
96
  .service-node.is-selected { z-index: 27; }
90
97
  .logo { width: 48px; height: 48px; flex: 0 0 auto; border-radius: 8px; display: grid; place-items: center; overflow: hidden; color: #fff; font-weight: 700; }
91
98
  .logo img { width: 28px; height: 28px; object-fit: contain; filter: invert(1); }
@@ -94,7 +101,7 @@ function pageCss() {
94
101
  .service-copy span { display: block; margin-top: 5px; color: #7f9188; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
95
102
  .status-dot { margin-left: auto; width: 8px; height: 8px; flex: 0 0 auto; border-radius: 999px; box-shadow: 0 0 14px currentColor; }
96
103
  .status-high { color: #65f0ad; background: #65f0ad; }
97
- .status-medium, .status-low { color: #f1c85b; background: #f1c85b; }
104
+ .status-medium, .status-low, .status-manual { color: #f1c85b; background: #f1c85b; }
98
105
  .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; }
99
106
  .legend i { display: inline-block; width: 8px; height: 8px; margin-right: 8px; border-radius: 999px; }
100
107
  .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); }
@@ -104,7 +111,7 @@ function pageCss() {
104
111
  .fit-icon { display: inline-block; width: 15px; height: 15px; border: 2px solid currentColor; border-radius: 3px; }
105
112
  .scan-foot { position: absolute; right: 32px; bottom: 32px; z-index: 20; color: #53665d; text-align: right; font-size: 12px; line-height: 1.6; }
106
113
  .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; }
107
- .drawer-scrim { position: absolute; inset: 0; z-index: 24; background: rgba(0,0,0,0.58); pointer-events: none; }
114
+ .drawer-scrim { position: absolute; inset: 0; z-index: 18; background: rgba(0,0,0,0.58); pointer-events: none; }
108
115
  .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); }
109
116
  .details-head { display: flex; align-items: center; gap: 16px; padding: 26px 26px 22px; border-bottom: 1px solid rgba(255,255,255,0.08); }
110
117
  .details-title { min-width: 0; flex: 1; }
@@ -129,6 +136,12 @@ function pageCss() {
129
136
  .delete-button { color: #ff6b57; background: transparent; font-weight: 800; }
130
137
  .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); }
131
138
  .helper-text { margin: 10px 0 0; color: #687c72; font-size: 12px; line-height: 1.45; }
139
+ .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; }
140
+ .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); }
141
+ .modal-head h2 { margin: 0; color: #f4fbf6; font-size: 20px; letter-spacing: 0; }
142
+ .modal-body { padding: 22px 24px 24px; }
143
+ .modal-actions { display: flex; justify-content: flex-end; gap: 12px; margin-top: 22px; }
144
+ .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; }
132
145
  .graph-3d { position: absolute; inset: 0; overflow: hidden; }
133
146
  .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
147
  .graph-3d canvas { display: block; cursor: grab; }
@@ -142,12 +155,15 @@ function pageCss() {
142
155
  .graph-3d__zoom button + button { border-top: 1px solid rgba(255,255,255,.08); }
143
156
  .graph-3d__zoom button:hover { color: #fff; background: rgba(255,255,255,.08); }
144
157
  .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; } }
158
+ @keyframes servicemapDash { from { stroke-dashoffset: 10; } to { stroke-dashoffset: 0; } }
159
+ @media (prefers-reduced-motion: reduce) { .edge-line.is-active { animation: none; } }
146
160
  @keyframes servicemapLinkFlow { to { background-position: 22px 0; } }
147
161
  @media (max-width: 920px) {
148
162
  .map { min-height: 1120px; }
149
163
  .service-node { width: 220px; }
150
- .top-actions { display: none; }
164
+ .servicemap-topbar { height: auto; min-height: 88px; flex-wrap: wrap; padding: 14px; }
165
+ .top-actions { display: flex; flex-wrap: wrap; gap: 8px; }
166
+ .top-actions > span { display: none; }
151
167
  .details-panel { position: fixed; inset: auto 12px 12px; top: 110px; width: auto; }
152
168
  }
153
169
  `;
@@ -259,20 +275,21 @@ ${htmlBody(graph)}
259
275
  `;
260
276
  }
261
277
 
262
- function interactiveTsxPage(graph) {
278
+ export function interactiveTsxPage(graph, { desktop = false } = {}) {
263
279
  return `'use client';
264
280
 
265
- import dynamic from 'next/dynamic';
266
- import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
281
+ ${desktop ? "import ForceGraph3D from 'react-force-graph-3d';" : "import dynamic from 'next/dynamic';"}
282
+ import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type PointerEvent } from 'react';
267
283
  import type { ForceGraphMethods, NodeObject } from 'react-force-graph-3d';
268
284
  import * as THREE from 'three';
269
285
  import SpriteText from 'three-spritetext';
270
286
 
271
287
  const css = ${JSON.stringify(pageCss())};
272
288
  type Evidence = { type?: string; value?: string; confidence?: string };
273
- type RawService = { id: string; name: string; key?: string; category?: string; confidence?: string; evidence?: Evidence[]; iconUrl?: string | null };
289
+ type RawService = { id: string; name: string; key?: string; category?: string; confidence?: string; evidence?: Evidence[]; iconUrl?: string | null; parentId?: string | null };
274
290
  type ServiceNode = RawService & { color: string; x: number; y: number };
275
291
  type DraftDetails = { subtitle: string; use: string; account: string; passwordLocation: string };
292
+ type NewServiceDraft = { name: string; category: string; parentId: string };
276
293
  type ServiceLink = { label: string; url: string };
277
294
  type GraphNode3D = { id: string; name: string; category: string; color: string; iconUrl?: string | null; monogram: string; isProduct: boolean; x?: number; y?: number; z?: number };
278
295
  type GraphLink3D = { id: string; source: string | GraphNode3D; target: string | GraphNode3D; label: string };
@@ -282,7 +299,7 @@ type OrbitControlsLike = { target?: THREE.Vector3 };
282
299
  const graph = ${JSON.stringify(graph)} as { generatedAt: string; project?: { name?: string; logoUrl?: string }; services?: RawService[] };
283
300
  const palette = ${JSON.stringify(palette)} as string[];
284
301
  const layout = ${JSON.stringify(layout)} as Array<{ x: number; y: number }>;
285
- const ForceGraph3D = dynamic(() => import('react-force-graph-3d'), { ssr: false });
302
+ ${desktop ? '' : "const ForceGraph3D = dynamic(() => import('react-force-graph-3d'), { ssr: false });"}
286
303
 
287
304
  function categoryLabel(value?: string | null) {
288
305
  return String(value ?? 'service').replace(/-/g, ' ');
@@ -292,12 +309,36 @@ function initials(name: string) {
292
309
  return name.split(/\\s+/).filter(Boolean).slice(0, 2).map((part) => part[0]).join('').toUpperCase();
293
310
  }
294
311
 
295
- function edgePath(node: ServiceNode) {
296
- const isLeft = node.x < 50;
297
- const fromX = isLeft ? node.x + 8 : node.x - 8;
298
- const endX = isLeft ? 47 : 53;
299
- const midX = isLeft ? Math.max(fromX + 7, 42) : Math.min(fromX - 7, 58);
300
- return \`M \${fromX} \${node.y} H \${midX} V 50 H \${endX}\`;
312
+ function Connections({ nodes, selectedId }: { nodes: ServiceNode[]; selectedId: string | null }) {
313
+ const ref = useRef<SVGSVGElement>(null);
314
+ const [size, setSize] = useState({ width: 0, height: 0, nodeHalfWidth: 125 });
315
+ useEffect(() => {
316
+ if (!ref.current) return;
317
+ const observer = new ResizeObserver(([entry]) => {
318
+ const node = ref.current?.parentElement?.querySelector('.service-node');
319
+ const nodeHalfWidth = node ? parseFloat(getComputedStyle(node).width) / 2 : 125;
320
+ setSize({ width: entry.contentRect.width, height: entry.contentRect.height, nodeHalfWidth });
321
+ });
322
+ observer.observe(ref.current);
323
+ return () => observer.disconnect();
324
+ }, []);
325
+ return <svg ref={ref} className="edges" aria-hidden="true">
326
+ {size.width > 0 && nodes.map((node) => {
327
+ const parent = nodes.find((candidate) => candidate.id === node.parentId);
328
+ const direction = node.x < (parent?.x ?? 50) ? -1 : 1;
329
+ const x1 = (parent?.x ?? 50) * size.width / 100 + direction * (parent ? size.nodeHalfWidth : 66);
330
+ const y1 = (parent?.y ?? 50) * size.height / 100;
331
+ const x2 = node.x * size.width / 100 - direction * size.nodeHalfWidth;
332
+ const y2 = node.y * size.height / 100;
333
+ const middle = (x1 + x2) / 2;
334
+ const dx = Math.sign(x2 - x1);
335
+ const dy = Math.sign(y2 - y1);
336
+ const radius = Math.min(5, Math.abs(x2 - x1) / 4, Math.abs(y2 - y1) / 2);
337
+ const path = \`M \${x1} \${y1} H \${middle - dx * radius} Q \${middle} \${y1} \${middle} \${y1 + dy * radius} V \${y2 - dy * radius} Q \${middle} \${y2} \${middle + dx * radius} \${y2} H \${x2}\`;
338
+ const active = selectedId === node.id || selectedId === node.parentId;
339
+ return <path key={node.id} d={path} fill="none" stroke="rgba(103,130,118,.48)" strokeWidth="1.2" className={\`edge-line \${active ? 'is-active' : selectedId ? 'is-muted' : ''}\`} />;
340
+ })}
341
+ </svg>;
301
342
  }
302
343
 
303
344
  function primaryEvidence(service: RawService) {
@@ -317,6 +358,25 @@ function graphLink(link: GraphLink3D) {
317
358
  return link;
318
359
  }
319
360
 
361
+ function slugify(value: string) {
362
+ return value.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'service';
363
+ }
364
+
365
+ function servicePosition(index: number) {
366
+ const point = layout[index];
367
+ if (point) return point;
368
+ const angle = (index - layout.length) * 2.399963229728653;
369
+ const radius = 34 + ((index - layout.length) % 4) * 4;
370
+ return {
371
+ x: 50 + Math.cos(angle) * radius,
372
+ y: 50 + Math.sin(angle) * radius * 0.72,
373
+ };
374
+ }
375
+
376
+ function relationshipLabel(parentId?: string | null) {
377
+ return parentId ? 'depends on' : 'uses';
378
+ }
379
+
320
380
  function iconObject(node: GraphNode3D, selected: boolean) {
321
381
  const group = new THREE.Group();
322
382
  const radius = node.isProduct ? 10 : 7;
@@ -367,7 +427,7 @@ function iconObject(node: GraphNode3D, selected: boolean) {
367
427
 
368
428
  const label = new SpriteText(node.name);
369
429
  label.color = selected ? '#b9f6da' : '#dfeae4';
370
- label.textHeight = selected ? 5.4 : 4.7;
430
+ label.textHeight = selected ? 4.1 : 3.2;
371
431
  label.fontSize = 220;
372
432
  label.fontWeight = selected ? '700' : '600';
373
433
  label.backgroundColor = 'rgba(5, 8, 7, .72)';
@@ -511,6 +571,70 @@ function DetailsPanel({
511
571
  );
512
572
  }
513
573
 
574
+ function AddServiceModal({
575
+ draft,
576
+ nodes,
577
+ onChange,
578
+ onClose,
579
+ onSubmit,
580
+ }: {
581
+ draft: NewServiceDraft;
582
+ nodes: ServiceNode[];
583
+ onChange: (draft: NewServiceDraft) => void;
584
+ onClose: () => void;
585
+ onSubmit: () => void;
586
+ }) {
587
+ return (
588
+ <>
589
+ <div className="drawer-scrim" />
590
+ <section className="modal-card" aria-label="Add service">
591
+ <div className="modal-head">
592
+ <h2>Add service</h2>
593
+ <button type="button" className="close-button" onClick={onClose} aria-label="Close add service">×</button>
594
+ </div>
595
+ <div className="modal-body">
596
+ <label className="field-label" htmlFor="new-service-name">Service name</label>
597
+ <input
598
+ id="new-service-name"
599
+ className="details-input"
600
+ value={draft.name}
601
+ onChange={(event) => onChange({ ...draft, name: event.target.value })}
602
+ placeholder="Stripe, Vercel, Google Cloud"
603
+ autoFocus
604
+ />
605
+
606
+ <label className="field-label" htmlFor="new-service-category">Use</label>
607
+ <input
608
+ id="new-service-category"
609
+ className="details-input"
610
+ value={draft.category}
611
+ onChange={(event) => onChange({ ...draft, category: event.target.value })}
612
+ placeholder="billing, hosting, analytics"
613
+ />
614
+
615
+ <label className="field-label" htmlFor="new-service-parent">Parent node</label>
616
+ <select
617
+ id="new-service-parent"
618
+ className="details-input"
619
+ value={draft.parentId}
620
+ onChange={(event) => onChange({ ...draft, parentId: event.target.value })}
621
+ >
622
+ <option value="product">{graph.project?.name ?? 'Product'}</option>
623
+ {nodes.map((node) => (
624
+ <option key={node.id} value={node.id}>{node.name}</option>
625
+ ))}
626
+ </select>
627
+
628
+ <div className="modal-actions">
629
+ <button type="button" className="secondary-button" onClick={onClose}>Cancel</button>
630
+ <button type="button" className="save-button" onClick={onSubmit}>Add service</button>
631
+ </div>
632
+ </div>
633
+ </section>
634
+ </>
635
+ );
636
+ }
637
+
514
638
  function ServiceGraph3D({
515
639
  nodes,
516
640
  selectedId,
@@ -524,6 +648,7 @@ function ServiceGraph3D({
524
648
  const graphRef = useRef<ForceGraphMethods<GraphNode3D, GraphLink3D> | undefined>(undefined);
525
649
  const [size, setSize] = useState({ width: 900, height: 700 });
526
650
  const hasFramed = useRef(false);
651
+ const hasConfiguredForces = useRef(false);
527
652
  const graphData = useMemo(() => ({
528
653
  nodes: [{
529
654
  id: 'project',
@@ -543,10 +668,10 @@ function ServiceGraph3D({
543
668
  isProduct: node.category === 'product',
544
669
  }))],
545
670
  links: nodes.map((node): GraphLink3D => ({
546
- id: \`project-\${node.id}\`,
547
- source: 'project',
671
+ id: \`\${node.parentId ?? 'project'}-\${node.id}\`,
672
+ source: node.parentId ?? 'project',
548
673
  target: node.id,
549
- label: node.confidence === 'high' ? 'uses' : 'may use',
674
+ label: node.confidence === 'high' ? relationshipLabel(node.parentId) : \`may \${relationshipLabel(node.parentId)}\`,
550
675
  })),
551
676
  }), [nodes]);
552
677
 
@@ -573,17 +698,28 @@ function ServiceGraph3D({
573
698
  return () => observer.disconnect();
574
699
  }, []);
575
700
 
576
- useEffect(() => {
701
+ const configureForces = useCallback(() => {
577
702
  const graphInstance = graphRef.current;
578
- if (!graphInstance) return;
703
+ if (!graphInstance || hasConfiguredForces.current) return;
579
704
  const charge = graphInstance.d3Force('charge') as AdjustableForce | undefined;
580
705
  const link = graphInstance.d3Force('link') as AdjustableForce | undefined;
581
706
  graphInstance.renderer().setPixelRatio(Math.min(window.devicePixelRatio * 1.5, 3));
582
- charge?.strength?.(-230);
583
- link?.distance?.(92);
707
+ charge?.strength?.(-720);
708
+ link?.distance?.(210);
584
709
  graphInstance.d3ReheatSimulation();
710
+ hasConfiguredForces.current = true;
711
+ }, []);
712
+
713
+ useEffect(() => {
585
714
  hasFramed.current = false;
586
- }, [graphData.nodes.length, graphData.links.length]);
715
+ hasConfiguredForces.current = false;
716
+ const frame = window.requestAnimationFrame(configureForces);
717
+ const initialFit = window.setTimeout(() => graphRef.current?.zoomToFit(400, 100), 350);
718
+ return () => {
719
+ window.cancelAnimationFrame(frame);
720
+ window.clearTimeout(initialFit);
721
+ };
722
+ }, [configureForces, graphData.nodes.length, graphData.links.length]);
587
723
 
588
724
  const focusNode = useCallback((node: NodeObject) => {
589
725
  const serviceNode = graphNode(node);
@@ -653,16 +789,17 @@ function ServiceGraph3D({
653
789
  linkDirectionalParticleWidth={1.8}
654
790
  linkDirectionalParticleSpeed={0.006}
655
791
  linkDirectionalParticleColor={() => '#b9f6da'}
656
- d3AlphaDecay={0.035}
657
- d3VelocityDecay={0.24}
658
- warmupTicks={50}
659
- cooldownTicks={160}
792
+ d3AlphaDecay={0.026}
793
+ d3VelocityDecay={0.18}
794
+ warmupTicks={90}
795
+ cooldownTicks={260}
660
796
  onNodeClick={focusNode}
661
797
  onBackgroundClick={() => onSelect(null)}
798
+ onEngineTick={configureForces}
662
799
  onEngineStop={() => {
663
800
  if (hasFramed.current) return;
664
801
  hasFramed.current = true;
665
- graphRef.current?.zoomToFit(700, 72);
802
+ graphRef.current?.zoomToFit(700, 130);
666
803
  }}
667
804
  />
668
805
  <div className="graph-3d__zoom" aria-label="3D zoom controls">
@@ -676,14 +813,19 @@ function ServiceGraph3D({
676
813
  }
677
814
 
678
815
  export default function InternalServiceMapPage() {
679
- const nodes = useMemo<ServiceNode[]>(() => (graph.services ?? []).slice(0, layout.length).map((service, index) => ({
816
+ const [services, setServices] = useState<RawService[]>(() => graph.services ?? []);
817
+ const nodes = useMemo<ServiceNode[]>(() => services.map((service, index) => ({
680
818
  ...service,
681
- ...layout[index],
819
+ ...servicePosition(index),
682
820
  color: palette[index % palette.length],
683
- })), []);
821
+ })), [services]);
684
822
  const [mode, setMode] = useState<'2d' | '3d'>('2d');
685
823
  const [zoom, setZoom] = useState(1);
824
+ const [pan, setPan] = useState({ x: 0, y: 0 });
825
+ const [panStart, setPanStart] = useState<{ pointerId: number; x: number; y: number; panX: number; panY: number } | null>(null);
686
826
  const [selectedId, setSelectedId] = useState<string | null>(null);
827
+ const [isAddingService, setIsAddingService] = useState(false);
828
+ const [newService, setNewService] = useState<NewServiceDraft>({ name: '', category: '', parentId: 'product' });
687
829
  const [drafts, setDrafts] = useState<Record<string, DraftDetails>>(() => Object.fromEntries(nodes.map((service) => [
688
830
  service.id,
689
831
  {
@@ -696,7 +838,61 @@ export default function InternalServiceMapPage() {
696
838
  const selected = nodes.find((service) => service.id === selectedId) ?? null;
697
839
  const zoomIn = () => setZoom((current) => Math.min(1.45, Number((current + 0.12).toFixed(2))));
698
840
  const zoomOut = () => setZoom((current) => Math.max(0.7, Number((current - 0.12).toFixed(2))));
699
- const resetZoom = () => setZoom(1);
841
+ const resetZoom = () => {
842
+ setZoom(1);
843
+ setPan({ x: 0, y: 0 });
844
+ };
845
+ const startPan = (event: PointerEvent<HTMLElement>) => {
846
+ if (mode !== '2d' || event.button !== 0 || (event.target as HTMLElement).closest('button, a, input, textarea, select')) return;
847
+ event.currentTarget.setPointerCapture(event.pointerId);
848
+ setPanStart({ pointerId: event.pointerId, x: event.clientX, y: event.clientY, panX: pan.x, panY: pan.y });
849
+ };
850
+ const movePan = (event: PointerEvent<HTMLElement>) => {
851
+ if (!panStart || panStart.pointerId !== event.pointerId) return;
852
+ setPan({
853
+ x: panStart.panX + event.clientX - panStart.x,
854
+ y: panStart.panY + event.clientY - panStart.y,
855
+ });
856
+ };
857
+ const stopPan = (event: PointerEvent<HTMLElement>) => {
858
+ if (panStart?.pointerId !== event.pointerId) return;
859
+ setPanStart(null);
860
+ };
861
+ const addService = () => {
862
+ const name = newService.name.trim();
863
+ if (!name) return;
864
+ const baseId = slugify(name);
865
+ const existingIds = new Set(services.map((service) => service.id));
866
+ let id = baseId;
867
+ let suffix = 2;
868
+ while (existingIds.has(id)) {
869
+ id = \`\${baseId}-\${suffix}\`;
870
+ suffix += 1;
871
+ }
872
+ const service: RawService = {
873
+ id,
874
+ key: id,
875
+ name,
876
+ category: newService.category.trim() || 'service',
877
+ confidence: 'manual',
878
+ evidence: [{ type: 'manual', value: 'Added in servicemap UI.', confidence: 'manual' }],
879
+ iconUrl: null,
880
+ parentId: newService.parentId === 'product' ? null : newService.parentId,
881
+ };
882
+ setServices((current) => [...current, service]);
883
+ setDrafts((current) => ({
884
+ ...current,
885
+ [id]: {
886
+ subtitle: categoryLabel(service.category),
887
+ use: primaryEvidence(service),
888
+ account: '',
889
+ passwordLocation: '',
890
+ },
891
+ }));
892
+ setSelectedId(id);
893
+ setNewService({ name: '', category: '', parentId: 'product' });
894
+ setIsAddingService(false);
895
+ };
700
896
 
701
897
  return (
702
898
  <main className="servicemap-shell">
@@ -711,6 +907,7 @@ export default function InternalServiceMapPage() {
711
907
  </div>
712
908
  <div className="top-actions">
713
909
  <span>{nodes.length} services</span>
910
+ <button type="button" className="add-service-button" onClick={() => setIsAddingService(true)}>+ Add service</button>
714
911
  <div className="mode-toggle" aria-label="View mode">
715
912
  <button type="button" className={\`mode-button \${mode === '2d' ? 'is-active' : ''}\`} onClick={() => setMode('2d')}>2D</button>
716
913
  <button type="button" className={\`mode-button \${mode === '3d' ? 'is-active' : ''}\`} onClick={() => setMode('3d')}>3D</button>
@@ -718,35 +915,29 @@ export default function InternalServiceMapPage() {
718
915
  </div>
719
916
  </header>
720
917
 
721
- <section className="map">
918
+ <section
919
+ className={\`map \${panStart ? 'is-panning' : ''} \${selectedId ? 'has-selection' : ''}\`}
920
+ onPointerDown={startPan}
921
+ onPointerMove={movePan}
922
+ onPointerUp={stopPan}
923
+ onPointerCancel={stopPan}
924
+ >
722
925
  <div className="intro">
723
926
  <strong>{graph.project?.name ?? 'Project'} infrastructure</strong>
724
927
  <span>{selected ? \`Tracing \${selected.name}\` : mode === '3d' ? 'Rotate and explore your service relationships' : 'Select a service to trace its dependencies'}</span>
725
928
  </div>
726
929
  {mode === '2d' ? (
727
930
  <>
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>
931
+ <div className="map-plane" style={{ '--zoom': zoom, '--pan-x': \`\${pan.x}px\`, '--pan-y': \`\${pan.y}px\` } as CSSProperties}>
932
+ <Connections nodes={nodes} selectedId={selectedId} />
744
933
  <div className="hub"><div className="hub-inner"><ProjectMark /></div></div>
745
- {nodes.map((service) => (
934
+ {nodes.map((service) => {
935
+ const isRelated = selectedId === service.parentId || selected?.parentId === service.id;
936
+ return (
746
937
  <button
747
938
  type="button"
748
939
  key={service.id}
749
- className={\`service-node \${selectedId === service.id ? 'is-selected' : ''}\`}
940
+ className={\`service-node \${selectedId === service.id ? 'is-selected' : ''} \${isRelated ? 'is-related' : ''}\`}
750
941
  style={{ left: \`\${service.x}%\`, top: \`\${service.y}%\` }}
751
942
  onClick={() => setSelectedId(service.id)}
752
943
  >
@@ -757,7 +948,8 @@ export default function InternalServiceMapPage() {
757
948
  </div>
758
949
  <i className={\`status-dot status-\${service.confidence}\`} />
759
950
  </button>
760
- ))}
951
+ );
952
+ })}
761
953
  </div>
762
954
  <div className="legend">
763
955
  <span><i style={{ background: '#4a7cff' }} />Cloud</span>
@@ -785,6 +977,15 @@ export default function InternalServiceMapPage() {
785
977
  onChange={(nextDetails) => setDrafts((current) => ({ ...current, [selected.id]: nextDetails }))}
786
978
  />
787
979
  ) : null}
980
+ {isAddingService ? (
981
+ <AddServiceModal
982
+ draft={newService}
983
+ nodes={nodes}
984
+ onChange={setNewService}
985
+ onClose={() => setIsAddingService(false)}
986
+ onSubmit={addService}
987
+ />
988
+ ) : null}
788
989
  </section>
789
990
  </main>
790
991
  );
@@ -844,7 +1045,7 @@ const html = ${JSON.stringify(htmlBody(graph))};
844
1045
  `;
845
1046
  }
846
1047
 
847
- async function prepareProjectLogo(graph, outputRoot) {
1048
+ export async function prepareProjectLogo(graph, outputRoot) {
848
1049
  const logoPath = graph.project?.logo?.path;
849
1050
  if (!logoPath) return;
850
1051
 
@@ -922,7 +1123,7 @@ async function main() {
922
1123
  console.log(`Wrote /internalservicemap page to ${route.path}`);
923
1124
  }
924
1125
 
925
- main().catch((error) => {
1126
+ if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) main().catch((error) => {
926
1127
  console.error(error);
927
1128
  process.exit(1);
928
1129
  });