Files
La-Fabrik/src/pages/waypoint/page.tsx
T
Tom Boullay 89044a18ec
📊 Quality / 🔒 Security Audit (pull_request) Has been cancelled
🔍 Lint / 🪄 Check lint (pull_request) Has been cancelled
🔍 Lint / 🎨 Check format (pull_request) Has been cancelled
🔍 Lint / 🔎 Typecheck (pull_request) Has been cancelled
📊 Quality / 📋 Dependency Freshness (pull_request) Has been cancelled
📊 Quality / 📦 Bundle Size (pull_request) Has been cancelled
🔍 Lint / 🏗 Build (pull_request) Has been cancelled
merge develop into feat/map-environment
2026-05-29 01:45:08 +02:00

1228 lines
38 KiB
TypeScript

import React, { useState, useEffect, useRef } from "react";
import { Canvas, useFrame, useThree } from "@react-three/fiber";
import {
useGLTF,
OrthographicCamera,
MapControls,
Line,
} from "@react-three/drei";
import * as THREE from "three";
import {
Trash2,
Link2,
Download,
Clipboard,
Info,
MapPin,
Map as MapIcon,
} from "lucide-react";
// ==========================================
// 1. Waypoint Interfaces
// ==========================================
export interface Waypoint {
id: number;
x: number;
y: number; // height (Raycasted from terrain)
z: number;
connections: number[];
}
// ==========================================
// 2. Editor Scene Manager Component
// ==========================================
interface EditorSceneProps {
waypoints: Waypoint[];
selectedId: number | null;
hoveredNodeId: number | null;
setHoveredNodeId: (id: number | null) => void;
setDragStartNodeId: (id: number | null) => void;
dragStartNodeId: number | null;
hoverPointRef: React.MutableRefObject<THREE.Vector3 | null>;
handleTerrainClick: (point: THREE.Vector3) => void;
handleSelectNode: (id: number) => void;
selectedConnection: { idA: number; idB: number } | null;
setSelectedConnection: (conn: { idA: number; idB: number } | null) => void;
hoveredConnection: { idA: number; idB: number } | null;
setHoveredConnection: (conn: { idA: number; idB: number } | null) => void;
}
const EditorScene: React.FC<EditorSceneProps> = ({
waypoints,
selectedId,
hoveredNodeId,
setHoveredNodeId,
setDragStartNodeId,
dragStartNodeId,
hoverPointRef,
handleTerrainClick,
handleSelectNode,
selectedConnection,
setSelectedConnection,
hoveredConnection,
setHoveredConnection,
}) => {
const { scene } = useGLTF("/models/terrain/terrain.glb");
const { raycaster, pointer, camera } = useThree();
const groupRef = useRef<THREE.Group>(null);
const rubberLineRef = useRef<THREE.Line>(null);
const rubberLineInstance = React.useMemo(() => new THREE.Line(), []);
// Mirror reactive props inside Refs to guarantee useFrame loop never closes over stale state
const hoveredNodeIdRef = useRef<number | null>(null);
const dragStartNodeIdRef = useRef<number | null>(null);
const waypointsRef = useRef<Waypoint[]>([]);
useEffect(() => {
hoveredNodeIdRef.current = hoveredNodeId;
}, [hoveredNodeId]);
useEffect(() => {
dragStartNodeIdRef.current = dragStartNodeId;
}, [dragStartNodeId]);
useEffect(() => {
waypointsRef.current = waypoints;
}, [waypoints]);
// Continuously raycast from mouse position to terrain and waypoints to detect hovers during drag
useFrame(() => {
if (!groupRef.current) return;
raycaster.setFromCamera(pointer, camera);
const intersects = raycaster.intersectObjects(
groupRef.current.children,
true,
);
// Find waypoint sphere hover (only trigger React state update if hovered ID changes)
const sphereIntersect = intersects.find(
(item) => item.object.name && item.object.name.startsWith("waypoint-"),
);
if (sphereIntersect) {
const nodeId = Number(
sphereIntersect.object.name.replace("waypoint-", ""),
);
if (hoveredNodeIdRef.current !== nodeId) {
setHoveredNodeId(nodeId);
}
} else {
if (hoveredNodeIdRef.current !== null) {
setHoveredNodeId(null);
}
}
// Find terrain mesh hover
const terrainIntersect = intersects.find(
(item) => item.object.name && !item.object.name.startsWith("waypoint-"),
);
const activeTerrainIntersect =
terrainIntersect || intersects.find((item) => !item.object.name);
if (activeTerrainIntersect && activeTerrainIntersect.point) {
const point = activeTerrainIntersect.point;
hoverPointRef.current = point.clone();
// 1. Bypass React state: Update HTML Floating Panel directly for 0ms lag
const coordsPanel = document.getElementById("coords-panel");
if (coordsPanel) {
coordsPanel.innerText = `X: ${point.x.toFixed(2)} | Y (Raycast): ${point.y.toFixed(2)} | Z: ${point.z.toFixed(2)}`;
}
// 2. Bypass React state: Update pink rubber band line dynamically in WebGL
const activeDragId = dragStartNodeIdRef.current;
if (activeDragId !== null && rubberLineRef.current) {
rubberLineRef.current.visible = true;
const startNode = waypointsRef.current.find(
(w) => w.id === activeDragId,
);
if (startNode) {
rubberLineRef.current.geometry.setFromPoints([
new THREE.Vector3(startNode.x, startNode.y + 0.4, startNode.z),
new THREE.Vector3(point.x, point.y + 0.4, point.z),
]);
}
} else if (rubberLineRef.current) {
rubberLineRef.current.visible = false;
}
} else {
if (rubberLineRef.current) {
rubberLineRef.current.visible = false;
}
}
});
return (
<group ref={groupRef}>
{/* 1. Terrain Mesh (Raycasted for adding/dragging) */}
<primitive
object={scene}
onClick={(e: any) => {
e.stopPropagation();
// Only click-to-create a new node if they are not actively dragging a link
if (dragStartNodeId === null && e.point) {
handleTerrainClick(e.point);
}
}}
/>
{/* 2. Drag Rubber Band Preview Line (WebGL optimized) */}
<primitive
object={rubberLineInstance}
ref={rubberLineRef}
visible={false}
>
<bufferGeometry attach="geometry" />
<lineBasicMaterial
attach="material"
color="#ff0055" // Neon pink
linewidth={3}
depthTest={false}
transparent
opacity={0.9}
/>
</primitive>
{/* 3. Render Established Connections */}
<ConnectionLines
waypoints={waypoints}
selectedConnection={selectedConnection}
setSelectedConnection={setSelectedConnection}
hoveredConnection={hoveredConnection}
setHoveredConnection={setHoveredConnection}
/>
{/* 4. Render Waypoint Node Spheres */}
<WaypointMarkers
waypoints={waypoints}
selectedId={selectedId}
onSelect={handleSelectNode}
hoveredNodeId={hoveredNodeId}
setHoveredNodeId={setHoveredNodeId}
setDragStartNodeId={setDragStartNodeId}
/>
</group>
);
};
// ==========================================
// 3. Grid Visualizer & Helpers
// ==========================================
interface WaypointMarkersProps {
waypoints: Waypoint[];
selectedId: number | null;
onSelect: (id: number) => void;
hoveredNodeId: number | null;
setHoveredNodeId: (id: number | null) => void;
setDragStartNodeId: (id: number | null) => void;
}
const WaypointMarkers: React.FC<WaypointMarkersProps> = ({
waypoints,
selectedId,
onSelect,
hoveredNodeId,
setHoveredNodeId,
setDragStartNodeId,
}) => {
return (
<group>
{waypoints.map((wp) => {
const isSelected = wp.id === selectedId;
const isHovered = wp.id === hoveredNodeId;
let color = "#3b82f6"; // Standard blue
let scale = 1.0;
if (isSelected) {
color = "#ff0055"; // Pink-red for selected
scale = 1.5;
} else if (isHovered) {
color = "#60a5fa"; // Bright blue for hovered
scale = 1.25;
}
return (
<group
key={wp.id}
position={[wp.x, wp.y + 0.5, wp.z]}
onPointerOver={(e) => {
e.stopPropagation();
setHoveredNodeId(wp.id);
}}
onPointerOut={() => {
setHoveredNodeId(null);
}}
onPointerDown={(e: any) => {
e.stopPropagation();
if (e.button === 0) {
// Left click start drag link connection
setDragStartNodeId(wp.id);
} else if (e.button === 2) {
// Right click select waypoint
onSelect(wp.id);
}
}}
>
{/* Core Marker Node */}
<mesh name={`waypoint-${wp.id}`}>
<sphereGeometry args={[0.8, 16, 16]} />
<meshBasicMaterial color={color} depthTest={false} />
</mesh>
{/* Ring indicator */}
<mesh rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[1.2 * scale, 1.4 * scale, 32]} />
<meshBasicMaterial
color={color}
side={THREE.DoubleSide}
depthTest={false}
transparent
opacity={0.7}
/>
</mesh>
</group>
);
})}
</group>
);
};
interface ConnectionLinesProps {
waypoints: Waypoint[];
selectedConnection: { idA: number; idB: number } | null;
setSelectedConnection: (conn: { idA: number; idB: number } | null) => void;
hoveredConnection: { idA: number; idB: number } | null;
setHoveredConnection: (conn: { idA: number; idB: number } | null) => void;
}
const ConnectionLines: React.FC<ConnectionLinesProps> = ({
waypoints,
selectedConnection,
setSelectedConnection,
hoveredConnection,
setHoveredConnection,
}) => {
// Generate pairs of lines
const lines = React.useMemo(() => {
const list: [THREE.Vector3, THREE.Vector3, number, number][] = [];
const drawn = new Set<string>();
waypoints.forEach((wp) => {
wp.connections.forEach((connId) => {
const other = waypoints.find((w) => w.id === connId);
if (other) {
const key =
wp.id < other.id ? `${wp.id}-${other.id}` : `${other.id}-${wp.id}`;
if (!drawn.has(key)) {
drawn.add(key);
list.push([
new THREE.Vector3(wp.x, wp.y + 0.4, wp.z),
new THREE.Vector3(other.x, other.y + 0.4, other.z),
wp.id,
other.id,
]);
}
}
});
});
return list;
}, [waypoints]);
return (
<group>
{lines.map(([start, end, idA, idB]) => {
const isSelected =
selectedConnection &&
((selectedConnection.idA === idA && selectedConnection.idB === idB) ||
(selectedConnection.idA === idB && selectedConnection.idB === idA));
const isHovered =
hoveredConnection &&
((hoveredConnection.idA === idA && hoveredConnection.idB === idB) ||
(hoveredConnection.idA === idB && hoveredConnection.idB === idA));
let color = "#10b981"; // Emerald green
let lineWidth = 3;
if (isSelected) {
color = "#f59e0b"; // Amber yellow for selected connection
lineWidth = 5.0;
} else if (isHovered) {
color = "#60a5fa"; // Bright blue for hovered connection
lineWidth = 4.5;
}
return (
<Line
key={`${idA}-${idB}`}
points={[start, end]}
color={color}
lineWidth={lineWidth}
onPointerOver={(e) => {
e.stopPropagation();
setHoveredConnection({ idA, idB });
}}
onPointerOut={(e) => {
e.stopPropagation();
setHoveredConnection(null);
}}
onClick={(e) => {
e.stopPropagation();
console.log(
`[Lien 3D] Sélectionné: Point ${idA} <-> Point ${idB}`,
);
setSelectedConnection({ idA, idB });
}}
/>
);
})}
</group>
);
};
// ==========================================
// 4. Main Waypoint Editor Page Component
// ==========================================
export const WaypointEditorPage: React.FC = () => {
const [waypoints, setWaypoints] = useState<Waypoint[]>([]);
const [selectedId, setSelectedId] = useState<number | null>(null);
const [hoveredNodeId, setHoveredNodeId] = useState<number | null>(null);
// Selection / Hover states for 3D paths/connections
const [selectedConnection, setSelectedConnection] = useState<{
idA: number;
idB: number;
} | null>(null);
const [hoveredConnection, setHoveredConnection] = useState<{
idA: number;
idB: number;
} | null>(null);
// Helper function to handle connection selection and reset node selection
const handleSelectConnection = (
conn: { idA: number; idB: number } | null,
) => {
if (conn) {
console.log(
`[Sélection] Liaison active sélectionnée : Point ${conn.idA} <-> Point ${conn.idB}`,
);
setSelectedId(null); // Clear selected node
}
setSelectedConnection(conn);
};
// Mutable ref for high frequency raycast updates to bypass React rendering loop
const hoverPointRef = useRef<THREE.Vector3 | null>(null);
// Connection / Drag states
const [dragStartNodeId, setDragStartNodeId] = useState<number | null>(null);
const [isConnectingMode, setIsConnectingMode] = useState<boolean>(false);
const [activeConnectionStartId, setActiveConnectionStartId] = useState<
number | null
>(null);
// Load from localstorage on mount
useEffect(() => {
console.log(
"[Initialisation] Chargement des waypoints depuis localStorage...",
);
const saved = localStorage.getItem("la-fabrik-waypoints");
if (saved) {
try {
const list = JSON.parse(saved);
console.log(
`[Initialisation] ${list.length} waypoints chargés avec succès !`,
);
setWaypoints(list);
} catch (e) {
console.error(
"[Initialisation] Erreur de parsing du stockage local",
e,
);
}
} else {
console.log(
"[Initialisation] Aucun point enregistré en localStorage. Démarrage à vide.",
);
}
}, []);
// Save to localstorage when waypoints change
const saveWaypoints = (list: Waypoint[]) => {
setWaypoints(list);
localStorage.setItem("la-fabrik-waypoints", JSON.stringify(list));
};
// Delete a specific connection (break the link)
const deleteSelectedConnection = (idA: number, idB: number) => {
console.log(
`[Liaisons] Suppression définitive du lien : Point ${idA} <-> Point ${idB}`,
);
setWaypoints((currentWaypoints) => {
const updatedList = currentWaypoints.map((wp) => {
if (wp.id === idA) {
return {
...wp,
connections: wp.connections.filter((cId) => cId !== idB),
};
}
if (wp.id === idB) {
return {
...wp,
connections: wp.connections.filter((cId) => cId !== idA),
};
}
return wp;
});
localStorage.setItem("la-fabrik-waypoints", JSON.stringify(updatedList));
return updatedList;
});
setSelectedConnection(null);
};
// Listen for global keyboard shortcuts (e.g. Delete node or connection)
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const activeEl = document.activeElement;
if (
activeEl &&
(activeEl.tagName === "INPUT" || activeEl.tagName === "TEXTAREA")
) {
return;
}
if (e.key === "Delete" || e.key === "Backspace") {
console.log(`[Hotkey] Touche '${e.key}' détectée.`);
if (selectedId !== null) {
console.log(
`[Hotkey] Touche de suppression activée sur le Point sélectionné : ID = ${selectedId}`,
);
handleDeleteNode(selectedId);
} else if (selectedConnection !== null) {
console.log(
`[Hotkey] Touche de suppression activée sur la Liaison sélectionnée : ${selectedConnection.idA} <-> ${selectedConnection.idB}`,
);
deleteSelectedConnection(
selectedConnection.idA,
selectedConnection.idB,
);
}
}
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [selectedId, selectedConnection, waypoints]);
// Add a new waypoint
const handleTerrainClick = (point: THREE.Vector3) => {
if (isConnectingMode) {
console.log(
"[Mode Connexion] Clic sur terrain vide. Annulation du mode liaison.",
);
setIsConnectingMode(false);
setActiveConnectionStartId(null);
return;
}
setWaypoints((currentWaypoints) => {
const nextId =
currentWaypoints.length > 0
? Math.max(...currentWaypoints.map((w) => w.id)) + 1
: 1;
const newWp: Waypoint = {
id: nextId,
x: Number(point.x.toFixed(2)),
y: Number(point.y.toFixed(2)),
z: Number(point.z.toFixed(2)),
connections: [],
};
console.log(
`[Création] Nouveau Point déposé : ID = ${nextId} | Coordonnées : (${newWp.x}, ${newWp.y}, ${newWp.z})`,
);
const newList = [...currentWaypoints, newWp];
localStorage.setItem("la-fabrik-waypoints", JSON.stringify(newList));
setTimeout(() => {
setSelectedConnection(null);
setSelectedId(nextId);
}, 0);
return newList;
});
};
// Select node or handle connections (toggles connections if they already exist)
const handleSelectNode = (id: number) => {
setSelectedConnection(null); // Reset connection selection
if (isConnectingMode && activeConnectionStartId !== null) {
if (activeConnectionStartId === id) {
console.log(
"[Mode Connexion] Tentative de liaison sur soi-même. Annulation.",
);
setIsConnectingMode(false);
setActiveConnectionStartId(null);
return;
}
console.log(
`[Mode Connexion] Création manuelle d'un lien : Point ${activeConnectionStartId} <-> Point ${id}`,
);
toggleConnection(activeConnectionStartId, id);
setIsConnectingMode(false);
setActiveConnectionStartId(null);
setSelectedId(id);
} else {
console.log(`[Sélection] Point sélectionné : ID = ${id}`);
setSelectedId(id);
}
};
// Toggle connection between two waypoint IDs (using functional state to prevent stale closures)
const toggleConnection = (idA: number, idB: number) => {
console.log(`[Liaisons] toggleConnection(Point ${idA}, Point ${idB})`);
setWaypoints((currentWaypoints) => {
const updatedList = currentWaypoints.map((wp) => {
if (wp.id === idA) {
const alreadyLinked = wp.connections.includes(idB);
console.log(
`[Liaisons] Point ${idA} : ${alreadyLinked ? "Suppression" : "Ajout"} de la liaison vers Point ${idB}`,
);
const conns = alreadyLinked
? wp.connections.filter((cId) => cId !== idB)
: [...wp.connections, idB];
return { ...wp, connections: conns };
}
if (wp.id === idB) {
const alreadyLinked = wp.connections.includes(idA);
console.log(
`[Liaisons] Point ${idB} : ${alreadyLinked ? "Suppression" : "Ajout"} de la liaison vers Point ${idA}`,
);
const conns = alreadyLinked
? wp.connections.filter((cId) => cId !== idA)
: [...wp.connections, idA];
return { ...wp, connections: conns };
}
return wp;
});
localStorage.setItem("la-fabrik-waypoints", JSON.stringify(updatedList));
return updatedList;
});
};
// Global pointer up handler for completing link drags (releases on empty space create & connect a node)
const handleGlobalPointerUp = () => {
if (dragStartNodeId !== null) {
if (hoveredNodeId !== null && hoveredNodeId !== dragStartNodeId) {
console.log(
`[Drag&Drop] Relâchement sur le Point existant : ID = ${hoveredNodeId}. Création/Toggling du lien.`,
);
toggleConnection(dragStartNodeId, hoveredNodeId);
} else if (hoverPointRef.current !== null) {
const point = hoverPointRef.current;
setWaypoints((currentWaypoints) => {
const nextId =
currentWaypoints.length > 0
? Math.max(...currentWaypoints.map((w) => w.id)) + 1
: 1;
const newWp: Waypoint = {
id: nextId,
x: Number(point.x.toFixed(2)),
y: Number(point.y.toFixed(2)),
z: Number(point.z.toFixed(2)),
connections: [dragStartNodeId],
};
console.log(
`[Drag&Drop] Relâchement sur zone vide. Création automatique du Point ${nextId} aux coordonnées (${newWp.x}, ${newWp.y}, ${newWp.z}) et liaison mutuelle avec le Point ${dragStartNodeId}`,
);
const updatedList = currentWaypoints.map((wp) => {
if (wp.id === dragStartNodeId) {
return {
...wp,
connections: wp.connections.includes(nextId)
? wp.connections
: [...wp.connections, nextId],
};
}
return wp;
});
const finalList = [...updatedList, newWp];
localStorage.setItem(
"la-fabrik-waypoints",
JSON.stringify(finalList),
);
setTimeout(() => {
setSelectedConnection(null);
setSelectedId(nextId);
}, 0);
return finalList;
});
} else {
setSelectedId(dragStartNodeId);
}
setDragStartNodeId(null);
}
};
// Delete current selected node
const handleDeleteNode = (id: number) => {
console.log(
`[Suppression] Action de suppression définitive du Point : ID = ${id}`,
);
setWaypoints((currentWaypoints) => {
const updatedList = currentWaypoints
.filter((wp) => wp.id !== id)
.map((wp) => ({
...wp,
connections: wp.connections.filter((cId) => cId !== id),
}));
console.log(
`[Suppression] Point ${id} supprimé. ${updatedList.length} points restants.`,
);
localStorage.setItem("la-fabrik-waypoints", JSON.stringify(updatedList));
return updatedList;
});
setSelectedId((currentSelected) =>
currentSelected === id ? null : currentSelected,
);
};
// Connect Mode Trigger
const startConnecting = (id: number) => {
console.log(
`[Mode Connexion] Démarrage mode connexion manuelle depuis Point ID = ${id}`,
);
setIsConnectingMode(true);
setActiveConnectionStartId(id);
};
// Clear all waypoints
const handleClearAll = () => {
if (
window.confirm(
"Voulez-vous vraiment TOUT supprimer ? Cette action est irréversible.",
)
) {
console.log(
"[Action] Suppression complète et définitive de tous les points de la carte.",
);
saveWaypoints([]);
setSelectedId(null);
setSelectedConnection(null);
}
};
// Copy network JSON to clipboard
const handleCopyToClipboard = () => {
navigator.clipboard.writeText(JSON.stringify(waypoints, null, 2));
alert("JSON copié dans le presse-papier !");
};
// Download network JSON file
const handleDownload = () => {
const dataStr =
"data:text/json;charset=utf-8," +
encodeURIComponent(JSON.stringify(waypoints, null, 2));
const downloadAnchor = document.createElement("a");
downloadAnchor.setAttribute("href", dataStr);
downloadAnchor.setAttribute("download", "roadNetwork.json");
document.body.appendChild(downloadAnchor);
downloadAnchor.click();
downloadAnchor.remove();
};
const selectedNode = waypoints.find((w) => w.id === selectedId);
return (
<div style={styles.container}>
{/* 1. Header Navigation */}
<header style={styles.header}>
<div style={styles.logoGroup}>
<MapIcon size={24} style={styles.logoIcon} />
<h1 style={styles.logoText}>La Fabrik Waypoint Network Editor</h1>
</div>
<div style={styles.headerControls}>
<button
style={styles.secondaryButton}
onClick={handleCopyToClipboard}
>
<Clipboard size={16} /> Copier JSON
</button>
<button style={styles.primaryButton} onClick={handleDownload}>
<Download size={16} /> Télécharger roadNetwork.json
</button>
</div>
</header>
<div style={styles.mainArea}>
{/* 2. Left sidebar: Nodes manager */}
<aside style={styles.sidebar}>
<div style={styles.sidebarHeader}>
<h2 style={styles.sidebarTitle}>Liste des Waypoints</h2>
{waypoints.length > 0 && (
<button style={styles.clearButton} onClick={handleClearAll}>
<Trash2 size={14} /> Tout effacer
</button>
)}
</div>
<div style={styles.nodesList}>
{waypoints.length === 0 ? (
<div style={styles.emptyState}>
<MapPin size={32} style={styles.emptyIcon} />
<p style={styles.emptyText}>
Cliquez sur le terrain pour placer votre premier point.
</p>
</div>
) : (
waypoints.map((wp) => (
<div
key={wp.id}
style={styles.nodeItem(wp.id === selectedId)}
onClick={() => handleSelectNode(wp.id)}
onMouseEnter={() => setHoveredNodeId(wp.id)}
onMouseLeave={() => setHoveredNodeId(null)}
>
<div style={styles.nodeInfo}>
<span style={styles.nodeBadge}>ID: {wp.id}</span>
<span style={styles.nodeCoords}>
({wp.x}, {wp.y}, {wp.z})
</span>
</div>
<div style={styles.nodeSubinfo}>
Connexions : {wp.connections.join(", ") || "Aucune"}
</div>
</div>
))
)}
</div>
{/* Details & editing for selected node */}
{selectedNode && (
<div style={styles.detailsCard}>
<h3 style={styles.detailsTitle}>
Détails Waypoint {selectedNode.id}
</h3>
<div style={styles.detailsGrid}>
<div style={styles.detailsRow}>
<strong>Pos X:</strong> <span>{selectedNode.x}</span>
</div>
<div style={styles.detailsRow}>
<strong>Pos Y:</strong>{" "}
<span>{selectedNode.y} (Hauteur)</span>
</div>
<div style={styles.detailsRow}>
<strong>Pos Z:</strong> <span>{selectedNode.z}</span>
</div>
</div>
<div style={styles.detailsActions}>
<button
style={styles.connectButton(
isConnectingMode &&
activeConnectionStartId === selectedNode.id,
)}
onClick={() => startConnecting(selectedNode.id)}
>
<Link2 size={16} />
{isConnectingMode &&
activeConnectionStartId === selectedNode.id
? "Cliquez sur un autre..."
: "Relier à..."}
</button>
<button
style={styles.deleteButton}
onClick={() => handleDeleteNode(selectedNode.id)}
>
<Trash2 size={16} /> Supprimer
</button>
</div>
</div>
)}
{/* Details & editing for selected connection path */}
{selectedConnection && (
<div style={styles.detailsCard}>
<h3 style={styles.detailsTitle}>Liaison Sélectionnée</h3>
<div style={{ ...styles.detailsGrid, marginBottom: "1.25rem" }}>
<div style={styles.detailsRow}>
<strong>Point A:</strong>{" "}
<span>ID {selectedConnection.idA}</span>
</div>
<div style={styles.detailsRow}>
<strong>Point B:</strong>{" "}
<span>ID {selectedConnection.idB}</span>
</div>
<div style={styles.detailsRow}>
<strong>Type:</strong> <span>Lien Bidirectionnel</span>
</div>
</div>
<button
style={styles.deleteButton}
onClick={() =>
deleteSelectedConnection(
selectedConnection.idA,
selectedConnection.idB,
)
}
>
<Trash2 size={16} /> Supprimer la Liaison
</button>
</div>
)}
</aside>
{/* 3. Three.js Canvas */}
<main
style={styles.canvasContainer}
onPointerUp={handleGlobalPointerUp}
onContextMenu={(e) => e.preventDefault()}
>
{/* Active Hover point details (DOM-optimized to prevent high-frequency React renders) */}
<div id="coords-panel" style={styles.coordsFloating}>
Survolez le terrain...
</div>
{isConnectingMode && (
<div style={styles.connectingBanner}>
<Info size={16} /> Mode Connexion Actif : Cliquez sur le deuxième
waypoint pour lier le point {activeConnectionStartId}.
</div>
)}
{dragStartNodeId !== null && (
<div style={styles.connectingBanner}>
<Link2 size={16} /> Relier le point {dragStartNodeId}... Glissez
et relâchez sur un autre point.
</div>
)}
<Canvas shadows>
{/* Top-down isometric / orthographic camera */}
<OrthographicCamera
makeDefault
position={[0, 150, 0]}
rotation={[-Math.PI / 2, 0, 0]}
zoom={8}
far={1000}
near={0.1}
/>
<ambientLight intensity={1.5} />
<directionalLight
position={[50, 200, 50]}
intensity={2.0}
castShadow
/>
{/* Locked Orbit/Map controls (Locked rotation for strict top-down, disabled during link dragging to prevent panning) */}
<MapControls
enableRotate={false}
enabled={dragStartNodeId === null}
/>
{/* Load Terrain, Track hover & draw drag previews/spheres cleanly using full-rate raycasting scene */}
<EditorScene
waypoints={waypoints}
selectedId={selectedId}
hoveredNodeId={hoveredNodeId}
setHoveredNodeId={setHoveredNodeId}
setDragStartNodeId={setDragStartNodeId}
dragStartNodeId={dragStartNodeId}
hoverPointRef={hoverPointRef}
handleTerrainClick={handleTerrainClick}
handleSelectNode={handleSelectNode}
selectedConnection={selectedConnection}
setSelectedConnection={handleSelectConnection}
hoveredConnection={hoveredConnection}
setHoveredConnection={setHoveredConnection}
/>
</Canvas>
</main>
</div>
</div>
);
};
// ==========================================
// Styles (Premium Dark Glassmorphism)
// ==========================================
const styles = {
container: {
width: "100vw",
height: "100vh",
display: "flex",
flexDirection: "column",
backgroundColor: "#0f172a", // deep slate
color: "#f8fafc",
fontFamily: "system-ui, -apple-system, sans-serif",
overflow: "hidden",
} as React.CSSProperties,
header: {
height: "64px",
backgroundColor: "rgba(30, 41, 59, 0.75)",
borderBottom: "1px solid rgba(255, 255, 255, 0.08)",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "0 24px",
backdropFilter: "blur(12px)",
zIndex: 10,
} as React.CSSProperties,
logoGroup: {
display: "flex",
alignItems: "center",
gap: "12px",
} as React.CSSProperties,
logoIcon: {
color: "#3b82f6",
},
logoText: {
fontSize: "18px",
fontWeight: 600,
letterSpacing: "-0.02em",
margin: 0,
} as React.CSSProperties,
headerControls: {
display: "flex",
gap: "12px",
} as React.CSSProperties,
primaryButton: {
backgroundColor: "#3b82f6",
color: "#ffffff",
border: "none",
borderRadius: "8px",
padding: "8px 16px",
fontSize: "14px",
fontWeight: 500,
cursor: "pointer",
display: "flex",
alignItems: "center",
gap: "8px",
boxShadow: "0 4px 12px rgba(59, 130, 246, 0.3)",
transition: "all 0.2s",
} as React.CSSProperties,
secondaryButton: {
backgroundColor: "rgba(255, 255, 255, 0.05)",
color: "#f8fafc",
border: "1px solid rgba(255, 255, 255, 0.1)",
borderRadius: "8px",
padding: "8px 16px",
fontSize: "14px",
fontWeight: 500,
cursor: "pointer",
display: "flex",
alignItems: "center",
gap: "8px",
transition: "all 0.2s",
} as React.CSSProperties,
mainArea: {
flex: 1,
display: "flex",
overflow: "hidden",
position: "relative",
} as React.CSSProperties,
sidebar: {
width: "360px",
backgroundColor: "rgba(15, 23, 42, 0.85)",
borderRight: "1px solid rgba(255, 255, 255, 0.08)",
display: "flex",
flexDirection: "column",
padding: "20px",
backdropFilter: "blur(16px)",
zIndex: 5,
} as React.CSSProperties,
sidebarHeader: {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "16px",
} as React.CSSProperties,
sidebarTitle: {
fontSize: "16px",
fontWeight: 600,
margin: 0,
} as React.CSSProperties,
clearButton: {
backgroundColor: "transparent",
color: "#ef4444",
border: "none",
cursor: "pointer",
fontSize: "12px",
display: "flex",
alignItems: "center",
gap: "4px",
} as React.CSSProperties,
nodesList: {
flex: 1,
overflowY: "auto",
display: "flex",
flexDirection: "column",
gap: "8px",
paddingRight: "4px",
marginBottom: "20px",
} as React.CSSProperties,
emptyState: {
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
height: "200px",
color: "#64748b",
textAlign: "center",
padding: "0 20px",
} as React.CSSProperties,
emptyIcon: {
marginBottom: "12px",
color: "#475569",
},
emptyText: {
fontSize: "13px",
lineHeight: "1.5",
margin: 0,
} as React.CSSProperties,
nodeItem: (isSelected: boolean): React.CSSProperties => ({
padding: "12px",
borderRadius: "10px",
border: `1px solid ${isSelected ? "rgba(59, 130, 246, 0.4)" : "rgba(255, 255, 255, 0.05)"}`,
backgroundColor: isSelected
? "rgba(59, 130, 246, 0.12)"
: "rgba(255, 255, 255, 0.02)",
cursor: "pointer",
transition: "all 0.2s",
display: "flex",
flexDirection: "column",
gap: "4px",
}),
nodeInfo: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
} as React.CSSProperties,
nodeBadge: {
fontSize: "12px",
fontWeight: 600,
color: "#3b82f6",
} as React.CSSProperties,
nodeCoords: {
fontSize: "11px",
color: "#94a3b8",
} as React.CSSProperties,
nodeSubinfo: {
fontSize: "11px",
color: "#64748b",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
} as React.CSSProperties,
detailsCard: {
backgroundColor: "rgba(255, 255, 255, 0.03)",
border: "1px solid rgba(255, 255, 255, 0.06)",
borderRadius: "12px",
padding: "16px",
display: "flex",
flexDirection: "column",
gap: "12px",
} as React.CSSProperties,
detailsTitle: {
fontSize: "14px",
fontWeight: 600,
margin: 0,
} as React.CSSProperties,
detailsGrid: {
display: "flex",
flexDirection: "column",
gap: "8px",
fontSize: "13px",
} as React.CSSProperties,
detailsRow: {
display: "flex",
justifyContent: "space-between",
borderBottom: "1px solid rgba(255, 255, 255, 0.04)",
paddingBottom: "4px",
} as React.CSSProperties,
detailsActions: {
display: "flex",
gap: "10px",
marginTop: "6px",
} as React.CSSProperties,
connectButton: (isActive: boolean): React.CSSProperties => ({
flex: 1,
backgroundColor: isActive ? "#ff0055" : "rgba(16, 185, 129, 0.15)",
color: isActive ? "#ffffff" : "#10b981",
border: `1px solid ${isActive ? "#ff0055" : "rgba(16, 185, 129, 0.3)"}`,
borderRadius: "8px",
padding: "8px 12px",
fontSize: "13px",
fontWeight: 500,
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "6px",
boxShadow: isActive ? "0 4px 12px rgba(255, 0, 85, 0.3)" : "none",
transition: "all 0.2s",
}),
deleteButton: {
backgroundColor: "rgba(239, 68, 68, 0.15)",
color: "#ef4444",
border: "1px solid rgba(239, 68, 68, 0.3)",
borderRadius: "8px",
padding: "8px 12px",
fontSize: "13px",
fontWeight: 500,
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "6px",
transition: "all 0.2s",
} as React.CSSProperties,
canvasContainer: {
flex: 1,
position: "relative",
} as React.CSSProperties,
coordsFloating: {
position: "absolute",
top: "16px",
left: "16px",
backgroundColor: "rgba(15, 23, 42, 0.85)",
border: "1px solid rgba(255, 255, 255, 0.08)",
borderRadius: "8px",
padding: "8px 14px",
fontSize: "12px",
color: "#94a3b8",
backdropFilter: "blur(8px)",
pointerEvents: "none",
zIndex: 1,
} as React.CSSProperties,
connectingBanner: {
position: "absolute",
top: "16px",
left: "50%",
transform: "translateX(-50%)",
backgroundColor: "#ff0055",
color: "#ffffff",
borderRadius: "8px",
padding: "10px 20px",
fontSize: "13px",
fontWeight: 500,
boxShadow: "0 4px 20px rgba(255, 0, 85, 0.4)",
display: "flex",
alignItems: "center",
gap: "8px",
pointerEvents: "none",
zIndex: 1,
} as React.CSSProperties,
};