servicemap 0.1.38 → 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.38",
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';
@@ -82,7 +83,7 @@ function pageCss() {
82
83
  .intro span { display: block; margin-top: 8px; color: #53665d; font-size: 14px; }
83
84
  .edges { position: absolute; inset: 0; z-index: 26; width: 100%; height: 100%; pointer-events: none; }
84
85
  .edge-line { transition: opacity 180ms ease, stroke 180ms ease; }
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-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; }
86
87
  .edge-line.is-muted { opacity: 0.12; }
87
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); }
88
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); }
@@ -154,12 +155,15 @@ function pageCss() {
154
155
  .graph-3d__zoom button + button { border-top: 1px solid rgba(255,255,255,.08); }
155
156
  .graph-3d__zoom button:hover { color: #fff; background: rgba(255,255,255,.08); }
156
157
  .graph-3d__loading { position: absolute; inset: 0; display: grid; place-items: center; color: #75857c; font-size: 9px; }
157
- @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; } }
158
160
  @keyframes servicemapLinkFlow { to { background-position: 22px 0; } }
159
161
  @media (max-width: 920px) {
160
162
  .map { min-height: 1120px; }
161
163
  .service-node { width: 220px; }
162
- .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; }
163
167
  .details-panel { position: fixed; inset: auto 12px 12px; top: 110px; width: auto; }
164
168
  }
165
169
  `;
@@ -271,10 +275,10 @@ ${htmlBody(graph)}
271
275
  `;
272
276
  }
273
277
 
274
- function interactiveTsxPage(graph) {
278
+ export function interactiveTsxPage(graph, { desktop = false } = {}) {
275
279
  return `'use client';
276
280
 
277
- import dynamic from 'next/dynamic';
281
+ ${desktop ? "import ForceGraph3D from 'react-force-graph-3d';" : "import dynamic from 'next/dynamic';"}
278
282
  import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type PointerEvent } from 'react';
279
283
  import type { ForceGraphMethods, NodeObject } from 'react-force-graph-3d';
280
284
  import * as THREE from 'three';
@@ -295,7 +299,7 @@ type OrbitControlsLike = { target?: THREE.Vector3 };
295
299
  const graph = ${JSON.stringify(graph)} as { generatedAt: string; project?: { name?: string; logoUrl?: string }; services?: RawService[] };
296
300
  const palette = ${JSON.stringify(palette)} as string[];
297
301
  const layout = ${JSON.stringify(layout)} as Array<{ x: number; y: number }>;
298
- const ForceGraph3D = dynamic(() => import('react-force-graph-3d'), { ssr: false });
302
+ ${desktop ? '' : "const ForceGraph3D = dynamic(() => import('react-force-graph-3d'), { ssr: false });"}
299
303
 
300
304
  function categoryLabel(value?: string | null) {
301
305
  return String(value ?? 'service').replace(/-/g, ' ');
@@ -305,17 +309,36 @@ function initials(name: string) {
305
309
  return name.split(/\\s+/).filter(Boolean).slice(0, 2).map((part) => part[0]).join('').toUpperCase();
306
310
  }
307
311
 
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) {
313
- const isLeft = node.x < 50;
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}\`;
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>;
319
342
  }
320
343
 
321
344
  function primaryEvidence(service: RawService) {
@@ -691,7 +714,11 @@ function ServiceGraph3D({
691
714
  hasFramed.current = false;
692
715
  hasConfiguredForces.current = false;
693
716
  const frame = window.requestAnimationFrame(configureForces);
694
- return () => window.cancelAnimationFrame(frame);
717
+ const initialFit = window.setTimeout(() => graphRef.current?.zoomToFit(400, 100), 350);
718
+ return () => {
719
+ window.cancelAnimationFrame(frame);
720
+ window.clearTimeout(initialFit);
721
+ };
695
722
  }, [configureForces, graphData.nodes.length, graphData.links.length]);
696
723
 
697
724
  const focusNode = useCallback((node: NodeObject) => {
@@ -809,7 +836,6 @@ export default function InternalServiceMapPage() {
809
836
  },
810
837
  ])));
811
838
  const selected = nodes.find((service) => service.id === selectedId) ?? null;
812
- const nodeById = useMemo(() => new Map(nodes.map((node) => [node.id, node])), [nodes]);
813
839
  const zoomIn = () => setZoom((current) => Math.min(1.45, Number((current + 0.12).toFixed(2))));
814
840
  const zoomOut = () => setZoom((current) => Math.max(0.7, Number((current - 0.12).toFixed(2))));
815
841
  const resetZoom = () => {
@@ -903,25 +929,7 @@ export default function InternalServiceMapPage() {
903
929
  {mode === '2d' ? (
904
930
  <>
905
931
  <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>
932
+ <Connections nodes={nodes} selectedId={selectedId} />
925
933
  <div className="hub"><div className="hub-inner"><ProjectMark /></div></div>
926
934
  {nodes.map((service) => {
927
935
  const isRelated = selectedId === service.parentId || selected?.parentId === service.id;
@@ -1037,7 +1045,7 @@ const html = ${JSON.stringify(htmlBody(graph))};
1037
1045
  `;
1038
1046
  }
1039
1047
 
1040
- async function prepareProjectLogo(graph, outputRoot) {
1048
+ export async function prepareProjectLogo(graph, outputRoot) {
1041
1049
  const logoPath = graph.project?.logo?.path;
1042
1050
  if (!logoPath) return;
1043
1051
 
@@ -1115,7 +1123,7 @@ async function main() {
1115
1123
  console.log(`Wrote /internalservicemap page to ${route.path}`);
1116
1124
  }
1117
1125
 
1118
- main().catch((error) => {
1126
+ if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) main().catch((error) => {
1119
1127
  console.error(error);
1120
1128
  process.exit(1);
1121
1129
  });