From a50a15efc665dc680faf86561160e95959f74f14 Mon Sep 17 00:00:00 2001 From: Luis Date: Sat, 13 Jun 2026 21:45:32 +0200 Subject: [PATCH] map --- package-lock.json | 240 ++++++++++++++++++++++++++++ package.json | 1 + prisma/schema.prisma | 2 + src/actions/locationActions.ts | 19 +++ src/app/globals.css | 1 + src/app/map/page.tsx | 42 +++++ src/components/Navigation.tsx | 13 ++ src/components/map/CableMap.tsx | 186 +++++++++++++++++++++ src/components/map/CableMapEdge.tsx | 55 +++++++ src/components/map/CableMapNode.tsx | 56 +++++++ 10 files changed, 615 insertions(+) create mode 100644 src/app/map/page.tsx create mode 100644 src/components/map/CableMap.tsx create mode 100644 src/components/map/CableMapEdge.tsx create mode 100644 src/components/map/CableMapNode.tsx diff --git a/package-lock.json b/package-lock.json index f286887..c1b587b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@prisma/client": "^7.8.0", "@tailwindcss/typography": "^0.5.20", "@types/better-sqlite3": "^7.6.13", + "@xyflow/react": "^12.11.0", "next": "16.2.9", "qrcode": "^1.5.4", "react": "19.2.4", @@ -2082,6 +2083,55 @@ "@types/node": "*" } }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, "node_modules/@types/debug": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", @@ -2850,6 +2900,48 @@ "win32" ] }, + "node_modules/@xyflow/react": { + "version": "12.11.0", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.11.0.tgz", + "integrity": "sha512-na4IO33FSs2OS72hASgZDmTYwFAkef7Z74uBUVrong3ARmQQHfnRUVaCFn1kTt5LbS6pK03TbYjCPGLjLFfziA==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.77", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "@types/react": ">=17", + "@types/react-dom": ">=17", + "react": ">=17", + "react-dom": ">=17" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.77", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.77.tgz", + "integrity": "sha512-qCDCMCQAAgUu8yHnhloHG9F5mwPX5E+Wl8McpYIOPSSXfzFJJoZcwOcsDiAjitVKIg2de1WmJbCHfpcvxprsgg==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, "node_modules/acorn": { "version": "8.17.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", @@ -3546,6 +3638,12 @@ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", "license": "ISC" }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", @@ -3645,6 +3743,111 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -9417,6 +9620,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -9754,6 +9966,34 @@ "zod": "^3.25.0 || ^4.0.0" } }, + "node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/package.json b/package.json index aff2f58..cd74886 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "@prisma/client": "^7.8.0", "@tailwindcss/typography": "^0.5.20", "@types/better-sqlite3": "^7.6.13", + "@xyflow/react": "^12.11.0", "next": "16.2.9", "qrcode": "^1.5.4", "react": "19.2.4", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index bf59f6c..507fe96 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -17,6 +17,8 @@ model Location { id Int @id @default(autoincrement()) name String description String? // Optional Markdown text + mapX Float? + mapY Float? // A location can be the start OR end of many cables startCables Cable[] @relation("StartLocation") diff --git a/src/actions/locationActions.ts b/src/actions/locationActions.ts index dd16e57..fc77a8d 100644 --- a/src/actions/locationActions.ts +++ b/src/actions/locationActions.ts @@ -85,4 +85,23 @@ export async function deleteLocation(id: number): Promise { await prisma.location.delete({ where: { id } }); revalidatePath("/locations"); revalidatePath("/cables"); +} + +export async function updateLocationPosition( + id: number, + x: number, + y: number +): Promise { + try { + await prisma.location.update({ + where: { id }, + data: { + mapX: x, + mapY: y, + }, + }); + revalidatePath("/map"); + } catch (error) { + console.error("updateLocationPosition failed:", error); + } } \ No newline at end of file diff --git a/src/app/globals.css b/src/app/globals.css index f4b9aa7..a6f9867 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,4 +1,5 @@ @import "tailwindcss"; +@import "@xyflow/react/dist/style.css"; /* Push content below the fixed mobile header */ @media (max-width: 1023px) { diff --git a/src/app/map/page.tsx b/src/app/map/page.tsx new file mode 100644 index 0000000..d1c67b4 --- /dev/null +++ b/src/app/map/page.tsx @@ -0,0 +1,42 @@ +import { prisma } from "@/lib/prisma"; +import CableMap from "@/components/map/CableMap"; + +export const dynamic = "force-dynamic"; + +export default async function MapPage() { + const locations = await prisma.location.findMany({ + include: { + startCables: { + include: { + endLocation: true, + _count: { select: { cores: true } }, + intermediateStops: { + orderBy: { order: "asc" }, + include: { location: true }, + }, + }, + }, + endCables: { + include: { + _count: { select: { cores: true } }, + }, + }, + }, + orderBy: { name: "asc" }, + }); + + return ( +
+
+

Kabelplan

+

+ Standorte und Kabel als interaktive Knotenkarte. +

