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
package/servicemap/SKILL.md
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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">
|