2026-01-30 13:01:54 +09:00
|
|
|
/**
|
|
|
|
|
* 선박 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 { 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, isShipVisible, showLabels, labelOptions } = useShipStore();
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Deck.gl 인스턴스 초기화
|
|
|
|
|
*/
|
|
|
|
|
const initDeck = useCallback((container) => {
|
|
|
|
|
if (deckRef.current) return;
|
|
|
|
|
|
|
|
|
|
// Canvas 엘리먼트 생성
|
|
|
|
|
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;
|
|
|
|
|
|
|
|
|
|
// Deck.gl 인스턴스 생성
|
|
|
|
|
deckRef.current = new Deck({
|
|
|
|
|
canvas,
|
|
|
|
|
controller: false,
|
|
|
|
|
layers: [],
|
|
|
|
|
useDevicePixels: true,
|
|
|
|
|
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, // OpenLayers와 Deck.gl 줌 레벨 차이 보정
|
|
|
|
|
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);
|
|
|
|
|
// OpenLayers 좌표를 경위도로 변환
|
|
|
|
|
const [minX, minY, maxX, maxY] = extent;
|
|
|
|
|
const [minLon, minLat] = toLonLat([minX, minY]);
|
|
|
|
|
const [maxLon, maxLat] = toLonLat([maxX, maxY]);
|
|
|
|
|
|
|
|
|
|
return { minLon, maxLon, minLat, maxLat };
|
|
|
|
|
}, [map]);
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 배치 렌더러 콜백 - 필터링된 선박으로 레이어 업데이트
|
|
|
|
|
* @param {Array} ships - 밀도 제한 적용된 선박 (아이콘 + 라벨 공통)
|
|
|
|
|
* @param {number} trigger - 렌더링 트리거
|
|
|
|
|
*/
|
|
|
|
|
const handleBatchRender = useCallback((ships, trigger) => {
|
|
|
|
|
if (!deckRef.current || !map) return;
|
|
|
|
|
|
|
|
|
|
const view = map.getView();
|
|
|
|
|
const zoom = view.getZoom() || 7;
|
|
|
|
|
const selectedShips = getSelectedShips();
|
|
|
|
|
|
|
|
|
|
// 현재 스토어에서 showLabels, labelOptions, isIntegrate, darkSignalIds 가져오기
|
|
|
|
|
const { showLabels: currentShowLabels, labelOptions: currentLabelOptions, isIntegrate: currentIsIntegrate, darkSignalIds } = useShipStore.getState();
|
|
|
|
|
|
|
|
|
|
// 레이어 생성 (밀도 제한 적용된 선박 = 아이콘 + 라벨 공통)
|
|
|
|
|
// 아이콘이 표시되는 선박에만 라벨/신호상태도 표시
|
|
|
|
|
const layers = createShipLayers(ships, selectedShips, zoom, currentShowLabels, currentLabelOptions, currentIsIntegrate, trigger, darkSignalIds);
|
|
|
|
|
|
|
|
|
|
// Deck.gl 레이어 업데이트
|
|
|
|
|
deckRef.current.setProps({ layers });
|
|
|
|
|
}, [map, getSelectedShips]);
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 선박 레이어 업데이트 (배치 렌더러 사용)
|
|
|
|
|
*/
|
|
|
|
|
const updateLayers = useCallback(() => {
|
|
|
|
|
if (!deckRef.current || !map) return;
|
|
|
|
|
|
|
|
|
|
if (!isShipVisible) {
|
|
|
|
|
// 선박 표시 Off
|
|
|
|
|
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;
|
|
|
|
|
|
|
|
|
|
// 맵 컨테이너에 Deck.gl 캔버스 추가
|
|
|
|
|
const viewport = map.getViewport();
|
|
|
|
|
initDeck(viewport);
|
|
|
|
|
|
|
|
|
|
// 배치 렌더러 초기화 (1회만)
|
|
|
|
|
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]);
|
|
|
|
|
|
|
|
|
|
// 선박 데이터 변경 시 레이어 업데이트
|
2026-01-30 13:06:56 +09:00
|
|
|
// ※ 성능 최적화: features/darkSignalIds는 mutable이므로 참조 비교 불가
|
|
|
|
|
// → featuresVersion/darkSignalVersion(숫자)으로 변경 감지
|
2026-01-30 13:01:54 +09:00
|
|
|
useEffect(() => {
|
|
|
|
|
// 스토어 구독하여 변경 감지
|
|
|
|
|
const unsubscribe = useShipStore.subscribe(
|
2026-01-30 13:06:56 +09:00
|
|
|
(state) => [state.featuresVersion, state.kindVisibility, state.sourceVisibility, state.isShipVisible, state.detailModals, state.isIntegrate, state.nationalVisibility, state.showLabels, state.labelOptions, state.darkSignalVisible, state.darkSignalVersion],
|
2026-01-30 13:01:54 +09:00
|
|
|
(current, prev) => {
|
2026-01-30 13:06:56 +09:00
|
|
|
// 필터 변경 감지 (kindVisibility, sourceVisibility, isShipVisible, isIntegrate, nationalVisibility, showLabels, labelOptions, darkSignalVisible, darkSignalVersion)
|
2026-01-30 13:01:54 +09:00
|
|
|
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; // 즉시 렌더링 후 추가 처리 불필요
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 데이터 변경 시 일반 렌더링 (적응형 주기 적용)
|
|
|
|
|
if (animationFrameRef.current) {
|
|
|
|
|
cancelAnimationFrame(animationFrameRef.current);
|
|
|
|
|
}
|
|
|
|
|
animationFrameRef.current = requestAnimationFrame(() => {
|
|
|
|
|
updateLayers();
|
|
|
|
|
deckRef.current?.redraw();
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
{ equalityFn: (a, b) => a.every((v, i) => v === b[i]) }
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
unsubscribe();
|
|
|
|
|
};
|
|
|
|
|
}, [updateLayers]);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
deckCanvas: canvasRef.current,
|
|
|
|
|
deckRef,
|
|
|
|
|
};
|
|
|
|
|
}
|