From 61fc3bbce4d25e097d272c9e15e8d24590a0d84f Mon Sep 17 00:00:00 2001 From: htlee Date: Tue, 17 Feb 2026 06:54:27 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=AA=A8=EB=B0=94=EC=9D=BC=20=EB=B0=98?= =?UTF-8?q?=EC=9D=91=ED=98=95=20UI=20(drawer,=20=EC=95=84=EC=BD=94?= =?UTF-8?q?=EB=94=94=EC=96=B8,=20=EB=B2=94=EB=A1=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Topbar: 햄버거 메뉴 버튼, 반응형 stats 숨김 - Sidebar: 모바일 drawer (fixed + translate-x), backdrop - Sidebar: Section 아코디언으로 전환 (details/summary) - Legend: 접기/펼치기 토글 추가 - panels.css: .sb/.sb-t 클래스 제거 Co-Authored-By: Claude Opus 4.6 --- apps/web/src/app/styles/components/panels.css | 21 - .../web/src/pages/dashboard/DashboardPage.tsx | 7 +- .../src/pages/dashboard/DashboardSidebar.tsx | 584 +++++++++--------- apps/web/src/widgets/legend/MapLegend.tsx | 209 ++++--- apps/web/src/widgets/topbar/Topbar.tsx | 44 +- 5 files changed, 437 insertions(+), 428 deletions(-) diff --git a/apps/web/src/app/styles/components/panels.css b/apps/web/src/app/styles/components/panels.css index f9ad84e..f976f9b 100644 --- a/apps/web/src/app/styles/components/panels.css +++ b/apps/web/src/app/styles/components/panels.css @@ -1,24 +1,3 @@ -.sb { - padding: 10px 12px; - border-bottom: 1px solid var(--border); -} - -.sb-t { - font-size: 9px; - font-weight: 700; - color: var(--muted); - letter-spacing: 1.5px; - text-transform: uppercase; - margin-bottom: 6px; -} - -.sb-t-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; -} - .relation-sort { display: flex; align-items: center; diff --git a/apps/web/src/pages/dashboard/DashboardPage.tsx b/apps/web/src/pages/dashboard/DashboardPage.tsx index ae15cae..0a5dbb7 100644 --- a/apps/web/src/pages/dashboard/DashboardPage.tsx +++ b/apps/web/src/pages/dashboard/DashboardPage.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo } from "react"; +import { useCallback, useMemo, useState } from "react"; import { useAuth } from "../../shared/auth"; import { useTheme } from "../../shared/hooks"; import { useAisTargetPolling } from "../../features/aisPolling/useAisTargetPolling"; @@ -60,6 +60,7 @@ function useLegacyIndex(data: LegacyVesselDataset | null) { export function DashboardPage() { const { user, logout } = useAuth(); const { theme, toggleTheme } = useTheme(); + const [isSidebarOpen, setIsSidebarOpen] = useState(false); const uid = user?.id ?? null; // ── Data fetching ── @@ -263,9 +264,13 @@ export function DashboardPage() { onLogout={logout} theme={theme} onToggleTheme={toggleTheme} + isSidebarOpen={isSidebarOpen} + onMenuToggle={() => setIsSidebarOpen((v) => !v)} /> setIsSidebarOpen(false)} state={state} legacyVesselsAll={legacyVesselsAll} legacyVesselsFiltered={legacyVesselsFiltered} diff --git a/apps/web/src/pages/dashboard/DashboardSidebar.tsx b/apps/web/src/pages/dashboard/DashboardSidebar.tsx index cc6991f..d0841c4 100644 --- a/apps/web/src/pages/dashboard/DashboardSidebar.tsx +++ b/apps/web/src/pages/dashboard/DashboardSidebar.tsx @@ -1,4 +1,5 @@ -import { ToggleButton } from '@wing/ui'; +import { useEffect } from 'react'; +import { ToggleButton, Section } from '@wing/ui'; import type { AisTarget } from '../../entities/aisTarget/model/types'; import type { LegacyVesselIndex } from '../../entities/legacyVessel/lib'; import type { LegacyVesselDataset, LegacyVesselInfo } from '../../entities/legacyVessel/model/types'; @@ -28,8 +29,9 @@ function fmtBbox(b: Bbox | null) { } interface DashboardSidebarProps { + isOpen: boolean; + onClose: () => void; state: ReturnType; - // Derived data legacyVesselsAll: DerivedLegacyVessel[]; legacyVesselsFiltered: DerivedLegacyVessel[]; legacyCounts: Record; @@ -43,7 +45,6 @@ interface DashboardSidebarProps { alarmFilterSummary: string; speedPanelType: VesselTypeCode; onFleetContextMenu: (ownerKey: string, mmsis: number[]) => void; - // Data fetching (admin panels) snapshot: AisPollingSnapshot; legacyError: string | null; legacyData: LegacyVesselDataset | null; @@ -54,6 +55,8 @@ interface DashboardSidebarProps { } export function DashboardSidebar({ + isOpen, + onClose, state, legacyVesselsAll, legacyVesselsFiltered, @@ -94,89 +97,109 @@ export function DashboardSidebar({ setUniqueSorted, setSortedIfChanged, toggleHighlightedMmsi, } = state; + useEffect(() => { + if (!isOpen) return; + const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; + document.addEventListener('keydown', handler); + return () => document.removeEventListener('keydown', handler); + }, [isOpen, onClose]); + return ( -
-
-
업종 필터
-
- { - setShowTargets((v) => { - const next = !v; - if (!next) setSelectedMmsi((m) => (legacyHits.has(m ?? -1) ? null : m)); - return next; - }); + <> + {isOpen && ( +
+ )} + +
+
+ +
+
+ { + setShowTargets((v) => { + const next = !v; + if (!next) setSelectedMmsi((m) => (legacyHits.has(m ?? -1) ? null : m)); + return next; + }); + }} + title="레거시(CN permit) 대상 선박 표시" + > + 대상 선박 + + setShowOthers((v) => !v)} title="대상 외 AIS 선박 표시"> + 기타 AIS + +
+ { + if (selectedLegacyVessel && selectedLegacyVessel.shipCode === code && typeEnabled[code]) setSelectedMmsi(null); + setTypeEnabled((prev) => ({ ...prev, [code]: !prev[code] })); }} - title="레거시(CN permit) 대상 선박 표시" - > - 대상 선박 - - setShowOthers((v) => !v)} title="대상 외 AIS 선박 표시"> - 기타 AIS - -
- { - if (selectedLegacyVessel && selectedLegacyVessel.shipCode === code && typeEnabled[code]) setSelectedMmsi(null); - setTypeEnabled((prev) => ({ ...prev, [code]: !prev[code] })); - }} - onToggleAll={() => { - const allOn = VESSEL_TYPE_ORDER.every((c) => typeEnabled[c]); - const nextVal = !allOn; - if (!nextVal && selectedLegacyVessel) setSelectedMmsi(null); - setTypeEnabled({ - PT: nextVal, 'PT-S': nextVal, GN: nextVal, OT: nextVal, PS: nextVal, FC: nextVal, - }); - }} - /> -
+ onToggleAll={() => { + const allOn = VESSEL_TYPE_ORDER.every((c) => typeEnabled[c]); + const nextVal = !allOn; + if (!nextVal && selectedLegacyVessel) setSelectedMmsi(null); + setTypeEnabled({ PT: nextVal, 'PT-S': nextVal, GN: nextVal, OT: nextVal, PS: nextVal, FC: nextVal }); + }} + /> + -
-
- 지도 표시 설정 -
- 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' }} - > - 3D - -
- setOverlays((prev) => ({ ...prev, [k]: !prev[k] }))} /> -
+
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' }} + > + 3D + + } + > + setOverlays((prev) => ({ ...prev, [k]: !prev[k] }))} /> +
-
-
속도 프로파일
- -
+
+ +
-
-
-
- 선단 연관관계{' '} - - {selectedLegacyVessel ? `${selectedLegacyVessel.permitNo} 연관` : '(선박 클릭 시 표시)'} - -
-
- - -
-
-
+
+ 선단 연관관계{' '} + + {selectedLegacyVessel ? `${selectedLegacyVessel.permitNo} 연관` : '(선박 클릭 시 표시)'} + + + } + actions={ +
+ + +
+ } + className="max-h-[260px] flex flex-col overflow-hidden [&>div:last-child]:overflow-y-auto [&>div:last-child]:min-h-0" + > -
-
+ -
-
- 선박 목록{' '} - - ({legacyVesselsFiltered.length}척) - -
- setHoveredMmsiSet([mmsi])} - onClearHover={() => setHoveredMmsiSet([])} - /> -
+
+ 선박 목록{' '} + ({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" + > + setHoveredMmsiSet([mmsi])} + onClearHover={() => setHoveredMmsiSet([])} + /> +
-
-
-
- 실시간 경고{' '} - - ({filteredAlarms.length}/{alarms.length}) - -
- - {LEGACY_ALARM_KINDS.length <= 3 ? ( -
- {LEGACY_ALARM_KINDS.map((k) => ( - - ))} -
- ) : ( -
- - {alarmFilterSummary} - -
- -
+
+ 실시간 경고{' '} + ({filteredAlarms.length}/{alarms.length}) + + } + actions={ + LEGACY_ALARM_KINDS.length <= 3 ? ( +
{LEGACY_ALARM_KINDS.map((k) => ( -
-
- )} -
- -
- -
-
- - {adminMode ? ( - <> -
-
ADMIN · AIS Target Polling
-
-
엔드포인트
-
{AIS_API_BASE}/api/ais-target/search
-
상태
-
- - {snapshot.status.toUpperCase()} - - {snapshot.error ? {snapshot.error} : null} -
-
최근 fetch
-
- {fmtIsoFull(snapshot.lastFetchAt)}{' '} - - ({snapshot.lastFetchMinutes ?? '-'}m, inserted {snapshot.lastInserted}, upserted {snapshot.lastUpserted}) - -
-
메시지
-
{snapshot.lastMessage ?? '-'}
-
-
- -
-
ADMIN · Legacy (CN Permit)
- {legacyError ? ( -
legacy load error: {legacyError}
) : ( -
-
데이터셋
-
/data/legacy/chinese-permitted.v1.json
-
매칭(현재 scope)
-
- {legacyVesselsAll.length}{' '} - / {targetsInScope.length} +
+ {alarmFilterSummary} +
+ +
+ {LEGACY_ALARM_KINDS.map((k) => ( + + ))}
-
생성시각
-
{legacyData?.generatedAt ? fmtIsoFull(legacyData.generatedAt) : 'loading...'}
+
+ ) + } + 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" + > + + + + {adminMode ? ( + <> +
+
+
엔드포인트
+
{AIS_API_BASE}/api/ais-target/search
+
상태
+
+ + {snapshot.status.toUpperCase()} + + {snapshot.error ? {snapshot.error} : null} +
+
최근 fetch
+
+ {fmtIsoFull(snapshot.lastFetchAt)}{' '} + + ({snapshot.lastFetchMinutes ?? '-'}m, inserted {snapshot.lastInserted}, upserted {snapshot.lastUpserted}) + +
+
메시지
+
{snapshot.lastMessage ?? '-'}
- )} -
+ -
-
ADMIN · Viewport / BBox
-
-
현재 View BBox
-
{fmtBbox(viewBbox)}
-
- +
+ {legacyError ? ( +
legacy load error: {legacyError}
+ ) : ( +
+
데이터셋
+
/data/legacy/chinese-permitted.v1.json
+
매칭(현재 scope)
+
+ {legacyVesselsAll.length}{' '} + / {targetsInScope.length} +
+
생성시각
+
{legacyData?.generatedAt ? fmtIsoFull(legacyData.generatedAt) : 'loading...'}
+
+ )} +
- - - +
+
+
현재 View BBox
+
{fmtBbox(viewBbox)}
+
+ + + +
+
+ 표시 선박: {targetsInScope.length} / 스토어:{' '} + {snapshot.total} +
-
- 표시 선박: {targetsInScope.length} / 스토어:{' '} - {snapshot.total} -
-
-
+ -
-
ADMIN · Map (Extras)
- setSettings((prev) => ({ ...prev, [k]: !prev[k] }))} /> -
단일 WebGL 컨텍스트: MapboxOverlay(interleaved)
-
+
+ setSettings((prev) => ({ ...prev, [k]: !prev[k] }))} /> +
단일 WebGL 컨텍스트: MapboxOverlay(interleaved)
+
-
-
ADMIN · AIS Targets (All)
- -
+
+ +
-
-
ADMIN · 수역 데이터
- {zonesError ? ( -
zones load error: {zonesError}
- ) : ( -
- {zones ? `loaded (${zones.features.length} features)` : 'loading...'} -
- )} -
- - ) : null} -
+
+ {zonesError ? ( +
zones load error: {zonesError}
+ ) : ( +
{zones ? `loaded (${zones.features.length} features)` : 'loading...'}
+ )} +
+ + ) : null} +
+ ); } diff --git a/apps/web/src/widgets/legend/MapLegend.tsx b/apps/web/src/widgets/legend/MapLegend.tsx index 1d7a7d3..3ae458f 100644 --- a/apps/web/src/widgets/legend/MapLegend.tsx +++ b/apps/web/src/widgets/legend/MapLegend.tsx @@ -1,114 +1,121 @@ +import { useState } from 'react'; import { ZONE_IDS, ZONE_META } from "../../entities/zone/model/meta"; import { LEGACY_CODE_COLORS_HEX, OTHER_AIS_SPEED_HEX, OVERLAY_RGB, rgbToHex, rgba } from "../../shared/lib/map/palette"; export function MapLegend() { + const [isOpen, setIsOpen] = useState(true); + return (
-
수역
- {ZONE_IDS.map((z) => ( -
-
- {ZONE_META[z].name} -
- ))} + -
- 기타 AIS 선박(속도) -
-
-
- SOG ≥ 10 kt -
-
-
- 1 ≤ SOG < 10 kt -
-
-
- SOG < 1 kt -
-
-
- SOG unknown -
+ {isOpen && ( + <> +
수역
+ {ZONE_IDS.map((z) => ( +
+
+ {ZONE_META[z].name} +
+ ))} -
- CN Permit(업종) -
-
-
- PT 본선 (ring + 색상) -
-
-
- PT-S 부속선 -
-
-
- GN 유망 -
-
-
- OT 1척식 -
-
-
- PS 위망 -
-
-
- FC 운반선 -
-
-
- C21 -
+
기타 AIS 선박(속도)
+
+
+ SOG ≥ 10 kt +
+
+
+ 1 ≤ SOG < 10 kt +
+
+
+ SOG < 1 kt +
+
+
+ SOG unknown +
-
- 밀도(3D) -
-
- Hexagon: 화면 내 AIS 포인트 집계 -
+
CN Permit(업종)
+
+
+ PT 본선 +
+
+
+ PT-S 부속선 +
+
+
+ GN 유망 +
+
+
+ OT 1척식 +
+
+
+ PS 위망 +
+
+
+ FC 운반선 +
+
+
+ C21 +
-
- 연결선 -
-
-
- PT↔PT-S 쌍 (정상) -
-
-
- 쌍 연결범위 -
-
-
- 쌍 이격 경고 (>3NM) -
-
-
- FC 환적 연결 (dashed) -
-
-
- 선단 범위 -
-
-
- FC 환적 연결 (의심) -
-
-
- 예측 벡터 (15분) -
+
밀도(3D)
+
+ Hexagon: 화면 내 AIS 포인트 집계 +
+ +
연결선
+
+
+ PT↔PT-S 쌍 (정상) +
+
+
+ 쌍 연결범위 +
+
+
+ 쌍 이격 경고 (>3NM) +
+
+
+ FC 환적 연결 (dashed) +
+
+
+ 선단 범위 +
+
+
+ FC 환적 연결 (의심) +
+
+
+ 예측 벡터 (15분) +
+ + )}
); } diff --git a/apps/web/src/widgets/topbar/Topbar.tsx b/apps/web/src/widgets/topbar/Topbar.tsx index 833f1ad..d281f0f 100644 --- a/apps/web/src/widgets/topbar/Topbar.tsx +++ b/apps/web/src/widgets/topbar/Topbar.tsx @@ -13,27 +13,49 @@ type Props = { onLogout?: () => void; theme?: "dark" | "light"; onToggleTheme?: () => void; + isSidebarOpen?: boolean; + onMenuToggle?: () => void; }; -export function Topbar({ total, fishing, transit, pairLinks, alarms, pollingStatus, lastFetchMinutes, clock, adminMode, onLogoClick, userName, onLogout, theme, onToggleTheme }: Props) { +export function Topbar({ total, fishing, transit, pairLinks, alarms, pollingStatus, lastFetchMinutes, clock, adminMode, onLogoClick, userName, onLogout, theme, onToggleTheme, isSidebarOpen, onMenuToggle }: Props) { const statusColor = pollingStatus === "ready" ? "#22C55E" : pollingStatus === "loading" ? "#F59E0B" : pollingStatus === "error" ? "#EF4444" : "var(--muted)"; return (
+ {onMenuToggle && ( + + )}
- 🛰 WING 조업감시·선단연관{" "} + WING + 조업감시·선단연관 {adminMode ? (ADMIN) : null}
-
- DATA API -
-
+
POLL{" "} {pollingStatus.toUpperCase()} @@ -41,15 +63,15 @@ export function Topbar({ total, fishing, transit, pairLinks, alarms, pollingStat
- 전체 {total}척 + {total}
-
+
조업 {fishing}
-
+
항해 {transit}
-
+
쌍연결 {pairLinks}
@@ -67,7 +89,7 @@ export function Topbar({ total, fishing, transit, pairLinks, alarms, pollingStat )} {userName && ( -
+
{userName} {onLogout && (