feat: 관심선박 필터/강조 레이어 + 관심구역 폴리곤 표시

- 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 <noreply@anthropic.com>
This commit is contained in:
LHT 2026-02-12 13:54:32 +09:00
부모 de2cd907f1
커밋 059b0670fc
7개의 변경된 파일409개의 추가작업 그리고 4개의 파일을 삭제

27
src/api/favoriteApi.js Normal file
파일 보기

@ -0,0 +1,27 @@
import { fetchWithAuth } from './fetchWithAuth';
/**
* 관심선박 목록 조회
* @returns {Promise<Array>} 관심선박 목록
*/
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<Array>} 관심구역 목록
*/
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 || [];
}

파일 보기

@ -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();
}, []);
}

143
src/hooks/useRealmLayer.js Normal file
파일 보기

@ -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}건 렌더링 완료`);
}

파일 보기

@ -20,6 +20,7 @@ import { getReplayLayers } from '../replay/utils/replayLayerRegistry';
import { getAreaSearchLayers } from '../areaSearch/utils/areaSearchLayerRegistry'; import { getAreaSearchLayers } from '../areaSearch/utils/areaSearchLayerRegistry';
import { getStsLayers } from '../areaSearch/utils/stsLayerRegistry'; import { getStsLayers } from '../areaSearch/utils/stsLayerRegistry';
import { shipBatchRenderer } from '../map/ShipBatchRenderer'; import { shipBatchRenderer } from '../map/ShipBatchRenderer';
import useFavoriteStore from '../stores/favoriteStore';
/** /**
* 선박 레이어 관리 * 선박 레이어 관리
@ -318,6 +319,24 @@ export default function useShipLayer(map) {
return () => unsubscribe(); return () => unsubscribe();
}, [map]); }, [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 { return {
deckCanvas: canvasRef.current, deckCanvas: canvasRef.current,
deckRef, deckRef,

파일 보기

@ -35,6 +35,7 @@ import {
SOURCE_PRIORITY_RANK, SOURCE_PRIORITY_RANK,
SOURCE_TO_ACTIVE_KEY, SOURCE_TO_ACTIVE_KEY,
} from '../types/constants'; } from '../types/constants';
import useFavoriteStore from '../stores/favoriteStore';
// ===================== // =====================
// 렌더링 설정 // 렌더링 설정
@ -126,7 +127,7 @@ const PRIORITY_DEFAULT = 11; // 기본값 (최하위)
*/ */
function getShipPriority(ship, favoriteSet) { function getShipPriority(ship, favoriteSet) {
// 관심선박 체크 (최우선) // 관심선박 체크 (최우선)
const favoriteKey = `${ship.targetId}_${ship.signalSourceCode}`; const favoriteKey = `${ship.signalSourceCode}_${ship.originalTargetId}`;
if (favoriteSet && favoriteSet.has(favoriteKey)) { if (favoriteSet && favoriteSet.has(favoriteKey)) {
return PRIORITY_FAVORITE; return PRIORITY_FAVORITE;
} }
@ -255,6 +256,21 @@ function buildFilterCache() {
dynamicPrioritySet = buildDynamicPrioritySet(features, enabledSources, darkSignalIds); 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 { return {
enabledKinds, enabledKinds,
enabledSources, enabledSources,
@ -264,6 +280,9 @@ function buildFilterCache() {
darkSignalVisible, darkSignalVisible,
darkSignalIds, darkSignalIds,
dynamicPrioritySet, dynamicPrioritySet,
isFavoriteEnabled,
favoriteSet,
favoriteTargetIds,
}; };
} }
@ -294,6 +313,24 @@ function applyFilterWithCache(ship, cache) {
// 전체 선박 표시 Off // 전체 선박 표시 Off
if (!cache.isShipVisible) return false; 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만 참조) // ① 다크시그널: 독립 필터 (선종/신호원/국적 무시, darkSignalVisible만 참조)
// 통합모드 체크보다 먼저 실행해야 통합 다크시그널 선박도 렌더링됨 // 통합모드 체크보다 먼저 실행해야 통합 다크시그널 선박도 렌더링됨
if (cache.darkSignalIds.has(ship.featureId)) return cache.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 kinds = Object.entries(kindVisibility).filter(([,v]) => v).map(([k]) => k).join(',');
const sources = Object.entries(sourceVisibility).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(','); 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: [], // 밀도 제한 적용된 선박 (아이콘 + 라벨 공통) lastShipsData: [], // 밀도 제한 적용된 선박 (아이콘 + 라벨 공통)
lastFilteredCount: 0, // 필터링된 선박 수 (밀도 제한 전) lastFilteredCount: 0, // 필터링된 선박 수 (밀도 제한 전)
lastRenderTrigger: 0, lastRenderTrigger: 0,
favoriteSet: null, // 관심선박 Set (향후 구현) favoriteSet: null, // (사용 안 함 — useFavoriteStore.getState() 직접 참조)
}; };
// 외부 콜백 // 외부 콜백
@ -728,8 +766,12 @@ class ShipBatchRenderer {
); );
// 5. 밀도 제한 적용 (선박 아이콘 클러스터링, 우선순위 기반) // 5. 밀도 제한 적용 (선박 아이콘 클러스터링, 우선순위 기반)
// 관심선박 토글 ON → favoriteSet 전달 (PRIORITY_FAVORITE=0, 최우선)
// 관심선박 토글 OFF → null 전달 (일반 선종 우선순위 적용)
const zoom = this.renderState.currentZoom; 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. 렌더링 트리거 증가 // 6. 렌더링 트리거 증가
this.cache.lastRenderTrigger++; this.cache.lastRenderTrigger++;

파일 보기

@ -14,11 +14,14 @@ import {
SIGNAL_FLAG_CONFIGS, SIGNAL_FLAG_CONFIGS,
} from '../../types/constants'; } from '../../types/constants';
import useShipStore from '../../stores/shipStore'; import useShipStore from '../../stores/shipStore';
import useFavoriteStore from '../../stores/favoriteStore';
import useTrackingModeStore from '../../stores/trackingModeStore'; import useTrackingModeStore from '../../stores/trackingModeStore';
import { useMapStore, THEME_COLORS, THEME_TYPES } from '../../stores/mapStore'; import { useMapStore, THEME_COLORS, THEME_TYPES } from '../../stores/mapStore';
// 아이콘 아틀라스 이미지 // 아이콘 아틀라스 이미지
import atlasImg from '../../assets/img/icon/atlas.png'; 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; 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 * 참조: mda-react-front/src/common/deck.ts
@ -1173,6 +1260,12 @@ export function createShipLayers(ships, selectedShips, zoom, showLabels = false,
// 2. 선박 아이콘 레이어 (밀도 제한 적용된 전체 선박) // 2. 선박 아이콘 레이어 (밀도 제한 적용된 전체 선박)
layers.push(createShipIconLayer(ships, zoom, darkSignalIds)); layers.push(createShipIconLayer(ships, zoom, darkSignalIds));
// 2.5 관심선박 강조 레이어 (배경원 + 아이콘, 선박 아이콘 위에 오버레이)
const favoriteLayers = createFavoriteHighlightLayers(ships, isIntegrate);
if (favoriteLayers.length > 0) {
layers.push(...favoriteLayers);
}
// 3. 추적 선박 레이어 (최상단 - 다른 아이콘 위에 표시) // 3. 추적 선박 레이어 (최상단 - 다른 아이콘 위에 표시)
const trackedLayers = createTrackedShipLayers(zoom); const trackedLayers = createTrackedShipLayers(zoom);
if (trackedLayers.length > 0) { if (trackedLayers.length > 0) {

파일 보기

@ -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;