feat: 한국 현황 위성지도/ENC 토글 + ENC 스타일 설정 #215
@ -2,6 +2,9 @@ import { useState, useMemo, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
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 { FieldAnalysisModal } from './FieldAnalysisModal';
|
||||
import { ReportModal } from './ReportModal';
|
||||
@ -88,6 +91,9 @@ export const KoreaDashboard = ({
|
||||
const [externalFlyTo, setExternalFlyTo] = useState<{ lat: number; lng: number; zoom: number } | null>(null);
|
||||
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 } =
|
||||
useSharedFilters();
|
||||
|
||||
@ -274,6 +280,24 @@ export const KoreaDashboard = ({
|
||||
return (
|
||||
<>
|
||||
{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">
|
||||
<button type="button" className={`mode-btn ${koreaFiltersResult.filters.illegalFishing ? 'active live' : ''}`}
|
||||
onClick={() => koreaFiltersResult.setFilter('illegalFishing', !koreaFiltersResult.filters.illegalFishing)} title={t('filters.illegalFishing')}>
|
||||
@ -311,7 +335,8 @@ export const KoreaDashboard = ({
|
||||
onClick={() => setShowOpsGuide(v => !v)} title="경비함정 작전 가이드">
|
||||
<span className="text-[11px]">⚓</span>작전가이드
|
||||
</button>
|
||||
</div>,
|
||||
</div>
|
||||
</>,
|
||||
headerSlot,
|
||||
)}
|
||||
{countsSlot && createPortal(
|
||||
@ -365,6 +390,8 @@ export const KoreaDashboard = ({
|
||||
externalFlyTo={externalFlyTo}
|
||||
onExternalFlyToDone={() => setExternalFlyTo(null)}
|
||||
opsRoute={opsRoute}
|
||||
mapMode={mapMode}
|
||||
encSettings={encSettings}
|
||||
/>
|
||||
<div className="map-overlay-left">
|
||||
<LayerPanel
|
||||
|
||||
@ -2,6 +2,10 @@ import { useRef, useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Map, NavigationControl, Marker, Source, Layer } 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 { useFontScale } from '../../hooks/useFontScale';
|
||||
import { FONT_MONO } from '../../styles/fonts';
|
||||
@ -78,6 +82,8 @@ interface Props {
|
||||
externalFlyTo?: { lat: number; lng: number; zoom: number } | null;
|
||||
onExternalFlyToDone?: () => void;
|
||||
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
|
||||
@ -213,10 +219,26 @@ const DebugTools = import.meta.env.DEV
|
||||
? lazy(() => import('./debug'))
|
||||
: 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 mapRef = useRef<MapRef>(null);
|
||||
const maplibreRef = useRef<import('maplibre-gl').Map | 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 fleetClusterLayerRef = useRef<DeckLayer[]>([]);
|
||||
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 전환 완료로 삭제)
|
||||
const handleMapLoad = useCallback(() => {}, []);
|
||||
const handleMapLoad = useCallback(() => {
|
||||
maplibreRef.current = mapRef.current?.getMap() ?? null;
|
||||
setEncSyncEpoch(v => v + 1);
|
||||
}, []);
|
||||
|
||||
// ── shipDeckStore 동기화 ──
|
||||
useEffect(() => {
|
||||
@ -656,7 +681,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
||||
ref={mapRef}
|
||||
initialViewState={{ ...KOREA_MAP_CENTER, zoom: KOREA_MAP_ZOOM }}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
mapStyle={MAP_STYLE}
|
||||
mapStyle={activeMapStyle}
|
||||
onZoom={handleZoom}
|
||||
onLoad={handleMapLoad}
|
||||
>
|
||||
@ -1057,6 +1082,8 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* ENC 설정 패널은 KoreaDashboard 헤더에서 렌더 */}
|
||||
</Map>
|
||||
);
|
||||
}
|
||||
|
||||
129
frontend/src/features/encMap/EncMapSettingsPanel.tsx
Normal file
129
frontend/src/features/encMap/EncMapSettingsPanel.tsx
Normal file
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
44
frontend/src/features/encMap/encSettings.ts
Normal file
44
frontend/src/features/encMap/encSettings.ts
Normal file
@ -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 */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
22
frontend/src/features/encMap/encStyle.ts
Normal file
22
frontend/src/features/encMap/encStyle.ts
Normal file
@ -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;
|
||||
}
|
||||
82
frontend/src/features/encMap/types.ts
Normal file
82
frontend/src/features/encMap/types.ts
Normal file
@ -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'] },
|
||||
];
|
||||
64
frontend/src/features/encMap/useEncMapSettings.ts
Normal file
64
frontend/src/features/encMap/useEncMapSettings.ts
Normal file
@ -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]);
|
||||
}
|
||||
불러오는 중...
Reference in New Issue
Block a user