Merge pull request 'feat: 한국 현황 위성지도/ENC 토글 + ENC 스타일 설정' (#215) from feature/enc-map-toggle into develop

This commit is contained in:
htlee 2026-04-01 15:02:56 +09:00
커밋 d44837e64a
7개의 변경된 파일400개의 추가작업 그리고 5개의 파일을 삭제

파일 보기

@ -2,6 +2,9 @@ import { useState, useMemo, useCallback } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useLocalStorage, useLocalStorageSet } from '../../hooks/useLocalStorage'; import { useLocalStorage, useLocalStorageSet } from '../../hooks/useLocalStorage';
import { DEFAULT_ENC_MAP_SETTINGS } from '../../features/encMap/types';
import type { EncMapSettings } from '../../features/encMap/types';
import { EncMapSettingsPanel } from '../../features/encMap/EncMapSettingsPanel';
import { KoreaMap } from './KoreaMap'; import { KoreaMap } from './KoreaMap';
import { FieldAnalysisModal } from './FieldAnalysisModal'; import { FieldAnalysisModal } from './FieldAnalysisModal';
import { ReportModal } from './ReportModal'; import { ReportModal } from './ReportModal';
@ -88,6 +91,9 @@ export const KoreaDashboard = ({
const [externalFlyTo, setExternalFlyTo] = useState<{ lat: number; lng: number; zoom: number } | null>(null); const [externalFlyTo, setExternalFlyTo] = useState<{ lat: number; lng: number; zoom: number } | null>(null);
const { t } = useTranslation(); const { t } = useTranslation();
const [mapMode, setMapMode] = useLocalStorage<'satellite' | 'enc'>('koreaMapMode', 'satellite');
const [encSettings, setEncSettings] = useLocalStorage<EncMapSettings>('encMapSettings', DEFAULT_ENC_MAP_SETTINGS);
const { hiddenAcCategories, hiddenShipCategories, toggleAcCategory, toggleShipCategory } = const { hiddenAcCategories, hiddenShipCategories, toggleAcCategory, toggleShipCategory } =
useSharedFilters(); useSharedFilters();
@ -274,6 +280,24 @@ export const KoreaDashboard = ({
return ( return (
<> <>
{headerSlot && createPortal( {headerSlot && createPortal(
<>
<div className="map-mode-toggle" style={{ display: 'flex', alignItems: 'center', gap: 2, marginRight: 8, position: 'relative' }}>
<button type="button"
className={`mode-btn${mapMode === 'satellite' ? ' active' : ''}`}
onClick={() => setMapMode('satellite')}
title="위성지도">
🛰
</button>
<button type="button"
className={`mode-btn${mapMode === 'enc' ? ' active' : ''}`}
onClick={() => setMapMode('enc')}
title="전자해도 (ENC)">
🗺 ENC
</button>
{mapMode === 'enc' && (
<EncMapSettingsPanel value={encSettings} onChange={setEncSettings} />
)}
</div>
<div className="mode-toggle"> <div className="mode-toggle">
<button type="button" className={`mode-btn ${koreaFiltersResult.filters.illegalFishing ? 'active live' : ''}`} <button type="button" className={`mode-btn ${koreaFiltersResult.filters.illegalFishing ? 'active live' : ''}`}
onClick={() => koreaFiltersResult.setFilter('illegalFishing', !koreaFiltersResult.filters.illegalFishing)} title={t('filters.illegalFishing')}> onClick={() => koreaFiltersResult.setFilter('illegalFishing', !koreaFiltersResult.filters.illegalFishing)} title={t('filters.illegalFishing')}>
@ -311,7 +335,8 @@ export const KoreaDashboard = ({
onClick={() => setShowOpsGuide(v => !v)} title="경비함정 작전 가이드"> onClick={() => setShowOpsGuide(v => !v)} title="경비함정 작전 가이드">
<span className="text-[11px]"></span> <span className="text-[11px]"></span>
</button> </button>
</div>, </div>
</>,
headerSlot, headerSlot,
)} )}
{countsSlot && createPortal( {countsSlot && createPortal(
@ -365,6 +390,8 @@ export const KoreaDashboard = ({
externalFlyTo={externalFlyTo} externalFlyTo={externalFlyTo}
onExternalFlyToDone={() => setExternalFlyTo(null)} onExternalFlyToDone={() => setExternalFlyTo(null)}
opsRoute={opsRoute} opsRoute={opsRoute}
mapMode={mapMode}
encSettings={encSettings}
/> />
<div className="map-overlay-left"> <div className="map-overlay-left">
<LayerPanel <LayerPanel

파일 보기

@ -2,6 +2,10 @@ import { useRef, useState, useEffect, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Map, NavigationControl, Marker, Source, Layer } from 'react-map-gl/maplibre'; import { Map, NavigationControl, Marker, Source, Layer } from 'react-map-gl/maplibre';
import type { MapRef } from 'react-map-gl/maplibre'; import type { MapRef } from 'react-map-gl/maplibre';
import type { StyleSpecification } from 'maplibre-gl';
import { fetchEncStyle } from '../../features/encMap/encStyle';
import { useEncMapSettings } from '../../features/encMap/useEncMapSettings';
import type { EncMapSettings } from '../../features/encMap/types';
import { ScatterplotLayer, TextLayer } from '@deck.gl/layers'; import { ScatterplotLayer, TextLayer } from '@deck.gl/layers';
import { useFontScale } from '../../hooks/useFontScale'; import { useFontScale } from '../../hooks/useFontScale';
import { FONT_MONO } from '../../styles/fonts'; import { FONT_MONO } from '../../styles/fonts';
@ -78,6 +82,8 @@ interface Props {
externalFlyTo?: { lat: number; lng: number; zoom: number } | null; externalFlyTo?: { lat: number; lng: number; zoom: number } | null;
onExternalFlyToDone?: () => void; onExternalFlyToDone?: () => void;
opsRoute?: { from: { lat: number; lng: number; name: string }; to: { lat: number; lng: number; name: string }; distanceNM: number; riskLevel: string } | null; opsRoute?: { from: { lat: number; lng: number; name: string }; to: { lat: number; lng: number; name: string }; distanceNM: number; riskLevel: string } | null;
mapMode: 'satellite' | 'enc';
encSettings: EncMapSettings;
} }
// MarineTraffic-style: satellite + dark ocean + nautical overlay // MarineTraffic-style: satellite + dark ocean + nautical overlay
@ -213,10 +219,26 @@ const DebugTools = import.meta.env.DEV
? lazy(() => import('./debug')) ? lazy(() => import('./debug'))
: null; : null;
export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintFeed, currentTime, koreaFilters, transshipSuspects, cableWatchSuspects, dokdoWatchSuspects, dokdoAlerts, vesselAnalysis, groupPolygons, hiddenShipCategories, hiddenNationalities, externalFlyTo, onExternalFlyToDone, opsRoute }: Props) { export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintFeed, currentTime, koreaFilters, transshipSuspects, cableWatchSuspects, dokdoWatchSuspects, dokdoAlerts, vesselAnalysis, groupPolygons, hiddenShipCategories, hiddenNationalities, externalFlyTo, onExternalFlyToDone, opsRoute, mapMode, encSettings }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const mapRef = useRef<MapRef>(null); const mapRef = useRef<MapRef>(null);
const maplibreRef = useRef<import('maplibre-gl').Map | null>(null);
const overlayRef = useRef<MapboxOverlay | null>(null); const overlayRef = useRef<MapboxOverlay | null>(null);
// ENC 스타일 사전 로드
const [encStyle, setEncStyle] = useState<StyleSpecification | null>(null);
useEffect(() => {
const ctrl = new AbortController();
fetchEncStyle(ctrl.signal).then(setEncStyle).catch(() => {});
return () => ctrl.abort();
}, []);
const activeMapStyle = mapMode === 'enc' && encStyle ? encStyle : MAP_STYLE;
// ENC 설정 적용을 트리거하는 epoch — 맵 로드/스타일 전환 시 증가
const [encSyncEpoch, setEncSyncEpoch] = useState(0);
// ENC 설정 런타임 적용
useEncMapSettings(maplibreRef, mapMode, encSettings, encSyncEpoch);
const replayLayerRef = useRef<DeckLayer[]>([]); const replayLayerRef = useRef<DeckLayer[]>([]);
const fleetClusterLayerRef = useRef<DeckLayer[]>([]); const fleetClusterLayerRef = useRef<DeckLayer[]>([]);
const requestRenderRef = useRef<(() => void) | null>(null); const requestRenderRef = useRef<(() => void) | null>(null);
@ -276,7 +298,10 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
}, []); }, []);
// MapLibre 맵 로드 완료 콜백 (ship-triangle/gear-diamond → deck.gl 전환 완료로 삭제) // MapLibre 맵 로드 완료 콜백 (ship-triangle/gear-diamond → deck.gl 전환 완료로 삭제)
const handleMapLoad = useCallback(() => {}, []); const handleMapLoad = useCallback(() => {
maplibreRef.current = mapRef.current?.getMap() ?? null;
setEncSyncEpoch(v => v + 1);
}, []);
// ── shipDeckStore 동기화 ── // ── shipDeckStore 동기화 ──
useEffect(() => { useEffect(() => {
@ -656,7 +681,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
ref={mapRef} ref={mapRef}
initialViewState={{ ...KOREA_MAP_CENTER, zoom: KOREA_MAP_ZOOM }} initialViewState={{ ...KOREA_MAP_CENTER, zoom: KOREA_MAP_ZOOM }}
style={{ width: '100%', height: '100%' }} style={{ width: '100%', height: '100%' }}
mapStyle={MAP_STYLE} mapStyle={activeMapStyle}
onZoom={handleZoom} onZoom={handleZoom}
onLoad={handleMapLoad} onLoad={handleMapLoad}
> >
@ -1057,6 +1082,8 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
</> </>
); );
})()} })()}
{/* ENC 설정 패널은 KoreaDashboard 헤더에서 렌더 */}
</Map> </Map>
); );
} }

