feat(app): add character location tracking and display

This commit is contained in:
2025-08-09 20:54:05 +02:00
parent 2a098ec0d2
commit fb3ebc10ff
7 changed files with 139 additions and 1 deletions

9
app.go
View File

@@ -5,6 +5,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"os" "os"
"time"
"github.com/wailsapp/wails/v2/pkg/runtime" "github.com/wailsapp/wails/v2/pkg/runtime"
) )
@@ -126,3 +127,11 @@ func (a *App) ListCharacters() ([]CharacterInfo, error) {
} }
return list, nil 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)
}

View File

@@ -78,6 +78,21 @@ type CharacterInfo struct {
CharacterName string `json:"character_name"` 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 { func NewESISSO(clientID string, redirectURI string, scopes []string) *ESISSO {
s := &ESISSO{ s := &ESISSO{
clientID: clientID, clientID: clientID,
@@ -105,6 +120,43 @@ func (s *ESISSO) initDB() error {
return nil 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() { func (s *ESISSO) saveToken() {
if s.db == nil || s.characterID == 0 { if s.db == nil || s.characterID == 0 {
return return

View File

@@ -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, PostRouteForAllByNames } from 'wailsjs/go/main/App'; import { ListCharacters, StartESILogin, SetDestinationForAll, PostRouteForAllByNames, GetCharacterLocations } 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';
@@ -97,6 +97,7 @@ export const RegionMap = ({ regionName, focusSystem, isCompact = false, isWormho
type OffIndicator = { from: string; toRegion: string; count: number; color: string; angle: number; sampleTo?: string }; type OffIndicator = { from: string; toRegion: string; count: number; color: string; angle: number; sampleTo?: string };
const [offRegionIndicators, setOffRegionIndicators] = useState<OffIndicator[]>([]); const [offRegionIndicators, setOffRegionIndicators] = useState<OffIndicator[]>([]);
const [meanInboundAngle, setMeanInboundAngle] = useState<Record<string, number>>({}); const [meanInboundAngle, setMeanInboundAngle] = useState<Record<string, number>>({});
const [charLocs, setCharLocs] = useState<Array<{ character_id: number; character_name: string; solar_system_name: string }>>([]);
useEffect(() => { useEffect(() => {
const onKeyDown = async (e: KeyboardEvent) => { const onKeyDown = async (e: KeyboardEvent) => {
@@ -154,6 +155,24 @@ export const RegionMap = ({ regionName, focusSystem, isCompact = false, isWormho
setMeanInboundAngle(angleMap); setMeanInboundAngle(angleMap);
}, [systems]); }, [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 // Compute off-region indicators: dedupe per (from, toRegion), compute avg color, and angle via universe centroids
useEffect(() => { useEffect(() => {
const computeOffRegion = async () => { const computeOffRegion = async () => {
@@ -644,6 +663,19 @@ 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 */} {/* Off-region indicators: labeled arrows pointing toward the destination region */}
{offRegionIndicators.map((ind, idx) => { {offRegionIndicators.map((ind, idx) => {
const pos = positions[ind.from]; const pos = positions[ind.from];

View File

@@ -8,6 +8,8 @@ export function ESILoggedIn():Promise<boolean>;
export function ESILoginStatus():Promise<string>; export function ESILoginStatus():Promise<string>;
export function GetCharacterLocations():Promise<Array<main.CharacterLocation>>;
export function Greet(arg1:string):Promise<string>; export function Greet(arg1:string):Promise<string>;
export function ListCharacters():Promise<Array<main.CharacterInfo>>; export function ListCharacters():Promise<Array<main.CharacterInfo>>;

View File

@@ -14,6 +14,10 @@ export function ESILoginStatus() {
return window['go']['main']['App']['ESILoginStatus'](); return window['go']['main']['App']['ESILoginStatus']();
} }
export function GetCharacterLocations() {
return window['go']['main']['App']['GetCharacterLocations']();
}
export function Greet(arg1) { export function Greet(arg1) {
return window['go']['main']['App']['Greet'](arg1); return window['go']['main']['App']['Greet'](arg1);
} }

View File

@@ -14,6 +14,45 @@ export namespace main {
this.character_name = source["character_name"]; 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

Binary file not shown.