servicemap 0.1.38 → 0.1.41

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.41",
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,12 @@ 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
+ Drag a service and verify that only that node moves and its connections follow, including after zooming.
30
+ After testing 3D, return the delivered preview to 2D and close any open details panel.
31
+ Always initialize new maps in 2D; do not persist the last selected view mode.
32
+ Desktop output uses the same interactive component as website output; missing interactions are generator bugs to fix, not controls to remove.
27
33
  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
34
  Do not deploy unless the user explicitly says yes.
29
35
  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,12 +83,14 @@ 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); }
89
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); }
90
- button.service-node { cursor: pointer; color: inherit; font: inherit; text-align: left; }
91
+ button.service-node { cursor: grab; touch-action: none; user-select: none; color: inherit; font: inherit; text-align: left; }
92
+ button.service-node:active { cursor: grabbing; }
93
+ .service-node img { pointer-events: none; }
91
94
  .service-node:hover, .service-node.is-selected { border-color: #66efae; box-shadow: 0 0 0 2px rgba(101,240,173,0.42), 0 18px 44px rgba(0,0,0,0.38); }
92
95
  .map.has-selection .service-node { opacity: 0.16; }
93
96
  .map.has-selection .service-node.is-related { opacity: 0.48; }
@@ -154,12 +157,15 @@ function pageCss() {
154
157
  .graph-3d__zoom button + button { border-top: 1px solid rgba(255,255,255,.08); }
155
158
  .graph-3d__zoom button:hover { color: #fff; background: rgba(255,255,255,.08); }
156
159
  .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; } }
160
+ @keyframes servicemapDash { from { stroke-dashoffset: 10; } to { stroke-dashoffset: 0; } }
161
+ @media (prefers-reduced-motion: reduce) { .edge-line.is-active { animation: none; } }
158
162
  @keyframes servicemapLinkFlow { to { background-position: 22px 0; } }
159
163
  @media (max-width: 920px) {
160
164
  .map { min-height: 1120px; }
161
165
  .service-node { width: 220px; }
162
- .top-actions { display: none; }
166
+ .servicemap-topbar { height: auto; min-height: 88px; flex-wrap: wrap; padding: 14px; }
167
+ .top-actions { display: flex; flex-wrap: wrap; gap: 8px; }
168
+ .top-actions > span { display: none; }
163
169
  .details-panel { position: fixed; inset: auto 12px 12px; top: 110px; width: auto; }
164
170
  }
165
171
  `;
@@ -271,10 +277,10 @@ ${htmlBody(graph)}
271
277
  `;
272
278
  }
273
279
 