파일 보기

@ -0,0 +1,129 @@
import { useState } from 'react';
import type { EncMapSettings } from './types';
import { DEFAULT_ENC_MAP_SETTINGS, ENC_DEPTH_COLOR_TARGETS } from './types';
import { FONT_MONO } from '../../styles/fonts';
interface EncMapSettingsPanelProps {
value: EncMapSettings;
onChange: (next: EncMapSettings) => void;
}
const SYMBOL_TOGGLES: { key: keyof EncMapSettings; label: string }[] = [
{ key: 'showBuoys', label: '부표' },
{ key: 'showBeacons', label: '비콘' },
{ key: 'showLights', label: '등대' },
{ key: 'showDangers', label: '위험물' },
{ key: 'showLandmarks', label: '랜드마크' },
{ key: 'showSoundings', label: '수심' },
{ key: 'showPilot', label: '도선소' },
{ key: 'showAnchorage', label: '정박지' },
{ key: 'showRestricted', label: '제한구역' },
{ key: 'showDredged', label: '준설구역' },
{ key: 'showTSS', label: '통항분리대' },
{ key: 'showContours', label: '등심선' },
];
const AREA_COLOR_INPUTS: { key: keyof EncMapSettings; label: string }[] = [
{ key: 'backgroundColor', label: '바다 배경' },
{ key: 'landColor', label: '육지' },
{ key: 'coastlineColor', label: '해안선' },
];
export function EncMapSettingsPanel({ value, onChange }: EncMapSettingsPanelProps) {
const [open, setOpen] = useState(false);
const update = <K extends keyof EncMapSettings>(key: K, val: EncMapSettings[K]) => {
onChange({ ...value, [key]: val });
};
const isDefault = JSON.stringify(value) === JSON.stringify(DEFAULT_ENC_MAP_SETTINGS);
const allChecked = SYMBOL_TOGGLES.every(({ key }) => value[key] as boolean);
const toggleAll = (checked: boolean) => {
const next = { ...value };
for (const { key } of SYMBOL_TOGGLES) {
(next as Record<string, unknown>)[key] = checked;
}
onChange(next);
};
return (
<>
<button
type="button"
onClick={() => setOpen(p => !p)}
title="ENC 스타일 설정"
className={`mode-btn${open ? ' active' : ''}`}
>
</button>
{open && (
<div style={{
position: 'absolute', top: '100%', left: 0, marginTop: 4, width: 240,
background: 'rgba(12,24,37,0.95)', border: '1px solid rgba(99,179,237,0.25)',
borderRadius: 8, padding: '8px 10px', zIndex: 100,
fontFamily: FONT_MONO, fontSize: 10, color: '#e2e8f0',
boxShadow: '0 4px 16px rgba(0,0,0,0.5)', maxHeight: 'calc(100vh - 80px)', overflowY: 'auto',
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
<span style={{ fontWeight: 600, fontSize: 11 }}>ENC </span>
{!isDefault && (
<button type="button" onClick={() => onChange(DEFAULT_ENC_MAP_SETTINGS)}
style={{ background: 'none', border: '1px solid rgba(239,68,68,0.3)', borderRadius: 3, color: '#f87171', cursor: 'pointer', padding: '1px 6px', fontSize: 9, fontFamily: FONT_MONO }}>
</button>
)}
</div>
{/* 레이어 토글 */}
<div style={{ marginBottom: 8 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 3, color: '#94a3b8', fontSize: 9 }}>
<span> </span>
<label style={{ display: 'flex', alignItems: 'center', gap: 3, cursor: 'pointer' }}>
<input type="checkbox" checked={allChecked} onChange={e => toggleAll(e.target.checked)} />
<span></span>
</label>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '2px 8px' }}>
{SYMBOL_TOGGLES.map(({ key, label }) => (
<label key={key} style={{ display: 'flex', alignItems: 'center', gap: 3, cursor: 'pointer' }}>
<input type="checkbox" checked={value[key] as boolean}
onChange={e => update(key, e.target.checked as never)} />
<span>{label}</span>
</label>
))}
</div>
</div>
{/* 영역 색상 */}
<div style={{ marginBottom: 8 }}>
<div style={{ color: '#94a3b8', fontSize: 9, marginBottom: 3 }}> </div>
{AREA_COLOR_INPUTS.map(({ key, label }) => (
<div key={key} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '1px 0' }}>
<span>{label}</span>
<input type="color" value={value[key] as string} title={label}
onChange={e => update(key, e.target.value as never)}
style={{ width: 24, height: 16, border: 'none', cursor: 'pointer', background: 'transparent' }} />
</div>
))}
</div>
{/* 수심 색상 */}
<div>
<div style={{ color: '#94a3b8', fontSize: 9, marginBottom: 3 }}> </div>
{ENC_DEPTH_COLOR_TARGETS.map(({ key, label }) => (
<div key={key} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '1px 0' }}>
<span>{label}</span>
<input type="color" value={value[key] as string} title={label}
onChange={e => update(key, e.target.value as never)}
style={{ width: 24, height: 16, border: 'none', cursor: 'pointer', background: 'transparent' }} />
</div>
))}
</div>
</div>
)}
</>
);
}

