fix: 불법어선 수역 내 한정 + AI 패널 항상 표시 + API 1시간 윈도우

- 불법어선 필터: classifyFishingZone으로 수역 내 비한국 어선만 판별
- 수역 내 어선에 빨간 강조 링+선박명 마커 표시
- AI 분석 패널: 데이터 유무 무관하게 항상 표시
- Backend: analyzed_at 기준 1시간 윈도우로 확대 (10분 → 1시간)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
htlee 2026-03-20 14:16:42 +09:00
부모 af02ad12ff
커밋 9507b0da26
4개의 변경된 파일44개의 추가작업 그리고 15개의 파일을 삭제

파일 보기

@ -7,5 +7,8 @@ import java.util.List;
public interface VesselAnalysisResultRepository extends JpaRepository<VesselAnalysisResult, Long> {
List<VesselAnalysisResult> findByTimestampAfter(Instant since);
List<VesselAnalysisResult> findByAnalyzedAtAfter(Instant since);
/** 가장 최근 analyzed_at 이후 결과 전체 (최신 분석 사이클) */
List<VesselAnalysisResult> findByAnalyzedAtGreaterThanEqual(Instant since);
}

파일 보기

@ -14,13 +14,11 @@ import java.util.List;
@RequiredArgsConstructor
public class VesselAnalysisService {
private static final int RECENT_MINUTES = 10;
private final VesselAnalysisResultRepository repository;
private final CacheManager cacheManager;
/**
* 최근 10분 분석 결과를 반환한다. Caffeine 캐시(TTL 5분) 적용.
* 최근 1시간 분석 결과를 반환한다. Caffeine 캐시(TTL 5분) 적용.
*/
@SuppressWarnings("unchecked")
public List<VesselAnalysisDto> getLatestResults() {
@ -32,8 +30,9 @@ public class VesselAnalysisService {
}
}
Instant since = Instant.now().minus(RECENT_MINUTES, ChronoUnit.MINUTES);
List<VesselAnalysisDto> results = repository.findByTimestampAfter(since)
// 최근 1시간 이내 분석 결과 (Python 5분 주기 최대 12사이클 포함)
Instant since = Instant.now().minus(1, ChronoUnit.HOURS);
List<VesselAnalysisDto> results = repository.findByAnalyzedAtAfter(since)
.stream()
.map(VesselAnalysisDto::from)
.toList();

파일 보기

@ -24,6 +24,8 @@ import { ChineseFishingOverlay } from './ChineseFishingOverlay';
import { AnalysisOverlay } from './AnalysisOverlay';
import { FishingZoneLayer } from './FishingZoneLayer';
import { AnalysisStatsPanel } from './AnalysisStatsPanel';
import { getMarineTrafficCategory } from '../../utils/marineTraffic';
import { classifyFishingZone } from '../../utils/fishingAnalysis';
import { fetchKoreaInfra } from '../../services/infra';
import type { PowerFacility } from '../../services/infra';
import type { Ship, Aircraft, SatellitePosition } from '../../types';
@ -206,6 +208,30 @@ export function KoreaMap({ ships, aircraft, satellites, layers, osintFeed, curre
</Source>
{layers.ships && <ShipLayer ships={ships} militaryOnly={layers.militaryOnly} />}
{/* Illegal fishing vessel markers */}
{koreaFilters.illegalFishing && ships.filter(s => {
const mtCat = getMarineTrafficCategory(s.typecode, s.category);
if (mtCat !== 'fishing' || s.flag === 'KR') return false;
return classifyFishingZone(s.lat, s.lng).zone !== 'OUTSIDE';
}).slice(0, 200).map(s => (
<Marker key={`illegal-${s.mmsi}`} longitude={s.lng} latitude={s.lat} anchor="center">
<div style={{
width: 20, height: 20, borderRadius: '50%',
border: '2px solid #ef4444',
backgroundColor: 'rgba(239, 68, 68, 0.2)',
animation: 'pulse 2s infinite',
pointerEvents: 'none',
}} />
<div style={{
fontSize: 8, fontWeight: 700, color: '#ef4444',
textShadow: '0 0 2px #000, 0 0 2px #000',
textAlign: 'center', marginTop: -2,
whiteSpace: 'nowrap', pointerEvents: 'none',
}}>
{s.name || s.mmsi}
</div>
</Marker>
))}
{/* Transship suspect labels — Marker DOM, inline styles kept for dynamic border color */}
{transshipSuspects.size > 0 && ships.filter(s => transshipSuspects.has(s.mmsi)).map(s => (
<Marker key={`ts-${s.mmsi}`} longitude={s.lng} latitude={s.lat} anchor="bottom">
@ -350,8 +376,8 @@ export function KoreaMap({ ships, aircraft, satellites, layers, osintFeed, curre
</div>
)}
{/* AI Analysis Stats Panel */}
{vesselAnalysis && vesselAnalysis.stats.total > 0 && (
{/* AI Analysis Stats Panel — 항상 표시 */}
{vesselAnalysis && (
<AnalysisStatsPanel
stats={vesselAnalysis.stats}
lastUpdated={vesselAnalysis.lastUpdated}

파일 보기

@ -1,6 +1,7 @@
import { useState, useMemo, useRef } from 'react';
import { KOREA_SUBMARINE_CABLES } from '../services/submarineCable';
import { getMarineTrafficCategory } from '../utils/marineTraffic';
import { classifyFishingZone } from '../utils/fishingAnalysis';
import type { Ship, VesselAnalysisDto } from '../types';
interface KoreaFilters {
@ -308,17 +309,17 @@ export function useKoreaFilters(
return visibleShips.filter(s => {
const mtCat = getMarineTrafficCategory(s.typecode, s.category);
if (filters.illegalFishing) {
// 특정어업수역 ~Ⅳ 내 비한국 어선만 불법어선으로 판별
if (mtCat === 'fishing' && s.flag !== 'KR') {
const zoneInfo = classifyFishingZone(s.lat, s.lng);
if (zoneInfo.zone !== 'OUTSIDE') return true;
}
// Python 분석: 영해/접속수역 침범
const analysis = analysisMap?.get(s.mmsi);
if (analysis) {
// Python 분석: 영해/접속수역 침범 또는 위험도 HIGH+ 어선
const zone = analysis.algorithms.location.zone;
const riskLevel = analysis.algorithms.riskScore.level;
const isThreat = zone === 'TERRITORIAL_SEA' || zone === 'CONTIGUOUS_ZONE'
|| riskLevel === 'CRITICAL' || riskLevel === 'HIGH';
if (isThreat) return true;
if (zone === 'TERRITORIAL_SEA' || zone === 'CONTIGUOUS_ZONE') return true;
}
// 비한국 어선 (기본 필터)
if (mtCat === 'fishing' && s.flag !== 'KR') return true;
}
if (filters.illegalTransship && transshipSuspects.has(s.mmsi)) return true;
if (filters.darkVessel && darkVesselSet.has(s.mmsi)) return true;