From 383b41f49a8a86d59908f972504084195e1593cd Mon Sep 17 00:00:00 2001 From: htlee Date: Tue, 17 Feb 2026 08:12:14 +0900 Subject: [PATCH] =?UTF-8?q?fix(sidebar):=20=EA=B2=80=EC=83=89=20=EB=B3=B5?= =?UTF-8?q?=EC=9B=90,=20=EA=B2=BD=EA=B3=A0=20=ED=95=84=ED=84=B0,=20?= =?UTF-8?q?=EB=82=B4=EB=B6=80=20=EC=8A=A4=ED=81=AC=EB=A1=A4=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(#25)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../web/src/pages/dashboard/DashboardPage.tsx | 4 - .../src/pages/dashboard/DashboardSidebar.tsx | 104 +++++++----------- apps/web/src/widgets/topbar/Topbar.tsx | 11 +- .../web/src/widgets/vesselList/VesselList.tsx | 49 +++++++-- packages/ui/src/components/ListItem.tsx | 6 +- packages/ui/src/components/Section.tsx | 25 +++-- packages/ui/src/theme/tokens.css | 8 ++ 7 files changed, 114 insertions(+), 93 deletions(-) diff --git a/apps/web/src/pages/dashboard/DashboardPage.tsx b/apps/web/src/pages/dashboard/DashboardPage.tsx index 4a011c0..850406f 100644 --- a/apps/web/src/pages/dashboard/DashboardPage.tsx +++ b/apps/web/src/pages/dashboard/DashboardPage.tsx @@ -228,8 +228,6 @@ export function DashboardPage() { const enabledTypes = useMemo(() => VESSEL_TYPE_ORDER.filter((c) => typeEnabled[c]), [typeEnabled]); const speedPanelType = (selectedLegacyVessel?.shipCode ?? (enabledTypes.length === 1 ? enabledTypes[0] : "PT")) as VesselTypeCode; - const alarmFilterSummary = allAlarmKindsEnabled ? "전체" : `${enabledAlarmKinds.length}/${LEGACY_ALARM_KINDS.length}`; - const handleFleetContextMenu = (ownerKey: string, mmsis: number[]) => { if (!mmsis.length) return; const members = mmsis @@ -279,8 +277,6 @@ export function DashboardPage() { filteredAlarms={filteredAlarms} alarms={alarms} alarmKindCounts={alarmKindCounts} - allAlarmKindsEnabled={allAlarmKindsEnabled} - alarmFilterSummary={alarmFilterSummary} speedPanelType={speedPanelType} onFleetContextMenu={handleFleetContextMenu} snapshot={snapshot} diff --git a/apps/web/src/pages/dashboard/DashboardSidebar.tsx b/apps/web/src/pages/dashboard/DashboardSidebar.tsx index d0841c4..0f1f645 100644 --- a/apps/web/src/pages/dashboard/DashboardSidebar.tsx +++ b/apps/web/src/pages/dashboard/DashboardSidebar.tsx @@ -1,4 +1,4 @@ -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { ToggleButton, Section } from '@wing/ui'; import type { AisTarget } from '../../entities/aisTarget/model/types'; import type { LegacyVesselIndex } from '../../entities/legacyVessel/lib'; @@ -41,8 +41,6 @@ interface DashboardSidebarProps { filteredAlarms: LegacyAlarm[]; alarms: LegacyAlarm[]; alarmKindCounts: Record; - allAlarmKindsEnabled: boolean; - alarmFilterSummary: string; speedPanelType: VesselTypeCode; onFleetContextMenu: (ownerKey: string, mmsis: number[]) => void; snapshot: AisPollingSnapshot; @@ -67,8 +65,6 @@ export function DashboardSidebar({ filteredAlarms, alarms, alarmKindCounts, - allAlarmKindsEnabled, - alarmFilterSummary, speedPanelType, onFleetContextMenu, snapshot, @@ -97,6 +93,8 @@ export function DashboardSidebar({ setUniqueSorted, setSortedIfChanged, toggleHighlightedMmsi, } = state; + const [isAlarmFilterOpen, setIsAlarmFilterOpen] = useState(false); + useEffect(() => { if (!isOpen) return; const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; @@ -115,12 +113,12 @@ export function DashboardSidebar({ fixed inset-y-0 left-0 z-[1200] w-[310px] max-w-[100vw] transform overflow-y-auto border-r border-wing-border bg-wing-surface transition-transform duration-200 ${isOpen ? 'translate-x-0' : '-translate-x-full'} - md:static md:z-auto md:translate-x-0 md:transition-none + md:static md:z-auto md:translate-x-0 md:transition-none md:overflow-hidden md:flex md:flex-col `} >
-
+
setProjection((p) => (p === 'globe' ? 'mercator' : 'globe'))} title={isProjectionToggleDisabled ? '3D 모드 준비 중...' : '3D 지구본 투영'} - className="px-2 py-0.5 text-[9px]" - style={{ opacity: isProjectionToggleDisabled ? 0.4 : 1, cursor: isProjectionToggleDisabled ? 'not-allowed' : 'pointer' }} + className={`px-2 py-0.5 text-[9px]${isProjectionToggleDisabled ? " opacity-40 cursor-not-allowed" : ""}`} > 3D @@ -173,7 +171,7 @@ export function DashboardSidebar({ setOverlays((prev) => ({ ...prev, [k]: !prev[k] }))} />
-
+
@@ -198,7 +196,8 @@ export function DashboardSidebar({
} - className="max-h-[260px] flex flex-col overflow-hidden [&>div:last-child]:overflow-y-auto [&>div:last-child]:min-h-0" + className="md:shrink-0 max-h-[260px] flex flex-col overflow-hidden" + contentClassName="flex-1 min-h-0 overflow-y-auto" > ({legacyVesselsFiltered.length}척) } - className="flex-1 min-h-0 flex flex-col [&>div:last-child]:overflow-y-auto [&>div:last-child]:min-h-0 [&>div:last-child]:flex-1" + className="md:flex-1 md:min-h-0 flex flex-col overflow-hidden" + contentClassName="md:flex-1 md:min-h-0 flex flex-col overflow-hidden" > } actions={ - LEGACY_ALARM_KINDS.length <= 3 ? ( -
- {LEGACY_ALARM_KINDS.map((k) => ( - - ))} -
- ) : ( -
- {alarmFilterSummary} -
- -
- {LEGACY_ALARM_KINDS.map((k) => ( - - ))} -
-
- ) + setIsAlarmFilterOpen((v) => !v)}> + 필터 + } - className="max-h-[130px] flex flex-col overflow-visible [&>div:last-child]:overflow-y-auto [&>div:last-child]:min-h-0 [&>div:last-child]:flex-1" + className="md:shrink-0 max-h-[180px] flex flex-col overflow-hidden" + contentClassName="flex-1 min-h-0 overflow-y-auto" > + {isAlarmFilterOpen && ( +
+ {LEGACY_ALARM_KINDS.map((k) => ( + + ))} +
+ )} {adminMode ? ( <> -
+
엔드포인트
{AIS_API_BASE}/api/ais-target/search
상태
- + {snapshot.status.toUpperCase()} - {snapshot.error ? {snapshot.error} : null} + {snapshot.error ? {snapshot.error} : null}
최근 fetch
@@ -323,16 +300,16 @@ export function DashboardSidebar({
-
+
{legacyError ? ( -
legacy load error: {legacyError}
+
legacy load error: {legacyError}
) : (
데이터셋
/data/legacy/chinese-permitted.v1.json
매칭(현재 scope)
- {legacyVesselsAll.length}{' '} + {legacyVesselsAll.length}{' '} / {targetsInScope.length}
생성시각
@@ -341,7 +318,7 @@ export function DashboardSidebar({ )}
-
+
현재 View BBox
{fmtBbox(viewBbox)}
@@ -400,7 +377,7 @@ export function DashboardSidebar({
-
+
setSettings((prev) => ({ ...prev, [k]: !prev[k] }))} />
단일 WebGL 컨텍스트: MapboxOverlay(interleaved)
@@ -408,14 +385,15 @@ export function DashboardSidebar({
-
+
{zonesError ? ( -
zones load error: {zonesError}
+
zones load error: {zonesError}
) : (
{zones ? `loaded (${zones.features.length} features)` : 'loading...'}
)} diff --git a/apps/web/src/widgets/topbar/Topbar.tsx b/apps/web/src/widgets/topbar/Topbar.tsx index 5a3a420..6c1c151 100644 --- a/apps/web/src/widgets/topbar/Topbar.tsx +++ b/apps/web/src/widgets/topbar/Topbar.tsx @@ -24,16 +24,16 @@ function StatChips({ total, fishing, transit, pairLinks, alarms }: Pick{total}
- 조업 {fishing} + 조업 {fishing}
- 항해 {transit} + 항해 {transit}
- 쌍연결 {pairLinks} + 쌍연결 {pairLinks}
- 경고 {alarms} + 경고 {alarms}
); @@ -71,9 +71,8 @@ export function Topbar({ total, fishing, transit, pairLinks, alarms, clock, admi {/* 로고 */}
WING diff --git a/apps/web/src/widgets/vesselList/VesselList.tsx b/apps/web/src/widgets/vesselList/VesselList.tsx index 112b7ba..bd1107b 100644 --- a/apps/web/src/widgets/vesselList/VesselList.tsx +++ b/apps/web/src/widgets/vesselList/VesselList.tsx @@ -1,3 +1,5 @@ +import { useMemo, useState } from "react"; +import { TextInput } from "@wing/ui"; import { VESSEL_TYPES } from "../../entities/vessel/model/meta"; import type { DerivedLegacyVessel } from "../../features/legacyDashboard/model/types"; import type { MouseEvent } from "react"; @@ -12,6 +14,26 @@ 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, nq: string): boolean { + if (normalize(v.permitNo).includes(nq)) return true; + if (normalize(v.name).includes(nq)) return true; + if (v.legacy.shipNameRoman && normalize(v.legacy.shipNameRoman).includes(nq)) return true; + if (v.legacy.shipNameCn && normalize(v.legacy.shipNameCn).includes(nq)) return true; + if (v.legacy.callSign && normalize(v.legacy.callSign).includes(nq)) return true; + if (normalize(String(v.mmsi)).includes(nq)) return true; + return false; +} + export function VesselList({ vessels, selectedMmsi, @@ -21,6 +43,8 @@ export function VesselList({ onHoverMmsi, onClearHover, }: Props) { + const [searchQuery, setSearchQuery] = useState(""); + const handlePrimaryAction = (e: MouseEvent, mmsi: number) => { if (e.shiftKey || e.ctrlKey || e.metaKey) { onToggleHighlightMmsi(mmsi); @@ -29,17 +53,23 @@ export function VesselList({ onSelectMmsi(mmsi); }; - function isFiniteNumber(x: unknown): x is number { - return typeof x === "number" && Number.isFinite(x); -} - - const sorted = vessels - .slice() - .sort((a, b) => (isFiniteNumber(b.sog) ? b.sog : -1) - (isFiniteNumber(a.sog) ? a.sog : -1)) - .slice(0, 80); + const sorted = useMemo(() => { + const nq = searchQuery.length >= 2 ? normalize(searchQuery) : ""; + const filtered = nq ? vessels.filter((v) => matchesQuery(v, nq)) : vessels; + return filtered.slice().sort((a, b) => (isFiniteNumber(b.sog) ? b.sog : -1) - (isFiniteNumber(a.sog) ? a.sog : -1)); + }, [vessels, searchQuery]); return ( -
+
+
+ setSearchQuery(e.target.value)} + className="h-5 w-full py-0 text-[9px]" + /> +
+
{sorted.map((v) => { const meta = VESSEL_TYPES[v.shipCode]; const primarySegs = meta.speedProfile.filter((s) => s.primary); @@ -76,6 +106,7 @@ export function VesselList({ ); })} {sorted.length === 0 ?
(표시할 대상 선박 없음)
: null} +
); } diff --git a/packages/ui/src/components/ListItem.tsx b/packages/ui/src/components/ListItem.tsx index 852414b..46daee7 100644 --- a/packages/ui/src/components/ListItem.tsx +++ b/packages/ui/src/components/ListItem.tsx @@ -13,9 +13,9 @@ export function ListItem({ selected, highlighted, className, children, ...props className={cn( 'flex cursor-pointer items-center gap-1.5 rounded px-1.5 py-1 text-[10px] transition-colors duration-100 select-none', 'hover:bg-wing-card', - selected && 'bg-[rgba(14,234,255,0.16)] border-[rgba(14,234,255,0.55)]', - highlighted && !selected && 'bg-[rgba(245,158,11,0.16)] border border-[rgba(245,158,11,0.4)]', - selected && highlighted && 'bg-gradient-to-r from-[rgba(14,234,255,0.16)] to-[rgba(245,158,11,0.16)] border-[rgba(14,234,255,0.7)]', + selected && 'bg-[var(--wing-select-bg)] border-[var(--wing-select-border)]', + highlighted && !selected && 'bg-[var(--wing-highlight-bg)] border border-[var(--wing-highlight-border)]', + selected && highlighted && 'bg-gradient-to-r from-[var(--wing-select-bg)] to-[var(--wing-highlight-bg)] border-[var(--wing-select-border)]', className, )} {...props} diff --git a/packages/ui/src/components/Section.tsx b/packages/ui/src/components/Section.tsx index 542008c..349d483 100644 --- a/packages/ui/src/components/Section.tsx +++ b/packages/ui/src/components/Section.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react'; import type { HTMLAttributes, ReactNode } from 'react'; import { cn } from '../utils/cn.ts'; @@ -5,25 +6,33 @@ interface SectionProps extends Omit, 'title'> { title: ReactNode; actions?: ReactNode; defaultOpen?: boolean; + contentClassName?: string; children: ReactNode; } -export function Section({ title, actions, defaultOpen = true, className, children, ...props }: SectionProps) { +export function Section({ title, actions, defaultOpen = true, className, contentClassName, children, ...props }: SectionProps) { + const [isOpen, setIsOpen] = useState(defaultOpen); + return ( -
- +
+
setIsOpen((v) => !v)} + > {title} {actions && ( - e.preventDefault()} className="flex items-center gap-1.5"> + e.stopPropagation()} className="flex items-center gap-1.5"> {actions} )} -
-
- {children}
-
+ {isOpen && ( +
+ {children} +
+ )} +
); } diff --git a/packages/ui/src/theme/tokens.css b/packages/ui/src/theme/tokens.css index ccbe0e3..8099441 100644 --- a/packages/ui/src/theme/tokens.css +++ b/packages/ui/src/theme/tokens.css @@ -18,6 +18,10 @@ --wing-overlay: rgba(2, 6, 23, 0.42); --wing-card-alpha: rgba(30, 41, 59, 0.55); --wing-subtle: rgba(255, 255, 255, 0.03); + --wing-select-bg: rgba(14, 234, 255, 0.16); + --wing-select-border: rgba(14, 234, 255, 0.55); + --wing-highlight-bg: rgba(245, 158, 11, 0.16); + --wing-highlight-border: rgba(245, 158, 11, 0.4); /* Legacy aliases (backward compatibility) */ --bg: var(--wing-bg); @@ -48,6 +52,10 @@ --wing-overlay: rgba(0, 0, 0, 0.25); --wing-card-alpha: rgba(226, 232, 240, 0.6); --wing-subtle: rgba(0, 0, 0, 0.03); + --wing-select-bg: rgba(14, 182, 210, 0.14); + --wing-select-border: rgba(14, 182, 210, 0.5); + --wing-highlight-bg: rgba(217, 119, 6, 0.14); + --wing-highlight-border: rgba(217, 119, 6, 0.4); --bg: var(--wing-bg); --panel: var(--wing-surface); -- 2.45.2