Merge pull request 'release: 2026-03-23.6 (5건 커밋)' (#166) from develop into main
All checks were successful
Deploy KCG / deploy (push) Successful in 2m22s

This commit is contained in:
htlee 2026-03-23 15:30:50 +09:00
커밋 ed77005619
11개의 변경된 파일106개의 추가작업 그리고 111개의 파일을 삭제

파일 보기

@ -4,6 +4,17 @@
## [Unreleased] ## [Unreleased]
## [2026-03-23.6]
### 수정
- LIVE 모드 렌더링 최적화: useMonitor 1초 setInterval 제거 (60배 과잉 재계산 해소)
- useKoreaFilters currentTime 의존성 제거 (5분 polling 시에만 필터 재계산)
- useKoreaData aircraft/satellite LIVE↔REPLAY 분리 (LIVE에서 불필요한 매초 propagation 제거)
- 특정어업수역 실제 폴리곤 좌표 적용 (bbox 직사각형 → 원본 GeoJSON EPSG:3857→WGS84 변환)
- FishingZoneLayer zone 속성 매칭 수정 (id→zone, 폴리곤 투명 렌더링 해결)
- 선박/분석 라벨 폰트 크기 80% 축소 (가독성 개선)
- DB migration 008 적용 (is_transship_suspect 칼럼 추가 → AI 분석 API 500 에러 해결)
## [2026-03-23.5] ## [2026-03-23.5]
### 추가 ### 추가

파일 보기

@ -2,35 +2,34 @@ import { Source, Layer } from 'react-map-gl/maplibre';
import fishingZonesData from '../../data/zones/fishing-zones-wgs84.json'; import fishingZonesData from '../../data/zones/fishing-zones-wgs84.json';
const ZONE_FILL: Record<string, string> = { const ZONE_FILL: Record<string, string> = {
ZONE_I: 'rgba(59, 130, 246, 0.15)', I: 'rgba(59, 130, 246, 0.15)',
ZONE_II: 'rgba(16, 185, 129, 0.15)', II: 'rgba(16, 185, 129, 0.15)',
ZONE_III: 'rgba(245, 158, 11, 0.15)', III: 'rgba(245, 158, 11, 0.15)',
ZONE_IV: 'rgba(239, 68, 68, 0.15)', IV: 'rgba(239, 68, 68, 0.15)',
}; };
const ZONE_LINE: Record<string, string> = { const ZONE_LINE: Record<string, string> = {
ZONE_I: 'rgba(59, 130, 246, 0.6)', I: 'rgba(59, 130, 246, 0.6)',
ZONE_II: 'rgba(16, 185, 129, 0.6)', II: 'rgba(16, 185, 129, 0.6)',
ZONE_III: 'rgba(245, 158, 11, 0.6)', III: 'rgba(245, 158, 11, 0.6)',
ZONE_IV: 'rgba(239, 68, 68, 0.6)', IV: 'rgba(239, 68, 68, 0.6)',
}; };
const fillColor = [ const fillColor = [
'match', ['get', 'id'], 'match', ['get', 'zone'],
'ZONE_I', ZONE_FILL.ZONE_I, 'I', ZONE_FILL.I,
'ZONE_II', ZONE_FILL.ZONE_II, 'II', ZONE_FILL.II,
'ZONE_III', ZONE_FILL.ZONE_III, 'III', ZONE_FILL.III,
'ZONE_IV', ZONE_FILL.ZONE_IV, 'IV', ZONE_FILL.IV,
'rgba(0,0,0,0)', 'rgba(0,0,0,0)',
] as maplibregl.ExpressionSpecification; ] as maplibregl.ExpressionSpecification;
const lineColor = [ const lineColor = [
'match', ['get', 'id'], 'match', ['get', 'zone'],
'ZONE_I', ZONE_LINE.ZONE_I, 'I', ZONE_LINE.I,
'ZONE_II', ZONE_LINE.ZONE_II, 'II', ZONE_LINE.II,
'ZONE_III', ZONE_LINE.ZONE_III, 'III', ZONE_LINE.III,
'ZONE_IV', ZONE_LINE.ZONE_IV, 'IV', ZONE_LINE.IV,
'rgba(0,0,0,0)', 'rgba(0,0,0,0)',
] as maplibregl.ExpressionSpecification; ] as maplibregl.ExpressionSpecification;

파일 보기

@ -157,7 +157,6 @@ export const KoreaDashboard = ({
const koreaFiltersResult = useKoreaFilters( const koreaFiltersResult = useKoreaFilters(
koreaData.ships, koreaData.ships,
koreaData.visibleShips, koreaData.visibleShips,
currentTime,
vesselAnalysis.analysisMap, vesselAnalysis.analysisMap,
); );

파일 보기

@ -242,7 +242,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
data: illegalFishingData, data: illegalFishingData,
getPosition: (d) => [d.lng, d.lat], getPosition: (d) => [d.lng, d.lat],
getText: (d) => d.name || d.mmsi, getText: (d) => d.name || d.mmsi,
getSize: 14 * zoomScale, getSize: 11 * zoomScale,
getColor: [239, 68, 68, 255], getColor: [239, 68, 68, 255],
getTextAnchor: 'middle', getTextAnchor: 'middle',
getAlignmentBaseline: 'top', getAlignmentBaseline: 'top',
@ -256,17 +256,18 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
updateTriggers: { getSize: [zoomScale] }, updateTriggers: { getSize: [zoomScale] },
}), [illegalFishingData, zoomScale]); }), [illegalFishingData, zoomScale]);
// 수역 라벨 TextLayer — illegalFishing 필터 활성 시 표시 // 수역 라벨 TextLayer — illegalFishing 또는 cnFishing 필터 활성 시 표시
const zoneLabelsLayer = useMemo(() => { 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 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; let sLng = 0, sLat = 0, n = 0;
for (const poly of geom.coordinates) { const rings = geom.type === 'MultiPolygon'
for (const ring of poly) { ? geom.coordinates.flatMap(poly => poly)
for (const [lng, lat] of ring) { : geom.coordinates;
sLng += lng; sLat += lat; n++; for (const ring of rings) {
} for (const [lng, lat] of ring) {
sLng += lng; sLat += lat; n++;
} }
} }
return { return {
@ -293,7 +294,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
characterSet: 'auto', characterSet: 'auto',
updateTriggers: { getSize: [zoomScale] }, updateTriggers: { getSize: [zoomScale] },
}); });
}, [koreaFilters.illegalFishing, zoomScale]); }, [koreaFilters.illegalFishing, koreaFilters.cnFishing, zoomScale]);
// 정적 레이어 (deck.gl) — 항구, 공항, 군사기지, 어항경고 등 // 정적 레이어 (deck.gl) — 항구, 공항, 군사기지, 어항경고 등
const staticDeckLayers = useStaticDeckLayers({ const staticDeckLayers = useStaticDeckLayers({
@ -356,7 +357,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
data: gears, data: gears,
getPosition: (d: Ship) => [d.lng, d.lat], getPosition: (d: Ship) => [d.lng, d.lat],
getText: (d: Ship) => d.name || d.mmsi, getText: (d: Ship) => d.name || d.mmsi,
getSize: 13 * zoomScale, getSize: 10 * zoomScale,
getColor: [249, 115, 22, 255], getColor: [249, 115, 22, 255],
getTextAnchor: 'middle' as const, getTextAnchor: 'middle' as const,
getAlignmentBaseline: 'top' as const, getAlignmentBaseline: 'top' as const,
@ -391,7 +392,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
data: [parent], data: [parent],
getPosition: (d: Ship) => [d.lng, d.lat], getPosition: (d: Ship) => [d.lng, d.lat],
getText: (d: Ship) => `${d.name || groupName} (모선)`, getText: (d: Ship) => `${d.name || groupName} (모선)`,
getSize: 14 * zoomScale, getSize: 11 * zoomScale,
getColor: [249, 115, 22, 255], getColor: [249, 115, 22, 255],
getTextAnchor: 'middle' as const, getTextAnchor: 'middle' as const,
getAlignmentBaseline: 'top' as const, getAlignmentBaseline: 'top' as const,
@ -456,7 +457,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
const prefix = role === 'LEADER' ? '★ ' : ''; const prefix = role === 'LEADER' ? '★ ' : '';
return `${prefix}${d.name || d.mmsi}`; return `${prefix}${d.name || d.mmsi}`;
}, },
getSize: 13 * zoomScale, getSize: 10 * zoomScale,
getColor: color, getColor: color,
getTextAnchor: 'middle' as const, getTextAnchor: 'middle' as const,
getAlignmentBaseline: 'top' as const, getAlignmentBaseline: 'top' as const,

파일 보기

@ -485,7 +485,7 @@ export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusM
layout={{ layout={{
'visibility': highlightKorean ? 'visible' : 'none', 'visibility': highlightKorean ? 'visible' : 'none',
'text-field': ['get', 'name'], '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-offset': [0, 2.2],
'text-anchor': 'top', 'text-anchor': 'top',
'text-allow-overlap': false, '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; const name = d.ship.name || d.ship.mmsi;
return `${name}\n${label} ${d.dto.algorithms.riskScore.score}`; return `${name}\n${label} ${d.dto.algorithms.riskScore.score}`;
}, },
getSize: 13 * sizeScale, getSize: 10 * sizeScale,
updateTriggers: { getSize: [sizeScale] }, updateTriggers: { getSize: [sizeScale] },
getColor: (d) => RISK_RGBA_BORDER[d.dto.algorithms.riskScore.level] ?? [200, 200, 200, 255], getColor: (d) => RISK_RGBA_BORDER[d.dto.algorithms.riskScore.level] ?? [200, 200, 200, 255],
getTextAnchor: 'middle', getTextAnchor: 'middle',
@ -167,7 +167,7 @@ export function useAnalysisDeckLayers(
const gap = d.dto.algorithms.darkVessel.gapDurationMin; const gap = d.dto.algorithms.darkVessel.gapDurationMin;
return gap > 0 ? `AIS 소실 ${Math.round(gap)}` : 'DARK'; return gap > 0 ? `AIS 소실 ${Math.round(gap)}` : 'DARK';
}, },
getSize: 13 * sizeScale, getSize: 10 * sizeScale,
updateTriggers: { getSize: [sizeScale] }, updateTriggers: { getSize: [sizeScale] },
getColor: [168, 85, 247, 255], getColor: [168, 85, 247, 255],
getTextAnchor: 'middle', getTextAnchor: 'middle',
@ -191,7 +191,7 @@ export function useAnalysisDeckLayers(
data: spoofData, data: spoofData,
getPosition: (d) => [d.ship.lng, d.ship.lat], getPosition: (d) => [d.ship.lng, d.ship.lat],
getText: (d) => `GPS ${Math.round(d.dto.algorithms.gpsSpoofing.spoofingScore * 100)}%`, getText: (d) => `GPS ${Math.round(d.dto.algorithms.gpsSpoofing.spoofingScore * 100)}%`,
getSize: 13 * sizeScale, getSize: 10 * sizeScale,
getColor: [239, 68, 68, 255], getColor: [239, 68, 68, 255],
getTextAnchor: 'start', getTextAnchor: 'start',
getPixelOffset: [12, -8], getPixelOffset: [12, -8],

파일 보기

@ -133,17 +133,37 @@ export function useKoreaData({
}, [refreshKey]); }, [refreshKey]);
// Propagate Korea satellite positions // Propagate Korea satellite positions
// LIVE: satellitesKorea 변경(초기 로드) 시에만 계산. currentTime 불필요.
// REPLAY: currentTime으로 궤도 보간 필요.
useEffect(() => { useEffect(() => {
if (satellitesKorea.length === 0) return; if (satellitesKorea.length === 0) return;
const now = Date.now(); if (isLive) {
if (now - satTimeKoreaRef.current < 2000) return; // LIVE 모드: 위성 데이터 로드 시 1회 계산
satTimeKoreaRef.current = now; const positions = propagateAll(satellitesKorea, new Date());
const positions = propagateAll(satellitesKorea, new Date(currentTime)); setSatPositionsKorea(positions);
setSatPositionsKorea(positions); } else {
}, [satellitesKorea, currentTime]); // 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, isLive, isLive ? 0 : currentTime]); // eslint-disable-line react-hooks/exhaustive-deps
// Propagate Korea aircraft (live only — no waypoint propagation needed) // Propagate Korea aircraft
const aircraft = useMemo(() => propagateAircraft(baseAircraftKorea, currentTime), [baseAircraftKorea, currentTime]); // 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 무관. // LIVE: baseShipsKorea 변경(5분 polling) 시에만 재계산. currentTime 무관.
// REPLAY: currentTime으로 활성 선박 + 웨이포인트 보간 필요. // REPLAY: currentTime으로 활성 선박 + 웨이포인트 보간 필요.

파일 보기

@ -40,10 +40,11 @@ const DOKDO = { lat: 37.2417, lng: 131.8647 };
const TERRITORIAL_DEG = 0.2; // ~22km (12해리) const TERRITORIAL_DEG = 0.2; // ~22km (12해리)
const ALERT_DEG = 0.4; // ~44km const ALERT_DEG = 0.4; // ~44km
// currentTime 파라미터 제거 — 데이터(koreaShips) 변경 시에만 재계산.
// 타임스탬프가 필요한 곳은 Date.now() 인라인 사용.
export function useKoreaFilters( export function useKoreaFilters(
koreaShips: Ship[], koreaShips: Ship[],
visibleShips: Ship[], visibleShips: Ship[],
currentTime: number,
analysisMap?: Map<string, VesselAnalysisDto>, analysisMap?: Map<string, VesselAnalysisDto>,
): UseKoreaFiltersResult { ): UseKoreaFiltersResult {
const [filters, setFilters] = useLocalStorage<KoreaFilters>('koreaFilters', { const [filters, setFilters] = useLocalStorage<KoreaFilters>('koreaFilters', {
@ -87,10 +88,11 @@ export function useKoreaFilters(
}, [filters.illegalTransship, analysisMap]); }, [filters.illegalTransship, analysisMap]);
// 다크베셀 탐지: AIS 신호 이력 추적 // 다크베셀 탐지: AIS 신호 이력 추적
// currentTime 제거 → koreaShips(5분 polling) 변경 시에만 재계산
const darkVesselSet = useMemo(() => { const darkVesselSet = useMemo(() => {
if (!filters.darkVessel) return new Set<string>(); if (!filters.darkVessel) return new Set<string>();
const now = currentTime; const now = Date.now();
const history = aisHistoryRef.current; const history = aisHistoryRef.current;
const result = new Set<string>(); const result = new Set<string>();
const currentMmsis = new Set(koreaShips.map(s => s.mmsi)); const currentMmsis = new Set(koreaShips.map(s => s.mmsi));
@ -151,9 +153,10 @@ export function useKoreaFilters(
} }
return result; return result;
}, [koreaShips, filters.darkVessel, currentTime, analysisMap]); }, [koreaShips, filters.darkVessel, analysisMap]);
// 해저케이블 감시 // 해저케이블 감시
// currentTime 제거 → koreaShips(5분 polling) 변경 시에만 재계산
const cableWatchSet = useMemo(() => { const cableWatchSet = useMemo(() => {
if (!filters.cableWatch) return new Set<string>(); if (!filters.cableWatch) return new Set<string>();
const result = new Set<string>(); const result = new Set<string>();
@ -177,7 +180,7 @@ export function useKoreaFilters(
return Math.hypot(dlng, py - cy); return Math.hypot(dlng, py - cy);
}; };
const now = currentTime; const now = Date.now();
const prevMap = cableNearStartRef.current; const prevMap = cableNearStartRef.current;
const currentNear = new Set<string>(); const currentNear = new Set<string>();
@ -207,9 +210,10 @@ export function useKoreaFilters(
} }
return result; return result;
}, [koreaShips, filters.cableWatch, currentTime]); }, [koreaShips, filters.cableWatch]);
// 독도감시 // 독도감시
// currentTime 제거 → koreaShips(5분 polling) 변경 시에만 재계산
const dokdoWatchSet = useMemo(() => { const dokdoWatchSet = useMemo(() => {
if (!filters.dokdoWatch) return new Set<string>(); if (!filters.dokdoWatch) return new Set<string>();
const result = new Set<string>(); const result = new Set<string>();
@ -227,14 +231,14 @@ export function useKoreaFilters(
if (!alerted.has(s.mmsi)) { if (!alerted.has(s.mmsi)) {
alerted.add(s.mmsi); alerted.add(s.mmsi);
const distKm = Math.round(dDokdo * 111); 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) { } else if (dDokdo < ALERT_DEG) {
result.add(s.mmsi); result.add(s.mmsi);
if (!alerted.has(`warn-${s.mmsi}`)) { if (!alerted.has(`warn-${s.mmsi}`)) {
alerted.add(`warn-${s.mmsi}`); alerted.add(`warn-${s.mmsi}`);
const distKm = Math.round(dDokdo * 111); 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; return result;
}, [koreaShips, filters.dokdoWatch, currentTime]); }, [koreaShips, filters.dokdoWatch]);
// 중국어선 의심 선박 Set // 중국어선 의심 선박 Set
const cnFishingSuspects = useMemo(() => { const cnFishingSuspects = useMemo(() => {

파일 보기

@ -1,6 +1,4 @@
import { useState, useCallback, useRef, useEffect } from 'react'; import { useState, useCallback } from 'react';
const TICK_INTERVAL = 1000; // update every 1 second in live mode
export interface MonitorState { export interface MonitorState {
currentTime: number; // always Date.now() currentTime: number; // always Date.now()
@ -13,24 +11,20 @@ export function useMonitor() {
historyMinutes: 60, historyMinutes: 60,
}); });
const intervalRef = useRef<number | null>(null); // LIVE 모드에서 1초 tick 제거 — 데이터 polling 응답 시에만 갱신하면 충분.
// currentTime은 표시용으로 렌더 시 Date.now() 사용, 데이터 훅에는 전달하지 않음.
// Start ticking immediately
useEffect(() => {
intervalRef.current = window.setInterval(() => {
setState(prev => ({ ...prev, currentTime: Date.now() }));
}, TICK_INTERVAL);
return () => {
if (intervalRef.current !== null) clearInterval(intervalRef.current);
};
}, []);
const setHistoryMinutes = useCallback((minutes: number) => { const setHistoryMinutes = useCallback((minutes: number) => {
setState(prev => ({ ...prev, historyMinutes: minutes })); setState(prev => ({ ...prev, historyMinutes: minutes }));
}, []); }, []);
/** 데이터 갱신 시점에 호출하여 currentTime 동기화 */
const refreshTime = useCallback(() => {
setState(prev => ({ ...prev, currentTime: Date.now() }));
}, []);
const startTime = state.currentTime - state.historyMinutes * 60_000; const startTime = state.currentTime - state.historyMinutes * 60_000;
const endTime = state.currentTime; 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) * ~ ( WGS84 GeoJSON)
*/ */
export const ZONE_POLYGONS = fishingZonesWgs84.features.map(f => ({ export const ZONE_POLYGONS = fishingZonesWgs84.features.map(f => {
id: f.properties.id as FishingZoneId, const zoneId = `ZONE_${f.properties.zone}` as FishingZoneId;
name: f.properties.name, return {
allowed: ZONE_ALLOWED[f.properties.id] ?? [], id: zoneId,
geojson: f as unknown as Feature<MultiPolygon>, name: f.properties.name,
})); allowed: ZONE_ALLOWED[zoneId] ?? [],
geojson: f as unknown as Feature<MultiPolygon>,
};
});
/** /**
* *