diff --git a/apps/web/src/app/styles.css b/apps/web/src/app/styles.css index 462325f..bdb121c 100644 --- a/apps/web/src/app/styles.css +++ b/apps/web/src/app/styles.css @@ -223,6 +223,26 @@ body { overflow-y: auto; } +.vessel-search-input { + flex: 1; + min-width: 0; + height: 20px; + padding: 2px 6px; + border: 1px solid var(--border); + border-radius: 3px; + background: var(--card); + color: var(--fg); + font-size: 9px; + outline: none; +} +.vessel-search-input::placeholder { + color: var(--muted); + opacity: 0.6; +} +.vessel-search-input:focus { + border-color: var(--accent); +} + .vi { display: flex; align-items: center; diff --git a/apps/web/src/pages/dashboard/DashboardPage.tsx b/apps/web/src/pages/dashboard/DashboardPage.tsx index 87e5bdb..ca79106 100644 --- a/apps/web/src/pages/dashboard/DashboardPage.tsx +++ b/apps/web/src/pages/dashboard/DashboardPage.tsx @@ -118,6 +118,7 @@ export function DashboardPage() { const [baseMap, _setBaseMap] = useState("enhanced"); const [projection, setProjection] = useState("mercator"); const [mapStyleSettings, setMapStyleSettings] = useState(DEFAULT_MAP_STYLE_SETTINGS); + const [vesselSearchQuery, setVesselSearchQuery] = useState(""); const [overlays, setOverlays] = useState({ pairLines: true, @@ -445,14 +446,24 @@ export function DashboardPage() {
-
- 선박 목록{" "} - - ({legacyVesselsFiltered.length}척) +
+ + 선박 목록{" "} + + ({legacyVesselsFiltered.length}척) + + setVesselSearchQuery(e.target.value)} + />
void; @@ -12,8 +14,29 @@ type Props = { onClearHover?: () => void; }; +function isFiniteNumber(x: unknown): x is number { + return typeof x === "number" && Number.isFinite(x); +} + +const STRIP_RE = /[\s\-.,_]/g; + +function normalize(s: string): string { + return s.replace(STRIP_RE, "").toLowerCase(); +} + +function matchesQuery(v: DerivedLegacyVessel, normalizedQuery: string): boolean { + if (normalize(v.permitNo).includes(normalizedQuery)) return true; + if (normalize(v.name).includes(normalizedQuery)) return true; + if (v.legacy.shipNameRoman && normalize(v.legacy.shipNameRoman).includes(normalizedQuery)) return true; + if (v.legacy.shipNameCn && normalize(v.legacy.shipNameCn).includes(normalizedQuery)) return true; + if (v.legacy.callSign && normalize(v.legacy.callSign).includes(normalizedQuery)) return true; + if (v.mmsi && normalize(String(v.mmsi)).includes(normalizedQuery)) return true; + return false; +} + export function VesselList({ vessels, + searchQuery, selectedMmsi, highlightedMmsiSet = [], onToggleHighlightMmsi, @@ -29,14 +52,16 @@ export function VesselList({ onSelectMmsi(mmsi); }; - function isFiniteNumber(x: unknown): x is number { - return typeof x === "number" && Number.isFinite(x); -} + const sorted = useMemo(() => { + const normalizedQuery = searchQuery.length >= 2 ? normalize(searchQuery) : ""; + const filtered = normalizedQuery + ? vessels.filter((v) => matchesQuery(v, normalizedQuery)) + : vessels; - const sorted = vessels - .slice() - .sort((a, b) => (isFiniteNumber(b.sog) ? b.sog : -1) - (isFiniteNumber(a.sog) ? a.sog : -1)) - .slice(0, 80); + return filtered + .slice() + .sort((a, b) => (isFiniteNumber(b.sog) ? b.sog : -1) - (isFiniteNumber(a.sog) ? a.sog : -1)); + }, [vessels, searchQuery]); return (