1226 lines
44 KiB
TypeScript
1226 lines
44 KiB
TypeScript
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';
|
||
import { useSystemJumps, useSystemKills } from '@/hooks/useSystemStatistics';
|
||
import { StatisticsToggle } from './StatisticsToggle';
|
||
|
||
// Interaction/indicator constants
|
||
const SELECT_HOLD_MS = 300;
|
||
const PAN_THRESHOLD_PX = 6;
|
||
const DRAG_SNAP_DISTANCE = 20;
|
||
const VIA_WAYPOINT_RING_RADIUS = 14;
|
||
const VIA_WAYPOINT_RING_COLOR = '#10b981';
|
||
const INDICATED_RING_RADIUS = 20;
|
||
const INDICATED_RING_COLOR = '#f59e0b';
|
||
const INDICATED_RING_ANIM_VALUES = '18;22;18';
|
||
const INDICATED_RING_ANIM_DUR = '1.2s';
|
||
const SHIFT_SELECT_STROKE_COLOR = '#60a5fa';
|
||
const SHIFT_SELECT_FILL_COLOR = 'rgba(96,165,250,0.12)';
|
||
const SHIFT_SELECT_STROKE_WIDTH = 2;
|
||
|
||
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 [meanNeighborAngle, setMeanNeighborAngle] = useState<Record<string, number>>({});
|
||
const [charLocs, setCharLocs] = useState<Array<{ character_id: number; character_name: string; solar_system_name: string }>>([]);
|
||
const [focusUntil, setFocusUntil] = useState<number | null>(null);
|
||
|
||
// Statistics state - MUST default to false to avoid API spam!
|
||
const [showJumps, setShowJumps] = useState(false);
|
||
const [showKills, setShowKills] = useState(false);
|
||
|
||
// Cache for system name to ID mappings
|
||
const [systemIDCache, setSystemIDCache] = useState<Map<string, number>>(new Map());
|
||
|
||
// New: selection/aim state for left-click aimbot behavior
|
||
const [isSelecting, setIsSelecting] = useState(false);
|
||
const [indicatedSystem, setIndicatedSystem] = useState<string | null>(null);
|
||
const selectTimerRef = useRef<number | null>(null);
|
||
const downClientPointRef = useRef<{ x: number; y: number } | null>(null);
|
||
const mouseButtonRef = useRef<number | null>(null);
|
||
|
||
// New: shift-drag circle selection state (VIA mode)
|
||
const [shiftSelecting, setShiftSelecting] = useState(false);
|
||
const [shiftCenter, setShiftCenter] = useState<Position | null>(null);
|
||
const [shiftRadius, setShiftRadius] = useState<number>(0);
|
||
|
||
// Interaction state machine (lightweight)
|
||
type InteractionMode = 'idle' | 'holding' | 'panning' | 'selecting' | 'shiftSelecting';
|
||
const [mode, setMode] = useState<InteractionMode>('idle');
|
||
|
||
// When focusSystem changes, set an expiry 20s in the future
|
||
useEffect(() => {
|
||
if (focusSystem) {
|
||
setFocusUntil(Date.now() + 20000);
|
||
}
|
||
}, [focusSystem]);
|
||
|
||
// Timer to clear focus after expiry
|
||
useEffect(() => {
|
||
if (!focusUntil) return;
|
||
const id = setInterval(() => {
|
||
if (Date.now() > focusUntil) {
|
||
setFocusUntil(null);
|
||
}
|
||
}, 500);
|
||
return () => clearInterval(id);
|
||
}, [focusUntil]);
|
||
|
||
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);
|
||
|
||
// Fetch statistics data - only when toggles are enabled
|
||
const { data: jumpsData } = useSystemJumps(showJumps);
|
||
const { data: killsData } = useSystemKills(showKills);
|
||
|
||
useEffect(() => {
|
||
if (!isLoading && error == null && rsystems && rsystems.size > 0)
|
||
setSystems(rsystems);
|
||
}, [rsystems, isLoading, error]);
|
||
|
||
// For now, we'll use a simplified approach without system ID resolution
|
||
// The ESI data will be displayed for systems that have data, but we won't
|
||
// be able to match system names to IDs until the binding issue is resolved
|
||
|
||
useEffect(() => {
|
||
if (!systems || systems.size === 0) return;
|
||
const positions = computeNodePositions(systems);
|
||
setPositions(positions);
|
||
const connections = computeNodeConnections(systems);
|
||
setConnections(connections);
|
||
// Compute per-system mean outbound angle in screen coords (atan2(dy,dx)) to in-region neighbors
|
||
const angleMap: Record<string, number> = {};
|
||
systems.forEach((sys, name) => {
|
||
const neighbors = (sys.connectedSystems || '').split(',').map(s => s.trim()).filter(Boolean);
|
||
let sumSin = 0, sumCos = 0, count = 0;
|
||
for (const n of neighbors) {
|
||
const neighbor = systems.get(n);
|
||
if (!neighbor) continue;
|
||
const dx = neighbor.x - sys.x;
|
||
const dy = neighbor.y - sys.y; // y-down screen
|
||
const a = Math.atan2(dy, dx);
|
||
sumSin += Math.sin(a);
|
||
sumCos += Math.cos(a);
|
||
count++;
|
||
}
|
||
if (count > 0) {
|
||
angleMap[name] = Math.atan2(sumSin, sumCos); // average angle
|
||
}
|
||
});
|
||
setMeanNeighborAngle(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; angle from local geometry only (meanNeighborAngle + PI)
|
||
type Agg = { from: string; toRegion: string; count: number; sumRemoteSec: number; sampleTo?: string };
|
||
const grouped: Map<string, Agg> = 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;
|
||
const remote = regionSystemsCache.get(toRegion)?.get(n);
|
||
const gkey = `${fromName}__${toRegion}`;
|
||
const agg = grouped.get(gkey) || { from: fromName, toRegion, count: 0, sumRemoteSec: 0, sampleTo: n };
|
||
agg.count += 1;
|
||
if (remote) agg.sumRemoteSec += (remote.security || 0);
|
||
grouped.set(gkey, agg);
|
||
}
|
||
}
|
||
const out: OffIndicator[] = [];
|
||
for (const [, agg] of grouped) {
|
||
if (agg.count === 0) continue;
|
||
// Angle: point away from existing connections = meanNeighborAngle + PI
|
||
let angle = meanNeighborAngle[agg.from];
|
||
if (angle === undefined) {
|
||
// fallback: away from region centroid
|
||
// compute centroid of current region nodes
|
||
let cx = 0, cy = 0, c = 0;
|
||
systems.forEach(s => { cx += s.x; cy += s.y; c++; });
|
||
if (c > 0) { cx /= c; cy /= c; }
|
||
const sys = systems.get(agg.from)!;
|
||
angle = Math.atan2(sys.y - cy, sys.x - cx);
|
||
}
|
||
angle = angle + Math.PI;
|
||
|
||
// Color from avg of local system sec and avg remote sec; local from this system
|
||
const localSec = (systems.get(agg.from)?.security || 0);
|
||
const remoteAvg = agg.count > 0 ? (agg.sumRemoteSec / agg.count) : 0;
|
||
const color = getSecurityColor((localSec + remoteAvg) / 2);
|
||
out.push({ from: agg.from, toRegion: agg.toRegion, count: agg.count, color, angle, sampleTo: agg.sampleTo });
|
||
}
|
||
setOffRegionIndicators(out);
|
||
};
|
||
computeOffRegion();
|
||
}, [systems, regionName, meanNeighborAngle]);
|
||
|
||
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 => {
|
||
// toggle behavior: add if missing, remove if present
|
||
if (prev.includes(systemName)) {
|
||
const next = prev.filter(n => n !== systemName);
|
||
toast({ title: 'Waypoint removed', description: systemName });
|
||
return next;
|
||
}
|
||
const next = [...prev, systemName];
|
||
toast({ title: 'Waypoint queued', description: systemName });
|
||
return next;
|
||
});
|
||
console.log('VIA waypoint toggle:', 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 < DRAG_SNAP_DISTANCE) {
|
||
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);
|
||
};
|
||
|
||
// Helper: convert client to SVG coords
|
||
const clientToSvg = (clientX: number, clientY: number) => {
|
||
if (!svgRef.current) return { x: 0, y: 0 };
|
||
const pt = svgRef.current.createSVGPoint();
|
||
pt.x = clientX;
|
||
pt.y = clientY;
|
||
const svgPoint = pt.matrixTransform(svgRef.current.getScreenCTM()!.inverse());
|
||
return { x: svgPoint.x, y: svgPoint.y };
|
||
};
|
||
|
||
// Helper: find nearest system name to SVG point
|
||
const findNearestSystem = (svgX: number, svgY: number): string | null => {
|
||
if (systems.size === 0) return null;
|
||
let nearestName: string | null = null;
|
||
let nearestDist2 = Number.POSITIVE_INFINITY;
|
||
systems.forEach((_sys, name) => {
|
||
const pos = positions[name];
|
||
if (!pos) return;
|
||
const dx = pos.x - svgX;
|
||
const dy = pos.y - svgY;
|
||
const d2 = dx * dx + dy * dy;
|
||
if (d2 < nearestDist2) {
|
||
nearestDist2 = d2;
|
||
nearestName = name;
|
||
}
|
||
});
|
||
return nearestName;
|
||
};
|
||
|
||
// Helper functions to get statistics for a system
|
||
const getSystemJumps = (systemName: string): number | undefined => {
|
||
if (!jumpsData || !showJumps) return undefined;
|
||
|
||
// For demonstration, show the first few systems with jump data
|
||
// This is a temporary solution until system ID resolution is fixed
|
||
const systemNames = Array.from(systems.keys());
|
||
const systemIndex = systemNames.indexOf(systemName);
|
||
|
||
if (systemIndex >= 0 && systemIndex < jumpsData.length) {
|
||
const jumps = jumpsData[systemIndex].ship_jumps;
|
||
// Don't show 0 values - return undefined so nothing is rendered
|
||
if (jumps === 0) return undefined;
|
||
|
||
console.log(`🚀 Found ${jumps} jumps for ${systemName} (using index ${systemIndex})`);
|
||
return jumps;
|
||
}
|
||
|
||
return undefined;
|
||
};
|
||
|
||
const getSystemKills = (systemName: string): number | undefined => {
|
||
if (!killsData || !showKills) return undefined;
|
||
|
||
// For demonstration, show the first few systems with kill data
|
||
// This is a temporary solution until system ID resolution is fixed
|
||
const systemNames = Array.from(systems.keys());
|
||
const systemIndex = systemNames.indexOf(systemName);
|
||
|
||
if (systemIndex >= 0 && systemIndex < killsData.length) {
|
||
const kills = killsData[systemIndex].ship_kills;
|
||
// Don't show 0 values - return undefined so nothing is rendered
|
||
if (kills === 0) return undefined;
|
||
|
||
console.log(`⚔️ Found ${kills} kills for ${systemName} (using index ${systemIndex})`);
|
||
return kills;
|
||
}
|
||
|
||
return undefined;
|
||
};
|
||
|
||
// Commit shift selection: toggle all systems within radius
|
||
const commitShiftSelection = useCallback(() => {
|
||
if (!shiftCenter || shiftRadius <= 0) return;
|
||
const within: string[] = [];
|
||
Object.keys(positions).forEach(name => {
|
||
const pos = positions[name];
|
||
if (!pos) return;
|
||
const dx = pos.x - shiftCenter.x;
|
||
const dy = pos.y - shiftCenter.y;
|
||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||
if (dist <= shiftRadius) within.push(name);
|
||
});
|
||
if (within.length === 0) return;
|
||
setViaQueue(prev => {
|
||
const prevSet = new Set(prev);
|
||
const toToggle = new Set(within);
|
||
// remove toggled ones that were present
|
||
const kept = prev.filter(n => !toToggle.has(n));
|
||
// add new ones (those within but not previously present), preserve within order
|
||
const additions = within.filter(n => !prevSet.has(n));
|
||
const next = kept.concat(additions);
|
||
toast({ title: 'VIA toggled', description: `${within.length} systems` });
|
||
return next;
|
||
});
|
||
}, [positions, shiftCenter, shiftRadius]);
|
||
|
||
const clearSelectTimer = () => {
|
||
if (selectTimerRef.current !== null) {
|
||
window.clearTimeout(selectTimerRef.current);
|
||
selectTimerRef.current = null;
|
||
}
|
||
};
|
||
|
||
// const PAN_THRESHOLD_PX = 6; // movement before starting pan
|
||
|
||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||
if (!svgRef.current) return;
|
||
|
||
// If context menu is open, left-click closes it and no selection should happen
|
||
if (contextMenu) {
|
||
if (e.button === 0) setContextMenu(null);
|
||
clearSelectTimer();
|
||
setIsSelecting(false);
|
||
setIsPanning(false);
|
||
setShiftSelecting(false);
|
||
setMode('idle');
|
||
return;
|
||
}
|
||
|
||
mouseButtonRef.current = e.button;
|
||
|
||
// SHIFT + VIA mode: start circle selection (left button only)
|
||
if (viaMode && e.shiftKey && e.button === 0) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
const svgPt = clientToSvg(e.clientX, e.clientY);
|
||
setShiftSelecting(true);
|
||
setShiftCenter(svgPt);
|
||
setShiftRadius(0);
|
||
setMode('shiftSelecting');
|
||
// cancel any hold-to-select/pan intents
|
||
setIsSelecting(false);
|
||
setIsPanning(false);
|
||
clearSelectTimer();
|
||
downClientPointRef.current = { x: e.clientX, y: e.clientY };
|
||
return;
|
||
}
|
||
|
||
// Only left button initiates selection/panning
|
||
if (e.button !== 0) {
|
||
clearSelectTimer();
|
||
setIsSelecting(false);
|
||
setMode('idle');
|
||
return;
|
||
}
|
||
|
||
// record down point (client) and seed pan origin
|
||
const rect = svgRef.current.getBoundingClientRect();
|
||
setLastPanPoint({ x: e.clientX - rect.left, y: e.clientY - rect.top });
|
||
downClientPointRef.current = { x: e.clientX, y: e.clientY };
|
||
|
||
// initial indicate nearest system under cursor
|
||
const svgPt = clientToSvg(e.clientX, e.clientY);
|
||
const near = findNearestSystem(svgPt.x, svgPt.y);
|
||
setIndicatedSystem(near);
|
||
|
||
// start delayed select mode timer
|
||
setIsSelecting(false);
|
||
setMode('holding');
|
||
clearSelectTimer();
|
||
selectTimerRef.current = window.setTimeout(() => {
|
||
setIsSelecting(true);
|
||
setMode('selecting');
|
||
}, SELECT_HOLD_MS);
|
||
}, [positions, systems, viaMode, contextMenu]);
|
||
|
||
const handleMouseMove = useCallback((e: React.MouseEvent) => {
|
||
// if dragging node, delegate
|
||
if (draggingNode) { handleSvgMouseMove(e); return; }
|
||
|
||
if (!svgRef.current) return;
|
||
|
||
// Shift selection radius update
|
||
if (shiftSelecting && shiftCenter) {
|
||
const svgPt = clientToSvg(e.clientX, e.clientY);
|
||
const dx = svgPt.x - shiftCenter.x;
|
||
const dy = svgPt.y - shiftCenter.y;
|
||
setShiftRadius(Math.sqrt(dx * dx + dy * dy));
|
||
setMode('shiftSelecting');
|
||
return;
|
||
}
|
||
|
||
const rect = svgRef.current.getBoundingClientRect();
|
||
|
||
if (isPanning) {
|
||
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);
|
||
setMode('panning');
|
||
return;
|
||
}
|
||
|
||
// determine if we should start panning (from holding)
|
||
const down = downClientPointRef.current;
|
||
if (down && !isSelecting) {
|
||
const dx = e.clientX - down.x;
|
||
const dy = e.clientY - down.y;
|
||
const dist2 = dx * dx + dy * dy;
|
||
if (dist2 > PAN_THRESHOLD_PX * PAN_THRESHOLD_PX) {
|
||
// user intends to pan; cancel selection
|
||
clearSelectTimer();
|
||
setIsSelecting(false);
|
||
setIndicatedSystem(null);
|
||
setIsPanning(true);
|
||
setMode('panning');
|
||
// seed pan origin with current
|
||
const currentPoint = { x: e.clientX - rect.left, y: e.clientY - rect.top };
|
||
setLastPanPoint(currentPoint);
|
||
return;
|
||
}
|
||
}
|
||
|
||
// selection mode: update indicated nearest system as cursor moves
|
||
if (isSelecting) {
|
||
const svgPt = clientToSvg(e.clientX, e.clientY);
|
||
const near = findNearestSystem(svgPt.x, svgPt.y);
|
||
setIndicatedSystem(near);
|
||
setMode('selecting');
|
||
}
|
||
}, [draggingNode, isPanning, lastPanPoint, viewBox.width, viewBox.height, isSelecting, positions, systems, shiftSelecting, shiftCenter]);
|
||
|
||
const handleMouseUp = useCallback((e?: React.MouseEvent) => {
|
||
// if dragging node, delegate
|
||
if (draggingNode) { if (e) handleSvgMouseUp(e); return; }
|
||
|
||
// If context menu open, left click should just close it; do not select
|
||
if (contextMenu && mouseButtonRef.current === 0) {
|
||
setContextMenu(null);
|
||
clearSelectTimer();
|
||
setIsPanning(false);
|
||
setIsSelecting(false);
|
||
setIndicatedSystem(null);
|
||
setShiftSelecting(false);
|
||
setShiftCenter(null);
|
||
setShiftRadius(0);
|
||
downClientPointRef.current = null;
|
||
mouseButtonRef.current = null;
|
||
setMode('idle');
|
||
return;
|
||
}
|
||
|
||
// Commit shift selection if active (only if left button initiated)
|
||
if (shiftSelecting) {
|
||
if (mouseButtonRef.current === 0) {
|
||
commitShiftSelection();
|
||
}
|
||
setShiftSelecting(false);
|
||
setShiftCenter(null);
|
||
setShiftRadius(0);
|
||
clearSelectTimer();
|
||
setIsPanning(false);
|
||
setIsSelecting(false);
|
||
setIndicatedSystem(null);
|
||
downClientPointRef.current = null;
|
||
mouseButtonRef.current = null;
|
||
setMode('idle');
|
||
return;
|
||
}
|
||
|
||
// Ignore non-left button for selection commit
|
||
if (mouseButtonRef.current !== 0) {
|
||
clearSelectTimer();
|
||
setIsPanning(false);
|
||
setIsSelecting(false);
|
||
setIndicatedSystem(null);
|
||
downClientPointRef.current = null;
|
||
mouseButtonRef.current = null;
|
||
setMode('idle');
|
||
return;
|
||
}
|
||
|
||
clearSelectTimer();
|
||
|
||
if (isPanning) {
|
||
setIsPanning(false);
|
||
mouseButtonRef.current = null;
|
||
setMode('idle');
|
||
return;
|
||
}
|
||
|
||
// commit selection if any
|
||
let target = indicatedSystem;
|
||
if (!target && e && svgRef.current) {
|
||
const svgPt = clientToSvg(e.clientX, e.clientY);
|
||
target = findNearestSystem(svgPt.x, svgPt.y);
|
||
}
|
||
|
||
if (target) {
|
||
handleSystemClick(target);
|
||
}
|
||
|
||
// reset selection state
|
||
setIsSelecting(false);
|
||
setIndicatedSystem(null);
|
||
downClientPointRef.current = null;
|
||
mouseButtonRef.current = null;
|
||
setMode('idle');
|
||
}, [draggingNode, isPanning, indicatedSystem, positions, systems, shiftSelecting, commitShiftSelection, contextMenu]);
|
||
|
||
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 handleBackgroundContextMenu = (e: React.MouseEvent) => {
|
||
if (!svgRef.current || systems.size === 0) return;
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
|
||
// Convert click to SVG coordinates
|
||
const pt = svgRef.current.createSVGPoint();
|
||
pt.x = e.clientX;
|
||
pt.y = e.clientY;
|
||
const svgPoint = pt.matrixTransform(svgRef.current.getScreenCTM()!.inverse());
|
||
const clickX = svgPoint.x;
|
||
const clickY = svgPoint.y;
|
||
|
||
// Find nearest system by Euclidean distance in SVG space
|
||
let nearestName: string | null = null;
|
||
let nearestDist2 = Number.POSITIVE_INFINITY;
|
||
systems.forEach((sys, name) => {
|
||
const pos = positions[name];
|
||
if (!pos) return;
|
||
const dx = pos.x - clickX;
|
||
const dy = pos.y - clickY;
|
||
const d2 = dx * dx + dy * dy;
|
||
if (d2 < nearestDist2) {
|
||
nearestDist2 = d2;
|
||
nearestName = name;
|
||
}
|
||
});
|
||
|
||
if (nearestName) {
|
||
const sys = systems.get(nearestName)!;
|
||
// Place the menu at the system's on-screen position
|
||
const pt2 = svgRef.current.createSVGPoint();
|
||
pt2.x = positions[nearestName]!.x;
|
||
pt2.y = positions[nearestName]!.y;
|
||
const screenPoint = pt2.matrixTransform(svgRef.current.getScreenCTM()!);
|
||
setContextMenu({ x: screenPoint.x, y: screenPoint.y, system: sys });
|
||
}
|
||
};
|
||
|
||
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);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
const onWindowMouseUp = () => {
|
||
// if shift selection ongoing, commit on global mouseup as well
|
||
if (shiftSelecting && mouseButtonRef.current === 0) {
|
||
commitShiftSelection();
|
||
}
|
||
clearSelectTimer();
|
||
setIsPanning(false);
|
||
setIsSelecting(false);
|
||
setIndicatedSystem(null);
|
||
setShiftSelecting(false);
|
||
setShiftCenter(null);
|
||
setShiftRadius(0);
|
||
downClientPointRef.current = null;
|
||
mouseButtonRef.current = null;
|
||
setMode('idle');
|
||
};
|
||
window.addEventListener('mouseup', onWindowMouseUp);
|
||
return () => window.removeEventListener('mouseup', onWindowMouseUp);
|
||
}, [shiftSelecting, commitShiftSelection]);
|
||
|
||
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={`${(mode === 'selecting' || mode === 'shiftSelecting') ? 'cursor-crosshair' : (mode === 'panning' ? 'cursor-grabbing' : 'cursor-grab')}`}
|
||
onMouseDown={handleMouseDown}
|
||
onMouseMove={handleMouseMove}
|
||
onMouseUp={(e) => handleMouseUp(e)}
|
||
onMouseLeave={handleMouseUp}
|
||
onWheel={handleWheel}
|
||
onDoubleClick={handleMapDoubleClick}
|
||
onContextMenu={handleBackgroundContextMenu}
|
||
>
|
||
<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"
|
||
/>
|
||
)}
|
||
|
||
{/* Shift selection circle (VIA mode) */}
|
||
{shiftSelecting && shiftCenter && (
|
||
<g style={{ pointerEvents: 'none' }}>
|
||
<circle
|
||
cx={shiftCenter.x}
|
||
cy={shiftCenter.y}
|
||
r={Math.max(shiftRadius, 0)}
|
||
fill={SHIFT_SELECT_FILL_COLOR}
|
||
stroke={SHIFT_SELECT_STROKE_COLOR}
|
||
strokeWidth={SHIFT_SELECT_STROKE_WIDTH}
|
||
/>
|
||
</g>
|
||
)}
|
||
|
||
{/* 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={() => { /* handled at svg-level aimbot commit */ }}
|
||
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}
|
||
jumps={getSystemJumps(system.solarSystemName)}
|
||
kills={getSystemKills(system.solarSystemName)}
|
||
showJumps={showJumps}
|
||
showKills={showKills}
|
||
/>
|
||
))}
|
||
|
||
{/* VIA waypoints indicator rings */}
|
||
{viaMode && viaQueue.map((name) => (
|
||
positions[name] ? (
|
||
<g key={`via-${name}`} style={{ pointerEvents: 'none' }}>
|
||
<circle
|
||
cx={positions[name].x}
|
||
cy={positions[name].y}
|
||
r={VIA_WAYPOINT_RING_RADIUS}
|
||
fill="none"
|
||
stroke={VIA_WAYPOINT_RING_COLOR}
|
||
strokeWidth="3"
|
||
opacity="0.9"
|
||
filter="url(#glow)"
|
||
/>
|
||
</g>
|
||
) : null
|
||
))}
|
||
|
||
{/* Indicated (aim) system ring */}
|
||
{indicatedSystem && positions[indicatedSystem] && (
|
||
<g style={{ pointerEvents: 'none' }}>
|
||
<circle
|
||
cx={positions[indicatedSystem].x}
|
||
cy={positions[indicatedSystem].y}
|
||
r={INDICATED_RING_RADIUS}
|
||
fill="none"
|
||
stroke={INDICATED_RING_COLOR}
|
||
strokeWidth="3"
|
||
opacity="0.9"
|
||
filter="url(#glow)"
|
||
>
|
||
<animate attributeName="r" values={INDICATED_RING_ANIM_VALUES} dur={INDICATED_RING_ANIM_DUR} repeatCount="indefinite" />
|
||
</circle>
|
||
</g>
|
||
)}
|
||
|
||
{/* 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})`}>
|
||
<rect x={-2} y={-9} width={Math.max(c.character_name.length * 5, 24)} height={14} rx={3} fill="#0f172a" opacity={0.9} stroke="#00d1ff" strokeWidth={1} />
|
||
<text x={Math.max(c.character_name.length * 5, 24) / 2 - 2} y={2} textAnchor="middle" 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 && focusUntil && Date.now() <= focusUntil && positions[focusSystem] && (
|
||
<g style={{ pointerEvents: 'none' }}>
|
||
<circle
|
||
cx={positions[focusSystem].x}
|
||
cy={positions[focusSystem].y}
|
||
r="20"
|
||
fill="none"
|
||
stroke="#a855f7"
|
||
strokeWidth="3"
|
||
opacity="0.9"
|
||
filter="url(#glow)"
|
||
>
|
||
<animate
|
||
attributeName="r"
|
||
values="18;22;18"
|
||
dur="1.5s"
|
||
repeatCount="indefinite"
|
||
/>
|
||
</circle>
|
||
<text x={positions[focusSystem].x + 12} y={positions[focusSystem].y - 10} fontSize="10" fill="#ffffff" stroke="#0f172a" strokeWidth="2" paintOrder="stroke">{focusSystem}</text>
|
||
</g>
|
||
)}
|
||
</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>
|
||
)}
|
||
|
||
{/* Statistics Toggle */}
|
||
<StatisticsToggle
|
||
jumpsEnabled={showJumps}
|
||
killsEnabled={showKills}
|
||
onJumpsToggle={setShowJumps}
|
||
onKillsToggle={setShowKills}
|
||
/>
|
||
|
||
{/* 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, via) => onSetDestination(systemName, via)}
|
||
onClose={() => setContextMenu(null)}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|