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

1226 lines
44 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';
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>
);
};