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
package/servicemap/SKILL.md
CHANGED
|
@@ -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',
|
|
664
|
-
|
|
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: #
|
|
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:
|
|
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:
|
|
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
|
-
.
|
|
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
|
|
309
|
-
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
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
|
});
|