ship-gis/src/hooks/useShipLayer.js
HeungTak Lee c068f55077 feat: 배경지도 전환 및 테마 시스템 구현
- 배경지도 타입 전환 (일반/전자해도/야간)
- 테마 연동 색상 시스템 (선박 라벨, 속도벡터 등)
- mapStore에 subscribeWithSelector 적용
- 신호원 우선순위/항적 조회기간 상수 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 06:36:09 +09:00

318 lines
9.6 KiB
JavaScript

/**
* 선박 Deck.gl 레이어 관리 훅
* - OpenLayers 맵과 Deck.gl 레이어 통합
* - 배치 렌더러 기반 최적화된 렌더링
* - 선박 데이터 변경 시 레이어 업데이트
* - 항적 레이어: 정적(경로/포인트) 캐싱 + 동적(가상선박) 경량 갱신
*
* 참조: mda-react-front/src/common/deck.ts
*/
import { useEffect, useRef, useCallback } from 'react';
import { Deck } from '@deck.gl/core';
import { toLonLat } from 'ol/proj';
import { createShipLayers, clearClusterCache } from '../map/layers/shipLayer';
import useShipStore from '../stores/shipStore';
import { useTrackQueryStore } from '../tracking/stores/trackQueryStore';
import useTrackingModeStore from '../stores/trackingModeStore';
import { useMapStore } from '../stores/mapStore';
import { getTrackQueryLayers } from '../tracking/utils/trackQueryLayerUtils';
import { getReplayLayers } from '../replay/utils/replayLayerRegistry';
import { shipBatchRenderer } from '../map/ShipBatchRenderer';
/**
* 선박 레이어 관리 훅
* @param {Object} map - OpenLayers 맵 인스턴스
* @returns {Object} { deckCanvas }
*/
export default function useShipLayer(map) {
const deckRef = useRef(null);
const canvasRef = useRef(null);
const animationFrameRef = useRef(null);
const batchRendererInitialized = useRef(false);
const getSelectedShips = useShipStore((s) => s.getSelectedShips);
const isShipVisible = useShipStore((s) => s.isShipVisible);
// 마지막 선박 레이어: 캐시용
const lastShipLayersRef = useRef([]);
/**
* Deck.gl 인스턴스 초기화
*/
const initDeck = useCallback((container) => {
if (deckRef.current) return;
const canvas = document.createElement('canvas');
canvas.id = 'deck-canvas';
canvas.style.position = 'absolute';
canvas.style.left = '0';
canvas.style.top = '0';
canvas.style.width = '100%';
canvas.style.height = '100%';
canvas.style.pointerEvents = 'none';
canvas.style.zIndex = '0';
container.appendChild(canvas);
canvasRef.current = canvas;
deckRef.current = new Deck({
canvas,
controller: false,
layers: [],
useDevicePixels: true,
pickingRadius: 12,
onError: (error) => {
console.error('[Deck.gl] Error:', error);
},
});
}, []);
/**
* Deck.gl viewState를 OpenLayers 뷰와 동기화
*/
const syncViewState = useCallback(() => {
if (!map || !deckRef.current) return;
const view = map.getView();
const center = view.getCenter();
const zoom = view.getZoom();
const rotation = view.getRotation();
if (!center || zoom === undefined) return;
const [lon, lat] = toLonLat(center);
deckRef.current.setProps({
viewState: {
longitude: lon,
latitude: lat,
zoom: zoom - 1,
bearing: (-rotation * 180) / Math.PI,
pitch: 0,
},
});
}, [map]);
/**
* 뷰포트 범위 계산
*/
const getViewportBounds = useCallback(() => {
if (!map) return null;
const view = map.getView();
const size = map.getSize();
if (!size) return null;
const extent = view.calculateExtent(size);
const [minX, minY, maxX, maxY] = extent;
const [minLon, minLat] = toLonLat([minX, minY]);
const [maxLon, maxLat] = toLonLat([maxX, maxY]);
return { minLon, maxLon, minLat, maxLat };
}, [map]);
/**
* 배치 렌더러 콜백 - 선박 레이어 생성 + 캐싱된 항적 레이어 병합
*/
const handleBatchRender = useCallback((ships, trigger) => {
if (!deckRef.current || !map) return;
const view = map.getView();
const zoom = view.getZoom() || 7;
const selectedShips = getSelectedShips();
const { showLabels: currentShowLabels, labelOptions: currentLabelOptions, isIntegrate: currentIsIntegrate, darkSignalIds } = useShipStore.getState();
// 라이브 선박 숨김 상태 확인
const { hideLiveShips } = useTrackQueryStore.getState();
// 선박 레이어 생성 (hideLiveShips일 때는 빈 배열)
const shipLayers = hideLiveShips
? []
: createShipLayers(ships, selectedShips, zoom, currentShowLabels, currentLabelOptions, currentIsIntegrate, trigger, darkSignalIds);
// 선박 레이어 캐시
lastShipLayersRef.current = shipLayers;
// 항적 레이어 (tracking 패키지 전역 레지스트리에서 가져옴)
const trackLayers = getTrackQueryLayers();
// 리플레이 레이어 (전역 레지스트리)
const replayLayers = getReplayLayers();
// 병합: 선박 + 항적 + 리플레이 레이어
deckRef.current.setProps({
layers: [...shipLayers, ...trackLayers, ...replayLayers],
});
}, [map, getSelectedShips]);
/**
* 선박 레이어 업데이트 (배치 렌더러 사용)
*/
const updateLayers = useCallback(() => {
if (!deckRef.current || !map) return;
if (!isShipVisible) {
deckRef.current.setProps({ layers: [] });
return;
}
const view = map.getView();
const zoom = view.getZoom() || 10;
const zoomIntChanged = shipBatchRenderer.setZoom(zoom);
const bounds = getViewportBounds();
shipBatchRenderer.setViewportBounds(bounds);
if (zoomIntChanged) {
clearClusterCache();
shipBatchRenderer.immediateRender();
return;
}
shipBatchRenderer.requestRender();
}, [map, isShipVisible, getViewportBounds]);
/**
* 렌더링 루프
*/
const render = useCallback(() => {
syncViewState();
updateLayers();
deckRef.current?.redraw();
}, [syncViewState, updateLayers]);
// 맵 초기화 및 이벤트 바인딩
useEffect(() => {
if (!map) return;
const viewport = map.getViewport();
initDeck(viewport);
if (!batchRendererInitialized.current) {
shipBatchRenderer.initialize(handleBatchRender);
batchRendererInitialized.current = true;
}
const handleMoveEnd = () => { render(); };
const handlePostRender = () => {
syncViewState();
deckRef.current?.redraw();
};
map.on('moveend', handleMoveEnd);
map.on('postrender', handlePostRender);
setTimeout(() => { render(); }, 100);
return () => {
map.un('moveend', handleMoveEnd);
map.un('postrender', handlePostRender);
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
}
if (deckRef.current) {
deckRef.current.finalize();
deckRef.current = null;
}
if (canvasRef.current) {
canvasRef.current.remove();
canvasRef.current = null;
}
shipBatchRenderer.dispose();
batchRendererInitialized.current = false;
};
}, [map, initDeck, render, syncViewState, handleBatchRender]);
// 선박 데이터 변경 시 레이어 업데이트
useEffect(() => {
const unsubscribe = useShipStore.subscribe(
(state) => [state.features, state.kindVisibility, state.sourceVisibility, state.isShipVisible, state.detailModals, state.isIntegrate, state.nationalVisibility, state.showLabels, state.labelOptions, state.darkSignalVisible, state.darkSignalIds],
(current, prev) => {
const filterChanged =
current[1] !== prev[1] ||
current[2] !== prev[2] ||
current[3] !== prev[3] ||
current[5] !== prev[5] ||
current[6] !== prev[6] ||
current[7] !== prev[7] ||
current[8] !== prev[8] ||
current[9] !== prev[9] ||
current[10] !== prev[10];
if (filterChanged) {
shipBatchRenderer.clearCache();
clearClusterCache();
shipBatchRenderer.immediateRender();
return;
}
updateLayers();
},
{ equalityFn: (a, b) => a.every((v, i) => v === b[i]) }
);
return () => { unsubscribe(); };
}, [updateLayers]);
// === trackQueryStore 변경 시 선박 레이어 리렌더 ===
// tracking 패키지의 TrackQueryViewer가 레이어를 전역 레지스트리에 등록하면
// 여기서 shipBatchRenderer를 트리거하여 deck.gl에 반영
useEffect(() => {
const unsubscribe = useTrackQueryStore.subscribe(
(state) => [state.tracks, state.currentTime, state.showPoints, state.showVirtualShip, state.showLabels, state.disabledVesselIds, state.hideLiveShips],
() => {
if (deckRef.current && map) {
shipBatchRenderer.requestRender();
}
},
{ equalityFn: (a, b) => a.every((v, i) => v === b[i]) }
);
return () => unsubscribe();
}, [map]);
// === trackingModeStore 변경 시 선박 레이어 리렌더 ===
// 반경 필터링 상태가 변경되면 즉시 렌더링
useEffect(() => {
const unsubscribe = useTrackingModeStore.subscribe(
(state) => [state.mode, state.trackedShipId, state.trackedShip, state.radiusNM],
() => {
if (deckRef.current && map) {
// 반경/추적 모드 변경은 필터 변경이므로 캐시 클리어 후 즉시 렌더링
shipBatchRenderer.clearCache();
clearClusterCache();
shipBatchRenderer.immediateRender();
}
},
{ equalityFn: (a, b) => a.every((v, i) => v === b[i]) }
);
return () => unsubscribe();
}, [map]);
// === mapStore 테마(배경지도) 변경 시 선박 레이어 리렌더 ===
// 테마 변경 시 선박명/속도벡터/선박크기 색상이 변경되므로 즉시 렌더링
useEffect(() => {
const unsubscribe = useMapStore.subscribe(
(state) => state.baseMapType,
() => {
if (deckRef.current && map) {
clearClusterCache();
shipBatchRenderer.immediateRender();
}
}
);
return () => unsubscribe();
}, [map]);
return {
deckCanvas: canvasRef.current,
deckRef,
};
}