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:
부모
af02ad12ff
커밋
9507b0da26
@ -7,5 +7,8 @@ import java.util.List;
|
|||||||
|
|
||||||
public interface VesselAnalysisResultRepository extends JpaRepository<VesselAnalysisResult, Long> {
|
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
|
@RequiredArgsConstructor
|
||||||
public class VesselAnalysisService {
|
public class VesselAnalysisService {
|
||||||
|
|
||||||
private static final int RECENT_MINUTES = 10;
|
|
||||||
|
|
||||||
private final VesselAnalysisResultRepository repository;
|
private final VesselAnalysisResultRepository repository;
|
||||||
private final CacheManager cacheManager;
|
private final CacheManager cacheManager;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 최근 10분 내 분석 결과를 반환한다. Caffeine 캐시(TTL 5분) 적용.
|
* 최근 1시간 내 분석 결과를 반환한다. Caffeine 캐시(TTL 5분) 적용.
|
||||||
*/
|
*/
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
public List<VesselAnalysisDto> getLatestResults() {
|
public List<VesselAnalysisDto> getLatestResults() {
|
||||||
@ -32,8 +30,9 @@ public class VesselAnalysisService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Instant since = Instant.now().minus(RECENT_MINUTES, ChronoUnit.MINUTES);
|
// 최근 1시간 이내 분석 결과 (Python 5분 주기 → 최대 12사이클 포함)
|
||||||
List<VesselAnalysisDto> results = repository.findByTimestampAfter(since)
|
Instant since = Instant.now().minus(1, ChronoUnit.HOURS);
|
||||||
|
List<VesselAnalysisDto> results = repository.findByAnalyzedAtAfter(since)
|
||||||
.stream()
|
.stream()
|
||||||
.map(VesselAnalysisDto::from)
|
.map(VesselAnalysisDto::from)
|
||||||
.toList();
|
.toList();
|
||||||
|
|||||||
@ -24,6 +24,8 @@ import { ChineseFishingOverlay } from './ChineseFishingOverlay';
|
|||||||
import { AnalysisOverlay } from './AnalysisOverlay';
|
import { AnalysisOverlay } from './AnalysisOverlay';
|
||||||
import { FishingZoneLayer } from './FishingZoneLayer';
|
import { FishingZoneLayer } from './FishingZoneLayer';
|
||||||
import { AnalysisStatsPanel } from './AnalysisStatsPanel';
|
import { AnalysisStatsPanel } from './AnalysisStatsPanel';
|
||||||
|
import { getMarineTrafficCategory } from '../../utils/marineTraffic';
|
||||||
|
import { classifyFishingZone } from '../../utils/fishingAnalysis';
|
||||||
import { fetchKoreaInfra } from '../../services/infra';
|
import { fetchKoreaInfra } from '../../services/infra';
|
||||||
import type { PowerFacility } from '../../services/infra';
|
import type { PowerFacility } from '../../services/infra';
|
||||||
import type { Ship, Aircraft, SatellitePosition } from '../../types';
|
import type { Ship, Aircraft, SatellitePosition } from '../../types';
|
||||||
@ -206,6 +208,30 @@ export function KoreaMap({ ships, aircraft, satellites, layers, osintFeed, curre
|
|||||||
</Source>
|
</Source>
|
||||||
|
|
||||||
{layers.ships && <ShipLayer ships={ships} militaryOnly={layers.militaryOnly} />}
|
{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 */}
|
{/* Transship suspect labels — Marker DOM, inline styles kept for dynamic border color */}
|
||||||
{transshipSuspects.size > 0 && ships.filter(s => transshipSuspects.has(s.mmsi)).map(s => (
|
{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">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* AI Analysis Stats Panel */}
|
{/* AI Analysis Stats Panel — 항상 표시 */}
|
||||||
{vesselAnalysis && vesselAnalysis.stats.total > 0 && (
|
{vesselAnalysis && (
|
||||||
<AnalysisStatsPanel
|
<AnalysisStatsPanel
|
||||||
stats={vesselAnalysis.stats}
|
stats={vesselAnalysis.stats}
|
||||||
lastUpdated={vesselAnalysis.lastUpdated}
|
lastUpdated={vesselAnalysis.lastUpdated}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { useState, useMemo, useRef } from 'react';
|
import { useState, useMemo, useRef } from 'react';
|
||||||
import { KOREA_SUBMARINE_CABLES } from '../services/submarineCable';
|
import { KOREA_SUBMARINE_CABLES } from '../services/submarineCable';
|
||||||
import { getMarineTrafficCategory } from '../utils/marineTraffic';
|
import { getMarineTrafficCategory } from '../utils/marineTraffic';
|
||||||
|
import { classifyFishingZone } from '../utils/fishingAnalysis';
|
||||||
import type { Ship, VesselAnalysisDto } from '../types';
|
import type { Ship, VesselAnalysisDto } from '../types';
|
||||||
|
|
||||||
interface KoreaFilters {
|
interface KoreaFilters {
|
||||||
@ -308,17 +309,17 @@ export function useKoreaFilters(
|
|||||||
return visibleShips.filter(s => {
|
return visibleShips.filter(s => {
|
||||||
const mtCat = getMarineTrafficCategory(s.typecode, s.category);
|
const mtCat = getMarineTrafficCategory(s.typecode, s.category);
|
||||||
if (filters.illegalFishing) {
|
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);
|
const analysis = analysisMap?.get(s.mmsi);
|
||||||
if (analysis) {
|
if (analysis) {
|
||||||
// Python 분석: 영해/접속수역 침범 또는 위험도 HIGH+ 어선
|
|
||||||
const zone = analysis.algorithms.location.zone;
|
const zone = analysis.algorithms.location.zone;
|
||||||
const riskLevel = analysis.algorithms.riskScore.level;
|
if (zone === 'TERRITORIAL_SEA' || zone === 'CONTIGUOUS_ZONE') return true;
|
||||||
const isThreat = zone === 'TERRITORIAL_SEA' || zone === 'CONTIGUOUS_ZONE'
|
|
||||||
|| riskLevel === 'CRITICAL' || riskLevel === 'HIGH';
|
|
||||||
if (isThreat) return true;
|
|
||||||
}
|
}
|
||||||
// 비한국 어선 (기본 필터)
|
|
||||||
if (mtCat === 'fishing' && s.flag !== 'KR') return true;
|
|
||||||
}
|
}
|
||||||
if (filters.illegalTransship && transshipSuspects.has(s.mmsi)) return true;
|
if (filters.illegalTransship && transshipSuspects.has(s.mmsi)) return true;
|
||||||
if (filters.darkVessel && darkVesselSet.has(s.mmsi)) return true;
|
if (filters.darkVessel && darkVesselSet.has(s.mmsi)) return true;
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user