feat(frontend): implement system search dialog with Aho-Corasick algorithm
This commit is contained in:
@@ -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>
|
||||||
);
|
);
|
||||||
|
121
frontend/src/components/SearchDialog.tsx
Normal file
121
frontend/src/components/SearchDialog.tsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
interface SearchResult {
|
||||||
|
system: string;
|
||||||
|
region: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAllSystems(): Promise<Array<SearchResult>> {
|
||||||
|
// Fetch the list of regions from universe.json then fetch each region JSON for systems
|
||||||
|
const res = await fetch('/universe.json');
|
||||||
|
if (!res.ok) return [];
|
||||||
|
const regions: Array<{ regionName: string } & Record<string, any>> = await res.json();
|
||||||
|
const out: Array<SearchResult> = [];
|
||||||
|
await Promise.all(regions.map(async (r) => {
|
||||||
|
try {
|
||||||
|
const rr = await fetch(`/${encodeURIComponent(r.regionName)}.json`);
|
||||||
|
if (!rr.ok) return;
|
||||||
|
const systems: Array<{ solarSystemName: string } & Record<string, any>> = await rr.json();
|
||||||
|
for (const s of systems) {
|
||||||
|
out.push({ system: s.solarSystemName, region: r.regionName });
|
||||||
|
}
|
||||||
|
} catch (_) { /* noop */ }
|
||||||
|
}));
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Build AC automaton of single query to enable contains search efficiently (case-insensitive)
|
||||||
|
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) {
|
||||||
|
// lazy load once
|
||||||
|
loadAllSystems().then(setAll);
|
||||||
|
}
|
||||||
|
}, [open, all.length]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (query.trim().length === 0) { setResults([]); return; }
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
const out: Array<SearchResult> = [];
|
||||||
|
for (const r of all) {
|
||||||
|
// contains via Aho–Corasick over the single query pattern
|
||||||
|
if (automaton.searchHas(r.system.toLowerCase())) {
|
||||||
|
out.push(r);
|
||||||
|
if (out.length >= 10) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setResults(out);
|
||||||
|
}, [query, all, automaton]);
|
||||||
|
|
||||||
|
const onSelect = (r: SearchResult) => {
|
||||||
|
setOpen(false);
|
||||||
|
setQuery('');
|
||||||
|
navigate(`/regions/${encodeURIComponent(r.region)}/${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>
|
||||||
|
);
|
||||||
|
};
|
59
frontend/src/lib/aho.ts
Normal file
59
frontend/src/lib/aho.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
export class AhoCorasick {
|
||||||
|
private goto: Array<Map<string, number>> = [new Map()];
|
||||||
|
private out: Array<boolean> = [false];
|
||||||
|
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.fail.push(0);
|
||||||
|
state = newState;
|
||||||
|
} else {
|
||||||
|
state = next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.out[state] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
build() {
|
||||||
|
const queue: number[] = [];
|
||||||
|
// Initialize depth 1 states
|
||||||
|
for (const [ch, s] of this.goto[0]) {
|
||||||
|
this.fail[s] = 0;
|
||||||
|
queue.push(s);
|
||||||
|
}
|
||||||
|
// BFS
|
||||||
|
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];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
Reference in New Issue
Block a user