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): Record { const nodePositions: Record = {}; systems.forEach((system, key) => { nodePositions[key] = { x: system.x, y: system.y }; }); return nodePositions; } function computeNodeConnections(systems: Map): Map { const connections: Map = 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> = new Map(); // Cache of universe region centroids (regionName -> {x, y}) const universeRegionPosCache: Map = 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(null); const [tempConnection, setTempConnection] = useState<{ from: Position; to: Position } | null>(null); const [systems, setSystems] = useState>(new Map()); const [contextMenu, setContextMenu] = useState(null); const [connections, setConnections] = useState>(new Map()); const [positions, setPositions] = useState>({}); const svgRef = useRef(null); const [viaMode, setViaMode] = useState(false); const [viaDest, setViaDest] = useState(null); const [viaQueue, setViaQueue] = useState([]); type OffIndicator = { from: string; toRegion: string; count: number; color: string; angle: number; sampleTo?: string }; const [offRegionIndicators, setOffRegionIndicators] = useState([]); const [meanNeighborAngle, setMeanNeighborAngle] = useState>({}); const [charLocs, setCharLocs] = useState>([]); const [focusUntil, setFocusUntil] = useState(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>(new Map()); // New: selection/aim state for left-click aimbot behavior const [isSelecting, setIsSelecting] = useState(false); const [indicatedSystem, setIndicatedSystem] = useState(null); const selectTimerRef = useRef(null); const downClientPointRef = useRef<{ x: number; y: number } | null>(null); const mouseButtonRef = useRef(null); // New: shift-drag circle selection state (VIA mode) const [shiftSelecting, setShiftSelecting] = useState(false); const [shiftCenter, setShiftCenter] = useState(null); const [shiftRadius, setShiftRadius] = useState(0); // Interaction state machine (lightweight) type InteractionMode = 'idle' | 'holding' | 'panning' | 'selecting' | 'shiftSelecting'; const [mode, setMode] = useState('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 = {}; 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 = 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(); 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(); 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 = 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 (
Loading {regionName} data...
); } if (error) { return (

Error Loading Region

Failed to load data for {regionName}

); } return (
handleMouseUp(e)} onMouseLeave={handleMouseUp} onWheel={handleWheel} onDoubleClick={handleMapDoubleClick} onContextMenu={handleBackgroundContextMenu} > {/* Render all connections */} {Array.from(connections.entries()).map(([key, connection]) => ( ))} {/* Render temporary connection while dragging */} {tempConnection && ( )} {/* Shift selection circle (VIA mode) */} {shiftSelecting && shiftCenter && ( )} {/* Render existing systems */} {Array.from(systems.entries()).map(([key, system]) => ( { /* 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] ? ( ) : null ))} {/* Indicated (aim) system ring */} {indicatedSystem && positions[indicatedSystem] && ( )} {/* 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 ( {c.character_name} ); })} {/* 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 ( {label} { e.stopPropagation(); navigate(`/regions/${encodeURIComponent(ind.toRegion)}`); }}> {label} ); })} {/* Highlight focused system */} {focusSystem && focusUntil && Date.now() <= focusUntil && positions[focusSystem] && ( {focusSystem} )} {viaMode && (
VIA mode: Dest {viaDest} ({viaQueue.length} waypoints). Esc to commit
)} {/* Statistics Toggle */} {/* Context Menu */} {contextMenu && ( handleRenameSystem(contextMenu.system.solarSystemName, newName)} onDelete={handleDeleteSystem} onClearConnections={handleClearConnections} onSetDestination={(systemName, via) => onSetDestination(systemName, via)} onClose={() => setContextMenu(null)} /> )}
); };