refactor(RegionMap.tsx): improve off-region link indicators with better caching and visualization
This commit is contained in:
@@ -8,7 +8,7 @@ import { loadWormholeSystems, saveWormholeSystem, deleteWormholeSystem } from '@
|
|||||||
import { System, Position, Connection as ConnectionType } from '@/lib/types';
|
import { System, Position, Connection as ConnectionType } from '@/lib/types';
|
||||||
import { getSecurityColor } from '@/utils/securityColors';
|
import { getSecurityColor } from '@/utils/securityColors';
|
||||||
import { Header } from './Header';
|
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 { toast } from '@/hooks/use-toast';
|
||||||
import { getSystemsRegions } from '@/utils/systemApi';
|
import { getSystemsRegions } from '@/utils/systemApi';
|
||||||
|
|
||||||
@@ -57,6 +57,26 @@ function computeNodeConnections(systems: Map<string, System>): Map<string, Conne
|
|||||||
return connections;
|
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) => {
|
export const RegionMap = ({ regionName, focusSystem, isCompact = false, isWormholeRegion = false }: RegionMapProps) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [viewBox, setViewBox] = useState({ x: 0, y: 0, width: 1200, height: 800 });
|
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<string | null>(null);
|
const [viaDest, setViaDest] = useState<string | null>(null);
|
||||||
const [viaQueue, setViaQueue] = useState<string[]>([]);
|
const [viaQueue, setViaQueue] = useState<string[]>([]);
|
||||||
|
|
||||||
const [offRegionLinks, setOffRegionLinks] = useState<Array<{ from: string; to: string; toRegion: string }>>([]);
|
type OffIndicator = { from: string; toRegion: string; count: number; color: string; angle: number; sampleTo?: string };
|
||||||
|
const [offRegionIndicators, setOffRegionIndicators] = useState<OffIndicator[]>([]);
|
||||||
|
const [meanInboundAngle, setMeanInboundAngle] = useState<Record<string, number>>({});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKeyDown = async (e: KeyboardEvent) => {
|
const onKeyDown = async (e: KeyboardEvent) => {
|
||||||
@@ -110,39 +132,108 @@ export const RegionMap = ({ regionName, focusSystem, isCompact = false, isWormho
|
|||||||
setPositions(positions);
|
setPositions(positions);
|
||||||
const connections = computeNodeConnections(systems);
|
const connections = computeNodeConnections(systems);
|
||||||
setConnections(connections);
|
setConnections(connections);
|
||||||
|
// Compute per-system mean inbound angle from in-region neighbors
|
||||||
|
const angleMap: Record<string, number> = {};
|
||||||
|
systems.forEach((sys, name) => {
|
||||||
|
const neighbors = (sys.connectedSystems || '').split(',').map(s => s.trim()).filter(Boolean);
|
||||||
|
let sumX = 0, sumY = 0, count = 0;
|
||||||
|
for (const n of neighbors) {
|
||||||
|
const neighbor = systems.get(n);
|
||||||
|
if (!neighbor) continue;
|
||||||
|
const ax = sys.x - neighbor.x;
|
||||||
|
const ay = sys.y - neighbor.y; // vector pointing into this system
|
||||||
|
const a = Math.atan2(ay, ax);
|
||||||
|
sumX += Math.cos(a);
|
||||||
|
sumY += Math.sin(a);
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
if (count > 0) {
|
||||||
|
angleMap[name] = Math.atan2(sumY, sumX);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setMeanInboundAngle(angleMap);
|
||||||
}, [systems]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const computeOffRegion = async () => {
|
const computeOffRegion = async () => {
|
||||||
if (!systems || systems.size === 0) {
|
if (!systems || systems.size === 0) { setOffRegionIndicators([]); return; }
|
||||||
setOffRegionLinks([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const out: Array<{ from: string; to: string; toRegion: string }> = [];
|
|
||||||
const toLookup: Set<string> = new Set();
|
const toLookup: Set<string> = new Set();
|
||||||
|
for (const [, sys] of systems.entries()) {
|
||||||
|
const neighbors = (sys.connectedSystems || '').split(',').map(s => s.trim()).filter(Boolean);
|
||||||
|
for (const n of neighbors) if (!systems.has(n)) toLookup.add(n);
|
||||||
|
}
|
||||||
|
if (toLookup.size === 0) { setOffRegionIndicators([]); return; }
|
||||||
|
|
||||||
|
const nameToRegion = await getSystemsRegions(Array.from(toLookup));
|
||||||
|
|
||||||
|
// Cache remote region systems (for security values) and universe positions
|
||||||
|
const neededRegions = new Set<string>();
|
||||||
|
for (const n of Object.keys(nameToRegion)) {
|
||||||
|
const r = nameToRegion[n];
|
||||||
|
if (!r) continue;
|
||||||
|
if (!regionSystemsCache.has(r)) neededRegions.add(r);
|
||||||
|
}
|
||||||
|
if (neededRegions.size > 0) {
|
||||||
|
await Promise.all(Array.from(neededRegions).map(async (r) => {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/${encodeURIComponent(r)}.json`);
|
||||||
|
if (!resp.ok) return;
|
||||||
|
const systemsList: System[] = await resp.json();
|
||||||
|
const m = new Map<string, System>();
|
||||||
|
systemsList.forEach(s => m.set(s.solarSystemName, s));
|
||||||
|
regionSystemsCache.set(r, m);
|
||||||
|
} catch (_) { /* noop */ }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
await ensureUniversePositions();
|
||||||
|
|
||||||
|
// Build indicators: group by from+toRegion
|
||||||
|
const grouped: Map<string, OffIndicator> = new Map();
|
||||||
for (const [fromName, sys] of systems.entries()) {
|
for (const [fromName, sys] of systems.entries()) {
|
||||||
const neighbors = (sys.connectedSystems || '').split(',').map(s => s.trim()).filter(Boolean);
|
const neighbors = (sys.connectedSystems || '').split(',').map(s => s.trim()).filter(Boolean);
|
||||||
for (const n of neighbors) {
|
for (const n of neighbors) {
|
||||||
if (!systems.has(n)) {
|
if (systems.has(n)) continue;
|
||||||
toLookup.add(n);
|
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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (toLookup.size === 0) { setOffRegionLinks([]); return; }
|
|
||||||
const regionMap = await getSystemsRegions(Array.from(toLookup));
|
setOffRegionIndicators(Array.from(grouped.values()));
|
||||||
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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setOffRegionLinks(out);
|
|
||||||
};
|
};
|
||||||
computeOffRegion();
|
computeOffRegion();
|
||||||
}, [systems, regionName]);
|
}, [systems, regionName]);
|
||||||
@@ -509,7 +600,7 @@ export const RegionMap = ({ regionName, focusSystem, isCompact = false, isWormho
|
|||||||
</feMerge>
|
</feMerge>
|
||||||
</filter>
|
</filter>
|
||||||
<marker id="arrowhead" markerWidth="6" markerHeight="6" refX="5" refY="3" orient="auto" markerUnits="strokeWidth">
|
<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="#ffd166" />
|
<path d="M0,0 L0,6 L6,3 z" fill="#ffffff" />
|
||||||
</marker>
|
</marker>
|
||||||
</defs>
|
</defs>
|
||||||
|
|
||||||
@@ -553,18 +644,30 @@ export const RegionMap = ({ regionName, focusSystem, isCompact = false, isWormho
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Off-region link indicators (clickable arrows) */}
|
{/* Off-region indicators: labeled arrows pointing toward the destination region */}
|
||||||
{offRegionLinks.map((link, idx) => {
|
{offRegionIndicators.map((ind, idx) => {
|
||||||
const pos = positions[link.from];
|
const pos = positions[ind.from];
|
||||||
if (!pos) return null;
|
if (!pos) return null;
|
||||||
const offsetX = 10;
|
const len = 26;
|
||||||
const offsetY = -14;
|
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 (
|
return (
|
||||||
<g key={`off-${idx}`} transform={`translate(${pos.x + offsetX}, ${pos.y + offsetY})`}>
|
<g key={`offr-${idx}`}>
|
||||||
<rect x={-10} y={-12} width={20} height={14} rx={3} fill="#1f2937" stroke="#ffd166" strokeWidth={1} opacity={0.9} />
|
<line x1={x1} y1={y1} x2={x2} y2={y2} stroke={ind.color} strokeWidth={2} markerEnd="url(#arrowhead)">
|
||||||
<path d="M-6,-5 L6,-5" stroke="#ffd166" strokeWidth={2} markerEnd="url(#arrowhead)" />
|
<title>{label}</title>
|
||||||
<text x={0} y={6} textAnchor="middle" fontSize="7" fill="#ffd166">{link.toRegion}</text>
|
</line>
|
||||||
<rect x={-10} y={-12} width={20} height={20} fill="transparent" onClick={(e) => { e.stopPropagation(); navigate(`/regions/${encodeURIComponent(link.toRegion)}/${encodeURIComponent(link.to)}`); }} />
|
<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>
|
</g>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
Reference in New Issue
Block a user