From 9c211f4ab69311130d67ea92b3d03acb6049dd34 Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 16 Feb 2026 08:06:36 +0900 Subject: [PATCH] =?UTF-8?q?feat(vesselList):=20=EC=84=A0=EB=B0=95=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EC=A0=84=EC=B2=B4=20=ED=91=9C=EC=8B=9C=20?= =?UTF-8?q?+=20=EA=B2=80=EC=83=89=20=ED=95=84=ED=84=B0=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 80척 제한(slice) 제거, 전체 선박 스크롤 표시 - 헤더에 검색 인풋 추가 (2글자 이상 입력 시 실시간 필터링) - 검색 대상: 등록번호, 선박명, 호출부호, MMSI - 정규화: 대소문자/공백/특수문자 무시 like 매칭 Co-Authored-By: Claude Opus 4.6 --- apps/web/src/app/styles.css | 20 ++++++++++ .../web/src/pages/dashboard/DashboardPage.tsx | 19 +++++++-- .../web/src/widgets/vesselList/VesselList.tsx | 39 +++++++++++++++---- 3 files changed, 67 insertions(+), 11 deletions(-) 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 (