Update
This commit is contained in:
515
frontend/src/components/RegionMap.tsx
Normal file
515
frontend/src/components/RegionMap.tsx
Normal file
@@ -0,0 +1,515 @@
|
||||
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { MapNode } from '@/components/MapNode';
|
||||
import { SystemContextMenu } from '@/components/SystemContextMenu';
|
||||
import { Connection } from '@/components/Connection';
|
||||
import { useRegionData } from '@/hooks/useRegionData';
|
||||
import { loadWormholeSystems, saveWormholeSystem, deleteWormholeSystem } from '@/utils/wormholeStorage';
|
||||
import { System, Position, Connection as ConnectionType } from '@/lib/types';
|
||||
import { getSecurityColor } from '@/utils/securityColors';
|
||||
import { Header } from './Header';
|
||||
|
||||
interface RegionMapProps {
|
||||
regionName: string;
|
||||
focusSystem?: string;
|
||||
isCompact?: boolean;
|
||||
isWormholeRegion?: boolean;
|
||||
}
|
||||
|
||||
interface ContextMenuState {
|
||||
x: number,
|
||||
y: number,
|
||||
system: System
|
||||
}
|
||||
|
||||
function computeNodePositions(systems: Map<string, System>): Record<string, Position> {
|
||||
const nodePositions: Record<string, Position> = {};
|
||||
systems.forEach((system, key) => {
|
||||
nodePositions[key] = { x: system.x, y: system.y };
|
||||
});
|
||||
return nodePositions;
|
||||
}
|
||||
function computeNodeConnections(systems: Map<string, System>): Map<string, ConnectionType> {
|
||||
const connections: Map<string, ConnectionType> = new Map();
|
||||
for (const [key, system] of systems.entries()) {
|
||||
const connectedSystems = system.connectedSystems.split(',');
|
||||
for (let connectedSystem of connectedSystems) {
|
||||
connectedSystem = connectedSystem.trim();
|
||||
const connectionKey = [key, connectedSystem].sort().join('-');
|
||||
const destinationSystem = systems.get(connectedSystem);
|
||||
if (destinationSystem) {
|
||||
const averageSecurity = ((system.security || 0) + (destinationSystem.security || 0)) / 2;
|
||||
const connection = {
|
||||
key: connectionKey,
|
||||
from: { x: system.x, y: system.y },
|
||||
to: { x: destinationSystem.x, y: destinationSystem.y },
|
||||
color: getSecurityColor(averageSecurity)
|
||||
}
|
||||
connections.set(connectionKey, connection);
|
||||
} else {
|
||||
console.log(`Destination system ${connectedSystem} not found for ${key}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return connections;
|
||||
}
|
||||
|
||||
export const RegionMap = ({ regionName, focusSystem, isCompact = false, isWormholeRegion = false }: RegionMapProps) => {
|
||||
const navigate = useNavigate();
|
||||
const [viewBox, setViewBox] = useState({ x: 0, y: 0, width: 1200, height: 800 });
|
||||
const [isPanning, setIsPanning] = useState(false);
|
||||
const [lastPanPoint, setLastPanPoint] = useState({ x: 0, y: 0 });
|
||||
const [draggingNode, setDraggingNode] = useState<string | null>(null);
|
||||
const [tempConnection, setTempConnection] = useState<{ from: Position; to: Position } | null>(null);
|
||||
const [systems, setSystems] = useState<Map<string, System>>(new Map());
|
||||
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
|
||||
const [connections, setConnections] = useState<Map<string, ConnectionType>>(new Map());
|
||||
const [positions, setPositions] = useState<Record<string, Position>>({});
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
|
||||
const { data: rsystems, isLoading, error } = useRegionData(regionName);
|
||||
useEffect(() => {
|
||||
if (!isLoading && error == null && rsystems && rsystems.size > 0)
|
||||
setSystems(rsystems);
|
||||
}, [rsystems, isLoading, error]);
|
||||
|
||||
// Process connections when systems or nodePositions change
|
||||
useEffect(() => {
|
||||
if (!systems || systems.size === 0) return;
|
||||
console.log("Computing node positions");
|
||||
const positions = computeNodePositions(systems);
|
||||
setPositions(positions);
|
||||
console.log("Computing node connections");
|
||||
const connections = computeNodeConnections(systems);
|
||||
setConnections(connections);
|
||||
}, [systems]);
|
||||
|
||||
// Load wormhole systems on mount if in wormhole region
|
||||
useEffect(() => {
|
||||
if (isWormholeRegion) {
|
||||
loadWormholeSystems().then(wormholeSystems => {
|
||||
setSystems(wormholeSystems);
|
||||
});
|
||||
}
|
||||
}, [isWormholeRegion]);
|
||||
|
||||
const handleSystemClick = (systemName: string) => {
|
||||
if (focusSystem === systemName) return;
|
||||
navigate(`/regions/${regionName}/${systemName}`);
|
||||
};
|
||||
|
||||
const handleSystemDoubleClick = async (e: React.MouseEvent, position: Position) => {
|
||||
if (!isWormholeRegion) return;
|
||||
e.stopPropagation();
|
||||
|
||||
// Create a new system at the clicked position
|
||||
const newSystemName = `WH-${Date.now()}`;
|
||||
const newSystem: System = {
|
||||
solarSystemName: newSystemName,
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
connectedSystems: ''
|
||||
}
|
||||
const dbSystem = await saveWormholeSystem(newSystem);
|
||||
setSystems(new Map(systems).set(newSystemName, dbSystem));
|
||||
};
|
||||
|
||||
const handleMapDoubleClick = (e: React.MouseEvent) => {
|
||||
if (!isWormholeRegion || !svgRef.current) return;
|
||||
e.stopPropagation();
|
||||
|
||||
const point = svgRef.current.createSVGPoint();
|
||||
point.x = e.clientX;
|
||||
point.y = e.clientY;
|
||||
|
||||
// Convert to SVG coordinates
|
||||
const svgPoint = point.matrixTransform(svgRef.current.getScreenCTM()!.inverse());
|
||||
const x = svgPoint.x;
|
||||
const y = svgPoint.y;
|
||||
|
||||
handleSystemDoubleClick(e, { x, y });
|
||||
};
|
||||
|
||||
const handleNodeDragStart = (e: React.MouseEvent, nodeId: string) => {
|
||||
if (!isWormholeRegion) return;
|
||||
e.stopPropagation();
|
||||
setDraggingNode(nodeId);
|
||||
|
||||
const point = svgRef.current.createSVGPoint();
|
||||
point.x = e.clientX;
|
||||
point.y = e.clientY;
|
||||
|
||||
// Convert to SVG coordinates
|
||||
const svgPoint = point.matrixTransform(svgRef.current.getScreenCTM()!.inverse());
|
||||
const x = svgPoint.x;
|
||||
const y = svgPoint.y;
|
||||
|
||||
setTempConnection({
|
||||
from: { x: systems.get(nodeId)!.x, y: systems.get(nodeId)!.y },
|
||||
to: { x, y }
|
||||
});
|
||||
};
|
||||
|
||||
const handleSvgMouseMove = (e: React.MouseEvent) => {
|
||||
if (!draggingNode || !isWormholeRegion || !svgRef.current) return;
|
||||
e.stopPropagation();
|
||||
|
||||
// Create a point in screen coordinates
|
||||
const point = svgRef.current.createSVGPoint();
|
||||
point.x = e.clientX;
|
||||
point.y = e.clientY;
|
||||
|
||||
// Convert to SVG coordinates
|
||||
const svgPoint = point.matrixTransform(svgRef.current.getScreenCTM()!.inverse());
|
||||
const x = svgPoint.x;
|
||||
const y = svgPoint.y;
|
||||
|
||||
// Check for nearby systems to snap to
|
||||
var targetSystem: System | null = null;
|
||||
systems.forEach(system => {
|
||||
if (system.solarSystemName === draggingNode) return;
|
||||
const dx = system.x - x;
|
||||
const dy = system.y - y;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
if (distance < 20) {
|
||||
targetSystem = system;
|
||||
}
|
||||
});
|
||||
|
||||
// Update temp connection with either snapped position or exact mouse position
|
||||
setTempConnection(prev => prev ? {
|
||||
from: prev.from,
|
||||
to: targetSystem ? { x: targetSystem.x, y: targetSystem.y } : { x, y }
|
||||
} : null);
|
||||
|
||||
if (targetSystem)
|
||||
handleNodeDragEnd(e, targetSystem.solarSystemName);
|
||||
};
|
||||
|
||||
const handleSvgMouseUp = (e: React.MouseEvent) => {
|
||||
if (!draggingNode || !isWormholeRegion) return;
|
||||
e.stopPropagation();
|
||||
handleNodeDragEnd(e);
|
||||
};
|
||||
|
||||
const handleNodeDragEnd = (e: React.MouseEvent, targetNodeId?: string) => {
|
||||
if (!draggingNode || !isWormholeRegion) return;
|
||||
e.stopPropagation();
|
||||
|
||||
if (targetNodeId && targetNodeId !== draggingNode) {
|
||||
// Check if connection already exists
|
||||
const connectionKey = [draggingNode, targetNodeId].sort().join('-');
|
||||
const connectionExists = connections.has(connectionKey);
|
||||
|
||||
if (!connectionExists) {
|
||||
const systemFrom = systems.get(draggingNode)!;
|
||||
const systemTo = systems.get(targetNodeId)!;
|
||||
|
||||
const systemFromConnectedSystems = systemFrom.connectedSystems.split(",").filter(system => system != "");
|
||||
systemFromConnectedSystems.push(targetNodeId);
|
||||
systemFrom.connectedSystems = systemFromConnectedSystems.join(",");
|
||||
|
||||
const systemToConnectedSystems = systemTo.connectedSystems.split(",").filter(system => system != "");
|
||||
systemToConnectedSystems.push(draggingNode);
|
||||
systemTo.connectedSystems = systemToConnectedSystems.join(",");
|
||||
|
||||
systems.set(draggingNode, systemFrom);
|
||||
systems.set(targetNodeId, systemTo);
|
||||
setSystems(new Map(systems));
|
||||
|
||||
// We will just trust that they got saved
|
||||
saveWormholeSystem(systemFrom);
|
||||
saveWormholeSystem(systemTo);
|
||||
}
|
||||
}
|
||||
|
||||
setDraggingNode(null);
|
||||
setTempConnection(null);
|
||||
};
|
||||
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
if (!svgRef.current) return;
|
||||
setIsPanning(true);
|
||||
const rect = svgRef.current.getBoundingClientRect();
|
||||
setLastPanPoint({
|
||||
x: e.clientX - rect.left,
|
||||
y: e.clientY - rect.top
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleMouseMove = useCallback((e: React.MouseEvent) => {
|
||||
if (!isPanning || !svgRef.current) return;
|
||||
|
||||
const rect = svgRef.current.getBoundingClientRect();
|
||||
const currentPoint = {
|
||||
x: e.clientX - rect.left,
|
||||
y: e.clientY - rect.top
|
||||
};
|
||||
|
||||
const deltaX = (lastPanPoint.x - currentPoint.x) * (viewBox.width / rect.width);
|
||||
const deltaY = (lastPanPoint.y - currentPoint.y) * (viewBox.height / rect.height);
|
||||
|
||||
setViewBox(prev => ({
|
||||
...prev,
|
||||
x: prev.x + deltaX,
|
||||
y: prev.y + deltaY
|
||||
}));
|
||||
|
||||
setLastPanPoint(currentPoint);
|
||||
}, [isPanning, lastPanPoint, viewBox.width, viewBox.height]);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
setIsPanning(false);
|
||||
}, []);
|
||||
|
||||
const handleWheel = useCallback((e: React.WheelEvent) => {
|
||||
e.preventDefault();
|
||||
if (!svgRef.current) return;
|
||||
|
||||
const rect = svgRef.current.getBoundingClientRect();
|
||||
const mouseX = e.clientX - rect.left;
|
||||
const mouseY = e.clientY - rect.top;
|
||||
|
||||
const scale = e.deltaY < 0 ? 1.1 : 0.9;
|
||||
const newWidth = viewBox.width * scale;
|
||||
const newHeight = viewBox.height * scale;
|
||||
|
||||
const mouseXInSVG = viewBox.x + (mouseX / rect.width) * viewBox.width;
|
||||
const mouseYInSVG = viewBox.y + (mouseY / rect.height) * viewBox.height;
|
||||
|
||||
const newX = mouseXInSVG - (mouseX / rect.width) * newWidth;
|
||||
const newY = mouseYInSVG - (mouseY / rect.height) * newHeight;
|
||||
|
||||
setViewBox({
|
||||
x: newX,
|
||||
y: newY,
|
||||
width: newWidth,
|
||||
height: newHeight
|
||||
});
|
||||
}, [viewBox]);
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent, system: System) => {
|
||||
if (!isWormholeRegion) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const rect = svgRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
|
||||
setContextMenu({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
system: system
|
||||
});
|
||||
};
|
||||
|
||||
const handleRenameSystem = (oldName: string, newName: string) => {
|
||||
const system = systems.get(oldName);
|
||||
if (system) {
|
||||
const newSystems = new Map(systems);
|
||||
system.solarSystemName = newName;
|
||||
newSystems.set(newName, system);
|
||||
newSystems.delete(oldName);
|
||||
saveWormholeSystem(system);
|
||||
|
||||
// Update all the systems this system connects to
|
||||
const connectedSystems = system.connectedSystems.split(',');
|
||||
for (const connectedSystem of connectedSystems) {
|
||||
const connectedSystemobj = newSystems.get(connectedSystem);
|
||||
if (connectedSystemobj) {
|
||||
connectedSystemobj.connectedSystems = connectedSystemobj.connectedSystems.replace(oldName, newName);
|
||||
newSystems.set(connectedSystem, connectedSystemobj);
|
||||
saveWormholeSystem(connectedSystemobj);
|
||||
}
|
||||
}
|
||||
|
||||
// Update all the systems that connect to this system
|
||||
for (const [key, system] of newSystems.entries()) {
|
||||
if (system.connectedSystems.includes(oldName)) {
|
||||
system.connectedSystems = system.connectedSystems.replace(oldName, newName);
|
||||
newSystems.set(key, system);
|
||||
saveWormholeSystem(system);
|
||||
}
|
||||
}
|
||||
|
||||
setSystems(newSystems);
|
||||
}
|
||||
|
||||
setContextMenu(null);
|
||||
};
|
||||
|
||||
const handleClearConnections = async (system: System) => {
|
||||
system.connectedSystems = '';
|
||||
for (const [key, isystem] of systems.entries()) {
|
||||
if (system.solarSystemName === isystem.solarSystemName) continue;
|
||||
if (!isystem.connectedSystems.includes(system.solarSystemName)) continue;
|
||||
isystem.connectedSystems = isystem.connectedSystems.split(',').filter(connectedSystem => connectedSystem !== system.solarSystemName).join(',');
|
||||
systems.set(key, isystem);
|
||||
saveWormholeSystem(isystem);
|
||||
}
|
||||
setSystems(new Map(systems));
|
||||
saveWormholeSystem(system);
|
||||
setContextMenu(null);
|
||||
};
|
||||
|
||||
const handleDeleteSystem = async (system: System) => {
|
||||
try {
|
||||
const newSystems = new Map(systems);
|
||||
newSystems.delete(system.solarSystemName);
|
||||
setSystems(newSystems);
|
||||
await deleteWormholeSystem(system);
|
||||
setContextMenu(null);
|
||||
} catch (error) {
|
||||
console.error('Error deleting system:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Close context menu when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = () => setContextMenu(null);
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
return () => document.removeEventListener('click', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="h-full w-full bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 flex items-center justify-center">
|
||||
<div className="text-white text-xl">Loading {regionName} data...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="h-full w-full bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="text-4xl font-bold text-white mb-4">Error Loading Region</h1>
|
||||
<p className="text-red-400 mb-6">Failed to load data for {regionName}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full h-full bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 relative">
|
||||
<Header
|
||||
title={`Region: ${regionName}`}
|
||||
breadcrumbs={[
|
||||
{ label: "Universe", path: "/" },
|
||||
{ label: regionName }
|
||||
]}
|
||||
/>
|
||||
<svg
|
||||
ref={svgRef}
|
||||
width="100%"
|
||||
height="100%"
|
||||
viewBox={`${viewBox.x} ${viewBox.y} ${viewBox.width} ${viewBox.height}`}
|
||||
className="cursor-grab active:cursor-grabbing"
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={(e) => {
|
||||
if (isPanning) {
|
||||
handleMouseMove(e);
|
||||
} else {
|
||||
handleSvgMouseMove(e);
|
||||
}
|
||||
}}
|
||||
onMouseUp={(e) => {
|
||||
if (isPanning) {
|
||||
handleMouseUp();
|
||||
} else {
|
||||
handleSvgMouseUp(e);
|
||||
}
|
||||
}}
|
||||
onMouseLeave={handleMouseUp}
|
||||
onWheel={handleWheel}
|
||||
onDoubleClick={handleMapDoubleClick}
|
||||
>
|
||||
<defs>
|
||||
<filter id="glow">
|
||||
<feGaussianBlur stdDeviation="3" result="coloredBlur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="coloredBlur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
{/* Render all connections */}
|
||||
{Array.from(connections.entries()).map(([key, connection]) => (
|
||||
<Connection
|
||||
key={key}
|
||||
from={connection.from}
|
||||
to={connection.to}
|
||||
color={connection.color}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Render temporary connection while dragging */}
|
||||
{tempConnection && (
|
||||
<Connection
|
||||
from={tempConnection.from}
|
||||
to={tempConnection.to}
|
||||
color="#f472b6"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Render existing systems */}
|
||||
{Array.from(systems.entries()).map(([key, system]) => (
|
||||
<MapNode
|
||||
key={key}
|
||||
id={system.solarSystemName}
|
||||
name={system.solarSystemName}
|
||||
position={positions[system.solarSystemName] || { x: 0, y: 0 }}
|
||||
onClick={() => handleSystemClick(system.solarSystemName)}
|
||||
onDoubleClick={(e) => handleSystemDoubleClick(e, positions[system.solarSystemName])}
|
||||
onDragStart={(e) => handleNodeDragStart(e, system.solarSystemName)}
|
||||
onDrag={handleSvgMouseMove}
|
||||
onDragEnd={handleNodeDragEnd}
|
||||
onContextMenu={(e) => handleContextMenu(e, system)}
|
||||
type="system"
|
||||
security={system.security}
|
||||
signatures={system.signatures}
|
||||
isDraggable={isWormholeRegion}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Highlight focused system */}
|
||||
{focusSystem && positions[focusSystem] && (
|
||||
<circle
|
||||
cx={positions[focusSystem].x}
|
||||
cy={positions[focusSystem].y}
|
||||
r="15"
|
||||
fill="none"
|
||||
stroke="#a855f7"
|
||||
strokeWidth="3"
|
||||
strokeDasharray="5,5"
|
||||
opacity="0.8"
|
||||
>
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
attributeType="XML"
|
||||
type="rotate"
|
||||
from={`0 ${positions[focusSystem].x} ${positions[focusSystem].y}`}
|
||||
to={`360 ${positions[focusSystem].x} ${positions[focusSystem].y}`}
|
||||
dur="10s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</circle>
|
||||
)}
|
||||
</svg>
|
||||
|
||||
{/* Context Menu */}
|
||||
{contextMenu && (
|
||||
<SystemContextMenu
|
||||
x={contextMenu.x}
|
||||
y={contextMenu.y}
|
||||
system={systems.get(contextMenu.system.solarSystemName)!}
|
||||
onRename={(newName) => handleRenameSystem(contextMenu.system.solarSystemName, newName)}
|
||||
onDelete={handleDeleteSystem}
|
||||
onClearConnections={handleClearConnections}
|
||||
onClose={() => setContextMenu(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
Reference in New Issue
Block a user