+
+ +
+ +
+
+ ); +} diff --git a/src/components/Navigation.tsx b/src/components/Navigation.tsx index 74dab76..2028ff1 100644 --- a/src/components/Navigation.tsx +++ b/src/components/Navigation.tsx @@ -16,6 +16,19 @@ const navItems = [ ), }, + { + href: "/map", + label: "Kabelplan", + icon: ( + + + + + + + ), + }, { href: "/locations", label: "Orte verwalten", diff --git a/src/components/map/CableMap.tsx b/src/components/map/CableMap.tsx new file mode 100644 index 0000000..e227011 --- /dev/null +++ b/src/components/map/CableMap.tsx @@ -0,0 +1,186 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { useRouter } from "next/navigation"; +import { + Background, + Controls, + MiniMap, + Node, + ReactFlow, + useEdgesState, + useNodesState, +} from "@xyflow/react"; +import CableMapNode from "./CableMapNode"; +import CableMapEdge from "./CableMapEdge"; +import { updateLocationPosition } from "@/actions/locationActions"; + +type LocationCable = { + id: number; + identifier: string; + startLocationId: number; + endLocationId: number; + _count: { cores: number }; + endLocation: { id: number; name: string }; + intermediateStops: { location: { id: number; name: string } }[]; +}; + +type LocationWithCables = { + id: number; + name: string; + mapX: number | null; + mapY: number | null; + startCables: LocationCable[]; + endCables: { id: number }[]; +}; + +interface CableMapProps { + locations: LocationWithCables[]; +} + +const nodeTypes = { location: CableMapNode as any }; +const edgeTypes = { cable: CableMapEdge as any }; + +export default function CableMap({ locations }: CableMapProps) { + const router = useRouter(); + const [selectedEdgeId, setSelectedEdgeId] = useState(null); + + const initialNodes = useMemo( + () => + locations.map((location, index) => ({ + id: String(location.id), + type: "location", + position: { + x: location.mapX ?? (index % 4) * 220, + y: location.mapY ?? Math.floor(index / 4) * 160, + }, + data: { + label: location.name, + cableCount: location.startCables.length + location.endCables.length, + locationId: location.id, + }, + })), + [locations] + ); + + const initialEdges = useMemo( + () => + locations.flatMap((location) => + location.startCables.flatMap((cable) => { + const route = [ + { id: String(cable.startLocationId), name: location.name }, + ...cable.intermediateStops.map((stop) => ({ + id: String(stop.location.id), + name: stop.location.name, + })), + { id: String(cable.endLocationId), name: cable.endLocation.name }, + ]; + + return route.slice(1).map((target, index) => { + const source = route[index]; + return { + id: `cable-${cable.id}-${index}`, + type: "cable", + source: source.id, + target: target.id, + data: { + label: cable.identifier, + coreCount: cable._count.cores, + cableId: cable.id, + sourceName: source.name, + targetName: target.name, + onEdgeClick: () => setSelectedEdgeId(cable.id), + }, + }; + }); + }) + ), + [locations] + ); + + const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); + const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); + + const selectedEdge = selectedEdgeId + ? edges.find((edge) => edge.data?.cableId === selectedEdgeId) + : null; + + const handleNodeDragStop = async (_event: unknown, node: Node) => { + const locationId = Number(node.data?.locationId); + if (!locationId || typeof node.position.x !== "number" || typeof node.position.y !== "number") { + return; + } + + await updateLocationPosition(locationId, node.position.x, node.position.y); + }; + + return locations.length === 0 ? ( +
+
Noch keine Orte vorhanden
+

+ Legen Sie zuerst einen Ort unter „Orte verwalten“ an, um den Kabelplan zu nutzen. +

+
+ ) : ( +
+ + + + { + const cableCount = typeof node.data?.cableCount === "number" ? node.data.cableCount : 0; + return cableCount > 0 ? "#2563eb" : "#94a3b8"; + }} + /> + + + {selectedEdge ? ( +
+
+
+

Kabeldetails

+

+ {selectedEdge.data.sourceName} → {selectedEdge.data.targetName} +

+
+ +
+
+

+ {selectedEdge.data.label} +

+

+ {selectedEdge.data.coreCount} {selectedEdge.data.coreCount === 1 ? "Ader" : "Adern"} +

+
+ +
+ ) : null} +
+ ); +} diff --git a/src/components/map/CableMapEdge.tsx b/src/components/map/CableMapEdge.tsx new file mode 100644 index 0000000..26b3674 --- /dev/null +++ b/src/components/map/CableMapEdge.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { EdgeProps, getBezierPath } from "@xyflow/react"; + +type CableEdgeData = { + label: string; + coreCount: number; + cableId: number; + sourceName?: string; + targetName?: string; + onEdgeClick?: () => void; +}; + +export default function CableMapEdge({ + id, + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + data, + selected, +}: EdgeProps) { + const [edgePath] = getBezierPath({ + sourceX, + sourceY, + sourcePosition, + targetX, + targetY, + targetPosition, + }); + const edgeId = `edge-${id}`; + const strokeWidth = 1 + Math.min(data.coreCount, 8) * 0.4; + const strokeColor = selected ? "#1d4ed8" : "#64748b"; + + return ( + <> + data.onEdgeClick?.()} + /> + + + {data.label} + + + + ); +} diff --git a/src/components/map/CableMapNode.tsx b/src/components/map/CableMapNode.tsx new file mode 100644 index 0000000..2cd5c47 --- /dev/null +++ b/src/components/map/CableMapNode.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { Handle, NodeProps, Position } from "@xyflow/react"; + +type LocationNodeData = { + label: string; + cableCount: number; + locationId: number; +}; + +export default function CableMapNode({ data }: NodeProps) { + const borderColor = data.cableCount === 0 + ? "border-slate-300" + : data.cableCount >= 5 + ? "border-orange-500" + : "border-blue-500"; + + return ( +
+
+
📍
+
+

{data.label}

+

+ {data.cableCount} {data.cableCount === 1 ? "Kabel" : "Kabel"} +

+
+
+ + + + + +
+ ); +}