diff --git a/frontend/src/components/RegionMap.tsx b/frontend/src/components/RegionMap.tsx index acb2898..8b9ebe1 100644 --- a/frontend/src/components/RegionMap.tsx +++ b/frontend/src/components/RegionMap.tsx @@ -8,7 +8,7 @@ import { loadWormholeSystems, saveWormholeSystem, deleteWormholeSystem } from '@ import { System, Position, Connection as ConnectionType } from '@/lib/types'; import { getSecurityColor } from '@/utils/securityColors'; import { Header } from './Header'; -import { ListCharacters, StartESILogin, SetDestinationForAll, AddWaypointForAllByName, PostRouteForAllByNames } from 'wailsjs/go/main/App'; +import { ListCharacters, StartESILogin, SetDestinationForAll, PostRouteForAllByNames } from 'wailsjs/go/main/App'; import { toast } from '@/hooks/use-toast'; import { getSystemsRegions } from '@/utils/systemApi'; @@ -57,6 +57,26 @@ function computeNodeConnections(systems: Map): Map 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 }); @@ -74,7 +94,9 @@ export const RegionMap = ({ regionName, focusSystem, isCompact = false, isWormho const [viaDest, setViaDest] = useState(null); const [viaQueue, setViaQueue] = useState([]); - const [offRegionLinks, setOffRegionLinks] = useState>([]); + type OffIndicator = { from: string; toRegion: string; count: number; color: string; angle: number; sampleTo?: string }; + const [offRegionIndicators, setOffRegionIndicators] = useState([]); + const [meanInboundAngle, setMeanInboundAngle] = useState>({}); useEffect(() => { const onKeyDown = async (e: KeyboardEvent) => { @@ -110,39 +132,108 @@ export const RegionMap = ({ regionName, focusSystem, isCompact = false, isWormho setPositions(positions); const connections = computeNodeConnections(systems); setConnections(connections); + // Compute per-system mean inbound angle from in-region neighbors + const angleMap: Record = {}; + systems.forEach((sys, name) => { + const neighbors = (sys.connectedSystems || '').split(',').map(s => s.trim()).filter(Boolean); + let sumX = 0, sumY = 0, count = 0; + for (const n of neighbors) { + const neighbor = systems.get(n); + if (!neighbor) continue; + const ax = sys.x - neighbor.x; + const ay = sys.y - neighbor.y; // vector pointing into this system + const a = Math.atan2(ay, ax); + sumX += Math.cos(a); + sumY += Math.sin(a); + count++; + } + if (count > 0) { + angleMap[name] = Math.atan2(sumY, sumX); + } + }); + setMeanInboundAngle(angleMap); }, [systems]); - // Compute off-region links lazily using PB only for missing nodes + // 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) { - setOffRegionLinks([]); - return; - } - const out: Array<{ from: string; to: string; toRegion: string }> = []; + if (!systems || systems.size === 0) { setOffRegionIndicators([]); return; } + const toLookup: Set = new Set(); - for (const [fromName, sys] of systems.entries()) { + 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); - } - } + for (const n of neighbors) if (!systems.has(n)) toLookup.add(n); } - if (toLookup.size === 0) { setOffRegionLinks([]); return; } - const regionMap = await getSystemsRegions(Array.from(toLookup)); + 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 + 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)) { - const toRegion = regionMap[n]; - if (toRegion && toRegion !== regionName) { - out.push({ from: fromName, to: n, toRegion }); + if (systems.has(n)) continue; + const toRegion = nameToRegion[n]; + if (!toRegion || toRegion === regionName) continue; + + // compute color + const remote = regionSystemsCache.get(toRegion)?.get(n); + const avgSec = ((sys.security || 0) + (remote?.security || 0)) / 2; + const color = getSecurityColor(avgSec); + + // compute angle via universe region centroids + let angle: number | undefined = undefined; + const inbound = meanInboundAngle[fromName]; + if (inbound !== undefined) { + angle = inbound + Math.PI; // opposite direction of mean inbound + } else { + const curPos = universeRegionPosCache.get(regionName); + const toPos = universeRegionPosCache.get(toRegion); + if (curPos && toPos) { + angle = Math.atan2(toPos.y - curPos.y, toPos.x - curPos.x); } } + if (angle === undefined) { + // final fallback deterministic angle + let h = 0; const key = `${fromName}->${toRegion}`; + for (let i = 0; i < key.length; i++) h = (h * 31 + key.charCodeAt(i)) >>> 0; + angle = (h % 360) * (Math.PI / 180); + } + + const gkey = `${fromName}__${toRegion}`; + const prev = grouped.get(gkey); + if (prev) { + prev.count += 1; + if (!prev.sampleTo) prev.sampleTo = n; + } else { + grouped.set(gkey, { from: fromName, toRegion, count: 1, color, angle, sampleTo: n }); + } } } - setOffRegionLinks(out); + + setOffRegionIndicators(Array.from(grouped.values())); }; computeOffRegion(); }, [systems, regionName]); @@ -509,7 +600,7 @@ export const RegionMap = ({ regionName, focusSystem, isCompact = false, isWormho - + @@ -553,18 +644,30 @@ export const RegionMap = ({ regionName, focusSystem, isCompact = false, isWormho /> ))} - {/* Off-region link indicators (clickable arrows) */} - {offRegionLinks.map((link, idx) => { - const pos = positions[link.from]; + {/* Off-region indicators: labeled arrows pointing toward the destination region */} + {offRegionIndicators.map((ind, idx) => { + const pos = positions[ind.from]; if (!pos) return null; - const offsetX = 10; - const offsetY = -14; + 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 ( - - - - {link.toRegion} - { e.stopPropagation(); navigate(`/regions/${encodeURIComponent(link.toRegion)}/${encodeURIComponent(link.to)}`); }} /> + + + {label} + + { e.stopPropagation(); navigate(`/regions/${encodeURIComponent(ind.toRegion)}`); }}> + + {label} + ); })}