servicemap 0.1.40 → 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.40",
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": {
@@ -26,6 +26,9 @@ If no website can be found, create a desktop app fallback at `servicemap-desktop
26
26
  9. Run the project's relevant build, lint, and focused tests.
27
27
  Open the generated map in a browser and verify both 2D and 3D modes.
28
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.
29
32
  Desktop output uses the same interactive component as website output; missing interactions are generator bugs to fix, not controls to remove.
30
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.
31
34
  Do not deploy unless the user explicitly says yes.
@@ -88,7 +88,9 @@ function pageCss() {
88
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); }
89
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); }
90
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); }
91
- 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; }
92
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); }
93
95
  .map.has-selection .service-node { opacity: 0.16; }
94
96
  .map.has-selection .service-node.is-related { opacity: 0.48; }
@@ -814,16 +816,64 @@ function ServiceGraph3D({
814
816
 
815
817
  export default function InternalServiceMapPage() {
816
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]);
817
835
  const nodes = useMemo<ServiceNode[]>(() => services.map((service, index) => ({
818
836
  ...service,
819
837
  ...servicePosition(index),
838
+ ...positions[service.id],
820
839
  color: palette[index % palette.length],
821
- })), [services]);
840
+ })), [services, positions]);
822
841
  const [mode, setMode] = useState<'2d' | '3d'>('2d');
823
842
  const [zoom, setZoom] = useState(1);
824
843
  const [pan, setPan] = useState({ x: 0, y: 0 });
825
844
  const [panStart, setPanStart] = useState<{ pointerId: number; x: number; y: number; panX: number; panY: number } | null>(null);
826
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
+ };
827
877
  const [isAddingService, setIsAddingService] = useState(false);
828
878
  const [newService, setNewService] = useState<NewServiceDraft>({ name: '', category: '', parentId: 'product' });
829
879
  const [drafts, setDrafts] = useState<Record<string, DraftDetails>>(() => Object.fromEntries(nodes.map((service) => [
@@ -939,7 +989,15 @@ export default function InternalServiceMapPage() {
939
989
  key={service.id}
940
990
  className={\`service-node \${selectedId === service.id ? 'is-selected' : ''} \${isRelated ? 'is-related' : ''}\`}
941
991
  style={{ left: \`\${service.x}%\`, top: \`\${service.y}%\` }}
942
- 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
+ }}
943
1001
  >
944
1002
  <ServiceLogo service={service} />
945
1003
  <div className="service-copy">