From 059b0670fc3bffa26d3855f1c07bc0abfcaff75c Mon Sep 17 00:00:00 2001 From: LHT Date: Thu, 12 Feb 2026 13:54:32 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EA=B4=80=EC=8B=AC=EC=84=A0=EB=B0=95=20?= =?UTF-8?q?=ED=95=84=ED=84=B0/=EA=B0=95=EC=A1=B0=20=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=96=B4=20+=20=EA=B4=80=EC=8B=AC=EA=B5=AC=EC=97=AD=20?= =?UTF-8?q?=ED=8F=B4=EB=A6=AC=EA=B3=A4=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - favoriteApi: 관심선박/관심구역 API 연동 - favoriteStore: favoriteSet(O(1) lookup), realmList 상태 관리 - ShipBatchRenderer: 관심선박 필터 우선 통과 + 밀도 제한 최우선 - shipLayer: 관심선박 위치에 ico_favship.svg 강조 IconLayer 오버레이 - useRealmLayer: 관심구역 OpenLayers 폴리곤(이름/색상/윤곽선) 렌더링 - useShipLayer: favoriteStore 변경 시 즉시 리렌더 구독 Co-Authored-By: Claude Opus 4.6 --- src/api/favoriteApi.js | 27 +++++++ src/hooks/useFavoriteData.js | 40 ++++++++++ src/hooks/useRealmLayer.js | 143 +++++++++++++++++++++++++++++++++++ src/hooks/useShipLayer.js | 19 +++++ src/map/ShipBatchRenderer.js | 50 +++++++++++- src/map/layers/shipLayer.js | 93 +++++++++++++++++++++++ src/stores/favoriteStore.js | 41 ++++++++++ 7 files changed, 409 insertions(+), 4 deletions(-) create mode 100644 src/api/favoriteApi.js create mode 100644 src/hooks/useFavoriteData.js create mode 100644 src/hooks/useRealmLayer.js create mode 100644 src/stores/favoriteStore.js diff --git a/src/api/favoriteApi.js b/src/api/favoriteApi.js new file mode 100644 index 00000000..b1ff9a59 --- /dev/null +++ b/src/api/favoriteApi.js @@ -0,0 +1,27 @@ +import { fetchWithAuth } from './fetchWithAuth'; + +/** + * 관심선박 목록 조회 + * @returns {Promise} 관심선박 목록 + */ +export async function fetchFavoriteShips() { + const response = await fetchWithAuth('/api/gis/my/dashboard/ship/attention/static/search'); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const result = await response.json(); + return result?.data || []; +} + +/** + * 관심구역 목록 조회 + * @returns {Promise} 관심구역 목록 + */ +export async function fetchRealms() { + const response = await fetchWithAuth('/api/gis/sea-relm/manage/show', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const result = await response.json(); + return result?.seaRelmManageShowDtoList || []; +} diff --git a/src/hooks/useFavoriteData.js b/src/hooks/useFavoriteData.js new file mode 100644 index 00000000..4d243ba1 --- /dev/null +++ b/src/hooks/useFavoriteData.js @@ -0,0 +1,40 @@ +import { useEffect, useRef } from 'react'; +import { fetchFavoriteShips, fetchRealms } from '../api/favoriteApi'; +import useFavoriteStore from '../stores/favoriteStore'; + +/** + * 관심선박 + 관심구역 데이터 로딩 훅 + * MapContainer에서 1회 호출 + */ +export default function useFavoriteData() { + const loaded = useRef(false); + + useEffect(() => { + if (loaded.current) return; + loaded.current = true; + + const load = async () => { + const [ships, realms] = await Promise.allSettled([ + fetchFavoriteShips(), + fetchRealms(), + ]); + + const shipList = ships.status === 'fulfilled' ? ships.value : []; + const realmList = realms.status === 'fulfilled' ? realms.value : []; + + if (ships.status === 'rejected') { + console.warn('[useFavoriteData] 관심선박 로드 실패:', ships.reason); + } + if (realms.status === 'rejected') { + console.warn('[useFavoriteData] 관심구역 로드 실패:', realms.reason); + } + + useFavoriteStore.getState().setFavoriteList(shipList); + useFavoriteStore.getState().setRealmList(realmList); + + console.log(`[useFavoriteData] 관심선박 ${shipList.length}건, 관심구역 ${realmList.length}건 로드`); + }; + + load(); + }, []); +} diff --git a/src/hooks/useRealmLayer.js b/src/hooks/useRealmLayer.js new file mode 100644 index 00000000..ca4ea962 --- /dev/null +++ b/src/hooks/useRealmLayer.js @@ -0,0 +1,143 @@ +import { useEffect, useRef } from 'react'; +import VectorLayer from 'ol/layer/Vector'; +import VectorSource from 'ol/source/Vector'; +import Feature from 'ol/Feature'; +import Polygon from 'ol/geom/Polygon'; +import { Style, Fill, Stroke, Text } from 'ol/style'; +import useFavoriteStore from '../stores/favoriteStore'; +import { useMapStore } from '../stores/mapStore'; + +/** + * 관심구역 OpenLayers 레이어 관리 훅 + * 참조: mda-react-front/src/services/commonService.ts - getRealmLayer() + */ +export default function useRealmLayer() { + const map = useMapStore((s) => s.map); + const layerRef = useRef(null); + const sourceRef = useRef(null); + + useEffect(() => { + if (!map) 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); + } + + // 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); + } + ); + + // isRealmVisible 변경 구독 + const unsubVisible = useFavoriteStore.subscribe( + (state) => state.isRealmVisible, + (isVisible) => { + 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 생성에 적합한 형식으로 변환 + * @param {Array} coordinates - 좌표 데이터 + * @returns {Array} Polygon rings 배열 [[lon,lat], ...] + */ +function normalizeCoordinates(coordinates) { + if (!Array.isArray(coordinates) || coordinates.length === 0) return null; + + // coordinates[0]이 숫자 배열이면 → 이미 ring 형태: [[lon,lat], ...] + if (Array.isArray(coordinates[0]) && typeof coordinates[0][0] === 'number') { + return coordinates; + } + + // coordinates[0][0]이 숫자 배열이면 → 이미 rings 형태: [[[lon,lat], ...]] + if (Array.isArray(coordinates[0]) && Array.isArray(coordinates[0][0]) && typeof coordinates[0][0][0] === 'number') { + return coordinates[0]; // 첫 번째 ring만 사용 + } + + return null; +} + +/** + * 관심구역 Feature 렌더링 + * @param {VectorSource} source - OL VectorSource + * @param {Array} realmList - 관심구역 데이터 배열 + */ +function renderRealms(source, realmList) { + source.clear(); + + if (!realmList || realmList.length === 0) return; + + let count = 0; + realmList.forEach((realm) => { + const ring = normalizeCoordinates(realm.coordinates); + if (!ring) return; + + try { + const polygon = new Polygon([ring]).transform('EPSG:4326', 'EPSG:3857'); + const feature = new Feature({ geometry: polygon }); + + feature.setStyle( + new Style({ + text: new Text({ + 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++; + } catch (err) { + console.warn('[useRealmLayer] Feature 생성 실패:', realm.seaRelmName, err); + } + }); + + console.log(`[useRealmLayer] ${count}/${realmList.length}건 렌더링 완료`); +} diff --git a/src/hooks/useShipLayer.js b/src/hooks/useShipLayer.js index 264d93d9..986f7729 100644 --- a/src/hooks/useShipLayer.js +++ b/src/hooks/useShipLayer.js @@ -20,6 +20,7 @@ import { getReplayLayers } from '../replay/utils/replayLayerRegistry'; import { getAreaSearchLayers } from '../areaSearch/utils/areaSearchLayerRegistry'; import { getStsLayers } from '../areaSearch/utils/stsLayerRegistry'; import { shipBatchRenderer } from '../map/ShipBatchRenderer'; +import useFavoriteStore from '../stores/favoriteStore'; /** * 선박 레이어 관리 훅 @@ -318,6 +319,24 @@ export default function useShipLayer(map) { return () => unsubscribe(); }, [map]); + // === favoriteStore 변경 시 선박 레이어 리렌더 === + // 관심선박 토글/목록 변경 시 필터 캐시 초기화 + 즉시 렌더링 + useEffect(() => { + const unsubscribe = useFavoriteStore.subscribe( + (state) => [state.isFavoriteEnabled, state.favoriteSet], + () => { + if (deckRef.current && map) { + shipBatchRenderer.clearCache(); + clearClusterCache(); + shipBatchRenderer.immediateRender(); + } + }, + { equalityFn: (a, b) => a[0] === b[0] && a[1] === b[1] } + ); + + return () => unsubscribe(); + }, [map]); + return { deckCanvas: canvasRef.current, deckRef, diff --git a/src/map/ShipBatchRenderer.js b/src/map/ShipBatchRenderer.js index b0250b96..2d3997a3 100644 --- a/src/map/ShipBatchRenderer.js +++ b/src/map/ShipBatchRenderer.js @@ -35,6 +35,7 @@ import { SOURCE_PRIORITY_RANK, SOURCE_TO_ACTIVE_KEY, } from '../types/constants'; +import useFavoriteStore from '../stores/favoriteStore'; // ===================== // 렌더링 설정 @@ -126,7 +127,7 @@ const PRIORITY_DEFAULT = 11; // 기본값 (최하위) */ function getShipPriority(ship, favoriteSet) { // 관심선박 체크 (최우선) - const favoriteKey = `${ship.targetId}_${ship.signalSourceCode}`; + const favoriteKey = `${ship.signalSourceCode}_${ship.originalTargetId}`; if (favoriteSet && favoriteSet.has(favoriteKey)) { return PRIORITY_FAVORITE; } @@ -255,6 +256,21 @@ function buildFilterCache() { dynamicPrioritySet = buildDynamicPrioritySet(features, enabledSources, darkSignalIds); } + // 관심선박 상태 + const { isFavoriteEnabled, favoriteSet } = useFavoriteStore.getState(); + + // 통합모드용: 관심선박이 포함된 통합그룹의 targetId Set + let favoriteTargetIds = null; + if (isFavoriteEnabled && favoriteSet.size > 0) { + favoriteTargetIds = new Set(); + for (const ship of features.values()) { + const favKey = `${ship.signalSourceCode}_${ship.originalTargetId}`; + if (favoriteSet.has(favKey)) { + favoriteTargetIds.add(ship.targetId); + } + } + } + return { enabledKinds, enabledSources, @@ -264,6 +280,9 @@ function buildFilterCache() { darkSignalVisible, darkSignalIds, dynamicPrioritySet, + isFavoriteEnabled, + favoriteSet, + favoriteTargetIds, }; } @@ -294,6 +313,24 @@ function applyFilterWithCache(ship, cache) { // 전체 선박 표시 Off if (!cache.isShipVisible) return false; + // ⓪ 관심선박: 다른 모든 필터보다 우선 + if (cache.isFavoriteEnabled && cache.favoriteTargetIds) { + if (cache.isIntegrate) { + // 통합모드: 이 통합그룹에 관심선박이 포함되어 있는가? + if (cache.favoriteTargetIds.has(ship.targetId)) { + // 통합선박이면 대표(isPriority)만 통과 + if (ship.integrate && !ship.isPriority) return false; + return true; + } + } else { + // 비통합모드: 직접 키 매칭 + const favKey = `${ship.signalSourceCode}_${ship.originalTargetId}`; + if (cache.favoriteSet.has(favKey)) { + return true; // 다른 모든 필터 무시 + } + } + } + // ① 다크시그널: 독립 필터 (선종/신호원/국적 무시, darkSignalVisible만 참조) // 통합모드 체크보다 먼저 실행해야 통합 다크시그널 선박도 렌더링됨 if (cache.darkSignalIds.has(ship.featureId)) return cache.darkSignalVisible; @@ -434,7 +471,8 @@ function generateFilterHash() { const kinds = Object.entries(kindVisibility).filter(([,v]) => v).map(([k]) => k).join(','); const sources = Object.entries(sourceVisibility).filter(([,v]) => v).map(([k]) => k).join(','); const nationals = Object.entries(nationalVisibility).filter(([,v]) => v).map(([k]) => k).join(','); - return `${kinds}|${sources}|${nationals}|${isIntegrate?1:0}|${darkSignalVisible?1:0}`; + const { isFavoriteEnabled, favoriteSet } = useFavoriteStore.getState(); + return `${kinds}|${sources}|${nationals}|${isIntegrate?1:0}|${darkSignalVisible?1:0}|${isFavoriteEnabled?1:0}|${favoriteSet.size}`; } /** @@ -596,7 +634,7 @@ class ShipBatchRenderer { lastShipsData: [], // 밀도 제한 적용된 선박 (아이콘 + 라벨 공통) lastFilteredCount: 0, // 필터링된 선박 수 (밀도 제한 전) lastRenderTrigger: 0, - favoriteSet: null, // 관심선박 Set (향후 구현) + favoriteSet: null, // (사용 안 함 — useFavoriteStore.getState() 직접 참조) }; // 외부 콜백 @@ -728,8 +766,12 @@ class ShipBatchRenderer { ); // 5. 밀도 제한 적용 (선박 아이콘 클러스터링, 우선순위 기반) + // 관심선박 토글 ON → favoriteSet 전달 (PRIORITY_FAVORITE=0, 최우선) + // 관심선박 토글 OFF → null 전달 (일반 선종 우선순위 적용) const zoom = this.renderState.currentZoom; - const densityLimitedShips = applyDensityLimit(filteredShips, zoom, this.cache.favoriteSet); + const { isFavoriteEnabled, favoriteSet: currentFavoriteSet } = useFavoriteStore.getState(); + const densityFavoriteSet = isFavoriteEnabled ? currentFavoriteSet : null; + const densityLimitedShips = applyDensityLimit(filteredShips, zoom, densityFavoriteSet); // 6. 렌더링 트리거 증가 this.cache.lastRenderTrigger++; diff --git a/src/map/layers/shipLayer.js b/src/map/layers/shipLayer.js index d0fe0dae..3ed64d27 100644 --- a/src/map/layers/shipLayer.js +++ b/src/map/layers/shipLayer.js @@ -14,11 +14,14 @@ import { SIGNAL_FLAG_CONFIGS, } from '../../types/constants'; import useShipStore from '../../stores/shipStore'; +import useFavoriteStore from '../../stores/favoriteStore'; import useTrackingModeStore from '../../stores/trackingModeStore'; import { useMapStore, THEME_COLORS, THEME_TYPES } from '../../stores/mapStore'; // 아이콘 아틀라스 이미지 import atlasImg from '../../assets/img/icon/atlas.png'; +// 관심선박 강조 아이콘 +import favShipIcon from '../../assets/images/ico_favship.svg'; /** * 현재 테마 색상 가져오기 @@ -1142,6 +1145,90 @@ export function createTrackedShipLayers(zoom) { return layers; } +/** + * 관심선박 매칭 데이터 추출 + * @param {Array} ships - 밀도 제한된 선박 배열 + * @param {boolean} isIntegrate - 통합모드 여부 + * @returns {Array} 관심선박 배열 + */ +function getFavoriteShips(ships, isIntegrate) { + const { isFavoriteEnabled, favoriteSet } = useFavoriteStore.getState(); + if (!isFavoriteEnabled || favoriteSet.size === 0) return []; + + // 통합모드: 관심선박이 속한 통합그룹의 targetId Set + let favoriteTargetIds = null; + if (isIntegrate) { + favoriteTargetIds = new Set(); + const { features } = useShipStore.getState(); + for (const ship of features.values()) { + const favKey = `${ship.signalSourceCode}_${ship.originalTargetId}`; + if (favoriteSet.has(favKey)) { + favoriteTargetIds.add(ship.targetId); + } + } + } + + return ships.filter((ship) => { + if (isIntegrate) { + return favoriteTargetIds.has(ship.targetId) && (!ship.integrate || ship.isPriority); + } + const favKey = `${ship.signalSourceCode}_${ship.originalTargetId}`; + return favoriteSet.has(favKey); + }); +} + +/** + * 관심선박 강조 레이어 생성 (배경원 + 아이콘) + * 참조: mda-react-front/src/common/targetLayer.ts - deckFavoriteLayer + * + * @param {Array} ships - 밀도 제한된 선박 배열 + * @param {boolean} isIntegrate - 통합모드 여부 + * @returns {Array} [ScatterplotLayer, IconLayer] 또는 빈 배열 + */ +function createFavoriteHighlightLayers(ships, isIntegrate) { + const favoriteShips = getFavoriteShips(ships, isIntegrate); + if (favoriteShips.length === 0) return []; + + // 배경 원 (반투명 노란색 — 선박 아이콘 뒤에서 강조) + const bgLayer = new ScatterplotLayer({ + id: 'favorite-bg-layer', + data: favoriteShips, + pickable: false, + stroked: true, + filled: true, + radiusScale: 1, + radiusMinPixels: 16, + radiusMaxPixels: 28, + lineWidthMinPixels: 2, + getPosition: (d) => [d.longitude, d.latitude], + getRadius: 20, + getFillColor: [255, 215, 0, 60], // 노란색 반투명 + getLineColor: [255, 165, 0, 200], // 주황색 테두리 + getLineWidth: 2, + }); + + // 별+선박 아이콘 (우상단 오프셋) + const iconLayer = new IconLayer({ + id: 'favorite-icon-layer', + data: favoriteShips, + pickable: false, + getPosition: (d) => [d.longitude, d.latitude], + getIcon: () => ({ + url: favShipIcon, + width: 20, + height: 20, + anchorX: 14, + anchorY: 16, + }), + getSize: 28, + sizeScale: 1, + sizeMinPixels: 24, + sizeMaxPixels: 36, + }); + + return [bgLayer, iconLayer]; +} + /** * 모든 선박 레이어 생성 (통합) * 참조: mda-react-front/src/common/deck.ts @@ -1173,6 +1260,12 @@ export function createShipLayers(ships, selectedShips, zoom, showLabels = false, // 2. 선박 아이콘 레이어 (밀도 제한 적용된 전체 선박) layers.push(createShipIconLayer(ships, zoom, darkSignalIds)); + // 2.5 관심선박 강조 레이어 (배경원 + 아이콘, 선박 아이콘 위에 오버레이) + const favoriteLayers = createFavoriteHighlightLayers(ships, isIntegrate); + if (favoriteLayers.length > 0) { + layers.push(...favoriteLayers); + } + // 3. 추적 선박 레이어 (최상단 - 다른 아이콘 위에 표시) const trackedLayers = createTrackedShipLayers(zoom); if (trackedLayers.length > 0) { diff --git a/src/stores/favoriteStore.js b/src/stores/favoriteStore.js new file mode 100644 index 00000000..f1415eb7 --- /dev/null +++ b/src/stores/favoriteStore.js @@ -0,0 +1,41 @@ +import { create } from 'zustand'; +import { subscribeWithSelector } from 'zustand/middleware'; + +/** + * 관심선박 + 관심구역 스토어 + * 참조: mda-react-front/src/shared/model/favoriteStore.ts + */ +const useFavoriteStore = create(subscribeWithSelector((set) => ({ + // === 관심선박 === + favoriteList: [], // API 원본 배열 + favoriteSet: new Set(), // O(1) lookup: signalSourceCode_originalTargetId + isFavoriteEnabled: false, // 필터 토글 (기본 OFF) + + // === 관심구역 === + realmList: [], // API 원본 배열 + isRealmVisible: true, // 레이어 표시 여부 + + // === 액션 === + + /** + * 관심선박 목록 설정 + * API 응답의 item.targetId는 originalTargetId에 해당 + */ + setFavoriteList: (list) => { + const newSet = new Set(); + list.forEach((item) => { + if (item.signalSourceCode && item.targetId) { + newSet.add(`${item.signalSourceCode}_${item.targetId}`); + } + }); + set({ favoriteList: list, favoriteSet: newSet }); + }, + + toggleFavoriteEnabled: () => set((s) => ({ isFavoriteEnabled: !s.isFavoriteEnabled })), + + setRealmList: (list) => set({ realmList: list }), + + toggleRealmVisible: () => set((s) => ({ isRealmVisible: !s.isRealmVisible })), +}))); + +export default useFavoriteStore;