release: Phase 3 완료 (React 19 + MapLibre GL JS 전환) #2
@ -1,13 +1,24 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import VectorLayer from 'ol/layer/Vector';
|
import maplibregl from 'maplibre-gl';
|
||||||
import VectorSource from 'ol/source/Vector';
|
import type { GeoJSONSource } from 'maplibre-gl';
|
||||||
import Feature from 'ol/Feature';
|
import type { Feature, FeatureCollection, Polygon } from 'geojson';
|
||||||
import Polygon from 'ol/geom/Polygon';
|
|
||||||
import type { Geometry } from 'ol/geom';
|
|
||||||
import { Style, Fill, Stroke, Text } from 'ol/style';
|
|
||||||
import useFavoriteStore from '../stores/favoriteStore';
|
import useFavoriteStore from '../stores/favoriteStore';
|
||||||
import { useMapStore } from '../stores/mapStore';
|
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 응답 형태) */
|
/** 관심구역 데이터 (API 응답 형태) */
|
||||||
interface RealmData {
|
interface RealmData {
|
||||||
coordinates: number[] | number[][] | number[][][];
|
coordinates: number[] | number[][] | number[][][];
|
||||||
@ -23,144 +34,232 @@ interface RealmData {
|
|||||||
seaRelmId?: string | number;
|
seaRelmId?: string | number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 빈 FeatureCollection */
|
||||||
* 관심구역 OpenLayers 레이어 관리 훅
|
const EMPTY_FC: FeatureCollection<Polygon> = { type: 'FeatureCollection', features: [] };
|
||||||
* 참조: mda-react-front/src/services/commonService.ts - getRealmLayer()
|
|
||||||
*/
|
|
||||||
export default function useRealmLayer(): void {
|
|
||||||
const map = useMapStore((s) => s.map);
|
|
||||||
const layerRef = useRef<VectorLayer<Feature<Geometry>> | null>(null);
|
|
||||||
const sourceRef = useRef<VectorSource | null>(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]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 좌표 배열 정규화
|
* 좌표 배열 정규화
|
||||||
* API 응답 형식에 관계없이 Polygon 생성에 적합한 형식으로 변환
|
* API 응답 형식에 관계없이 Polygon ring에 적합한 형식으로 변환
|
||||||
* @param {Array} coordinates - 좌표 데이터
|
|
||||||
* @returns {Array} Polygon rings 배열 [[lon,lat], ...]
|
|
||||||
*/
|
*/
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
function normalizeCoordinates(coordinates: any): number[][] | null {
|
function normalizeCoordinates(coordinates: any): number[][] | null {
|
||||||
if (!Array.isArray(coordinates) || coordinates.length === 0) return 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') {
|
if (Array.isArray(coordinates[0]) && typeof coordinates[0][0] === 'number') {
|
||||||
return coordinates;
|
return coordinates;
|
||||||
}
|
}
|
||||||
|
|
||||||
// coordinates[0][0]이 숫자 배열이면 → 이미 rings 형태: [[[lon,lat], ...]]
|
|
||||||
if (Array.isArray(coordinates[0]) && Array.isArray(coordinates[0][0]) && typeof coordinates[0][0][0] === 'number') {
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 관심구역 Feature 렌더링
|
* RealmData 배열 → GeoJSON FeatureCollection 변환
|
||||||
* @param {VectorSource} source - OL VectorSource
|
* properties에 스타일 정보를 포함하여 data-driven expression으로 개별 스타일링
|
||||||
* @param {Array} realmList - 관심구역 데이터 배열
|
|
||||||
*/
|
*/
|
||||||
function renderRealms(source: VectorSource, realmList: RealmData[]): void {
|
function buildRealmGeoJSON(realmList: RealmData[]): FeatureCollection<Polygon> {
|
||||||
source.clear();
|
if (!realmList || realmList.length === 0) return EMPTY_FC;
|
||||||
|
|
||||||
if (!realmList || realmList.length === 0) return;
|
const features: Feature<Polygon>[] = [];
|
||||||
|
|
||||||
let count = 0;
|
|
||||||
realmList.forEach((realm) => {
|
realmList.forEach((realm) => {
|
||||||
const ring = normalizeCoordinates(realm.coordinates);
|
const ring = normalizeCoordinates(realm.coordinates);
|
||||||
if (!ring) return;
|
if (!ring || ring.length < 3) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const polygon = new Polygon([ring]).transform('EPSG:4326', 'EPSG:3857');
|
// GeoJSON Polygon ring은 닫혀야 함 (first == last)
|
||||||
const feature = new Feature({ geometry: polygon });
|
const coords = [...ring];
|
||||||
|
const first = coords[0];
|
||||||
feature.setStyle(
|
const last = coords[coords.length - 1];
|
||||||
new Style({
|
if (first[0] !== last[0] || first[1] !== last[1]) {
|
||||||
text: new Text({
|
coords.push([first[0], first[1]]);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
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) {
|
} catch (err) {
|
||||||
console.warn('[useRealmLayer] Feature 생성 실패:', realm.seaRelmName, 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<FeatureCollection<Polygon>>(EMPTY_FC);
|
||||||
|
const isVisibleRef = useRef<boolean>(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]);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,14 +1,34 @@
|
|||||||
/**
|
/**
|
||||||
* 추적 모드 훅
|
* 추적 모드 훅
|
||||||
* - 선박 모드일 때 추적 함정 중심으로 지도 이동
|
* - 선박 모드일 때 추적 함정 중심으로 지도 이동
|
||||||
* - 반경 원 레이어 생성 및 업데이트 (OL → MapLibre 마이그레이션 Session E에서 구현)
|
* - 반경 원 레이어: MapLibre GeoJSON source + fill/line layer (@turf/circle)
|
||||||
*/
|
*/
|
||||||
import { useEffect, useRef, useCallback } from 'react';
|
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 { useMapStore } from '../stores/mapStore';
|
||||||
import useShipStore from '../stores/shipStore';
|
import useShipStore from '../stores/shipStore';
|
||||||
import useTrackingModeStore from '../stores/trackingModeStore';
|
import useTrackingModeStore from '../stores/trackingModeStore';
|
||||||
import type { ShipFeature } from '../types/ship';
|
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<Polygon> = { type: 'FeatureCollection', features: [] };
|
||||||
|
|
||||||
|
/** 반경 원 색상 (shipLayer 추적 선박 마커 색상과 통일: [0, 212, 255]) */
|
||||||
|
const RADIUS_COLOR = '#00D4FF';
|
||||||
|
|
||||||
/** useTrackingMode 반환 타입 */
|
/** useTrackingMode 반환 타입 */
|
||||||
interface UseTrackingModeReturn {
|
interface UseTrackingModeReturn {
|
||||||
isTrackingActive: boolean;
|
isTrackingActive: boolean;
|
||||||
@ -16,11 +36,58 @@ interface UseTrackingModeReturn {
|
|||||||
radiusNM: number;
|
radiusNM: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 반경 원 GeoJSON 생성
|
||||||
|
*/
|
||||||
|
function buildRadiusGeoJSON(lon: number, lat: number, radiusNM: number): Feature<Polygon> {
|
||||||
|
const radiusKm = radiusNM * NM_TO_KM;
|
||||||
|
return turf.circle([lon, lat], radiusKm, { steps: 64, units: 'kilometers' }) as Feature<Polygon>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 반경 원 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에서 호출
|
* MapContainer에서 호출
|
||||||
*/
|
*/
|
||||||
export default function useTrackingMode(): UseTrackingModeReturn {
|
export default function useTrackingMode(): UseTrackingModeReturn {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 마이그레이션 기간 mapStore.map: any
|
||||||
const map = useMapStore((s) => s.map);
|
const map = useMapStore((s) => s.map);
|
||||||
const features = useShipStore((s) => s.features);
|
const features = useShipStore((s) => s.features);
|
||||||
|
|
||||||
@ -33,7 +100,8 @@ export default function useTrackingMode(): UseTrackingModeReturn {
|
|||||||
// 이전 좌표 (중복 업데이트 방지)
|
// 이전 좌표 (중복 업데이트 방지)
|
||||||
const prevCoordsRef = useRef<string | null>(null);
|
const prevCoordsRef = useRef<string | null>(null);
|
||||||
|
|
||||||
// TODO: Session E — 반경 원 레이어를 MapLibre 네이티브 GeoJSON source + circle-layer로 구현
|
// 반경 원 데이터 (style.load 복구용)
|
||||||
|
const radiusDataRef = useRef<FeatureCollection<Polygon>>(EMPTY_FC);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 지도 중심 이동 (MapLibre API)
|
* 지도 중심 이동 (MapLibre API)
|
||||||
@ -57,11 +125,9 @@ export default function useTrackingMode(): UseTrackingModeReturn {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (mode !== 'ship' || !trackedShipId) return;
|
if (mode !== 'ship' || !trackedShipId) return;
|
||||||
|
|
||||||
// features에서 추적 함정의 최신 데이터 가져오기
|
|
||||||
const latestShip = features.get(trackedShipId);
|
const latestShip = features.get(trackedShipId);
|
||||||
if (!latestShip) return;
|
if (!latestShip) return;
|
||||||
|
|
||||||
// 좌표가 변경되었는지 확인
|
|
||||||
const newCoords = `${latestShip.longitude},${latestShip.latitude}`;
|
const newCoords = `${latestShip.longitude},${latestShip.latitude}`;
|
||||||
if (prevCoordsRef.current === newCoords) return;
|
if (prevCoordsRef.current === newCoords) return;
|
||||||
|
|
||||||
@ -79,10 +145,74 @@ export default function useTrackingMode(): UseTrackingModeReturn {
|
|||||||
const { longitude, latitude } = trackedShip;
|
const { longitude, latitude } = trackedShip;
|
||||||
if (!longitude || !latitude) return;
|
if (!longitude || !latitude) return;
|
||||||
|
|
||||||
// 지도 중심 이동
|
|
||||||
centerMapOnShip(longitude, latitude);
|
centerMapOnShip(longitude, latitude);
|
||||||
}, [mode, trackedShip, map, centerMapOnShip]);
|
}, [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 {
|
return {
|
||||||
isTrackingActive: mode === 'ship' && trackedShip !== null,
|
isTrackingActive: mode === 'ship' && trackedShip !== null,
|
||||||
trackedShip,
|
trackedShip,
|
||||||
|
|||||||
@ -99,7 +99,7 @@ export default function MapContainer() {
|
|||||||
// 측정 도구 (MapLibre 전환 후 별도 세션에서 수정)
|
// 측정 도구 (MapLibre 전환 후 별도 세션에서 수정)
|
||||||
useMeasure();
|
useMeasure();
|
||||||
|
|
||||||
// 추적 모드 (MapLibre 전환 후 별도 세션에서 수정)
|
// 추적 모드 (지도 추적 + 반경 원 레이어)
|
||||||
useTrackingMode();
|
useTrackingMode();
|
||||||
|
|
||||||
// 배경지도 타입 변경 시 스타일 교체
|
// 배경지도 타입 변경 시 스타일 교체
|
||||||
|
|||||||
@ -11,6 +11,7 @@ type BaseMapType = 'normal' | 'enc' | 'dark';
|
|||||||
|
|
||||||
const OSM_RASTER_STYLE: StyleSpecification = {
|
const OSM_RASTER_STYLE: StyleSpecification = {
|
||||||
version: 8,
|
version: 8,
|
||||||
|
glyphs: 'https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf',
|
||||||
sources: {
|
sources: {
|
||||||
osm: {
|
osm: {
|
||||||
type: 'raster',
|
type: 'raster',
|
||||||
@ -25,6 +26,7 @@ const OSM_RASTER_STYLE: StyleSpecification = {
|
|||||||
|
|
||||||
const DARK_RASTER_STYLE: StyleSpecification = {
|
const DARK_RASTER_STYLE: StyleSpecification = {
|
||||||
version: 8,
|
version: 8,
|
||||||
|
glyphs: 'https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf',
|
||||||
sources: {
|
sources: {
|
||||||
'carto-dark': {
|
'carto-dark': {
|
||||||
type: 'raster',
|
type: 'raster',
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user