release: Phase 3 완료 (React 19 + MapLibre GL JS 전환) #2

병합
htlee develop 에서 main 로 11 commits 를 머지했습니다 2026-02-15 17:53:44 +09:00
4개의 변경된 파일351개의 추가작업 그리고 120개의 파일을 삭제
Showing only changes of commit 2a7f1af6d2 - Show all commits

파일 보기

@ -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',