36 Commits

Author SHA1 Message Date
2c0134ed4d Add labelScale prop for dynamic text scaling in MapNode and adjust RegionMap to utilize it based on compact view 2025-10-05 16:47:45 +02:00
634c72be2d Add header prop to control visibility of the header component 2025-10-05 16:39:24 +02:00
7efa724631 Save on focusout not fucking keyup 2025-10-05 15:21:45 +02:00
22a7d1ad45 Implement note rules
So we may specify regex rules to map to notes
2025-10-05 15:12:17 +02:00
f6450fdafb refactor(RegionMap, StatisticsToggle): reposition StatisticsToggle to avoid overlaps and simplify layout 2025-09-15 17:40:23 +02:00
7f4ca796aa refactor(MapNode, StatisticsToggle): streamline text rendering in MapNode and replace switches with buttons in StatisticsToggle for improved UI 2025-09-15 17:39:07 +02:00
0c5d0616e5 feat(RegionMap): implement system ID caching and enhance jump/kill data retrieval 2025-09-15 17:30:58 +02:00
6f3a5dce64 feat(MapNode): add viewBoxWidth prop for scaling and adjust text rendering for fixed visual size 2025-09-15 00:09:52 +02:00
8575155f4b feat(SystemStatistics): add system jumps and kills statistics with toggle functionality 2025-09-14 22:42:14 +02:00
41f7d3157f feat(ESISSO): refactor callback handling into a dedicated function and improve request logging 2025-09-09 11:03:54 +02:00
e72bab7086 feat(ESISSO): enhance callback server with detailed logging and improved response handling 2025-09-09 11:01:21 +02:00
3ca3bf8810 Update ESI SSO client ID and expand permission scopes for enhanced functionality 2025-09-09 11:00:23 +02:00
81713d09fd Enable toggling waypoint sending characters 2025-08-28 15:40:34 +02:00
2d6af8bfa9 feat(SearchDialog): add shift-click functionality to set system destination 2025-08-11 22:36:07 +02:00
dad6d79740 feat(RegionMap): introduce interaction state machine for improved event handling 2025-08-11 19:45:30 +02:00
3b20e07b17 fix(RegionMap.tsx): restrict map interactions to left mouse button for consistency 2025-08-11 19:32:20 +02:00
3a4e30d372 feat(RegionMap): implement shift-drag circle selection for VIA mode 2025-08-11 19:29:43 +02:00
b0ad48985a refactor(RegionMap.tsx): extract map interaction constants to improve code readability 2025-08-11 19:26:35 +02:00
c55b3bd882 fix(RegionMap.tsx): update cursor styles based on interaction state for improved UX 2025-08-11 19:23:40 +02:00
c5f7fd483e fix(RegionMap.tsx): add mouseup event listener to reset panning and selection states 2025-08-11 19:22:15 +02:00
c21f82667a feat(RegionMap): implement left-click aimbot and VIA waypoint toggling 2025-08-11 19:20:37 +02:00
f7879c7ea8 feat(RegionMap): add context menu for background clicks to show nearest system 2025-08-10 22:38:40 +02:00
22ef386ea2 fix(RegionMap.tsx): update onSetDestination prop to accept 'via' parameter for improved functionality 2025-08-10 22:34:00 +02:00
51179485a1 fix(RegionMap): enhance system focus visual feedback with improved styling and animation 2025-08-10 22:31:52 +02:00
11fda4e11f feat(search): implement Aho-Corasick for efficient substring searching and improve result ordering 2025-08-10 22:29:15 +02:00
7af7d9ecd0 refactor(SearchDialog.tsx): simplify system loading by directly calling ListSystemsWithRegions 2025-08-10 22:26:54 +02:00
97178bc9a5 feat(go, ts): add system-region mapping and highlight functionality 2025-08-10 22:26:02 +02:00
2561cd7d30 feat(frontend): load systems from PocketBase and add system focus to region page 2025-08-10 22:18:17 +02:00
9c40135102 feat(frontend): implement system search dialog with Aho-Corasick algorithm 2025-08-10 22:06:55 +02:00
90b190b8d5 Code format 2025-08-09 21:34:02 +02:00
c10f4b43cb refactor(RegionMap.tsx): rename meanInboundAngle to meanNeighborAngle and update logic to compute mean angle to neighbors 2025-08-09 21:33:13 +02:00
e2f804bac7 fix(RegionMap.tsx): flip connection indicator angle by 180 degrees to point away from existing connections 2025-08-09 21:23:09 +02:00
91cbb6c841 feat(app): add location read scope and update map angle calculations 2025-08-09 21:17:23 +02:00
fb3ebc10ff feat(app): add character location tracking and display 2025-08-09 20:54:05 +02:00
2a098ec0d2 refactor(RegionMap.tsx): improve off-region link indicators with better caching and visualization 2025-08-09 20:44:49 +02:00
ee2a1fcde0 Update 2025-08-09 20:22:50 +02:00
42 changed files with 2125 additions and 295 deletions

120
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"
) )
@@ -27,14 +28,54 @@ func (a *App) startup(ctx context.Context) {
clientID := os.Getenv("EVE_SSO_CLIENT_ID") clientID := os.Getenv("EVE_SSO_CLIENT_ID")
if clientID == "" { if clientID == "" {
clientID = "5091f74037374697938384bdbac2698c" clientID = "77c5adb91e46459b874204ceeedb459f"
} }
redirectURI := os.Getenv("EVE_SSO_REDIRECT_URI") redirectURI := os.Getenv("EVE_SSO_REDIRECT_URI")
if redirectURI == "" { if redirectURI == "" {
redirectURI = "http://localhost:8080/callback" redirectURI = "http://localhost:8080/callback"
} }
a.ssi = NewESISSO(clientID, redirectURI, []string{"esi-ui.write_waypoint.v1"}) // Add location read scope so we can fetch character locations
a.ssi = NewESISSO(clientID, redirectURI, []string{
"esi-location.read_location.v1",
"esi-location.read_ship_type.v1",
"esi-mail.organize_mail.v1",
"esi-mail.read_mail.v1",
"esi-mail.send_mail.v1",
"esi-skills.read_skills.v1",
"esi-skills.read_skillqueue.v1",
"esi-wallet.read_character_wallet.v1",
"esi-wallet.read_corporation_wallet.v1",
"esi-characters.read_contacts.v1",
"esi-killmails.read_killmails.v1",
"esi-assets.read_assets.v1",
"esi-planets.manage_planets.v1",
"esi-ui.write_waypoint.v1",
"esi-characters.write_contacts.v1",
"esi-markets.structure_markets.v1",
"esi-characters.read_loyalty.v1",
"esi-characters.read_chat_channels.v1",
"esi-characters.read_medals.v1",
"esi-characters.read_standings.v1",
"esi-characters.read_agents_research.v1",
"esi-industry.read_character_jobs.v1",
"esi-markets.read_character_orders.v1",
"esi-characters.read_blueprints.v1",
"esi-characters.read_corporation_roles.v1",
"esi-location.read_online.v1",
"esi-characters.read_fatigue.v1",
"esi-killmails.read_corporation_killmails.v1",
"esi-wallet.read_corporation_wallets.v1",
"esi-characters.read_notifications.v1",
"esi-assets.read_corporation_assets.v1",
"esi-industry.read_corporation_jobs.v1",
"esi-markets.read_corporation_orders.v1",
"esi-industry.read_character_mining.v1",
"esi-industry.read_corporation_mining.v1",
"esi-planets.read_customs_offices.v1",
"esi-characters.read_titles.v1",
"esi-characters.read_fw_stats.v1",
})
} }
// Greet returns a greeting for the given name // Greet returns a greeting for the given name
@@ -122,7 +163,80 @@ func (a *App) ListCharacters() ([]CharacterInfo, error) {
} }
list := make([]CharacterInfo, 0, len(tokens)) list := make([]CharacterInfo, 0, len(tokens))
for _, t := range tokens { for _, t := range tokens {
list = append(list, CharacterInfo{CharacterID: t.CharacterID, CharacterName: t.CharacterName}) list = append(list, CharacterInfo{CharacterID: t.CharacterID, CharacterName: t.CharacterName, WaypointEnabled: t.WaypointEnabled})
} }
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)
}
// ToggleCharacterWaypointEnabled toggles waypoint enabled status for a character
func (a *App) ToggleCharacterWaypointEnabled(characterID int64) error {
if a.ssi == nil {
return errors.New("ESI not initialised")
}
return a.ssi.ToggleCharacterWaypointEnabled(characterID)
}
// GetSystemJumps fetches system jump statistics from ESI
func (a *App) GetSystemJumps() ([]SystemJumps, error) {
fmt.Printf("🔍 App.GetSystemJumps() called - this should ONLY happen when toggle is ON!\n")
if a.ssi == nil {
return nil, errors.New("ESI not initialised")
}
ctx, cancel := context.WithTimeout(a.ctx, 15*time.Second)
defer cancel()
return a.ssi.GetSystemJumps(ctx)
}
// GetSystemKills fetches system kill statistics from ESI
func (a *App) GetSystemKills() ([]SystemKills, error) {
fmt.Printf("🔍 App.GetSystemKills() called - this should ONLY happen when toggle is ON!\n")
if a.ssi == nil {
return nil, errors.New("ESI not initialised")
}
ctx, cancel := context.WithTimeout(a.ctx, 15*time.Second)
defer cancel()
return a.ssi.GetSystemKills(ctx)
}
// ResolveSystemIDByName resolves a system name to its ID
func (a *App) ResolveSystemIDByName(systemName string) (int64, error) {
fmt.Printf("🔍 App.ResolveSystemIDByName() called for system: %s\n", systemName)
if a.ssi == nil {
return 0, errors.New("ESI not initialised")
}
ctx, cancel := context.WithTimeout(a.ctx, 5*time.Second)
defer cancel()
return a.ssi.ResolveSystemIDByName(ctx, systemName)
}
// SystemRegion holds system + region names from local DB
type SystemRegion struct {
System string `json:"system"`
Region string `json:"region"`
}
// ListSystemsWithRegions returns all solar system names and their regions from the local SQLite DB
func (a *App) ListSystemsWithRegions() ([]SystemRegion, error) {
if a.ssi == nil || a.ssi.db == nil {
return nil, errors.New("db not initialised")
}
var rows []SystemRegion
// mapSolarSystems has regionID; mapRegions has regionName
q := `SELECT s.solarSystemName AS system, r.regionName AS region
FROM mapSolarSystems s
JOIN mapRegions r ON r.regionID = s.regionID`
if err := a.ssi.db.Raw(q).Scan(&rows).Error; err != nil {
return nil, err
}
return rows, nil
}

View File

