Files
eve-signaler/frontend/src/components/RegionMap.tsx

754 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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';
import { ListCharacters, StartESILogin, SetDestinationForAll, PostRouteForAllByNames, GetCharacterLocations } from 'wailsjs/go/main/App';
import { toast } from '@/hooks/use-toast';
import { getSystemsRegions } from '@/utils/systemApi';
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;
}
// Cache of region -> Map(systemName -> System) from region JSONs
const regionSystemsCache: Map<string, Map<string, System>> = new Map();
// Cache of universe region centroids (regionName -> {x, y})
const universeRegionPosCache: Map<string, { x: number; y: number }> = new Map();
let universeLoaded = false;
const ensureUniversePositions = async () => {
if (universeLoaded) return;
try {
const resp = await fetch('/universe.json');
if (!resp.ok) return;
const regions: Array<{ regionName: string; x: string; y: string; security: number; connectsTo: string }> = await resp.json();
for (const r of regions) {
universeRegionPosCache.set(r.regionName, { x: parseInt(r.x, 10), y: parseInt(r.y, 10) });
}
universeLoaded = true;
} catch (_) {
// ignore
}
};
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 [viaMode, setViaMode] = useState(false);
const [viaDest, setViaDest] = useState<string | null>(null);
const [viaQueue, setViaQueue] = useState<string[]>([]);
type OffIndicator = { from: string; toRegion: string; count: number; color: string; angle: number; sampleTo?: string };
const [offRegionIndicators, setOffRegionIndicators] = useState<OffIndicator[]>([]);
const [meanInboundAngle, setMeanInboundAngle] = useState<Record<string, number>>({});
const [charLocs, setCharLocs] = useState<Array<{ character_id: number; character_name: string; solar_system_name: string }>>([]);
useEffect(() => {
const onKeyDown = async (e: KeyboardEvent) => {
if (e.key === 'Escape' && viaMode) {
try {
if (!(await ensureAnyLoggedIn())) return;
if (viaDest) {
await PostRouteForAllByNames(viaDest, viaQueue);
toast({ title: 'Route set', description: `${viaDest}${viaQueue.length ? ' via ' + viaQueue.join(', ') : ''}` });
}
} catch (err: any) {
toast({ title: 'Failed to set route', description: String(err), variant: 'destructive' });
} finally {
setViaMode(false);
setViaDest(null);
setViaQueue([]);
}
}
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [viaMode, viaDest, viaQueue]);
const { data: rsystems, isLoading, error } = useRegionData(regionName);
useEffect(() => {
if (!isLoading && error == null && rsystems && rsystems.size > 0)
setSystems(rsystems);
}, [rsystems, isLoading, error]);
useEffect(() => {
if (!systems || systems.size === 0) return;
const positions = computeNodePositions(systems);
setPositions(positions);
const connections = computeNodeConnections(systems);
setConnections(connections);
// Compute per-system mean inbound angle from in-region neighbors
const angleMap: Record<string, number> = {};
systems.forEach((sys, name) => {
const neighbors = (sys.connectedSystems || '').split(',').map(s => s.trim()).filter(Boolean);
let sumX = 0, sumY = 0, count = 0;
for (const n of neighbors) {
const neighbor = systems.get(n);
if (!neighbor) continue;
const ax = sys.x - neighbor.x;
const ay = sys.y - neighbor.y; // vector pointing into this system
const a = Math.atan2(ay, ax);
sumX += Math.cos(a);
sumY += Math.sin(a);
count++;
}
if (count > 0) {
angleMap[name] = Math.atan2(sumY, sumX);
}
});
setMeanInboundAngle(angleMap);
}, [systems]);
// Poll character locations every 7s and store those in this region
useEffect(() => {
let timer: any;
const tick = async () => {
try {
const locs = await GetCharacterLocations();
const here = locs.filter(l => !!l.solar_system_name && systems.has(l.solar_system_name));
setCharLocs(here.map(l => ({ character_id: l.character_id, character_name: l.character_name, solar_system_name: l.solar_system_name })));
} catch (_) {
// ignore
} finally {
timer = setTimeout(tick, 7000);
}
};
tick();
return () => { if (timer) clearTimeout(timer); };
}, [systems]);
// Compute off-region indicators: dedupe per (from, toRegion), compute avg color, and angle via universe centroids
useEffect(() => {
const computeOffRegion = async () => {
if (!systems || systems.size === 0) { setOffRegionIndicators([]); return; }
const toLookup: Set<string> = new Set();
for (const [, sys] of systems.entries()) {
const neighbors = (sys.connectedSystems || '').split(',').map(s => s.trim()).filter(Boolean);
for (const n of neighbors) if (!systems.has(n)) toLookup.add(n);
}
if (toLookup.size === 0) { setOffRegionIndicators([]); return; }
const nameToRegion = await getSystemsRegions(Array.from(toLookup));
// Cache remote region systems (for security values) and universe positions
const neededRegions = new Set<string>();
for (const n of Object.keys(nameToRegion)) {
const r = nameToRegion[n];
if (!r) continue;
if (!regionSystemsCache.has(r)) neededRegions.add(r);
}
if (neededRegions.size > 0) {
await Promise.all(Array.from(neededRegions).map(async (r) => {
try {
const resp = await fetch(`/${encodeURIComponent(r)}.json`);
if (!resp.ok) return;
const systemsList: System[] = await resp.json();
const m = new Map<string, System>();
systemsList.forEach(s => m.set(s.solarSystemName, s));
regionSystemsCache.set(r, m);
} catch (_) { /* noop */ }
}));
}
await ensureUniversePositions();
// Build indicators: group by from+toRegion
const grouped: Map<string, OffIndicator> = new Map();
for (const [fromName, sys] of systems.entries()) {
const neighbors = (sys.connectedSystems || '').split(',').map(s => s.trim()).filter(Boolean);
for (const n of neighbors) {
if (systems.has(n)) continue;
const toRegion = nameToRegion[n];
if (!toRegion || toRegion === regionName) continue;
// compute color
const remote = regionSystemsCache.get(toRegion)?.get(n);
const avgSec = ((sys.security || 0) + (remote?.security || 0)) / 2;
const color = getSecurityColor(avgSec);
// compute angle via universe region centroids
let angle: number | undefined = undefined;
const inbound = meanInboundAngle[fromName];
if (inbound !== undefined) {
angle = inbound + Math.PI; // opposite direction of mean inbound
} else {
const curPos = universeRegionPosCache.get(regionName);
const toPos = universeRegionPosCache.get(toRegion);
if (curPos && toPos) {
angle = Math.atan2(toPos.y - curPos.y, toPos.x - curPos.x);
}
}
if (angle === undefined) {
// final fallback deterministic angle
let h = 0; const key = `${fromName}->${toRegion}`;
for (let i = 0; i < key.length; i++) h = (h * 31 + key.charCodeAt(i)) >>> 0;
angle = (h % 360) * (Math.PI / 180);
}
const gkey = `${fromName}__${toRegion}`;
const prev = grouped.get(gkey);
if (prev) {
prev.count += 1;
if (!prev.sampleTo) prev.sampleTo = n;
} else {
grouped.set(gkey, { from: fromName, toRegion, count: 1, color, angle, sampleTo: n });
}
}
}
setOffRegionIndicators(Array.from(grouped.values()));
};
computeOffRegion();
}, [systems, regionName]);
useEffect(() => {
if (isWormholeRegion) {
loadWormholeSystems().then(wormholeSystems => {
setSystems(wormholeSystems);
});
}
}, [isWormholeRegion]);
const ensureAnyLoggedIn = async () => {
try {
const list = await ListCharacters();
if (Array.isArray(list) && list.length > 0) return true;
await StartESILogin();
toast({ title: 'EVE Login', description: 'Complete login in your browser, then retry.' });
return false;
} catch (e: any) {
await StartESILogin();
toast({ title: 'EVE Login', description: 'Complete login in your browser, then retry.', variant: 'destructive' });
return false;
}
};
const handleSystemClick = async (systemName: string) => {
if (viaMode) {
setViaQueue(prev => {
if (prev.includes(systemName)) return prev;
const next = [...prev, systemName];
return next;
});
console.log('Queued waypoint:', systemName);
toast({ title: 'Waypoint queued', description: systemName });
return;
}
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) => {
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);
}
};
const onSetDestination = async (systemName: string, wantVia: boolean) => {
try {
if (!(await ensureAnyLoggedIn())) return;
if (wantVia) {
setViaDest(systemName);
setViaQueue([]);
setViaMode(true);
console.log('Via mode start, dest:', systemName);
toast({ title: 'Via mode', description: `Destination ${systemName}. Click systems to add waypoints. Esc to commit.` });
} else {
await SetDestinationForAll(systemName, true, false);
toast({ title: 'Destination set', description: systemName });
}
} catch (e: any) {
console.error('Failed to set destination:', e);
toast({ title: 'Failed to set destination', description: String(e), variant: 'destructive' });
}
};
// 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 if (draggingNode) { handleSvgMouseMove(e); } }}
onMouseUp={(e) => { if (isPanning) { handleMouseUp(); } else if (draggingNode) { 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>
<marker id="arrowhead" markerWidth="6" markerHeight="6" refX="5" refY="3" orient="auto" markerUnits="strokeWidth">
<path d="M0,0 L0,6 L6,3 z" fill="#ffffff" />
</marker>
</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}
disableNavigate={viaMode}
/>
))}
{/* Character location markers */}
{charLocs.map((c, idx) => {
const pos = positions[c.solar_system_name];
if (!pos) return null;
const yoff = -18 - (idx % 3) * 10; // stagger small vertical offsets if multiple in same system
return (
<g key={`char-${c.character_id}-${idx}`} transform={`translate(${pos.x}, ${pos.y + yoff})`}>
<circle r={4} fill="#00d1ff" stroke="#ffffff" strokeWidth={1} />
<text x={6} y={3} fontSize={8} fill="#ffffff">{c.character_name}</text>
</g>
);
})}
{/* Off-region indicators: labeled arrows pointing toward the destination region */}
{offRegionIndicators.map((ind, idx) => {
const pos = positions[ind.from];
if (!pos) return null;
const len = 26;
const r0 = 10; // start just outside node
const dx = Math.cos(ind.angle);
const dy = Math.sin(ind.angle);
const x1 = pos.x + dx * r0;
const y1 = pos.y + dy * r0;
const x2 = x1 + dx * len;
const y2 = y1 + dy * len;
const labelX = x2 + dx * 8;
const labelY = y2 + dy * 8;
const label = ind.count > 1 ? `${ind.toRegion} ×${ind.count}` : ind.toRegion;
return (
<g key={`offr-${idx}`}>
<line x1={x1} y1={y1} x2={x2} y2={y2} stroke={ind.color} strokeWidth={2} markerEnd="url(#arrowhead)">
<title>{label}</title>
</line>
<g transform={`translate(${labelX}, ${labelY})`} onClick={(e) => { e.stopPropagation(); navigate(`/regions/${encodeURIComponent(ind.toRegion)}`); }}>
<rect x={-2} y={-10} width={Math.max(label.length * 5, 24)} height={14} rx={7} fill="#0f172a" opacity={0.85} stroke={ind.color} strokeWidth={1} />
<text x={Math.max(label.length * 5, 24) / 2 - 2} y={0} textAnchor="middle" fontSize="8" fill="#ffffff">{label}</text>
</g>
</g>
);
})}
{/* 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>
{viaMode && (
<div className="absolute top-2 right-2 px-2 py-1 rounded bg-emerald-600 text-white text-xs shadow">
VIA mode: Dest {viaDest} ({viaQueue.length} waypoints). Esc to commit
</div>
)}
{/* 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}
onSetDestination={(systemName) => onSetDestination(systemName, true)}
onClose={() => setContextMenu(null)}
/>
)}
</div>
);
};