파일 보기

@ -0,0 +1,44 @@
import type maplibregl from 'maplibre-gl';
import type { EncMapSettings } from './types';
import { ENC_LAYER_CATEGORIES, ENC_COLOR_TARGETS, ENC_DEPTH_COLOR_TARGETS } from './types';
export function applyEncVisibility(map: maplibregl.Map, settings: EncMapSettings): void {
for (const [key, layerIds] of Object.entries(ENC_LAYER_CATEGORIES)) {
const visible = settings[key as keyof EncMapSettings] as boolean;
const vis = visible ? 'visible' : 'none';
for (const layerId of layerIds) {
try {
if (map.getLayer(layerId)) {
map.setLayoutProperty(layerId, 'visibility', vis);
}
} catch { /* layer may not exist */ }
}
}
}
export function applyEncColors(map: maplibregl.Map, settings: EncMapSettings): void {
for (const [layerId, prop, key] of ENC_COLOR_TARGETS) {
try {
if (map.getLayer(layerId)) {
map.setPaintProperty(layerId, prop, settings[key] as string);
}
} catch { /* ignore */ }
}
try {
if (map.getLayer('background')) {
map.setPaintProperty('background', 'background-color', settings.backgroundColor);
}
} catch { /* ignore */ }
for (const { key, layerIds } of ENC_DEPTH_COLOR_TARGETS) {
const color = settings[key] as string;
for (const layerId of layerIds) {
try {
if (map.getLayer(layerId)) {
map.setPaintProperty(layerId, 'fill-color', color);
}
} catch { /* ignore */ }
}
}
}

