diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2f2c4b3..1b5f8a6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -6,6 +6,7 @@ import { RegionPage } from "./pages/RegionPage"; import { SystemView } from "./pages/SystemView"; import NotFound from "./pages/NotFound"; import "./App.css"; +import { SearchDialog } from "@/components/SearchDialog"; const queryClient = new QueryClient(); @@ -21,6 +22,7 @@ function App() { } /> + ); diff --git a/frontend/src/components/SearchDialog.tsx b/frontend/src/components/SearchDialog.tsx new file mode 100644 index 0000000..c9bf01a --- /dev/null +++ b/frontend/src/components/SearchDialog.tsx @@ -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> { + // 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> = await res.json(); + const out: Array = []; + 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> = 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>([]); + const [results, setResults] = useState>([]); + const inputRef = useRef(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 = []; + 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 ( + + + + Search systems + + + setQuery(e.target.value)} + className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-400" + /> + + {results.length === 0 && query && ( + No results + )} + {results.map((r, idx) => ( + onSelect(r)} + > + {r.system} + {r.region} + + ))} + + + + + ); +}; \ No newline at end of file diff --git a/frontend/src/lib/aho.ts b/frontend/src/lib/aho.ts new file mode 100644 index 0000000..3e54af4 --- /dev/null +++ b/frontend/src/lib/aho.ts @@ -0,0 +1,59 @@ +export class AhoCorasick { + private goto: Array> = [new Map()]; + private out: Array = [false]; + private fail: Array = [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; + } +} \ No newline at end of file