Compare commits
7 Commits
90b190b8d5
...
22ef386ea2
Author | SHA1 | Date | |
---|---|---|---|
22ef386ea2 | |||
51179485a1 | |||
11fda4e11f | |||
7af7d9ecd0 | |||
97178bc9a5 | |||
2561cd7d30 | |||
9c40135102 |
22
app.go
22
app.go
@@ -138,3 +138,25 @@ func (a *App) GetCharacterLocations() ([]CharacterLocation, error) {
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
return a.ssi.GetCharacterLocations(ctx)
|
return a.ssi.GetCharacterLocations(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
@@ -6,6 +6,7 @@ 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";
|
||||||
|
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
@@ -21,6 +22,7 @@ function App() {
|
|||||||
<Route path="*" element={<NotFound />} />
|
<Route path="*" element={<NotFound />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
|
<SearchDialog />
|
||||||
</Router>
|
</Router>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
|
@@ -98,6 +98,25 @@ export const RegionMap = ({ regionName, focusSystem, isCompact = false, isWormho
|
|||||||
const [offRegionIndicators, setOffRegionIndicators] = useState<OffIndicator[]>([]);
|
const [offRegionIndicators, setOffRegionIndicators] = useState<OffIndicator[]>([]);
|
||||||
const [meanNeighborAngle, setMeanNeighborAngle] = useState<Record<string, number>>({});
|
const [meanNeighborAngle, setMeanNeighborAngle] = useState<Record<string, number>>({});
|
||||||
const [charLocs, setCharLocs] = useState<Array<{ character_id: number; character_name: string; solar_system_name: string }>>([]);
|
const [charLocs, setCharLocs] = useState<Array<{ character_id: number; character_name: string; solar_system_name: string }>>([]);
|
||||||
|
const [focusUntil, setFocusUntil] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// 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) => {
|
||||||
@@ -700,27 +719,27 @@ export const RegionMap = ({ regionName, focusSystem, isCompact = false, isWormho
|
|||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Highlight focused system */}
|
{/* Highlight focused system */}
|
||||||
{focusSystem && positions[focusSystem] && (
|
{focusSystem && focusUntil && Date.now() <= focusUntil && positions[focusSystem] && (
|
||||||
|
<g style={{ pointerEvents: 'none' }}>
|
||||||
<circle
|
<circle
|
||||||
cx={positions[focusSystem].x}
|
cx={positions[focusSystem].x}
|
||||||
cy={positions[focusSystem].y}
|
cy={positions[focusSystem].y}
|
||||||
r="15"
|
r="20"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="#a855f7"
|
stroke="#a855f7"
|
||||||
strokeWidth="3"
|
strokeWidth="3"
|
||||||
strokeDasharray="5,5"
|
opacity="0.9"
|
||||||
opacity="0.8"
|
filter="url(#glow)"
|
||||||
>
|
>
|
||||||
<animateTransform
|
<animate
|
||||||
attributeName="transform"
|
attributeName="r"
|
||||||
attributeType="XML"
|
values="18;22;18"
|
||||||
type="rotate"
|
dur="1.5s"
|
||||||
from={`0 ${positions[focusSystem].x} ${positions[focusSystem].y}`}
|
|
||||||
to={`360 ${positions[focusSystem].x} ${positions[focusSystem].y}`}
|
|
||||||
dur="10s"
|
|
||||||
repeatCount="indefinite"
|
repeatCount="indefinite"
|
||||||
/>
|
/>
|
||||||
</circle>
|
</circle>
|
||||||
|
<text x={positions[focusSystem].x + 12} y={positions[focusSystem].y - 10} fontSize="10" fill="#ffffff" stroke="#0f172a" strokeWidth="2" paintOrder="stroke">{focusSystem}</text>
|
||||||
|
</g>
|
||||||
)}
|
)}
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
@@ -739,7 +758,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)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
116
frontend/src/components/SearchDialog.tsx
Normal file
116
frontend/src/components/SearchDialog.tsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
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 } from 'wailsjs/go/main/App';
|
||||||
|
|
||||||
|
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)}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
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={() => onSelect(r)}
|
||||||
|
>
|
||||||
|
<div className="text-sm font-medium">{r.system}</div>
|
||||||
|
<div className="text-xs text-slate-300">{r.region}</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
80
frontend/src/lib/aho.ts
Normal file
80
frontend/src/lib/aho.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
@@ -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,6 +6,8 @@ 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) {
|
||||||
@@ -31,6 +33,7 @@ export const RegionPage = () => {
|
|||||||
<RegionMap
|
<RegionMap
|
||||||
regionName={region}
|
regionName={region}
|
||||||
isWormholeRegion={region === "Wormhole"}
|
isWormholeRegion={region === "Wormhole"}
|
||||||
|
focusSystem={focus}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
1
frontend/wails.json
Normal file
1
frontend/wails.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
2
frontend/wailsjs/go/main/App.d.ts
vendored
2
frontend/wailsjs/go/main/App.d.ts
vendored
@@ -14,6 +14,8 @@ 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 SetDestinationForAll(arg1:string,arg2:boolean,arg3:boolean):Promise<void>;
|
export function SetDestinationForAll(arg1:string,arg2:boolean,arg3:boolean):Promise<void>;
|
||||||
|
@@ -26,6 +26,10 @@ 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);
|
||||||
}
|
}
|
||||||
|
@@ -53,6 +53,20 @@ export namespace main {
|
|||||||
return a;
|
return a;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user