fix: LIVE 모드 렌더링 최적화 + 특정어업수역 폴리곤 수정
- useMonitor 1초 setInterval 제거 (LIVE 60배 과잉 재계산 해소) - useKoreaFilters currentTime 의존성 제거 (5분 polling 시에만 재계산) - useKoreaData aircraft/satellite LIVE/REPLAY 분리 - 특정어업수역 실제 폴리곤 좌표 적용 (bbox→원본 GeoJSON 변환) - FishingZoneLayer zone 속성 매칭 수정 - 선박/분석 라벨 폰트 크기 80% 축소
This commit is contained in:
부모
66f792724b
커밋
e2b531d9c5
@ -2,35 +2,34 @@ import { Source, Layer } from 'react-map-gl/maplibre';
|
||||
import fishingZonesData from '../../data/zones/fishing-zones-wgs84.json';
|
||||
|
||||
const ZONE_FILL: Record<string, string> = {
|
||||
ZONE_I: 'rgba(59, 130, 246, 0.15)',
|
||||
ZONE_II: 'rgba(16, 185, 129, 0.15)',
|
||||
ZONE_III: 'rgba(245, 158, 11, 0.15)',
|
||||
ZONE_IV: 'rgba(239, 68, 68, 0.15)',
|
||||
I: 'rgba(59, 130, 246, 0.15)',
|
||||
II: 'rgba(16, 185, 129, 0.15)',
|
||||
III: 'rgba(245, 158, 11, 0.15)',
|
||||
IV: 'rgba(239, 68, 68, 0.15)',
|
||||
};
|
||||
|
||||
const ZONE_LINE: Record<string, string> = {
|
||||
ZONE_I: 'rgba(59, 130, 246, 0.6)',
|
||||
ZONE_II: 'rgba(16, 185, 129, 0.6)',
|
||||
ZONE_III: 'rgba(245, 158, 11, 0.6)',
|
||||
ZONE_IV: 'rgba(239, 68, 68, 0.6)',
|
||||
I: 'rgba(59, 130, 246, 0.6)',
|
||||
II: 'rgba(16, 185, 129, 0.6)',
|
||||
III: 'rgba(245, 158, 11, 0.6)',
|
||||
IV: 'rgba(239, 68, 68, 0.6)',
|
||||
};
|
||||
|
||||
|
||||
const fillColor = [
|
||||
'match', ['get', 'id'],
|
||||
'ZONE_I', ZONE_FILL.ZONE_I,
|
||||
'ZONE_II', ZONE_FILL.ZONE_II,
|
||||
'ZONE_III', ZONE_FILL.ZONE_III,
|
||||
'ZONE_IV', ZONE_FILL.ZONE_IV,
|
||||
'match', ['get', 'zone'],
|
||||
'I', ZONE_FILL.I,
|
||||
'II', ZONE_FILL.II,
|
||||
'III', ZONE_FILL.III,
|
||||
'IV', ZONE_FILL.IV,
|
||||
'rgba(0,0,0,0)',
|
||||
] as maplibregl.ExpressionSpecification;
|
||||
|
||||
const lineColor = [
|
||||
'match', ['get', 'id'],
|
||||
'ZONE_I', ZONE_LINE.ZONE_I,
|
||||
'ZONE_II', ZONE_LINE.ZONE_II,
|
||||
'ZONE_III', ZONE_LINE.ZONE_III,
|
||||
'ZONE_IV', ZONE_LINE.ZONE_IV,
|
||||
'match', ['get', 'zone'],
|
||||
'I', ZONE_LINE.I,
|
||||
'II', ZONE_LINE.II,
|
||||
'III', ZONE_LINE.III,
|
||||
'IV', ZONE_LINE.IV,
|
||||
'rgba(0,0,0,0)',
|
||||
] as maplibregl.ExpressionSpecification;
|
||||
|
||||
|
||||
@ -157,7 +157,6 @@ export const KoreaDashboard = ({
|
||||
const koreaFiltersResult = useKoreaFilters(
|
||||
koreaData.ships,
|
||||
koreaData.visibleShips,
|
||||
currentTime,
|
||||
vesselAnalysis.analysisMap,
|
||||
);
|
||||
|
||||
|
||||
@ -242,7 +242,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
||||
data: illegalFishingData,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => d.name || d.mmsi,
|
||||
getSize: 14 * zoomScale,
|
||||
getSize: 11 * zoomScale,
|
||||
getColor: [239, 68, 68, 255],
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'top',
|
||||
@ -256,19 +256,20 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
||||
updateTriggers: { getSize: [zoomScale] },
|
||||
}), [illegalFishingData, zoomScale]);
|
||||
|
||||
// 수역 라벨 TextLayer — illegalFishing 필터 활성 시만 표시
|
||||
// 수역 라벨 TextLayer — illegalFishing 또는 cnFishing 필터 활성 시 표시
|
||||
const zoneLabelsLayer = useMemo(() => {
|
||||
if (!koreaFilters.illegalFishing) return null;
|
||||
if (!koreaFilters.illegalFishing && !koreaFilters.cnFishing) return null;
|
||||
const data = (fishingZonesData as GeoJSON.FeatureCollection).features.map(f => {
|
||||
const geom = f.geometry as GeoJSON.MultiPolygon;
|
||||
const geom = f.geometry as GeoJSON.Polygon | GeoJSON.MultiPolygon;
|
||||
let sLng = 0, sLat = 0, n = 0;
|
||||
for (const poly of geom.coordinates) {
|
||||
for (const ring of poly) {
|
||||
const rings = geom.type === 'MultiPolygon'
|
||||
? geom.coordinates.flatMap(poly => poly)
|
||||
: geom.coordinates;
|
||||
for (const ring of rings) {
|
||||
for (const [lng, lat] of ring) {
|
||||
sLng += lng; sLat += lat; n++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
name: (f.properties as { name: string }).name,
|
||||
lng: n > 0 ? sLng / n : 0,
|
||||
@ -293,7 +294,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
||||
characterSet: 'auto',
|
||||
updateTriggers: { getSize: [zoomScale] },
|
||||
});
|
||||
}, [koreaFilters.illegalFishing, zoomScale]);
|
||||
}, [koreaFilters.illegalFishing, koreaFilters.cnFishing, zoomScale]);
|
||||
|
||||
// 정적 레이어 (deck.gl) — 항구, 공항, 군사기지, 어항경고 등
|
||||
const staticDeckLayers = useStaticDeckLayers({
|
||||
@ -356,7 +357,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
||||
data: gears,
|
||||
getPosition: (d: Ship) => [d.lng, d.lat],
|
||||
getText: (d: Ship) => d.name || d.mmsi,
|
||||
getSize: 13 * zoomScale,
|
||||
getSize: 10 * zoomScale,
|
||||
getColor: [249, 115, 22, 255],
|
||||
getTextAnchor: 'middle' as const,
|
||||
getAlignmentBaseline: 'top' as const,
|
||||
@ -391,7 +392,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
||||
data: [parent],
|
||||
getPosition: (d: Ship) => [d.lng, d.lat],
|
||||
getText: (d: Ship) => `▼ ${d.name || groupName} (모선)`,
|
||||
getSize: 14 * zoomScale,
|
||||
getSize: 11 * zoomScale,
|
||||
getColor: [249, 115, 22, 255],
|
||||
getTextAnchor: 'middle' as const,
|
||||
getAlignmentBaseline: 'top' as const,
|
||||
@ -456,7 +457,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
||||
const prefix = role === 'LEADER' ? '★ ' : '';
|
||||
return `${prefix}${d.name || d.mmsi}`;
|
||||
},
|
||||
getSize: 13 * zoomScale,
|
||||
getSize: 10 * zoomScale,
|
||||
getColor: color,
|
||||
getTextAnchor: 'middle' as const,
|
||||
getAlignmentBaseline: 'top' as const,
|
||||
|
||||
@ -485,7 +485,7 @@ export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusM
|
||||
layout={{
|
||||
'visibility': highlightKorean ? 'visible' : 'none',
|
||||
'text-field': ['get', 'name'],
|
||||
'text-size': ['interpolate', ['linear'], ['zoom'], 4, 8, 6, 9, 8, 11, 10, 14, 12, 16, 13, 18, 14, 20],
|
||||
'text-size': ['interpolate', ['linear'], ['zoom'], 4, 6, 6, 7, 8, 9, 10, 11, 12, 13, 13, 15, 14, 17],
|
||||
'text-offset': [0, 2.2],
|
||||
'text-anchor': 'top',
|
||||
'text-allow-overlap': false,
|
||||
|
||||
File diff suppressed because one or more lines are too long
@ -123,7 +123,7 @@ export function useAnalysisDeckLayers(
|
||||
const name = d.ship.name || d.ship.mmsi;
|
||||
return `${name}\n${label} ${d.dto.algorithms.riskScore.score}`;
|
||||
},
|
||||
getSize: 13 * sizeScale,
|
||||
getSize: 10 * sizeScale,
|
||||
updateTriggers: { getSize: [sizeScale] },
|
||||
getColor: (d) => RISK_RGBA_BORDER[d.dto.algorithms.riskScore.level] ?? [200, 200, 200, 255],
|
||||
getTextAnchor: 'middle',
|
||||
@ -167,7 +167,7 @@ export function useAnalysisDeckLayers(
|
||||
const gap = d.dto.algorithms.darkVessel.gapDurationMin;
|
||||
return gap > 0 ? `AIS 소실 ${Math.round(gap)}분` : 'DARK';
|
||||
},
|
||||
getSize: 13 * sizeScale,
|
||||
getSize: 10 * sizeScale,
|
||||
updateTriggers: { getSize: [sizeScale] },
|
||||
getColor: [168, 85, 247, 255],
|
||||
getTextAnchor: 'middle',
|
||||
@ -191,7 +191,7 @@ export function useAnalysisDeckLayers(
|
||||
data: spoofData,
|
||||
getPosition: (d) => [d.ship.lng, d.ship.lat],
|
||||
getText: (d) => `GPS ${Math.round(d.dto.algorithms.gpsSpoofing.spoofingScore * 100)}%`,
|
||||
getSize: 13 * sizeScale,
|
||||
getSize: 10 * sizeScale,
|
||||
getColor: [239, 68, 68, 255],
|
||||
getTextAnchor: 'start',
|
||||
getPixelOffset: [12, -8],
|
||||
|
||||
@ -133,17 +133,37 @@ export function useKoreaData({
|
||||
}, [refreshKey]);
|
||||
|
||||
// Propagate Korea satellite positions
|
||||
// LIVE: satellitesKorea 변경(초기 로드) 시에만 계산. currentTime 불필요.
|
||||
// REPLAY: currentTime으로 궤도 보간 필요.
|
||||
useEffect(() => {
|
||||
if (satellitesKorea.length === 0) return;
|
||||
if (isLive) {
|
||||
// LIVE 모드: 위성 데이터 로드 시 1회 계산
|
||||
const positions = propagateAll(satellitesKorea, new Date());
|
||||
setSatPositionsKorea(positions);
|
||||
} else {
|
||||
// REPLAY 모드: currentTime 변경 시 재계산 (throttle 2초)
|
||||
const now = Date.now();
|
||||
if (now - satTimeKoreaRef.current < 2000) return;
|
||||
satTimeKoreaRef.current = now;
|
||||
const positions = propagateAll(satellitesKorea, new Date(currentTime));
|
||||
setSatPositionsKorea(positions);
|
||||
}, [satellitesKorea, currentTime]);
|
||||
}
|
||||
}, [satellitesKorea, isLive, isLive ? 0 : currentTime]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Propagate Korea aircraft (live only — no waypoint propagation needed)
|
||||
const aircraft = useMemo(() => propagateAircraft(baseAircraftKorea, currentTime), [baseAircraftKorea, currentTime]);
|
||||
// Propagate Korea aircraft
|
||||
// LIVE: baseAircraftKorea 변경(60초 polling) 시에만 계산. currentTime 불필요.
|
||||
// REPLAY: currentTime으로 보간.
|
||||
const liveAircraft = useMemo(
|
||||
() => propagateAircraft(baseAircraftKorea, Date.now()),
|
||||
[baseAircraftKorea],
|
||||
);
|
||||
const replayAircraft = useMemo(() => {
|
||||
if (isLive) return liveAircraft;
|
||||
return propagateAircraft(baseAircraftKorea, currentTime);
|
||||
}, [isLive, baseAircraftKorea, currentTime, liveAircraft]);
|
||||
|
||||
const aircraft = isLive ? liveAircraft : replayAircraft;
|
||||
|
||||
// LIVE: baseShipsKorea 변경(5분 polling) 시에만 재계산. currentTime 무관.
|
||||
// REPLAY: currentTime으로 활성 선박 + 웨이포인트 보간 필요.
|
||||
|
||||
@ -40,10 +40,11 @@ const DOKDO = { lat: 37.2417, lng: 131.8647 };
|
||||
const TERRITORIAL_DEG = 0.2; // ~22km (12해리)
|
||||
const ALERT_DEG = 0.4; // ~44km
|
||||
|
||||
// currentTime 파라미터 제거 — 데이터(koreaShips) 변경 시에만 재계산.
|
||||
// 타임스탬프가 필요한 곳은 Date.now() 인라인 사용.
|
||||
export function useKoreaFilters(
|
||||
koreaShips: Ship[],
|
||||
visibleShips: Ship[],
|
||||
currentTime: number,
|
||||
analysisMap?: Map<string, VesselAnalysisDto>,
|
||||
): UseKoreaFiltersResult {
|
||||
const [filters, setFilters] = useLocalStorage<KoreaFilters>('koreaFilters', {
|
||||
@ -87,10 +88,11 @@ export function useKoreaFilters(
|
||||
}, [filters.illegalTransship, analysisMap]);
|
||||
|
||||
// 다크베셀 탐지: AIS 신호 이력 추적
|
||||
// currentTime 제거 → koreaShips(5분 polling) 변경 시에만 재계산
|
||||
const darkVesselSet = useMemo(() => {
|
||||
if (!filters.darkVessel) return new Set<string>();
|
||||
|
||||
const now = currentTime;
|
||||
const now = Date.now();
|
||||
const history = aisHistoryRef.current;
|
||||
const result = new Set<string>();
|
||||
const currentMmsis = new Set(koreaShips.map(s => s.mmsi));
|
||||
@ -151,9 +153,10 @@ export function useKoreaFilters(
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [koreaShips, filters.darkVessel, currentTime, analysisMap]);
|
||||
}, [koreaShips, filters.darkVessel, analysisMap]);
|
||||
|
||||
// 해저케이블 감시
|
||||
// currentTime 제거 → koreaShips(5분 polling) 변경 시에만 재계산
|
||||
const cableWatchSet = useMemo(() => {
|
||||
if (!filters.cableWatch) return new Set<string>();
|
||||
const result = new Set<string>();
|
||||
@ -177,7 +180,7 @@ export function useKoreaFilters(
|
||||
return Math.hypot(dlng, py - cy);
|
||||
};
|
||||
|
||||
const now = currentTime;
|
||||
const now = Date.now();
|
||||
const prevMap = cableNearStartRef.current;
|
||||
const currentNear = new Set<string>();
|
||||
|
||||
@ -207,9 +210,10 @@ export function useKoreaFilters(
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [koreaShips, filters.cableWatch, currentTime]);
|
||||
}, [koreaShips, filters.cableWatch]);
|
||||
|
||||
// 독도감시
|
||||
// currentTime 제거 → koreaShips(5분 polling) 변경 시에만 재계산
|
||||
const dokdoWatchSet = useMemo(() => {
|
||||
if (!filters.dokdoWatch) return new Set<string>();
|
||||
const result = new Set<string>();
|
||||
@ -227,14 +231,14 @@ export function useKoreaFilters(
|
||||
if (!alerted.has(s.mmsi)) {
|
||||
alerted.add(s.mmsi);
|
||||
const distKm = Math.round(dDokdo * 111);
|
||||
newAlerts.push({ mmsi: s.mmsi, name: s.name || s.mmsi, dist: distKm, time: currentTime });
|
||||
newAlerts.push({ mmsi: s.mmsi, name: s.name || s.mmsi, dist: distKm, time: Date.now() });
|
||||
}
|
||||
} else if (dDokdo < ALERT_DEG) {
|
||||
result.add(s.mmsi);
|
||||
if (!alerted.has(`warn-${s.mmsi}`)) {
|
||||
alerted.add(`warn-${s.mmsi}`);
|
||||
const distKm = Math.round(dDokdo * 111);
|
||||
newAlerts.push({ mmsi: s.mmsi, name: s.name || s.mmsi, dist: distKm, time: currentTime });
|
||||
newAlerts.push({ mmsi: s.mmsi, name: s.name || s.mmsi, dist: distKm, time: Date.now() });
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -250,7 +254,7 @@ export function useKoreaFilters(
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [koreaShips, filters.dokdoWatch, currentTime]);
|
||||
}, [koreaShips, filters.dokdoWatch]);
|
||||
|
||||
// 중국어선 의심 선박 Set
|
||||
const cnFishingSuspects = useMemo(() => {
|
||||
|
||||
@ -1,6 +1,4 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
|
||||
const TICK_INTERVAL = 1000; // update every 1 second in live mode
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
export interface MonitorState {
|
||||
currentTime: number; // always Date.now()
|
||||
@ -13,24 +11,20 @@ export function useMonitor() {
|
||||
historyMinutes: 60,
|
||||
});
|
||||
|
||||
const intervalRef = useRef<number | null>(null);
|
||||
|
||||
// Start ticking immediately
|
||||
useEffect(() => {
|
||||
intervalRef.current = window.setInterval(() => {
|
||||
setState(prev => ({ ...prev, currentTime: Date.now() }));
|
||||
}, TICK_INTERVAL);
|
||||
return () => {
|
||||
if (intervalRef.current !== null) clearInterval(intervalRef.current);
|
||||
};
|
||||
}, []);
|
||||
// LIVE 모드에서 1초 tick 제거 — 데이터 polling 응답 시에만 갱신하면 충분.
|
||||
// currentTime은 표시용으로 렌더 시 Date.now() 사용, 데이터 훅에는 전달하지 않음.
|
||||
|
||||
const setHistoryMinutes = useCallback((minutes: number) => {
|
||||
setState(prev => ({ ...prev, historyMinutes: minutes }));
|
||||
}, []);
|
||||
|
||||
/** 데이터 갱신 시점에 호출하여 currentTime 동기화 */
|
||||
const refreshTime = useCallback(() => {
|
||||
setState(prev => ({ ...prev, currentTime: Date.now() }));
|
||||
}, []);
|
||||
|
||||
const startTime = state.currentTime - state.historyMinutes * 60_000;
|
||||
const endTime = state.currentTime;
|
||||
|
||||
return { state, startTime, endTime, setHistoryMinutes };
|
||||
return { state, startTime, endTime, setHistoryMinutes, refreshTime };
|
||||
}
|
||||
|
||||
@ -66,12 +66,15 @@ const ZONE_ALLOWED: Record<string, string[]> = {
|
||||
/**
|
||||
* 특정어업수역 Ⅰ~Ⅳ 폴리곤 (사전 변환된 WGS84 GeoJSON)
|
||||
*/
|
||||
export const ZONE_POLYGONS = fishingZonesWgs84.features.map(f => ({
|
||||
id: f.properties.id as FishingZoneId,
|
||||
export const ZONE_POLYGONS = fishingZonesWgs84.features.map(f => {
|
||||
const zoneId = `ZONE_${f.properties.zone}` as FishingZoneId;
|
||||
return {
|
||||
id: zoneId,
|
||||
name: f.properties.name,
|
||||
allowed: ZONE_ALLOWED[f.properties.id] ?? [],
|
||||
allowed: ZONE_ALLOWED[zoneId] ?? [],
|
||||
geojson: f as unknown as Feature<MultiPolygon>,
|
||||
}));
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* 특정어업수역 폴리곤 기반 수역 분류
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user