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:
부모
de2cd907f1
커밋
059b0670fc
27
src/api/favoriteApi.js
Normal file
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 || [];
|
||||||
|
}
|
||||||
40
src/hooks/useFavoriteData.js
Normal file
40
src/hooks/useFavoriteData.js
Normal file
@ -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
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) {
|
||||||
|
|||||||
41
src/stores/favoriteStore.js
Normal file
41
src/stores/favoriteStore.js
Normal file
@ -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;
|
||||||
불러오는 중...
Reference in New Issue
Block a user