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]
## [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]
### 추가

파일 보기

@ -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>,
}));
};
});
/**
*