274
- function interactiveTsxPage(graph) {
280
+ export function interactiveTsxPage(graph, { desktop = false } = {}) {
275
281
  return `'use client';
276
282
 
277
- import dynamic from 'next/dynamic';
283
+ ${desktop ? "import ForceGraph3D from 'react-force-graph-3d';" : "import dynamic from 'next/dynamic';"}
278
284
  import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type PointerEvent } from 'react';
279
285
  import type { ForceGraphMethods, NodeObject } from 'react-force-graph-3d';
280
286
  import * as THREE from 'three';
@@ -295,7 +301,7 @@ type OrbitControlsLike = { target?: THREE.Vector3 };
295
301
  const graph = ${JSON.stringify(graph)} as { generatedAt: string; project?: { name?: string; logoUrl?: string }; services?: RawService[] };
296
302
  const palette = ${JSON.stringify(palette)} as string[];
297
303
  const layout = ${JSON.stringify(layout)} as Array<{ x: number; y: number }>;
298
- const ForceGraph3D = dynamic(() => import('react-force-graph-3d'), { ssr: false });
304
+ ${desktop ? '' : "const ForceGraph3D = dynamic(() => import('react-force-graph-3d'), { ssr: false });"}
299
305
 
300
306
  function categoryLabel(value?: string | null) {
301
307
  return String(value ?? 'service').replace(/-/g, ' ');
@@ -305,17 +311,36 @@ function initials(name: string) {
305
311
  return name.split(/\\s+/).filter(Boolean).slice(0, 2).map((part) => part[0]).join('').toUpperCase();
306
312
  }
307
313
 
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}\`;
314
+ function Connections({ nodes, selectedId }: { nodes: ServiceNode[]; selectedId: string | null }) {
315
+ const ref = useRef<SVGSVGElement>(null);
316
+ const [size, setSize] = useState({ width: 0, height: 0, nodeHalfWidth: 125 });
317
+ useEffect(() => {
318
+ if (!ref.current) return;
319
+ const observer = new ResizeObserver(([entry]) => {
320
+ const node = ref.current?.parentElement?.querySelector('.service-node');
321
+ const nodeHalfWidth = node ? parseFloat(getComputedStyle(node).width) / 2 : 125;
322
+ setSize({ width: entry.contentRect.width, height: entry.contentRect.height, nodeHalfWidth });
323
+ });
324
+ observer.observe(ref.current);
325
+ return () => observer.disconnect();
326
+ }, []);
327
+ return <svg ref={ref} className="edges" aria-hidden="true">
328
+ {size.width > 0 && nodes.map((node) => {
329
+ const parent = nodes.find((candidate) => candidate.id === node.parentId);
330
+ const direction = node.x < (parent?.x ?? 50) ? -1 : 1;
331
+ const x1 = (parent?.x ?? 50) * size.width / 100 + direction * (parent ? size.nodeHalfWidth : 66);
332
+ const y1 = (parent?.y ?? 50) * size.height / 100;
333
+ const x2 = node.x * size.width / 100 - direction * size.nodeHalfWidth;
334
+ const y2 = node.y * size.height / 100;
335
+ const middle = (x1 + x2) / 2;
336
+ const dx = Math.sign(x2 - x1);
337
+ const dy = Math.sign(y2 - y1);
338
+ const radius = Math.min(5, Math.abs(x2 - x1) / 4, Math.abs(y2 - y1) / 2);
339
+ 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}\`;
340
+ const active = selectedId === node.id || selectedId === node.parentId;
341
+ 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' : ''}\`} />;
342
+ })}
343
+ </svg>;
319
344
  }
320
345
 
321
346
  function primaryEvidence(service: RawService) {
@@ -691,7 +716,11 @@ function ServiceGraph3D({
691
716
  hasFramed.current = false;
692
717
  hasConfiguredForces.current = false;
693
718
  const frame = window.requestAnimationFrame(configureForces);
694
- return () => window.cancelAnimationFrame(frame);
719
+ const initialFit = window.setTimeout(() => graphRef.current?.zoomToFit(400, 100), 350);
720
+ return () => {
721
+ window.cancelAnimationFrame(frame);
722
+ window.clearTimeout(initialFit);
723
+ };
695
724
  }, [configureForces, graphData.nodes.length, graphData.links.length]);
696
725
 
697
726
  const focusNode = useCallback((node: NodeObject) => {
@@ -787,16 +816,64 @@ function ServiceGraph3D({
787
816
 
788
817
  export default function InternalServiceMapPage() {
789
818
  const [services, setServices] = useState<RawService[]>(() => graph.services ?? []);
819
+ const [positions, setPositions] = useState<Record<string, { x: number; y: number }>>({});
820
+ const positionsRef = useRef(positions);
821
+ const layoutKey = 'servicemap:positions:' + (graph.project?.name ?? 'project');
822
+ const nodeDrag = useRef<{ id: string; pointerId: number; clientX: number; clientY: number; x: number; y: number; width: number; height: number; moved: boolean } | null>(null);
823
+ const suppressClick = useRef(false);
824
+ useEffect(() => {
825
+ try {
826
+ const stored = JSON.parse(localStorage.getItem(layoutKey) ?? '{}');
827
+ const valid = Object.fromEntries(Object.entries(stored).filter(([, value]) => {
828
+ const point = value as { x?: number; y?: number } | null;
829
+ return point && Number.isFinite(point.x) && Number.isFinite(point.y);
830
+ })) as Record<string, { x: number; y: number }>;
831
+ positionsRef.current = valid;
832
+ setPositions(valid);
833
+ } catch { /* Layout remains usable when storage is unavailable. */ }
834
+ }, [layoutKey]);
790
835
  const nodes = useMemo<ServiceNode[]>(() => services.map((service, index) => ({
791
836
  ...service,
792
837
  ...servicePosition(index),
838
+ ...positions[service.id],
793
839
  color: palette[index % palette.length],
794
- })), [services]);
840
+ })), [services, positions]);
795
841
  const [mode, setMode] = useState<'2d' | '3d'>('2d');
796
842
  const [zoom, setZoom] = useState(1);
797
843
  const [pan, setPan] = useState({ x: 0, y: 0 });
798
844
  const [panStart, setPanStart] = useState<{ pointerId: number; x: number; y: number; panX: number; panY: number } | null>(null);
799
845
  const [selectedId, setSelectedId] = useState<string | null>(null);
846
+ const startNodeDrag = (event: PointerEvent<HTMLButtonElement>, node: ServiceNode) => {
847
+ if (event.button !== 0) return;
848
+ event.stopPropagation();
849
+ const plane = event.currentTarget.parentElement!.getBoundingClientRect();
850
+ event.currentTarget.setPointerCapture(event.pointerId);
851
+ suppressClick.current = false;
852
+ nodeDrag.current = { id: node.id, pointerId: event.pointerId, clientX: event.clientX, clientY: event.clientY, x: node.x, y: node.y, width: plane.width, height: plane.height, moved: false };
853
+ };
854
+ const moveNodeDrag = (event: PointerEvent<HTMLButtonElement>) => {
855
+ const drag = nodeDrag.current;
856
+ if (!drag || drag.pointerId !== event.pointerId) return;
857
+ event.stopPropagation();
858
+ const dx = event.clientX - drag.clientX;
859
+ const dy = event.clientY - drag.clientY;
860
+ if (!drag.moved && Math.hypot(dx, dy) < 4) return;
861
+ drag.moved = true;
862
+ suppressClick.current = true;
863
+ const next = { ...positionsRef.current, [drag.id]: { x: drag.x + dx / drag.width * 100, y: drag.y + dy / drag.height * 100 } };
864
+ positionsRef.current = next;
865
+ setPositions(next);
866
+ };
867
+ const stopNodeDrag = (event: PointerEvent<HTMLButtonElement>) => {
868
+ const drag = nodeDrag.current;
869
+ if (!drag || drag.pointerId !== event.pointerId) return;
870
+ event.stopPropagation();
871
+ nodeDrag.current = null;
872
+ if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
873
+ if (drag.moved) {
874
+ try { localStorage.setItem(layoutKey, JSON.stringify(positionsRef.current)); } catch { /* Keep the current layout in memory. */ }
875
+ }
876
+ };
800
877
  const [isAddingService, setIsAddingService] = useState(false);
801
878
  const [newService, setNewService] = useState<NewServiceDraft>({ name: '', category: '', parentId: 'product' });
802
879
  const [drafts, setDrafts] = useState<Record<string, DraftDetails>>(() => Object.fromEntries(nodes.map((service) => [
@@ -809,7 +886,6 @@ export default function InternalServiceMapPage() {
809
886
  },
810
887
  ])));
811
888
  const selected = nodes.find((service) => service.id === selectedId) ?? null;
812
- const nodeById = useMemo(() => new Map(nodes.map((node) => [node.id, node])), [nodes]);
813
889
  const zoomIn = () => setZoom((current) => Math.min(1.45, Number((current + 0.12).toFixed(2))));
814
890
  const zoomOut = () => setZoom((current) => Math.max(0.7, Number((current - 0.12).toFixed(2))));
815
891
  const resetZoom = () => {
@@ -903,25 +979,7 @@ export default function InternalServiceMapPage() {
903
979
  {mode === '2d' ? (
904
980
  <>
905
981
  <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>
982
+ <Connections nodes={nodes} selectedId={selectedId} />
925
983
  <div className="hub"><div className="hub-inner"><ProjectMark /></div></div>
926
984
  {nodes.map((service) => {
927
985
  const isRelated = selectedId === service.parentId || selected?.parentId === service.id;
@@ -931,7 +989,15 @@ export default function InternalServiceMapPage() {
931
989
  key={service.id}
932
990
  className={\`service-node \${selectedId === service.id ? 'is-selected' : ''} \${isRelated ? 'is-related' : ''}\`}
933
991
  style={{ left: \`\${service.x}%\`, top: \`\${service.y}%\` }}
934
- onClick={() => setSelectedId(service.id)}
992
+ onPointerDown={(event) => startNodeDrag(event, service)}
993
+ onPointerMove={moveNodeDrag}
994
+ onPointerUp={stopNodeDrag}
995
+ onPointerCancel={stopNodeDrag}
996
+ onLostPointerCapture={stopNodeDrag}
997
+ onClick={(event) => {
998
+ if (event.detail !== 0 && suppressClick.current) { suppressClick.current = false; return; }
999
+ setSelectedId(service.id);
1000
+ }}
935
1001
  >
936
1002
  <ServiceLogo service={service} />
937
1003
  <div className="service-copy">
@@ -1037,7 +1103,7 @@ const html = ${JSON.stringify(htmlBody(graph))};
1037
1103
  `;
1038
1104
  }
1039
1105
 
1040
- async function prepareProjectLogo(graph, outputRoot) {
1106
+ export async function prepareProjectLogo(graph, outputRoot) {
1041
1107
  const logoPath = graph.project?.logo?.path;
1042
1108
  if (!logoPath) return;
1043
1109
 
@@ -1115,7 +1181,7 @@ async function main() {
1115
1181
  console.log(`Wrote /internalservicemap page to ${route.path}`);
1116
1182
  }
1117
1183
 
1118
- main().catch((error) => {
1184
+ if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) main().catch((error) => {
1119
1185
  console.error(error);
1120
1186
  process.exit(1);
1121
1187
  });