Compare commits
3 Commits
a584bc9c55
...
fb3ebc10ff
Author | SHA1 | Date | |
---|---|---|---|
fb3ebc10ff | |||
2a098ec0d2 | |||
ee2a1fcde0 |
9
app.go
9
app.go
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
)
|
||||
@@ -126,3 +127,11 @@ func (a *App) ListCharacters() ([]CharacterInfo, error) {
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
// GetCharacterLocations exposes current locations for all characters
|
||||
func (a *App) GetCharacterLocations() ([]CharacterLocation, error) {
|
||||
if a.ssi == nil { return nil, errors.New("ESI not initialised") }
|
||||
ctx, cancel := context.WithTimeout(a.ctx, 6*time.Second)
|
||||
defer cancel()
|
||||
return a.ssi.GetCharacterLocations(ctx)
|
||||
}
|
||||
|
52
esi_sso.go
52
esi_sso.go
@@ -78,6 +78,21 @@ type CharacterInfo struct {
|
||||
CharacterName string `json:"character_name"`
|
||||
}
|
||||
|
||||
// CharacterLocation represents a character's current location
|
||||
type CharacterLocation struct {
|
||||
CharacterID int64 `json:"character_id"`
|
||||
CharacterName string `json:"character_name"`
|
||||
SolarSystemID int64 `json:"solar_system_id"`
|
||||
SolarSystemName string `json:"solar_system_name"`
|
||||
RetrievedAt time.Time `json:"retrieved_at"`
|
||||
}
|
||||
|
||||
type esiCharacterLocationResponse struct {
|
||||
SolarSystemID int64 `json:"solar_system_id"`
|
||||
StationID int64 `json:"station_id"`
|
||||
StructureID int64 `json:"structure_id"`
|
||||
}
|
||||
|
||||
func NewESISSO(clientID string, redirectURI string, scopes []string) *ESISSO {
|
||||
s := &ESISSO{
|
||||
clientID: clientID,
|
||||
@@ -105,6 +120,43 @@ func (s *ESISSO) initDB() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveSystemNameByID returns the system name for an ID from the local DB, or empty if not found
|
||||
func (s *ESISSO) resolveSystemNameByID(id int64) string {
|
||||
if s.db == nil || id == 0 { return "" }
|
||||
var ss SolarSystem
|
||||
if err := s.db.Select("solarSystemName").First(&ss, "solarSystemID = ?", id).Error; err != nil { return "" }
|
||||
return ss.SolarSystemName
|
||||
}
|
||||
|
||||
// GetCharacterLocations returns current locations for all stored characters
|
||||
func (s *ESISSO) GetCharacterLocations(ctx context.Context) ([]CharacterLocation, error) {
|
||||
if s.db == nil { return nil, errors.New("db not initialised") }
|
||||
var tokens []ESIToken
|
||||
if err := s.db.Find(&tokens).Error; err != nil { return nil, err }
|
||||
out := make([]CharacterLocation, 0, len(tokens))
|
||||
client := &http.Client{ Timeout: 5 * time.Second }
|
||||
for i := range tokens {
|
||||
t := &tokens[i]
|
||||
tok, err := s.ensureAccessTokenFor(ctx, t)
|
||||
if err != nil { continue }
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, esiBase+"/v2/characters/"+strconv.FormatInt(t.CharacterID,10)+"/location", nil)
|
||||
if err != nil { continue }
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+tok)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil { continue }
|
||||
func() {
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK { return }
|
||||
var lr esiCharacterLocationResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&lr); err != nil { return }
|
||||
name := s.resolveSystemNameByID(lr.SolarSystemID)
|
||||
out = append(out, CharacterLocation{ CharacterID: t.CharacterID, CharacterName: t.CharacterName, SolarSystemID: lr.SolarSystemID, SolarSystemName: name, RetrievedAt: time.Now() })
|
||||
}()
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *ESISSO) saveToken() {
|
||||
if s.db == nil || s.characterID == 0 {
|
||||
return
|
||||
|
@@ -8,8 +8,9 @@ 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, GetCharacterLocations } from 'wailsjs/go/main/App';
|
||||
import { toast } from '@/hooks/use-toast';
|
||||
import { getSystemsRegions } from '@/utils/systemApi';
|
||||
|
||||
interface RegionMapProps {
|
||||
regionName: string;
|
||||
@@ -56,6 +57,26 @@ function computeNodeConnections(systems: Map<string, System>): Map<string, Conne
|
||||
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 });
|
||||
@@ -73,6 +94,11 @@ export const RegionMap = ({ regionName, focusSystem, isCompact = false, isWormho
|
||||
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 [meanInboundAngle, setMeanInboundAngle] = useState<Record<string, number>>({});
|
||||
const [charLocs, setCharLocs] = useState<Array<{ character_id: number; character_name: string; solar_system_name: string }>>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = async (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && viaMode) {
|
||||
@@ -107,8 +133,130 @@ 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<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]);
|
||||
|
||||
// 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
|
||||
const grouped: Map<string, OffIndicator> = 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;
|
||||
|
||||
// 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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setOffRegionIndicators(Array.from(grouped.values()));
|
||||
};
|
||||
computeOffRegion();
|
||||
}, [systems, regionName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isWormholeRegion) {
|
||||
loadWormholeSystems().then(wormholeSystems => {
|
||||
@@ -470,6 +618,9 @@ export const RegionMap = ({ regionName, focusSystem, isCompact = false, isWormho
|
||||
<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 */}
|
||||
@@ -512,6 +663,47 @@ export const RegionMap = ({ regionName, focusSystem, isCompact = false, isWormho
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 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})`}>
|
||||
<circle r={4} fill="#00d1ff" stroke="#ffffff" strokeWidth={1} />
|
||||
<text x={6} y={3} 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 && positions[focusSystem] && (
|
||||
<circle
|
||||
|
@@ -4,3 +4,40 @@ export const getSystemId = async (systemName: string): Promise<string> => {
|
||||
const system = await pb.collection('regionview').getFirstListItem(`sysname='${systemName}'`);
|
||||
return system.id;
|
||||
};
|
||||
|
||||
const regionCache: Map<string, string> = new Map();
|
||||
|
||||
export const getSystemRegion = async (systemName: string): Promise<string> => {
|
||||
const key = systemName;
|
||||
const cached = regionCache.get(key);
|
||||
if (cached) return cached;
|
||||
const rec = await pb.collection('regionview').getFirstListItem(`sysname='${systemName}'`);
|
||||
regionCache.set(key, rec.sysregion);
|
||||
return rec.sysregion as string;
|
||||
};
|
||||
|
||||
export const getSystemsRegions = async (systemNames: string[]): Promise<Record<string, string>> => {
|
||||
const result: Record<string, string> = {};
|
||||
const pending: string[] = [];
|
||||
for (const name of systemNames) {
|
||||
const cached = regionCache.get(name);
|
||||
if (cached) {
|
||||
result[name] = cached;
|
||||
} else {
|
||||
pending.push(name);
|
||||
}
|
||||
}
|
||||
if (pending.length === 0) return result;
|
||||
// Fetch uncached in parallel
|
||||
const fetched = await Promise.all(
|
||||
pending.map(async (name) => {
|
||||
const rec = await pb.collection('regionview').getFirstListItem(`sysname='${name}'`);
|
||||
regionCache.set(name, rec.sysregion);
|
||||
return { name, region: rec.sysregion as string };
|
||||
})
|
||||
);
|
||||
for (const { name, region } of fetched) {
|
||||
result[name] = region;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
2
frontend/wailsjs/go/main/App.d.ts
vendored
2
frontend/wailsjs/go/main/App.d.ts
vendored
@@ -8,6 +8,8 @@ export function ESILoggedIn():Promise<boolean>;
|
||||
|
||||
export function ESILoginStatus():Promise<string>;
|
||||
|
||||
export function GetCharacterLocations():Promise<Array<main.CharacterLocation>>;
|
||||
|
||||
export function Greet(arg1:string):Promise<string>;
|
||||
|
||||
export function ListCharacters():Promise<Array<main.CharacterInfo>>;
|
||||
|
@@ -14,6 +14,10 @@ export function ESILoginStatus() {
|
||||
return window['go']['main']['App']['ESILoginStatus']();
|
||||
}
|
||||
|
||||
export function GetCharacterLocations() {
|
||||
return window['go']['main']['App']['GetCharacterLocations']();
|
||||
}
|
||||
|
||||
export function Greet(arg1) {
|
||||
return window['go']['main']['App']['Greet'](arg1);
|
||||
}
|
||||
|
@@ -14,6 +14,45 @@ export namespace main {
|
||||
this.character_name = source["character_name"];
|
||||
}
|
||||
}
|
||||
export class CharacterLocation {
|
||||
character_id: number;
|
||||
character_name: string;
|
||||
solar_system_id: number;
|
||||
solar_system_name: string;
|
||||
// Go type: time
|
||||
retrieved_at: any;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new CharacterLocation(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.character_id = source["character_id"];
|
||||
this.character_name = source["character_name"];
|
||||
this.solar_system_id = source["solar_system_id"];
|
||||
this.solar_system_name = source["solar_system_name"];
|
||||
this.retrieved_at = this.convertValues(source["retrieved_at"], null);
|
||||
}
|
||||
|
||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||
if (!a) {
|
||||
return a;
|
||||
}
|
||||
if (a.slice && a.map) {
|
||||
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||
} else if ("object" === typeof a) {
|
||||
if (asMap) {
|
||||
for (const key of Object.keys(a)) {
|
||||
a[key] = new classs(a[key]);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
return new classs(a);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
BIN
signalerr.exe
Normal file
BIN
signalerr.exe
Normal file
Binary file not shown.
Reference in New Issue
Block a user