diff --git a/src/hooks/useRealmLayer.ts b/src/hooks/useRealmLayer.ts index af5ee1b4..8869dc06 100644 --- a/src/hooks/useRealmLayer.ts +++ b/src/hooks/useRealmLayer.ts @@ -1,13 +1,24 @@ import { useEffect, useRef } from 'react'; -import VectorLayer from 'ol/layer/Vector'; -import VectorSource from 'ol/source/Vector'; -import Feature from 'ol/Feature'; -import Polygon from 'ol/geom/Polygon'; -import type { Geometry } from 'ol/geom'; -import { Style, Fill, Stroke, Text } from 'ol/style'; +import maplibregl from 'maplibre-gl'; +import type { GeoJSONSource } from 'maplibre-gl'; +import type { Feature, FeatureCollection, Polygon } from 'geojson'; import useFavoriteStore from '../stores/favoriteStore'; import { useMapStore } from '../stores/mapStore'; +/** MapLibre source/layer ID */ +const REALM_SOURCE_ID = 'realm-source'; +const REALM_FILL_LAYER_ID = 'realm-fill-layer'; +const REALM_LINE_SOLID_LAYER_ID = 'realm-line-solid-layer'; +const REALM_LINE_DOTTED_LAYER_ID = 'realm-line-dotted-layer'; +const REALM_LABEL_LAYER_ID = 'realm-label-layer'; + +const ALL_REALM_LAYER_IDS = [ + REALM_LABEL_LAYER_ID, + REALM_LINE_DOTTED_LAYER_ID, + REALM_LINE_SOLID_LAYER_ID, + REALM_FILL_LAYER_ID, +] as const; + /** 관심구역 데이터 (API 응답 형태) */ interface RealmData { coordinates: number[] | number[][] | number[][][]; @@ -23,144 +34,232 @@ interface RealmData { seaRelmId?: string | number; } -/** - * 관심구역 OpenLayers 레이어 관리 훅 - * 참조: mda-react-front/src/services/commonService.ts - getRealmLayer() - */ -export default function useRealmLayer(): void { - const map = useMapStore((s) => s.map); - const layerRef = useRef> | null>(null); - const sourceRef = useRef(null); - - useEffect(() => { - if (!map) return; - - // MapLibre 전환 후 OL VectorLayer는 호환 불가 — Session E에서 MapLibre 네이티브 레이어로 마이그레이션 - if (typeof map.addControl === 'function' && typeof map.getCanvas === 'function') { - console.warn('[useRealmLayer] MapLibre 맵 감지 — OL 관심구역 레이어 비활성화 (Session E에서 마이그레이션)'); - return; - } - - const source = new VectorSource(); - const layer = new VectorLayer({ - source, - zIndex: 50, - }); - - map.addLayer(layer); - layerRef.current = layer; - sourceRef.current = source; - - // 초기 데이터 렌더링 - const { realmList, isRealmVisible } = useFavoriteStore.getState(); - console.log(`[useRealmLayer] 초기화: realmList=${realmList.length}건, visible=${isRealmVisible}`); - layer.setVisible(isRealmVisible); - if (realmList.length > 0) { - renderRealms(source, realmList as unknown as RealmData[]); - } - - // realmList 변경 구독 - const unsubRealmList = useFavoriteStore.subscribe( - (state) => state.realmList, - (newRealmList) => { - console.log(`[useRealmLayer] realmList 변경 감지: ${newRealmList.length}건`); - if (newRealmList.length > 0) { - console.log('[useRealmLayer] 첫 번째 realm 샘플:', JSON.stringify(newRealmList[0]).slice(0, 300)); - } - renderRealms(source, newRealmList as unknown as RealmData[]); - } - ); - - // isRealmVisible 변경 구독 - const unsubVisible = useFavoriteStore.subscribe( - (state) => state.isRealmVisible, - (isVisible: boolean) => { - console.log(`[useRealmLayer] visible 토글: ${isVisible}, layer=${!!layerRef.current}, features=${sourceRef.current?.getFeatures()?.length || 0}`); - if (layerRef.current) { - layerRef.current.setVisible(isVisible); - } - } - ); - - return () => { - unsubRealmList(); - unsubVisible(); - if (map && layerRef.current) { - map.removeLayer(layerRef.current); - } - layerRef.current = null; - sourceRef.current = null; - }; - }, [map]); -} +/** 빈 FeatureCollection */ +const EMPTY_FC: FeatureCollection = { type: 'FeatureCollection', features: [] }; /** * 좌표 배열 정규화 - * API 응답 형식에 관계없이 Polygon 생성에 적합한 형식으로 변환 - * @param {Array} coordinates - 좌표 데이터 - * @returns {Array} Polygon rings 배열 [[lon,lat], ...] + * API 응답 형식에 관계없이 Polygon ring에 적합한 형식으로 변환 */ // eslint-disable-next-line @typescript-eslint/no-explicit-any function normalizeCoordinates(coordinates: any): number[][] | null { if (!Array.isArray(coordinates) || coordinates.length === 0) return null; - // coordinates[0]이 숫자 배열이면 → 이미 ring 형태: [[lon,lat], ...] if (Array.isArray(coordinates[0]) && typeof coordinates[0][0] === 'number') { return coordinates; } - // coordinates[0][0]이 숫자 배열이면 → 이미 rings 형태: [[[lon,lat], ...]] if (Array.isArray(coordinates[0]) && Array.isArray(coordinates[0][0]) && typeof coordinates[0][0][0] === 'number') { - return coordinates[0]; // 첫 번째 ring만 사용 + return coordinates[0]; } return null; } /** - * 관심구역 Feature 렌더링 - * @param {VectorSource} source - OL VectorSource - * @param {Array} realmList - 관심구역 데이터 배열 + * RealmData 배열 → GeoJSON FeatureCollection 변환 + * properties에 스타일 정보를 포함하여 data-driven expression으로 개별 스타일링 */ -function renderRealms(source: VectorSource, realmList: RealmData[]): void { - source.clear(); +function buildRealmGeoJSON(realmList: RealmData[]): FeatureCollection { + if (!realmList || realmList.length === 0) return EMPTY_FC; - if (!realmList || realmList.length === 0) return; + const features: Feature[] = []; - let count = 0; realmList.forEach((realm) => { const ring = normalizeCoordinates(realm.coordinates); - if (!ring) return; + if (!ring || ring.length < 3) return; try { - const polygon = new Polygon([ring]).transform('EPSG:4326', 'EPSG:3857'); - const feature = new Feature({ geometry: polygon }); - - feature.setStyle( - new Style({ - text: new Text({ - text: realm.seaRelmNameYn === 'Y' ? (realm.seaRelmName || '') : '', - fill: new Fill({ color: realm.fontColor || '#000000' }), - font: `${realm.fontSize || 12}px ${realm.fontKind || 'sans-serif'}`, - }), - stroke: new Stroke({ - color: realm.outlineColor || '#333333', - width: Number(realm.outlineWidth) || 2, - lineDash: realm.outlineType === 'dot' ? [15, 15] : undefined, - }), - fill: new Fill({ color: realm.fillColor || 'rgba(0,0,0,0.1)' }), - }) - ); - - if (realm.seaRelmId) { - feature.setId(realm.seaRelmId); + // GeoJSON Polygon ring은 닫혀야 함 (first == last) + const coords = [...ring]; + const first = coords[0]; + const last = coords[coords.length - 1]; + if (first[0] !== last[0] || first[1] !== last[1]) { + coords.push([first[0], first[1]]); } - source.addFeature(feature); - count++; + + features.push({ + type: 'Feature', + id: realm.seaRelmId != null ? Number(realm.seaRelmId) : undefined, + properties: { + fillColor: realm.fillColor || 'rgba(0,0,0,0.1)', + outlineColor: realm.outlineColor || '#333333', + outlineWidth: Number(realm.outlineWidth) || 2, + isDotted: realm.outlineType === 'dot', + showLabel: realm.seaRelmNameYn === 'Y', + labelText: realm.seaRelmName || '', + fontColor: realm.fontColor || '#000000', + fontSize: realm.fontSize || 12, + }, + geometry: { + type: 'Polygon', + coordinates: [coords], + }, + }); } catch (err) { console.warn('[useRealmLayer] Feature 생성 실패:', realm.seaRelmName, err); } }); - console.log(`[useRealmLayer] ${count}/${realmList.length}건 렌더링 완료`); + return { type: 'FeatureCollection', features }; +} + +/** + * Realm source/layer를 맵에 추가 (멱등) + */ +function ensureRealmLayers(map: maplibregl.Map): void { + if (!map.getSource(REALM_SOURCE_ID)) { + map.addSource(REALM_SOURCE_ID, { + type: 'geojson', + data: EMPTY_FC, + }); + } + + if (!map.getLayer(REALM_FILL_LAYER_ID)) { + map.addLayer({ + id: REALM_FILL_LAYER_ID, + type: 'fill', + source: REALM_SOURCE_ID, + paint: { + 'fill-color': ['get', 'fillColor'], + 'fill-opacity': 1, + }, + }); + } + + // 실선 레이어 (line-dasharray가 data-driven 미지원이므로 실선/점선 분리) + if (!map.getLayer(REALM_LINE_SOLID_LAYER_ID)) { + map.addLayer({ + id: REALM_LINE_SOLID_LAYER_ID, + type: 'line', + source: REALM_SOURCE_ID, + filter: ['!=', ['get', 'isDotted'], true], + paint: { + 'line-color': ['get', 'outlineColor'], + 'line-width': ['get', 'outlineWidth'], + }, + }); + } + + // 점선 레이어 + if (!map.getLayer(REALM_LINE_DOTTED_LAYER_ID)) { + map.addLayer({ + id: REALM_LINE_DOTTED_LAYER_ID, + type: 'line', + source: REALM_SOURCE_ID, + filter: ['==', ['get', 'isDotted'], true], + paint: { + 'line-color': ['get', 'outlineColor'], + 'line-width': ['get', 'outlineWidth'], + 'line-dasharray': [4, 4], + }, + }); + } + + // 라벨 레이어 + if (!map.getLayer(REALM_LABEL_LAYER_ID)) { + map.addLayer({ + id: REALM_LABEL_LAYER_ID, + type: 'symbol', + source: REALM_SOURCE_ID, + filter: ['==', ['get', 'showLabel'], true], + layout: { + 'text-field': ['get', 'labelText'], + 'text-size': ['get', 'fontSize'], + 'text-font': ['Open Sans Regular', 'Arial Unicode MS Regular'], + 'text-allow-overlap': true, + }, + paint: { + 'text-color': ['get', 'fontColor'], + }, + }); + } +} + +/** + * 관심구역 MapLibre 레이어 관리 훅 + * - GeoJSON source + fill/line/symbol layer로 폴리곤 및 라벨 표시 + * - favoriteStore 구독으로 realmList/visibility 변경 반영 + * - style.load 이벤트로 배경지도 전환 후 레이어 복구 + */ +export default function useRealmLayer(): void { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- 마이그레이션 기간 mapStore.map: any + const map = useMapStore((s) => s.map); + const currentDataRef = useRef>(EMPTY_FC); + const isVisibleRef = useRef(true); + + useEffect(() => { + if (!map) return; + if (typeof map.getCanvas !== 'function') return; + + const mlMap = map as maplibregl.Map; + + /** 현재 상태를 맵에 적용 (초기화 + style.load 복구 공용) */ + const applyState = () => { + ensureRealmLayers(mlMap); + + const source = mlMap.getSource(REALM_SOURCE_ID) as GeoJSONSource | undefined; + if (source) { + source.setData(currentDataRef.current); + } + + const visibility = isVisibleRef.current ? 'visible' : 'none'; + ALL_REALM_LAYER_IDS.forEach((layerId) => { + if (mlMap.getLayer(layerId)) { + mlMap.setLayoutProperty(layerId, 'visibility', visibility as 'visible' | 'none'); + } + }); + }; + + // 배경지도 전환(setStyle) 후 레이어 복구 + mlMap.on('style.load', applyState); + + // 초기 데이터 로드 + const { realmList, isRealmVisible } = useFavoriteStore.getState(); + isVisibleRef.current = isRealmVisible; + if (realmList.length > 0) { + currentDataRef.current = buildRealmGeoJSON(realmList as unknown as RealmData[]); + } + + if (mlMap.isStyleLoaded()) { + applyState(); + } + + // realmList 변경 구독 + const unsubRealmList = useFavoriteStore.subscribe( + (state) => state.realmList, + (newRealmList) => { + currentDataRef.current = buildRealmGeoJSON(newRealmList as unknown as RealmData[]); + const source = mlMap.getSource(REALM_SOURCE_ID) as GeoJSONSource | undefined; + if (source) { + source.setData(currentDataRef.current); + } + }, + ); + + // isRealmVisible 변경 구독 + const unsubVisible = useFavoriteStore.subscribe( + (state) => state.isRealmVisible, + (isVisible: boolean) => { + isVisibleRef.current = isVisible; + const visibility = isVisible ? 'visible' : 'none'; + ALL_REALM_LAYER_IDS.forEach((layerId) => { + if (mlMap.getLayer(layerId)) { + mlMap.setLayoutProperty(layerId, 'visibility', visibility as 'visible' | 'none'); + } + }); + }, + ); + + return () => { + unsubRealmList(); + unsubVisible(); + mlMap.off('style.load', applyState); + + ALL_REALM_LAYER_IDS.forEach((layerId) => { + if (mlMap.getLayer(layerId)) mlMap.removeLayer(layerId); + }); + if (mlMap.getSource(REALM_SOURCE_ID)) mlMap.removeSource(REALM_SOURCE_ID); + }; + }, [map]); } diff --git a/src/hooks/useTrackingMode.ts b/src/hooks/useTrackingMode.ts index 9711e2a0..795be514 100644 --- a/src/hooks/useTrackingMode.ts +++ b/src/hooks/useTrackingMode.ts @@ -1,14 +1,34 @@ /** * 추적 모드 훅 * - 선박 모드일 때 추적 함정 중심으로 지도 이동 - * - 반경 원 레이어 생성 및 업데이트 (OL → MapLibre 마이그레이션 Session E에서 구현) + * - 반경 원 레이어: MapLibre GeoJSON source + fill/line layer (@turf/circle) */ import { useEffect, useRef, useCallback } from 'react'; +import maplibregl from 'maplibre-gl'; +import type { GeoJSONSource } from 'maplibre-gl'; +import * as turf from '@turf/turf'; +import type { Feature, FeatureCollection, Polygon } from 'geojson'; import { useMapStore } from '../stores/mapStore'; import useShipStore from '../stores/shipStore'; import useTrackingModeStore from '../stores/trackingModeStore'; import type { ShipFeature } from '../types/ship'; +/** MapLibre source/layer ID */ +const RADIUS_SOURCE_ID = 'tracking-radius-source'; +const RADIUS_FILL_LAYER_ID = 'tracking-radius-fill-layer'; +const RADIUS_LINE_LAYER_ID = 'tracking-radius-line-layer'; + +const ALL_RADIUS_LAYER_IDS = [RADIUS_LINE_LAYER_ID, RADIUS_FILL_LAYER_ID] as const; + +/** NM → km 변환 */ +const NM_TO_KM = 1.852; + +/** 빈 FeatureCollection */ +const EMPTY_FC: FeatureCollection = { type: 'FeatureCollection', features: [] }; + +/** 반경 원 색상 (shipLayer 추적 선박 마커 색상과 통일: [0, 212, 255]) */ +const RADIUS_COLOR = '#00D4FF'; + /** useTrackingMode 반환 타입 */ interface UseTrackingModeReturn { isTrackingActive: boolean; @@ -16,11 +36,58 @@ interface UseTrackingModeReturn { radiusNM: number; } +/** + * 반경 원 GeoJSON 생성 + */ +function buildRadiusGeoJSON(lon: number, lat: number, radiusNM: number): Feature { + const radiusKm = radiusNM * NM_TO_KM; + return turf.circle([lon, lat], radiusKm, { steps: 64, units: 'kilometers' }) as Feature; +} + +/** + * 반경 원 source/layer를 맵에 추가 (멱등) + */ +function ensureRadiusLayers(map: maplibregl.Map): void { + if (!map.getSource(RADIUS_SOURCE_ID)) { + map.addSource(RADIUS_SOURCE_ID, { + type: 'geojson', + data: EMPTY_FC, + }); + } + + if (!map.getLayer(RADIUS_FILL_LAYER_ID)) { + map.addLayer({ + id: RADIUS_FILL_LAYER_ID, + type: 'fill', + source: RADIUS_SOURCE_ID, + paint: { + 'fill-color': RADIUS_COLOR, + 'fill-opacity': 0.05, + }, + }); + } + + if (!map.getLayer(RADIUS_LINE_LAYER_ID)) { + map.addLayer({ + id: RADIUS_LINE_LAYER_ID, + type: 'line', + source: RADIUS_SOURCE_ID, + paint: { + 'line-color': RADIUS_COLOR, + 'line-width': 1.5, + 'line-opacity': 0.6, + 'line-dasharray': [4, 2], + }, + }); + } +} + /** * 추적 모드 훅 * MapContainer에서 호출 */ export default function useTrackingMode(): UseTrackingModeReturn { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- 마이그레이션 기간 mapStore.map: any const map = useMapStore((s) => s.map); const features = useShipStore((s) => s.features); @@ -33,7 +100,8 @@ export default function useTrackingMode(): UseTrackingModeReturn { // 이전 좌표 (중복 업데이트 방지) const prevCoordsRef = useRef(null); - // TODO: Session E — 반경 원 레이어를 MapLibre 네이티브 GeoJSON source + circle-layer로 구현 + // 반경 원 데이터 (style.load 복구용) + const radiusDataRef = useRef>(EMPTY_FC); /** * 지도 중심 이동 (MapLibre API) @@ -57,11 +125,9 @@ export default function useTrackingMode(): UseTrackingModeReturn { useEffect(() => { if (mode !== 'ship' || !trackedShipId) return; - // features에서 추적 함정의 최신 데이터 가져오기 const latestShip = features.get(trackedShipId); if (!latestShip) return; - // 좌표가 변경되었는지 확인 const newCoords = `${latestShip.longitude},${latestShip.latitude}`; if (prevCoordsRef.current === newCoords) return; @@ -79,10 +145,74 @@ export default function useTrackingMode(): UseTrackingModeReturn { const { longitude, latitude } = trackedShip; if (!longitude || !latitude) return; - // 지도 중심 이동 centerMapOnShip(longitude, latitude); }, [mode, trackedShip, map, centerMapOnShip]); + // 반경 원 레이어 관리 + useEffect(() => { + if (!map) return; + if (typeof map.getCanvas !== 'function') return; + + const mlMap = map as maplibregl.Map; + + /** 반경 원 상태를 맵에 적용 */ + const applyState = () => { + ensureRadiusLayers(mlMap); + const source = mlMap.getSource(RADIUS_SOURCE_ID) as GeoJSONSource | undefined; + if (source) { + source.setData(radiusDataRef.current); + } + }; + + /** 추적 상태에 따라 반경 원 갱신 */ + const updateRadiusCircle = () => { + const { mode: currentMode, trackedShip: currentShip, radiusNM: currentRadius } = + useTrackingModeStore.getState(); + + if (currentMode !== 'ship' || !currentShip?.longitude || !currentShip?.latitude) { + radiusDataRef.current = EMPTY_FC; + } else { + const circle = buildRadiusGeoJSON(currentShip.longitude, currentShip.latitude, currentRadius); + radiusDataRef.current = { type: 'FeatureCollection', features: [circle] }; + } + + const source = mlMap.getSource(RADIUS_SOURCE_ID) as GeoJSONSource | undefined; + if (source) { + source.setData(radiusDataRef.current); + } + }; + + // 배경지도 전환(setStyle) 후 레이어 복구 + mlMap.on('style.load', applyState); + + // 초기 설정 + if (mlMap.isStyleLoaded()) { + ensureRadiusLayers(mlMap); + } + + // trackingModeStore 구독: mode, trackedShip, radiusNM 변경 시 원 갱신 + const unsubTracking = useTrackingModeStore.subscribe( + (state) => [state.mode, state.trackedShip, state.radiusNM] as const, + () => { updateRadiusCircle(); }, + { + equalityFn: (a, b) => a[0] === b[0] && a[1] === b[1] && a[2] === b[2], + }, + ); + + // 초기 상태 반영 + updateRadiusCircle(); + + return () => { + unsubTracking(); + mlMap.off('style.load', applyState); + + ALL_RADIUS_LAYER_IDS.forEach((layerId) => { + if (mlMap.getLayer(layerId)) mlMap.removeLayer(layerId); + }); + if (mlMap.getSource(RADIUS_SOURCE_ID)) mlMap.removeSource(RADIUS_SOURCE_ID); + }; + }, [map]); + return { isTrackingActive: mode === 'ship' && trackedShip !== null, trackedShip, diff --git a/src/map/MapContainer.tsx b/src/map/MapContainer.tsx index 7e1c865c..591e84bd 100644 --- a/src/map/MapContainer.tsx +++ b/src/map/MapContainer.tsx @@ -99,7 +99,7 @@ export default function MapContainer() { // 측정 도구 (MapLibre 전환 후 별도 세션에서 수정) useMeasure(); - // 추적 모드 (MapLibre 전환 후 별도 세션에서 수정) + // 추적 모드 (지도 추적 + 반경 원 레이어) useTrackingMode(); // 배경지도 타입 변경 시 스타일 교체 diff --git a/src/map/layers/baseLayer.ts b/src/map/layers/baseLayer.ts index 8f339178..66fab033 100644 --- a/src/map/layers/baseLayer.ts +++ b/src/map/layers/baseLayer.ts @@ -11,6 +11,7 @@ type BaseMapType = 'normal' | 'enc' | 'dark'; const OSM_RASTER_STYLE: StyleSpecification = { version: 8, + glyphs: 'https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf', sources: { osm: { type: 'raster', @@ -25,6 +26,7 @@ const OSM_RASTER_STYLE: StyleSpecification = { const DARK_RASTER_STYLE: StyleSpecification = { version: 8, + glyphs: 'https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf', sources: { 'carto-dark': { type: 'raster',