/** * 선박 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, }; }