servicemap 0.1.34 → 0.1.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "servicemap",
3
- "version": "0.1.34",
3
+ "version": "0.1.38",
4
4
  "description": "Generate an internal service map page or desktop app for a software project.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -69,23 +69,29 @@ function pageCss() {
69
69
  .brand h1 { margin: 0; font-size: 20px; line-height: 1; letter-spacing: 0; }
70
70
  .brand p, .top-meta { margin: 6px 0 0; color: #8aa097; font-size: 14px; }
71
71
  .top-actions { display: flex; align-items: center; gap: 12px; color: #8da59a; font-size: 14px; white-space: nowrap; }
72
+ .add-service-button { min-height: 40px; border: 0; border-radius: 10px; background: #287a5a; color: #effbf5; padding: 0 14px; cursor: pointer; font: inherit; font-weight: 800; box-shadow: 0 10px 28px rgba(54,180,126,0.16); }
72
73
  .mode-toggle { display: inline-flex; gap: 4px; padding: 4px; border-radius: 10px; background: #0b130f; }
73
74
  .mode-button { min-height: 36px; border: 0; border-radius: 8px; background: transparent; color: #789086; padding: 0 12px; cursor: pointer; font: inherit; font-weight: 800; }
74
75
  .mode-button.is-active { background: #276f53; color: #c9f9df; box-shadow: 0 0 18px rgba(101,240,173,0.16); }
75
76
  .map { position: relative; height: calc(100vh - 88px); min-height: 760px; background: radial-gradient(circle at 50% 52%, rgba(41, 96, 69, 0.22), transparent 24%), radial-gradient(circle at 1px 1px, rgba(88, 160, 120, 0.2) 1px, transparent 1px), #020806; background-size: auto, 28px 28px, auto; }
76
- .map-plane { position: absolute; inset: 0; transform: scale(var(--zoom, 1)); transform-origin: 50% 50%; transition: transform 160ms ease; }
77
+ .map.is-panning { cursor: grabbing; user-select: none; }
78
+ .map-plane { position: absolute; inset: 0; z-index: 20; transform: translate3d(var(--pan-x, 0px), var(--pan-y, 0px), 0) scale(var(--zoom, 1)); transform-origin: 50% 50%; transition: transform 160ms ease; }
79
+ .map.is-panning .map-plane { transition: none; }
77
80
  .intro { position: absolute; left: 32px; top: 32px; z-index: 20; }
78
81
  .intro strong { display: block; color: #779286; font-size: 12px; letter-spacing: 0.22em; text-transform: uppercase; }
79
82
  .intro span { display: block; margin-top: 8px; color: #53665d; font-size: 14px; }
80
83
  .edges { position: absolute; inset: 0; z-index: 26; width: 100%; height: 100%; pointer-events: none; }
81
84
  .edge-line { transition: opacity 180ms ease, stroke 180ms ease; }
82
- .edge-line.is-active { stroke: #65f0ad !important; stroke-width: 0.28; stroke-linecap: round; stroke-dasharray: 0.72 0.58; animation: servicemapDash 1.1s linear infinite; filter: drop-shadow(0 0 6px rgba(101,240,173,0.72)); opacity: 1; }
83
- .edge-line.is-muted { opacity: 0.24; }
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-muted { opacity: 0.12; }
84
87
  .hub { position: absolute; left: 50%; top: 50%; z-index: 20; width: 132px; height: 132px; transform: translate(-50%, -50%); border-radius: 24px; border: 1px solid #315c47; background: #09120d; display: grid; place-items: center; box-shadow: 0 0 0 9px rgba(79,183,128,0.12), 0 0 42px rgba(100,255,174,0.16); }
85
88
  .hub-inner { width: 92px; height: 92px; border-radius: 18px; background: #fbfbf7; color: #111612; display: grid; place-items: center; font-size: 40px; box-shadow: inset 0 0 0 1px rgba(0,0,0,0.08); }
86
89
  .service-node { position: absolute; z-index: 10; width: 250px; height: 74px; transform: translate(-50%, -50%); display: flex; align-items: center; gap: 16px; padding: 0 16px; border-radius: 8px; border: 1px solid #274036; background: rgba(16,24,19,0.95); box-shadow: 0 0 0 1px rgba(140,255,190,0.05), 0 18px 44px rgba(0,0,0,0.38); }
87
90
  button.service-node { cursor: pointer; color: inherit; font: inherit; text-align: left; }
88
91
  .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
+ .map.has-selection .service-node { opacity: 0.16; }
93
+ .map.has-selection .service-node.is-related { opacity: 0.48; }
94
+ .map.has-selection .service-node.is-selected { opacity: 1; }
89
95
  .service-node.is-selected { z-index: 27; }
90
96
  .logo { width: 48px; height: 48px; flex: 0 0 auto; border-radius: 8px; display: grid; place-items: center; overflow: hidden; color: #fff; font-weight: 700; }
91
97
  .logo img { width: 28px; height: 28px; object-fit: contain; filter: invert(1); }
@@ -94,7 +100,7 @@ function pageCss() {
94
100
  .service-copy span { display: block; margin-top: 5px; color: #7f9188; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
95
101
  .status-dot { margin-left: auto; width: 8px; height: 8px; flex: 0 0 auto; border-radius: 999px; box-shadow: 0 0 14px currentColor; }
96
102
  .status-high { color: #65f0ad; background: #65f0ad; }
97
- .status-medium, .status-low { color: #f1c85b; background: #f1c85b; }
103
+ .status-medium, .status-low, .status-manual { color: #f1c85b; background: #f1c85b; }
98
104
  .legend { position: absolute; left: 50%; bottom: 32px; z-index: 20; transform: translateX(-50%); display: flex; align-items: center; gap: 20px; border: 1px solid rgba(255,255,255,0.1); border-radius: 999px; background: rgba(11,20,15,0.9); color: #789086; padding: 8px 20px; font-size: 12px; }
99
105
  .legend i { display: inline-block; width: 8px; height: 8px; margin-right: 8px; border-radius: 999px; }
100
106
  .zoom-controls { position: absolute; left: 28px; bottom: 26px; z-index: 29; display: grid; overflow: hidden; border: 1px solid rgba(255,255,255,0.12); border-radius: 10px; background: rgba(7,14,10,0.9); box-shadow: 0 12px 32px rgba(0,0,0,0.34); }
@@ -104,7 +110,7 @@ function pageCss() {
104
110
  .fit-icon { display: inline-block; width: 15px; height: 15px; border: 2px solid currentColor; border-radius: 3px; }
105
111
  .scan-foot { position: absolute; right: 32px; bottom: 32px; z-index: 20; color: #53665d; text-align: right; font-size: 12px; line-height: 1.6; }
106
112
  .access-warning { position: absolute; left: 32px; right: 32px; bottom: 88px; z-index: 25; max-width: 720px; border: 1px solid rgba(241,200,91,0.42); border-radius: 8px; background: rgba(31,27,12,0.92); color: #f7daa0; padding: 12px 14px; font-size: 14px; line-height: 1.45; }
107
- .drawer-scrim { position: absolute; inset: 0; z-index: 24; background: rgba(0,0,0,0.58); pointer-events: none; }
113
+ .drawer-scrim { position: absolute; inset: 0; z-index: 18; background: rgba(0,0,0,0.58); pointer-events: none; }
108
114
  .details-panel { position: absolute; right: 22px; top: 64px; bottom: 26px; z-index: 30; width: min(492px, calc(100vw - 44px)); overflow: hidden; border: 1px solid #294034; border-radius: 24px; background: linear-gradient(180deg, rgba(21,31,25,0.98), rgba(8,17,12,0.98)); box-shadow: 0 24px 80px rgba(0,0,0,0.52); }
109
115
  .details-head { display: flex; align-items: center; gap: 16px; padding: 26px 26px 22px; border-bottom: 1px solid rgba(255,255,255,0.08); }
110
116
  .details-title { min-width: 0; flex: 1; }
@@ -129,6 +135,12 @@ function pageCss() {
129
135
  .delete-button { color: #ff6b57; background: transparent; font-weight: 800; }
130
136
  .save-button { min-height: 48px; border-radius: 10px; background: #287a5a; color: #effbf5; padding: 0 18px; font-weight: 800; box-shadow: 0 10px 30px rgba(54,180,126,0.18); }
131
137
  .helper-text { margin: 10px 0 0; color: #687c72; font-size: 12px; line-height: 1.45; }
138
+ .modal-card { position: absolute; left: 50%; top: 50%; z-index: 31; width: min(440px, calc(100vw - 44px)); transform: translate(-50%, -50%); border: 1px solid #294034; border-radius: 20px; background: linear-gradient(180deg, rgba(21,31,25,0.99), rgba(8,17,12,0.99)); box-shadow: 0 24px 80px rgba(0,0,0,0.56); overflow: hidden; }
139
+ .modal-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 22px 24px; border-bottom: 1px solid rgba(255,255,255,0.08); }
140
+ .modal-head h2 { margin: 0; color: #f4fbf6; font-size: 20px; letter-spacing: 0; }
141
+ .modal-body { padding: 22px 24px 24px; }
142
+ .modal-actions { display: flex; justify-content: flex-end; gap: 12px; margin-top: 22px; }
143
+ .secondary-button { min-height: 44px; border: 1px solid #2a3c33; border-radius: 10px; background: rgba(255,255,255,0.04); color: #c8d8d0; padding: 0 16px; cursor: pointer; font: inherit; font-weight: 800; }
132
144
  .graph-3d { position: absolute; inset: 0; overflow: hidden; }
133
145
  .graph-3d::before { content: ''; position: absolute; z-index: 1; inset: 0; pointer-events: none; background: radial-gradient(circle at 50% 50%, transparent 38%, rgba(2,5,4,.46) 100%); }
134
146
  .graph-3d canvas { display: block; cursor: grab; }
@@ -263,16 +275,17 @@ function interactiveTsxPage(graph) {
263
275
  return `'use client';
264
276
 
265
277
  import dynamic from 'next/dynamic';
266
- import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
278
+ import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type PointerEvent } from 'react';
267
279
  import type { ForceGraphMethods, NodeObject } from 'react-force-graph-3d';
268
280
  import * as THREE from 'three';
269
281
  import SpriteText from 'three-spritetext';
270
282
 
271
283
  const css = ${JSON.stringify(pageCss())};
272
284
  type Evidence = { type?: string; value?: string; confidence?: string };
273
- type RawService = { id: string; name: string; key?: string; category?: string; confidence?: string; evidence?: Evidence[]; iconUrl?: string | null };
285
+ type RawService = { id: string; name: string; key?: string; category?: string; confidence?: string; evidence?: Evidence[]; iconUrl?: string | null; parentId?: string | null };
274
286
  type ServiceNode = RawService & { color: string; x: number; y: number };
275
287
  type DraftDetails = { subtitle: string; use: string; account: string; passwordLocation: string };
288
+ type NewServiceDraft = { name: string; category: string; parentId: string };
276
289
  type ServiceLink = { label: string; url: string };
277
290
  type GraphNode3D = { id: string; name: string; category: string; color: string; iconUrl?: string | null; monogram: string; isProduct: boolean; x?: number; y?: number; z?: number };
278
291
  type GraphLink3D = { id: string; source: string | GraphNode3D; target: string | GraphNode3D; label: string };
@@ -293,11 +306,16 @@ function initials(name: string) {
293
306
  }
294
307
 
295
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) {
296
313
  const isLeft = node.x < 50;
297
- const fromX = isLeft ? node.x + 8 : node.x - 8;
298
- const endX = isLeft ? 47 : 53;
299
- const midX = isLeft ? Math.max(fromX + 7, 42) : Math.min(fromX - 7, 58);
300
- return \`M \${fromX} \${node.y} H \${midX} V 50 H \${endX}\`;
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}\`;
301
319
  }
302
320
 
303
321
  function primaryEvidence(service: RawService) {
@@ -317,6 +335,25 @@ function graphLink(link: GraphLink3D) {
317
335
  return link;
318
336
  }
319
337
 
338
+ function slugify(value: string) {
339
+ return value.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'service';
340
+ }
341
+
342
+ function servicePosition(index: number) {
343
+ const point = layout[index];
344
+ if (point) return point;
345
+ const angle = (index - layout.length) * 2.399963229728653;
346
+ const radius = 34 + ((index - layout.length) % 4) * 4;
347
+ return {
348
+ x: 50 + Math.cos(angle) * radius,
349
+ y: 50 + Math.sin(angle) * radius * 0.72,
350
+ };
351
+ }
352
+
353
+ function relationshipLabel(parentId?: string | null) {
354
+ return parentId ? 'depends on' : 'uses';
355
+ }
356
+
320
357
  function iconObject(node: GraphNode3D, selected: boolean) {
321
358
  const group = new THREE.Group();
322
359
  const radius = node.isProduct ? 10 : 7;
@@ -367,7 +404,7 @@ function iconObject(node: GraphNode3D, selected: boolean) {
367
404
 
368
405
  const label = new SpriteText(node.name);
369
406
  label.color = selected ? '#b9f6da' : '#dfeae4';
370
- label.textHeight = selected ? 5.4 : 4.7;
407
+ label.textHeight = selected ? 4.1 : 3.2;
371
408
  label.fontSize = 220;
372
409
  label.fontWeight = selected ? '700' : '600';
373
410
  label.backgroundColor = 'rgba(5, 8, 7, .72)';
@@ -511,6 +548,70 @@ function DetailsPanel({
511
548
  );
512
549
  }
513
550
 
551
+ function AddServiceModal({
552
+ draft,
553
+ nodes,
554
+ onChange,
555
+ onClose,
556
+ onSubmit,
557
+ }: {
558
+ draft: NewServiceDraft;
559
+ nodes: ServiceNode[];
560
+ onChange: (draft: NewServiceDraft) => void;
561
+ onClose: () => void;
562
+ onSubmit: () => void;
563
+ }) {
564
+ return (
565
+ <>
566
+ <div className="drawer-scrim" />
567
+ <section className="modal-card" aria-label="Add service">
568
+ <div className="modal-head">
569
+ <h2>Add service</h2>
570
+ <button type="button" className="close-button" onClick={onClose} aria-label="Close add service">×</button>
571
+ </div>
572
+ <div className="modal-body">
573
+ <label className="field-label" htmlFor="new-service-name">Service name</label>
574
+ <input
575
+ id="new-service-name"
576
+ className="details-input"
577
+ value={draft.name}
578
+ onChange={(event) => onChange({ ...draft, name: event.target.value })}
579
+ placeholder="Stripe, Vercel, Google Cloud"
580
+ autoFocus
581
+ />
582
+
583
+ <label className="field-label" htmlFor="new-service-category">Use</label>
584
+ <input
585
+ id="new-service-category"
586
+ className="details-input"
587
+ value={draft.category}
588
+ onChange={(event) => onChange({ ...draft, category: event.target.value })}
589
+ placeholder="billing, hosting, analytics"
590
+ />
591
+
592
+ <label className="field-label" htmlFor="new-service-parent">Parent node</label>
593
+ <select
594
+ id="new-service-parent"
595
+ className="details-input"
596
+ value={draft.parentId}
597
+ onChange={(event) => onChange({ ...draft, parentId: event.target.value })}
598
+ >
599
+ <option value="product">{graph.project?.name ?? 'Product'}</option>
600
+ {nodes.map((node) => (
601
+ <option key={node.id} value={node.id}>{node.name}</option>
602
+ ))}
603
+ </select>
604
+
605
+ <div className="modal-actions">
606
+ <button type="button" className="secondary-button" onClick={onClose}>Cancel</button>
607
+ <button type="button" className="save-button" onClick={onSubmit}>Add service</button>
608
+ </div>
609
+ </div>
610
+ </section>
611
+ </>
612
+ );
613
+ }
614
+
514
615
  function ServiceGraph3D({
515
616
  nodes,
516
617
  selectedId,
@@ -524,6 +625,7 @@ function ServiceGraph3D({
524
625
  const graphRef = useRef<ForceGraphMethods<GraphNode3D, GraphLink3D> | undefined>(undefined);
525
626
  const [size, setSize] = useState({ width: 900, height: 700 });
526
627
  const hasFramed = useRef(false);
628
+ const hasConfiguredForces = useRef(false);
527
629
  const graphData = useMemo(() => ({
528
630
  nodes: [{
529
631
  id: 'project',
@@ -543,10 +645,10 @@ function ServiceGraph3D({
543
645
  isProduct: node.category === 'product',
544
646
  }))],
545
647
  links: nodes.map((node): GraphLink3D => ({
546
- id: \`project-\${node.id}\`,
547
- source: 'project',
648
+ id: \`\${node.parentId ?? 'project'}-\${node.id}\`,
649
+ source: node.parentId ?? 'project',
548
650
  target: node.id,
549
- label: node.confidence === 'high' ? 'uses' : 'may use',
651
+ label: node.confidence === 'high' ? relationshipLabel(node.parentId) : \`may \${relationshipLabel(node.parentId)}\`,
550
652
  })),
551
653
  }), [nodes]);
552
654
 
@@ -573,17 +675,24 @@ function ServiceGraph3D({
573
675
  return () => observer.disconnect();
574
676
  }, []);
575
677
 
576
- useEffect(() => {
678
+ const configureForces = useCallback(() => {
577
679
  const graphInstance = graphRef.current;
578
- if (!graphInstance) return;
680
+ if (!graphInstance || hasConfiguredForces.current) return;
579
681
  const charge = graphInstance.d3Force('charge') as AdjustableForce | undefined;
580
682
  const link = graphInstance.d3Force('link') as AdjustableForce | undefined;
581
683
  graphInstance.renderer().setPixelRatio(Math.min(window.devicePixelRatio * 1.5, 3));
582
- charge?.strength?.(-230);
583
- link?.distance?.(92);
684
+ charge?.strength?.(-720);
685
+ link?.distance?.(210);
584
686
  graphInstance.d3ReheatSimulation();
687
+ hasConfiguredForces.current = true;
688
+ }, []);
689
+
690
+ useEffect(() => {
585
691
  hasFramed.current = false;
586
- }, [graphData.nodes.length, graphData.links.length]);
692
+ hasConfiguredForces.current = false;
693
+ const frame = window.requestAnimationFrame(configureForces);
694
+ return () => window.cancelAnimationFrame(frame);
695
+ }, [configureForces, graphData.nodes.length, graphData.links.length]);
587
696
 
588
697
  const focusNode = useCallback((node: NodeObject) => {
589
698
  const serviceNode = graphNode(node);
@@ -653,16 +762,17 @@ function ServiceGraph3D({
653
762
  linkDirectionalParticleWidth={1.8}
654
763
  linkDirectionalParticleSpeed={0.006}
655
764
  linkDirectionalParticleColor={() => '#b9f6da'}
656
- d3AlphaDecay={0.035}
657
- d3VelocityDecay={0.24}
658
- warmupTicks={50}
659
- cooldownTicks={160}
765
+ d3AlphaDecay={0.026}
766
+ d3VelocityDecay={0.18}
767
+ warmupTicks={90}
768
+ cooldownTicks={260}
660
769
  onNodeClick={focusNode}
661
770
  onBackgroundClick={() => onSelect(null)}
771
+ onEngineTick={configureForces}
662
772
  onEngineStop={() => {
663
773
  if (hasFramed.current) return;
664
774
  hasFramed.current = true;
665
- graphRef.current?.zoomToFit(700, 72);
775
+ graphRef.current?.zoomToFit(700, 130);
666
776
  }}
667
777
  />
668
778
  <div className="graph-3d__zoom" aria-label="3D zoom controls">
@@ -676,14 +786,19 @@ function ServiceGraph3D({
676
786
  }
677
787
 
678
788
  export default function InternalServiceMapPage() {
679
- const nodes = useMemo<ServiceNode[]>(() => (graph.services ?? []).slice(0, layout.length).map((service, index) => ({
789
+ const [services, setServices] = useState<RawService[]>(() => graph.services ?? []);
790
+ const nodes = useMemo<ServiceNode[]>(() => services.map((service, index) => ({
680
791
  ...service,
681
- ...layout[index],
792
+ ...servicePosition(index),
682
793
  color: palette[index % palette.length],
683
- })), []);
794
+ })), [services]);
684
795
  const [mode, setMode] = useState<'2d' | '3d'>('2d');
685
796
  const [zoom, setZoom] = useState(1);
797
+ const [pan, setPan] = useState({ x: 0, y: 0 });
798
+ const [panStart, setPanStart] = useState<{ pointerId: number; x: number; y: number; panX: number; panY: number } | null>(null);
686
799
  const [selectedId, setSelectedId] = useState<string | null>(null);
800
+ const [isAddingService, setIsAddingService] = useState(false);
801
+ const [newService, setNewService] = useState<NewServiceDraft>({ name: '', category: '', parentId: 'product' });
687
802
  const [drafts, setDrafts] = useState<Record<string, DraftDetails>>(() => Object.fromEntries(nodes.map((service) => [
688
803
  service.id,
689
804
  {
@@ -694,9 +809,64 @@ export default function InternalServiceMapPage() {
694
809
  },
695
810
  ])));
696
811
  const selected = nodes.find((service) => service.id === selectedId) ?? null;
812
+ const nodeById = useMemo(() => new Map(nodes.map((node) => [node.id, node])), [nodes]);
697
813
  const zoomIn = () => setZoom((current) => Math.min(1.45, Number((current + 0.12).toFixed(2))));
698
814
  const zoomOut = () => setZoom((current) => Math.max(0.7, Number((current - 0.12).toFixed(2))));
699
- const resetZoom = () => setZoom(1);
815
+ const resetZoom = () => {
816
+ setZoom(1);
817
+ setPan({ x: 0, y: 0 });
818
+ };
819
+ const startPan = (event: PointerEvent<HTMLElement>) => {
820
+ if (mode !== '2d' || event.button !== 0 || (event.target as HTMLElement).closest('button, a, input, textarea, select')) return;
821
+ event.currentTarget.setPointerCapture(event.pointerId);
822
+ setPanStart({ pointerId: event.pointerId, x: event.clientX, y: event.clientY, panX: pan.x, panY: pan.y });
823
+ };
824
+ const movePan = (event: PointerEvent<HTMLElement>) => {
825
+ if (!panStart || panStart.pointerId !== event.pointerId) return;
826
+ setPan({
827
+ x: panStart.panX + event.clientX - panStart.x,
828
+ y: panStart.panY + event.clientY - panStart.y,
829
+ });
830
+ };
831
+ const stopPan = (event: PointerEvent<HTMLElement>) => {
832
+ if (panStart?.pointerId !== event.pointerId) return;
833
+ setPanStart(null);
834
+ };
835
+ const addService = () => {
836
+ const name = newService.name.trim();
837
+ if (!name) return;
838
+ const baseId = slugify(name);
839
+ const existingIds = new Set(services.map((service) => service.id));
840
+ let id = baseId;
841
+ let suffix = 2;
842
+ while (existingIds.has(id)) {
843
+ id = \`\${baseId}-\${suffix}\`;
844
+ suffix += 1;
845
+ }
846
+ const service: RawService = {
847
+ id,
848
+ key: id,
849
+ name,
850
+ category: newService.category.trim() || 'service',
851
+ confidence: 'manual',
852
+ evidence: [{ type: 'manual', value: 'Added in servicemap UI.', confidence: 'manual' }],
853
+ iconUrl: null,
854
+ parentId: newService.parentId === 'product' ? null : newService.parentId,
855
+ };
856
+ setServices((current) => [...current, service]);
857
+ setDrafts((current) => ({
858
+ ...current,
859
+ [id]: {
860
+ subtitle: categoryLabel(service.category),
861
+ use: primaryEvidence(service),
862
+ account: '',
863
+ passwordLocation: '',
864
+ },
865
+ }));
866
+ setSelectedId(id);
867
+ setNewService({ name: '', category: '', parentId: 'product' });
868
+ setIsAddingService(false);
869
+ };
700
870
 
701
871
  return (
702
872
  <main className="servicemap-shell">
@@ -711,6 +881,7 @@ export default function InternalServiceMapPage() {
711
881
  </div>
712
882
  <div className="top-actions">
713
883
  <span>{nodes.length} services</span>
884
+ <button type="button" className="add-service-button" onClick={() => setIsAddingService(true)}>+ Add service</button>
714
885
  <div className="mode-toggle" aria-label="View mode">
715
886
  <button type="button" className={\`mode-button \${mode === '2d' ? 'is-active' : ''}\`} onClick={() => setMode('2d')}>2D</button>
716
887
  <button type="button" className={\`mode-button \${mode === '3d' ? 'is-active' : ''}\`} onClick={() => setMode('3d')}>3D</button>
@@ -718,35 +889,47 @@ export default function InternalServiceMapPage() {
718
889
  </div>
719
890
  </header>
720
891
 
721
- <section className="map">
892
+ <section
893
+ className={\`map \${panStart ? 'is-panning' : ''} \${selectedId ? 'has-selection' : ''}\`}
894
+ onPointerDown={startPan}
895
+ onPointerMove={movePan}
896
+ onPointerUp={stopPan}
897
+ onPointerCancel={stopPan}
898
+ >
722
899
  <div className="intro">
723
900
  <strong>{graph.project?.name ?? 'Project'} infrastructure</strong>
724
901
  <span>{selected ? \`Tracing \${selected.name}\` : mode === '3d' ? 'Rotate and explore your service relationships' : 'Select a service to trace its dependencies'}</span>
725
902
  </div>
726
903
  {mode === '2d' ? (
727
904
  <>
728
- <div className="map-plane" style={{ '--zoom': zoom } as CSSProperties}>
905
+ <div className="map-plane" style={{ '--zoom': zoom, '--pan-x': \`\${pan.x}px\`, '--pan-y': \`\${pan.y}px\` } as CSSProperties}>
729
906
  <svg className="edges" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
730
- {nodes.map((service) => (
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 (
731
911
  <path
732
912
  key={service.id}
733
- className={\`edge-line \${selectedId === service.id ? 'is-active' : selectedId ? 'is-muted' : ''}\`}
734
- d={edgePath(service)}
913
+ className={\`edge-line \${isActive ? 'is-active' : selectedId ? 'is-muted' : ''}\`}
914
+ d={parent ? edgePathBetween(parent, service) : edgePath(service)}
735
915
  fill="none"
736
- stroke={selectedId === service.id ? '#65f0ad' : 'rgba(103, 130, 118, 0.48)'}
737
- strokeWidth={selectedId === service.id ? '0.28' : '0.16'}
738
- strokeDasharray={selectedId === service.id ? '0.72 0.58' : undefined}
739
- strokeLinecap={selectedId === service.id ? 'round' : undefined}
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}
740
920
  vectorEffect="non-scaling-stroke"
741
921
  />
742
- ))}
922
+ );
923
+ })}
743
924
  </svg>
744
925
  <div className="hub"><div className="hub-inner"><ProjectMark /></div></div>
745
- {nodes.map((service) => (
926
+ {nodes.map((service) => {
927
+ const isRelated = selectedId === service.parentId || selected?.parentId === service.id;
928
+ return (
746
929
  <button
747
930
  type="button"
748
931
  key={service.id}
749
- className={\`service-node \${selectedId === service.id ? 'is-selected' : ''}\`}
932
+ className={\`service-node \${selectedId === service.id ? 'is-selected' : ''} \${isRelated ? 'is-related' : ''}\`}
750
933
  style={{ left: \`\${service.x}%\`, top: \`\${service.y}%\` }}
751
934
  onClick={() => setSelectedId(service.id)}
752
935
  >
@@ -757,7 +940,8 @@ export default function InternalServiceMapPage() {
757
940
  </div>
758
941
  <i className={\`status-dot status-\${service.confidence}\`} />
759
942
  </button>
760
- ))}
943
+ );
944
+ })}
761
945
  </div>
762
946
  <div className="legend">
763
947
  <span><i style={{ background: '#4a7cff' }} />Cloud</span>
@@ -785,6 +969,15 @@ export default function InternalServiceMapPage() {
785
969
  onChange={(nextDetails) => setDrafts((current) => ({ ...current, [selected.id]: nextDetails }))}
786
970
  />
787
971
  ) : null}
972
+ {isAddingService ? (
973
+ <AddServiceModal
974
+ draft={newService}
975
+ nodes={nodes}
976
+ onChange={setNewService}
977
+ onClose={() => setIsAddingService(false)}
978
+ onSubmit={addService}
979
+ />
980
+ ) : null}
788
981
  </section>
789
982
  </main>
790
983
  );