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; 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 = ({ 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(null); const rubberLineRef = useRef(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(null); const dragStartNodeIdRef = useRef(null); const waypointsRef = useRef([]); 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 ( {/* 1. Terrain Mesh (Raycasted for adding/dragging) */} { 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) */} {/* 3. Render Established Connections */} {/* 4. Render Waypoint Node Spheres */} ); }; // ========================================== // 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 = ({ waypoints, selectedId, onSelect, hoveredNodeId, setHoveredNodeId, setDragStartNodeId, }) => { return ( {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 ( { 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 */} {/* Ring indicator */} ); })} ); }; 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 = ({ 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(); 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 ( {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 ( { 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 }); }} /> ); })} ); }; // ========================================== // 4. Main Waypoint Editor Page Component // ========================================== export const WaypointEditorPage: React.FC = () => { const [waypoints, setWaypoints] = useState([]); const [selectedId, setSelectedId] = useState(null); const [hoveredNodeId, setHoveredNodeId] = useState(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(null); // Connection / Drag states const [dragStartNodeId, setDragStartNodeId] = useState(null); const [isConnectingMode, setIsConnectingMode] = useState(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 (
{/* 1. Header Navigation */}

La Fabrik — Waypoint Network Editor

{/* 2. Left sidebar: Nodes manager */} {/* 3. Three.js Canvas */}
e.preventDefault()} > {/* Active Hover point details (DOM-optimized to prevent high-frequency React renders) */}
Survolez le terrain...
{isConnectingMode && (
Mode Connexion Actif : Cliquez sur le deuxième waypoint pour lier le point {activeConnectionStartId}.
)} {dragStartNodeId !== null && (
Relier le point {dragStartNodeId}... Glissez et relâchez sur un autre point.
)} {/* Top-down isometric / orthographic camera */} {/* Locked Orbit/Map controls (Locked rotation for strict top-down, disabled during link dragging to prevent panning) */} {/* Load Terrain, Track hover & draw drag previews/spheres cleanly using full-rate raycasting scene */}
); }; // ========================================== // 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, };