map
This commit is contained in:
240
package-lock.json
generated
240
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -85,4 +85,23 @@ export async function deleteLocation(id: number): Promise<void> {
|
||||
await prisma.location.delete({ where: { id } });
|
||||
revalidatePath("/locations");
|
||||
revalidatePath("/cables");
|
||||
}
|
||||
|
||||
export async function updateLocationPosition(
|
||||
id: number,
|
||||
x: number,
|
||||
y: number
|
||||
): Promise<void> {
|
||||
try {
|
||||
await prisma.location.update({
|
||||
where: { id },
|
||||
data: {
|
||||
mapX: x,
|
||||
mapY: y,
|
||||
},
|
||||
});
|
||||
revalidatePath("/map");
|
||||
} catch (error) {
|
||||
console.error("updateLocationPosition failed:", error);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
@import "tailwindcss";
|
||||
@import "@xyflow/react/dist/style.css";
|
||||
|
||||
/* Push content below the fixed mobile header */
|
||||
@media (max-width: 1023px) {
|
||||
|
||||
42
src/app/map/page.tsx
Normal file
42
src/app/map/page.tsx
Normal file
@@ -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 (
|
||||
<div className="relative min-h-[calc(100vh-6rem)]">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-slate-900">Kabelplan</h1>
|
||||
<p className="text-slate-500 mt-1 text-sm">
|
||||
Standorte und Kabel als interaktive Knotenkarte.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-full">
|
||||
<CableMap locations={locations} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,19 @@ const navItems = [
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
href: "/map",
|
||||
label: "Kabelplan",
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<circle cx="6" cy="7" r="1.5" />
|
||||
<circle cx="12" cy="17" r="1.5" />
|
||||
<circle cx="18" cy="7" r="1.5" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8}
|
||||
d="M6 7h4m4 10h4m-2-3L8 10" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
href: "/locations",
|
||||
label: "Orte verwalten",
|
||||
|
||||
186
src/components/map/CableMap.tsx
Normal file
186
src/components/map/CableMap.tsx
Normal file
@@ -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<number | null>(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 ? (
|
||||
<div className="rounded-3xl border border-slate-200 bg-white p-10 shadow-sm">
|
||||
<div className="text-slate-700 text-lg font-semibold">Noch keine Orte vorhanden</div>
|
||||
<p className="mt-2 text-slate-500 text-sm leading-6">
|
||||
Legen Sie zuerst einen Ort unter „Orte verwalten“ an, um den Kabelplan zu nutzen.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative h-[calc(100vh-9rem)] rounded-[32px] border border-slate-200 bg-white shadow-sm">
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onNodeDragStop={handleNodeDragStop}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.15 }}
|
||||
minZoom={0.2}
|
||||
maxZoom={2}
|
||||
selectionOnDrag={false}
|
||||
>
|
||||
<Background gap={16} size={1} color="#e2e8f0" />
|
||||
<Controls position="top-left" />
|
||||
<MiniMap
|
||||
nodeColor={(node) => {
|
||||
const cableCount = typeof node.data?.cableCount === "number" ? node.data.cableCount : 0;
|
||||
return cableCount > 0 ? "#2563eb" : "#94a3b8";
|
||||
}}
|
||||
/>
|
||||
</ReactFlow>
|
||||
|
||||
{selectedEdge ? (
|
||||
<div className="absolute top-4 right-4 z-20 w-72 rounded-3xl border border-slate-200 bg-white/95 p-4 shadow-xl backdrop-blur-sm">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-900">Kabeldetails</p>
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
{selectedEdge.data.sourceName} → {selectedEdge.data.targetName}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedEdgeId(null)}
|
||||
className="text-slate-400 hover:text-slate-700"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<p className="text-base font-semibold text-slate-900">
|
||||
{selectedEdge.data.label}
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-slate-500">
|
||||
{selectedEdge.data.coreCount} {selectedEdge.data.coreCount === 1 ? "Ader" : "Adern"}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push(`/cables/${selectedEdge.data.cableId}`)}
|
||||
className="mt-4 w-full rounded-2xl bg-blue-600 px-3 py-2 text-sm font-semibold text-white hover:bg-blue-700"
|
||||
>
|
||||
Details öffnen
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
55
src/components/map/CableMapEdge.tsx
Normal file
55
src/components/map/CableMapEdge.tsx
Normal file
@@ -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<any>) {
|
||||
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 (
|
||||
<>
|
||||
<path
|
||||
id={edgeId}
|
||||
d={edgePath}
|
||||
stroke={strokeColor}
|
||||
strokeWidth={strokeWidth}
|
||||
fill="none"
|
||||
className="cursor-pointer transition-colors"
|
||||
onClick={() => data.onEdgeClick?.()}
|
||||
/>
|
||||
<text>
|
||||
<textPath href={`#${edgeId}`} startOffset="50%" textAnchor="middle" className="text-xs fill-slate-700">
|
||||
{data.label}
|
||||
</textPath>
|
||||
</text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
56
src/components/map/CableMapNode.tsx
Normal file
56
src/components/map/CableMapNode.tsx
Normal file
@@ -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<any>) {
|
||||
const borderColor = data.cableCount === 0
|
||||
? "border-slate-300"
|
||||
: data.cableCount >= 5
|
||||
? "border-orange-500"
|
||||
: "border-blue-500";
|
||||
|
||||
return (
|
||||
<div className={`min-w-[180px] max-w-[240px] rounded-3xl border bg-white p-4 shadow-sm ${borderColor}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-full bg-slate-100 p-2 text-base">📍</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-900">{data.label}</p>
|
||||
<p className="text-xs text-slate-500 mt-0.5">
|
||||
{data.cableCount} {data.cableCount === 1 ? "Kabel" : "Kabel"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
className="opacity-0"
|
||||
isConnectable={false}
|
||||
/>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
className="opacity-0"
|
||||
isConnectable={false}
|
||||
/>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
className="opacity-0"
|
||||
isConnectable={false}
|
||||
/>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
className="opacity-0"
|
||||
isConnectable={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user