feat(frontend): implement system search dialog with Aho-Corasick algorithm

This commit is contained in:
2025-08-10 22:06:55 +02:00
parent 90b190b8d5
commit 9c40135102
3 changed files with 182 additions and 0 deletions

View 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 AhoCorasick 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>
);
};