@@ -63,19 +63,49 @@ type SolarSystem struct {
func (SolarSystem) TableName() string { return "mapSolarSystems" } func (SolarSystem) TableName() string { return "mapSolarSystems" }
type ESIToken struct { type ESIToken struct {
ID uint `gorm:"primaryKey"` ID uint `gorm:"primaryKey"`
CharacterID int64 `gorm:"index"` CharacterID int64 `gorm:"index"`
CharacterName string CharacterName string
AccessToken string AccessToken string
RefreshToken string RefreshToken string
ExpiresAt time.Time ExpiresAt time.Time
UpdatedAt time.Time WaypointEnabled bool `gorm:"default:true"`
CreatedAt time.Time UpdatedAt time.Time
CreatedAt time.Time
} }
type CharacterInfo struct { type CharacterInfo struct {
CharacterID int64 `json:"character_id"` CharacterID int64 `json:"character_id"`
CharacterName string `json:"character_name"` CharacterName string `json:"character_name"`
WaypointEnabled bool `json:"waypoint_enabled"`
}
// 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"`
}
// ESI Statistics data structures
type SystemJumps struct {
SystemID int64 `json:"system_id"`
ShipJumps int64 `json:"ship_jumps"`
}
type SystemKills struct {
SystemID int64 `json:"system_id"`
ShipKills int64 `json:"ship_kills"`
PodKills int64 `json:"pod_kills"`
NpcKills int64 `json:"npc_kills"`
} }
func NewESISSO(clientID string, redirectURI string, scopes []string) *ESISSO { func NewESISSO(clientID string, redirectURI string, scopes []string) *ESISSO {
@@ -105,6 +135,127 @@ 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
}
// GetSystemJumps fetches system jump statistics from ESI
func (s *ESISSO) GetSystemJumps(ctx context.Context) ([]SystemJumps, error) {
fmt.Printf("🚀 ESI API REQUEST: Fetching system jumps data from https://esi.evetech.net/v2/universe/system_jumps\n")
client := &http.Client{Timeout: 10 * time.Second}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, esiBase+"/v2/universe/system_jumps", nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")
req.Header.Set("X-Compatibility-Date", "2025-08-26")
req.Header.Set("X-Tenant", "tranquility")
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("ESI API returned status %d", resp.StatusCode)
}
var jumps []SystemJumps
if err := json.NewDecoder(resp.Body).Decode(&jumps); err != nil {
return nil, err
}
fmt.Printf("✅ ESI API SUCCESS: Fetched %d system jumps entries\n", len(jumps))
return jumps, nil
}
// GetSystemKills fetches system kill statistics from ESI
func (s *ESISSO) GetSystemKills(ctx context.Context) ([]SystemKills, error) {
fmt.Printf("⚔️ ESI API REQUEST: Fetching system kills data from https://esi.evetech.net/v2/universe/system_kills\n")
client := &http.Client{Timeout: 10 * time.Second}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, esiBase+"/v2/universe/system_kills", nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")
req.Header.Set("X-Compatibility-Date", "2025-08-26")
req.Header.Set("X-Tenant", "tranquility")
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("ESI API returned status %d", resp.StatusCode)
}
var kills []SystemKills
if err := json.NewDecoder(resp.Body).Decode(&kills); err != nil {
return nil, err
}
fmt.Printf("✅ ESI API SUCCESS: Fetched %d system kills entries\n", len(kills))
return kills, 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
@@ -157,6 +308,37 @@ func (s *ESISSO) BuildAuthorizeURL() (string, error) {
return issuerAuthorizeURL + "?" + q.Encode(), nil return issuerAuthorizeURL + "?" + q.Encode(), nil
} }
func (s *ESISSO) handleCallback(w http.ResponseWriter, r *http.Request) {
fmt.Printf("Callback received: %s %s\n", r.Method, r.URL.String())
if r.Method != http.MethodGet {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
q := r.URL.Query()
code := q.Get("code")
st := q.Get("state")
if code == "" || st == "" || st != s.state {
fmt.Printf("Invalid SSO response: code=%s, state=%s, expected_state=%s\n", code, st, s.state)
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte("Invalid SSO response"))
return
}
fmt.Printf("Exchanging token for code: %s\n", code)
if err := s.exchangeToken(r.Context(), code); err != nil {
fmt.Printf("Token exchange failed: %v\n", err)
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte("Token exchange failed: " + err.Error()))
return
}
fmt.Printf("Login successful for character: %s (%d)\n", s.characterName, s.characterID)
w.Header().Set("Content-Type", "text/html")
_, _ = io.WriteString(w, "<html><body><h1>Login successful!</h1><p>You can close this window.</p></body></html>")
go func() {
time.Sleep(1 * time.Second)
_ = s.server.Shutdown(context.Background())
}()
}
func (s *ESISSO) StartCallbackServerAsync() error { func (s *ESISSO) StartCallbackServerAsync() error {
u, err := url.Parse(s.redirectURI) u, err := url.Parse(s.redirectURI)
if err != nil { if err != nil {
@@ -175,29 +357,22 @@ func (s *ESISSO) StartCallbackServerAsync() error {
} }
mux := http.NewServeMux() mux := http.NewServeMux()
// Add a catch-all handler to debug what's being requested
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Printf("DEBUG: Request received - Method: %s, URL: %s, Path: %s\n", r.Method, r.URL.String(), r.URL.Path)
if r.URL.Path == u.Path {
// This is our callback, handle it
s.handleCallback(w, r)
} else {
fmt.Printf("DEBUG: 404 - Path %s does not match expected %s\n", r.URL.Path, u.Path)
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte("Not found"))
}
})
mux.HandleFunc(u.Path, func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc(u.Path, func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet { s.handleCallback(w, r)
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
q := r.URL.Query()
code := q.Get("code")
st := q.Get("state")
if code == "" || st == "" || st != s.state {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte("Invalid SSO response"))
return
}
if err := s.exchangeToken(r.Context(), code); err != nil {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte("Token exchange failed: " + err.Error()))
return
}
_, _ = io.WriteString(w, "Login successful. You can close this window.")
go func() {
time.Sleep(200 * time.Millisecond)
_ = s.server.Shutdown(context.Background())
}()
}) })
ln, err := net.Listen("tcp", hostPort) ln, err := net.Listen("tcp", hostPort)
@@ -205,6 +380,7 @@ func (s *ESISSO) StartCallbackServerAsync() error {
return err return err
} }
fmt.Printf("Callback server listening on %s%s\n", hostPort, u.Path)
s.server = &http.Server{Handler: mux} s.server = &http.Server{Handler: mux}
go func() { _ = s.server.Serve(ln) }() go func() { _ = s.server.Serve(ln) }()
return nil return nil
@@ -442,7 +618,6 @@ func (s *ESISSO) postWaypointWithToken(tok string, destinationID int64, clearOth
q.Set("datasource", "tranquility") q.Set("datasource", "tranquility")
endpoint := esiBase + "/v2/ui/autopilot/waypoint?" + q.Encode() endpoint := esiBase + "/v2/ui/autopilot/waypoint?" + q.Encode()
fmt.Printf("ESI: POST waypoint dest=%d clear=%v addToBeginning=%v\n", destinationID, clearOthers, addToBeginning)
req, err := http.NewRequest(http.MethodPost, endpoint, nil) req, err := http.NewRequest(http.MethodPost, endpoint, nil)
if err != nil { if err != nil {
return err return err
@@ -457,11 +632,9 @@ func (s *ESISSO) postWaypointWithToken(tok string, destinationID int64, clearOth
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusOK { if resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusOK {
fmt.Println("ESI: waypoint set OK", resp.Status)
return nil return nil
} }
b, _ := io.ReadAll(resp.Body) b, _ := io.ReadAll(resp.Body)
fmt.Printf("ESI: waypoint failed %s body=%s\n", resp.Status, string(b))
return fmt.Errorf("waypoint failed: %s: %s", resp.Status, string(b)) return fmt.Errorf("waypoint failed: %s: %s", resp.Status, string(b))
} }
@@ -475,6 +648,9 @@ func (s *ESISSO) PostWaypointForAll(destinationID int64, clearOthers bool, addTo
} }
var firstErr error var firstErr error
for i := range tokens { for i := range tokens {
if !tokens[i].WaypointEnabled {
continue
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
tok, err := s.ensureAccessTokenFor(ctx, &tokens[i]) tok, err := s.ensureAccessTokenFor(ctx, &tokens[i])
cancel() cancel()
@@ -491,6 +667,19 @@ func (s *ESISSO) PostWaypointForAll(destinationID int64, clearOthers bool, addTo
return firstErr return firstErr
} }
// ToggleCharacterWaypointEnabled toggles the waypoint enabled status for a character
func (s *ESISSO) ToggleCharacterWaypointEnabled(characterID int64) error {
if s.db == nil {
return errors.New("db not initialised")
}
var token ESIToken
if err := s.db.Where("character_id = ?", characterID).First(&token).Error; err != nil {
return err
}
token.WaypointEnabled = !token.WaypointEnabled
return s.db.Save(&token).Error
}
func (s *ESISSO) Status() SSOStatus { func (s *ESISSO) Status() SSOStatus {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
@@ -615,16 +804,24 @@ func (s *ESISSO) ResolveSystemIDsByNames(ctx context.Context, names []string) ([
// PostRouteForAll clears route and posts vias then destination last // PostRouteForAll clears route and posts vias then destination last
func (s *ESISSO) PostRouteForAll(destID int64, viaIDs []int64) error { func (s *ESISSO) PostRouteForAll(destID int64, viaIDs []int64) error {
if s.db == nil { return errors.New("db not initialised") } if s.db == nil {
return errors.New("db not initialised")
}
var tokens []ESIToken var tokens []ESIToken
if err := s.db.Find(&tokens).Error; err != nil { return err } if err := s.db.Find(&tokens).Error; err != nil {
// Deduplicate by CharacterID return err
}
// Deduplicate by CharacterID and filter enabled characters
uniq := make(map[int64]ESIToken, len(tokens)) uniq := make(map[int64]ESIToken, len(tokens))
for _, t := range tokens { for _, t := range tokens {
uniq[t.CharacterID] = t if t.WaypointEnabled {
uniq[t.CharacterID] = t
}
} }
uniqueTokens := make([]ESIToken, 0, len(uniq)) uniqueTokens := make([]ESIToken, 0, len(uniq))
for _, t := range uniq { uniqueTokens = append(uniqueTokens, t) } for _, t := range uniq {
uniqueTokens = append(uniqueTokens, t)
}
var mu sync.Mutex var mu sync.Mutex
var firstErr error var firstErr error
@@ -638,20 +835,53 @@ func (s *ESISSO) PostRouteForAll(destID int64, viaIDs []int64) error {
tok, err := s.ensureAccessTokenFor(ctx, &t) tok, err := s.ensureAccessTokenFor(ctx, &t)
cancel() cancel()
if err != nil { if err != nil {
mu.Lock(); if firstErr == nil { firstErr = err }; mu.Unlock(); return mu.Lock()
if firstErr == nil {
firstErr = err
}
mu.Unlock()
return
} }
// Post sequence for this character // Post sequence for this character
if len(viaIDs) > 0 { if len(viaIDs) > 0 {
if err := s.postWaypointWithToken(tok, viaIDs[0], true, false); err != nil { mu.Lock(); if firstErr == nil { firstErr = err }; mu.Unlock(); return } if err := s.postWaypointWithToken(tok, viaIDs[0], true, false); err != nil {
for _, id := range viaIDs[1:] { mu.Lock()
if err := s.postWaypointWithToken(tok, id, false, false); err != nil { mu.Lock(); if firstErr == nil { firstErr = err }; mu.Unlock(); return } if firstErr == nil {
firstErr = err
}
mu.Unlock()
return
}
for _, id := range viaIDs[1:] {
if err := s.postWaypointWithToken(tok, id, false, false); err != nil {
mu.Lock()
if firstErr == nil {
firstErr = err
}
mu.Unlock()
return
}
}
if err := s.postWaypointWithToken(tok, destID, false, false); err != nil {
mu.Lock()
if firstErr == nil {
firstErr = err
}
mu.Unlock()
return
} }
if err := s.postWaypointWithToken(tok, destID, false, false); err != nil { mu.Lock(); if firstErr == nil { firstErr = err }; mu.Unlock(); return }
} else { } else {
if err := s.postWaypointWithToken(tok, destID, true, false); err != nil { mu.Lock(); if firstErr == nil { firstErr = err }; mu.Unlock(); return } if err := s.postWaypointWithToken(tok, destID, true, false); err != nil {
mu.Lock()
if firstErr == nil {
firstErr = err
}
mu.Unlock()
return
}
} }
}(uniqueTokens[i]) }(uniqueTokens[i])
} }
wg.Wait() wg.Wait()
return firstErr return firstErr
} }

View File

@@ -6,6 +6,8 @@ import { RegionPage } from "./pages/RegionPage";
import { SystemView } from "./pages/SystemView"; import { SystemView } from "./pages/SystemView";
import NotFound from "./pages/NotFound"; import NotFound from "./pages/NotFound";
import "./App.css"; import "./App.css";
import { SearchDialog } from "@/components/SearchDialog";
import { SignatureRules } from "./pages/SignatureRules";
const queryClient = new QueryClient(); const queryClient = new QueryClient();
@@ -18,9 +20,11 @@ function App() {
<Route path="/regions/:region" element={<RegionPage />} /> <Route path="/regions/:region" element={<RegionPage />} />
<Route path="/regions/:region/:system" element={<SystemView />} /> <Route path="/regions/:region/:system" element={<SystemView />} />
<Route path="/systems/:system" element={<SystemView />} /> <Route path="/systems/:system" element={<SystemView />} />
<Route path="/settings/signature-rules" element={<SignatureRules />} />
<Route path="*" element={<NotFound />} /> <Route path="*" element={<NotFound />} />
</Routes> </Routes>
<Toaster /> <Toaster />
<SearchDialog />
</Router> </Router>
</QueryClientProvider> </QueryClientProvider>
); );

View File

@@ -2,16 +2,17 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { import {
Breadcrumb, Breadcrumb,
BreadcrumbItem, BreadcrumbItem,
BreadcrumbLink, BreadcrumbLink,
BreadcrumbList, BreadcrumbList,
BreadcrumbPage, BreadcrumbPage,
BreadcrumbSeparator, BreadcrumbSeparator,
} from '@/components/ui/breadcrumb'; } from '@/components/ui/breadcrumb';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { toast } from '@/hooks/use-toast'; import { toast } from '@/hooks/use-toast';
import { StartESILogin, ESILoggedIn, ListCharacters } from 'wailsjs/go/main/App'; import { StartESILogin, ESILoggedIn, ListCharacters, ToggleCharacterWaypointEnabled } from 'wailsjs/go/main/App';
import { main } from 'wailsjs/go/models';
interface HeaderProps { interface HeaderProps {
title: string; title: string;
@@ -21,17 +22,15 @@ interface HeaderProps {
}>; }>;
} }
interface CharacterInfo { character_id: number; character_name: string }
export const Header = ({ title, breadcrumbs = [] }: HeaderProps) => { export const Header = ({ title, breadcrumbs = [] }: HeaderProps) => {
const navigate = useNavigate(); const navigate = useNavigate();
const [chars, setChars] = useState<CharacterInfo[]>([]); const [chars, setChars] = useState<main.CharacterInfo[]>([]);
const refreshState = async () => { const refreshState = async () => {
try { try {
const list = await ListCharacters(); const list = await ListCharacters();
setChars((list as any[]).map((c: any) => ({ character_id: c.character_id, character_name: c.character_name }))); setChars(list);
} catch {} } catch { }
}; };
useEffect(() => { useEffect(() => {
@@ -55,6 +54,17 @@ export const Header = ({ title, breadcrumbs = [] }: HeaderProps) => {
} }
}; };
const handleCharacterClick = async (character: main.CharacterInfo) => {
try {
await ToggleCharacterWaypointEnabled(character.character_id);
await refreshState();
const newStatus = character.waypoint_enabled ? 'disabled' : 'enabled';
toast({ title: 'Waypoint Status', description: `${character.character_name} waypoints ${newStatus}` });
} catch (e: any) {
toast({ title: 'Toggle failed', description: String(e), variant: 'destructive' });
}
};
return ( return (
<div className="flex-shrink-0 py-4 px-4 border-b border-purple-500/20"> <div className="flex-shrink-0 py-4 px-4 border-b border-purple-500/20">
{breadcrumbs.length > 0 && ( {breadcrumbs.length > 0 && (
@@ -84,14 +94,29 @@ export const Header = ({ title, breadcrumbs = [] }: HeaderProps) => {
</Breadcrumb> </Breadcrumb>
</div> </div>
)} )}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-white">{title}</h1> <h1 className="text-2xl font-bold text-white">{title}</h1>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Button size="sm" variant="outline" className="border-purple-500/40 text-purple-200" onClick={() => navigate('/settings/signature-rules')}>Rules</Button>
{chars.length > 0 && ( {chars.length > 0 && (
<div className="flex flex-wrap gap-2 max-w-[50vw] justify-end"> <div
className="grid gap-1 flex-1 justify-end"
style={{
gridTemplateColumns: `repeat(${Math.ceil(chars.length / 2)}, 1fr)`,
gridTemplateRows: 'repeat(2, auto)'
}}
>
{chars.map((c) => ( {chars.map((c) => (
<span key={c.character_id} className="px-2 py-1 rounded-full bg-purple-500/20 text-purple-200 border border-purple-400/40 text-xs whitespace-nowrap"> <span
key={c.character_id}
onClick={() => handleCharacterClick(c)}
className={`px-3 py-1 text-xs cursor-pointer transition-colors text-center overflow-hidden text-ellipsis ${c.waypoint_enabled
? 'bg-purple-500/20 text-purple-200 border border-purple-400/40 hover:bg-purple-500/30'
: 'bg-gray-500/20 text-gray-400 border border-gray-400/40 hover:bg-gray-500/30'
}`}
title={`Click to ${c.waypoint_enabled ? 'disable' : 'enable'} waypoints for ${c.character_name}`}
>
{c.character_name} {c.character_name}
</span> </span>
))} ))}

View File

@@ -16,6 +16,12 @@ interface MapNodeProps {
signatures?: number; signatures?: number;
isDraggable?: boolean; isDraggable?: boolean;
disableNavigate?: boolean; disableNavigate?: boolean;
jumps?: number;
kills?: number;
showJumps?: boolean;
showKills?: boolean;
viewBoxWidth?: number; // Add viewBox width for scaling calculations
labelScale?: number;
} }
export const MapNode: React.FC<MapNodeProps> = ({ export const MapNode: React.FC<MapNodeProps> = ({
@@ -33,6 +39,12 @@ export const MapNode: React.FC<MapNodeProps> = ({
signatures, signatures,
isDraggable = false, isDraggable = false,
disableNavigate = false, disableNavigate = false,
jumps,
kills,
showJumps = false,
showKills = false,
viewBoxWidth = 1200,
labelScale = 1,
}) => { }) => {
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
@@ -177,7 +189,7 @@ export const MapNode: React.FC<MapNodeProps> = ({
className="transition-all duration-300" className="transition-all duration-300"
/> />
{/* Node label */} {/* Node label - fixed visual size regardless of zoom */}
<text <text
x="0" x="0"
y={textOffset} y={textOffset}
@@ -187,23 +199,92 @@ export const MapNode: React.FC<MapNodeProps> = ({
fontWeight="bold" fontWeight="bold"
className={`transition-all duration-300 ${isHovered ? 'fill-purple-200' : 'fill-white' className={`transition-all duration-300 ${isHovered ? 'fill-purple-200' : 'fill-white'
} pointer-events-none select-none`} } pointer-events-none select-none`}
style={{ textShadow: '2px 2px 4px rgba(0,0,0,0.8)' }} style={{
textShadow: '2px 2px 4px rgba(0,0,0,0.8)',
vectorEffect: 'non-scaling-stroke'
}}
transform={`scale(${(1 / (1200 / viewBoxWidth)) * labelScale})`}
> >
{name} {security !== undefined && ( {name} {security !== undefined && (
<tspan fill={getSecurityColor(security)}>{security.toFixed(1)}</tspan> <tspan fill={getSecurityColor(security)}>{security.toFixed(1)}</tspan>
)} )}
</text> </text>
<text
x="0" {/* Dynamic text positioning based on what's shown */}
y={textOffset + 15} {(() => {
textAnchor="middle" let currentY = textOffset + 15;
fill="#a3a3a3" const textElements = [];
fontSize="12"
className="pointer-events-none select-none" // Add signatures if present
style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.8)' }} if (signatures !== undefined && signatures > 0) {
> textElements.push(
{signatures !== undefined && signatures > 0 && `📡 ${signatures}`} <text
</text> key="signatures"
x="0"
y={currentY}
textAnchor="middle"
fill="#a3a3a3"
fontSize="12"
className="pointer-events-none select-none"
style={{
textShadow: '1px 1px 2px rgba(0,0,0,0.8)',
vectorEffect: 'non-scaling-stroke'
}}
transform={`scale(${(1 / (1200 / viewBoxWidth)) * labelScale})`}
>
📡 {signatures}
</text>
);
currentY += 15;
}
// Add jumps if enabled and present
if (showJumps && jumps !== undefined) {
textElements.push(
<text
key="jumps"
x="0"
y={currentY}
textAnchor="middle"
fill="#60a5fa"
fontSize="10"
className="pointer-events-none select-none"
style={{
textShadow: '1px 1px 2px rgba(0,0,0,0.8)',
vectorEffect: 'non-scaling-stroke'
}}
transform={`scale(${(1 / (1200 / viewBoxWidth)) * labelScale})`}
>
🚀 {jumps}
</text>
);
currentY += 15;
}
// Add kills if enabled and present
if (showKills && kills !== undefined) {
textElements.push(
<text
key="kills"
x="0"
y={currentY}
textAnchor="middle"
fill="#f87171"
fontSize="10"
className="pointer-events-none select-none"
style={{
textShadow: '1px 1px 2px rgba(0,0,0,0.8)',
vectorEffect: 'non-scaling-stroke'
}}
transform={`scale(${(1 / (1200 / viewBoxWidth)) * labelScale})`}
>
{kills}
</text>
);
}
return textElements;
})()}
</g> </g>
); );
} }

View File

@@ -1,4 +1,4 @@
import React, { useState, useRef, useCallback, useEffect } from 'react'; import React, { useState, useRef, useCallback, useEffect, useMemo } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { MapNode } from '@/components/MapNode'; import { MapNode } from '@/components/MapNode';
import { SystemContextMenu } from '@/components/SystemContextMenu'; import { SystemContextMenu } from '@/components/SystemContextMenu';
@@ -8,14 +8,32 @@ 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, GetCharacterLocations } from 'wailsjs/go/main/App';
import { toast } from '@/hooks/use-toast'; import { toast } from '@/hooks/use-toast';
import { getSystemsRegions } from '@/utils/systemApi';
import { useSystemJumps, useSystemKills, resolveSystemID } 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 { interface RegionMapProps {
regionName: string; regionName: string;
focusSystem?: string; focusSystem?: string;
isCompact?: boolean; isCompact?: boolean;
isWormholeRegion?: boolean; isWormholeRegion?: boolean;
header?: boolean;
} }
interface ContextMenuState { interface ContextMenuState {
@@ -56,7 +74,27 @@ function computeNodeConnections(systems: Map<string, System>): Map<string, Conne
return connections; return connections;
} }
export const RegionMap = ({ regionName, focusSystem, isCompact = false, isWormholeRegion = false }: RegionMapProps) => { // 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, header = true }: 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 });
const [isPanning, setIsPanning] = useState(false); const [isPanning, setIsPanning] = useState(false);
@@ -73,6 +111,54 @@ 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[]>([]);
type OffIndicator = { from: string; toRegion: string; count: number; color: string; angle: number; sampleTo?: string };
const [offRegionIndicators, setOffRegionIndicators] = useState<OffIndicator[]>([]);
const [meanNeighborAngle, setMeanNeighborAngle] = useState<Record<string, number>>({});
const [charLocs, setCharLocs] = useState<Array<{ character_id: number; character_name: string; solar_system_name: string }>>([]);
const [focusUntil, setFocusUntil] = useState<number | null>(null);
// Statistics state - MUST default to false to avoid API spam!
const [showJumps, setShowJumps] = useState(false);
const [showKills, setShowKills] = useState(false);
// System ID cache for statistics lookup
const [systemIDCache, setSystemIDCache] = useState<Map<string, number>>(new Map());
// New: selection/aim state for left-click aimbot behavior
const [isSelecting, setIsSelecting] = useState(false);
const [indicatedSystem, setIndicatedSystem] = useState<string | null>(null);
const selectTimerRef = useRef<number | null>(null);
const downClientPointRef = useRef<{ x: number; y: number } | null>(null);
const mouseButtonRef = useRef<number | null>(null);
// New: shift-drag circle selection state (VIA mode)
const [shiftSelecting, setShiftSelecting] = useState(false);
const [shiftCenter, setShiftCenter] = useState<Position | null>(null);
const [shiftRadius, setShiftRadius] = useState<number>(0);
// Interaction state machine (lightweight)
type InteractionMode = 'idle' | 'holding' | 'panning' | 'selecting' | 'shiftSelecting';
const [mode, setMode] = useState<InteractionMode>('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(() => { useEffect(() => {
const onKeyDown = async (e: KeyboardEvent) => { const onKeyDown = async (e: KeyboardEvent) => {
if (e.key === 'Escape' && viaMode) { if (e.key === 'Escape' && viaMode) {
@@ -96,19 +182,161 @@ export const RegionMap = ({ regionName, focusSystem, isCompact = false, isWormho
}, [viaMode, viaDest, viaQueue]); }, [viaMode, viaDest, viaQueue]);
const { data: rsystems, isLoading, error } = useRegionData(regionName); 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(() => { useEffect(() => {
if (!isLoading && error == null && rsystems && rsystems.size > 0) if (!isLoading && error == null && rsystems && rsystems.size > 0) {
setSystems(rsystems); setSystems(rsystems);
// Pre-resolve all system IDs for statistics lookup
const resolveSystemIDs = async () => {
const newCache = new Map<string, number>();
for (const systemName of rsystems.keys()) {
try {
const id = await resolveSystemID(systemName);
if (id) {
newCache.set(systemName, id);
}
} catch (error) {
console.warn(`Failed to resolve system ID for ${systemName}:`, error);
}
}
setSystemIDCache(newCache);
};
resolveSystemIDs();
}
}, [rsystems, isLoading, error]); }, [rsystems, isLoading, error]);
useEffect(() => { useEffect(() => {
if (!systems || systems.size === 0) return; if (!systems || systems.size === 0) return;
const positions = computeNodePositions(systems); const positions = computeNodePositions(systems);
setPositions(positions); setPositions(positions);
const connections = computeNodeConnections(systems); const connections = computeNodeConnections(systems);
setConnections(connections); setConnections(connections);
// Compute per-system mean outbound angle in screen coords (atan2(dy,dx)) to in-region neighbors
const angleMap: Record<string, number> = {};
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]); }, [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; angle from local geometry only (meanNeighborAngle + PI)
type Agg = { from: string; toRegion: string; count: number; sumRemoteSec: number; sampleTo?: string };
const grouped: Map<string, Agg> = 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(() => { useEffect(() => {
if (isWormholeRegion) { if (isWormholeRegion) {
loadWormholeSystems().then(wormholeSystems => { loadWormholeSystems().then(wormholeSystems => {
@@ -134,12 +362,17 @@ export const RegionMap = ({ regionName, focusSystem, isCompact = false, isWormho
const handleSystemClick = async (systemName: string) => { const handleSystemClick = async (systemName: string) => {
if (viaMode) { if (viaMode) {
setViaQueue(prev => { setViaQueue(prev => {
if (prev.includes(systemName)) return 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]; const next = [...prev, systemName];
toast({ title: 'Waypoint queued', description: systemName });
return next; return next;
}); });
console.log('Queued waypoint:', systemName); console.log('VIA waypoint toggle:', systemName);
toast({ title: 'Waypoint queued', description: systemName });
return; return;
} }
if (focusSystem === systemName) return; if (focusSystem === systemName) return;
@@ -219,7 +452,7 @@ export const RegionMap = ({ regionName, focusSystem, isCompact = false, isWormho
const dx = system.x - x; const dx = system.x - x;
const dy = system.y - y; const dy = system.y - y;
const distance = Math.sqrt(dx * dx + dy * dy); const distance = Math.sqrt(dx * dx + dy * dy);
if (distance < 20) { if (distance < DRAG_SNAP_DISTANCE) {
targetSystem = system; targetSystem = system;
} }
}); });
@@ -275,24 +508,311 @@ export const RegionMap = ({ regionName, focusSystem, isCompact = false, isWormho
setTempConnection(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;
};
// Create lookup maps for system statistics
const jumpsBySystemID = useMemo(() => {
if (!jumpsData) return new Map();
const map = new Map<number, number>();
jumpsData.forEach(jump => {
map.set(jump.system_id, jump.ship_jumps);
});
return map;
}, [jumpsData]);
const killsBySystemID = useMemo(() => {
if (!killsData) return new Map();
const map = new Map<number, number>();
killsData.forEach(kill => {
map.set(kill.system_id, kill.ship_kills);
});
return map;
}, [killsData]);
// Helper functions to get statistics for a system
const getSystemJumps = (systemName: string): number | undefined => {
if (!showJumps) return undefined;
const systemID = systemIDCache.get(systemName);
if (!systemID) return undefined;
const jumps = jumpsBySystemID.get(systemID);
if (!jumps || jumps === 0) return undefined;
console.log(`🚀 Found ${jumps} jumps for ${systemName} (ID: ${systemID})`);
return jumps;
};
const getSystemKills = (systemName: string): number | undefined => {
if (!showKills) return undefined;
const systemID = systemIDCache.get(systemName);
if (!systemID) return undefined;
const kills = killsBySystemID.get(systemID);
if (!kills || kills === 0) return undefined;
console.log(`⚔️ Found ${kills} kills for ${systemName} (ID: ${systemID})`);
return kills;
};
// 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) => { const handleMouseDown = useCallback((e: React.MouseEvent) => {
if (!svgRef.current) return; if (!svgRef.current) return;
setIsPanning(true);
// 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(); const rect = svgRef.current.getBoundingClientRect();
setLastPanPoint({ x: e.clientX - rect.left, y: e.clientY - rect.top }); 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) => { const handleMouseMove = useCallback((e: React.MouseEvent) => {
if (!isPanning || !svgRef.current) return; // if dragging node, delegate
const rect = svgRef.current.getBoundingClientRect(); if (draggingNode) { handleSvgMouseMove(e); return; }
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);
}, [isPanning, lastPanPoint, viewBox.width, viewBox.height]);
const handleMouseUp = useCallback(() => { setIsPanning(false); }, []); 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) => { const handleWheel = useCallback((e: React.WheelEvent) => {
e.preventDefault(); e.preventDefault();
@@ -320,6 +840,45 @@ export const RegionMap = ({ regionName, focusSystem, isCompact = false, isWormho
}); });
}, [viewBox]); }, [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) => { const handleContextMenu = (e: React.MouseEvent, system: System) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@@ -421,6 +980,27 @@ export const RegionMap = ({ regionName, focusSystem, isCompact = false, isWormho
return () => document.removeEventListener('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) { if (isLoading) {
return ( return (
<div className="h-full w-full bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 flex items-center justify-center"> <div className="h-full w-full bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 flex items-center justify-center">
@@ -442,25 +1022,28 @@ export const RegionMap = ({ regionName, focusSystem, isCompact = false, isWormho
return ( return (
<div className="w-full h-full bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 relative"> <div className="w-full h-full bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 relative">
<Header {header && (
title={`Region: ${regionName}`} <Header
breadcrumbs={[ title={`Region: ${regionName}`}
{ label: "Universe", path: "/" }, breadcrumbs={[
{ label: regionName } { label: "Universe", path: "/" },
]} { label: regionName }
/> ]}
/>
)}
<svg <svg
ref={svgRef} ref={svgRef}
width="100%" width="100%"
height="100%" height="100%"
viewBox={`${viewBox.x} ${viewBox.y} ${viewBox.width} ${viewBox.height}`} viewBox={`${viewBox.x} ${viewBox.y} ${viewBox.width} ${viewBox.height}`}
className="cursor-grab active:cursor-grabbing" className={`${(mode === 'selecting' || mode === 'shiftSelecting') ? 'cursor-crosshair' : (mode === 'panning' ? 'cursor-grabbing' : 'cursor-grab')}`}
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
onMouseMove={(e) => { if (isPanning) { handleMouseMove(e); } else if (draggingNode) { handleSvgMouseMove(e); } }} onMouseMove={handleMouseMove}
onMouseUp={(e) => { if (isPanning) { handleMouseUp(); } else if (draggingNode) { handleSvgMouseUp(e); } }} onMouseUp={(e) => handleMouseUp(e)}
onMouseLeave={handleMouseUp} onMouseLeave={handleMouseUp}
onWheel={handleWheel} onWheel={handleWheel}
onDoubleClick={handleMapDoubleClick} onDoubleClick={handleMapDoubleClick}
onContextMenu={handleBackgroundContextMenu}
> >
<defs> <defs>
<filter id="glow"> <filter id="glow">
@@ -470,6 +1053,9 @@ export const RegionMap = ({ regionName, focusSystem, isCompact = false, isWormho
<feMergeNode in="SourceGraphic" /> <feMergeNode in="SourceGraphic" />
</feMerge> </feMerge>
</filter> </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> </defs>
{/* Render all connections */} {/* Render all connections */}
@@ -491,6 +1077,20 @@ export const RegionMap = ({ regionName, focusSystem, isCompact = false, isWormho
/> />
)} )}
{/* Shift selection circle (VIA mode) */}
{shiftSelecting && shiftCenter && (
<g style={{ pointerEvents: 'none' }}>
<circle
cx={shiftCenter.x}
cy={shiftCenter.y}
r={Math.max(shiftRadius, 0)}
fill={SHIFT_SELECT_FILL_COLOR}
stroke={SHIFT_SELECT_STROKE_COLOR}
strokeWidth={SHIFT_SELECT_STROKE_WIDTH}
/>
</g>
)}
{/* Render existing systems */} {/* Render existing systems */}
{Array.from(systems.entries()).map(([key, system]) => ( {Array.from(systems.entries()).map(([key, system]) => (
<MapNode <MapNode
@@ -498,7 +1098,7 @@ export const RegionMap = ({ regionName, focusSystem, isCompact = false, isWormho
id={system.solarSystemName} id={system.solarSystemName}
name={system.solarSystemName} name={system.solarSystemName}
position={positions[system.solarSystemName] || { x: 0, y: 0 }} position={positions[system.solarSystemName] || { x: 0, y: 0 }}
onClick={() => handleSystemClick(system.solarSystemName)} onClick={() => { /* handled at svg-level aimbot commit */ }}
onDoubleClick={(e) => handleSystemDoubleClick(e, positions[system.solarSystemName])} onDoubleClick={(e) => handleSystemDoubleClick(e, positions[system.solarSystemName])}
onDragStart={(e) => handleNodeDragStart(e, system.solarSystemName)} onDragStart={(e) => handleNodeDragStart(e, system.solarSystemName)}
onDrag={handleSvgMouseMove} onDrag={handleSvgMouseMove}
@@ -509,31 +1109,114 @@ export const RegionMap = ({ regionName, focusSystem, isCompact = false, isWormho
signatures={system.signatures} signatures={system.signatures}
isDraggable={isWormholeRegion} isDraggable={isWormholeRegion}
disableNavigate={viaMode} disableNavigate={viaMode}
jumps={getSystemJumps(system.solarSystemName)}
kills={getSystemKills(system.solarSystemName)}
showJumps={showJumps}
showKills={showKills}
viewBoxWidth={viewBox.width}
labelScale={isCompact ? 2.0 : 1}
/> />
))} ))}
{/* VIA waypoints indicator rings */}
{viaMode && viaQueue.map((name) => (
positions[name] ? (
<g key={`via-${name}`} style={{ pointerEvents: 'none' }}>
<circle
cx={positions[name].x}
cy={positions[name].y}
r={VIA_WAYPOINT_RING_RADIUS}
fill="none"
stroke={VIA_WAYPOINT_RING_COLOR}
strokeWidth="3"
opacity="0.9"
filter="url(#glow)"
/>
</g>
) : null
))}
{/* Indicated (aim) system ring */}
{indicatedSystem && positions[indicatedSystem] && (
<g style={{ pointerEvents: 'none' }}>
<circle
cx={positions[indicatedSystem].x}
cy={positions[indicatedSystem].y}
r={INDICATED_RING_RADIUS}
fill="none"
stroke={INDICATED_RING_COLOR}
strokeWidth="3"
opacity="0.9"
filter="url(#glow)"
>
<animate attributeName="r" values={INDICATED_RING_ANIM_VALUES} dur={INDICATED_RING_ANIM_DUR} repeatCount="indefinite" />
</circle>
</g>
)}
{/* 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})`}>
<rect x={-2} y={-9} width={Math.max(c.character_name.length * 5, 24)} height={14} rx={3} fill="#0f172a" opacity={0.9} stroke="#00d1ff" strokeWidth={1} />
<text x={Math.max(c.character_name.length * 5, 24) / 2 - 2} y={2} textAnchor="middle" 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 */} {/* Highlight focused system */}
{focusSystem && positions[focusSystem] && ( {focusSystem && focusUntil && Date.now() <= focusUntil && positions[focusSystem] && (
<circle <g style={{ pointerEvents: 'none' }}>
cx={positions[focusSystem].x} <circle
cy={positions[focusSystem].y} cx={positions[focusSystem].x}
r="15" cy={positions[focusSystem].y}
fill="none" r="20"
stroke="#a855f7" fill="none"
strokeWidth="3" stroke="#a855f7"
strokeDasharray="5,5" strokeWidth="3"
opacity="0.8" opacity="0.9"
> filter="url(#glow)"
<animateTransform >
attributeName="transform" <animate
attributeType="XML" attributeName="r"
type="rotate" values="18;22;18"
from={`0 ${positions[focusSystem].x} ${positions[focusSystem].y}`} dur="1.5s"
to={`360 ${positions[focusSystem].x} ${positions[focusSystem].y}`} repeatCount="indefinite"
dur="10s" />
repeatCount="indefinite" </circle>
/> <text x={positions[focusSystem].x + 12} y={positions[focusSystem].y - 10} fontSize="10" fill="#ffffff" stroke="#0f172a" strokeWidth="2" paintOrder="stroke">{focusSystem}</text>
</circle> </g>
)} )}
</svg> </svg>
@@ -543,6 +1226,16 @@ export const RegionMap = ({ regionName, focusSystem, isCompact = false, isWormho
</div> </div>
)} )}
{/* Statistics Toggle - positioned to avoid overlaps */}
<div className="absolute bottom-4 left-4">
<StatisticsToggle
jumpsEnabled={showJumps}
killsEnabled={showKills}
onJumpsToggle={setShowJumps}
onKillsToggle={setShowKills}
/>
</div>
{/* Context Menu */} {/* Context Menu */}
{contextMenu && ( {contextMenu && (
<SystemContextMenu <SystemContextMenu
@@ -552,7 +1245,7 @@ export const RegionMap = ({ regionName, focusSystem, isCompact = false, isWormho
onRename={(newName) => handleRenameSystem(contextMenu.system.solarSystemName, newName)} onRename={(newName) => handleRenameSystem(contextMenu.system.solarSystemName, newName)}
onDelete={handleDeleteSystem} onDelete={handleDeleteSystem}
onClearConnections={handleClearConnections} onClearConnections={handleClearConnections}
onSetDestination={(systemName) => onSetDestination(systemName, true)} onSetDestination={(systemName, via) => onSetDestination(systemName, via)}
onClose={() => setContextMenu(null)} onClose={() => setContextMenu(null)}
/> />
)} )}

View File

@@ -0,0 +1,151 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { AhoCorasick } from '@/lib/aho';
import { ListSystemsWithRegions, SetDestinationForAll, ListCharacters, StartESILogin } from 'wailsjs/go/main/App';
import { toast } from '@/hooks/use-toast';
interface SearchResult {
system: string;
region: string;
}
async function loadAllSystems(): Promise<Array<SearchResult>> {
const list = await ListSystemsWithRegions();
const seen = new Set<string>();
const out: Array<SearchResult> = [];
for (const item of list) {
const system = String(item.system);
if (seen.has(system)) continue;
seen.add(system);
out.push({ system, region: String(item.region) });
}
return out;
}
export const SearchDialog: React.FC = () => {
const navigate = useNavigate();
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const [all, setAll] = useState<Array<SearchResult>>([]);
const [results, setResults] = useState<Array<SearchResult>>([]);
const inputRef = useRef<HTMLInputElement | null>(null);
const automaton = useMemo(() => {
const ac = new AhoCorasick();
if (query.trim().length > 0) ac.add(query.toLowerCase());
ac.build();
return ac;
}, [query]);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
const isCtrlF = (e.key === 'f' || e.key === 'F') && (e.ctrlKey || e.metaKey);
if (isCtrlF) {
e.preventDefault();
setOpen(true);
setTimeout(() => inputRef.current?.focus(), 0);
}
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, []);
useEffect(() => {
if (!open) return;
if (all.length === 0) {
loadAllSystems().then(setAll);
}
}, [open, all.length]);
useEffect(() => {
const q = query.trim().toLowerCase();
if (q.length === 0) { setResults([]); return; }
const scored: Array<{ s: SearchResult; idx: number }> = [];
for (const r of all) {
const idx = automaton.searchFirstIndex(r.system.toLowerCase());
if (idx >= 0) scored.push({ s: r, idx });
}
scored.sort((a, b) => {
if (a.idx !== b.idx) return a.idx - b.idx; // earlier index first
if (a.s.system.length !== b.s.system.length) return a.s.system.length - b.s.system.length; // shorter name next
return a.s.system.localeCompare(b.s.system);
});
setResults(scored.slice(0, 10).map(x => x.s));
}, [query, all, automaton]);
const onSelect = (r: SearchResult) => {
setOpen(false);
setQuery('');
navigate(`/regions/${encodeURIComponent(r.region)}?focus=${encodeURIComponent(r.system)}`);
};
const ensureAnyLoggedIn = async (): Promise<boolean> => {
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 handleResultClick = async (e: React.MouseEvent, r: SearchResult) => {
if (e.shiftKey) {
e.preventDefault();
e.stopPropagation();
try {
if (!(await ensureAnyLoggedIn())) return;
await SetDestinationForAll(r.system, true, false);
toast({ title: 'Destination set', description: r.system });
} catch (err: any) {
toast({ title: 'Failed to set destination', description: String(err), variant: 'destructive' });
} finally {
setOpen(false);
setQuery('');
}
return;
}
onSelect(r);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-lg bg-slate-900/95 border border-purple-500/40 text-white">
<DialogHeader>
<DialogTitle>Search systems</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-2">
<Input
ref={inputRef}
placeholder="Type system name..."
value={query}
onChange={(e) => setQuery(e.target.value)}
className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-400"
/>
<div className="max-h-80 overflow-auto divide-y divide-slate-800 rounded-md border border-slate-800">
{results.length === 0 && query && (
<div className="p-3 text-sm text-slate-300">No results</div>
)}
{results.map((r, idx) => (
<button
key={`${r.region}-${r.system}-${idx}`}
className="w-full text-left p-3 hover:bg-purple-500/20"
onClick={(e) => handleResultClick(e, r)}
title="Click to open, Shift+Click to set destination"
>
<div className="text-sm font-medium">{r.system}</div>
<div className="text-xs text-slate-300">{r.region}</div>
</button>
))}
</div>
</div>
</DialogContent>
</Dialog>
);
};

View File

@@ -3,15 +3,15 @@ import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
AlertDialogCancel, AlertDialogCancel,
AlertDialogContent, AlertDialogContent,
AlertDialogDescription, AlertDialogDescription,
AlertDialogFooter, AlertDialogFooter,
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
AlertDialogTrigger, AlertDialogTrigger,
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import { SigviewRecord as Signature } from "@/lib/pbtypes"; import { SigviewRecord as Signature } from "@/lib/pbtypes";
import { getSignatureMeta } from "@/hooks/useSignatureCategories"; import { getSignatureMeta } from "@/hooks/useSignatureCategories";
@@ -52,7 +52,7 @@ export const SignatureCard = ({ signature, onDelete, onUpdate }: SignatureCardPr
return ( return (
<> <>
<Card <Card
className={`${isGasSite ? 'bg-emerald-900/40 border-emerald-500 shadow-[0_0_15px_rgba(16,185,129,0.5)] hover:shadow-[0_0_20px_rgba(16,185,129,0.7)]' : 'bg-slate-800/40 border-slate-700'} hover:bg-slate-800/60 transition-all duration-200 hover:border-slate-600 relative cursor-pointer`} className={`${isGasSite ? 'bg-emerald-900/40 border-emerald-500 shadow-[0_0_15px_rgba(16,185,129,0.5)] hover:shadow-[0_0_20px_rgba(16,185,129,0.7)]' : 'bg-slate-800/40 border-slate-700'} hover:bg-slate-800/60 transition-all duration-200 hover:border-slate-600 relative cursor-pointer`}
onClick={() => setIsEditModalOpen(true)} onClick={() => setIsEditModalOpen(true)}
> >
@@ -60,29 +60,31 @@ export const SignatureCard = ({ signature, onDelete, onUpdate }: SignatureCardPr
<div className="space-y-3"> <div className="space-y-3">
{/* Type Badge - Most Important */} {/* Type Badge - Most Important */}
<div className="flex items-center justify-center"> <div className="flex items-center justify-center">
<Badge <Badge
variant="outline" variant="outline"
className={`${meta.color} px-3 py-1 text-sm font-semibold flex items-center gap-2`} className={`${meta.color} px-3 py-1 text-sm font-semibold flex items-center gap-2`}
> >
{meta.icon} {meta.icon}
{signature.type || 'Unknown Type'} {signature.type || 'Unknown Type'}
</Badge> </Badge>
</div> </div>
{/* Signature Name */} {/* Signature Name */}
<div className="text-center"> <div className="text-center">
<h3 className="text-white font-medium text-lg"> <h3 className="text-white font-medium text-lg">
{signature.signame || 'Unnamed Signature'} {signature.signame || 'Unnamed Signature'}
</h3> </h3>
{signature.note && ( {signature.note && (
<div className="mt-2"> <div className="mt-2 flex flex-wrap gap-1 justify-center">
<Badge variant="outline" className="bg-blue-900/50 text-blue-200 border-blue-500 px-3 py-1 text-sm font-semibold"> {signature.note.split(';').filter(Boolean).map((note, index) => (
{signature.note} <Badge key={index} variant="outline" className="bg-blue-900/50 text-blue-200 border-blue-500 px-3 py-1 text-sm font-semibold">
</Badge> {note.trim()}
</Badge>
))}
</div> </div>
)} )}
</div> </div>
{/* Additional Info */} {/* Additional Info */}
<div className="space-y-2 text-sm text-slate-400"> <div className="space-y-2 text-sm text-slate-400">
<div className="flex justify-between"> <div className="flex justify-between">

View File

@@ -53,9 +53,9 @@ export const SignatureCategories = ({ categories, onToggleCategory, onDelete, on
<CardContent className="p-0"> <CardContent className="p-0">
<div className="divide-y divide-slate-700"> <div className="divide-y divide-slate-700">
{category.signatures.map((signature) => ( {category.signatures.map((signature) => (
<SignatureListItem <SignatureListItem
key={signature.id} key={signature.id}
signature={signature} signature={signature}
onDelete={onDelete} onDelete={onDelete}
onUpdate={onUpdate} onUpdate={onUpdate}
/> />

View File

@@ -1,12 +1,12 @@
import { useState } from "react"; import { useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";

View File

@@ -2,15 +2,15 @@ import { useState } from "react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
AlertDialogCancel, AlertDialogCancel,
AlertDialogContent, AlertDialogContent,
AlertDialogDescription, AlertDialogDescription,
AlertDialogFooter, AlertDialogFooter,
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
AlertDialogTrigger, AlertDialogTrigger,
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import { Clock, AlertTriangle, Skull, Trash2 } from "lucide-react"; import { Clock, AlertTriangle, Skull, Trash2 } from "lucide-react";
import { SigviewRecord as Signature } from "@/lib/pbtypes"; import { SigviewRecord as Signature } from "@/lib/pbtypes";
@@ -88,9 +88,8 @@ export const SignatureListItem = ({ signature, onDelete, onUpdate }: SignatureLi
return ( return (
<> <>
<div <div
className={`flex items-center justify-between p-4 border-b border-slate-700 hover:bg-slate-800/40 transition-colors cursor-pointer ${ className={`flex items-center justify-between p-4 border-b border-slate-700 hover:bg-slate-800/40 transition-colors cursor-pointer ${oldEntry ? "opacity-50" : ""
oldEntry ? "opacity-50" : "" } ${isGasSite ? 'bg-emerald-900/40 border-emerald-500 shadow-[0_0_15px_rgba(16,185,129,0.5)] hover:shadow-[0_0_20px_rgba(16,185,129,0.7)]' : ''}`}
} ${isGasSite ? 'bg-emerald-900/40 border-emerald-500 shadow-[0_0_15px_rgba(16,185,129,0.5)] hover:shadow-[0_0_20px_rgba(16,185,129,0.7)]' : ''}`}
onClick={() => setIsEditModalOpen(true)} onClick={() => setIsEditModalOpen(true)}
> >
<div className="flex items-center gap-4 flex-1"> <div className="flex items-center gap-4 flex-1">
@@ -118,9 +117,13 @@ export const SignatureListItem = ({ signature, onDelete, onUpdate }: SignatureLi
)} )}
</h3> </h3>
{signature.note && ( {signature.note && (
<Badge variant="outline" className="bg-blue-900/50 text-blue-200 border-blue-500 px-2 py-0.5 text-sm font-semibold ml-2"> <div className="flex flex-wrap gap-1 ml-2">
{signature.note} {signature.note.split(';').filter(Boolean).map((note, index) => (
</Badge> <Badge key={index} variant="outline" className="bg-blue-900/50 text-blue-200 border-blue-500 px-2 py-0.5 text-sm font-semibold">
{note.trim()}
</Badge>
))}
</div>
)} )}
</div> </div>
</div> </div>

View File

@@ -0,0 +1,44 @@
import React from 'react';
interface StatisticsToggleProps {
jumpsEnabled: boolean;
killsEnabled: boolean;
onJumpsToggle: (enabled: boolean) => void;
onKillsToggle: (enabled: boolean) => void;
}
export const StatisticsToggle: React.FC<StatisticsToggleProps> = ({
jumpsEnabled,
killsEnabled,
onJumpsToggle,
onKillsToggle,
}) => {
return (
<div className="bg-slate-800/90 backdrop-blur-sm rounded-lg p-2 shadow-lg border border-slate-700">
<div className="flex gap-2">
<button
onClick={() => onJumpsToggle(!jumpsEnabled)}
className={`p-2 rounded transition-colors ${
jumpsEnabled
? 'bg-blue-600/20 text-blue-400 hover:bg-blue-600/30'
: 'bg-gray-600/20 text-gray-400 hover:bg-gray-600/30'
}`}
title={jumpsEnabled ? 'Hide Jumps' : 'Show Jumps'}
>
🚀
</button>
<button
onClick={() => onKillsToggle(!killsEnabled)}
className={`p-2 rounded transition-colors ${
killsEnabled
? 'bg-red-600/20 text-red-400 hover:bg-red-600/30'
: 'bg-gray-600/20 text-gray-400 hover:bg-gray-600/30'
}`}
title={killsEnabled ? 'Hide Kills' : 'Show Kills'}
>
</button>
</div>
</div>
);
};

View File

@@ -25,7 +25,7 @@ const badgeVariants = cva(
export interface BadgeProps export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>, extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {} VariantProps<typeof badgeVariants> { }
function Badge({ className, variant, ...props }: BadgeProps) { function Badge({ className, variant, ...props }: BadgeProps) {
return ( return (

View File

@@ -35,7 +35,7 @@ const buttonVariants = cva(
export interface ButtonProps export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>, extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> { VariantProps<typeof buttonVariants> {
asChild?: boolean asChild?: boolean
} }

View File

@@ -82,13 +82,13 @@ const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
([theme, prefix]) => ` ([theme, prefix]) => `
${prefix} [data-chart=${id}] { ${prefix} [data-chart=${id}] {
${colorConfig ${colorConfig
.map(([key, itemConfig]) => { .map(([key, itemConfig]) => {
const color = const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color itemConfig.color
return color ? ` --color-${key}: ${color};` : null return color ? ` --color-${key}: ${color};` : null
}) })
.join("\n")} .join("\n")}
} }
` `
) )
@@ -103,13 +103,13 @@ const ChartTooltip = RechartsPrimitive.Tooltip
const ChartTooltipContent = React.forwardRef< const ChartTooltipContent = React.forwardRef<
HTMLDivElement, HTMLDivElement,
React.ComponentProps<typeof RechartsPrimitive.Tooltip> & React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & { React.ComponentProps<"div"> & {
hideLabel?: boolean hideLabel?: boolean
hideIndicator?: boolean hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed" indicator?: "line" | "dot" | "dashed"
nameKey?: string nameKey?: string
labelKey?: string labelKey?: string
} }
>( >(
( (
{ {
@@ -259,10 +259,10 @@ const ChartLegend = RechartsPrimitive.Legend
const ChartLegendContent = React.forwardRef< const ChartLegendContent = React.forwardRef<
HTMLDivElement, HTMLDivElement,
React.ComponentProps<"div"> & React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & { Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean hideIcon?: boolean
nameKey?: string nameKey?: string
} }
>( >(
( (
{ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey }, { className, hideIcon = false, payload, verticalAlign = "bottom", nameKey },
@@ -326,8 +326,8 @@ function getPayloadConfigFromPayload(
const payloadPayload = const payloadPayload =
"payload" in payload && "payload" in payload &&
typeof payload.payload === "object" && typeof payload.payload === "object" &&
payload.payload !== null payload.payload !== null
? payload.payload ? payload.payload
: undefined : undefined

View File

@@ -21,7 +21,7 @@ const Command = React.forwardRef<
)) ))
Command.displayName = CommandPrimitive.displayName Command.displayName = CommandPrimitive.displayName
interface CommandDialogProps extends DialogProps {} interface CommandDialogProps extends DialogProps { }
const CommandDialog = ({ children, ...props }: CommandDialogProps) => { const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
return ( return (

View File

@@ -11,7 +11,7 @@ const labelVariants = cva(
const Label = React.forwardRef< const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>, React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants> VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<LabelPrimitive.Root <LabelPrimitive.Root
ref={ref} ref={ref}

View File

@@ -31,9 +31,9 @@ const ScrollBar = React.forwardRef<
className={cn( className={cn(
"flex touch-none select-none transition-colors", "flex touch-none select-none transition-colors",
orientation === "vertical" && orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]", "h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" && orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]", "h-2.5 flex-col border-t border-t-transparent p-[1px]",
className className
)} )}
{...props} {...props}

View File

@@ -75,7 +75,7 @@ const SelectContent = React.forwardRef<
className={cn( className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", "relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" && position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className className
)} )}
position={position} position={position}
@@ -86,7 +86,7 @@ const SelectContent = React.forwardRef<
className={cn( className={cn(
"p-1", "p-1",
position === "popper" && position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]" "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)} )}
> >
{children} {children}

View File

@@ -128,4 +128,3 @@ export {
Sheet, SheetClose, Sheet, SheetClose,
SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetOverlay, SheetPortal, SheetTitle, SheetTrigger SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetOverlay, SheetPortal, SheetTitle, SheetTrigger
} }

View File

@@ -612,7 +612,7 @@ const SidebarMenuAction = React.forwardRef<
"peer-data-[size=lg]/menu-button:top-2.5", "peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden", "group-data-[collapsible=icon]:hidden",
showOnHover && showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0", "group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0",
className className
)} )}
{...props} {...props}

View File

@@ -3,7 +3,7 @@ import * as React from "react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
export interface TextareaProps export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {} extends React.TextareaHTMLAttributes<HTMLTextAreaElement> { }
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>( const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => { ({ className, ...props }, ref) => {

View File

@@ -41,7 +41,7 @@ const toastVariants = cva(
const Toast = React.forwardRef< const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>, React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants> VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => { >(({ className, variant, ...props }, ref) => {
return ( return (
<ToastPrimitives.Root <ToastPrimitives.Root

View File

@@ -15,7 +15,7 @@ const ToggleGroupContext = React.createContext<
const ToggleGroup = React.forwardRef< const ToggleGroup = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Root>, React.ElementRef<typeof ToggleGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> & React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants> VariantProps<typeof toggleVariants>
>(({ className, variant, size, children, ...props }, ref) => ( >(({ className, variant, size, children, ...props }, ref) => (
<ToggleGroupPrimitive.Root <ToggleGroupPrimitive.Root
ref={ref} ref={ref}
@@ -33,7 +33,7 @@ ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName
const ToggleGroupItem = React.forwardRef< const ToggleGroupItem = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Item>, React.ElementRef<typeof ToggleGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> & React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants> VariantProps<typeof toggleVariants>
>(({ className, children, variant, size, ...props }, ref) => { >(({ className, children, variant, size, ...props }, ref) => {
const context = React.useContext(ToggleGroupContext) const context = React.useContext(ToggleGroupContext)

View File

@@ -29,7 +29,7 @@ const toggleVariants = cva(
const Toggle = React.forwardRef< const Toggle = React.forwardRef<
React.ElementRef<typeof TogglePrimitive.Root>, React.ElementRef<typeof TogglePrimitive.Root>,
React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> & React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants> VariantProps<typeof toggleVariants>
>(({ className, variant, size, ...props }, ref) => ( >(({ className, variant, size, ...props }, ref) => (
<TogglePrimitive.Root <TogglePrimitive.Root
ref={ref} ref={ref}

View File

@@ -32,21 +32,21 @@ type ActionType = typeof actionTypes
type Action = type Action =
| { | {
type: ActionType["ADD_TOAST"] type: ActionType["ADD_TOAST"]
toast: ToasterToast toast: ToasterToast
} }
| { | {
type: ActionType["UPDATE_TOAST"] type: ActionType["UPDATE_TOAST"]
toast: Partial<ToasterToast> toast: Partial<ToasterToast>
} }
| { | {
type: ActionType["DISMISS_TOAST"] type: ActionType["DISMISS_TOAST"]
toastId?: ToasterToast["id"] toastId?: ToasterToast["id"]
} }
| { | {
type: ActionType["REMOVE_TOAST"] type: ActionType["REMOVE_TOAST"]
toastId?: ToasterToast["id"] toastId?: ToasterToast["id"]
} }
interface State { interface State {
toasts: ToasterToast[] toasts: ToasterToast[]
@@ -104,9 +104,9 @@ export const reducer = (state: State, action: Action): State => {
toasts: state.toasts.map((t) => toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined t.id === toastId || toastId === undefined
? { ? {
...t, ...t,
open: false, open: false,
} }
: t : t
), ),
} }

View File

@@ -0,0 +1,41 @@
import { useQuery } from '@tanstack/react-query';
import * as App from 'wailsjs/go/main/App';
// Helper function to resolve system name to ID
export const resolveSystemID = async (systemName: string): Promise<number | null> => {
try {
const id = await App.ResolveSystemIDByName(systemName);
return id;
} catch (error) {
console.warn(`Failed to resolve system ID for ${systemName}:`, error);
return null;
}
};
export const useSystemJumps = (enabled: boolean = false) => {
console.log('useSystemJumps called with enabled:', enabled);
return useQuery({
queryKey: ['systemJumps'],
queryFn: () => {
console.log('🚀 FETCHING SYSTEM JUMPS DATA - API REQUEST MADE!');
return App.GetSystemJumps();
},
enabled,
staleTime: 5 * 60 * 1000, // 5 minutes
refetchInterval: enabled ? 5 * 60 * 1000 : false, // Only refetch when enabled
});
};
export const useSystemKills = (enabled: boolean = false) => {
console.log('useSystemKills called with enabled:', enabled);
return useQuery({
queryKey: ['systemKills'],
queryFn: () => {
console.log('⚔️ FETCHING SYSTEM KILLS DATA - API REQUEST MADE!');
return App.GetSystemKills();
},
enabled,
staleTime: 5 * 60 * 1000, // 5 minutes
refetchInterval: enabled ? 5 * 60 * 1000 : false, // Only refetch when enabled
});
};

View File

@@ -1,5 +1,3 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@@ -66,7 +64,9 @@
* { * {
@apply border-border; @apply border-border;
} }
html, body {
html,
body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
height: 100vh; height: 100vh;
width: 100vw; width: 100vw;
@@ -74,12 +74,14 @@
margin: 0; margin: 0;
padding: 0; padding: 0;
} }
body { body {
font-family: 'Inter', system-ui, sans-serif; font-family: 'Inter', system-ui, sans-serif;
} }
#root { #root {
height: 100vh; height: 100vh;
width: 100vw; width: 100vw;
overflow: hidden; overflow: hidden;
} }
} }

80
frontend/src/lib/aho.ts Normal file
View File

@@ -0,0 +1,80 @@
export class AhoCorasick {
private goto: Array<Map<string, number>> = [new Map()];
private out: Array<boolean> = [false];
private outLen: Array<number> = [0];
private fail: Array<number> = [0];
add(pattern: string) {
let state = 0;
for (const ch of pattern) {
const next = this.goto[state].get(ch);
if (next === undefined) {
const newState = this.goto.length;
this.goto[state].set(ch, newState);
this.goto.push(new Map());
this.out.push(false);
this.outLen.push(0);
this.fail.push(0);
state = newState;
} else {
state = next;
}
}
this.out[state] = true;
this.outLen[state] = Math.max(this.outLen[state], pattern.length);
}
build() {
const queue: number[] = [];
for (const [, s] of this.goto[0]) {
this.fail[s] = 0;
queue.push(s);
}
while (queue.length > 0) {
const r = queue.shift()!;
for (const [a, s] of this.goto[r]) {
queue.push(s);
let state = this.fail[r];
while (state !== 0 && !this.goto[state].has(a)) {
state = this.fail[state];
}
const f = this.goto[state].get(a) ?? 0;
this.fail[s] = f;
this.out[s] = this.out[s] || this.out[f];
if (this.outLen[f] > this.outLen[s]) this.outLen[s] = this.outLen[f];
}
}
}
// Returns true if any pattern is found in text
searchHas(text: string): boolean {
let state = 0;
for (const ch of text) {
while (state !== 0 && !this.goto[state].has(ch)) {
state = this.fail[state];
}
state = this.goto[state].get(ch) ?? 0;
if (this.out[state]) return true;
}
return false;
}
// Returns the starting index of the first match in text, or -1 if none
searchFirstIndex(text: string): number {
let state = 0;
let i = 0;
for (const ch of text) {
while (state !== 0 && !this.goto[state].has(ch)) {
state = this.fail[state];
}
state = this.goto[state].get(ch) ?? 0;
if (this.out[state]) {
const len = this.outLen[state] || 0;
if (len > 0) return i - len + 1;
return i; // fallback
}
i++;
}
return -1;
}
}

View File

@@ -11,8 +11,13 @@ export enum Collections {
Mfas = "_mfas", Mfas = "_mfas",
Otps = "_otps", Otps = "_otps",
Superusers = "_superusers", Superusers = "_superusers",
IndBillitem = "ind_billItem",
IndChar = "ind_char",
IndJob = "ind_job",
IndTransaction = "ind_transaction",
Regionview = "regionview", Regionview = "regionview",
Signature = "signature", Signature = "signature",
SignatureNoteRules = "signature_note_rules",
Sigview = "sigview", Sigview = "sigview",
System = "system", System = "system",
WormholeSystems = "wormholeSystems", WormholeSystems = "wormholeSystems",
@@ -25,8 +30,8 @@ export type HTMLString = string
type ExpandType<T> = unknown extends T type ExpandType<T> = unknown extends T
? T extends unknown ? T extends unknown
? { expand?: unknown } ? { expand?: unknown }
: { expand: T } : { expand: T }
: { expand: T } : { expand: T }
// System fields // System fields
@@ -94,6 +99,74 @@ export type SuperusersRecord = {
verified?: boolean verified?: boolean
} }
export type IndBillitemRecord = {
created?: IsoDateString
id: string
name: string
quantity: number
updated?: IsoDateString
}
export type IndCharRecord = {
created?: IsoDateString
id: string
name: string
updated?: IsoDateString
}
export enum IndJobStatusOptions {
"Planned" = "Planned",
"Acquisition" = "Acquisition",
"Running" = "Running",
"Done" = "Done",
"Selling" = "Selling",
"Closed" = "Closed",
"Tracked" = "Tracked",
"Staging" = "Staging",
"Inbound" = "Inbound",
"Outbound" = "Outbound",
"Delivered" = "Delivered",
"Queued" = "Queued",
}
export type IndJobRecord = {
billOfMaterials?: RecordIdString[]
character?: RecordIdString
consumedMaterials?: RecordIdString[]
created?: IsoDateString
expenditures?: RecordIdString[]
id: string
income?: RecordIdString[]
jobEnd?: IsoDateString
jobStart?: IsoDateString
outputItem: string
outputQuantity: number
parallel?: number
produced?: number
projectedCost?: number
projectedRevenue?: number
runtime?: number
saleEnd?: IsoDateString
saleStart?: IsoDateString
status: IndJobStatusOptions
updated?: IsoDateString
}
export type IndTransactionRecord = {
buyer?: string
corporation?: string
created?: IsoDateString
date: IsoDateString
id: string
itemName: string
job?: RecordIdString
location?: string
quantity: number
totalPrice: number
unitPrice: number
updated?: IsoDateString
wallet?: string
}
export type RegionviewRecord = { export type RegionviewRecord = {
id: string id: string
sigcount?: number sigcount?: number
@@ -114,6 +187,15 @@ export type SignatureRecord = {
updated?: IsoDateString updated?: IsoDateString
} }
export type SignatureNoteRulesRecord = {
created?: IsoDateString
enabled?: boolean
id: string
note: string
regex: string
updated?: IsoDateString
}
export type SigviewRecord = { export type SigviewRecord = {
created?: IsoDateString created?: IsoDateString
dangerous?: boolean dangerous?: boolean
@@ -153,8 +235,13 @@ export type ExternalauthsResponse<Texpand = unknown> = Required<ExternalauthsRec
export type MfasResponse<Texpand = unknown> = Required<MfasRecord> & BaseSystemFields<Texpand> export type MfasResponse<Texpand = unknown> = Required<MfasRecord> & BaseSystemFields<Texpand>
export type OtpsResponse<Texpand = unknown> = Required<OtpsRecord> & BaseSystemFields<Texpand> export type OtpsResponse<Texpand = unknown> = Required<OtpsRecord> & BaseSystemFields<Texpand>
export type SuperusersResponse<Texpand = unknown> = Required<SuperusersRecord> & AuthSystemFields<Texpand> export type SuperusersResponse<Texpand = unknown> = Required<SuperusersRecord> & AuthSystemFields<Texpand>
export type IndBillitemResponse<Texpand = unknown> = Required<IndBillitemRecord> & BaseSystemFields<Texpand>
export type IndCharResponse<Texpand = unknown> = Required<IndCharRecord> & BaseSystemFields<Texpand>
export type IndJobResponse<Texpand = unknown> = Required<IndJobRecord> & BaseSystemFields<Texpand>
export type IndTransactionResponse<Texpand = unknown> = Required<IndTransactionRecord> & BaseSystemFields<Texpand>
export type RegionviewResponse<Texpand = unknown> = Required<RegionviewRecord> & BaseSystemFields<Texpand> export type RegionviewResponse<Texpand = unknown> = Required<RegionviewRecord> & BaseSystemFields<Texpand>
export type SignatureResponse<Texpand = unknown> = Required<SignatureRecord> & BaseSystemFields<Texpand> export type SignatureResponse<Texpand = unknown> = Required<SignatureRecord> & BaseSystemFields<Texpand>
export type SignatureNoteRulesResponse<Texpand = unknown> = Required<SignatureNoteRulesRecord> & BaseSystemFields<Texpand>
export type SigviewResponse<Texpand = unknown> = Required<SigviewRecord> & BaseSystemFields<Texpand> export type SigviewResponse<Texpand = unknown> = Required<SigviewRecord> & BaseSystemFields<Texpand>
export type SystemResponse<Texpand = unknown> = Required<SystemRecord> & BaseSystemFields<Texpand> export type SystemResponse<Texpand = unknown> = Required<SystemRecord> & BaseSystemFields<Texpand>
export type WormholeSystemsResponse<Texpand = unknown> = Required<WormholeSystemsRecord> & BaseSystemFields<Texpand> export type WormholeSystemsResponse<Texpand = unknown> = Required<WormholeSystemsRecord> & BaseSystemFields<Texpand>
@@ -167,8 +254,13 @@ export type CollectionRecords = {
_mfas: MfasRecord _mfas: MfasRecord
_otps: OtpsRecord _otps: OtpsRecord
_superusers: SuperusersRecord _superusers: SuperusersRecord
ind_billItem: IndBillitemRecord
ind_char: IndCharRecord
ind_job: IndJobRecord
ind_transaction: IndTransactionRecord
regionview: RegionviewRecord regionview: RegionviewRecord
signature: SignatureRecord signature: SignatureRecord
signature_note_rules: SignatureNoteRulesRecord
sigview: SigviewRecord sigview: SigviewRecord
system: SystemRecord system: SystemRecord
wormholeSystems: WormholeSystemsRecord wormholeSystems: WormholeSystemsRecord
@@ -180,8 +272,13 @@ export type CollectionResponses = {
_mfas: MfasResponse _mfas: MfasResponse
_otps: OtpsResponse _otps: OtpsResponse
_superusers: SuperusersResponse _superusers: SuperusersResponse
ind_billItem: IndBillitemResponse
ind_char: IndCharResponse
ind_job: IndJobResponse
ind_transaction: IndTransactionResponse
regionview: RegionviewResponse regionview: RegionviewResponse
signature: SignatureResponse signature: SignatureResponse
signature_note_rules: SignatureNoteRulesResponse
sigview: SigviewResponse sigview: SigviewResponse
system: SystemResponse system: SystemResponse
wormholeSystems: WormholeSystemsResponse wormholeSystems: WormholeSystemsResponse
@@ -196,8 +293,13 @@ export type TypedPocketBase = PocketBase & {
collection(idOrName: '_mfas'): RecordService<MfasResponse> collection(idOrName: '_mfas'): RecordService<MfasResponse>
collection(idOrName: '_otps'): RecordService<OtpsResponse> collection(idOrName: '_otps'): RecordService<OtpsResponse>
collection(idOrName: '_superusers'): RecordService<SuperusersResponse> collection(idOrName: '_superusers'): RecordService<SuperusersResponse>
collection(idOrName: 'ind_billItem'): RecordService<IndBillitemResponse>
collection(idOrName: 'ind_char'): RecordService<IndCharResponse>
collection(idOrName: 'ind_job'): RecordService<IndJobResponse>
collection(idOrName: 'ind_transaction'): RecordService<IndTransactionResponse>
collection(idOrName: 'regionview'): RecordService<RegionviewResponse> collection(idOrName: 'regionview'): RecordService<RegionviewResponse>
collection(idOrName: 'signature'): RecordService<SignatureResponse> collection(idOrName: 'signature'): RecordService<SignatureResponse>
collection(idOrName: 'signature_note_rules'): RecordService<SignatureNoteRulesResponse>
collection(idOrName: 'sigview'): RecordService<SigviewResponse> collection(idOrName: 'sigview'): RecordService<SigviewResponse>
collection(idOrName: 'system'): RecordService<SystemResponse> collection(idOrName: 'system'): RecordService<SystemResponse>
collection(idOrName: 'wormholeSystems'): RecordService<WormholeSystemsResponse> collection(idOrName: 'wormholeSystems'): RecordService<WormholeSystemsResponse>

View File

@@ -1,5 +1,5 @@
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client';
import App from './App.tsx' import App from './App.tsx';
import './index.css' import './index.css';
createRoot(document.getElementById("root")!).render(<App />); createRoot(document.getElementById("root")!).render(<App />);

View File

@@ -1,4 +1,4 @@
import { useParams } from 'react-router-dom'; import { useParams, useSearchParams } from 'react-router-dom';
import { RegionMap } from '@/components/RegionMap'; import { RegionMap } from '@/components/RegionMap';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { ArrowLeft } from 'lucide-react'; import { ArrowLeft } from 'lucide-react';
@@ -6,15 +6,17 @@ import { useNavigate } from 'react-router-dom';
export const RegionPage = () => { export const RegionPage = () => {
const { region } = useParams<{ region: string }>(); const { region } = useParams<{ region: string }>();
const [sp] = useSearchParams();
const focus = sp.get('focus') || undefined;
const navigate = useNavigate(); const navigate = useNavigate();
if (!region) { if (!region) {
return ( return (
<div className="h-screen w-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 flex items-center justify-center overflow-hidden"> <div className="h-screen w-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 flex items-center justify-center overflow-hidden">
<div className="text-center"> <div className="text-center">
<h1 className="text-4xl font-bold text-white mb-4">Region Not Found</h1> <h1 className="text-4xl font-bold text-white mb-4">Region Not Found</h1>
<p className="text-purple-200 mb-6">The requested region does not exist in our database.</p> <p className="text-purple-200 mb-6">The requested region does not exist in our database.</p>
<Button <Button
onClick={() => navigate('/')} onClick={() => navigate('/')}
className="bg-purple-600 hover:bg-purple-700" className="bg-purple-600 hover:bg-purple-700"
> >
@@ -28,9 +30,10 @@ export const RegionPage = () => {
return ( return (
<div className="h-screen w-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 overflow-hidden"> <div className="h-screen w-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 overflow-hidden">
<RegionMap <RegionMap
regionName={region} regionName={region}
isWormholeRegion={region === "Wormhole"} isWormholeRegion={region === "Wormhole"}
focusSystem={focus}
/> />
</div> </div>
); );

View File

@@ -0,0 +1,124 @@
import { useEffect, useState } from 'react';
import pb from '@/lib/pocketbase';
import { Header } from '@/components/Header';
import { Button } from '@/components/ui/button';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import { toast } from '@/hooks/use-toast';
import { SignatureNoteRulesResponse, Collections } from '@/lib/pbtypes';
export const SignatureRules = () => {
const [rules, setRules] = useState<SignatureNoteRulesResponse[]>([]);
const [loading, setLoading] = useState(false);
const [creating, setCreating] = useState({ regex: '', note: '' });
const load = async () => {
setLoading(true);
try {
const list = await pb.collection(Collections.SignatureNoteRules).getFullList<SignatureNoteRulesResponse>({ batch: 1000, sort: '-updated' });
setRules(list);
} catch (e: any) {
toast({ title: 'Load failed', description: String(e), variant: 'destructive' });
} finally {
setLoading(false);
}
};
useEffect(() => { load(); }, []);
const handleCreate = async () => {
if (!creating.regex.trim() || !creating.note.trim()) return;
try {
await pb.collection(Collections.SignatureNoteRules).create({ regex: creating.regex.trim(), note: creating.note.trim(), enabled: true });
setCreating({ regex: '', note: '' });
await load();
toast({ title: 'Rule added', description: 'New rule created.' });
} catch (e: any) {
toast({ title: 'Create failed', description: String(e), variant: 'destructive' });
}
};
const handleUpdate = async (id: string, patch: Partial<SignatureNoteRulesResponse>) => {
try {
await pb.collection(Collections.SignatureNoteRules).update(id, patch);
await load();
} catch (e: any) {
toast({ title: 'Update failed', description: String(e), variant: 'destructive' });
}
};
const handleDelete = async (id: string) => {
try {
await pb.collection(Collections.SignatureNoteRules).delete(id);
await load();
toast({ title: 'Rule deleted' });
} catch (e: any) {
toast({ title: 'Delete failed', description: String(e), variant: 'destructive' });
}
};
return (
<div className="h-screen w-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 overflow-hidden">
<div className="h-full flex flex-col">
<Header title="Signature Rules" breadcrumbs={[{ label: 'Universe', path: '/' }, { label: 'Signature Rules' }]} />
<div className="flex-1 overflow-auto p-4 space-y-4">
<div className="bg-black/20 border border-purple-500/30 rounded p-4 space-y-3">
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<Input
type="text"
placeholder="Regex (e.g. ^Angel.*Outpost$|Guristas.*)"
value={creating.regex}
onChange={e => setCreating({ ...creating, regex: e.target.value })}
className="font-mono"
/>
<Input placeholder="Note/Tag (e.g. 3/10)" value={creating.note} onChange={e => setCreating({ ...creating, note: e.target.value })} />
<Button onClick={handleCreate} disabled={loading}>Add Rule</Button>
</div>
</div>
<div className="bg-black/20 border border-purple-500/30 rounded">
<Table>
<TableHeader>
<TableRow>
<TableHead className="text-slate-300">Enabled</TableHead>
<TableHead className="text-slate-300">Regex</TableHead>
<TableHead className="text-slate-300">Note</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rules.map(r => (
<TableRow key={r.id}>
<TableCell>
<Switch checked={!!r.enabled} onCheckedChange={(v) => handleUpdate(r.id, { enabled: v })} />
</TableCell>
<TableCell className="max-w-0">
<Input
type="text"
value={r.regex}
onChange={e => setRules(prev => prev.map(x => x.id === r.id ? { ...x, regex: e.target.value } : x))}
onBlur={e => handleUpdate(r.id, { regex: e.currentTarget.value })}
className="font-mono"
/>
</TableCell>
<TableCell className="max-w-0">
<Input
value={r.note}
onChange={e => setRules(prev => prev.map(x => x.id === r.id ? { ...x, note: e.target.value } : x))}
onBlur={e => handleUpdate(r.id, { note: e.currentTarget.value })}
/>
</TableCell>
<TableCell className="text-right">
<Button variant="destructive" onClick={() => handleDelete(r.id)}>Delete</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</div>
</div>
);
};

View File

@@ -8,7 +8,7 @@ import { Header } from "@/components/Header";
import { parseSignature, parseScannedPercentage } from "@/utils/signatureParser"; import { parseSignature, parseScannedPercentage } from "@/utils/signatureParser";
import { getSystemId } from "@/utils/systemApi"; import { getSystemId } from "@/utils/systemApi";
import pb from "@/lib/pocketbase"; import pb from "@/lib/pocketbase";
import { SigviewRecord as Signature, SignatureRecord } from "@/lib/pbtypes"; import { SigviewRecord as Signature, SignatureRecord, SignatureNoteRulesResponse, Collections } from "@/lib/pbtypes";
export const SystemView = () => { export const SystemView = () => {
const { system, region } = useParams(); const { system, region } = useParams();
@@ -163,12 +163,17 @@ export const SystemView = () => {
try { try {
const systemId = await getSystemId(system); const systemId = await getSystemId(system);
let rules: Array<Pick<SignatureNoteRulesResponse, 'regex' | 'note' | 'enabled'>> = [];
try {
const list = await pb.collection(Collections.SignatureNoteRules).getFullList<SignatureNoteRulesResponse>({ batch: 1000 });
rules = list.filter(r => r.enabled).map(r => ({ regex: r.regex, note: r.note, enabled: r.enabled }));
} catch { }
const lines = pastedText.trim().split('\n').filter(line => line.trim()); const lines = pastedText.trim().split('\n').filter(line => line.trim());
const parsedSignatures: Omit<Signature, 'id'>[] = []; const parsedSignatures: Omit<Signature, 'id'>[] = [];
// Parse all signatures // Parse all signatures
for (const line of lines) { for (const line of lines) {
const parsed = parseSignature(line); const parsed = parseSignature(line, rules);
if (parsed) { if (parsed) {
parsedSignatures.push({ parsedSignatures.push({
...parsed, ...parsed,
@@ -276,6 +281,7 @@ export const SystemView = () => {
regionName={region} regionName={region}
focusSystem={system} focusSystem={system}
isCompact={true} isCompact={true}
header={false}
/> />
</div> </div>
</div> </div>

View File

@@ -1,7 +1,7 @@
export const getSecurityColor = (security: number): string => { export const getSecurityColor = (security: number): string => {
// Clamp security between -1 and 1 // Clamp security between -1 and 1
const clampedSecurity = Math.max(-1, Math.min(1, security)); const clampedSecurity = Math.max(-1, Math.min(1, security));
// Define color points for specific security values // Define color points for specific security values
const colorPoints = [ const colorPoints = [
{ sec: -1.0, color: [75, 0, 130] }, // Dark purple (same as 0.1) { sec: -1.0, color: [75, 0, 130] }, // Dark purple (same as 0.1)
@@ -31,7 +31,7 @@ export const getSecurityColor = (security: number): string => {
// Calculate the ratio between the two points // Calculate the ratio between the two points
const ratio = (clampedSecurity - lowerPoint.sec) / (upperPoint.sec - lowerPoint.sec); const ratio = (clampedSecurity - lowerPoint.sec) / (upperPoint.sec - lowerPoint.sec);
// Interpolate between the colors // Interpolate between the colors
const red = Math.round(lowerPoint.color[0] + (upperPoint.color[0] - lowerPoint.color[0]) * ratio); const red = Math.round(lowerPoint.color[0] + (upperPoint.color[0] - lowerPoint.color[0]) * ratio);
const green = Math.round(lowerPoint.color[1] + (upperPoint.color[1] - lowerPoint.color[1]) * ratio); const green = Math.round(lowerPoint.color[1] + (upperPoint.color[1] - lowerPoint.color[1]) * ratio);

View File

@@ -1,52 +1,7 @@
import { SigviewRecord as Signature } from "@/lib/pbtypes"; import { SigviewRecord as Signature, SignatureNoteRulesResponse } from "@/lib/pbtypes";
const oneOutOfTen = [
"Minmatar Contracted Bio-Farm",
"Old Meanie - Cultivation Center",
"Pith Robux Asteroid Mining & Co.",
"Sansha Military Outpost",
"Serpentis Drug Outlet",
];
const twoOutOfTen = [
"Angel Creo-Corp Mining",
"Blood Raider Human Farm",
"Pith Merchant Depot",
"Sansha Acclimatization Facility",
"Serpentis Live Cargo Distribution Facilities",
"Rogue Drone Infestation Sprout",
];
const threeOutOfTen = [
"Angel Repurposed Outpost",
"Blood Raider Intelligence Collection Point",
"Guristas Guerilla Grounds",
"Sansha's Command Relay Outpost",
"Serpentis Narcotic Warehouses",
"Rogue Drone Asteroid Infestation",
];
const fourOutOfTen = [
"Angel Cartel Occupied Mining Colony",
"Mul-Zatah Monastery",
"Guristas Scout Outpost",
"Sansha's Nation Occupied Mining Colony",
"Serpentis Phi-Outpost",
"Drone Infested Mine",
];
const fiveOutOfTen = [
"Angel's Red Light District",
"Blood Raider Psychotropics Depot",
"Guristas Hallucinogen Supply Waypoint",
"Sansha's Nation Neural Paralytic Facility",
"Serpentis Corporation Hydroponics Site",
"Outgrowth Rogue Drone Hive",
];
function isFourOutOfTen(signature: string): boolean {
return fourOutOfTen.some((s) => signature.includes(s));
}
function isFiveOutOfTen(signature: string): boolean {
return fiveOutOfTen.some((s) => signature.includes(s));
}
export const parseSignature = (text: string): Omit<Signature, 'system' | 'id' | 'sysid'> | null => { export const parseSignature = (text: string, rules?: Array<Pick<SignatureNoteRulesResponse, 'regex' | 'note' | 'enabled'>>): Omit<Signature, 'system' | 'id' | 'sysid'> | null => {
const parts = text.split('\t'); const parts = text.split('\t');
if (parts.length < 4) return null; if (parts.length < 4) return null;
@@ -56,16 +11,26 @@ export const parseSignature = (text: string): Omit<Signature, 'system' | 'id' |
return null; return null;
} }
let note = ""; const appliedNotes: string[] = [];
const isFour = isFourOutOfTen(parts[3]);
if (isFour) { if (rules && rules.length > 0) {
note = "4/10"; for (const rule of rules) {
} if (rule && rule.enabled) {
const isFive = isFiveOutOfTen(parts[3]); try {
if (isFive) { const re = new RegExp(rule.regex, 'i');
note = "5/10"; if (re.test(parts[3])) {
appliedNotes.push(rule.note);
}
} catch {
// invalid regex - ignore
}
}
}
} }
const dedupedNotes = Array.from(new Set(appliedNotes)).filter(Boolean);
const note = dedupedNotes.join(';');
return { return {
identifier: parts[0], identifier: parts[0],
type: parts[2], type: parts[2],

View File

@@ -4,3 +4,40 @@ export const getSystemId = async (systemName: string): Promise<string> => {
const system = await pb.collection('regionview').getFirstListItem(`sysname='${systemName}'`); const system = await pb.collection('regionview').getFirstListItem(`sysname='${systemName}'`);
return system.id; 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;
};

1
frontend/wails.json Normal file
View File

@@ -0,0 +1 @@

View File

@@ -8,12 +8,24 @@ export function ESILoggedIn():Promise<boolean>;
export function ESILoginStatus():Promise<string>; export function ESILoginStatus():Promise<string>;
export function GetCharacterLocations():Promise<Array<main.CharacterLocation>>;
export function GetSystemJumps():Promise<Array<main.SystemJumps>>;
export function GetSystemKills():Promise<Array<main.SystemKills>>;
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>>;
export function ListSystemsWithRegions():Promise<Array<main.SystemRegion>>;
export function PostRouteForAllByNames(arg1:string,arg2:Array<string>):Promise<void>; export function PostRouteForAllByNames(arg1:string,arg2:Array<string>):Promise<void>;
export function ResolveSystemIDByName(arg1:string):Promise<number>;
export function SetDestinationForAll(arg1:string,arg2:boolean,arg3:boolean):Promise<void>; export function SetDestinationForAll(arg1:string,arg2:boolean,arg3:boolean):Promise<void>;
export function StartESILogin():Promise<string>; export function StartESILogin():Promise<string>;
export function ToggleCharacterWaypointEnabled(arg1:number):Promise<void>;

View File

@@ -14,6 +14,18 @@ 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 GetSystemJumps() {
return window['go']['main']['App']['GetSystemJumps']();
}
export function GetSystemKills() {
return window['go']['main']['App']['GetSystemKills']();
}
export function Greet(arg1) { export function Greet(arg1) {
return window['go']['main']['App']['Greet'](arg1); return window['go']['main']['App']['Greet'](arg1);
} }
@@ -22,10 +34,18 @@ export function ListCharacters() {
return window['go']['main']['App']['ListCharacters'](); return window['go']['main']['App']['ListCharacters']();
} }
export function ListSystemsWithRegions() {
return window['go']['main']['App']['ListSystemsWithRegions']();
}
export function PostRouteForAllByNames(arg1, arg2) { export function PostRouteForAllByNames(arg1, arg2) {
return window['go']['main']['App']['PostRouteForAllByNames'](arg1, arg2); return window['go']['main']['App']['PostRouteForAllByNames'](arg1, arg2);
} }
export function ResolveSystemIDByName(arg1) {
return window['go']['main']['App']['ResolveSystemIDByName'](arg1);
}
export function SetDestinationForAll(arg1, arg2, arg3) { export function SetDestinationForAll(arg1, arg2, arg3) {
return window['go']['main']['App']['SetDestinationForAll'](arg1, arg2, arg3); return window['go']['main']['App']['SetDestinationForAll'](arg1, arg2, arg3);
} }
@@ -33,3 +53,7 @@ export function SetDestinationForAll(arg1, arg2, arg3) {
export function StartESILogin() { export function StartESILogin() {
return window['go']['main']['App']['StartESILogin'](); return window['go']['main']['App']['StartESILogin']();
} }
export function ToggleCharacterWaypointEnabled(arg1) {
return window['go']['main']['App']['ToggleCharacterWaypointEnabled'](arg1);
}

View File

@@ -3,6 +3,7 @@ export namespace main {
export class CharacterInfo { export class CharacterInfo {
character_id: number; character_id: number;
character_name: string; character_name: string;
waypoint_enabled: boolean;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new CharacterInfo(source); return new CharacterInfo(source);
@@ -12,6 +13,92 @@ export namespace main {
if ('string' === typeof source) source = JSON.parse(source); if ('string' === typeof source) source = JSON.parse(source);
this.character_id = source["character_id"]; this.character_id = source["character_id"];
this.character_name = source["character_name"]; this.character_name = source["character_name"];
this.waypoint_enabled = source["waypoint_enabled"];
}
}
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;
}
}
export class SystemJumps {
system_id: number;
ship_jumps: number;
static createFrom(source: any = {}) {
return new SystemJumps(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.system_id = source["system_id"];
this.ship_jumps = source["ship_jumps"];
}
}
export class SystemKills {
system_id: number;
ship_kills: number;
pod_kills: number;
npc_kills: number;
static createFrom(source: any = {}) {
return new SystemKills(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.system_id = source["system_id"];
this.ship_kills = source["ship_kills"];
this.pod_kills = source["pod_kills"];
this.npc_kills = source["npc_kills"];
}
}
export class SystemRegion {
system: string;
region: string;
static createFrom(source: any = {}) {
return new SystemRegion(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.system = source["system"];
this.region = source["region"];
} }
} }

BIN
signalerr.exe Normal file

Binary file not shown.