-
- 실시간 경고{' '}
-
- ({filteredAlarms.length}/{alarms.length})
-
-
-
- {LEGACY_ALARM_KINDS.length <= 3 ? (
-
- {LEGACY_ALARM_KINDS.map((k) => (
-
- ))}
-
- ) : (
-
-
- {alarmFilterSummary}
-
-
-
-
+
-
-
-
-
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}
+
- 생성시각
- {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 선박(속도)
-
-
-
-
-
+ {isOpen && (
+ <>
+
수역
+ {ZONE_IDS.map((z) => (
+
+
+ {ZONE_META[z].name}
+
+ ))}
-
- CN Permit(업종)
-
-
-
-
-
-
-
-
+
기타 AIS 선박(속도)
+
+
+
+
-
- 밀도(3D)
-
-
- Hexagon: 화면 내 AIS 포인트 집계
-
+
CN Permit(업종)
+
+
+
+
+
+
+
-
- 연결선
-
-
-
-
-
-
-
-
+
밀도(3D)
+
+ Hexagon: 화면 내 AIS 포인트 집계
+
+
+
연결선
+
+
+
+
+
+
+
+ >
+ )}
);
}
diff --git a/apps/web/src/widgets/topbar/Topbar.tsx b/apps/web/src/widgets/topbar/Topbar.tsx
index 833f1ad..5a3a420 100644
--- a/apps/web/src/widgets/topbar/Topbar.tsx
+++ b/apps/web/src/widgets/topbar/Topbar.tsx
@@ -1,11 +1,11 @@
-type Props = {
+import { useState } from "react";
+
+interface Props {
total: number;
fishing: number;
transit: number;
pairLinks: number;
alarms: number;
- pollingStatus: "idle" | "loading" | "ready" | "error";
- lastFetchMinutes: number | null;
clock: string;
adminMode?: boolean;
onLogoClick?: () => void;
@@ -13,72 +13,122 @@ 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) {
- const statusColor =
- pollingStatus === "ready" ? "#22C55E" : pollingStatus === "loading" ? "#F59E0B" : pollingStatus === "error" ? "#EF4444" : "var(--muted)";
+function StatChips({ total, fishing, transit, pairLinks, alarms }: Pick
) {
return (
-
-
- 🛰
WING 조업감시·선단연관{" "}
- {adminMode ?
(ADMIN) : null}
+ <>
+
+ {total}척
-
-
- DATA API
-
-
- POLL{" "}
-
- {pollingStatus.toUpperCase()}
- {lastFetchMinutes ? `(${lastFetchMinutes}m)` : ""}
-
-
-
- 전체 {total}척
-
-
- 조업 {fishing}
-
-
- 항해 {transit}
-
-
- 쌍연결 {pairLinks}
-
-
- 경고 {alarms}
-
+
+ 조업 {fishing}
-
{clock}
- {onToggleTheme && (
-
+
+ 쌍연결 {pairLinks}
+
+
+ 경고 {alarms}
+
+ >
+ );
+}
+
+export function Topbar({ total, fishing, transit, pairLinks, alarms, clock, adminMode, onLogoClick, userName, onLogout, theme, onToggleTheme, isSidebarOpen, onMenuToggle }: Props) {
+ const [isStatsOpen, setIsStatsOpen] = useState(false);
+
+ return (
+
+
+ {/* 햄버거 메뉴 (모바일) */}
+ {onMenuToggle && (
+
+ )}
+
+ {/* 로고 */}
+
- {theme === "dark" ? "Light" : "Dark"}
-
- )}
- {userName && (
-
- {userName}
- {onLogout && (
+ WING
+ 조업감시·선단연관
+ {adminMode ? (ADMIN) : null}
+
+
+ {/* 데스크톱: 인라인 통계 */}
+
+
+
+
+ {/* 항상 표시: 시계 + 테마 + 사용자 */}
+
+
{clock}
+ {onToggleTheme && (
)}
+ {userName && (
+
+ {userName}
+ {onLogout && (
+
+ )}
+
+ )}
+
+
+
+ {/* 모바일 통계 바 (펼침 시) — 레이아웃 흐름에 포함, 지도 영역 밀어내기 */}
+ {isStatsOpen && (
+
+
)}
+
+ {/* 모바일 통계 토글 탭 — topbar 하단 우측에 걸침 */}
+
);
}