MapTiler Ocean 완전 스타일 기반 별도 베이스맵 모드. features/oceanMap/ 자체 완결 블록 — 기존 enhanced 코드 변경 없음. - resolveOceanStyle: Ocean style.json fetch + 한국어 라벨 - useOceanMapSettings: 런타임 커스텀 (수심색상/등심선/hillshade/라벨) - OceanMapSettingsPanel: 9개 섹션 설정 UI - 사이드바 Ocean 토글 + 설정 패널 baseMap 분기 - resolveMapStyle dynamic import로 번들 분리 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
427 lines
19 KiB
TypeScript
427 lines
19 KiB
TypeScript
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';
|
|
import type { LegacyVesselDataset, LegacyVesselInfo } from '../../entities/legacyVessel/model/types';
|
|
import type { VesselTypeCode } from '../../entities/vessel/model/types';
|
|
import { VESSEL_TYPE_ORDER } from '../../entities/vessel/model/meta';
|
|
import type { ZonesGeoJson } from '../../entities/zone/api/useZones';
|
|
import type { AisPollingSnapshot } from '../../features/aisPolling/useAisTargetPolling';
|
|
import { Map3DSettingsToggles } from '../../features/map3dSettings/Map3DSettingsToggles';
|
|
import type { DerivedLegacyVessel, LegacyAlarm, LegacyAlarmKind } from '../../features/legacyDashboard/model/types';
|
|
import { LEGACY_ALARM_KIND_LABEL, LEGACY_ALARM_KINDS } from '../../features/legacyDashboard/model/types';
|
|
import { MapToggles } from '../../features/mapToggles/MapToggles';
|
|
import { TypeFilterGrid } from '../../features/typeFilter/TypeFilterGrid';
|
|
import { AisTargetList } from '../../widgets/aisTargetList/AisTargetList';
|
|
import { AlarmsPanel } from '../../widgets/alarms/AlarmsPanel';
|
|
import { RelationsPanel } from '../../widgets/relations/RelationsPanel';
|
|
import { SpeedProfilePanel } from '../../widgets/speed/SpeedProfilePanel';
|
|
import { VesselList } from '../../widgets/vesselList/VesselList';
|
|
import { fmtIsoFull } from '../../shared/lib/datetime';
|
|
import type { useDashboardState } from './useDashboardState';
|
|
import type { Bbox } from './useDashboardState';
|
|
|
|
const AIS_API_BASE = (import.meta.env.VITE_API_URL || '/snp-api').replace(/\/$/, '');
|
|
|
|
function fmtBbox(b: Bbox | null) {
|
|
if (!b) return '-';
|
|
return `${b[0].toFixed(4)},${b[1].toFixed(4)},${b[2].toFixed(4)},${b[3].toFixed(4)}`;
|
|
}
|
|
|
|
interface DashboardSidebarProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
state: ReturnType<typeof useDashboardState>;
|
|
legacyVesselsAll: DerivedLegacyVessel[];
|
|
legacyVesselsFiltered: DerivedLegacyVessel[];
|
|
legacyCounts: Record<VesselTypeCode, number>;
|
|
selectedLegacyVessel: DerivedLegacyVessel | null;
|
|
activeHighlightedMmsiSet: number[];
|
|
legacyHits: Map<number, LegacyVesselInfo>;
|
|
filteredAlarms: LegacyAlarm[];
|
|
alarms: LegacyAlarm[];
|
|
alarmKindCounts: Record<LegacyAlarmKind, number>;
|
|
speedPanelType: VesselTypeCode;
|
|
onFleetContextMenu: (ownerKey: string, mmsis: number[]) => void;
|
|
snapshot: AisPollingSnapshot;
|
|
legacyError: string | null;
|
|
legacyData: LegacyVesselDataset | null;
|
|
targetsInScope: AisTarget[];
|
|
zonesError: string | null;
|
|
zones: ZonesGeoJson | null;
|
|
legacyIndex: LegacyVesselIndex | null;
|
|
}
|
|
|
|
export function DashboardSidebar({
|
|
isOpen,
|
|
onClose,
|
|
state,
|
|
legacyVesselsAll,
|
|
legacyVesselsFiltered,
|
|
legacyCounts,
|
|
selectedLegacyVessel,
|
|
activeHighlightedMmsiSet,
|
|
legacyHits,
|
|
filteredAlarms,
|
|
alarms,
|
|
alarmKindCounts,
|
|
speedPanelType,
|
|
onFleetContextMenu,
|
|
snapshot,
|
|
legacyError,
|
|
legacyData,
|
|
targetsInScope,
|
|
zonesError,
|
|
zones,
|
|
legacyIndex,
|
|
}: DashboardSidebarProps) {
|
|
const {
|
|
showTargets, setShowTargets, showOthers, setShowOthers,
|
|
typeEnabled, setTypeEnabled,
|
|
overlays, setOverlays,
|
|
projection, setProjection, isProjectionToggleDisabled,
|
|
freeCamera, toggleFreeCamera,
|
|
baseMap, setBaseMap,
|
|
selectedMmsi, setSelectedMmsi,
|
|
fleetRelationSortMode, setFleetRelationSortMode,
|
|
hoveredFleetOwnerKey, hoveredFleetMmsiSet,
|
|
setHoveredMmsiSet, setHoveredPairMmsiSet,
|
|
setHoveredFleetOwnerKey, setHoveredFleetMmsiSet,
|
|
alarmKindEnabled, setAlarmKindEnabled,
|
|
adminMode,
|
|
viewBbox, useViewportFilter, setUseViewportFilter,
|
|
useApiBbox, setUseApiBbox, setApiBbox,
|
|
settings, setSettings,
|
|
setUniqueSorted, setSortedIfChanged, toggleHighlightedMmsi,
|
|
} = state;
|
|
|
|
const [isAlarmFilterOpen, setIsAlarmFilterOpen] = useState(false);
|
|
|
|
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 (
|
|
<>
|
|
{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 md:overflow-hidden md:flex md:flex-col
|
|
`}
|
|
>
|
|
<div className="h-[44px] md:hidden" />
|
|
|
|
<Section title="업종 필터" className="md:shrink-0">
|
|
<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] }));
|
|
}}
|
|
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>
|
|
|
|
<Section
|
|
title="지도 표시 설정"
|
|
className="md:shrink-0"
|
|
actions={
|
|
<div className="flex gap-1">
|
|
<ToggleButton
|
|
on={freeCamera}
|
|
onClick={toggleFreeCamera}
|
|
title="자유 시점 모드 (회전/틸트 허용)"
|
|
className="px-2 py-0.5 text-[9px]"
|
|
>
|
|
자유 시점
|
|
</ToggleButton>
|
|
<ToggleButton
|
|
on={baseMap === 'ocean'}
|
|
onClick={() => setBaseMap(baseMap === 'ocean' ? 'enhanced' : 'ocean')}
|
|
title="Ocean 전용 지도 (해양 정보 극대화)"
|
|
className="px-2 py-0.5 text-[9px]"
|
|
>
|
|
Ocean
|
|
</ToggleButton>
|
|
<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]${isProjectionToggleDisabled ? " opacity-40 cursor-not-allowed" : ""}`}
|
|
>
|
|
3D
|
|
</ToggleButton>
|
|
</div>
|
|
}
|
|
>
|
|
<MapToggles value={overlays} onToggle={(k) => setOverlays((prev) => ({ ...prev, [k]: !prev[k] }))} />
|
|
</Section>
|
|
|
|
<Section title="속도 프로파일" defaultOpen={false} className="md:shrink-0">
|
|
<SpeedProfilePanel selectedType={speedPanelType} />
|
|
</Section>
|
|
|
|
<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="md:shrink-0 max-h-[260px] flex flex-col overflow-hidden"
|
|
contentClassName="flex-1 min-h-0 overflow-y-auto"
|
|
>
|
|
<RelationsPanel
|
|
selectedVessel={selectedLegacyVessel}
|
|
vessels={legacyVesselsAll}
|
|
fleetVessels={legacyVesselsFiltered}
|
|
onSelectMmsi={setSelectedMmsi}
|
|
onToggleHighlightMmsi={toggleHighlightedMmsi}
|
|
onHoverMmsi={(mmsis) => setHoveredMmsiSet(setUniqueSorted(mmsis))}
|
|
onClearHover={() => setHoveredMmsiSet([])}
|
|
onHoverPair={(pairMmsis) => setHoveredPairMmsiSet(setUniqueSorted(pairMmsis))}
|
|
onClearPairHover={() => setHoveredPairMmsiSet([])}
|
|
onHoverFleet={(ownerKey, fleetMmsis) => {
|
|
setHoveredFleetOwnerKey(ownerKey);
|
|
setHoveredFleetMmsiSet(setSortedIfChanged(fleetMmsis));
|
|
}}
|
|
onClearFleetHover={() => {
|
|
setHoveredFleetOwnerKey(null);
|
|
setHoveredFleetMmsiSet((prev) => (prev.length === 0 ? prev : []));
|
|
}}
|
|
fleetSortMode={fleetRelationSortMode}
|
|
hoveredFleetOwnerKey={hoveredFleetOwnerKey}
|
|
hoveredFleetMmsiSet={hoveredFleetMmsiSet}
|
|
onContextMenuFleet={onFleetContextMenu}
|
|
/>
|
|
</Section>
|
|
|
|
<Section
|
|
title={
|
|
<>
|
|
선박 목록{' '}
|
|
<span className="text-[8px] font-normal normal-case tracking-normal text-wing-accent">({legacyVesselsFiltered.length}척)</span>
|
|
</>
|
|
}
|
|
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"
|
|
>
|
|
<VesselList
|
|
vessels={legacyVesselsFiltered}
|
|
selectedMmsi={selectedMmsi}
|
|
highlightedMmsiSet={activeHighlightedMmsiSet}
|
|
onSelectMmsi={setSelectedMmsi}
|
|
onToggleHighlightMmsi={toggleHighlightedMmsi}
|
|
onHoverMmsi={(mmsi) => setHoveredMmsiSet([mmsi])}
|
|
onClearHover={() => setHoveredMmsiSet([])}
|
|
/>
|
|
</Section>
|
|
|
|
<Section
|
|
title={
|
|
<>
|
|
실시간 경고{' '}
|
|
<span className="text-[8px] font-normal normal-case tracking-normal text-wing-accent">({filteredAlarms.length}/{alarms.length})</span>
|
|
</>
|
|
}
|
|
actions={
|
|
<ToggleButton on={isAlarmFilterOpen} onClick={() => setIsAlarmFilterOpen((v) => !v)}>
|
|
필터
|
|
</ToggleButton>
|
|
}
|
|
className="md:shrink-0 max-h-[180px] flex flex-col overflow-hidden"
|
|
contentClassName="flex-1 min-h-0 overflow-y-auto"
|
|
>
|
|
{isAlarmFilterOpen && (
|
|
<div className="flex flex-wrap gap-x-2.5 gap-y-1 mb-1.5">
|
|
{LEGACY_ALARM_KINDS.map((k) => (
|
|
<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 className="text-wing-text">{alarmKindCounts[k] ?? 0}</span>
|
|
</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
)}
|
|
<AlarmsPanel alarms={filteredAlarms} onSelectMmsi={setSelectedMmsi} />
|
|
</Section>
|
|
|
|
{adminMode ? (
|
|
<>
|
|
<Section title="ADMIN · AIS Target Polling" defaultOpen={false} className="md:shrink-0">
|
|
<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' ? 'var(--wing-success)' : snapshot.status === 'error' ? 'var(--wing-danger)' : 'var(--wing-warning)' }}>
|
|
{snapshot.status.toUpperCase()}
|
|
</b>
|
|
{snapshot.error ? <span style={{ marginLeft: 6, color: 'var(--wing-danger)' }}>{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>
|
|
</Section>
|
|
|
|
<Section title="ADMIN · Legacy (CN Permit)" defaultOpen={false} className="md:shrink-0">
|
|
{legacyError ? (
|
|
<div style={{ fontSize: 11, color: 'var(--wing-danger)' }}>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: 'var(--wing-warning)' }}>{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>
|
|
|
|
<Section title="ADMIN · Viewport / BBox" defaultOpen={false} className="md:shrink-0">
|
|
<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>
|
|
</Section>
|
|
|
|
<Section title="ADMIN · Map (Extras)" defaultOpen={false} className="md:shrink-0">
|
|
<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>
|
|
|
|
<Section
|
|
title="ADMIN · AIS Targets (All)"
|
|
defaultOpen={false}
|
|
className="md:shrink-0 md:max-h-[200px] flex flex-col md:overflow-hidden"
|
|
contentClassName="md:flex-1 md:min-h-0 md:overflow-y-auto"
|
|
>
|
|
<AisTargetList targets={targetsInScope} selectedMmsi={selectedMmsi} onSelectMmsi={setSelectedMmsi} legacyIndex={legacyIndex} />
|
|
</Section>
|
|
|
|
<Section title="ADMIN · 수역 데이터" defaultOpen={false} className="md:shrink-0">
|
|
{zonesError ? (
|
|
<div style={{ fontSize: 11, color: 'var(--wing-danger)' }}>zones load error: {zonesError}</div>
|
|
) : (
|
|
<div style={{ fontSize: 11, color: 'var(--muted)' }}>{zones ? `loaded (${zones.features.length} features)` : 'loading...'}</div>
|
|
)}
|
|
</Section>
|
|
</>
|
|
) : null}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|