파일 보기

@ -0,0 +1,22 @@
import type { StyleSpecification } from 'maplibre-gl';
const NAUTICAL_STYLE_URL = 'https://tiles.gcnautical.com/styles/nautical.json';
const SERVER_FONTS = ['Noto Sans CJK KR Regular', 'Noto Sans Regular'];
export async function fetchEncStyle(signal: AbortSignal): Promise<StyleSpecification> {
const res = await fetch(NAUTICAL_STYLE_URL, { signal });
if (!res.ok) throw new Error(`ENC style fetch failed: ${res.status}`);
const style = (await res.json()) as StyleSpecification;
for (const layer of style.layers) {
const layout = (layer as { layout?: Record<string, unknown> }).layout;
if (!layout) continue;
const tf = layout['text-font'];
if (Array.isArray(tf) && tf.every((v) => typeof v === 'string')) {
layout['text-font'] = SERVER_FONTS;
}
}
return style;
}

파일 보기

@ -0,0 +1,82 @@
export interface EncMapSettings {
showBuoys: boolean;
showBeacons: boolean;
showLights: boolean;
showDangers: boolean;
showLandmarks: boolean;
showSoundings: boolean;
showPilot: boolean;
showAnchorage: boolean;
showRestricted: boolean;
showDredged: boolean;
showTSS: boolean;
showContours: boolean;
landColor: string;
coastlineColor: string;
backgroundColor: string;
depthDrying: string;
depthVeryShallow: string;
depthSafetyZone: string;
depthMedium: string;
depthDeep: string;
}
export const DEFAULT_ENC_MAP_SETTINGS: EncMapSettings = {
showBuoys: true,
showBeacons: true,
showLights: true,
showDangers: true,
showLandmarks: true,
showSoundings: true,
showPilot: true,
showAnchorage: true,
showRestricted: true,
showDredged: true,
showTSS: true,
showContours: true,
landColor: '#BFBE8D',
coastlineColor: '#4C5B62',
backgroundColor: '#93AEBB',
depthDrying: '#58AF99',
depthVeryShallow: '#61B7FF',
depthSafetyZone: '#82CAFF',
depthMedium: '#A7D9FA',
depthDeep: '#C9EDFD',
};
export const ENC_LAYER_CATEGORIES: Record<string, string[]> = {
showBuoys: ['boylat', 'boycar', 'boyisd', 'boysaw', 'boyspp'],
showBeacons: ['lndmrk'],
showLights: ['lights', 'lights-catlit'],
showDangers: ['uwtroc', 'obstrn', 'wrecks'],
showLandmarks: ['lndmrk'],
showSoundings: ['soundg', 'soundg-critical'],
showPilot: ['pilbop'],
showAnchorage: ['achare', 'achare-outline'],
showRestricted: ['resare-outline', 'resare-symbol', 'mipare'],
showDredged: [
'drgare-drying', 'drgare-very-shallow', 'drgare-safety-zone',
'drgare-medium', 'drgare-deep', 'drgare-pattern', 'drgare-outline', 'drgare-symbol',
],
showTSS: ['tsslpt', 'tsslpt-outline'],
showContours: ['depcnt', 'depare-safety-edge', 'depare-safety-edge-label'],
};
export const ENC_COLOR_TARGETS: [layerId: string, prop: string, settingsKey: keyof EncMapSettings][] = [
['lndare', 'fill-color', 'landColor'],
['globe-lndare', 'fill-color', 'landColor'],
['coalne', 'line-color', 'coastlineColor'],
['globe-coalne', 'line-color', 'coastlineColor'],
];
export const ENC_DEPTH_COLOR_TARGETS: { key: keyof EncMapSettings; label: string; layerIds: string[] }[] = [
{ key: 'depthDrying', label: '건출 (< 0m)', layerIds: ['depare-drying', 'drgare-drying'] },
{ key: 'depthVeryShallow', label: '극천 (0~2m)', layerIds: ['depare-very-shallow', 'drgare-very-shallow'] },
{ key: 'depthSafetyZone', label: '안전수심 (2~30m)', layerIds: ['depare-safety-zone', 'drgare-safety-zone'] },
{ key: 'depthMedium', label: '중간 (30m~)', layerIds: ['depare-medium', 'drgare-medium'] },
{ key: 'depthDeep', label: '심해', layerIds: ['depare-deep', 'drgare-deep'] },
];

