feat: 모바일 반응형 UI (drawer, 아코디언, 범례) #22
@ -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;
|
||||
|
||||
@ -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 ──
|
||||
@ -247,15 +248,13 @@ export function DashboardPage() {
|
||||
|
||||
// ── Render ──
|
||||
return (
|
||||
<div className="grid h-screen grid-cols-[310px_1fr] grid-rows-[44px_1fr] max-md:grid-cols-[1fr]">
|
||||
<div className="grid h-screen grid-cols-[310px_1fr] grid-rows-[44px_1fr] max-md:grid-cols-[1fr] max-md:grid-rows-[auto_1fr]">
|
||||
<Topbar
|
||||
total={legacyVesselsAll.length}
|
||||
fishing={fishingCount}
|
||||
transit={transitCount}
|
||||
pairLinks={pairLinksAll.length}
|
||||
alarms={alarms.length}
|
||||
pollingStatus={snapshot.status}
|
||||
lastFetchMinutes={snapshot.lastFetchMinutes}
|
||||
clock={clock}
|
||||
adminMode={adminMode}
|
||||
onLogoClick={onLogoClick}
|
||||
@ -263,9 +262,13 @@ export function DashboardPage() {
|
||||
onLogout={logout}
|
||||
theme={theme}
|
||||
onToggleTheme={toggleTheme}
|
||||
isSidebarOpen={isSidebarOpen}
|
||||
onMenuToggle={() => setIsSidebarOpen((v) => !v)}
|
||||
/>
|
||||
|
||||
<DashboardSidebar
|
||||
isOpen={isSidebarOpen}
|
||||
onClose={() => setIsSidebarOpen(false)}
|
||||
state={state}
|
||||
legacyVesselsAll={legacyVesselsAll}
|
||||
legacyVesselsFiltered={legacyVesselsFiltered}
|
||||
|
||||
@ -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<typeof useDashboardState>;
|
||||
// Derived data
|
||||
legacyVesselsAll: DerivedLegacyVessel[];
|
||||
legacyVesselsFiltered: DerivedLegacyVessel[];
|
||||
legacyCounts: Record<VesselTypeCode, number>;
|
||||
@ -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 (
|
||||
<div className="flex flex-col overflow-y-auto border-r border-wing-border bg-wing-surface max-md:hidden">
|
||||
<div className="sb">
|
||||
<div className="sb-t">업종 필터</div>
|
||||
<div className="flex flex-wrap gap-0.75 mb-1.5">
|
||||
<ToggleButton
|
||||
on={showTargets}
|
||||
onClick={() => {
|
||||
setShowTargets((v) => {
|
||||
const next = !v;
|
||||
if (!next) setSelectedMmsi((m) => (legacyHits.has(m ?? -1) ? null : m));
|
||||
return next;
|
||||
});
|
||||
<>
|
||||
{isOpen && (
|
||||
<div className="fixed inset-0 z-[1100] bg-black/50 md:hidden" onClick={onClose} aria-hidden />
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`
|
||||
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
|
||||
`}
|
||||
>
|
||||
<div className="h-[44px] md:hidden" />
|
||||
|
||||
<Section title="업종 필터">
|
||||
<div className="flex flex-wrap gap-0.75 mb-1.5">
|
||||
<ToggleButton
|
||||
on={showTargets}
|
||||
onClick={() => {
|
||||
setShowTargets((v) => {
|
||||
const next = !v;
|
||||
if (!next) setSelectedMmsi((m) => (legacyHits.has(m ?? -1) ? null : m));
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
title="레거시(CN permit) 대상 선박 표시"
|
||||
>
|
||||
대상 선박
|
||||
</ToggleButton>
|
||||
<ToggleButton on={showOthers} onClick={() => setShowOthers((v) => !v)} title="대상 외 AIS 선박 표시">
|
||||
기타 AIS
|
||||
</ToggleButton>
|
||||
</div>
|
||||
<TypeFilterGrid
|
||||
enabled={typeEnabled}
|
||||
totalCount={legacyVesselsAll.length}
|
||||
countsByType={legacyCounts}
|
||||
onToggle={(code) => {
|
||||
if (selectedLegacyVessel && selectedLegacyVessel.shipCode === code && typeEnabled[code]) setSelectedMmsi(null);
|
||||
setTypeEnabled((prev) => ({ ...prev, [code]: !prev[code] }));
|
||||
}}
|
||||
title="레거시(CN permit) 대상 선박 표시"
|
||||
>
|
||||
대상 선박
|
||||
</ToggleButton>
|
||||
<ToggleButton on={showOthers} onClick={() => setShowOthers((v) => !v)} title="대상 외 AIS 선박 표시">
|
||||
기타 AIS
|
||||
</ToggleButton>
|
||||
</div>
|
||||
<TypeFilterGrid
|
||||
enabled={typeEnabled}
|
||||
totalCount={legacyVesselsAll.length}
|
||||
countsByType={legacyCounts}
|
||||
onToggle={(code) => {
|
||||
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,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
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 });
|
||||
}}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<div className="sb">
|
||||
<div className="sb-t" style={{ display: 'flex', alignItems: 'center' }}>
|
||||
지도 표시 설정
|
||||
<div style={{ flex: 1 }} />
|
||||
<ToggleButton
|
||||
on={projection === 'globe'}
|
||||
onClick={isProjectionToggleDisabled ? undefined : () => 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
|
||||
</ToggleButton>
|
||||
</div>
|
||||
<MapToggles value={overlays} onToggle={(k) => setOverlays((prev) => ({ ...prev, [k]: !prev[k] }))} />
|
||||
</div>
|
||||
<Section
|
||||
title="지도 표시 설정"
|
||||
actions={
|
||||
<ToggleButton
|
||||
on={projection === 'globe'}
|
||||
onClick={isProjectionToggleDisabled ? undefined : () => 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
|
||||
</ToggleButton>
|
||||
}
|
||||
>
|
||||
<MapToggles value={overlays} onToggle={(k) => setOverlays((prev) => ({ ...prev, [k]: !prev[k] }))} />
|
||||
</Section>
|
||||
|
||||
<div className="sb">
|
||||
<div className="sb-t">속도 프로파일</div>
|
||||
<SpeedProfilePanel selectedType={speedPanelType} />
|
||||
</div>
|
||||
<Section title="속도 프로파일" defaultOpen={false}>
|
||||
<SpeedProfilePanel selectedType={speedPanelType} />
|
||||
</Section>
|
||||
|
||||
<div className="sb" style={{ maxHeight: 260, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
<div className="sb-t sb-t-row">
|
||||
<div>
|
||||
선단 연관관계{' '}
|
||||
<span style={{ color: 'var(--accent)', fontSize: 8 }}>
|
||||
{selectedLegacyVessel ? `${selectedLegacyVessel.permitNo} 연관` : '(선박 클릭 시 표시)'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="relation-sort">
|
||||
<label className="relation-sort__option">
|
||||
<input type="radio" name="fleet-relation-sort" checked={fleetRelationSortMode === 'count'} onChange={() => setFleetRelationSortMode('count')} />
|
||||
척수
|
||||
</label>
|
||||
<label className="relation-sort__option">
|
||||
<input type="radio" name="fleet-relation-sort" checked={fleetRelationSortMode === 'range'} onChange={() => setFleetRelationSortMode('range')} />
|
||||
범위
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ overflowY: 'auto', minHeight: 0 }}>
|
||||
<Section
|
||||
title={
|
||||
<>
|
||||
선단 연관관계{' '}
|
||||
<span className="text-[8px] font-normal normal-case tracking-normal text-wing-accent">
|
||||
{selectedLegacyVessel ? `${selectedLegacyVessel.permitNo} 연관` : '(선박 클릭 시 표시)'}
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
actions={
|
||||
<div className="relation-sort">
|
||||
<label className="relation-sort__option">
|
||||
<input type="radio" name="fleet-relation-sort" checked={fleetRelationSortMode === 'count'} onChange={() => setFleetRelationSortMode('count')} />
|
||||
척수
|
||||
</label>
|
||||
<label className="relation-sort__option">
|
||||
<input type="radio" name="fleet-relation-sort" checked={fleetRelationSortMode === 'range'} onChange={() => setFleetRelationSortMode('range')} />
|
||||
범위
|
||||
</label>
|
||||
</div>
|
||||
}
|
||||
className="max-h-[260px] flex flex-col overflow-hidden [&>div:last-child]:overflow-y-auto [&>div:last-child]:min-h-0"
|
||||
>
|
||||
<RelationsPanel
|
||||
selectedVessel={selectedLegacyVessel}
|
||||
vessels={legacyVesselsAll}
|
||||
@ -200,233 +223,206 @@ export function DashboardSidebar({
|
||||
hoveredFleetMmsiSet={hoveredFleetMmsiSet}
|
||||
onContextMenuFleet={onFleetContextMenu}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<div className="sb" style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
||||
<div className="sb-t">
|
||||
선박 목록{' '}
|
||||
<span style={{ color: 'var(--accent)', fontSize: 8 }}>
|
||||
({legacyVesselsFiltered.length}척)
|
||||
</span>
|
||||
</div>
|
||||
<VesselList
|
||||
vessels={legacyVesselsFiltered}
|
||||
selectedMmsi={selectedMmsi}
|
||||
highlightedMmsiSet={activeHighlightedMmsiSet}
|
||||
onSelectMmsi={setSelectedMmsi}
|
||||
onToggleHighlightMmsi={toggleHighlightedMmsi}
|
||||
onHoverMmsi={(mmsi) => setHoveredMmsiSet([mmsi])}
|
||||
onClearHover={() => setHoveredMmsiSet([])}
|
||||
/>
|
||||
</div>
|
||||
<Section
|
||||
title={
|
||||
<>
|
||||
선박 목록{' '}
|
||||
<span className="text-[8px] font-normal normal-case tracking-normal text-wing-accent">({legacyVesselsFiltered.length}척)</span>
|
||||
</>
|
||||
}
|
||||
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"
|
||||
>
|
||||
<VesselList
|
||||
vessels={legacyVesselsFiltered}
|
||||
selectedMmsi={selectedMmsi}
|
||||
highlightedMmsiSet={activeHighlightedMmsiSet}
|
||||
onSelectMmsi={setSelectedMmsi}
|
||||
onToggleHighlightMmsi={toggleHighlightedMmsi}
|
||||
onHoverMmsi={(mmsi) => setHoveredMmsiSet([mmsi])}
|
||||
onClearHover={() => setHoveredMmsiSet([])}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<div className="sb" style={{ maxHeight: 130, display: 'flex', flexDirection: 'column', overflow: 'visible' }}>
|
||||
<div className="sb-t sb-t-row" style={{ marginBottom: 6 }}>
|
||||
<div>
|
||||
실시간 경고{' '}
|
||||
<span style={{ color: 'var(--accent)', fontSize: 8 }}>
|
||||
({filteredAlarms.length}/{alarms.length})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{LEGACY_ALARM_KINDS.length <= 3 ? (
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
{LEGACY_ALARM_KINDS.map((k) => (
|
||||
<label key={k} style={{ display: 'inline-flex', gap: 4, alignItems: 'center', cursor: 'pointer', userSelect: 'none' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!alarmKindEnabled[k]}
|
||||
onChange={() => setAlarmKindEnabled((prev) => ({ ...prev, [k]: !prev[k] }))}
|
||||
/>
|
||||
<span style={{ fontSize: 8, color: 'var(--muted)', whiteSpace: 'nowrap' }}>{LEGACY_ALARM_KIND_LABEL[k]}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<details className="alarm-filter">
|
||||
<summary className="alarm-filter__summary" title="경고 종류 필터">
|
||||
{alarmFilterSummary}
|
||||
</summary>
|
||||
<div className="alarm-filter__menu" role="menu" aria-label="alarm kind filter">
|
||||
<label className="alarm-filter__row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allAlarmKindsEnabled}
|
||||
onChange={() =>
|
||||
setAlarmKindEnabled((prev) => {
|
||||
const allOn = LEGACY_ALARM_KINDS.every((k) => prev[k]);
|
||||
const nextVal = allOn ? false : true;
|
||||
return Object.fromEntries(LEGACY_ALARM_KINDS.map((k) => [k, nextVal])) as Record<LegacyAlarmKind, boolean>;
|
||||
})
|
||||
}
|
||||
/>
|
||||
전체
|
||||
<span className="alarm-filter__cnt">{alarms.length}</span>
|
||||
</label>
|
||||
<div className="alarm-filter__sep" />
|
||||
<Section
|
||||
title={
|
||||
<>
|
||||
실시간 경고{' '}
|
||||
<span className="text-[8px] font-normal normal-case tracking-normal text-wing-accent">({filteredAlarms.length}/{alarms.length})</span>
|
||||
</>
|
||||
}
|
||||
actions={
|
||||
LEGACY_ALARM_KINDS.length <= 3 ? (
|
||||
<div className="flex gap-1.5 items-center">
|
||||
{LEGACY_ALARM_KINDS.map((k) => (
|
||||
<label key={k} className="alarm-filter__row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!alarmKindEnabled[k]}
|
||||
onChange={() => setAlarmKindEnabled((prev) => ({ ...prev, [k]: !prev[k] }))}
|
||||
/>
|
||||
{LEGACY_ALARM_KIND_LABEL[k]}
|
||||
<span className="alarm-filter__cnt">{alarmKindCounts[k] ?? 0}</span>
|
||||
<label key={k} className="inline-flex gap-1 items-center cursor-pointer select-none">
|
||||
<input type="checkbox" checked={!!alarmKindEnabled[k]} onChange={() => setAlarmKindEnabled((prev) => ({ ...prev, [k]: !prev[k] }))} />
|
||||
<span className="text-[8px] text-wing-muted whitespace-nowrap">{LEGACY_ALARM_KIND_LABEL[k]}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ overflowY: 'auto', minHeight: 0, flex: 1 }}>
|
||||
<AlarmsPanel alarms={filteredAlarms} onSelectMmsi={setSelectedMmsi} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{adminMode ? (
|
||||
<>
|
||||
<div className="sb">
|
||||
<div className="sb-t">ADMIN · AIS Target Polling</div>
|
||||
<div style={{ fontSize: 11, lineHeight: 1.45 }}>
|
||||
<div style={{ color: 'var(--muted)', fontSize: 10 }}>엔드포인트</div>
|
||||
<div style={{ wordBreak: 'break-all' }}>{AIS_API_BASE}/api/ais-target/search</div>
|
||||
<div style={{ marginTop: 6, color: 'var(--muted)', fontSize: 10 }}>상태</div>
|
||||
<div>
|
||||
<b style={{ color: snapshot.status === 'ready' ? '#22C55E' : snapshot.status === 'error' ? '#EF4444' : '#F59E0B' }}>
|
||||
{snapshot.status.toUpperCase()}
|
||||
</b>
|
||||
{snapshot.error ? <span style={{ marginLeft: 6, color: '#EF4444' }}>{snapshot.error}</span> : null}
|
||||
</div>
|
||||
<div style={{ marginTop: 6, color: 'var(--muted)', fontSize: 10 }}>최근 fetch</div>
|
||||
<div>
|
||||
{fmtIsoFull(snapshot.lastFetchAt)}{' '}
|
||||
<span style={{ color: 'var(--muted)', fontSize: 10 }}>
|
||||
({snapshot.lastFetchMinutes ?? '-'}m, inserted {snapshot.lastInserted}, upserted {snapshot.lastUpserted})
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ marginTop: 6, color: 'var(--muted)', fontSize: 10 }}>메시지</div>
|
||||
<div style={{ color: 'var(--text)', fontSize: 10 }}>{snapshot.lastMessage ?? '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sb">
|
||||
<div className="sb-t">ADMIN · Legacy (CN Permit)</div>
|
||||
{legacyError ? (
|
||||
<div style={{ fontSize: 11, color: '#EF4444' }}>legacy load error: {legacyError}</div>
|
||||
) : (
|
||||
<div style={{ fontSize: 11, lineHeight: 1.45 }}>
|
||||
<div style={{ color: 'var(--muted)', fontSize: 10 }}>데이터셋</div>
|
||||
<div style={{ wordBreak: 'break-all', fontSize: 10 }}>/data/legacy/chinese-permitted.v1.json</div>
|
||||
<div style={{ marginTop: 6, color: 'var(--muted)', fontSize: 10 }}>매칭(현재 scope)</div>
|
||||
<div>
|
||||
<b style={{ color: '#F59E0B' }}>{legacyVesselsAll.length}</b>{' '}
|
||||
<span style={{ color: 'var(--muted)', fontSize: 10 }}>/ {targetsInScope.length}</span>
|
||||
<details className="alarm-filter">
|
||||
<summary className="alarm-filter__summary" title="경고 종류 필터">{alarmFilterSummary}</summary>
|
||||
<div className="alarm-filter__menu" role="menu" aria-label="alarm kind filter">
|
||||
<label className="alarm-filter__row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allAlarmKindsEnabled}
|
||||
onChange={() =>
|
||||
setAlarmKindEnabled((prev) => {
|
||||
const allOn = LEGACY_ALARM_KINDS.every((k) => prev[k]);
|
||||
const nextVal = !allOn;
|
||||
return Object.fromEntries(LEGACY_ALARM_KINDS.map((k) => [k, nextVal])) as Record<LegacyAlarmKind, boolean>;
|
||||
})
|
||||
}
|
||||
/>
|
||||
전체
|
||||
<span className="alarm-filter__cnt">{alarms.length}</span>
|
||||
</label>
|
||||
<div className="alarm-filter__sep" />
|
||||
{LEGACY_ALARM_KINDS.map((k) => (
|
||||
<label key={k} className="alarm-filter__row">
|
||||
<input type="checkbox" checked={!!alarmKindEnabled[k]} onChange={() => setAlarmKindEnabled((prev) => ({ ...prev, [k]: !prev[k] }))} />
|
||||
{LEGACY_ALARM_KIND_LABEL[k]}
|
||||
<span className="alarm-filter__cnt">{alarmKindCounts[k] ?? 0}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ marginTop: 6, color: 'var(--muted)', fontSize: 10 }}>생성시각</div>
|
||||
<div style={{ fontSize: 10, color: 'var(--text)' }}>{legacyData?.generatedAt ? fmtIsoFull(legacyData.generatedAt) : 'loading...'}</div>
|
||||
</details>
|
||||
)
|
||||
}
|
||||
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"
|
||||
>
|
||||
<AlarmsPanel alarms={filteredAlarms} onSelectMmsi={setSelectedMmsi} />
|
||||
</Section>
|
||||
|
||||
{adminMode ? (
|
||||
<>
|
||||
<Section title="ADMIN · AIS Target Polling" defaultOpen={false}>
|
||||
<div style={{ fontSize: 11, lineHeight: 1.45 }}>
|
||||
<div style={{ color: 'var(--muted)', fontSize: 10 }}>엔드포인트</div>
|
||||
<div style={{ wordBreak: 'break-all' }}>{AIS_API_BASE}/api/ais-target/search</div>
|
||||
<div style={{ marginTop: 6, color: 'var(--muted)', fontSize: 10 }}>상태</div>
|
||||
<div>
|
||||
<b style={{ color: snapshot.status === 'ready' ? '#22C55E' : snapshot.status === 'error' ? '#EF4444' : '#F59E0B' }}>
|
||||
{snapshot.status.toUpperCase()}
|
||||
</b>
|
||||
{snapshot.error ? <span style={{ marginLeft: 6, color: '#EF4444' }}>{snapshot.error}</span> : null}
|
||||
</div>
|
||||
<div style={{ marginTop: 6, color: 'var(--muted)', fontSize: 10 }}>최근 fetch</div>
|
||||
<div>
|
||||
{fmtIsoFull(snapshot.lastFetchAt)}{' '}
|
||||
<span style={{ color: 'var(--muted)', fontSize: 10 }}>
|
||||
({snapshot.lastFetchMinutes ?? '-'}m, inserted {snapshot.lastInserted}, upserted {snapshot.lastUpserted})
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ marginTop: 6, color: 'var(--muted)', fontSize: 10 }}>메시지</div>
|
||||
<div style={{ color: 'var(--text)', fontSize: 10 }}>{snapshot.lastMessage ?? '-'}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<div className="sb">
|
||||
<div className="sb-t">ADMIN · Viewport / BBox</div>
|
||||
<div style={{ fontSize: 11, lineHeight: 1.45 }}>
|
||||
<div style={{ color: 'var(--muted)', fontSize: 10 }}>현재 View BBox</div>
|
||||
<div style={{ wordBreak: 'break-all', fontSize: 10 }}>{fmtBbox(viewBbox)}</div>
|
||||
<div style={{ marginTop: 8, display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
<button
|
||||
onClick={() => setUseViewportFilter((v) => !v)}
|
||||
style={{
|
||||
fontSize: 10, padding: '4px 8px', borderRadius: 6,
|
||||
border: '1px solid var(--border)',
|
||||
background: useViewportFilter ? 'rgba(59,130,246,.18)' : 'var(--card)',
|
||||
color: 'var(--text)', cursor: 'pointer',
|
||||
}}
|
||||
title="지도/리스트에 현재 화면 영역 내 선박만 표시(클라이언트 필터)"
|
||||
>
|
||||
Viewport filter {useViewportFilter ? 'ON' : 'OFF'}
|
||||
</button>
|
||||
<Section title="ADMIN · Legacy (CN Permit)" defaultOpen={false}>
|
||||
{legacyError ? (
|
||||
<div style={{ fontSize: 11, color: '#EF4444' }}>legacy load error: {legacyError}</div>
|
||||
) : (
|
||||
<div style={{ fontSize: 11, lineHeight: 1.45 }}>
|
||||
<div style={{ color: 'var(--muted)', fontSize: 10 }}>데이터셋</div>
|
||||
<div style={{ wordBreak: 'break-all', fontSize: 10 }}>/data/legacy/chinese-permitted.v1.json</div>
|
||||
<div style={{ marginTop: 6, color: 'var(--muted)', fontSize: 10 }}>매칭(현재 scope)</div>
|
||||
<div>
|
||||
<b style={{ color: '#F59E0B' }}>{legacyVesselsAll.length}</b>{' '}
|
||||
<span style={{ color: 'var(--muted)', fontSize: 10 }}>/ {targetsInScope.length}</span>
|
||||
</div>
|
||||
<div style={{ marginTop: 6, color: 'var(--muted)', fontSize: 10 }}>생성시각</div>
|
||||
<div style={{ fontSize: 10, color: 'var(--text)' }}>{legacyData?.generatedAt ? fmtIsoFull(legacyData.generatedAt) : 'loading...'}</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!viewBbox) return;
|
||||
setUseApiBbox((v) => {
|
||||
const next = !v;
|
||||
if (next && viewBbox) setApiBbox(fmtBbox(viewBbox));
|
||||
if (!next) setApiBbox(undefined);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
style={{
|
||||
fontSize: 10, padding: '4px 8px', borderRadius: 6,
|
||||
border: '1px solid var(--border)',
|
||||
background: useApiBbox ? 'rgba(245,158,11,.14)' : 'var(--card)',
|
||||
color: viewBbox ? 'var(--text)' : 'var(--muted)',
|
||||
cursor: viewBbox ? 'pointer' : 'not-allowed',
|
||||
}}
|
||||
title="서버에서 bbox로 필터링해서 내려받기(페이로드 감소). 켜는 순간 현재 view bbox로 고정됨."
|
||||
disabled={!viewBbox}
|
||||
>
|
||||
API bbox {useApiBbox ? 'ON' : 'OFF'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!viewBbox) return;
|
||||
setApiBbox(fmtBbox(viewBbox));
|
||||
setUseApiBbox(true);
|
||||
}}
|
||||
style={{
|
||||
fontSize: 10, padding: '4px 8px', borderRadius: 6,
|
||||
border: '1px solid var(--border)',
|
||||
background: 'var(--card)',
|
||||
color: viewBbox ? 'var(--text)' : 'var(--muted)',
|
||||
cursor: viewBbox ? 'pointer' : 'not-allowed',
|
||||
}}
|
||||
disabled={!viewBbox}
|
||||
title="현재 view bbox로 API bbox를 갱신"
|
||||
>
|
||||
bbox=viewport
|
||||
</button>
|
||||
<Section title="ADMIN · Viewport / BBox" defaultOpen={false}>
|
||||
<div style={{ fontSize: 11, lineHeight: 1.45 }}>
|
||||
<div style={{ color: 'var(--muted)', fontSize: 10 }}>현재 View BBox</div>
|
||||
<div style={{ wordBreak: 'break-all', fontSize: 10 }}>{fmtBbox(viewBbox)}</div>
|
||||
<div style={{ marginTop: 8, display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
<button
|
||||
onClick={() => setUseViewportFilter((v) => !v)}
|
||||
style={{
|
||||
fontSize: 10, padding: '4px 8px', borderRadius: 6,
|
||||
border: '1px solid var(--border)',
|
||||
background: useViewportFilter ? 'rgba(59,130,246,.18)' : 'var(--card)',
|
||||
color: 'var(--text)', cursor: 'pointer',
|
||||
}}
|
||||
title="지도/리스트에 현재 화면 영역 내 선박만 표시(클라이언트 필터)"
|
||||
>
|
||||
Viewport filter {useViewportFilter ? 'ON' : 'OFF'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!viewBbox) return;
|
||||
setUseApiBbox((v) => {
|
||||
const next = !v;
|
||||
if (next && viewBbox) setApiBbox(fmtBbox(viewBbox));
|
||||
if (!next) setApiBbox(undefined);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
style={{
|
||||
fontSize: 10, padding: '4px 8px', borderRadius: 6,
|
||||
border: '1px solid var(--border)',
|
||||
background: useApiBbox ? 'rgba(245,158,11,.14)' : 'var(--card)',
|
||||
color: viewBbox ? 'var(--text)' : 'var(--muted)',
|
||||
cursor: viewBbox ? 'pointer' : 'not-allowed',
|
||||
}}
|
||||
title="서버에서 bbox로 필터링해서 내려받기"
|
||||
disabled={!viewBbox}
|
||||
>
|
||||
API bbox {useApiBbox ? 'ON' : 'OFF'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { if (!viewBbox) return; setApiBbox(fmtBbox(viewBbox)); setUseApiBbox(true); }}
|
||||
style={{
|
||||
fontSize: 10, padding: '4px 8px', borderRadius: 6,
|
||||
border: '1px solid var(--border)', background: 'var(--card)',
|
||||
color: viewBbox ? 'var(--text)' : 'var(--muted)', cursor: viewBbox ? 'pointer' : 'not-allowed',
|
||||
}}
|
||||
disabled={!viewBbox}
|
||||
title="현재 view bbox로 API bbox를 갱신"
|
||||
>
|
||||
bbox=viewport
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ marginTop: 8, color: 'var(--muted)', fontSize: 10 }}>
|
||||
표시 선박: <b style={{ color: 'var(--text)' }}>{targetsInScope.length}</b> / 스토어:{' '}
|
||||
<b style={{ color: 'var(--text)' }}>{snapshot.total}</b>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginTop: 8, color: 'var(--muted)', fontSize: 10 }}>
|
||||
표시 선박: <b style={{ color: 'var(--text)' }}>{targetsInScope.length}</b> / 스토어:{' '}
|
||||
<b style={{ color: 'var(--text)' }}>{snapshot.total}</b>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<div className="sb">
|
||||
<div className="sb-t">ADMIN · Map (Extras)</div>
|
||||
<Map3DSettingsToggles value={settings} onToggle={(k) => setSettings((prev) => ({ ...prev, [k]: !prev[k] }))} />
|
||||
<div style={{ fontSize: 10, color: 'var(--muted)', marginTop: 6 }}>단일 WebGL 컨텍스트: MapboxOverlay(interleaved)</div>
|
||||
</div>
|
||||
<Section title="ADMIN · Map (Extras)" defaultOpen={false}>
|
||||
<Map3DSettingsToggles value={settings} onToggle={(k) => setSettings((prev) => ({ ...prev, [k]: !prev[k] }))} />
|
||||
<div style={{ fontSize: 10, color: 'var(--muted)', marginTop: 6 }}>단일 WebGL 컨텍스트: MapboxOverlay(interleaved)</div>
|
||||
</Section>
|
||||
|
||||
<div className="sb" style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
||||
<div className="sb-t">ADMIN · AIS Targets (All)</div>
|
||||
<AisTargetList
|
||||
targets={targetsInScope}
|
||||
selectedMmsi={selectedMmsi}
|
||||
onSelectMmsi={setSelectedMmsi}
|
||||
legacyIndex={legacyIndex}
|
||||
/>
|
||||
</div>
|
||||
<Section
|
||||
title="ADMIN · AIS Targets (All)"
|
||||
defaultOpen={false}
|
||||
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"
|
||||
>
|
||||
<AisTargetList targets={targetsInScope} selectedMmsi={selectedMmsi} onSelectMmsi={setSelectedMmsi} legacyIndex={legacyIndex} />
|
||||
</Section>
|
||||
|
||||
<div className="sb" style={{ maxHeight: 130, overflowY: 'auto' }}>
|
||||
<div className="sb-t">ADMIN · 수역 데이터</div>
|
||||
{zonesError ? (
|
||||
<div style={{ fontSize: 11, color: '#EF4444' }}>zones load error: {zonesError}</div>
|
||||
) : (
|
||||
<div style={{ fontSize: 11, color: 'var(--muted)' }}>
|
||||
{zones ? `loaded (${zones.features.length} features)` : 'loading...'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<Section title="ADMIN · 수역 데이터" defaultOpen={false} className="max-h-[130px] overflow-y-auto">
|
||||
{zonesError ? (
|
||||
<div style={{ fontSize: 11, color: '#EF4444' }}>zones load error: {zonesError}</div>
|
||||
) : (
|
||||
<div style={{ fontSize: 11, color: 'var(--muted)' }}>{zones ? `loaded (${zones.features.length} features)` : 'loading...'}</div>
|
||||
)}
|
||||
</Section>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
<div className="map-legend">
|
||||
<div className="lt">수역</div>
|
||||
{ZONE_IDS.map((z) => (
|
||||
<div key={z} className="li">
|
||||
<div className="ls" style={{ background: `${ZONE_META[z].color}33`, border: `1px solid ${ZONE_META[z].color}` }} />
|
||||
{ZONE_META[z].name}
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
className="flex w-full cursor-pointer items-center justify-between border-none bg-transparent p-0 text-left"
|
||||
onClick={() => setIsOpen((v) => !v)}
|
||||
>
|
||||
<span className="lt" style={{ marginBottom: 0 }}>범례</span>
|
||||
<span className="text-[9px] text-wing-muted">{isOpen ? '▾' : '▸'}</span>
|
||||
</button>
|
||||
|
||||
<div className="lt" style={{ marginTop: 8 }}>
|
||||
기타 AIS 선박(속도)
|
||||
</div>
|
||||
<div className="li">
|
||||
<div className="ls" style={{ background: OTHER_AIS_SPEED_HEX.fast, borderRadius: 999 }} />
|
||||
SOG ≥ 10 kt
|
||||
</div>
|
||||
<div className="li">
|
||||
<div className="ls" style={{ background: OTHER_AIS_SPEED_HEX.moving, borderRadius: 999 }} />
|
||||
1 ≤ SOG < 10 kt
|
||||
</div>
|
||||
<div className="li">
|
||||
<div className="ls" style={{ background: OTHER_AIS_SPEED_HEX.stopped, borderRadius: 999 }} />
|
||||
SOG < 1 kt
|
||||
</div>
|
||||
<div className="li">
|
||||
<div className="ls" style={{ background: OTHER_AIS_SPEED_HEX.moving, opacity: 0.55, borderRadius: 999 }} />
|
||||
SOG unknown
|
||||
</div>
|
||||
{isOpen && (
|
||||
<>
|
||||
<div className="lt" style={{ marginTop: 6 }}>수역</div>
|
||||
{ZONE_IDS.map((z) => (
|
||||
<div key={z} className="li">
|
||||
<div className="ls" style={{ background: `${ZONE_META[z].color}33`, border: `1px solid ${ZONE_META[z].color}` }} />
|
||||
{ZONE_META[z].name}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="lt" style={{ marginTop: 8 }}>
|
||||
CN Permit(업종)
|
||||
</div>
|
||||
<div className="li">
|
||||
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX.PT, borderRadius: 999 }} />
|
||||
PT 본선 (ring + 색상)
|
||||
</div>
|
||||
<div className="li">
|
||||
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX["PT-S"], borderRadius: 999 }} />
|
||||
PT-S 부속선
|
||||
</div>
|
||||
<div className="li">
|
||||
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX.GN, borderRadius: 999 }} />
|
||||
GN 유망
|
||||
</div>
|
||||
<div className="li">
|
||||
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX.OT, borderRadius: 999 }} />
|
||||
OT 1척식
|
||||
</div>
|
||||
<div className="li">
|
||||
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX.PS, borderRadius: 999 }} />
|
||||
PS 위망
|
||||
</div>
|
||||
<div className="li">
|
||||
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX.FC, borderRadius: 999 }} />
|
||||
FC 운반선
|
||||
</div>
|
||||
<div className="li">
|
||||
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX.C21, borderRadius: 999 }} />
|
||||
C21
|
||||
</div>
|
||||
<div className="lt" style={{ marginTop: 8 }}>기타 AIS 선박(속도)</div>
|
||||
<div className="li">
|
||||
<div className="ls" style={{ background: OTHER_AIS_SPEED_HEX.fast, borderRadius: 999 }} />
|
||||
SOG ≥ 10 kt
|
||||
</div>
|
||||
<div className="li">
|
||||
<div className="ls" style={{ background: OTHER_AIS_SPEED_HEX.moving, borderRadius: 999 }} />
|
||||
1 ≤ SOG < 10 kt
|
||||
</div>
|
||||
<div className="li">
|
||||
<div className="ls" style={{ background: OTHER_AIS_SPEED_HEX.stopped, borderRadius: 999 }} />
|
||||
SOG < 1 kt
|
||||
</div>
|
||||
<div className="li">
|
||||
<div className="ls" style={{ background: OTHER_AIS_SPEED_HEX.moving, opacity: 0.55, borderRadius: 999 }} />
|
||||
SOG unknown
|
||||
</div>
|
||||
|
||||
<div className="lt" style={{ marginTop: 8 }}>
|
||||
밀도(3D)
|
||||
</div>
|
||||
<div className="li" style={{ color: "var(--muted)" }}>
|
||||
Hexagon: 화면 내 AIS 포인트 집계
|
||||
</div>
|
||||
<div className="lt" style={{ marginTop: 8 }}>CN Permit(업종)</div>
|
||||
<div className="li">
|
||||
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX.PT, borderRadius: 999 }} />
|
||||
PT 본선
|
||||
</div>
|
||||
<div className="li">
|
||||
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX["PT-S"], borderRadius: 999 }} />
|
||||
PT-S 부속선
|
||||
</div>
|
||||
<div className="li">
|
||||
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX.GN, borderRadius: 999 }} />
|
||||
GN 유망
|
||||
</div>
|
||||
<div className="li">
|
||||
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX.OT, borderRadius: 999 }} />
|
||||
OT 1척식
|
||||
</div>
|
||||
<div className="li">
|
||||
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX.PS, borderRadius: 999 }} />
|
||||
PS 위망
|
||||
</div>
|
||||
<div className="li">
|
||||
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX.FC, borderRadius: 999 }} />
|
||||
FC 운반선
|
||||
</div>
|
||||
<div className="li">
|
||||
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX.C21, borderRadius: 999 }} />
|
||||
C21
|
||||
</div>
|
||||
|
||||
<div className="lt" style={{ marginTop: 8 }}>
|
||||
연결선
|
||||
</div>
|
||||
<div className="li">
|
||||
<div style={{ width: 20, height: 2, background: rgba(OVERLAY_RGB.pairNormal, 0.35), borderRadius: 1 }} />
|
||||
PT↔PT-S 쌍 (정상)
|
||||
</div>
|
||||
<div className="li">
|
||||
<div style={{ width: 14, height: 14, borderRadius: "50%", border: `1px solid ${rgba(OVERLAY_RGB.pairNormal, 0.6)}` }} />
|
||||
쌍 연결범위
|
||||
</div>
|
||||
<div className="li">
|
||||
<div style={{ width: 20, height: 2, background: rgbToHex(OVERLAY_RGB.pairWarn), borderRadius: 1 }} />
|
||||
쌍 이격 경고 (>3NM)
|
||||
</div>
|
||||
<div className="li">
|
||||
<div style={{ width: 20, height: 2, background: rgbToHex(OVERLAY_RGB.fcTransfer), borderRadius: 1 }} />
|
||||
FC 환적 연결 (dashed)
|
||||
</div>
|
||||
<div className="li">
|
||||
<div style={{ width: 14, height: 14, borderRadius: "50%", border: `1px solid ${rgba(OVERLAY_RGB.fleetRange, 0.75)}`, opacity: 0.8 }} />
|
||||
선단 범위
|
||||
</div>
|
||||
<div className="li">
|
||||
<div style={{ width: 20, height: 2, background: rgbToHex(OVERLAY_RGB.suspicious), borderRadius: 1 }} />
|
||||
FC 환적 연결 (의심)
|
||||
</div>
|
||||
<div className="li">
|
||||
<div
|
||||
style={{
|
||||
width: 20,
|
||||
height: 2,
|
||||
borderRadius: 1,
|
||||
background: "repeating-linear-gradient(to right, rgba(226,232,240,0.55), rgba(226,232,240,0.55) 4px, rgba(0,0,0,0) 4px, rgba(0,0,0,0) 7px)",
|
||||
}}
|
||||
/>
|
||||
예측 벡터 (15분)
|
||||
</div>
|
||||
<div className="lt" style={{ marginTop: 8 }}>밀도(3D)</div>
|
||||
<div className="li" style={{ color: "var(--muted)" }}>
|
||||
Hexagon: 화면 내 AIS 포인트 집계
|
||||
</div>
|
||||
|
||||
<div className="lt" style={{ marginTop: 8 }}>연결선</div>
|
||||
<div className="li">
|
||||
<div style={{ width: 20, height: 2, background: rgba(OVERLAY_RGB.pairNormal, 0.35), borderRadius: 1 }} />
|
||||
PT↔PT-S 쌍 (정상)
|
||||
</div>
|
||||
<div className="li">
|
||||
<div style={{ width: 14, height: 14, borderRadius: "50%", border: `1px solid ${rgba(OVERLAY_RGB.pairNormal, 0.6)}` }} />
|
||||
쌍 연결범위
|
||||
</div>
|
||||
<div className="li">
|
||||
<div style={{ width: 20, height: 2, background: rgbToHex(OVERLAY_RGB.pairWarn), borderRadius: 1 }} />
|
||||
쌍 이격 경고 (>3NM)
|
||||
</div>
|
||||
<div className="li">
|
||||
<div style={{ width: 20, height: 2, background: rgbToHex(OVERLAY_RGB.fcTransfer), borderRadius: 1 }} />
|
||||
FC 환적 연결 (dashed)
|
||||
</div>
|
||||
<div className="li">
|
||||
<div style={{ width: 14, height: 14, borderRadius: "50%", border: `1px solid ${rgba(OVERLAY_RGB.fleetRange, 0.75)}`, opacity: 0.8 }} />
|
||||
선단 범위
|
||||
</div>
|
||||
<div className="li">
|
||||
<div style={{ width: 20, height: 2, background: rgbToHex(OVERLAY_RGB.suspicious), borderRadius: 1 }} />
|
||||
FC 환적 연결 (의심)
|
||||
</div>
|
||||
<div className="li">
|
||||
<div
|
||||
style={{
|
||||
width: 20,
|
||||
height: 2,
|
||||
borderRadius: 1,
|
||||
background: "repeating-linear-gradient(to right, rgba(226,232,240,0.55), rgba(226,232,240,0.55) 4px, rgba(0,0,0,0) 4px, rgba(0,0,0,0) 7px)",
|
||||
}}
|
||||
/>
|
||||
예측 벡터 (15분)
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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<Props, "total" | "fishing" | "transit" | "pairLinks" | "alarms">) {
|
||||
return (
|
||||
<div className="col-span-full flex items-center gap-2.5 border-b border-wing-border bg-wing-surface px-3.5 z-[1000]">
|
||||
<div
|
||||
className="flex items-center gap-1.5 whitespace-nowrap text-sm font-extrabold"
|
||||
onClick={onLogoClick}
|
||||
style={{ cursor: onLogoClick ? "pointer" : undefined }}
|
||||
title={adminMode ? "ADMIN" : undefined}
|
||||
>
|
||||
🛰 <span className="text-wing-accent">WING</span> 조업감시·선단연관{" "}
|
||||
{adminMode ? <span className="text-[10px] text-wing-warning">(ADMIN)</span> : null}
|
||||
<>
|
||||
<div className="flex items-center gap-1 text-[10px] text-wing-muted">
|
||||
<b className="text-xs text-wing-text">{total}</b>척
|
||||
</div>
|
||||
<div className="ml-auto flex flex-wrap justify-end gap-3.5">
|
||||
<div className="flex items-center gap-1 text-[10px] text-wing-muted">
|
||||
DATA <b style={{ color: "#22C55E" }}>API</b>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-[10px] text-wing-muted">
|
||||
POLL{" "}
|
||||
<b style={{ color: statusColor }}>
|
||||
{pollingStatus.toUpperCase()}
|
||||
{lastFetchMinutes ? `(${lastFetchMinutes}m)` : ""}
|
||||
</b>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-[10px] text-wing-muted">
|
||||
전체 <b className="text-xs text-wing-text">{total}</b>척
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-[10px] text-wing-muted">
|
||||
조업 <b style={{ color: "#22C55E" }}>{fishing}</b>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-[10px] text-wing-muted">
|
||||
항해 <b style={{ color: "#3B82F6" }}>{transit}</b>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-[10px] text-wing-muted">
|
||||
쌍연결 <b style={{ color: "#F59E0B" }}>{pairLinks}</b>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-[10px] text-wing-muted">
|
||||
경고 <b style={{ color: "#EF4444" }}>{alarms}</b>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-[10px] text-wing-muted">
|
||||
조업 <b style={{ color: "#22C55E" }}>{fishing}</b>
|
||||
</div>
|
||||
<div className="ml-2.5 whitespace-nowrap text-[10px] font-semibold text-wing-accent">{clock}</div>
|
||||
{onToggleTheme && (
|
||||
<button
|
||||
className="ml-1 cursor-pointer rounded border border-wing-border bg-transparent px-1.5 py-0.5 text-[9px] text-wing-muted transition-all duration-150 hover:border-wing-accent hover:text-wing-text"
|
||||
onClick={onToggleTheme}
|
||||
title={theme === "dark" ? "라이트 모드로 전환" : "다크 모드로 전환"}
|
||||
<div className="flex items-center gap-1 text-[10px] text-wing-muted">
|
||||
항해 <b style={{ color: "#3B82F6" }}>{transit}</b>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-[10px] text-wing-muted">
|
||||
쌍연결 <b style={{ color: "#F59E0B" }}>{pairLinks}</b>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-[10px] text-wing-muted">
|
||||
경고 <b style={{ color: "#EF4444" }}>{alarms}</b>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function Topbar({ total, fishing, transit, pairLinks, alarms, clock, adminMode, onLogoClick, userName, onLogout, theme, onToggleTheme, isSidebarOpen, onMenuToggle }: Props) {
|
||||
const [isStatsOpen, setIsStatsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="col-span-full relative z-[1000]">
|
||||
<div className="flex h-[44px] items-center gap-2.5 border-b border-wing-border bg-wing-surface px-3.5">
|
||||
{/* 햄버거 메뉴 (모바일) */}
|
||||
{onMenuToggle && (
|
||||
<button
|
||||
className="flex cursor-pointer items-center justify-center rounded border border-wing-border bg-transparent p-1 text-wing-muted transition-colors hover:border-wing-accent hover:text-wing-text md:hidden"
|
||||
onClick={onMenuToggle}
|
||||
aria-label={isSidebarOpen ? "메뉴 닫기" : "메뉴 열기"}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
{isSidebarOpen ? (
|
||||
<>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<line x1="3" y1="6" x2="21" y2="6" />
|
||||
<line x1="3" y1="12" x2="21" y2="12" />
|
||||
<line x1="3" y1="18" x2="21" y2="18" />
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 로고 */}
|
||||
<div
|
||||
className="flex items-center gap-1.5 whitespace-nowrap text-sm font-extrabold"
|
||||
onClick={onLogoClick}
|
||||
style={{ cursor: onLogoClick ? "pointer" : undefined }}
|
||||
title={adminMode ? "ADMIN" : undefined}
|
||||
>
|
||||
{theme === "dark" ? "Light" : "Dark"}
|
||||
</button>
|
||||
)}
|
||||
{userName && (
|
||||
<div className="ml-2.5 flex shrink-0 items-center gap-2">
|
||||
<span className="whitespace-nowrap text-[10px] font-medium text-wing-text">{userName}</span>
|
||||
{onLogout && (
|
||||
<span className="text-wing-accent">WING</span>
|
||||
<span className="hidden sm:inline">조업감시·선단연관</span>
|
||||
{adminMode ? <span className="text-[10px] text-wing-warning">(ADMIN)</span> : null}
|
||||
</div>
|
||||
|
||||
{/* 데스크톱: 인라인 통계 */}
|
||||
<div className="ml-auto hidden items-center gap-3.5 md:flex">
|
||||
<StatChips total={total} fishing={fishing} transit={transit} pairLinks={pairLinks} alarms={alarms} />
|
||||
</div>
|
||||
|
||||
{/* 항상 표시: 시계 + 테마 + 사용자 */}
|
||||
<div className="ml-auto flex shrink-0 items-center gap-2.5 md:ml-2.5">
|
||||
<span className="whitespace-nowrap text-[10px] font-semibold text-wing-accent">{clock}</span>
|
||||
{onToggleTheme && (
|
||||
<button
|
||||
className="cursor-pointer whitespace-nowrap rounded border border-wing-border bg-transparent px-1.5 py-0.5 text-[9px] text-wing-muted transition-all duration-150 hover:border-wing-accent hover:text-wing-text"
|
||||
onClick={onLogout}
|
||||
className="cursor-pointer rounded border border-wing-border bg-transparent px-1.5 py-0.5 text-[9px] text-wing-muted transition-all duration-150 hover:border-wing-accent hover:text-wing-text"
|
||||
onClick={onToggleTheme}
|
||||
title={theme === "dark" ? "라이트 모드로 전환" : "다크 모드로 전환"}
|
||||
>
|
||||
로그아웃
|
||||
{theme === "dark" ? "Light" : "Dark"}
|
||||
</button>
|
||||
)}
|
||||
{userName && (
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<span className="hidden whitespace-nowrap text-[10px] font-medium text-wing-text sm:inline">{userName}</span>
|
||||
{onLogout && (
|
||||
<button
|
||||
className="cursor-pointer whitespace-nowrap rounded border border-wing-border bg-transparent px-1.5 py-0.5 text-[9px] text-wing-muted transition-all duration-150 hover:border-wing-accent hover:text-wing-text"
|
||||
onClick={onLogout}
|
||||
>
|
||||
로그아웃
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 모바일 통계 바 (펼침 시) — 레이아웃 흐름에 포함, 지도 영역 밀어내기 */}
|
||||
{isStatsOpen && (
|
||||
<div className="flex items-center justify-center gap-4 border-b border-wing-border bg-wing-surface px-3.5 py-2 md:hidden">
|
||||
<StatChips total={total} fishing={fishing} transit={transit} pairLinks={pairLinks} alarms={alarms} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 모바일 통계 토글 탭 — topbar 하단 우측에 걸침 */}
|
||||
<button
|
||||
className="absolute bottom-0 right-3.5 z-10 translate-y-full cursor-pointer rounded-b-md border border-t-0 border-wing-border bg-wing-surface px-2 py-0.5 text-[8px] text-wing-muted transition-colors hover:text-wing-text md:hidden"
|
||||
onClick={() => setIsStatsOpen((v) => !v)}
|
||||
aria-label={isStatsOpen ? "통계 닫기" : "통계 열기"}
|
||||
>
|
||||
{isStatsOpen ? "▴" : "▾ 통계"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user