파일 보기

@ -0,0 +1,64 @@
import { useEffect, useRef, type MutableRefObject } from 'react';
import type maplibregl from 'maplibre-gl';
import { applyEncVisibility, applyEncColors } from './encSettings';
import type { EncMapSettings } from './types';
/**
* callback .
* gc-wing-dev onMapStyleReady .
*/
function onStyleReady(map: maplibregl.Map, callback: () => void): () => void {
if (map.isStyleLoaded()) {
callback();
return () => {};
}
let fired = false;
const runOnce = () => {
if (fired || !map.isStyleLoaded()) return;
fired = true;
callback();
try { map.off('style.load', runOnce); map.off('styledata', runOnce); } catch { /* ignore */ }
};
map.on('style.load', runOnce);
map.on('styledata', runOnce);
return () => {
try { map.off('style.load', runOnce); map.off('styledata', runOnce); } catch { /* ignore */ }
};
}
export function useEncMapSettings(
mapRef: MutableRefObject<maplibregl.Map | null>,
mapMode: 'satellite' | 'enc',
settings: EncMapSettings,
syncEpoch = 0,
) {
// settings를 ref로 유지 — style.load 콜백에서 최신값 참조
const settingsRef = useRef(settings);
settingsRef.current = settings;
// syncEpoch 변경 = 맵 로드 완료 → 전체 설정 재적용
// mapMode 변경 = 위성↔ENC 전환 → style.load 대기 후 적용
useEffect(() => {
if (mapMode !== 'enc') return;
const map = mapRef.current;
if (!map) return;
const applyAll = () => {
const s = settingsRef.current;
applyEncVisibility(map, s);
applyEncColors(map, s);
};
const stop = onStyleReady(map, applyAll);
return stop;
}, [mapMode, syncEpoch, mapRef]);
// settings 변경 시 즉시 적용 (스타일이 이미 로드된 상태에서)
useEffect(() => {
if (mapMode !== 'enc') return;
const map = mapRef.current;
if (!map || !map.isStyleLoaded()) return;
applyEncVisibility(map, settings);
applyEncColors(map, settings);
}, [settings, mapMode, mapRef]);
}