Merge pull request 'release: 2026-03-31 (39건 커밋)' (#210) from develop into main
All checks were successful
Deploy KCG / deploy (push) Successful in 1m39s

This commit is contained in:
htlee 2026-03-31 10:12:09 +09:00
커밋 93ce2092d2
37개의 변경된 파일6478개의 추가작업 그리고 1267개의 파일을 삭제

파일 보기

@ -148,75 +148,10 @@ jobs:
sleep 10
done
# ═══ Prediction (FastAPI → redis-211) ═══
- name: Deploy prediction via SSH
env:
DEPLOY_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
PRED_HOST: 192.168.1.18
PRED_PORT: 32023
run: |
mkdir -p ~/.ssh
printf '%s\n' "$DEPLOY_KEY" > ~/.ssh/id_deploy
chmod 600 ~/.ssh/id_deploy
SSH_OPTS="-i ~/.ssh/id_deploy -o StrictHostKeyChecking=no -o ConnectTimeout=10 -o ServerAliveInterval=15 -p $PRED_PORT"
SCP_OPTS="-i ~/.ssh/id_deploy -o StrictHostKeyChecking=no -P $PRED_PORT"
REMOTE_DIR=/home/apps/kcg-prediction
# 코드 전송 (rsync 대체: tar + scp)
tar czf /tmp/prediction.tar.gz -C prediction --exclude='__pycache__' --exclude='venv' --exclude='.env' .
for attempt in 1 2 3; do
echo "SCP prediction attempt $attempt/3..."
if scp $SCP_OPTS /tmp/prediction.tar.gz root@$PRED_HOST:/tmp/prediction.tar.gz; then break; fi
[ "$attempt" -eq 3 ] && { echo "ERROR: SCP failed"; exit 1; }
sleep 10
done
# systemd 서비스 파일 전송
scp $SCP_OPTS deploy/kcg-prediction.service root@$PRED_HOST:/tmp/kcg-prediction.service
# 원격 설치 + 재시작 (단일 SSH — tar.gz는 SCP에서 이미 전송됨)
ssh $SSH_OPTS root@$PRED_HOST bash -s << 'SCRIPT'
set -e
REMOTE_DIR=/home/apps/kcg-prediction
mkdir -p $REMOTE_DIR
cd $REMOTE_DIR
# 코드 배포
tar xzf /tmp/prediction.tar.gz -C $REMOTE_DIR
# venv + 의존성
python3 -m venv venv 2>/dev/null || true
venv/bin/pip install -r requirements.txt -q
# SELinux 컨텍스트 (Rocky Linux)
chcon -R -t bin_t venv/bin/ 2>/dev/null || true
# systemd 서비스 갱신
if ! diff -q /tmp/kcg-prediction.service /etc/systemd/system/kcg-prediction.service >/dev/null 2>&1; then
cp /tmp/kcg-prediction.service /etc/systemd/system/kcg-prediction.service
systemctl daemon-reload
systemctl enable kcg-prediction
fi
# 재시작
systemctl restart kcg-prediction
# health 확인 (60초 — 초기 로드에 ~30초 소요)
for i in $(seq 1 12); do
if curl -sf http://localhost:8001/health > /dev/null 2>&1; then
echo "Prediction healthy (attempt ${i})"
rm -f /tmp/prediction.tar.gz /tmp/kcg-prediction.service
exit 0
fi
sleep 5
done
echo "WARNING: Prediction health timeout (서비스는 시작됨, 초기 로드 진행 중)"
systemctl is-active kcg-prediction && echo "Service is active"
rm -f /tmp/prediction.tar.gz /tmp/kcg-prediction.service
SCRIPT
echo "Prediction deployment completed"
# ═══ Prediction (FastAPI) — CI/CD 제외, 수동 배포 ═══
# ssh redis-211 에서 수동 배포:
# scp prediction/*.py redis-211:/home/apps/kcg-prediction/
# ssh redis-211 "sudo systemctl restart kcg-prediction"
- name: Cleanup
if: always()

파일 보기

@ -202,6 +202,35 @@ deploy/ # systemd + nginx 배포 설정
| **DB** | kcgdb | 211.208.115.83:5432/kcgdb (유저: kcg_app, pw: Kcg2026monitor) |
| **DB** | snpdb | 211.208.115.83:5432/snpdb (유저: snp, pw: snp#8932, 읽기 전용) |
## 디버그 도구 가이드
### 원칙
- 디버그/개발 전용 기능은 `import.meta.env.DEV` 가드로 감싸서 **프로덕션 빌드에서 코드 자체가 제거**되도록 구현
- Vite production 빌드 시 `import.meta.env.DEV = false` → dead code elimination → 번들 미포함
- 무거운 DB 조회, 통계 계산 등도 DEV 가드 안이면 프로덕션에 영향 없음
### 파일 구조
- 디버그 컴포넌트: `frontend/src/components/{도메인}/debug/` 디렉토리에 분리
- 메인 컴포넌트에서는 import + DEV 가드로만 연결:
```tsx
import { DebugTool } from './debug/DebugTool';
const debug = import.meta.env.DEV ? useDebugHook() : null;
// JSX:
{debug && <DebugTool ... />}
```
### 기존 디버그 도구
| 도구 | 위치 | 기능 |
|------|------|------|
| CoordDebugTool | `korea/debug/CoordDebugTool.tsx` | Ctrl+Click 좌표 표시 (DD/DMS, 다중 포인트) |
### 디버그 도구 분류 기준
다음에 해당하면 디버그 도구로 분류하고, 불확실하면 사용자에게 확인:
- 개발/검증 목적의 좌표/데이터 표시 도구
- 프로덕션 사용자에게 불필요한 진단 정보
- 임시 데이터 시각화, 성능 프로파일링
- 특정 조건에서만 활성화되는 테스트 기능
## 팀 규칙
- 코드 스타일: `.claude/rules/code-style.md`

파일 보기

@ -48,4 +48,19 @@ public class GroupPolygonController {
List<GroupPolygonDto> history = groupPolygonService.getGroupHistory(groupKey, hours);
return ResponseEntity.ok(history);
}
/**
* 특정 어구 그룹의 연관성 점수 (멀티모델)
*/
@GetMapping("/{groupKey}/correlations")
public ResponseEntity<Map<String, Object>> getGroupCorrelations(
@PathVariable String groupKey,
@RequestParam(defaultValue = "0.3") double minScore) {
List<Map<String, Object>> correlations = groupPolygonService.getGroupCorrelations(groupKey, minScore);
return ResponseEntity.ok(Map.of(
"groupKey", groupKey,
"count", correlations.size(),
"items", correlations
));
}
}

파일 보기

@ -58,6 +58,24 @@ public class GroupPolygonService {
ORDER BY snapshot_time DESC
""";
private static final String GROUP_CORRELATIONS_SQL = """
SELECT s.target_mmsi, s.target_type, s.target_name,
s.current_score, s.streak_count, s.observation_count,
s.freeze_state, s.shadow_bonus_total,
r.proximity_ratio, r.visit_score, r.heading_coherence,
m.id AS model_id, m.name AS model_name, m.is_default
FROM kcg.gear_correlation_scores s
JOIN kcg.correlation_param_models m ON s.model_id = m.id
LEFT JOIN LATERAL (
SELECT proximity_ratio, visit_score, heading_coherence
FROM kcg.gear_correlation_raw_metrics
WHERE group_key = s.group_key AND target_mmsi = s.target_mmsi
ORDER BY observed_at DESC LIMIT 1
) r ON TRUE
WHERE s.group_key = ? AND s.current_score >= ? AND m.is_active = TRUE
ORDER BY m.is_default DESC, s.current_score DESC
""";
private static final String GEAR_STATS_SQL = """
SELECT COUNT(*) AS gear_groups,
COALESCE(SUM(member_count), 0) AS gear_count
@ -81,6 +99,35 @@ public class GroupPolygonService {
}
}
/**
* 특정 어구 그룹의 연관성 점수 (멀티모델).
*/
public List<Map<String, Object>> getGroupCorrelations(String groupKey, double minScore) {
try {
return jdbcTemplate.query(GROUP_CORRELATIONS_SQL, (rs, rowNum) -> {
Map<String, Object> row = new java.util.LinkedHashMap<>();
row.put("targetMmsi", rs.getString("target_mmsi"));
row.put("targetType", rs.getString("target_type"));
row.put("targetName", rs.getString("target_name"));
row.put("score", rs.getDouble("current_score"));
row.put("streak", rs.getInt("streak_count"));
row.put("observations", rs.getInt("observation_count"));
row.put("freezeState", rs.getString("freeze_state"));
row.put("shadowBonus", rs.getDouble("shadow_bonus_total"));
row.put("proximityRatio", rs.getObject("proximity_ratio"));
row.put("visitScore", rs.getObject("visit_score"));
row.put("headingCoherence", rs.getObject("heading_coherence"));
row.put("modelId", rs.getInt("model_id"));
row.put("modelName", rs.getString("model_name"));
row.put("isDefault", rs.getBoolean("is_default"));
return row;
}, groupKey, minScore);
} catch (Exception e) {
log.warn("getGroupCorrelations failed for {}: {}", groupKey, e.getMessage());
return List.of();
}
}
/**
* 최신 스냅샷의 전체 그룹 폴리곤 목록 (5분 캐시).
*/

파일 보기

@ -0,0 +1,146 @@
-- 010: 어구 연관성 추적 시스템
-- - correlation_param_models: 파라미터 모델 마스터
-- - gear_correlation_raw_metrics: raw 메트릭 (타임스탬프 파티셔닝, 7일 보존)
-- - gear_correlation_scores: 모델별 어피니티 점수 (상태 테이블)
-- - system_config: 런타임 설정 (파티션 보관기간 등)
SET search_path TO kcg, public;
-- ── 파라미터 모델 ──
CREATE TABLE IF NOT EXISTS kcg.correlation_param_models (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE,
is_default BOOLEAN DEFAULT FALSE,
is_active BOOLEAN DEFAULT TRUE,
params JSONB NOT NULL,
description TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- default 모델 삽입
INSERT INTO kcg.correlation_param_models (name, is_default, is_active, params, description)
VALUES ('default', TRUE, TRUE,
'{"alpha_base":0.30,"alpha_min":0.08,"alpha_decay_per_streak":0.005,"track_threshold":0.50,"polygon_threshold":0.70,"w_proximity":0.45,"w_visit":0.35,"w_activity":0.20,"w_dtw":0.30,"w_sog_corr":0.20,"w_heading":0.25,"w_prox_vv":0.25,"w_prox_persist":0.50,"w_drift":0.30,"w_signal_sync":0.20,"group_quiet_ratio":0.30,"normal_gap_hours":1.0,"decay_slow":0.015,"decay_fast":0.08,"stale_hours":6.0,"shadow_stay_bonus":0.10,"shadow_return_bonus":0.15,"candidate_radius_factor":3.0,"proximity_threshold_nm":5.0,"visit_threshold_nm":5.0,"night_bonus":1.3,"long_decay_days":7.0}',
'기본 추적 모델')
ON CONFLICT (name) DO NOTHING;
-- ── Raw 메트릭 (모델 독립, 5분마다 기록, 타임스탬프 파티셔닝) ──
CREATE TABLE IF NOT EXISTS kcg.gear_correlation_raw_metrics (
id BIGSERIAL,
observed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
group_key VARCHAR(100) NOT NULL,
target_mmsi VARCHAR(20) NOT NULL,
target_type VARCHAR(10) NOT NULL,
target_name VARCHAR(200),
-- Raw 메트릭 (모든 모델이 공유)
proximity_ratio DOUBLE PRECISION,
visit_score DOUBLE PRECISION,
activity_sync DOUBLE PRECISION,
dtw_similarity DOUBLE PRECISION,
speed_correlation DOUBLE PRECISION,
heading_coherence DOUBLE PRECISION,
drift_similarity DOUBLE PRECISION,
-- Shadow
shadow_stay BOOLEAN DEFAULT FALSE,
shadow_return BOOLEAN DEFAULT FALSE,
-- 상태
gear_group_active_ratio DOUBLE PRECISION,
PRIMARY KEY (id, observed_at)
) PARTITION BY RANGE (observed_at);
-- 일별 파티션 생성 함수
CREATE OR REPLACE FUNCTION kcg.create_raw_metric_partitions(days_ahead INT DEFAULT 3)
RETURNS void AS $$
DECLARE
d DATE;
partition_name TEXT;
BEGIN
FOR i IN 0..days_ahead LOOP
d := CURRENT_DATE + i;
partition_name := 'gear_correlation_raw_metrics_' || TO_CHAR(d, 'YYYYMMDD');
IF NOT EXISTS (
SELECT 1 FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relname = partition_name AND n.nspname = 'kcg'
) THEN
EXECUTE format(
'CREATE TABLE IF NOT EXISTS kcg.%I PARTITION OF kcg.gear_correlation_raw_metrics
FOR VALUES FROM (%L) TO (%L)',
partition_name, d, d + 1
);
END IF;
END LOOP;
END;
$$ LANGUAGE plpgsql;
-- 초기 파티션 생성 (오늘 + 3일)
SELECT kcg.create_raw_metric_partitions(3);
-- raw_metrics 인덱스
CREATE INDEX IF NOT EXISTS idx_raw_metrics_group_time
ON kcg.gear_correlation_raw_metrics (group_key, observed_at DESC);
CREATE INDEX IF NOT EXISTS idx_raw_metrics_target
ON kcg.gear_correlation_raw_metrics (target_mmsi, observed_at DESC);
-- ── 어피니티 점수 (모델별 독립, 상태 테이블) ──
CREATE TABLE IF NOT EXISTS kcg.gear_correlation_scores (
id BIGSERIAL PRIMARY KEY,
model_id INT NOT NULL REFERENCES kcg.correlation_param_models(id) ON DELETE CASCADE,
group_key VARCHAR(100) NOT NULL,
target_mmsi VARCHAR(20) NOT NULL,
target_type VARCHAR(10) NOT NULL,
target_name VARCHAR(200),
-- 모델별 점수 (EMA 결과)
current_score DOUBLE PRECISION DEFAULT 0,
streak_count INT DEFAULT 0,
observation_count INT DEFAULT 0,
-- Shadow 축적
shadow_bonus_total DOUBLE PRECISION DEFAULT 0,
shadow_stay_count INT DEFAULT 0,
shadow_return_count INT DEFAULT 0,
-- 상태
freeze_state VARCHAR(20) DEFAULT 'ACTIVE',
-- 시간
first_observed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_observed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (model_id, group_key, target_mmsi)
);
CREATE INDEX IF NOT EXISTS idx_gc_model_group
ON kcg.gear_correlation_scores (model_id, group_key, current_score DESC);
CREATE INDEX IF NOT EXISTS idx_gc_active
ON kcg.gear_correlation_scores (current_score DESC)
WHERE current_score >= 0.5;
-- ── 시스템 설정 (런타임 변경 가능, 재시작 불필요) ──
CREATE TABLE IF NOT EXISTS kcg.system_config (
key VARCHAR(100) PRIMARY KEY,
value JSONB NOT NULL,
description TEXT,
updated_at TIMESTAMPTZ DEFAULT NOW(),
updated_by VARCHAR(100) DEFAULT 'system'
);
INSERT INTO kcg.system_config (key, value, description) VALUES
('partition.raw_metrics.retention_days', '7',
'raw_metrics 파티션 보관 기간 (일). 초과 시 파티션 DROP.'),
('partition.raw_metrics.create_ahead_days', '3',
'미래 파티션 미리 생성 일수.'),
('partition.scores.cleanup_days', '30',
'미관측 점수 레코드 정리 기간 (일).'),
('correlation.max_active_models', '5',
'동시 활성 모델 최대 수.')
ON CONFLICT (key) DO NOTHING;

파일 보기

@ -4,6 +4,34 @@
## [Unreleased]
## [2026-03-31]
### 추가
- 어구 연관성 프론트엔드 표시 — Backend API + 모델별 팝업/토글 UI
- 어구 그룹 멀티모델 폴리곤 오버레이 + 토글 패널
- 어구 리플레이 deck.gl + Zustand 전환 (TripsLayer GPU 트레일 + rAF 10fps)
- 리플레이 IconLayer (SVG ship-triangle/gear-diamond, COG 회전)
- 재생 컨트롤 확장: 항적/이름 토글, 일치율 드롭다운(50~90%), 개별 on/off
- 트랙 API 전체 모델 확장 — 모델별 점수 + 24h 트랙 반환
- 모델별 폴리곤 중심 경로 + 현재 중심점 렌더링 (모델명 라벨)
### 변경
- FleetClusterLayer 2357줄 → 10파일 리팩토링 (오케스트레이터 + 서브컴포넌트)
- 리플레이 렌더링: MapLibre GeoJSON → deck.gl (React re-render 20회/초 → 0회)
- 연관 선박 위치: 트랙 보간 우선, live 선박 fallback
- 토글 패널 위치 고정 + 모델 카드 가로 스크롤
- enabledVessels 토글 시 폴리곤 + 중심경로 동시 재계산
### 수정
- 이름 기반 레이어 항상 ON 고정 + 최상위 z-index (다른 모델에 가려지지 않음)
- Prediction API DB 접속 context manager 누락
- Prediction proxy rewrite 경로 불일치 (/api/prediction → /api)
- nginx prediction API 라우팅 추가
### 기타
- CI/CD: Prediction 자동 배포 제거 → 수동 배포 전환
- zustand 5.0.12, @deck.gl/geo-layers 9.2.11 의존성 추가
## [2026-03-26]
### 추가

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다. Load Diff

파일 보기

@ -11,6 +11,7 @@
},
"dependencies": {
"@deck.gl/core": "^9.2.11",
"@deck.gl/geo-layers": "^9.2.11",
"@deck.gl/layers": "^9.2.11",
"@deck.gl/mapbox": "^9.2.11",
"@fontsource-variable/fira-code": "^5.2.7",
@ -32,7 +33,8 @@
"react-map-gl": "^8.1.0",
"recharts": "^3.8.0",
"satellite.js": "^6.0.2",
"tailwindcss": "^4.2.1"
"tailwindcss": "^4.2.1",
"zustand": "^5.0.12"
},
"devDependencies": {
"@eslint/js": "^9.39.1",

파일 보기

@ -0,0 +1,350 @@
import { useState } from 'react';
import type { GearCorrelationItem } from '../../services/vesselAnalysis';
import type { UseGroupPolygonsResult } from '../../hooks/useGroupPolygons';
import { FONT_MONO } from '../../styles/fonts';
import { MODEL_ORDER, MODEL_COLORS, MODEL_DESC } from './fleetClusterConstants';
import { useGearReplayStore } from '../../stores/gearReplayStore';
interface CorrelationPanelProps {
selectedGearGroup: string;
memberCount: number;
groupPolygons: UseGroupPolygonsResult | undefined;
correlationByModel: Map<string, GearCorrelationItem[]>;
availableModels: { name: string; count: number; isDefault: boolean }[];
enabledModels: Set<string>;
enabledVessels: Set<string>;
correlationLoading: boolean;
hoveredTarget: { mmsi: string; model: string } | null;
onEnabledModelsChange: (updater: (prev: Set<string>) => Set<string>) => void;
onEnabledVesselsChange: (updater: (prev: Set<string>) => Set<string>) => void;
onHoveredTargetChange: (target: { mmsi: string; model: string } | null) => void;
}
// Ensure MODEL_ORDER is treated as string array for Record lookups
const _MODEL_ORDER: string[] = MODEL_ORDER as unknown as string[];
const CorrelationPanel = ({
selectedGearGroup,
memberCount,
groupPolygons,
correlationByModel,
availableModels,
enabledModels,
enabledVessels,
correlationLoading,
hoveredTarget,
onEnabledModelsChange,
onEnabledVesselsChange,
onHoveredTargetChange,
}: CorrelationPanelProps) => {
const historyActive = useGearReplayStore(s => s.historyFrames.length > 0);
// Local tooltip state
const [hoveredModelTip, setHoveredModelTip] = useState<string | null>(null);
const [pinnedModelTip, setPinnedModelTip] = useState<string | null>(null);
const activeModelTip = pinnedModelTip ?? hoveredModelTip;
// Compute identity data from groupPolygons
const allGroups = groupPolygons
? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]
: [];
const group = allGroups.find(g => g.groupKey === selectedGearGroup);
const identityVessels = group?.members.filter(m => m.isParent) ?? [];
const identityGear = group?.members.filter(m => !m.isParent) ?? [];
// Suppress unused MODEL_ORDER warning — used for ordering checks
void _MODEL_ORDER;
// Common card styles
const cardStyle: React.CSSProperties = {
background: 'rgba(12,24,37,0.95)',
borderRadius: 6,
minWidth: 160,
maxWidth: 200,
flexShrink: 0,
border: '1px solid rgba(255,255,255,0.08)',
position: 'relative',
};
const cardScrollStyle: React.CSSProperties = {
padding: '6px 8px',
maxHeight: 200,
overflowY: 'auto',
};
// Model title tooltip hover/click handlers
const handleTipHover = (model: string) => {
if (!pinnedModelTip) setHoveredModelTip(model);
};
const handleTipLeave = () => {
if (!pinnedModelTip) setHoveredModelTip(null);
};
const handleTipClick = (model: string) => {
setPinnedModelTip(prev => prev === model ? null : model);
setHoveredModelTip(null);
};
const renderModelTip = (model: string, color: string) => {
if (activeModelTip !== model) return null;
const desc = MODEL_DESC[model];
if (!desc) return null;
return (
<div style={{
position: 'absolute',
bottom: '100%',
left: 0,
marginBottom: 4,
padding: '6px 10px',
background: 'rgba(15,23,42,0.97)',
border: `1px solid ${color}66`,
borderRadius: 5,
fontSize: 9,
color: '#e2e8f0',
zIndex: 30,
whiteSpace: 'nowrap',
boxShadow: '0 2px 12px rgba(0,0,0,0.6)',
pointerEvents: pinnedModelTip === model ? 'auto' : 'none',
}}>
<div style={{ fontWeight: 700, color, marginBottom: 4 }}>{desc.summary}</div>
{desc.details.map((line, i) => (
<div key={i} style={{ color: '#94a3b8', lineHeight: 1.5 }}>{line}</div>
))}
{pinnedModelTip === model && (
<div style={{
color: '#64748b',
fontSize: 8,
marginTop: 4,
borderTop: '1px solid rgba(255,255,255,0.06)',
paddingTop: 3,
}}>
</div>
)}
</div>
);
};
// Common row renderer (correlation target — with score bar, model-independent hover)
const toggleVessel = (mmsi: string) => {
onEnabledVesselsChange(prev => {
const next = new Set(prev);
if (next.has(mmsi)) next.delete(mmsi); else next.add(mmsi);
return next;
});
};
const renderRow = (c: GearCorrelationItem, color: string, modelName: string) => {
const pct = (c.score * 100).toFixed(0);
const barW = Math.max(2, c.score * 30);
const barColor = c.score >= 0.7 ? '#22c55e' : c.score >= 0.5 ? '#eab308' : '#94a3b8';
const isVessel = c.targetType === 'VESSEL';
const isEnabled = enabledVessels.has(c.targetMmsi);
const isHovered = hoveredTarget?.mmsi === c.targetMmsi && hoveredTarget?.model === modelName;
return (
<div
key={c.targetMmsi}
style={{
fontSize: 9, marginBottom: 1, display: 'flex', alignItems: 'center', gap: 3,
padding: '1px 2px', borderRadius: 2, cursor: 'pointer',
background: isHovered ? `${color}22` : 'transparent',
opacity: isEnabled ? 1 : 0.5,
}}
onClick={() => toggleVessel(c.targetMmsi)}
onMouseEnter={() => onHoveredTargetChange({ mmsi: c.targetMmsi, model: modelName })}
onMouseLeave={() => onHoveredTargetChange(null)}
>
<input type="checkbox" checked={isEnabled} readOnly title="맵 표시"
style={{ accentColor: color, width: 9, height: 9, flexShrink: 0, pointerEvents: 'none' }} />
<span style={{ color: isVessel ? '#60a5fa' : '#f97316', width: 10, textAlign: 'center', flexShrink: 0 }}>
{isVessel ? '⛴' : '◆'}
</span>
<span style={{ color: '#cbd5e1', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{c.targetName || c.targetMmsi}
</span>
<div style={{ width: 50, display: 'flex', alignItems: 'center', gap: 2, flexShrink: 0 }}>
<div style={{ width: 24, height: 3, background: 'rgba(255,255,255,0.08)', borderRadius: 2, overflow: 'hidden' }}>
<div style={{ width: barW, height: '100%', background: barColor, borderRadius: 2 }} />
</div>
<span style={{ color: barColor, fontSize: 8, minWidth: 20, textAlign: 'right' }}>{pct}%</span>
</div>
</div>
);
};
// Member row renderer (identity model — no score, independent hover)
const renderMemberRow = (m: { mmsi: string; name: string }, icon: string, iconColor: string) => {
const isHovered = hoveredTarget?.mmsi === m.mmsi && hoveredTarget?.model === 'identity';
return (
<div
key={m.mmsi}
style={{
fontSize: 9,
marginBottom: 1,
display: 'flex',
alignItems: 'center',
gap: 4,
padding: '1px 2px',
borderRadius: 2,
cursor: 'default',
background: isHovered ? 'rgba(249,115,22,0.15)' : 'transparent',
}}
onMouseEnter={() => onHoveredTargetChange({ mmsi: m.mmsi, model: 'identity' })}
onMouseLeave={() => onHoveredTargetChange(null)}
>
<span style={{ color: iconColor, width: 10, textAlign: 'center', flexShrink: 0 }}>{icon}</span>
<span style={{ color: '#cbd5e1', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{m.name || m.mmsi}
</span>
</div>
);
};
return (
<div style={{
position: 'absolute',
bottom: historyActive ? 120 : 20,
left: 'calc(50% - 210px)',
display: 'flex',
gap: 6,
alignItems: 'flex-start',
zIndex: 21,
fontFamily: FONT_MONO,
fontSize: 10,
color: '#e2e8f0',
pointerEvents: 'auto',
maxWidth: 'calc(100vw - 40px)',
overflowX: 'auto',
overflowY: 'visible',
}}>
{/* 고정: 토글 패널 */}
<div style={{
background: 'rgba(12,24,37,0.95)',
border: '1px solid rgba(249,115,22,0.3)',
borderRadius: 8,
padding: '8px 10px',
position: 'sticky',
left: 0,
minWidth: 165,
flexShrink: 0,
boxShadow: '0 4px 16px rgba(0,0,0,0.5)',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 6 }}>
<span style={{ fontWeight: 700, color: '#f97316', fontSize: 11 }}>{selectedGearGroup}</span>
<span style={{ color: '#64748b', fontSize: 9 }}>{memberCount}</span>
</div>
<div style={{ fontSize: 9, fontWeight: 700, color: '#93c5fd', marginBottom: 4 }}> </div>
<label style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 9, cursor: 'pointer', marginBottom: 3 }}>
<input
type="checkbox"
checked={true}
disabled
style={{ accentColor: '#f97316', width: 11, height: 11, opacity: 0.6 }}
title="이름 기반 (항상 ON)"
/>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: '#f97316', flexShrink: 0 }} />
<span style={{ color: '#94a3b8' }}> ()</span>
<span style={{ color: '#64748b', marginLeft: 'auto' }}>{memberCount}</span>
</label>
{correlationLoading && <div style={{ fontSize: 8, color: '#64748b' }}>...</div>}
{availableModels.map(m => {
const color = MODEL_COLORS[m.name] ?? '#94a3b8';
const modelItems = correlationByModel.get(m.name) ?? [];
const vc = modelItems.filter(c => c.targetType === 'VESSEL').length;
const gc = modelItems.filter(c => c.targetType !== 'VESSEL').length;
return (
<label key={m.name} style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 9, cursor: 'pointer', marginBottom: 3 }}>
<input type="checkbox" checked={enabledModels.has(m.name)}
onChange={() => onEnabledModelsChange(prev => {
const next = new Set(prev);
if (next.has(m.name)) next.delete(m.name); else next.add(m.name);
return next;
})}
style={{ accentColor: color, width: 11, height: 11 }} title={m.name} />
<span style={{ width: 8, height: 8, borderRadius: '50%', background: color, flexShrink: 0 }} />
<span style={{ color: '#e2e8f0', flex: 1 }}>{m.name}{m.isDefault ? '*' : ''}</span>
<span style={{ color: '#64748b', fontSize: 8 }}>{vc}{gc}</span>
</label>
);
})}
</div>
{/* 이름 기반 카드 (체크 시) */}
{enabledModels.has('identity') && (identityVessels.length > 0 || identityGear.length > 0) && (
<div style={{ ...cardStyle, borderColor: 'rgba(249,115,22,0.25)' }}>
{renderModelTip('identity', '#f97316')}
<div style={cardScrollStyle}>
<div
style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 4, cursor: 'help' }}
onMouseEnter={() => handleTipHover('identity')}
onMouseLeave={handleTipLeave}
onClick={() => handleTipClick('identity')}
>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: '#f97316', flexShrink: 0 }} />
<span style={{ fontSize: 9, fontWeight: 700, color: '#f97316' }}> </span>
</div>
{identityVessels.length > 0 && (
<>
<div style={{ fontSize: 8, color: '#64748b', marginBottom: 2 }}> ({identityVessels.length})</div>
{identityVessels.map(m => renderMemberRow(m, '⛴', '#60a5fa'))}
</>
)}
{identityGear.length > 0 && (
<>
<div style={{ fontSize: 8, color: '#64748b', marginBottom: 2, marginTop: 3 }}> ({identityGear.length})</div>
{identityGear.slice(0, 12).map(m => renderMemberRow(m, '◆', '#f97316'))}
{identityGear.length > 12 && (
<div style={{ fontSize: 8, color: '#4a6b82' }}>+{identityGear.length - 12} </div>
)}
</>
)}
</div>
</div>
)}
{/* 각 Correlation 모델 카드 (체크 시 우측에 추가) */}
{availableModels.filter(m => enabledModels.has(m.name)).map(m => {
const color = MODEL_COLORS[m.name] ?? '#94a3b8';
const items = correlationByModel.get(m.name) ?? [];
const vessels = items.filter(c => c.targetType === 'VESSEL');
const gears = items.filter(c => c.targetType !== 'VESSEL');
if (vessels.length === 0 && gears.length === 0) return null;
return (
<div key={m.name} style={{ ...cardStyle, borderColor: `${color}40` }}>
{renderModelTip(m.name, color)}
<div style={cardScrollStyle}>
<div
style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 4, cursor: 'help' }}
onMouseEnter={() => handleTipHover(m.name)}
onMouseLeave={handleTipLeave}
onClick={() => handleTipClick(m.name)}
>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: color, flexShrink: 0 }} />
<span style={{ fontSize: 9, fontWeight: 700, color }}>{m.name}{m.isDefault ? '*' : ''}</span>
</div>
{vessels.length > 0 && (
<>
<div style={{ fontSize: 8, color: '#64748b', marginBottom: 2 }}> ({vessels.length})</div>
{vessels.slice(0, 10).map(c => renderRow(c, color, m.name))}
{vessels.length > 10 && (
<div style={{ fontSize: 8, color: '#4a6b82' }}>+{vessels.length - 10} </div>
)}
</>
)}
{gears.length > 0 && (
<>
<div style={{ fontSize: 8, color: '#64748b', marginBottom: 2, marginTop: 3 }}> ({gears.length})</div>
{gears.slice(0, 10).map(c => renderRow(c, color, m.name))}
{gears.length > 10 && (
<div style={{ fontSize: 8, color: '#4a6b82' }}>+{gears.length - 10} </div>
)}
</>
)}
</div>
</div>
);
})}
</div>
);
};
export default CorrelationPanel;

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다. Load Diff

파일 보기

@ -0,0 +1,397 @@
import { Source, Layer, Popup } from 'react-map-gl/maplibre';
import { FONT_MONO } from '../../styles/fonts';
import type { FleetCompany } from '../../services/vesselAnalysis';
import type { VesselAnalysisDto } from '../../types';
import type { UseGroupPolygonsResult } from '../../hooks/useGroupPolygons';
import type {
HoverTooltipState,
GearPickerPopupState,
PickerCandidate,
} from './fleetClusterTypes';
import type { FleetClusterGeoJsonResult } from './useFleetClusterGeoJson';
import { MODEL_ORDER, MODEL_COLORS } from './fleetClusterConstants';
interface FleetClusterMapLayersProps {
geo: FleetClusterGeoJsonResult;
selectedGearGroup: string | null;
hoveredMmsi: string | null;
enabledModels: Set<string>;
expandedFleet: number | null;
historyActive: boolean;
// Popup/tooltip state
hoverTooltip: HoverTooltipState | null;
gearPickerPopup: GearPickerPopupState | null;
pickerHoveredGroup: string | null;
// Data for tooltip rendering
groupPolygons: UseGroupPolygonsResult | undefined;
companies: Map<number, FleetCompany>;
analysisMap: Map<string, VesselAnalysisDto>;
// Whether any correlation trails exist (drives conditional render)
hasCorrelationTracks: boolean;
// Callbacks
onPickerHover: (group: string | null) => void;
onPickerSelect: (candidate: PickerCandidate) => void;
onPickerClose: () => void;
}
const FleetClusterMapLayers = ({
geo,
selectedGearGroup,
hoveredMmsi,
enabledModels,
expandedFleet,
historyActive,
hoverTooltip,
gearPickerPopup,
pickerHoveredGroup,
groupPolygons,
companies,
analysisMap,
hasCorrelationTracks,
onPickerHover,
onPickerSelect,
onPickerClose,
}: FleetClusterMapLayersProps) => {
const {
fleetPolygonGeoJSON,
lineGeoJSON,
hoveredGeoJSON,
gearClusterGeoJson,
memberMarkersGeoJson,
pickerHighlightGeoJson,
operationalPolygons,
correlationVesselGeoJson,
correlationTrailGeoJson,
modelBadgesGeoJson,
hoverHighlightGeoJson,
hoverHighlightTrailGeoJson,
} = geo;
return (
<>
{/* 선단 폴리곤 레이어 */}
<Source id="fleet-cluster-fill" type="geojson" data={fleetPolygonGeoJSON}>
<Layer
id="fleet-cluster-fill-layer"
type="fill"
paint={{
'fill-color': ['get', 'color'],
'fill-opacity': 0.1,
}}
/>
<Layer
id="fleet-cluster-line-layer"
type="line"
paint={{
'line-color': ['get', 'color'],
'line-opacity': 0.5,
'line-width': 1.5,
}}
/>
</Source>
{/* 2척 선단 라인 (서버측 Polygon 처리로 빈 컬렉션) */}
<Source id="fleet-cluster-line" type="geojson" data={lineGeoJSON}>
<Layer
id="fleet-cluster-line-only"
type="line"
paint={{
'line-color': ['get', 'color'],
'line-opacity': 0.5,
'line-width': 1.5,
'line-dasharray': [4, 2],
}}
/>
</Source>
{/* 호버 하이라이트 (별도 Source) */}
<Source id="fleet-cluster-hovered" type="geojson" data={hoveredGeoJSON}>
<Layer
id="fleet-cluster-hovered-fill"
type="fill"
paint={{
'fill-color': ['get', 'color'],
'fill-opacity': 0.25,
}}
/>
</Source>
{/* 선택된 어구 그룹 — 이름 기반 하이라이트 폴리곤 */}
{selectedGearGroup && enabledModels.has('identity') && !historyActive && (() => {
const allGroups = groupPolygons
? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]
: [];
const group = allGroups.find(g => g.groupKey === selectedGearGroup);
if (!group?.polygon) return null;
const hlGeoJson: GeoJSON.FeatureCollection = {
type: 'FeatureCollection',
features: [{
type: 'Feature',
properties: {},
geometry: group.polygon,
}],
};
return (
<Source id="gear-cluster-selected" type="geojson" data={hlGeoJson}>
<Layer id="gear-selected-fill" type="fill" paint={{ 'fill-color': '#f97316', 'fill-opacity': 0.25 }} />
<Layer id="gear-selected-line" type="line" paint={{ 'line-color': '#f97316', 'line-width': 3, 'line-opacity': 0.9 }} />
</Source>
);
})()}
{/* 선택된 어구 그룹 — 모델별 오퍼레이셔널 폴리곤 오버레이 */}
{selectedGearGroup && !historyActive && operationalPolygons.map(op => (
<Source key={`op-${op.modelName}`} id={`gear-op-${op.modelName}`} type="geojson" data={op.geojson}>
<Layer id={`gear-op-fill-${op.modelName}`} type="fill" paint={{
'fill-color': op.color, 'fill-opacity': 0.12,
}} />
<Layer id={`gear-op-line-${op.modelName}`} type="line" paint={{
'line-color': op.color, 'line-width': 2.5, 'line-opacity': 0.8,
'line-dasharray': [6, 3],
}} />
</Source>
))}
{/* 비허가 어구 클러스터 폴리곤 */}
<Source id="gear-clusters" type="geojson" data={gearClusterGeoJson}>
<Layer
id="gear-cluster-fill-layer"
type="fill"
paint={{
'fill-color': ['case', ['==', ['get', 'inZone'], 1], 'rgba(220, 38, 38, 0.12)', 'rgba(249, 115, 22, 0.08)'],
}}
/>
<Layer
id="gear-cluster-line-layer"
type="line"
paint={{
'line-color': ['case', ['==', ['get', 'inZone'], 1], '#dc2626', '#f97316'],
'line-opacity': 0.7,
'line-width': ['case', ['==', ['get', 'inZone'], 1], 2, 1.5],
'line-dasharray': [4, 2],
}}
/>
</Source>
{/* 가상 선박 마커 — 히스토리 재생 모드에서는 숨김 (deck.gl 레이어로 대체) */}
<Source id="group-member-markers" type="geojson" data={historyActive ? ({ type: 'FeatureCollection', features: [] } as GeoJSON.FeatureCollection) : memberMarkersGeoJson}>
<Layer
id="group-member-icon"
type="symbol"
layout={{
'icon-image': ['case', ['==', ['get', 'isGear'], 1], 'gear-diamond', 'ship-triangle'],
'icon-size': ['interpolate', ['linear'], ['zoom'],
4, ['*', ['get', 'baseSize'], 0.9],
6, ['*', ['get', 'baseSize'], 1.2],
8, ['*', ['get', 'baseSize'], 1.8],
10, ['*', ['get', 'baseSize'], 2.6],
12, ['*', ['get', 'baseSize'], 3.2],
13, ['*', ['get', 'baseSize'], 4.0],
14, ['*', ['get', 'baseSize'], 4.8],
],
'icon-rotate': ['case', ['==', ['get', 'isGear'], 1], 0, ['get', 'cog']],
'icon-rotation-alignment': 'map',
'icon-allow-overlap': true,
'icon-ignore-placement': true,
'text-field': ['get', 'name'],
'text-size': ['interpolate', ['linear'], ['zoom'], 4, 6, 8, 8, 12, 10],
'text-offset': [0, 1.4],
'text-anchor': 'top',
'text-allow-overlap': false,
'text-font': ['Open Sans Regular', 'Arial Unicode MS Regular'],
}}
paint={{
'icon-color': ['get', 'color'],
'icon-halo-color': 'rgba(0,0,0,0.6)',
'icon-halo-width': 0.5,
'text-color': ['get', 'color'],
'text-halo-color': '#000000',
'text-halo-width': 1,
}}
/>
</Source>
{/* 어구 picker 호버 하이라이트 */}
<Source id="gear-picker-highlight" type="geojson" data={pickerHighlightGeoJson}>
<Layer id="gear-picker-highlight-fill" type="fill"
paint={{ 'fill-color': '#ffffff', 'fill-opacity': 0.25 }} />
<Layer id="gear-picker-highlight-line" type="line"
paint={{ 'line-color': '#ffffff', 'line-width': 2, 'line-dasharray': [3, 2] }} />
</Source>
{/* 어구 다중 선택 팝업 */}
{gearPickerPopup && (
<Popup longitude={gearPickerPopup.lng} latitude={gearPickerPopup.lat}
onClose={() => { onPickerClose(); }}
closeOnClick={false} className="gl-popup" maxWidth="220px">
<div style={{ fontSize: 10, fontFamily: FONT_MONO, padding: '4px 0' }}>
<div style={{ fontWeight: 700, marginBottom: 4, color: '#e2e8f0', padding: '0 6px' }}>
({gearPickerPopup.candidates.length})
</div>
{gearPickerPopup.candidates.map(c => (
<div key={c.isFleet ? `fleet-${c.clusterId}` : c.name}
onMouseEnter={() => onPickerHover(c.isFleet ? String(c.clusterId) : c.name)}
onMouseLeave={() => onPickerHover(null)}
onClick={() => {
onPickerSelect(c);
onPickerClose();
}}
style={{
cursor: 'pointer', padding: '3px 6px',
borderLeft: `3px solid ${c.isFleet ? '#63b3ed' : c.inZone ? '#dc2626' : '#f97316'}`,
marginBottom: 2, borderRadius: 2,
backgroundColor: pickerHoveredGroup === (c.isFleet ? String(c.clusterId) : c.name) ? 'rgba(255,255,255,0.1)' : 'transparent',
}}>
<span style={{ color: c.isFleet ? '#63b3ed' : '#e2e8f0', fontSize: 9 }}>{c.isFleet ? '⚓ ' : ''}{c.name}</span>
<span style={{ color: '#64748b', marginLeft: 4 }}>({c.count}{c.isFleet ? '척' : '개'})</span>
</div>
))}
</div>
</Popup>
)}
{/* 폴리곤 호버 툴팁 */}
{hoverTooltip && (() => {
if (hoverTooltip.type === 'fleet') {
const cid = hoverTooltip.id as number;
const group = groupPolygons?.fleetGroups.find(g => Number(g.groupKey) === cid);
const company = companies.get(cid);
const memberCount = group?.memberCount ?? 0;
return (
<Popup longitude={hoverTooltip.lng} latitude={hoverTooltip.lat}
closeButton={false} closeOnClick={false} anchor="bottom"
className="gl-popup" maxWidth="220px"
>
<div style={{ fontFamily: FONT_MONO, fontSize: 10, padding: 4, color: '#e2e8f0' }}>
<div style={{ fontWeight: 700, color: group?.color ?? '#63b3ed', marginBottom: 3 }}>
{company?.nameCn || group?.groupLabel || `선단 #${cid}`}
</div>
<div style={{ color: '#94a3b8' }}> {memberCount}</div>
{expandedFleet === cid && group?.members.slice(0, 5).map(m => {
const dto = analysisMap.get(m.mmsi);
const role = dto?.algorithms.fleetRole.role ?? m.role;
return (
<div key={m.mmsi} style={{ fontSize: 9, color: '#7ea8c4', marginTop: 2 }}>
{role === 'LEADER' ? '★' : '·'} {m.name || m.mmsi} <span style={{ color: '#4a6b82' }}>{m.sog.toFixed(1)}kt</span>
</div>
);
})}
<div style={{ fontSize: 8, color: '#4a6b82', marginTop: 3 }}> </div>
</div>
</Popup>
);
}
if (hoverTooltip.type === 'gear') {
const name = hoverTooltip.id as string;
const allGroups = groupPolygons
? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]
: [];
const group = allGroups.find(g => g.groupKey === name);
if (!group) return null;
const parentMember = group.members.find(m => m.isParent);
const gearMembers = group.members.filter(m => !m.isParent);
return (
<Popup longitude={hoverTooltip.lng} latitude={hoverTooltip.lat}
closeButton={false} closeOnClick={false} anchor="bottom"
className="gl-popup" maxWidth={selectedGearGroup === name ? '280px' : '220px'}
>
<div style={{ fontFamily: FONT_MONO, fontSize: 10, padding: 4, color: '#e2e8f0' }}>
<div style={{ fontWeight: 700, color: '#f97316', marginBottom: 3 }}>
{name} <span style={{ fontWeight: 400, color: '#94a3b8' }}> {gearMembers.length}</span>
</div>
{parentMember && (
<div style={{ fontSize: 9, color: '#fbbf24' }}>: {parentMember.name || parentMember.mmsi}</div>
)}
{selectedGearGroup === name && gearMembers.slice(0, 5).map(m => (
<div key={m.mmsi} style={{ fontSize: 9, color: '#7ea8c4', marginTop: 1 }}>
· {m.name || m.mmsi}
</div>
))}
<div style={{ fontSize: 8, color: '#4a6b82', marginTop: 3 }}> /</div>
</div>
</Popup>
);
}
return null;
})()}
{/* ── 연관 대상 트레일 + 마커 (비재생 모드) ── */}
{selectedGearGroup && !historyActive && hasCorrelationTracks && (
<Source id="correlation-trails" type="geojson" data={correlationTrailGeoJson}>
<Layer id="correlation-trails-line" type="line" paint={{
'line-color': ['get', 'color'], 'line-width': 2, 'line-opacity': 0.6,
'line-dasharray': [6, 3],
}} />
</Source>
)}
{selectedGearGroup && !historyActive && (
<Source id="correlation-vessels" type="geojson" data={correlationVesselGeoJson}>
<Layer id="correlation-vessels-icon" type="symbol" layout={{
'icon-image': ['case', ['==', ['get', 'isVessel'], 1], 'ship-triangle', 'gear-diamond'],
'icon-size': ['case', ['==', ['get', 'isVessel'], 1], 0.7, 0.5],
'icon-rotate': ['case', ['==', ['get', 'isVessel'], 1], ['get', 'cog'], 0],
'icon-rotation-alignment': 'map',
'icon-allow-overlap': true,
}} paint={{
'icon-color': ['get', 'color'],
'icon-halo-color': 'rgba(0,0,0,0.6)',
'icon-halo-width': 1,
}} />
<Layer id="correlation-vessels-label" type="symbol" layout={{
'text-field': ['get', 'name'],
'text-size': 8,
'text-offset': [0, 1.5],
'text-allow-overlap': false,
}} paint={{
'text-color': ['get', 'color'],
'text-halo-color': 'rgba(0,0,0,0.8)',
'text-halo-width': 1,
}} />
</Source>
)}
{/* ── 모델 배지 (비재생 모드) ── */}
{selectedGearGroup && !historyActive && (
<Source id="model-badges" type="geojson" data={modelBadgesGeoJson}>
{MODEL_ORDER.map((model, i) => (
enabledModels.has(model) ? (
<Layer key={`badge-${model}`} id={`model-badge-${model}`} type="circle"
filter={['==', ['get', `m${i}`], 1]}
paint={{
'circle-radius': 3,
'circle-color': MODEL_COLORS[model] ?? '#94a3b8',
'circle-stroke-width': 0.5,
'circle-stroke-color': 'rgba(0,0,0,0.6)',
'circle-translate': [10 + i * 7, -6],
}}
/>
) : null
))}
</Source>
)}
{/* ── 호버 하이라이트 (비재생 모드) ── */}
{hoveredMmsi && !historyActive && (
<Source id="hover-highlight-point" type="geojson" data={hoverHighlightGeoJson}>
<Layer id="hover-highlight-glow" type="circle" paint={{
'circle-radius': 14, 'circle-color': '#ffffff', 'circle-opacity': 0.25,
'circle-blur': 0.8,
}} />
<Layer id="hover-highlight-ring" type="circle" paint={{
'circle-radius': 8, 'circle-color': 'transparent',
'circle-stroke-width': 2, 'circle-stroke-color': '#ffffff',
}} />
</Source>
)}
{hoveredMmsi && !historyActive && (
<Source id="hover-highlight-trail" type="geojson" data={hoverHighlightTrailGeoJson}>
<Layer id="hover-highlight-trail-line" type="line" paint={{
'line-color': '#ffffff', 'line-width': 3, 'line-opacity': 0.7,
}} />
</Source>
)}
</>
);
};
export default FleetClusterMapLayers;

파일 보기

@ -0,0 +1,171 @@
import type { FleetCompany } from '../../services/vesselAnalysis';
import type { VesselAnalysisDto } from '../../types';
import type { UseGroupPolygonsResult } from '../../hooks/useGroupPolygons';
import type { FleetListItem } from './fleetClusterTypes';
import { panelStyle, headerStyle, toggleButtonStyle } from './fleetClusterConstants';
import GearGroupSection from './GearGroupSection';
interface FleetGearListPanelProps {
fleetList: FleetListItem[];
companies: Map<number, FleetCompany>;
analysisMap: Map<string, VesselAnalysisDto>;
inZoneGearGroups: UseGroupPolygonsResult['gearInZoneGroups'];
outZoneGearGroups: UseGroupPolygonsResult['gearOutZoneGroups'];
activeSection: string | null;
expandedFleet: number | null;
expandedGearGroup: string | null;
hoveredFleetId: number | null;
onToggleSection: (key: string) => void;
onExpandFleet: (id: number | null) => void;
onHoverFleet: (id: number | null) => void;
onFleetZoom: (id: number) => void;
onGearGroupZoom: (name: string) => void;
onExpandGearGroup: (name: string | null) => void;
onShipSelect: (mmsi: string) => void;
}
const FleetGearListPanel = ({
fleetList,
companies,
analysisMap,
inZoneGearGroups,
outZoneGearGroups,
activeSection,
expandedFleet,
expandedGearGroup,
hoveredFleetId,
onToggleSection,
onExpandFleet,
onHoverFleet,
onFleetZoom,
onGearGroupZoom,
onExpandGearGroup,
onShipSelect,
}: FleetGearListPanelProps) => {
return (
<div style={panelStyle}>
{/* ── 선단 현황 섹션 ── */}
<div style={{ ...headerStyle, cursor: 'pointer' }} onClick={() => onToggleSection('fleet')}>
<span style={{ fontWeight: 700, color: '#63b3ed', letterSpacing: 0.5 }}>
({fleetList.length})
</span>
<button type="button" style={toggleButtonStyle} aria-label="선단 현황 접기/펴기">
{activeSection === 'fleet' ? '▲' : '▼'}
</button>
</div>
{activeSection === 'fleet' && (
<div style={{ padding: '4px 0', overflowY: 'auto', flex: 1 }}>
{fleetList.length === 0 ? (
<div style={{ color: '#64748b', textAlign: 'center', padding: '8px 10px' }}>
</div>
) : (
fleetList.map(({ id, mmsiList, label, color, members }) => {
const company = companies.get(id);
const companyName = company?.nameCn ?? label ?? `선단 #${id}`;
const isOpen = expandedFleet === id;
const isHovered = hoveredFleetId === id;
const mainMembers = members.filter(m => {
const dto = analysisMap.get(m.mmsi);
return dto?.algorithms.fleetRole.role === 'LEADER' || dto?.algorithms.fleetRole.role === 'MEMBER';
});
const displayMembers = mainMembers.length > 0 ? mainMembers : members;
return (
<div key={id}>
<div
onMouseEnter={() => onHoverFleet(id)}
onMouseLeave={() => onHoverFleet(null)}
style={{
display: 'flex', alignItems: 'center', gap: 4, padding: '4px 10px',
cursor: 'pointer',
backgroundColor: isHovered ? 'rgba(255,255,255,0.06)' : 'transparent',
borderLeft: isOpen ? `2px solid ${color}` : '2px solid transparent',
transition: 'background-color 0.1s',
}}
>
<span onClick={() => onExpandFleet(isOpen ? null : id)}
style={{ color: '#94a3b8', fontSize: 9, flexShrink: 0, cursor: 'pointer' }}>
{isOpen ? '▾' : '▸'}
</span>
<span style={{ width: 8, height: 8, borderRadius: '50%', backgroundColor: color, flexShrink: 0 }} />
<span onClick={() => onExpandFleet(isOpen ? null : id)}
style={{ flex: 1, color: '#e2e8f0', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', cursor: 'pointer' }}
title={company ? `${company.nameCn} / ${company.nameEn}` : companyName}>
{companyName}
</span>
<span style={{ color: '#64748b', fontSize: 10, flexShrink: 0 }}>({mmsiList.length})</span>
<button type="button" onClick={e => { e.stopPropagation(); onFleetZoom(id); }}
style={{ background: 'none', border: '1px solid rgba(99,179,237,0.3)', borderRadius: 3, color: '#63b3ed', fontSize: 9, cursor: 'pointer', padding: '1px 4px', flexShrink: 0 }}
title="이 선단으로 지도 이동">
zoom
</button>
</div>
{isOpen && (
<div style={{ paddingLeft: 22, paddingRight: 10, paddingBottom: 6, fontSize: 10, color: '#94a3b8', borderLeft: `2px solid ${color}33`, marginLeft: 10 }}>
<div style={{ color: '#64748b', fontSize: 9, marginBottom: 3 }}>:</div>
{displayMembers.map(m => {
const dto = analysisMap.get(m.mmsi);
const role = dto?.algorithms.fleetRole.role ?? m.role;
const displayName = m.name || m.mmsi;
return (
<div key={m.mmsi} style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 2 }}>
<span style={{ flex: 1, color: '#cbd5e1', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{displayName}
</span>
<span style={{ color: role === 'LEADER' ? '#fbbf24' : '#64748b', fontSize: 9, flexShrink: 0 }}>
({role === 'LEADER' ? 'MAIN' : 'SUB'})
</span>
<button type="button" onClick={() => onShipSelect(m.mmsi)}
style={{ background: 'none', border: 'none', color: '#63b3ed', fontSize: 10, cursor: 'pointer', padding: '0 2px', flexShrink: 0 }}
title="선박으로 이동" aria-label={`${displayName} 선박으로 이동`}>
</button>
</div>
);
})}
</div>
)}
</div>
);
})
)}
</div>
)}
{/* ── 조업구역내 어구 ── */}
<GearGroupSection
groups={inZoneGearGroups}
sectionKey="inZone"
sectionLabel={`조업구역내 어구 (${inZoneGearGroups.length}개)`}
accentColor="#dc2626"
hoverBgColor="rgba(220,38,38,0.06)"
isActive={activeSection === 'inZone'}
expandedGroup={expandedGearGroup}
onToggleSection={() => onToggleSection('inZone')}
onToggleGroup={(name) => onExpandGearGroup(expandedGearGroup === name ? null : name)}
onGroupZoom={onGearGroupZoom}
onShipSelect={onShipSelect}
/>
{/* ── 비허가 어구 ── */}
<GearGroupSection
groups={outZoneGearGroups}
sectionKey="outZone"
sectionLabel={`비허가 어구 (${outZoneGearGroups.length}개)`}
accentColor="#f97316"
hoverBgColor="rgba(255,255,255,0.04)"
isActive={activeSection === 'outZone'}
expandedGroup={expandedGearGroup}
onToggleSection={() => onToggleSection('outZone')}
onToggleGroup={(name) => onExpandGearGroup(expandedGearGroup === name ? null : name)}
onGroupZoom={onGearGroupZoom}
onShipSelect={onShipSelect}
/>
</div>
);
};
export default FleetGearListPanel;

파일 보기

@ -0,0 +1,211 @@
import type { GroupPolygonDto } from '../../services/vesselAnalysis';
import { FONT_MONO } from '../../styles/fonts';
import { headerStyle, toggleButtonStyle } from './fleetClusterConstants';
interface GearGroupSectionProps {
groups: GroupPolygonDto[];
sectionKey: string;
sectionLabel: string;
accentColor: string;
hoverBgColor: string;
isActive: boolean;
expandedGroup: string | null;
onToggleSection: () => void;
onToggleGroup: (name: string) => void;
onGroupZoom: (name: string) => void;
onShipSelect: (mmsi: string) => void;
}
const GearGroupSection = ({
groups,
sectionKey,
sectionLabel,
accentColor,
hoverBgColor,
isActive,
expandedGroup,
onToggleSection,
onToggleGroup,
onGroupZoom,
onShipSelect,
}: GearGroupSectionProps) => {
const isInZoneSection = sectionKey === 'inZone';
return (
<>
<div
style={{
...headerStyle,
borderTop: `1px solid ${accentColor}40`,
cursor: 'pointer',
}}
onClick={onToggleSection}
>
<span style={{ fontWeight: 700, color: accentColor, letterSpacing: 0.3, fontFamily: FONT_MONO }}>
{sectionLabel} ({groups.length})
</span>
<button
type="button"
style={toggleButtonStyle}
aria-label={`${sectionLabel} 접기/펴기`}
>
{isActive ? '▲' : '▼'}
</button>
</div>
{isActive && (
<div style={{ padding: '4px 0', overflowY: 'auto', flex: 1 }}>
{groups.map(g => {
const name = g.groupKey;
const isOpen = expandedGroup === name;
const parentMember = g.members.find(m => m.isParent);
const gearMembers = g.members.filter(m => !m.isParent);
const zoneName = g.zoneName ?? '';
return (
<div key={name} id={`gear-row-${name}`}>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 4,
padding: '3px 10px',
cursor: 'pointer',
borderLeft: isOpen ? `2px solid ${accentColor}` : '2px solid transparent',
transition: 'background-color 0.1s',
fontFamily: FONT_MONO,
}}
onMouseEnter={e => {
(e.currentTarget as HTMLDivElement).style.backgroundColor = hoverBgColor;
}}
onMouseLeave={e => {
(e.currentTarget as HTMLDivElement).style.backgroundColor = 'transparent';
}}
>
<span
onClick={() => onToggleGroup(name)}
style={{ color: '#94a3b8', fontSize: 9, flexShrink: 0, cursor: 'pointer' }}
>
{isOpen ? '▾' : '▸'}
</span>
<span style={{
width: 8,
height: 8,
borderRadius: '50%',
backgroundColor: accentColor,
flexShrink: 0,
}} />
<span
onClick={() => onToggleGroup(name)}
style={{
flex: 1,
color: '#e2e8f0',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
cursor: 'pointer',
}}
title={isInZoneSection ? `${name}${zoneName}` : name}
>
{name}
</span>
{parentMember && (
<span
style={{ color: '#fbbf24', fontSize: 8, flexShrink: 0 }}
title={`모선: ${parentMember.name}`}
>
</span>
)}
{isInZoneSection && zoneName && (
<span style={{ color: '#ef4444', fontSize: 9, flexShrink: 0 }}>{zoneName}</span>
)}
<span style={{ color: '#64748b', fontSize: 10, flexShrink: 0 }}>
({gearMembers.length}{isInZoneSection ? '' : '개'})
</span>
<button
type="button"
onClick={e => {
e.stopPropagation();
onGroupZoom(name);
}}
style={{
background: 'none',
border: `1px solid ${accentColor}80`,
borderRadius: 3,
color: accentColor,
fontSize: 9,
cursor: 'pointer',
padding: '1px 4px',
flexShrink: 0,
}}
title="이 어구 그룹으로 지도 이동"
>
zoom
</button>
</div>
{isOpen && (
<div style={{
paddingLeft: 24,
paddingRight: 10,
paddingBottom: 4,
fontSize: 9,
color: '#94a3b8',
borderLeft: `2px solid ${accentColor}40`,
marginLeft: 10,
fontFamily: FONT_MONO,
}}>
{parentMember && (
<div style={{ color: '#fbbf24', marginBottom: 2 }}>
: {parentMember.name || parentMember.mmsi}
</div>
)}
<div style={{ color: '#64748b', marginBottom: 2 }}> :</div>
{gearMembers.map(m => (
<div key={m.mmsi} style={{
display: 'flex',
alignItems: 'center',
gap: 4,
marginBottom: 1,
}}>
<span style={{
flex: 1,
color: '#475569',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}>
{m.name || m.mmsi}
</span>
<button
type="button"
onClick={() => onShipSelect(m.mmsi)}
style={{
background: 'none',
border: 'none',
color: accentColor,
fontSize: 10,
cursor: 'pointer',
padding: '0 2px',
flexShrink: 0,
}}
title="어구 위치로 이동"
aria-label={`${m.name || m.mmsi} 위치로 이동`}
>
</button>
</div>
))}
</div>
)}
</div>
);
})}
</div>
)}
</>
);
};
export default GearGroupSection;

파일 보기

@ -0,0 +1,128 @@
import { useRef, useEffect } from 'react';
import { FONT_MONO } from '../../styles/fonts';
import { useGearReplayStore } from '../../stores/gearReplayStore';
interface HistoryReplayControllerProps {
onClose: () => void;
onFilterByScore: (minPct: number | null) => void;
}
const HistoryReplayController = ({ onClose, onFilterByScore }: HistoryReplayControllerProps) => {
const isPlaying = useGearReplayStore(s => s.isPlaying);
const snapshotRanges = useGearReplayStore(s => s.snapshotRanges);
const frameCount = useGearReplayStore(s => s.historyFrames.length);
const showTrails = useGearReplayStore(s => s.showTrails);
const showLabels = useGearReplayStore(s => s.showLabels);
const progressBarRef = useRef<HTMLInputElement>(null);
const progressIndicatorRef = useRef<HTMLDivElement>(null);
const timeDisplayRef = useRef<HTMLSpanElement>(null);
useEffect(() => {
const unsub = useGearReplayStore.subscribe(
s => s.currentTime,
(currentTime) => {
const { startTime, endTime } = useGearReplayStore.getState();
if (endTime <= startTime) return;
const progress = (currentTime - startTime) / (endTime - startTime);
if (progressBarRef.current) progressBarRef.current.value = String(Math.round(progress * 1000));
if (progressIndicatorRef.current) progressIndicatorRef.current.style.left = `${progress * 100}%`;
if (timeDisplayRef.current) {
timeDisplayRef.current.textContent = new Date(currentTime).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' });
}
},
);
return unsub;
}, []);
const store = useGearReplayStore;
const btnStyle: React.CSSProperties = {
background: 'none', border: '1px solid rgba(99,179,237,0.3)', borderRadius: 4,
color: '#e2e8f0', cursor: 'pointer', padding: '2px 6px', fontSize: 10, fontFamily: FONT_MONO,
};
const btnActiveStyle: React.CSSProperties = {
...btnStyle, background: 'rgba(99,179,237,0.15)', color: '#93c5fd',
};
return (
<div style={{
position: 'absolute', bottom: 20, left: '50%', transform: 'translateX(-50%)',
background: 'rgba(12,24,37,0.95)', border: '1px solid rgba(99,179,237,0.25)',
borderRadius: 8, padding: '8px 14px', display: 'flex', flexDirection: 'column', gap: 4,
zIndex: 20, fontFamily: FONT_MONO, fontSize: 10, color: '#e2e8f0',
boxShadow: '0 4px 16px rgba(0,0,0,0.5)', pointerEvents: 'auto', minWidth: 420,
}}>
{/* 프로그레스 바 */}
<div style={{ position: 'relative', height: 8, background: 'rgba(255,255,255,0.05)', borderRadius: 4, overflow: 'hidden' }}>
{snapshotRanges.map((pos, i) => (
<div key={i} style={{
position: 'absolute', left: `${pos * 100}%`, top: 0, width: 2, height: '100%',
background: 'rgba(251,191,36,0.4)',
}} />
))}
<div ref={progressIndicatorRef} style={{
position: 'absolute', left: '0%', top: -1, width: 3, height: 10,
background: '#fbbf24', borderRadius: 1, transform: 'translateX(-50%)',
}} />
</div>
{/* 컨트롤 행 1: 재생 + 타임라인 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<button type="button" onClick={() => { if (isPlaying) store.getState().pause(); else store.getState().play(); }}
style={{ ...btnStyle, fontSize: 12 }}>
{isPlaying ? '⏸' : '▶'}
</button>
<span ref={timeDisplayRef} style={{ color: '#fbbf24', minWidth: 40, textAlign: 'center' }}>--:--</span>
<input ref={progressBarRef} type="range" min={0} max={1000} defaultValue={0}
onChange={e => {
const { startTime, endTime } = store.getState();
const progress = Number(e.target.value) / 1000;
store.getState().pause();
store.getState().seek(startTime + progress * (endTime - startTime));
}}
style={{ flex: 1, cursor: 'pointer', accentColor: '#fbbf24' }}
title="히스토리 타임라인" aria-label="히스토리 타임라인" />
<span style={{ color: '#64748b', fontSize: 9 }}>{frameCount}</span>
<button type="button" onClick={onClose}
style={{ background: 'none', border: '1px solid rgba(239,68,68,0.3)', borderRadius: 4, color: '#ef4444', cursor: 'pointer', padding: '2px 6px', fontSize: 11, fontFamily: FONT_MONO }}>
</button>
</div>
{/* 컨트롤 행 2: 표시 옵션 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 6, borderTop: '1px solid rgba(255,255,255,0.06)', paddingTop: 4 }}>
<button type="button" onClick={() => store.getState().setShowTrails(!showTrails)}
style={showTrails ? btnActiveStyle : btnStyle} title="전체 항적 표시">
</button>
<button type="button" onClick={() => store.getState().setShowLabels(!showLabels)}
style={showLabels ? btnActiveStyle : btnStyle} title="이름 표시">
</button>
<span style={{ color: '#475569', margin: '0 2px' }}>|</span>
<span style={{ color: '#64748b', fontSize: 9 }}></span>
<select
onChange={e => {
const val = e.target.value;
onFilterByScore(val === '' ? null : Number(val));
}}
style={{
background: 'rgba(15,23,42,0.9)', border: '1px solid rgba(99,179,237,0.3)',
borderRadius: 4, color: '#e2e8f0', fontSize: 9, fontFamily: FONT_MONO,
padding: '1px 4px', cursor: 'pointer',
}}
title="일치율 이상만 표시" aria-label="일치율 필터"
>
<option value=""></option>
<option value="50">50%+</option>
<option value="60">60%+</option>
<option value="70">70%+</option>
<option value="80">80%+</option>
<option value="90">90%+</option>
</select>
</div>
</div>
);
};
export default HistoryReplayController;

파일 보기

@ -5,9 +5,12 @@ import type { MapRef } from 'react-map-gl/maplibre';
import { ScatterplotLayer, TextLayer } from '@deck.gl/layers';
import { useFontScale } from '../../hooks/useFontScale';
import { FONT_MONO } from '../../styles/fonts';
import type { MapboxOverlay } from '@deck.gl/mapbox';
import type { Layer as DeckLayer } from '@deck.gl/core';
import { DeckGLOverlay } from '../layers/DeckGLOverlay';
import { useAnalysisDeckLayers } from '../../hooks/useAnalysisDeckLayers';
import { useStaticDeckLayers } from '../../hooks/useStaticDeckLayers';
import { useGearReplayLayers } from '../../hooks/useGearReplayLayers';
import type { StaticPickInfo } from '../../hooks/useStaticDeckLayers';
import fishingZonesData from '../../data/zones/fishing-zones-wgs84.json';
import { ShipLayer } from '../layers/ShipLayer';
@ -201,9 +204,17 @@ const FILTER_I18N_KEY: Record<string, string> = {
cnFishing: 'filters.cnFishingMonitor',
};
// [DEBUG] 개발용 도구 — DEV에서만 동적 로드, 프로덕션 번들에서 완전 제거
import { lazy, Suspense } from 'react';
const DebugTools = import.meta.env.DEV
? lazy(() => import('./debug'))
: null;
export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintFeed, currentTime, koreaFilters, transshipSuspects, cableWatchSuspects, dokdoWatchSuspects, dokdoAlerts, vesselAnalysis, groupPolygons, hiddenShipCategories, hiddenNationalities, externalFlyTo, onExternalFlyToDone, opsRoute }: Props) {
const { t } = useTranslation();
const mapRef = useRef<MapRef>(null);
const overlayRef = useRef<MapboxOverlay | null>(null);
const replayLayerRef = useRef<DeckLayer[]>([]);
const [infra, setInfra] = useState<PowerFacility[]>([]);
const [flyToTarget, setFlyToTarget] = useState<{ lng: number; lat: number; zoom: number } | null>(null);
const [selectedAnalysisMmsi, setSelectedAnalysisMmsi] = useState<string | null>(null);
@ -225,6 +236,24 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
const [analysisPanelOpen, setAnalysisPanelOpen] = useLocalStorage('analysisPanelOpen', false);
const [activeBadgeFilter, setActiveBadgeFilter] = useState<string | null>(null);
// ── deck.gl 리플레이 레이어 (Zustand → imperative setProps, React 렌더 우회) ──
const reactLayersRef = useRef<DeckLayer[]>([]);
type ShipPos = { lng: number; lat: number; course?: number };
const shipsRef = useRef(new globalThis.Map<string, ShipPos>());
// live 선박 위치를 ref에 동기화 (리플레이 fallback용)
const allShipsList = allShips ?? ships;
const shipPosMap = new globalThis.Map<string, ShipPos>();
for (const s of allShipsList) shipPosMap.set(s.mmsi, { lng: s.lng, lat: s.lat, course: s.course });
shipsRef.current = shipPosMap;
const requestRender = useCallback(() => {
if (!overlayRef.current) return;
overlayRef.current.setProps({
layers: [...reactLayersRef.current, ...replayLayerRef.current],
});
}, []);
useGearReplayLayers(replayLayerRef, requestRender, shipsRef);
useEffect(() => {
fetchKoreaInfra().then(setInfra).catch(() => {});
}, []);
@ -609,6 +638,9 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
>
<NavigationControl position="top-right" />
{/* [DEBUG] 개발용 도구 — 프로덕션 번들에서 완전 제거 */}
{DebugTools && <Suspense><DebugTools mapRef={mapRef} /></Suspense>}
<Source id="country-labels" type="geojson" data={countryLabelsGeoJSON()}>
<Layer
id="country-label-lg"
@ -794,16 +826,23 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
</Source>
)}
{/* deck.gl GPU 오버레이 — 불법어선 강조 + 분석 위험도 마커 */}
<DeckGLOverlay layers={[
...staticDeckLayers,
illegalFishingLayer,
illegalFishingLabelLayer,
zoneLabelsLayer,
...selectedGearLayers,
...selectedFleetLayers,
...(analysisPanelOpen ? analysisDeckLayers : []),
].filter(Boolean)} />
{/* deck.gl GPU 오버레이 — 정적 + 리플레이 레이어 합성 */}
<DeckGLOverlay
overlayRef={overlayRef}
layers={(() => {
const base = [
...staticDeckLayers,
illegalFishingLayer,
illegalFishingLabelLayer,
zoneLabelsLayer,
...selectedGearLayers,
...selectedFleetLayers,
...(analysisPanelOpen ? analysisDeckLayers : []),
].filter(Boolean);
reactLayersRef.current = base;
return [...base, ...replayLayerRef.current];
})()}
/>
{/* 정적 마커 클릭 Popup — 통합 리치 디자인 */}
{staticPickInfo && (
<StaticFacilityPopup pickInfo={staticPickInfo} onClose={() => setStaticPickInfo(null)} />

파일 보기

@ -0,0 +1,90 @@
/**
* [DEBUG]
* KoreaMap에서 import하면 .
* import, , .
*/
import { useState, useEffect } from 'react';
import { Marker, Popup } from 'react-map-gl/maplibre';
import type { MapRef } from 'react-map-gl/maplibre';
interface CoordPoint {
lat: number;
lng: number;
id: number;
}
function toDMS(dd: number, axis: 'lat' | 'lng'): string {
const dir = axis === 'lat' ? (dd >= 0 ? 'N' : 'S') : (dd >= 0 ? 'E' : 'W');
const abs = Math.abs(dd);
const d = Math.floor(abs);
const mFull = (abs - d) * 60;
const m = Math.floor(mFull);
const s = ((mFull - m) * 60).toFixed(2);
return `${d}°${String(m).padStart(2, '0')}${String(s).padStart(5, '0')}${dir}`;
}
interface Props {
mapRef: React.RefObject<MapRef | null>;
}
/**
* .
* mapRef를 click /.
* KoreaMap의 .
*/
export default function DevCoordDebug({ mapRef }: Props) {
const [points, setPoints] = useState<CoordPoint[]>([]);
useEffect(() => {
const map = mapRef.current?.getMap();
if (!map) return;
const handler = (e: maplibregl.MapMouseEvent) => {
if (e.originalEvent.ctrlKey || e.originalEvent.metaKey) {
e.originalEvent.preventDefault();
setPoints(prev => [...prev, { lat: e.lngLat.lat, lng: e.lngLat.lng, id: Date.now() }]);
}
};
map.on('click', handler);
return () => { map.off('click', handler); };
}, [mapRef]);
return (
<>
{points.map(cp => (
<div key={cp.id}>
<Marker longitude={cp.lng} latitude={cp.lat}>
<div style={{
width: 12, height: 12, borderRadius: '50%',
background: '#f43f5e', border: '2px solid #fff',
boxShadow: '0 0 6px rgba(244,63,94,0.8)',
}} />
</Marker>
<Popup
longitude={cp.lng}
latitude={cp.lat}
onClose={() => setPoints(prev => prev.filter(p => p.id !== cp.id))}
closeButton={true}
closeOnClick={false}
anchor="bottom"
offset={[0, -10]}
style={{ zIndex: 50 }}
>
<div style={{ fontFamily: 'monospace', fontSize: 11, lineHeight: 1.8, padding: '2px 4px', color: '#fff' }}>
<div style={{ fontWeight: 700, marginBottom: 4, borderBottom: '1px solid rgba(255,255,255,0.3)', paddingBottom: 2, color: '#93c5fd' }}>
WGS84 (EPSG:4326)
</div>
<div><b>DD</b></div>
<div style={{ paddingLeft: 8 }}>{cp.lat.toFixed(6)}°N</div>
<div style={{ paddingLeft: 8 }}>{cp.lng.toFixed(6)}°E</div>
<div style={{ marginTop: 2 }}><b>DMS</b></div>
<div style={{ paddingLeft: 8 }}>{toDMS(cp.lat, 'lat')}</div>
<div style={{ paddingLeft: 8 }}>{toDMS(cp.lng, 'lng')}</div>
</div>
</Popup>
</div>
))}
</>
);
}

파일 보기

@ -0,0 +1,24 @@
/**
* [DEBUG] export
*
* / .
* KoreaMap에서는 lazy import.
* .
*/
import type { MapRef } from 'react-map-gl/maplibre';
import DevCoordDebug from './DevCoordDebug';
interface Props {
mapRef: React.RefObject<MapRef | null>;
}
export default function DebugTools({ mapRef }: Props) {
return (
<>
<DevCoordDebug mapRef={mapRef} />
{/* 디버그 도구 추가 시 여기에 한 줄 추가 */}
{/* <DevZoneOverlay mapRef={mapRef} /> */}
{/* <DevPerformance mapRef={mapRef} /> */}
</>
);
}

파일 보기

@ -0,0 +1,116 @@
import { FONT_MONO } from '../../styles/fonts';
// ── 모델 순서/색상/설명 ──
export const MODEL_ORDER = ['identity', 'default', 'aggressive', 'conservative', 'proximity-heavy', 'visit-pattern'] as const;
export const MODEL_COLORS: Record<string, string> = {
'identity': '#f97316',
'default': '#3b82f6',
'aggressive': '#22c55e',
'conservative': '#a855f7',
'proximity-heavy': '#06b6d4',
'visit-pattern': '#f43f5e',
};
export const MODEL_DESC: Record<string, { summary: string; details: string[] }> = {
'identity': {
summary: '이름 패턴매칭 — 동일 모선명 기반 어구 그룹',
details: [
'패턴: NAME_인덱스 (_ 필수, 공백만은 선박)',
'거리제한: ~10NM 이내 연결 클러스터링',
'모선연결: 어구와 ~20NM 이내 시 포함',
],
},
'default': {
summary: '기본 모델 — 균형 가중치',
details: [
'어구-선박: 근접도 45% · 방문 35% · 활동동기화 20%',
'선박-선박: DTW 30% · SOG 20% · COG 25% · 근접비 25%',
'EMA: α 0.30→0.08 · 추적시작 50% · 폴리곤 70%',
'감쇠: 정상 0.015/5분 · 장기(6h+) 0.08/5분',
'근접판정: 5NM · 후보반경: 그룹×3배',
],
},
'aggressive': {
summary: '공격적 추적 — 빠른 상승, 약한 감쇠',
details: [
'어구-선박: 근접도 55% · 방문 25% · 활동동기화 20%',
'EMA: α 0.40→0.10 · 추적시작 40% · 폴리곤 60%',
'감쇠: 정상 0.010/5분 · 장기(8h+) 0.06/5분',
'근접판정: 7NM · 후보반경: 그룹×4배',
'야간보너스: ×1.5 · shadow: 체류+0.15 복귀+0.20',
],
},
'conservative': {
summary: '보수적 추적 — 높은 임계값, 강한 감쇠',
details: [
'어구-선박: 근접도 40% · 방문 40% · 활동동기화 20%',
'EMA: α 0.20→0.05 · 추적시작 60% · 폴리곤 80%',
'감쇠: 정상 0.020/5분 · 장기(4h+) 0.10/5분',
'근접판정: 4NM · 후보반경: 그룹×2.5배',
'야간보너스: ×1.2 · shadow: 체류+0.08 복귀+0.12',
],
},
'proximity-heavy': {
summary: '근접 중심 — 거리 기반 판단 우선',
details: [
'어구-선박: 근접도 70% · 방문 20% · 활동동기화 10%',
'선박-선박: 근접비 50% · DTW 20% · SOG 15% · COG 15%',
'EMA: α 0.30→0.08 · 추적시작 50% · 폴리곤 70%',
'근접판정: 5NM · 후보반경: 그룹×3배',
'shadow: 체류+0.12 복귀+0.18',
],
},
'visit-pattern': {
summary: '방문 패턴 — 반복 접근 추적',
details: [
'어구-선박: 근접도 25% · 방문 55% · 활동동기화 20%',
'EMA: α 0.30→0.08 · 추적시작 50% · 폴리곤 70%',
'근접판정: 6NM · 후보반경: 그룹×3.5배',
'야간보너스: ×1.4',
],
},
};
// ── 패널 스타일 상수 ──
export const panelStyle: React.CSSProperties = {
position: 'absolute',
bottom: 60,
left: 10,
zIndex: 10,
minWidth: 220,
maxWidth: 300,
backgroundColor: 'rgba(12, 24, 37, 0.92)',
border: '1px solid rgba(99, 179, 237, 0.25)',
borderRadius: 8,
color: '#e2e8f0',
fontFamily: FONT_MONO,
fontSize: 11,
boxShadow: '0 4px 16px rgba(0, 0, 0, 0.5)',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
pointerEvents: 'auto',
maxHeight: 'min(45vh, 400px)',
};
export const headerStyle: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '6px 10px',
borderBottom: 'none',
cursor: 'default',
userSelect: 'none',
flexShrink: 0,
};
export const toggleButtonStyle: React.CSSProperties = {
background: 'none',
border: 'none',
color: '#94a3b8',
cursor: 'pointer',
fontSize: 10,
padding: '0 2px',
lineHeight: 1,
};

파일 보기

@ -0,0 +1,58 @@
import type { Ship, VesselAnalysisDto } from '../../types';
import type { MemberInfo } from '../../services/vesselAnalysis';
// ── 히스토리 스냅샷 + 보간 플래그 ──
export type HistoryFrame = GroupPolygonDto & { _interp?: boolean; _longGap?: boolean };
// ── 외부 노출 타입 (KoreaMap에서 import) ──
export interface SelectedGearGroupData {
parent: Ship | null;
gears: Ship[];
groupName: string;
}
export interface SelectedFleetData {
clusterId: number;
ships: Ship[];
companyName: string;
}
// ── 내부 공유 타입 ──
export interface HoverTooltipState {
lng: number;
lat: number;
type: 'fleet' | 'gear';
id: number | string;
}
export interface PickerCandidate {
name: string;
count: number;
inZone: boolean;
isFleet: boolean;
clusterId?: number;
}
export interface GearPickerPopupState {
lng: number;
lat: number;
candidates: PickerCandidate[];
}
export interface FleetListItem {
id: number;
mmsiList: string[];
label: string;
memberCount: number;
areaSqNm: number;
color: string;
members: MemberInfo[];
}
// ── 상수 ──
export const GEAR_BUFFER_DEG = 0.01;
export const CIRCLE_SEGMENTS = 16;
export const TIMELINE_DURATION_MS = 12 * 60 * 60_000; // 12시간
export const PLAYBACK_CYCLE_SEC = 30; // 30초에 12시간 전체 재생
export const TICK_MS = 50; // 50ms 간격 업데이트
export const EMPTY_ANALYSIS = new globalThis.Map<string, VesselAnalysisDto>();

파일 보기

@ -0,0 +1,204 @@
import type { GeoJSON } from 'geojson';
import type { GroupPolygonDto } from '../../services/vesselAnalysis';
import type { HistoryFrame } from './fleetClusterTypes';
import { GEAR_BUFFER_DEG, CIRCLE_SEGMENTS } from './fleetClusterTypes';
/** 트랙 포인트에서 주어진 시각의 보간 위치 반환 */
export function interpolateTrackPosition(
track: { ts: number; lat: number; lon: number; cog: number }[],
timeMs: number,
): { lat: number; lon: number; cog: number } | null {
if (track.length === 0) return null;
if (track.length === 1) return { lat: track[0].lat, lon: track[0].lon, cog: track[0].cog };
if (timeMs <= track[0].ts) return { lat: track[0].lat, lon: track[0].lon, cog: track[0].cog };
if (timeMs >= track[track.length - 1].ts) {
const last = track[track.length - 1];
return { lat: last.lat, lon: last.lon, cog: last.cog };
}
// Binary search for surrounding points
let lo = 0, hi = track.length - 1;
while (lo < hi - 1) {
const mid = (lo + hi) >> 1;
if (track[mid].ts <= timeMs) lo = mid; else hi = mid;
}
const a = track[lo], b = track[hi];
const t = (timeMs - a.ts) / (b.ts - a.ts);
return {
lat: a.lat + t * (b.lat - a.lat),
lon: a.lon + t * (b.lon - a.lon),
cog: b.cog,
};
}
/**
* Convex hull + buffer (Python polygon_builder.py )
* - 1점: 원형 (GEAR_BUFFER_DEG=0.01 ~ 1.1km)
* - 2점:
* - 3+: convex hull +
*/
export function buildInterpPolygon(points: [number, number][]): GeoJSON.Polygon | null {
if (points.length === 0) return null;
if (points.length === 1) {
const [cx, cy] = points[0];
const ring: [number, number][] = [];
for (let i = 0; i <= CIRCLE_SEGMENTS; i++) {
const angle = (2 * Math.PI * i) / CIRCLE_SEGMENTS;
ring.push([cx + GEAR_BUFFER_DEG * Math.cos(angle), cy + GEAR_BUFFER_DEG * Math.sin(angle)]);
}
return { type: 'Polygon', coordinates: [ring] };
}
if (points.length === 2) {
const [p1, p2] = points;
const dx = p2[0] - p1[0];
const dy = p2[1] - p1[1];
const len = Math.sqrt(dx * dx + dy * dy) || 1e-10;
const nx = (-dy / len) * GEAR_BUFFER_DEG;
const ny = (dx / len) * GEAR_BUFFER_DEG;
const ring: [number, number][] = [];
const half = CIRCLE_SEGMENTS / 2;
ring.push([p1[0] + nx, p1[1] + ny]);
ring.push([p2[0] + nx, p2[1] + ny]);
const a2 = Math.atan2(ny, nx);
for (let i = 0; i <= half; i++) {
const angle = a2 - Math.PI * i / half;
ring.push([p2[0] + GEAR_BUFFER_DEG * Math.cos(angle), p2[1] + GEAR_BUFFER_DEG * Math.sin(angle)]);
}
ring.push([p1[0] - nx, p1[1] - ny]);
const a1 = Math.atan2(-ny, -nx);
for (let i = 0; i <= half; i++) {
const angle = a1 - Math.PI * i / half;
ring.push([p1[0] + GEAR_BUFFER_DEG * Math.cos(angle), p1[1] + GEAR_BUFFER_DEG * Math.sin(angle)]);
}
ring.push(ring[0]);
return { type: 'Polygon', coordinates: [ring] };
}
const hull = convexHull(points);
return bufferPolygon(hull, GEAR_BUFFER_DEG);
}
/** 단순 convex hull (Graham scan) */
export function convexHull(points: [number, number][]): [number, number][] {
const pts = [...points].sort((a, b) => a[0] - b[0] || a[1] - b[1]);
if (pts.length <= 2) return pts;
const cross = (o: [number, number], a: [number, number], b: [number, number]) =>
(a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
const lower: [number, number][] = [];
for (const p of pts) {
while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], p) <= 0) lower.pop();
lower.push(p);
}
const upper: [number, number][] = [];
for (let i = pts.length - 1; i >= 0; i--) {
while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], pts[i]) <= 0) upper.pop();
upper.push(pts[i]);
}
lower.pop();
upper.pop();
return lower.concat(upper);
}
/** 폴리곤 외곽에 buffer 적용 (각 변의 오프셋 + 꼭짓점 라운드) */
export function bufferPolygon(hull: [number, number][], buf: number): GeoJSON.Polygon {
const ring: [number, number][] = [];
const n = hull.length;
for (let i = 0; i < n; i++) {
const p = hull[i];
const prev = hull[(i - 1 + n) % n];
const next = hull[(i + 1) % n];
const a1 = Math.atan2(p[1] - prev[1], p[0] - prev[0]) - Math.PI / 2;
const a2 = Math.atan2(next[1] - p[1], next[0] - p[0]) - Math.PI / 2;
const startA = a1;
let endA = a2;
if (endA < startA) endA += 2 * Math.PI;
const steps = Math.max(2, Math.round((endA - startA) / (Math.PI / 8)));
for (let s = 0; s <= steps; s++) {
const a = startA + (endA - startA) * s / steps;
ring.push([p[0] + buf * Math.cos(a), p[1] + buf * Math.sin(a)]);
}
}
ring.push(ring[0]);
return { type: 'Polygon', coordinates: [ring] };
}
/**
* gap .
* - gap <= 30분: 5분 ( , )
* - gap > 30분: 30분 +
*/
export function fillGapFrames(snapshots: GroupPolygonDto[]): HistoryFrame[] {
if (snapshots.length < 2) return snapshots;
const STEP_SHORT_MS = 300_000;
const STEP_LONG_MS = 1_800_000;
const THRESHOLD_MS = 1_800_000;
const result: GroupPolygonDto[] = [];
for (let i = 0; i < snapshots.length; i++) {
result.push(snapshots[i]);
if (i >= snapshots.length - 1) continue;
const prev = snapshots[i];
const next = snapshots[i + 1];
const t0 = new Date(prev.snapshotTime).getTime();
const t1 = new Date(next.snapshotTime).getTime();
const gap = t1 - t0;
if (gap <= STEP_SHORT_MS) continue;
const nextMap = new Map(next.members.map(m => [m.mmsi, m]));
const common = prev.members.filter(m => nextMap.has(m.mmsi));
if (common.length === 0) continue;
if (gap <= THRESHOLD_MS) {
for (let t = t0 + STEP_SHORT_MS; t < t1; t += STEP_SHORT_MS) {
const ratio = (t - t0) / gap;
const cLon = prev.centerLon + (next.centerLon - prev.centerLon) * ratio;
const cLat = prev.centerLat + (next.centerLat - prev.centerLat) * ratio;
result.push({
...prev,
snapshotTime: new Date(t).toISOString(),
centerLon: cLon,
centerLat: cLat,
_interp: true,
});
}
} else {
for (let t = t0 + STEP_LONG_MS; t < t1; t += STEP_LONG_MS) {
const ratio = (t - t0) / gap;
const positions: [number, number][] = [];
const members: typeof prev.members = [];
for (const pm of common) {
const nm = nextMap.get(pm.mmsi)!;
const lon = pm.lon + (nm.lon - pm.lon) * ratio;
const lat = pm.lat + (nm.lat - pm.lat) * ratio;
const dLon = nm.lon - pm.lon;
const dLat = nm.lat - pm.lat;
const cog = (dLon === 0 && dLat === 0) ? pm.cog : (Math.atan2(dLon, dLat) * 180 / Math.PI + 360) % 360;
members.push({ mmsi: pm.mmsi, name: pm.name, lon, lat, sog: pm.sog, cog, role: pm.role, isParent: pm.isParent });
positions.push([lon, lat]);
}
const cLon = positions.reduce((s, p) => s + p[0], 0) / positions.length;
const cLat = positions.reduce((s, p) => s + p[1], 0) / positions.length;
const polygon = buildInterpPolygon(positions);
result.push({
...prev,
snapshotTime: new Date(t).toISOString(),
polygon,
centerLon: cLon,
centerLat: cLat,
memberCount: members.length,
members,
_interp: true,
_longGap: true,
});
}
}
}
return result;
}

파일 보기

@ -0,0 +1,389 @@
import { useMemo } from 'react';
import type { GeoJSON } from 'geojson';
import type { Ship, VesselAnalysisDto } from '../../types';
import type { GearCorrelationItem, CorrelationVesselTrack } from '../../services/vesselAnalysis';
import type { UseGroupPolygonsResult } from '../../hooks/useGroupPolygons';
import type { FleetListItem } from './fleetClusterTypes';
import { buildInterpPolygon } from './fleetClusterUtils';
import { MODEL_ORDER, MODEL_COLORS } from './fleetClusterConstants';
export interface UseFleetClusterGeoJsonParams {
ships: Ship[];
shipMap: Map<string, Ship>;
groupPolygons: UseGroupPolygonsResult | undefined;
analysisMap: Map<string, VesselAnalysisDto>;
hoveredFleetId: number | null;
selectedGearGroup: string | null;
pickerHoveredGroup: string | null;
historyActive: boolean;
correlationData: GearCorrelationItem[];
correlationTracks: CorrelationVesselTrack[];
enabledModels: Set<string>;
enabledVessels: Set<string>;
hoveredMmsi: string | null;
}
export interface FleetClusterGeoJsonResult {
// static/base GeoJSON
fleetPolygonGeoJSON: GeoJSON;
lineGeoJSON: GeoJSON;
hoveredGeoJSON: GeoJSON;
gearClusterGeoJson: GeoJSON;
memberMarkersGeoJson: GeoJSON;
pickerHighlightGeoJson: GeoJSON;
selectedGearHighlightGeoJson: GeoJSON.FeatureCollection | null;
// correlation GeoJSON
correlationVesselGeoJson: GeoJSON;
correlationTrailGeoJson: GeoJSON;
modelBadgesGeoJson: GeoJSON;
hoverHighlightGeoJson: GeoJSON;
hoverHighlightTrailGeoJson: GeoJSON;
// operational polygons (per model)
operationalPolygons: { modelName: string; color: string; geojson: GeoJSON.FeatureCollection }[];
// derived values
fleetList: FleetListItem[];
correlationByModel: Map<string, GearCorrelationItem[]>;
availableModels: { name: string; count: number; isDefault: boolean }[];
}
const EMPTY_FC: GeoJSON = { type: 'FeatureCollection', features: [] };
export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): FleetClusterGeoJsonResult {
const {
ships,
shipMap,
groupPolygons,
hoveredFleetId,
selectedGearGroup,
pickerHoveredGroup,
historyActive,
correlationData,
correlationTracks,
enabledModels,
enabledVessels,
hoveredMmsi,
} = params;
// ── 선단 폴리곤 GeoJSON (서버 제공) ──
const fleetPolygonGeoJSON = useMemo((): GeoJSON => {
const features: GeoJSON.Feature[] = [];
if (!groupPolygons) return { type: 'FeatureCollection', features };
for (const g of groupPolygons.fleetGroups) {
if (!g.polygon) continue;
features.push({
type: 'Feature',
properties: { clusterId: Number(g.groupKey), color: g.color },
geometry: g.polygon,
});
}
return { type: 'FeatureCollection', features };
}, [groupPolygons]);
// 2척 선단 라인은 서버에서 Shapely buffer로 이미 Polygon 처리되므로 빈 컬렉션
const lineGeoJSON = useMemo((): GeoJSON => ({
type: 'FeatureCollection', features: [],
}), []);
// 호버 하이라이트용 단일 폴리곤
const hoveredGeoJSON = useMemo((): GeoJSON => {
if (hoveredFleetId === null || !groupPolygons) return EMPTY_FC;
const g = groupPolygons.fleetGroups.find(f => Number(f.groupKey) === hoveredFleetId);
if (!g?.polygon) return EMPTY_FC;
return {
type: 'FeatureCollection',
features: [{
type: 'Feature',
properties: { clusterId: hoveredFleetId, color: g.color },
geometry: g.polygon,
}],
};
}, [hoveredFleetId, groupPolygons]);
// 모델별 연관성 데이터 그룹핑
const correlationByModel = useMemo(() => {
const map = new Map<string, GearCorrelationItem[]>();
for (const c of correlationData) {
const list = map.get(c.modelName) ?? [];
list.push(c);
map.set(c.modelName, list);
}
return map;
}, [correlationData]);
// 사용 가능한 모델 목록 (데이터가 있는 모델만)
const availableModels = useMemo(() => {
const models: { name: string; count: number; isDefault: boolean }[] = [];
for (const [name, items] of correlationByModel) {
models.push({ name, count: items.length, isDefault: items[0]?.isDefault ?? false });
}
models.sort((a, b) => (a.isDefault ? -1 : 0) - (b.isDefault ? -1 : 0));
return models;
}, [correlationByModel]);
// 오퍼레이셔널 폴리곤 (비재생 정적 연산)
const operationalPolygons = useMemo(() => {
if (!selectedGearGroup || !groupPolygons) return [];
const allGroups = [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups];
const group = allGroups.find(g => g.groupKey === selectedGearGroup);
if (!group) return [];
const basePts: [number, number][] = group.members.map(m => [m.lon, m.lat]);
const result: { modelName: string; color: string; geojson: GeoJSON.FeatureCollection }[] = [];
for (const [mn, items] of correlationByModel) {
if (!enabledModels.has(mn)) continue;
const extra: [number, number][] = [];
for (const c of items) {
if (c.score < 0.7) continue;
const s = ships.find(x => x.mmsi === c.targetMmsi);
if (s) extra.push([s.lng, s.lat]);
}
if (extra.length === 0) continue;
const polygon = buildInterpPolygon([...basePts, ...extra]);
if (!polygon) continue;
const color = MODEL_COLORS[mn] ?? '#94a3b8';
result.push({
modelName: mn,
color,
geojson: {
type: 'FeatureCollection',
features: [{ type: 'Feature', properties: { modelName: mn, color }, geometry: polygon }],
},
});
}
return result;
}, [selectedGearGroup, groupPolygons, correlationByModel, enabledModels, ships]);
// 어구 클러스터 GeoJSON (서버 제공)
const gearClusterGeoJson = useMemo((): GeoJSON => {
const features: GeoJSON.Feature[] = [];
if (!groupPolygons) return { type: 'FeatureCollection', features };
for (const g of [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]) {
if (!g.polygon) continue;
features.push({
type: 'Feature',
properties: {
name: g.groupKey,
gearCount: g.memberCount,
inZone: g.groupType === 'GEAR_IN_ZONE' ? 1 : 0,
},
geometry: g.polygon,
});
}
return { type: 'FeatureCollection', features };
}, [groupPolygons]);
// 가상 선박 마커 GeoJSON (API members + shipMap heading 보정)
const memberMarkersGeoJson = useMemo((): GeoJSON => {
const features: GeoJSON.Feature[] = [];
if (!groupPolygons) return { type: 'FeatureCollection', features };
const addMember = (
m: { mmsi: string; name: string; lat: number; lon: number; cog: number; isParent: boolean; role: string },
groupKey: string,
groupType: string,
color: string,
) => {
const realShip = shipMap.get(m.mmsi);
const heading = realShip?.heading ?? m.cog ?? 0;
const lat = realShip?.lat ?? m.lat;
const lon = realShip?.lng ?? m.lon;
features.push({
type: 'Feature',
properties: {
mmsi: m.mmsi,
name: m.name,
groupKey,
groupType,
role: m.role,
isParent: m.isParent ? 1 : 0,
isGear: (groupType !== 'FLEET' && !m.isParent) ? 1 : 0,
color,
cog: heading,
baseSize: (groupType !== 'FLEET' && !m.isParent) ? 0.11 : m.isParent ? 0.18 : 0.14,
},
geometry: { type: 'Point', coordinates: [lon, lat] },
});
};
for (const g of groupPolygons.fleetGroups) {
for (const m of g.members) addMember(m, g.groupKey, 'FLEET', g.color);
}
for (const g of [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]) {
const color = g.groupType === 'GEAR_IN_ZONE' ? '#dc2626' : '#f97316';
for (const m of g.members) addMember(m, g.groupKey, g.groupType, color);
}
return { type: 'FeatureCollection', features };
}, [groupPolygons, shipMap]);
// picker 호버 하이라이트 (선단 + 어구 통합)
const pickerHighlightGeoJson = useMemo((): GeoJSON => {
if (!pickerHoveredGroup || !groupPolygons) return EMPTY_FC;
const fleet = groupPolygons.fleetGroups.find(x => String(x.groupKey) === pickerHoveredGroup);
if (fleet?.polygon) return { type: 'FeatureCollection', features: [{ type: 'Feature', properties: {}, geometry: fleet.polygon }] };
const all = [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups];
const g = all.find(x => x.groupKey === pickerHoveredGroup);
if (!g?.polygon) return EMPTY_FC;
return { type: 'FeatureCollection', features: [{ type: 'Feature', properties: {}, geometry: g.polygon }] };
}, [pickerHoveredGroup, groupPolygons]);
// 선택된 어구 그룹 하이라이트 폴리곤
const selectedGearHighlightGeoJson = useMemo((): GeoJSON.FeatureCollection | null => {
if (!selectedGearGroup || !enabledModels.has('identity') || historyActive) return null;
const allGroups = groupPolygons
? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]
: [];
const group = allGroups.find(g => g.groupKey === selectedGearGroup);
if (!group?.polygon) return null;
return {
type: 'FeatureCollection',
features: [{
type: 'Feature',
properties: {},
geometry: group.polygon,
}],
};
}, [selectedGearGroup, enabledModels, historyActive, groupPolygons]);
// ── 연관 대상 마커 (ships[] fallback) ──
const correlationVesselGeoJson = useMemo((): GeoJSON => {
if (!selectedGearGroup || correlationByModel.size === 0) return EMPTY_FC;
const features: GeoJSON.Feature[] = [];
const seen = new Set<string>();
for (const [mn, items] of correlationByModel) {
if (!enabledModels.has(mn)) continue;
const color = MODEL_COLORS[mn] ?? '#94a3b8';
for (const c of items) {
if (seen.has(c.targetMmsi)) continue;
const s = ships.find(x => x.mmsi === c.targetMmsi);
if (!s) continue;
seen.add(c.targetMmsi);
features.push({
type: 'Feature',
properties: {
mmsi: c.targetMmsi,
name: c.targetName || c.targetMmsi,
score: c.score,
cog: s.course ?? 0,
color,
isVessel: c.targetType === 'VESSEL' ? 1 : 0,
},
geometry: { type: 'Point', coordinates: [s.lng, s.lat] },
});
}
}
return { type: 'FeatureCollection', features };
}, [selectedGearGroup, correlationByModel, enabledModels, ships]);
// 연관 대상 트레일 (전체 항적)
const correlationTrailGeoJson = useMemo((): GeoJSON => {
if (correlationTracks.length === 0) return EMPTY_FC;
const features: GeoJSON.Feature[] = [];
const vesselColor = new Map<string, string>();
for (const [mn, items] of correlationByModel) {
if (!enabledModels.has(mn)) continue;
for (const c of items) {
if (!vesselColor.has(c.targetMmsi)) vesselColor.set(c.targetMmsi, MODEL_COLORS[mn] ?? '#60a5fa');
}
}
for (const vt of correlationTracks) {
if (!enabledVessels.has(vt.mmsi)) continue;
const color = vesselColor.get(vt.mmsi) ?? '#60a5fa';
const coords: [number, number][] = vt.track.map(pt => [pt.lon, pt.lat]);
if (coords.length >= 2) {
features.push({ type: 'Feature', properties: { mmsi: vt.mmsi, color }, geometry: { type: 'LineString', coordinates: coords } });
}
}
return { type: 'FeatureCollection', features };
}, [correlationTracks, enabledVessels, correlationByModel, enabledModels]);
// 모델 배지 GeoJSON (groupPolygons 기반)
const modelBadgesGeoJson = useMemo((): GeoJSON => {
if (!selectedGearGroup) return EMPTY_FC;
const targets = new Map<string, { lon: number; lat: number; models: Set<string> }>();
if (enabledModels.has('identity') && groupPolygons) {
const all = [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups];
const members = all.find(g => g.groupKey === selectedGearGroup)?.members ?? [];
for (const m of members) {
const e = targets.get(m.mmsi) ?? { lon: m.lon, lat: m.lat, models: new Set<string>() };
e.lon = m.lon; e.lat = m.lat; e.models.add('identity');
targets.set(m.mmsi, e);
}
}
for (const [mn, items] of correlationByModel) {
if (!enabledModels.has(mn)) continue;
for (const c of items) {
if (c.score < 0.3) continue;
const s = ships.find(x => x.mmsi === c.targetMmsi);
if (!s) continue;
const e = targets.get(c.targetMmsi) ?? { lon: s.lng, lat: s.lat, models: new Set<string>() };
e.lon = s.lng; e.lat = s.lat; e.models.add(mn);
targets.set(c.targetMmsi, e);
}
}
const features: GeoJSON.Feature[] = [];
for (const [mmsi, t] of targets) {
if (t.models.size === 0) continue;
const props: Record<string, unknown> = { mmsi };
for (let i = 0; i < MODEL_ORDER.length; i++) props[`m${i}`] = t.models.has(MODEL_ORDER[i]) ? 1 : 0;
features.push({ type: 'Feature', properties: props, geometry: { type: 'Point', coordinates: [t.lon, t.lat] } });
}
return { type: 'FeatureCollection', features };
}, [selectedGearGroup, enabledModels, groupPolygons, correlationByModel, ships]);
// 호버 하이라이트 — 대상 위치
const hoverHighlightGeoJson = useMemo((): GeoJSON => {
if (!hoveredMmsi || !selectedGearGroup) return EMPTY_FC;
if (groupPolygons) {
const all = [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups];
const m = all.find(g => g.groupKey === selectedGearGroup)?.members.find(x => x.mmsi === hoveredMmsi);
if (m) return { type: 'FeatureCollection', features: [{ type: 'Feature', properties: {}, geometry: { type: 'Point', coordinates: [m.lon, m.lat] } }] };
}
const s = ships.find(x => x.mmsi === hoveredMmsi);
if (s) return { type: 'FeatureCollection', features: [{ type: 'Feature', properties: {}, geometry: { type: 'Point', coordinates: [s.lng, s.lat] } }] };
return EMPTY_FC;
}, [hoveredMmsi, selectedGearGroup, groupPolygons, ships]);
// 호버 하이라이트 — 대상 항적
const hoverHighlightTrailGeoJson = useMemo((): GeoJSON => {
if (!hoveredMmsi) return EMPTY_FC;
const vt = correlationTracks.find(v => v.mmsi === hoveredMmsi);
if (!vt) return EMPTY_FC;
const coords: [number, number][] = vt.track.map(pt => [pt.lon, pt.lat]);
if (coords.length < 2) return EMPTY_FC;
return { type: 'FeatureCollection', features: [{ type: 'Feature', properties: {}, geometry: { type: 'LineString', coordinates: coords } }] };
}, [hoveredMmsi, correlationTracks]);
// 선단 목록 (멤버 수 내림차순)
const fleetList = useMemo((): FleetListItem[] => {
if (!groupPolygons) return [];
return groupPolygons.fleetGroups.map(g => ({
id: Number(g.groupKey),
mmsiList: g.members.map(m => m.mmsi),
label: g.groupLabel,
memberCount: g.memberCount,
areaSqNm: g.areaSqNm,
color: g.color,
members: g.members,
})).sort((a, b) => b.memberCount - a.memberCount);
}, [groupPolygons]);
return {
fleetPolygonGeoJSON,
lineGeoJSON,
hoveredGeoJSON,
gearClusterGeoJson,
memberMarkersGeoJson,
pickerHighlightGeoJson,
selectedGearHighlightGeoJson,
correlationVesselGeoJson,
correlationTrailGeoJson,
modelBadgesGeoJson,
hoverHighlightGeoJson,
hoverHighlightTrailGeoJson,
operationalPolygons,
fleetList,
correlationByModel,
availableModels,
};
}

파일 보기

@ -1,22 +1,26 @@
import type { MutableRefObject } from 'react';
import { useControl } from 'react-map-gl/maplibre';
import { MapboxOverlay } from '@deck.gl/mapbox';
import type { Layer } from '@deck.gl/core';
interface Props {
layers: Layer[];
overlayRef?: MutableRefObject<MapboxOverlay | null>;
}
/**
* MapLibre Map deck.gl GPU .
* interleaved 모드: MapLibre deck.gl z-order로 .
* overlayRef: 외부에서 imperative setProps .
*/
export function DeckGLOverlay({ layers }: Props) {
export function DeckGLOverlay({ layers, overlayRef }: Props) {
const overlay = useControl<MapboxOverlay>(
() => new MapboxOverlay({
interleaved: true,
getCursor: ({ isHovering }) => isHovering ? 'pointer' : '',
}),
);
if (overlayRef) overlayRef.current = overlay;
overlay.setProps({ layers });
return null;
}

파일 보기

@ -0,0 +1,698 @@
import { useEffect, useRef, useCallback } from 'react';
import type { Layer } from '@deck.gl/core';
import { TripsLayer } from '@deck.gl/geo-layers';
import { ScatterplotLayer, IconLayer, PathLayer, PolygonLayer, TextLayer } from '@deck.gl/layers';
import { useGearReplayStore } from '../stores/gearReplayStore';
import { findFrameAtTime, interpolateMemberPositions } from '../stores/gearReplayPreprocess';
import type { MemberPosition } from '../stores/gearReplayPreprocess';
import { MODEL_ORDER, MODEL_COLORS } from '../components/korea/fleetClusterConstants';
import { buildInterpPolygon } from '../components/korea/fleetClusterUtils';
import type { GearCorrelationItem } from '../services/vesselAnalysis';
import { SHIP_ICON_MAPPING } from '../utils/shipIconSvg';
// ── Constants ─────────────────────────────────────────────────────────────────
const TRAIL_LENGTH_MS = 3_600_000; // 1 hour trail
const RENDER_INTERVAL_MS = 100; // 10fps throttle during playback
// ── Helper ───────────────────────────────────────────────────────────────────
function hexToRgb(hex: string): [number, number, number] {
const h = hex.replace('#', '');
return [
parseInt(h.substring(0, 2), 16),
parseInt(h.substring(2, 4), 16),
parseInt(h.substring(4, 6), 16),
];
}
// ── Types ─────────────────────────────────────────────────────────────────────
interface CorrPosition {
mmsi: string;
name: string;
lon: number;
lat: number;
cog: number;
color: [number, number, number, number];
isVessel: boolean;
}
// ── Hook ──────────────────────────────────────────────────────────────────────
/**
* Gear group replay animation layers for deck.gl.
*
* Performance:
* - currentTime changes are subscribed via zustand.subscribe (NOT React selectors).
* React never re-renders during playback.
* - Layer objects are built imperatively and written to replayLayerRef.
* - The parent calls overlay.setProps() to push layers to WebGL.
*/
export function useGearReplayLayers(
replayLayerRef: React.MutableRefObject<Layer[]>,
requestRender: () => void,
shipsRef: React.MutableRefObject<Map<string, { lng: number; lat: number; course?: number }>>,
): void {
// ── React selectors (infrequent changes only) ────────────────────────────
const historyFrames = useGearReplayStore(s => s.historyFrames);
const memberTripsData = useGearReplayStore(s => s.memberTripsData);
const correlationTripsData = useGearReplayStore(s => s.correlationTripsData);
const centerTrailSegments = useGearReplayStore(s => s.centerTrailSegments);
const centerDotsPositions = useGearReplayStore(s => s.centerDotsPositions);
const enabledModels = useGearReplayStore(s => s.enabledModels);
const enabledVessels = useGearReplayStore(s => s.enabledVessels);
const hoveredMmsi = useGearReplayStore(s => s.hoveredMmsi);
const correlationByModel = useGearReplayStore(s => s.correlationByModel);
const modelCenterTrails = useGearReplayStore(s => s.modelCenterTrails);
const showTrails = useGearReplayStore(s => s.showTrails);
const showLabels = useGearReplayStore(s => s.showLabels);
// ── Refs ─────────────────────────────────────────────────────────────────
const cursorRef = useRef(0); // frame cursor for O(1) forward lookup
// ── renderFrame ──────────────────────────────────────────────────────────
// 디버그 로그 (첫 프레임에서만 출력)
const debugLoggedRef = useRef(false);
const renderFrame = useCallback(() => {
if (historyFrames.length === 0) {
replayLayerRef.current = [];
requestRender();
return;
}
const state = useGearReplayStore.getState();
const ct = state.currentTime;
const st = state.startTime;
const shouldLog = !debugLoggedRef.current;
if (shouldLog) debugLoggedRef.current = true;
// Find current frame
const { index: frameIdx, cursor } = findFrameAtTime(state.frameTimes, ct, cursorRef.current);
cursorRef.current = cursor;
const layers: Layer[] = [];
// ── 항상 표시: 센터 트레일 + 도트 ──────────────────────────────────
// Center trail segments (PathLayer) — 항상 ON
for (let i = 0; i < centerTrailSegments.length; i++) {
const seg = centerTrailSegments[i];
if (seg.path.length < 2) continue;
layers.push(new PathLayer({
id: `replay-center-trail-${i}`,
data: [{ path: seg.path }],
getPath: (d: { path: [number, number][] }) => d.path,
getColor: seg.isInterpolated
? [249, 115, 22, 200]
: [251, 191, 36, 180],
widthMinPixels: 2,
}));
}
// Center dots (real data only) — 항상 ON
if (centerDotsPositions.length > 0) {
layers.push(new ScatterplotLayer({
id: 'replay-center-dots',
data: centerDotsPositions,
getPosition: (d: [number, number]) => d,
getFillColor: [251, 191, 36, 150],
getRadius: 80,
radiusUnits: 'meters',
radiusMinPixels: 2.5,
}));
}
// ── Dynamic layers (depend on currentTime) ────────────────────────────
if (frameIdx < 0) {
// No valid frame at this time — only show static layers
replayLayerRef.current = layers;
requestRender();
return;
}
const frame = state.historyFrames[frameIdx];
const isStale = !!frame._longGap || !!frame._interp;
// Member positions (interpolated) — 항상 계산 (배지/오퍼레이셔널에서 사용)
const members = interpolateMemberPositions(state.historyFrames, frameIdx, ct);
const memberPts: [number, number][] = members.map(m => [m.lon, m.lat]);
// ── "항적" 토글: 전체 24h 경로 PathLayer (정적 배경) ─────────────
if (showTrails) {
// 멤버 전체 항적 (identity — 항상 ON)
if (memberTripsData.length > 0) {
for (const trip of memberTripsData) {
if (trip.path.length < 2) continue;
layers.push(new PathLayer({
id: `replay-member-path-${trip.id}`,
data: [{ path: trip.path }],
getPath: (d: { path: [number, number][] }) => d.path,
getColor: [180, 180, 180, 80], // 낮은 채도 — TripsLayer보다 연하게
widthMinPixels: 1,
}));
}
}
// 연관 선박 전체 항적 (correlation)
if (correlationTripsData.length > 0) {
const activeMmsis = new Set<string>();
for (const [mn, items] of correlationByModel) {
if (!enabledModels.has(mn)) continue;
for (const c of items as GearCorrelationItem[]) {
if (enabledVessels.has(c.targetMmsi)) activeMmsis.add(c.targetMmsi);
}
}
for (const trip of correlationTripsData) {
if (!activeMmsis.has(trip.id) || trip.path.length < 2) continue;
layers.push(new PathLayer({
id: `replay-corr-path-${trip.id}`,
data: [{ path: trip.path }],
getPath: (d: { path: [number, number][] }) => d.path,
getColor: [100, 140, 200, 60], // 연한 파랑
widthMinPixels: 1,
}));
}
}
}
// 1. Correlation TripsLayer (GPU animated, 항상 ON, 고채도)
if (correlationTripsData.length > 0) {
const activeMmsis = new Set<string>();
for (const [mn, items] of correlationByModel) {
if (!enabledModels.has(mn)) continue;
for (const c of items as GearCorrelationItem[]) {
if (enabledVessels.has(c.targetMmsi)) activeMmsis.add(c.targetMmsi);
}
}
const enabledTrips = correlationTripsData.filter(d => activeMmsis.has(d.id));
if (enabledTrips.length > 0) {
layers.push(new TripsLayer({
id: 'replay-corr-trails',
data: enabledTrips,
getPath: d => d.path,
getTimestamps: d => d.timestamps,
getColor: [100, 180, 255, 220], // 고채도 파랑 (항적보다 밝게)
widthMinPixels: 2.5,
fadeTrail: true,
trailLength: TRAIL_LENGTH_MS,
currentTime: ct - st,
}));
}
}
// (identity 레이어는 최하단 — 최상위 z-index로 이동됨)
// 3. Member position markers (IconLayer, identity — 항상 ON, placeholder)
if (members.length > 0) {
layers.push(new IconLayer<MemberPosition>({
id: 'replay-members',
data: members,
getPosition: d => [d.lon, d.lat],
getIcon: d => d.isGear ? SHIP_ICON_MAPPING['gear-diamond'] : SHIP_ICON_MAPPING['ship-triangle'],
getSize: d => d.isParent ? 24 : d.isGear ? 14 : 18,
getAngle: d => d.isGear ? 0 : -(d.cog || 0),
getColor: d => {
if (d.stale) return [100, 116, 139, 180];
if (d.isGear) return [168, 184, 200, 230];
return [251, 191, 36, 230];
},
sizeUnits: 'pixels',
billboard: false,
}));
// Member labels — showLabels 제어
if (showLabels) layers.push(new TextLayer<MemberPosition>({
id: 'replay-member-labels',
data: members,
getPosition: d => [d.lon, d.lat],
getText: d => d.name || d.mmsi,
getColor: d => d.stale
? [148, 163, 184, 200]
: d.isGear
? [226, 232, 240, 255]
: [251, 191, 36, 255],
getSize: 10,
getPixelOffset: [0, 14],
background: true,
getBackgroundColor: [0, 0, 0, 200],
backgroundPadding: [2, 1],
fontFamily: '"Fira Code Variable", monospace',
}));
}
// 6. Correlation vessel positions (트랙 보간 → 끝점 clamp → live fallback)
const corrPositions: CorrPosition[] = [];
const corrTrackMap = new Map(correlationTripsData.map(d => [d.id, d]));
const liveShips = shipsRef.current;
const relTime = ct - st;
for (const [mn, items] of correlationByModel) {
if (!enabledModels.has(mn)) continue;
const color = MODEL_COLORS[mn] ?? '#94a3b8';
const [r, g, b] = hexToRgb(color);
for (const c of items as GearCorrelationItem[]) {
if (!enabledVessels.has(c.targetMmsi)) continue; // OFF → 아이콘+트레일+폴리곤 모두 제외
if (corrPositions.some(p => p.mmsi === c.targetMmsi)) continue;
let lon: number | undefined;
let lat: number | undefined;
let cog = 0;
// 방법 1: 트랙 데이터 (보간 + 범위 밖은 끝점 clamp)
const tripData = corrTrackMap.get(c.targetMmsi);
if (tripData && tripData.path.length > 0) {
const ts = tripData.timestamps;
const path = tripData.path;
if (relTime <= ts[0]) {
// 트랙 시작 전 → 첫 점 사용
lon = path[0][0]; lat = path[0][1];
if (path.length > 1) {
const dx = path[1][0] - path[0][0];
const dy = path[1][1] - path[0][1];
cog = (dx === 0 && dy === 0) ? 0 : (Math.atan2(dx, dy) * 180 / Math.PI + 360) % 360;
}
} else if (relTime >= ts[ts.length - 1]) {
// 트랙 종료 후 → 마지막 점 사용
const last = path.length - 1;
lon = path[last][0]; lat = path[last][1];
if (last > 0) {
const dx = path[last][0] - path[last - 1][0];
const dy = path[last][1] - path[last - 1][1];
cog = (dx === 0 && dy === 0) ? 0 : (Math.atan2(dx, dy) * 180 / Math.PI + 360) % 360;
}
} else {
// 범위 내 → 보간
let lo = 0;
let hi = ts.length - 1;
while (lo < hi - 1) {
const mid = (lo + hi) >> 1;
if (ts[mid] <= relTime) lo = mid; else hi = mid;
}
const ratio = ts[hi] !== ts[lo] ? (relTime - ts[lo]) / (ts[hi] - ts[lo]) : 0;
lon = path[lo][0] + (path[hi][0] - path[lo][0]) * ratio;
lat = path[lo][1] + (path[hi][1] - path[lo][1]) * ratio;
const dx = path[hi][0] - path[lo][0];
const dy = path[hi][1] - path[lo][1];
cog = (dx === 0 && dy === 0) ? 0 : (Math.atan2(dx, dy) * 180 / Math.PI + 360) % 360;
}
}
// 방법 2: live 선박 위치 fallback
if (lon === undefined) {
const ship = liveShips.get(c.targetMmsi);
if (ship) {
lon = ship.lng;
lat = ship.lat;
cog = ship.course ?? 0;
}
}
if (lon === undefined || lat === undefined) continue;
corrPositions.push({
mmsi: c.targetMmsi,
name: c.targetName || c.targetMmsi,
lon,
lat,
cog,
color: [r, g, b, 230],
isVessel: c.targetType === 'VESSEL',
});
}
}
// 디버그: 첫 프레임에서 전체 상태 출력
if (shouldLog) {
const trackHit = corrPositions.filter(p => corrTrackMap.has(p.mmsi)).length;
const liveHit = corrPositions.length - trackHit;
const sampleTrip = memberTripsData[0];
console.log('[GearReplay] renderFrame:', {
historyFrames: state.historyFrames.length,
memberTripsData: memberTripsData.length,
corrTripsData: correlationTripsData.length,
corrTrackMap: corrTrackMap.size,
showTrails, showLabels,
relTime: Math.round(relTime / 60000) + 'min',
currentTime: Math.round((ct - st) / 60000) + 'min (rel)',
members: members.length,
corrPositions: corrPositions.length,
posSource: `track:${trackHit} live:${liveHit}`,
memberTrip0: sampleTrip ? { id: sampleTrip.id, pts: sampleTrip.path.length, tsRange: `${Math.round(sampleTrip.timestamps[0]/60000)}~${Math.round(sampleTrip.timestamps[sampleTrip.timestamps.length-1]/60000)}min` } : 'none',
});
// 모델별 상세
for (const [mn, items] of state.correlationByModel) {
const modEnabled = enabledModels.has(mn);
const modPositions = corrPositions.filter(p => {
return items.some(c => c.targetMmsi === p.mmsi);
}).length;
console.log(` [${mn}] ${modEnabled ? 'ON' : 'OFF'} ${items.length}건 → 위치확인 ${modPositions}`);
}
}
if (corrPositions.length > 0) {
layers.push(new IconLayer<CorrPosition>({
id: 'replay-corr-vessels',
data: corrPositions,
getPosition: d => [d.lon, d.lat],
getIcon: d => d.isVessel ? SHIP_ICON_MAPPING['ship-triangle'] : SHIP_ICON_MAPPING['gear-diamond'],
getSize: d => d.isVessel ? 18 : 12,
getAngle: d => d.isVessel ? -(d.cog || 0) : 0,
getColor: d => d.color,
sizeUnits: 'pixels',
billboard: false,
}));
if (showLabels) layers.push(new TextLayer<CorrPosition>({
id: 'replay-corr-labels',
data: corrPositions,
getPosition: d => [d.lon, d.lat],
getText: d => d.name,
getColor: d => d.color,
getSize: 8,
getPixelOffset: [0, 15],
background: true,
getBackgroundColor: [0, 0, 0, 200],
backgroundPadding: [2, 1],
}));
}
// 7. Hover highlight
if (hoveredMmsi) {
const hoveredMember = members.find(m => m.mmsi === hoveredMmsi);
const hoveredCorr = corrPositions.find(c => c.mmsi === hoveredMmsi);
const hoveredPos: [number, number] | null = hoveredMember
? [hoveredMember.lon, hoveredMember.lat]
: hoveredCorr
? [hoveredCorr.lon, hoveredCorr.lat]
: null;
if (hoveredPos) {
layers.push(new ScatterplotLayer({
id: 'replay-hover-glow',
data: [{ position: hoveredPos }],
getPosition: (d: { position: [number, number] }) => d.position,
getFillColor: [255, 255, 255, 60],
getRadius: 400,
radiusUnits: 'meters',
radiusMinPixels: 14,
}));
layers.push(new ScatterplotLayer({
id: 'replay-hover-ring',
data: [{ position: hoveredPos }],
getPosition: (d: { position: [number, number] }) => d.position,
getFillColor: [0, 0, 0, 0],
getRadius: 250,
radiusUnits: 'meters',
radiusMinPixels: 8,
stroked: true,
getLineColor: [255, 255, 255, 255],
lineWidthMinPixels: 2,
}));
}
// Hover trail (from correlation track)
const hoveredTrack = correlationTripsData.find(d => d.id === hoveredMmsi);
if (hoveredTrack) {
const relTime = ct - st;
let clipIdx = hoveredTrack.timestamps.length;
for (let i = 0; i < hoveredTrack.timestamps.length; i++) {
if (hoveredTrack.timestamps[i] > relTime) {
clipIdx = i;
break;
}
}
const clippedPath = hoveredTrack.path.slice(0, clipIdx);
if (clippedPath.length >= 2) {
layers.push(new PathLayer({
id: 'replay-hover-trail',
data: [{ path: clippedPath }],
getPath: (d: { path: [number, number][] }) => d.path,
getColor: [255, 255, 255, 180],
widthMinPixels: 3,
}));
}
}
}
// 8. Operational polygons (멤버 위치 + enabledVessels ON인 연관 선박으로 폴리곤 생성)
for (const [mn, items] of correlationByModel) {
if (!enabledModels.has(mn)) continue;
const color = MODEL_COLORS[mn] ?? '#94a3b8';
const [r, g, b] = hexToRgb(color);
const extraPts: [number, number][] = [];
for (const c of items as GearCorrelationItem[]) {
// enabledVessels로 개별 on/off 제어 (토글 대응)
if (!enabledVessels.has(c.targetMmsi)) continue;
const cp = corrPositions.find(p => p.mmsi === c.targetMmsi);
if (cp) extraPts.push([cp.lon, cp.lat]);
}
if (extraPts.length === 0) continue;
const basePts = memberPts; // identity 항상 ON
const opPolygon = buildInterpPolygon([...basePts, ...extraPts]);
if (!opPolygon) continue;
layers.push(new PolygonLayer({
id: `replay-op-${mn}`,
data: [{ polygon: opPolygon.coordinates }],
getPolygon: (d: { polygon: number[][][] }) => d.polygon,
getFillColor: [r, g, b, 30],
getLineColor: [r, g, b, 200],
getLineWidth: 2,
lineWidthMinPixels: 2,
filled: true,
stroked: true,
}));
}
// 8.5. Model center trails + current center point (모델별 폴리곤 중심 경로)
for (const trail of modelCenterTrails) {
if (!enabledModels.has(trail.modelName)) continue;
if (trail.path.length < 2) continue;
const color = MODEL_COLORS[trail.modelName] ?? '#94a3b8';
const [r, g, b] = hexToRgb(color);
// 중심 경로 (PathLayer, 연한 모델 색상)
layers.push(new PathLayer({
id: `replay-model-trail-${trail.modelName}`,
data: [{ path: trail.path }],
getPath: (d: { path: [number, number][] }) => d.path,
getColor: [r, g, b, 100],
widthMinPixels: 1.5,
}));
// 현재 중심점 (보간)
const ts = trail.timestamps;
if (ts.length > 0 && relTime >= ts[0] && relTime <= ts[ts.length - 1]) {
let lo = 0, hi = ts.length - 1;
while (lo < hi - 1) { const mid = (lo + hi) >> 1; if (ts[mid] <= relTime) lo = mid; else hi = mid; }
const ratio = ts[hi] !== ts[lo] ? (relTime - ts[lo]) / (ts[hi] - ts[lo]) : 0;
const cx = trail.path[lo][0] + (trail.path[hi][0] - trail.path[lo][0]) * ratio;
const cy = trail.path[lo][1] + (trail.path[hi][1] - trail.path[lo][1]) * ratio;
const centerData = [{ position: [cx, cy] as [number, number] }];
layers.push(new ScatterplotLayer({
id: `replay-model-center-${trail.modelName}`,
data: centerData,
getPosition: (d: { position: [number, number] }) => d.position,
getFillColor: [r, g, b, 255],
getRadius: 150,
radiusUnits: 'meters',
radiusMinPixels: 5,
stroked: true,
getLineColor: [255, 255, 255, 200],
lineWidthMinPixels: 1.5,
}));
if (showLabels) {
layers.push(new TextLayer({
id: `replay-model-center-label-${trail.modelName}`,
data: centerData,
getPosition: (d: { position: [number, number] }) => d.position,
getText: () => trail.modelName,
getColor: [r, g, b, 255],
getSize: 9,
getPixelOffset: [0, -12],
background: true,
getBackgroundColor: [0, 0, 0, 200],
backgroundPadding: [3, 1],
fontFamily: '"Fira Code Variable", monospace',
}));
}
}
}
// 9. Model badges (small colored dots next to each vessel/gear per model)
{
const badgeTargets = new Map<string, { lon: number; lat: number; models: Set<string> }>();
// Identity model: group members
// Identity — 항상 ON
for (const m of members) {
const e = badgeTargets.get(m.mmsi) ?? { lon: m.lon, lat: m.lat, models: new Set<string>() };
e.lon = m.lon; e.lat = m.lat; e.models.add('identity');
badgeTargets.set(m.mmsi, e);
}
// Correlation models
for (const [mn, items] of correlationByModel) {
if (!enabledModels.has(mn)) continue;
for (const c of items as GearCorrelationItem[]) {
if (c.score < 0.3) continue;
const cp = corrPositions.find(p => p.mmsi === c.targetMmsi);
if (!cp) continue;
const e = badgeTargets.get(c.targetMmsi) ?? { lon: cp.lon, lat: cp.lat, models: new Set<string>() };
e.lon = cp.lon; e.lat = cp.lat; e.models.add(mn);
badgeTargets.set(c.targetMmsi, e);
}
}
// Render one ScatterplotLayer per model (offset by index)
for (let mi = 0; mi < MODEL_ORDER.length; mi++) {
const model = MODEL_ORDER[mi];
if (!enabledModels.has(model)) continue;
const color = MODEL_COLORS[model] ?? '#94a3b8';
const [r, g, b] = hexToRgb(color);
const badgeData: { position: [number, number] }[] = [];
for (const [, t] of badgeTargets) {
if (t.models.has(model)) badgeData.push({ position: [t.lon, t.lat] });
}
if (badgeData.length === 0) continue;
layers.push(new ScatterplotLayer({
id: `replay-badge-${model}`,
data: badgeData,
getPosition: (d: { position: [number, number] }) => d.position,
getFillColor: [r, g, b, 255],
getRadius: 3,
radiusUnits: 'pixels',
stroked: true,
getLineColor: [0, 0, 0, 150],
lineWidthMinPixels: 0.5,
// Offset each model's badges to the right
getPixelOffset: [10 + mi * 7, -6] as [number, number],
}));
}
}
// ══ Identity 레이어 (최상위 z-index — 항상 ON, 다른 모델 위에 표시) ══
// 폴리곤
const identityPolygon = buildInterpPolygon(memberPts);
if (identityPolygon) {
layers.push(new PolygonLayer({
id: 'replay-identity-polygon',
data: [{ polygon: identityPolygon.coordinates }],
getPolygon: (d: { polygon: number[][][] }) => d.polygon,
getFillColor: isStale ? [148, 163, 184, 30] : [251, 191, 36, 40],
getLineColor: isStale ? [148, 163, 184, 100] : [251, 191, 36, 180],
getLineWidth: isStale ? 1 : 2,
lineWidthMinPixels: 1,
filled: true,
stroked: true,
}));
}
// TripsLayer (멤버 트레일)
if (memberTripsData.length > 0) {
layers.push(new TripsLayer({
id: 'replay-identity-trails',
data: memberTripsData,
getPath: d => d.path,
getTimestamps: d => d.timestamps,
getColor: [255, 200, 60, 220],
widthMinPixels: 2,
fadeTrail: true,
trailLength: TRAIL_LENGTH_MS,
currentTime: ct - st,
}));
}
// 센터 포인트
layers.push(new ScatterplotLayer({
id: 'replay-identity-center',
data: [{ position: [frame.centerLon, frame.centerLat] as [number, number] }],
getPosition: (d: { position: [number, number] }) => d.position,
getFillColor: isStale ? [249, 115, 22, 255] : [239, 68, 68, 255],
getRadius: 200,
radiusUnits: 'meters',
radiusMinPixels: 7,
stroked: true,
getLineColor: [255, 255, 255, 255],
lineWidthMinPixels: 2,
}));
replayLayerRef.current = layers;
requestRender();
}, [
historyFrames, memberTripsData, correlationTripsData,
centerTrailSegments, centerDotsPositions,
enabledModels, enabledVessels, hoveredMmsi, correlationByModel,
modelCenterTrails, showTrails, showLabels,
replayLayerRef, requestRender,
]);
// 데이터/필터 변경 시 디버그 로그 리셋
useEffect(() => {
debugLoggedRef.current = false;
if (correlationByModel.size > 0) {
console.log('[GearReplay] 데이터 갱신:', {
models: [...correlationByModel.keys()],
enabledModels: [...enabledModels],
corrTrips: correlationTripsData.length,
});
}
}, [correlationByModel, enabledModels, correlationTripsData]);
// ── zustand.subscribe effect (currentTime → renderFrame) ─────────────────
useEffect(() => {
if (historyFrames.length === 0) return;
// Initial render
renderFrame();
let lastRenderTime = 0;
let pendingRafId: number | null = null;
const unsub = useGearReplayStore.subscribe(
s => s.currentTime,
() => {
const isPlaying = useGearReplayStore.getState().isPlaying;
if (!isPlaying) {
// Seek/pause — immediate render for responsiveness
renderFrame();
return;
}
const now = performance.now();
if (now - lastRenderTime >= RENDER_INTERVAL_MS) {
lastRenderTime = now;
renderFrame();
} else if (!pendingRafId) {
pendingRafId = requestAnimationFrame(() => {
pendingRafId = null;
lastRenderTime = performance.now();
renderFrame();
});
}
},
);
return () => {
unsub();
if (pendingRafId) cancelAnimationFrame(pendingRafId);
};
}, [historyFrames, renderFrame]);
// ── Cleanup on unmount ────────────────────────────────────────────────────
useEffect(() => {
return () => {
replayLayerRef.current = [];
requestRender();
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- intentional: run only on unmount
}, []);
}

파일 보기

@ -95,6 +95,84 @@ export async function fetchGroupHistory(groupKey: string, hours = 24): Promise<G
return res.json();
}
/* ── Gear Correlation Types ──────────────────────────────────── */
export interface GearCorrelationItem {
targetMmsi: string;
targetType: 'VESSEL' | 'GEAR_BUOY' | 'GEAR_GROUP';
targetName: string;
score: number;
streak: number;
observations: number;
freezeState: string;
proximityRatio: number | null;
visitScore: number | null;
headingCoherence: number | null;
modelId: number;
modelName: string;
isDefault: boolean;
}
export interface GearCorrelationResponse {
groupKey: string;
count: number;
items: GearCorrelationItem[];
}
export async function fetchGroupCorrelations(
groupKey: string,
minScore = 0.3,
): Promise<GearCorrelationResponse> {
const res = await fetch(
`${API_BASE}/vessel-analysis/groups/${encodeURIComponent(groupKey)}/correlations?minScore=${minScore}`,
{ headers: { accept: 'application/json' } },
);
if (!res.ok) return { groupKey, count: 0, items: [] };
return res.json();
}
/* ── Correlation Tracks (Prediction API) ──────────────────────── */
export interface CorrelationTrackPoint {
ts: number; // epoch ms
lat: number;
lon: number;
sog: number;
cog: number;
}
export interface CorrelationVesselTrack {
mmsi: string;
name: string;
type: string; // 'VESSEL' | 'GEAR'
score: number;
models: Record<string, number>; // { modelName: score }
track: CorrelationTrackPoint[];
}
export interface CorrelationTracksResponse {
groupKey: string;
vessels: CorrelationVesselTrack[];
}
export async function fetchCorrelationTracks(
groupKey: string,
hours = 24,
minScore = 0.3,
): Promise<CorrelationTracksResponse> {
const url = `/api/prediction/v1/correlation/${encodeURIComponent(groupKey)}/tracks?hours=${hours}&minScore=${minScore}`;
console.log('[fetchCorrelationTracks] URL:', url);
const res = await fetch(url);
console.log('[fetchCorrelationTracks] status:', res.status, res.statusText);
if (!res.ok) {
console.warn('[fetchCorrelationTracks] 실패:', res.status);
return { groupKey, vessels: [] };
}
const data: CorrelationTracksResponse = await res.json();
console.log('[fetchCorrelationTracks] 응답:', data.vessels.length, '건');
return data;
}
/* ── Fleet Companies ─────────────────────────────────────────── */
// 캐시 (세션 중 1회 로드)

파일 보기

@ -0,0 +1,320 @@
import type { HistoryFrame } from '../components/korea/fleetClusterTypes';
import type { CorrelationVesselTrack, GearCorrelationItem } from '../services/vesselAnalysis';
import { buildInterpPolygon } from '../components/korea/fleetClusterUtils';
export interface TripsLayerDatum {
id: string;
path: [number, number][]; // [lon, lat][]
timestamps: number[]; // relative ms from startTime (TripsLayer requirement)
color: [number, number, number, number];
}
export interface MemberPosition {
mmsi: string;
name: string;
lon: number;
lat: number;
cog: number;
role: string;
isParent: boolean;
isGear: boolean;
stale: boolean;
}
export interface CenterTrailSegment {
path: [number, number][];
isInterpolated: boolean;
}
/**
* Walk all frames and collect per-MMSI tracks for TripsLayer rendering.
* timestamps are relative ms from startTime (deck.gl TripsLayer requirement).
*/
export function buildMemberTripsData(frames: HistoryFrame[], startTime: number): TripsLayerDatum[] {
const memberMap = new Map<string, { path: [number, number][]; timestamps: number[] }>();
for (const frame of frames) {
const t = new Date(frame.snapshotTime).getTime() - startTime;
for (const member of frame.members) {
const entry = memberMap.get(member.mmsi) ?? { path: [], timestamps: [] };
entry.path.push([member.lon, member.lat]);
entry.timestamps.push(t);
memberMap.set(member.mmsi, entry);
}
}
const result: TripsLayerDatum[] = [];
for (const [mmsi, data] of memberMap) {
if (data.path.length >= 2) {
result.push({
id: mmsi,
path: data.path,
timestamps: data.timestamps,
color: [200, 200, 200, 180],
});
}
}
return result;
}
/**
* Convert correlation vessel tracks to TripsLayer format.
* timestamps are relative ms from startTime (deck.gl TripsLayer requirement).
*/
export function buildCorrelationTripsData(
tracks: CorrelationVesselTrack[],
startTime: number,
): TripsLayerDatum[] {
const result: TripsLayerDatum[] = [];
for (const vt of tracks) {
if (vt.track.length >= 2) {
result.push({
id: vt.mmsi,
path: vt.track.map(pt => [pt.lon, pt.lat]),
timestamps: vt.track.map(pt => pt.ts - startTime),
color: [96, 165, 250, 150],
});
}
}
return result;
}
/**
* Split center trail into real/interpolated segments and collect real-data dot positions.
* Consecutive frames with the same _longGap flag form one segment.
*/
export function buildCenterTrailData(
frames: HistoryFrame[],
): { segments: CenterTrailSegment[]; dots: [number, number][] } {
const segments: CenterTrailSegment[] = [];
const dots: [number, number][] = [];
if (frames.length === 0) return { segments, dots };
let segStart = 0;
for (let i = 1; i <= frames.length; i++) {
const curInterp = i < frames.length ? !!frames[i]._longGap : null;
const startInterp = !!frames[segStart]._longGap;
if (i < frames.length && curInterp === startInterp) continue;
const from = segStart > 0 ? segStart - 1 : segStart;
const seg = frames.slice(from, i);
if (seg.length >= 2) {
segments.push({
path: seg.map(s => [s.centerLon, s.centerLat]),
isInterpolated: startInterp,
});
}
segStart = i;
}
for (const frame of frames) {
if (!frame._longGap && !frame._interp) {
dots.push([frame.centerLon, frame.centerLat]);
}
}
return { segments, dots };
}
/**
* Map real (non-interpolated) frames to normalized [0, 1] positions
* along the timeline, for progress bar gap indicators.
*/
export function buildSnapshotRanges(
frames: HistoryFrame[],
startTime: number,
endTime: number,
): number[] {
const duration = endTime - startTime;
if (duration <= 0) return [];
return frames
.filter(h => !h._interp)
.map(h => (new Date(h.snapshotTime).getTime() - startTime) / duration);
}
/**
* Cursor-based frame index lookup.
* Uses forward linear scan from cursorHint during normal playback (O(12)),
* falls back to binary search when time goes backward or hint is invalid.
* Returns { index: -1 } when the closest frame is more than 30 minutes away.
*/
export function findFrameAtTime(
frameTimes: number[],
timeMs: number,
cursorHint: number,
): { index: number; cursor: number } {
if (frameTimes.length === 0) return { index: -1, cursor: 0 };
// Forward linear scan from cursor
if (cursorHint >= 0 && cursorHint < frameTimes.length) {
if (frameTimes[cursorHint] <= timeMs) {
let i = cursorHint;
while (i < frameTimes.length - 1 && frameTimes[i + 1] <= timeMs) {
i++;
}
return { index: i, cursor: i };
}
// Time went backward — fall through to binary search
}
// Binary search fallback
let lo = 0;
let hi = frameTimes.length - 1;
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (frameTimes[mid] <= timeMs) {
lo = mid;
} else {
hi = mid - 1;
}
}
if (Math.abs(frameTimes[lo] - timeMs) > 1_800_000) {
return { index: -1, cursor: lo };
}
return { index: lo, cursor: lo };
}
/**
* Interpolate member positions between frameIdx and frameIdx+1 at timeMs.
* Returns stale=true for frames marked as _longGap or _interp.
*/
export function interpolateMemberPositions(
frames: HistoryFrame[],
frameIdx: number,
timeMs: number,
): MemberPosition[] {
if (frameIdx < 0 || frameIdx >= frames.length) return [];
const frame = frames[frameIdx];
const isStale = !!frame._longGap || !!frame._interp;
const toPosition = (
m: { mmsi: string; name: string; lon: number; lat: number; cog: number; role: string; isParent: boolean },
lon: number,
lat: number,
cog: number,
): MemberPosition => ({
mmsi: m.mmsi,
name: m.name,
lon,
lat,
cog,
role: m.role,
isParent: m.isParent,
isGear: m.role === 'GEAR' || !m.isParent,
stale: isStale,
});
// No next frame — return current positions as-is
if (frameIdx >= frames.length - 1) {
return frame.members.map(m => toPosition(m, m.lon, m.lat, m.cog));
}
const nextFrame = frames[frameIdx + 1];
const t0 = new Date(frame.snapshotTime).getTime();
const t1 = new Date(nextFrame.snapshotTime).getTime();
const ratio = t1 > t0 ? Math.max(0, Math.min(1, (timeMs - t0) / (t1 - t0))) : 0;
const nextMap = new Map(nextFrame.members.map(m => [m.mmsi, m]));
return frame.members.map(m => {
const nm = nextMap.get(m.mmsi);
if (!nm) {
return toPosition(m, m.lon, m.lat, m.cog);
}
return toPosition(
m,
m.lon + (nm.lon - m.lon) * ratio,
m.lat + (nm.lat - m.lat) * ratio,
nm.cog,
);
});
}
/**
* .
* + ( ) .
*/
export interface ModelCenterTrail {
modelName: string;
path: [number, number][]; // [lon, lat][]
timestamps: number[]; // relative ms
}
export function buildModelCenterTrails(
frames: HistoryFrame[],
corrTracks: CorrelationVesselTrack[],
corrByModel: Map<string, GearCorrelationItem[]>,
enabledVessels: Set<string>,
startTime: number,
): ModelCenterTrail[] {
// 연관 선박 트랙 맵: mmsi → {timestampsMs[], geometry[][]}
const trackMap = new Map<string, { ts: number[]; path: [number, number][] }>();
for (const vt of corrTracks) {
if (vt.track.length < 1) continue;
trackMap.set(vt.mmsi, {
ts: vt.track.map(p => p.ts),
path: vt.track.map(p => [p.lon, p.lat]),
});
}
const results: ModelCenterTrail[] = [];
for (const [mn, items] of corrByModel) {
const enabledItems = items.filter(c => enabledVessels.has(c.targetMmsi));
if (enabledItems.length === 0) continue;
const path: [number, number][] = [];
const timestamps: number[] = [];
for (const frame of frames) {
const t = new Date(frame.snapshotTime).getTime();
const relT = t - startTime;
// 멤버 위치
const allPts: [number, number][] = frame.members.map(m => [m.lon, m.lat]);
// 연관 선박 위치 (트랙 보간 or 마지막 점 clamp)
for (const c of enabledItems) {
const track = trackMap.get(c.targetMmsi);
if (!track || track.path.length === 0) continue;
let lon: number, lat: number;
if (t <= track.ts[0]) {
lon = track.path[0][0]; lat = track.path[0][1];
} else if (t >= track.ts[track.ts.length - 1]) {
const last = track.path.length - 1;
lon = track.path[last][0]; lat = track.path[last][1];
} else {
let lo = 0, hi = track.ts.length - 1;
while (lo < hi - 1) { const mid = (lo + hi) >> 1; if (track.ts[mid] <= t) lo = mid; else hi = mid; }
const ratio = track.ts[hi] !== track.ts[lo] ? (t - track.ts[lo]) / (track.ts[hi] - track.ts[lo]) : 0;
lon = track.path[lo][0] + (track.path[hi][0] - track.path[lo][0]) * ratio;
lat = track.path[lo][1] + (track.path[hi][1] - track.path[lo][1]) * ratio;
}
allPts.push([lon, lat]);
}
// 폴리곤 중심 계산
const poly = buildInterpPolygon(allPts);
if (!poly) continue;
const ring = poly.coordinates[0];
let cx = 0, cy = 0;
for (const pt of ring) { cx += pt[0]; cy += pt[1]; }
cx /= ring.length; cy /= ring.length;
path.push([cx, cy]);
timestamps.push(relT);
}
if (path.length >= 2) {
results.push({ modelName: mn, path, timestamps });
}
}
return results;
}

파일 보기

@ -0,0 +1,297 @@
import { create } from 'zustand';
import { subscribeWithSelector } from 'zustand/middleware';
import type { HistoryFrame } from '../components/korea/fleetClusterTypes';
import type { GearCorrelationItem, CorrelationVesselTrack } from '../services/vesselAnalysis';
import {
buildMemberTripsData,
buildCorrelationTripsData,
buildCenterTrailData,
buildSnapshotRanges,
buildModelCenterTrails,
} from './gearReplayPreprocess';
import type { ModelCenterTrail } from './gearReplayPreprocess';
// ── Pre-processed data types for deck.gl layers ──────────────────
export interface TripsLayerDatum {
id: string;
path: [number, number][];
timestamps: number[];
color: [number, number, number, number];
}
export interface MemberPosition {
mmsi: string;
name: string;
lon: number;
lat: number;
cog: number;
role: string;
isParent: boolean;
isGear: boolean;
stale: boolean;
}
export interface CenterTrailSegment {
path: [number, number][];
isInterpolated: boolean;
}
// ── Speed factor: 1x = 30 real seconds covers 12 timeline hours ──
const SPEED_FACTOR = (12 * 60 * 60 * 1000) / (30 * 1000); // 1440
// ── Module-level rAF state (outside React) ───────────────────────
let animationFrameId: number | null = null;
let lastFrameTime: number | null = null;
// ── Store interface ───────────────────────────────────────────────
interface GearReplayState {
// Playback state
isPlaying: boolean;
currentTime: number;
startTime: number;
endTime: number;
playbackSpeed: number;
// Source data
historyFrames: HistoryFrame[];
frameTimes: number[];
selectedGroupKey: string | null;
rawCorrelationTracks: CorrelationVesselTrack[];
// Pre-computed layer data
memberTripsData: TripsLayerDatum[];
correlationTripsData: TripsLayerDatum[];
centerTrailSegments: CenterTrailSegment[];
centerDotsPositions: [number, number][];
snapshotRanges: number[];
modelCenterTrails: ModelCenterTrail[];
// Filter / display state
enabledModels: Set<string>;
enabledVessels: Set<string>;
hoveredMmsi: string | null;
correlationByModel: Map<string, GearCorrelationItem[]>;
showTrails: boolean;
showLabels: boolean;
// Actions
loadHistory: (
frames: HistoryFrame[],
corrTracks: CorrelationVesselTrack[],
corrData: GearCorrelationItem[],
enabledModels: Set<string>,
enabledVessels: Set<string>,
) => void;
play: () => void;
pause: () => void;
seek: (timeMs: number) => void;
setPlaybackSpeed: (speed: number) => void;
setEnabledModels: (models: Set<string>) => void;
setEnabledVessels: (vessels: Set<string>) => void;
setHoveredMmsi: (mmsi: string | null) => void;
setShowTrails: (show: boolean) => void;
setShowLabels: (show: boolean) => void;
updateCorrelation: (corrData: GearCorrelationItem[], corrTracks: CorrelationVesselTrack[]) => void;
reset: () => void;
}
// ── Store ─────────────────────────────────────────────────────────
export const useGearReplayStore = create<GearReplayState>()(
subscribeWithSelector((set, get) => {
const animate = (): void => {
const state = get();
if (!state.isPlaying) return;
const now = performance.now();
if (lastFrameTime === null) lastFrameTime = now;
const delta = now - lastFrameTime;
lastFrameTime = now;
const newTime = state.currentTime + delta * SPEED_FACTOR * state.playbackSpeed;
if (newTime >= state.endTime) {
set({ currentTime: state.startTime });
animationFrameId = requestAnimationFrame(animate);
return;
}
set({ currentTime: newTime });
animationFrameId = requestAnimationFrame(animate);
};
return {
// Playback state
isPlaying: false,
currentTime: 0,
startTime: 0,
endTime: 0,
playbackSpeed: 1,
// Source data
historyFrames: [],
frameTimes: [],
selectedGroupKey: null,
rawCorrelationTracks: [],
// Pre-computed layer data
memberTripsData: [],
correlationTripsData: [],
centerTrailSegments: [],
centerDotsPositions: [],
snapshotRanges: [],
modelCenterTrails: [],
// Filter / display state
enabledModels: new Set<string>(),
enabledVessels: new Set<string>(),
hoveredMmsi: null,
showTrails: true,
showLabels: true,
correlationByModel: new Map<string, GearCorrelationItem[]>(),
// ── Actions ────────────────────────────────────────────────
loadHistory: (frames, corrTracks, corrData, enabledModels, enabledVessels) => {
const startTime = Date.now() - 12 * 60 * 60 * 1000;
const endTime = Date.now();
const frameTimes = frames.map(f => new Date(f.snapshotTime).getTime());
const memberTrips = buildMemberTripsData(frames, startTime);
const corrTrips = buildCorrelationTripsData(corrTracks, startTime);
const { segments, dots } = buildCenterTrailData(frames);
const ranges = buildSnapshotRanges(frames, startTime, endTime);
const byModel = new Map<string, GearCorrelationItem[]>();
for (const c of corrData) {
const list = byModel.get(c.modelName) ?? [];
list.push(c);
byModel.set(c.modelName, list);
}
const modelTrails = buildModelCenterTrails(frames, corrTracks, byModel, enabledVessels, startTime);
set({
historyFrames: frames,
frameTimes,
startTime,
endTime,
currentTime: startTime,
rawCorrelationTracks: corrTracks,
memberTripsData: memberTrips,
correlationTripsData: corrTrips,
centerTrailSegments: segments,
centerDotsPositions: dots,
snapshotRanges: ranges,
modelCenterTrails: modelTrails,
enabledModels,
enabledVessels,
correlationByModel: byModel,
selectedGroupKey: frames[0]?.groupKey ?? null,
});
},
play: () => {
const state = get();
if (state.endTime <= state.startTime) return;
lastFrameTime = null;
if (state.currentTime >= state.endTime) {
set({ isPlaying: true, currentTime: state.startTime });
} else {
set({ isPlaying: true });
}
animationFrameId = requestAnimationFrame(animate);
},
pause: () => {
if (animationFrameId !== null) {
cancelAnimationFrame(animationFrameId);
animationFrameId = null;
}
lastFrameTime = null;
set({ isPlaying: false });
},
seek: (timeMs) => {
const { startTime, endTime } = get();
set({ currentTime: Math.max(startTime, Math.min(endTime, timeMs)) });
},
setPlaybackSpeed: (speed) => set({ playbackSpeed: speed }),
setEnabledModels: (models) => set({ enabledModels: models }),
setEnabledVessels: (vessels) => {
const state = get();
const modelTrails = state.historyFrames.length > 0
? buildModelCenterTrails(state.historyFrames, state.rawCorrelationTracks, state.correlationByModel, vessels, state.startTime)
: [];
set({ enabledVessels: vessels, modelCenterTrails: modelTrails });
},
setHoveredMmsi: (mmsi) => set({ hoveredMmsi: mmsi }),
setShowTrails: (show) => set({ showTrails: show }),
setShowLabels: (show) => set({ showLabels: show }),
updateCorrelation: (corrData, corrTracks) => {
const state = get();
if (state.historyFrames.length === 0) {
console.log('[GearReplayStore] updateCorrelation 스킵: historyFrames 비어있음');
return;
}
const byModel = new Map<string, GearCorrelationItem[]>();
for (const c of corrData) {
const list = byModel.get(c.modelName) ?? [];
list.push(c);
byModel.set(c.modelName, list);
}
const corrTrips = buildCorrelationTripsData(corrTracks, state.startTime);
console.log('[GearReplayStore] updateCorrelation:', {
corrData: corrData.length,
models: [...byModel.keys()],
corrTrips: corrTrips.length,
corrTracks: corrTracks.length,
});
const modelTrails = buildModelCenterTrails(state.historyFrames, corrTracks, byModel, state.enabledVessels, state.startTime);
set({ correlationByModel: byModel, correlationTripsData: corrTrips, modelCenterTrails: modelTrails, rawCorrelationTracks: corrTracks });
},
reset: () => {
if (animationFrameId !== null) {
cancelAnimationFrame(animationFrameId);
animationFrameId = null;
}
lastFrameTime = null;
set({
isPlaying: false,
currentTime: 0,
startTime: 0,
endTime: 0,
playbackSpeed: 1,
historyFrames: [],
frameTimes: [],
selectedGroupKey: null,
rawCorrelationTracks: [],
memberTripsData: [],
correlationTripsData: [],
centerTrailSegments: [],
centerDotsPositions: [],
snapshotRanges: [],
modelCenterTrails: [],
enabledModels: new Set<string>(),
enabledVessels: new Set<string>(),
hoveredMmsi: null,
showTrails: true,
showLabels: true,
correlationByModel: new Map<string, GearCorrelationItem[]>(),
});
},
};
}),
);

파일 보기

@ -0,0 +1,40 @@
/**
* deck.gl IconLayer용 SVG .
* MapLibre ship-triangle / gear-diamond .
* Data URI로 .
*/
const ICON_SIZE = 64;
/** 선박 삼각형 SVG (heading 0 = north, 위쪽 꼭짓점) */
function createShipTriangleSvg(): string {
const s = ICON_SIZE;
return `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 ${s} ${s}">
<polygon points="${s / 2},2 ${s * 0.12},${s - 2} ${s / 2},${s * 0.62} ${s * 0.88},${s - 2}" fill="white"/>
</svg>`;
}
/** 어구 마름모 SVG */
function createGearDiamondSvg(): string {
const s = ICON_SIZE;
return `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 ${s} ${s}">
<polygon points="${s / 2},4 ${s - 4},${s / 2} ${s / 2},${s - 4} 4,${s / 2}" fill="white"/>
</svg>`;
}
function svgToDataUri(svg: string): string {
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
}
// ── 정적 캐시 (모듈 로드 시 1회 생성) ──
const SHIP_TRIANGLE_URI = svgToDataUri(createShipTriangleSvg());
const GEAR_DIAMOND_URI = svgToDataUri(createGearDiamondSvg());
export const SHIP_ICON_MAPPING = {
'ship-triangle': { url: SHIP_TRIANGLE_URI, width: ICON_SIZE, height: ICON_SIZE, anchorY: ICON_SIZE * 0.62, mask: true },
'gear-diamond': { url: GEAR_DIAMOND_URI, width: ICON_SIZE, height: ICON_SIZE, anchorY: ICON_SIZE / 2, mask: true },
};
export const SHIP_ICON_ATLAS = SHIP_TRIANGLE_URI;
export const GEAR_ICON_ATLAS = GEAR_DIAMOND_URI;
export { ICON_SIZE };

파일 보기

@ -115,6 +115,11 @@ export default defineConfig(({ mode }): UserConfig => ({
changeOrigin: true,
secure: true,
},
'/api/prediction/': {
target: 'http://192.168.1.18:8001',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api\/prediction/, '/api'),
},
'/ollama': {
target: 'http://localhost:11434',
changeOrigin: true,

파일 보기

@ -0,0 +1,785 @@
"""어구 그룹 다단계 연관성 분석 — 멀티모델 패턴 추적.
Phase 1: default 모델 1개로 동작 (DB에서 is_active=true 모델 로드).
Phase 2: 글로벌 모델 max 5 병렬 실행.
어구 중심 점수 체계:
- 어구 신호 기준 관측 윈도우 (어구 비활성 FREEZE)
- 선박 shadow 추적 (비활성 활성 전환 보너스)
- 적응형 EMA + streak 자기강화
- 퍼센트 기반 무제한 추적 (50%+)
"""
from __future__ import annotations
import logging
import math
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Optional
logger = logging.getLogger(__name__)
# ── 상수 ──────────────────────────────────────────────────────────
_EARTH_RADIUS_NM = 3440.065
_NM_TO_M = 1852.0
# ── 파라미터 모델 ─────────────────────────────────────────────────
@dataclass
class ModelParams:
"""추적 모델의 전체 파라미터셋."""
model_id: int = 1
name: str = 'default'
# EMA
alpha_base: float = 0.30
alpha_min: float = 0.08
alpha_decay_per_streak: float = 0.005
# 임계값
track_threshold: float = 0.50
polygon_threshold: float = 0.70
# 메트릭 가중치 — 어구-선박
w_proximity: float = 0.45
w_visit: float = 0.35
w_activity: float = 0.20
# 메트릭 가중치 — 선박-선박
w_dtw: float = 0.30
w_sog_corr: float = 0.20
w_heading: float = 0.25
w_prox_vv: float = 0.25
# 메트릭 가중치 — 어구-어구
w_prox_persist: float = 0.50
w_drift: float = 0.30
w_signal_sync: float = 0.20
# Freeze 기준
group_quiet_ratio: float = 0.30
normal_gap_hours: float = 1.0
# 감쇠
decay_slow: float = 0.015
decay_fast: float = 0.08
stale_hours: float = 6.0
# Shadow
shadow_stay_bonus: float = 0.10
shadow_return_bonus: float = 0.15
# 거리
candidate_radius_factor: float = 3.0
proximity_threshold_nm: float = 5.0
visit_threshold_nm: float = 5.0
# 야간
night_bonus: float = 1.3
# 장기 감쇠
long_decay_days: float = 7.0
@classmethod
def from_db_row(cls, row: dict) -> ModelParams:
"""DB correlation_param_models 행에서 생성."""
params_json = row.get('params', {})
return cls(
model_id=row['id'],
name=row['name'],
**{k: v for k, v in params_json.items() if hasattr(cls, k)},
)
# ── Haversine 거리 ────────────────────────────────────────────────
def _haversine_nm(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
"""두 좌표 간 거리 (해리)."""
phi1 = math.radians(lat1)
phi2 = math.radians(lat2)
dphi = math.radians(lat2 - lat1)
dlam = math.radians(lon2 - lon1)
a = math.sin(dphi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlam / 2) ** 2
return _EARTH_RADIUS_NM * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
# ── Freeze 판단 ───────────────────────────────────────────────────
def should_freeze(
gear_group_active_ratio: float,
target_last_observed: Optional[datetime],
now: datetime,
params: ModelParams,
) -> tuple[bool, str]:
"""감쇠 적용 여부 판단. 어구 그룹이 기준."""
# 1. 어구 그룹 비활성 → 비교 불가
if gear_group_active_ratio < params.group_quiet_ratio:
return True, 'GROUP_QUIET'
# 2. 개별 부재가 정상 범위
if target_last_observed is not None:
hours_absent = (now - target_last_observed).total_seconds() / 3600
if hours_absent < params.normal_gap_hours:
return True, 'NORMAL_GAP'
return False, 'ACTIVE'
# ── EMA 업데이트 ──────────────────────────────────────────────────
def update_score(
prev_score: Optional[float],
raw_score: Optional[float],
streak: int,
last_observed: Optional[datetime],
now: datetime,
gear_group_active_ratio: float,
shadow_bonus: float,
params: ModelParams,
) -> tuple[float, int, str]:
"""적응형 EMA 점수 업데이트.
Returns: (new_score, new_streak, state)
"""
# 관측 불가
if raw_score is None:
frz, reason = should_freeze(
gear_group_active_ratio, last_observed, now, params,
)
if frz:
return (prev_score or 0.0), streak, reason
# 실제 이탈 → 감쇠
hours_absent = 0.0
if last_observed is not None:
hours_absent = (now - last_observed).total_seconds() / 3600
decay = params.decay_fast if hours_absent > params.stale_hours else params.decay_slow
return max(0.0, (prev_score or 0.0) - decay), 0, 'SIGNAL_LOSS'
# Shadow 보너스
adjusted = min(1.0, raw_score + shadow_bonus)
# Case 1: 임계값 이상 → streak 보상
if adjusted >= params.track_threshold:
streak += 1
alpha = max(params.alpha_min,
params.alpha_base - streak * params.alpha_decay_per_streak)
if prev_score is None:
return adjusted, streak, 'ACTIVE'
return alpha * adjusted + (1.0 - alpha) * prev_score, streak, 'ACTIVE'
# Case 2: 패턴 이탈
alpha = params.alpha_base
if prev_score is None:
return adjusted, 0, 'PATTERN_DIVERGE'
return alpha * adjusted + (1.0 - alpha) * prev_score, 0, 'PATTERN_DIVERGE'
# ── 어구-선박 메트릭 ──────────────────────────────────────────────
def _compute_gear_vessel_metrics(
gear_center_lat: float,
gear_center_lon: float,
gear_radius_nm: float,
vessel_track: list[dict],
params: ModelParams,
) -> dict:
"""어구 그룹 중심 vs 선박 궤적 메트릭.
vessel_track: [{lat, lon, sog, cog, timestamp}, ...]
"""
if not vessel_track:
return {'proximity_ratio': 0, 'visit_score': 0, 'activity_sync': 0, 'composite': 0}
threshold_nm = max(gear_radius_nm * 2, params.proximity_threshold_nm)
# 1. proximity_ratio — 근접 지속비
close_count = 0
for p in vessel_track:
d = _haversine_nm(gear_center_lat, gear_center_lon, p['lat'], p['lon'])
if d < threshold_nm:
close_count += 1
proximity_ratio = close_count / len(vessel_track)
# 2. visit_score — 방문 패턴
visit_threshold = params.visit_threshold_nm
in_zone = False
visits = 0
stay_points = 0
away_points = 0
for p in vessel_track:
d = _haversine_nm(gear_center_lat, gear_center_lon, p['lat'], p['lon'])
if d < visit_threshold:
if not in_zone:
visits += 1
in_zone = True
stay_points += 1
else:
in_zone = False
away_points += 1
visit_count_norm = min(1.0, visits / 5.0) if visits > 0 else 0.0
total = stay_points + away_points
stay_ratio = stay_points / total if total > 0 else 0.0
visit_score = 0.5 * visit_count_norm + 0.5 * stay_ratio
# 3. activity_sync — 영역 내 저속 비율 (조업/관리 행위)
in_zone_count = 0
in_zone_slow = 0
for p in vessel_track:
d = _haversine_nm(gear_center_lat, gear_center_lon, p['lat'], p['lon'])
if d < visit_threshold:
in_zone_count += 1
if p.get('sog', 0) < 2.0:
in_zone_slow += 1
activity_sync = in_zone_slow / in_zone_count if in_zone_count > 0 else 0.0
# 가중 합산
composite = (
params.w_proximity * proximity_ratio
+ params.w_visit * visit_score
+ params.w_activity * activity_sync
)
return {
'proximity_ratio': round(proximity_ratio, 4),
'visit_score': round(visit_score, 4),
'activity_sync': round(activity_sync, 4),
'composite': round(composite, 4),
}
# ── 선박-선박 메트릭 ──────────────────────────────────────────────
def _compute_vessel_vessel_metrics(
track_a: list[dict],
track_b: list[dict],
params: ModelParams,
) -> dict:
"""두 선박 궤적 간 메트릭."""
from algorithms.track_similarity import (
compute_heading_coherence,
compute_proximity_ratio,
compute_sog_correlation,
compute_track_similarity,
)
if not track_a or not track_b:
return {
'dtw_similarity': 0, 'speed_correlation': 0,
'heading_coherence': 0, 'proximity_ratio': 0, 'composite': 0,
}
# DTW
pts_a = [(p['lat'], p['lon']) for p in track_a]
pts_b = [(p['lat'], p['lon']) for p in track_b]
dtw_sim = compute_track_similarity(pts_a, pts_b)
# SOG 상관
sog_a = [p.get('sog', 0) for p in track_a]
sog_b = [p.get('sog', 0) for p in track_b]
sog_corr = compute_sog_correlation(sog_a, sog_b)
# COG 동조
cog_a = [p.get('cog', 0) for p in track_a]
cog_b = [p.get('cog', 0) for p in track_b]
heading = compute_heading_coherence(cog_a, cog_b)
# 근접비
prox = compute_proximity_ratio(pts_a, pts_b, params.proximity_threshold_nm)
composite = (
params.w_dtw * dtw_sim
+ params.w_sog_corr * sog_corr
+ params.w_heading * heading
+ params.w_prox_vv * prox
)
return {
'dtw_similarity': round(dtw_sim, 4),
'speed_correlation': round(sog_corr, 4),
'heading_coherence': round(heading, 4),
'proximity_ratio': round(prox, 4),
'composite': round(composite, 4),
}
# ── 어구-어구 메트릭 ──────────────────────────────────────────────
def _compute_gear_gear_metrics(
center_a: tuple[float, float],
center_b: tuple[float, float],
center_history_a: list[dict],
center_history_b: list[dict],
params: ModelParams,
) -> dict:
"""두 어구 그룹 간 메트릭."""
if not center_history_a or not center_history_b:
return {
'proximity_ratio': 0, 'drift_similarity': 0,
'composite': 0,
}
# 1. 근접 지속성 — 현재 중심 간 거리의 안정성
dist_nm = _haversine_nm(center_a[0], center_a[1], center_b[0], center_b[1])
prox_persist = max(0.0, 1.0 - dist_nm / 20.0) # 20NM 이상이면 0
# 2. 표류 유사도 — 중심 이동 벡터 코사인 유사도
drift_sim = 0.0
n = min(len(center_history_a), len(center_history_b))
if n >= 2:
# 마지막 2점으로 이동 벡터 계산
da_lat = center_history_a[-1].get('lat', 0) - center_history_a[-2].get('lat', 0)
da_lon = center_history_a[-1].get('lon', 0) - center_history_a[-2].get('lon', 0)
db_lat = center_history_b[-1].get('lat', 0) - center_history_b[-2].get('lat', 0)
db_lon = center_history_b[-1].get('lon', 0) - center_history_b[-2].get('lon', 0)
dot = da_lat * db_lat + da_lon * db_lon
mag_a = (da_lat ** 2 + da_lon ** 2) ** 0.5
mag_b = (db_lat ** 2 + db_lon ** 2) ** 0.5
if mag_a > 1e-10 and mag_b > 1e-10:
cos_sim = dot / (mag_a * mag_b)
drift_sim = max(0.0, (cos_sim + 1.0) / 2.0)
composite = (
params.w_prox_persist * prox_persist
+ params.w_drift * drift_sim
)
return {
'proximity_ratio': round(prox_persist, 4),
'drift_similarity': round(drift_sim, 4),
'composite': round(composite, 4),
}
# ── Shadow 보너스 계산 ────────────────────────────────────────────
def compute_shadow_bonus(
vessel_positions_during_inactive: list[dict],
last_known_gear_center: tuple[float, float],
group_radius_nm: float,
params: ModelParams,
) -> tuple[float, bool, bool]:
"""어구 비활성 동안 선박이 어구 근처에 머물렀는지 평가.
Returns: (bonus, stayed_nearby, returned_before_resume)
"""
if not vessel_positions_during_inactive or last_known_gear_center is None:
return 0.0, False, False
gc_lat, gc_lon = last_known_gear_center
threshold_nm = max(group_radius_nm * 2, params.proximity_threshold_nm)
# 1. 평균 거리
dists = [
_haversine_nm(gc_lat, gc_lon, p['lat'], p['lon'])
for p in vessel_positions_during_inactive
]
avg_dist = sum(dists) / len(dists)
stayed = avg_dist < threshold_nm
# 2. 마지막 위치가 근처인지 (복귀 판단)
returned = dists[-1] < threshold_nm if dists else False
bonus = 0.0
if stayed:
bonus += params.shadow_stay_bonus
if returned:
bonus += params.shadow_return_bonus
return bonus, stayed, returned
# ── 후보 필터링 ───────────────────────────────────────────────────
def _compute_group_radius(members: list[dict]) -> float:
"""그룹 멤버 간 최대 거리의 절반 (NM)."""
if len(members) < 2:
return 1.0 # 최소 1NM
max_dist = 0.0
for i in range(len(members)):
for j in range(i + 1, len(members)):
d = _haversine_nm(
members[i]['lat'], members[i]['lon'],
members[j]['lat'], members[j]['lon'],
)
if d > max_dist:
max_dist = d
return max(1.0, max_dist / 2.0)
def find_candidates(
gear_center_lat: float,
gear_center_lon: float,
group_radius_nm: float,
group_mmsis: set[str],
all_positions: dict[str, dict],
params: ModelParams,
) -> list[str]:
"""어구 그룹 주변 후보 MMSI 필터링."""
search_radius = group_radius_nm * params.candidate_radius_factor
candidates = []
for mmsi, pos in all_positions.items():
if mmsi in group_mmsis:
continue
d = _haversine_nm(gear_center_lat, gear_center_lon, pos['lat'], pos['lon'])
if d < search_radius:
candidates.append(mmsi)
return candidates
# ── 메인 실행 ─────────────────────────────────────────────────────
def _get_vessel_track(vessel_store, mmsi: str, hours: int = 6) -> list[dict]:
"""vessel_store에서 특정 MMSI의 최근 N시간 궤적 추출 (벡터화)."""
df = vessel_store._tracks.get(mmsi)
if df is None or len(df) == 0:
return []
import pandas as pd
now = datetime.now(timezone.utc)
cutoff = now - pd.Timedelta(hours=hours)
ts_col = df['timestamp']
if hasattr(ts_col.dtype, 'tz') and ts_col.dtype.tz is not None:
mask = ts_col >= pd.Timestamp(cutoff)
else:
mask = ts_col >= pd.Timestamp(cutoff.replace(tzinfo=None))
recent = df.loc[mask]
if recent.empty:
return []
# 벡터화 추출 (iterrows 대신)
lats = recent['lat'].values
lons = recent['lon'].values
sogs = (recent['sog'] if 'sog' in recent.columns
else recent.get('raw_sog', pd.Series(dtype=float))).fillna(0).values
cogs = (recent['cog'] if 'cog' in recent.columns
else pd.Series(0, index=recent.index)).fillna(0).values
return [
{'lat': float(lats[i]), 'lon': float(lons[i]),
'sog': float(sogs[i]), 'cog': float(cogs[i])}
for i in range(len(lats))
]
def _compute_gear_active_ratio(
gear_members: list[dict],
all_positions: dict[str, dict],
now: datetime,
stale_sec: float = 21600,
) -> float:
"""어구 그룹의 활성 멤버 비율."""
if not gear_members:
return 0.0
active = 0
for m in gear_members:
pos = all_positions.get(m['mmsi'])
if pos is None:
continue
ts = pos.get('timestamp')
if ts is None:
continue
if isinstance(ts, datetime):
last_dt = ts if ts.tzinfo is not None else ts.replace(tzinfo=timezone.utc)
else:
try:
import pandas as pd
last_dt = pd.Timestamp(ts).to_pydatetime()
if last_dt.tzinfo is None:
last_dt = last_dt.replace(tzinfo=timezone.utc)
except Exception:
continue
age = (now - last_dt).total_seconds()
if age < stale_sec:
active += 1
return active / len(gear_members)
def _is_gear_pattern(name: str) -> bool:
"""어구 이름 패턴 판별."""
import re
return bool(re.match(r'^.+_\d+_\d*$', name or ''))
_MAX_CANDIDATES_PER_GROUP = 30 # 후보 수 상한 (성능 보호)
def run_gear_correlation(
vessel_store,
gear_groups: list[dict],
conn,
) -> dict:
"""어구 연관성 분석 메인 실행 (배치 최적화).
Args:
vessel_store: VesselStore 인스턴스
gear_groups: detect_gear_groups() 결과
conn: kcgdb 커넥션
Returns:
{'updated': int, 'models': int, 'raw_inserted': int}
"""
import time as _time
import re as _re
_gear_re = _re.compile(r'^.+_(?=\S*\d)\S+(?:[_ ]\S*)*[_ ]*$|^.+%$|^\d+$')
t0 = _time.time()
now = datetime.now(timezone.utc)
all_positions = vessel_store.get_all_latest_positions()
# 활성 모델 로드
models = _load_active_models(conn)
if not models:
logger.warning('no active correlation models found')
return {'updated': 0, 'models': 0, 'raw_inserted': 0}
# 기존 점수 전체 사전 로드 (건별 쿼리 대신 벌크)
all_scores = _load_all_scores(conn)
raw_batch: list[tuple] = []
score_batch: list[tuple] = []
total_updated = 0
total_raw = 0
default_params = models[0]
for gear_group in gear_groups:
parent_name = gear_group['parent_name']
members = gear_group['members']
if not members:
continue
# 그룹 중심 + 반경
center_lat = sum(m['lat'] for m in members) / len(members)
center_lon = sum(m['lon'] for m in members) / len(members)
group_radius = _compute_group_radius(members)
# 어구 활성도
active_ratio = _compute_gear_active_ratio(members, all_positions, now)
# 그룹 멤버 MMSI 셋
group_mmsis = {m['mmsi'] for m in members}
if gear_group.get('parent_mmsi'):
group_mmsis.add(gear_group['parent_mmsi'])
# 후보 필터링 + 수 제한
candidates = find_candidates(
center_lat, center_lon, group_radius,
group_mmsis, all_positions, default_params,
)
if not candidates:
continue
if len(candidates) > _MAX_CANDIDATES_PER_GROUP:
# 가까운 순서로 제한
candidates.sort(key=lambda m: _haversine_nm(
center_lat, center_lon,
all_positions[m]['lat'], all_positions[m]['lon'],
))
candidates = candidates[:_MAX_CANDIDATES_PER_GROUP]
for target_mmsi in candidates:
target_pos = all_positions.get(target_mmsi)
if target_pos is None:
continue
target_name = target_pos.get('name', '')
target_is_gear = bool(_gear_re.match(target_name or ''))
target_type = 'GEAR_BUOY' if target_is_gear else 'VESSEL'
# 메트릭 계산 (어구는 단순 거리, 선박은 track 기반)
if target_is_gear:
d = _haversine_nm(center_lat, center_lon,
target_pos['lat'], target_pos['lon'])
prox = max(0.0, 1.0 - d / 20.0)
metrics = {'proximity_ratio': prox, 'composite': prox}
else:
vessel_track = _get_vessel_track(vessel_store, target_mmsi, hours=6)
metrics = _compute_gear_vessel_metrics(
center_lat, center_lon, group_radius,
vessel_track, default_params,
)
# raw 메트릭 배치 수집
raw_batch.append((
now, parent_name, target_mmsi, target_type, target_name,
metrics.get('proximity_ratio'), metrics.get('visit_score'),
metrics.get('activity_sync'), metrics.get('dtw_similarity'),
metrics.get('speed_correlation'), metrics.get('heading_coherence'),
metrics.get('drift_similarity'), False, False, active_ratio,
))
total_raw += 1
# 모델별 EMA 업데이트
for model in models:
if target_is_gear:
composite = metrics.get('proximity_ratio', 0) * model.w_prox_persist
else:
composite = (
model.w_proximity * (metrics.get('proximity_ratio') or 0)
+ model.w_visit * (metrics.get('visit_score') or 0)
+ model.w_activity * (metrics.get('activity_sync') or 0)
)
# 사전 로드된 점수에서 조회 (DB 쿼리 없음)
score_key = (model.model_id, parent_name, target_mmsi)
prev = all_scores.get(score_key)
prev_score = prev['current_score'] if prev else None
streak = prev['streak_count'] if prev else 0
last_obs = prev['last_observed_at'] if prev else None
new_score, new_streak, state = update_score(
prev_score, composite, streak,
last_obs, now, active_ratio,
0.0, model,
)
if new_score >= model.track_threshold or prev is not None:
score_batch.append((
model.model_id, parent_name, target_mmsi,
target_type, target_name,
round(new_score, 6), new_streak, state,
now, now, now,
))
total_updated += 1
# 배치 DB 저장
_batch_insert_raw(conn, raw_batch)
_batch_upsert_scores(conn, score_batch)
conn.commit()
elapsed = round(_time.time() - t0, 2)
logger.info(
'gear correlation internals: %.2fs, %d groups, %d raw, %d scores, %d models',
elapsed, len(gear_groups), total_raw, total_updated, len(models),
)
return {
'updated': total_updated,
'models': len(models),
'raw_inserted': total_raw,
}
# ── DB 헬퍼 (배치 최적화) ─────────────────────────────────────────
def _load_active_models(conn) -> list[ModelParams]:
"""활성 모델 로드."""
cur = conn.cursor()
try:
cur.execute(
"SELECT id, name, params FROM kcg.correlation_param_models "
"WHERE is_active = TRUE ORDER BY is_default DESC, id ASC"
)
rows = cur.fetchall()
models = []
for row in rows:
import json
params = row[2] if isinstance(row[2], dict) else json.loads(row[2])
models.append(ModelParams.from_db_row({
'id': row[0], 'name': row[1], 'params': params,
}))
return models
except Exception as e:
logger.error('failed to load models: %s', e)
return [ModelParams()]
finally:
cur.close()
def _load_all_scores(conn) -> dict[tuple, dict]:
"""모든 점수를 사전 로드. {(model_id, group_key, target_mmsi): {...}}"""
cur = conn.cursor()
try:
cur.execute(
"SELECT model_id, group_key, target_mmsi, "
"current_score, streak_count, last_observed_at "
"FROM kcg.gear_correlation_scores"
)
result = {}
for row in cur.fetchall():
key = (row[0], row[1], row[2])
result[key] = {
'current_score': row[3],
'streak_count': row[4],
'last_observed_at': row[5],
}
return result
except Exception as e:
logger.warning('failed to load all scores: %s', e)
return {}
finally:
cur.close()
def _batch_insert_raw(conn, batch: list[tuple]):
"""raw 메트릭 배치 INSERT."""
if not batch:
return
cur = conn.cursor()
try:
from psycopg2.extras import execute_values
execute_values(
cur,
"""INSERT INTO kcg.gear_correlation_raw_metrics
(observed_at, group_key, target_mmsi, target_type, target_name,
proximity_ratio, visit_score, activity_sync,
dtw_similarity, speed_correlation, heading_coherence,
drift_similarity, shadow_stay, shadow_return,
gear_group_active_ratio)
VALUES %s""",
batch,
page_size=500,
)
except Exception as e:
logger.warning('batch insert raw failed: %s', e)
finally:
cur.close()
def _batch_upsert_scores(conn, batch: list[tuple]):
"""점수 배치 UPSERT."""
if not batch:
return
cur = conn.cursor()
try:
from psycopg2.extras import execute_values
execute_values(
cur,
"""INSERT INTO kcg.gear_correlation_scores
(model_id, group_key, target_mmsi, target_type, target_name,
current_score, streak_count, freeze_state,
first_observed_at, last_observed_at, updated_at)
VALUES %s
ON CONFLICT (model_id, group_key, target_mmsi)
DO UPDATE SET
target_type = EXCLUDED.target_type,
target_name = EXCLUDED.target_name,
current_score = EXCLUDED.current_score,
streak_count = EXCLUDED.streak_count,
freeze_state = EXCLUDED.freeze_state,
observation_count = kcg.gear_correlation_scores.observation_count + 1,
last_observed_at = EXCLUDED.last_observed_at,
updated_at = EXCLUDED.updated_at""",
batch,
page_size=500,
)
except Exception as e:
logger.warning('batch upsert scores failed: %s', e)
finally:
cur.close()

파일 보기

@ -23,8 +23,8 @@ from algorithms.location import classify_zone
logger = logging.getLogger(__name__)
# 프론트 FleetClusterLayer.tsx gearGroupMap 패턴과 동일
GEAR_PATTERN = re.compile(r'^(.+?)_\d+_\d+_?$')
# 어구 이름 패턴 — _ 필수 (공백만으로는 어구 미판정, fleet_tracker.py와 동일)
GEAR_PATTERN = re.compile(r'^(.+?)_(?=\S*\d)\S+(?:[_ ]\S*)*[_ ]*$|^(\d+)$')
MAX_DIST_DEG = 0.15 # ~10NM
STALE_SEC = 21600 # 6시간 (어구 P75 갭 3.5h, P90 갭 8h 커버)
FLEET_BUFFER_DEG = 0.02
@ -130,14 +130,21 @@ def detect_gear_groups(
all_positions = vessel_store.get_all_latest_positions()
# 선박명 → mmsi 맵 (모선 탐색용, 어구 패턴이 아닌 선박만)
# 정규화 키(공백 제거) + 원본 이름 모두 등록
name_to_mmsi: dict[str, str] = {}
for mmsi, pos in all_positions.items():
name = (pos.get('name') or '').strip()
if name and not GEAR_PATTERN.match(name):
name_to_mmsi[name] = mmsi
name_to_mmsi[name.replace(' ', '')] = mmsi
# 1단계: 같은 모선명 어구 수집 (60분 이내만)
# parent 이름 정규화 — 공백 제거 후 같은 모선은 하나로 통합
def _normalize_parent(raw: str) -> str:
return raw.replace(' ', '')
# 1단계: 같은 모선명 어구 수집 (60분 이내만, 공백 정규화)
raw_groups: dict[str, list[dict]] = {}
parent_display: dict[str, str] = {} # normalized → 대표 원본 이름
for mmsi, pos in all_positions.items():
name = (pos.get('name') or '').strip()
if not name:
@ -164,7 +171,11 @@ def detect_gear_groups(
if not m:
continue
parent_name = m.group(1).strip()
parent_raw = (m.group(1) or name).strip()
parent_key = _normalize_parent(parent_raw)
# 대표 이름: 공백 없는 버전 우선 (더 정규화된 형태)
if parent_key not in parent_display or ' ' not in parent_raw:
parent_display[parent_key] = parent_raw
entry = {
'mmsi': mmsi,
'name': name,
@ -173,61 +184,121 @@ def detect_gear_groups(
'sog': pos.get('sog', 0),
'cog': pos.get('cog', 0),
}
raw_groups.setdefault(parent_name, []).append(entry)
raw_groups.setdefault(parent_key, []).append(entry)
# 2단계: 거리 기반 서브 클러스터링 (anchor 기준 MAX_DIST_DEG 이내만)
# 2단계: 연결 기반 서브 클러스터링 (각 어구가 클러스터 내 최소 1개와 MAX_DIST_DEG 이내)
# 같은 parent 이름이라도 거리가 먼 어구들은 별도 서브그룹으로 분리
results: list[dict] = []
for parent_name, gears in raw_groups.items():
parent_mmsi = name_to_mmsi.get(parent_name)
for parent_key, gears in raw_groups.items():
parent_mmsi = name_to_mmsi.get(parent_key)
display_name = parent_display.get(parent_key, parent_key)
# 기준점(anchor): 모선 있으면 모선 위치, 없으면 첫 어구
anchor_lat: Optional[float] = None
anchor_lon: Optional[float] = None
if not gears:
continue
# 모선 위치 (있으면 시드 포인트로 활용)
seed_lat: Optional[float] = None
seed_lon: Optional[float] = None
if parent_mmsi and parent_mmsi in all_positions:
parent_pos = all_positions[parent_mmsi]
anchor_lat = parent_pos['lat']
anchor_lon = parent_pos['lon']
p = all_positions[parent_mmsi]
seed_lat, seed_lon = p['lat'], p['lon']
if anchor_lat is None and gears:
anchor_lat = gears[0]['lat']
anchor_lon = gears[0]['lon']
# 연결 기반 클러스터링 (Union-Find 방식)
n = len(gears)
parent_uf = list(range(n))
if anchor_lat is None or anchor_lon is None:
def find(x: int) -> int:
while parent_uf[x] != x:
parent_uf[x] = parent_uf[parent_uf[x]]
x = parent_uf[x]
return x
def union(a: int, b: int) -> None:
ra, rb = find(a), find(b)
if ra != rb:
parent_uf[ra] = rb
for i in range(n):
for j in range(i + 1, n):
if (abs(gears[i]['lat'] - gears[j]['lat']) <= MAX_DIST_DEG
and abs(gears[i]['lon'] - gears[j]['lon']) <= MAX_DIST_DEG):
union(i, j)
# 클러스터별 그룹화
clusters: dict[int, list[int]] = {}
for i in range(n):
clusters.setdefault(find(i), []).append(i)
# 모선이 있으면 모선과 가장 가까운 클러스터에 연결 (MAX_DIST_DEG 이내만)
seed_cluster_root: Optional[int] = None
if seed_lat is not None and seed_lon is not None:
best_dist = float('inf')
for root, idxs in clusters.items():
for i in idxs:
d = abs(gears[i]['lat'] - seed_lat) + abs(gears[i]['lon'] - seed_lon)
if d < best_dist:
best_dist = d
seed_cluster_root = root
# 모선이 어느 클러스터와도 MAX_DIST_DEG 초과 → 연결하지 않음
if best_dist > MAX_DIST_DEG * 2:
seed_cluster_root = None
# 클러스터마다 서브그룹 생성 (최소 2개 이상이거나 모선 포함)
for ci, (root, idxs) in enumerate(clusters.items()):
has_seed = (root == seed_cluster_root)
if len(idxs) < 2 and not has_seed:
continue
members = [
{'mmsi': gears[i]['mmsi'], 'name': gears[i]['name'],
'lat': gears[i]['lat'], 'lon': gears[i]['lon'],
'sog': gears[i]['sog'], 'cog': gears[i]['cog']}
for i in idxs
]
# 서브그룹 이름: 1개면 원본, 2개 이상이면 #1, #2
sub_name = display_name if len(clusters) == 1 else f'{display_name}#{ci + 1}'
sub_mmsi = parent_mmsi if has_seed else None
results.append({
'parent_name': sub_name,
'parent_key': parent_key,
'parent_mmsi': sub_mmsi,
'members': members,
})
# 3단계: 동일 parent_key 서브그룹 간 근접 병합 (거리 이내 시)
# prefix 기반 병합은 과도한 그룹화 유발 → 동일 키만 병합
def _groups_nearby(a: dict, b: dict) -> bool:
for ma in a['members']:
for mb in b['members']:
if abs(ma['lat'] - mb['lat']) <= MAX_DIST_DEG and abs(ma['lon'] - mb['lon']) <= MAX_DIST_DEG:
return True
return False
merged: list[dict] = []
skip: set[int] = set()
results.sort(key=lambda g: len(g['members']), reverse=True)
for i, big in enumerate(results):
if i in skip:
continue
for j, small in enumerate(results):
if j <= i or j in skip:
continue
# 동일 parent_key만 병합 (prefix 매칭 제거 — 과도한 병합 방지)
if big['parent_key'] == small['parent_key'] and _groups_nearby(big, small):
existing_mmsis = {m['mmsi'] for m in big['members']}
for m in small['members']:
if m['mmsi'] not in existing_mmsis:
big['members'].append(m)
existing_mmsis.add(m['mmsi'])
if not big['parent_mmsi'] and small['parent_mmsi']:
big['parent_mmsi'] = small['parent_mmsi']
skip.add(j)
del big['parent_key']
merged.append(big)
# MAX_DIST_DEG 이내 어구만 포함
_anchor_lat: float = anchor_lat
_anchor_lon: float = anchor_lon
nearby = [
g for g in gears
if abs(g['lat'] - _anchor_lat) <= MAX_DIST_DEG
and abs(g['lon'] - _anchor_lon) <= MAX_DIST_DEG
]
if not nearby:
continue
# members 구성: 어구 목록
members = [
{
'mmsi': g['mmsi'],
'name': g['name'],
'lat': g['lat'],
'lon': g['lon'],
'sog': g['sog'],
'cog': g['cog'],
}
for g in nearby
]
results.append({
'parent_name': parent_name,
'parent_mmsi': parent_mmsi,
'members': members,
})
return results
return merged
def build_all_group_snapshots(
@ -340,13 +411,18 @@ def build_all_group_snapshots(
if not in_zone and len(gear_members) < MIN_GEAR_GROUP_SIZE:
continue
# 폴리곤 points: 어구 좌표 + 모선 좌표
# 폴리곤 points: 어구 좌표 + 모선 좌표 (근접 시에만)
points = [(g['lon'], g['lat']) for g in gear_members]
parent_nearby = False
if parent_mmsi and parent_mmsi in all_positions:
parent_pos = all_positions[parent_mmsi]
p_lon, p_lat = parent_pos['lon'], parent_pos['lat']
if (p_lon, p_lat) not in points:
points.append((p_lon, p_lat))
# 모선이 어구 클러스터 내 최소 1개와 MAX_DIST_DEG*2 이내일 때만 포함
if any(abs(g['lat'] - p_lat) <= MAX_DIST_DEG * 2
and abs(g['lon'] - p_lon) <= MAX_DIST_DEG * 2 for g in gear_members):
if (p_lon, p_lat) not in points:
points.append((p_lon, p_lat))
parent_nearby = True
polygon_wkt, center_wkt, area_sq_nm, _clat, _clon = build_group_polygon(
points, GEAR_BUFFER_DEG
@ -354,8 +430,8 @@ def build_all_group_snapshots(
# members JSONB 구성
members_out: list[dict] = []
# 모선 먼저
if parent_mmsi and parent_mmsi in all_positions:
# 모선 먼저 (근접 시에만)
if parent_nearby and parent_mmsi and parent_mmsi in all_positions:
parent_pos = all_positions[parent_mmsi]
members_out.append({
'mmsi': parent_mmsi,

파일 보기

@ -158,3 +158,87 @@ def match_gear_by_track(
})
return results
def compute_sog_correlation(
sog_a: list[float],
sog_b: list[float],
) -> float:
"""두 SOG 시계열의 피어슨 상관계수 (0~1 정규화).
시계열 길이가 다르면 짧은 기준으로 자름.
데이터 부족(< 3)이면 0.0 반환.
"""
n = min(len(sog_a), len(sog_b))
if n < 3:
return 0.0
a = sog_a[:n]
b = sog_b[:n]
mean_a = sum(a) / n
mean_b = sum(b) / n
cov = sum((a[i] - mean_a) * (b[i] - mean_b) for i in range(n))
var_a = sum((x - mean_a) ** 2 for x in a)
var_b = sum((x - mean_b) ** 2 for x in b)
denom = (var_a * var_b) ** 0.5
if denom < 1e-12:
return 0.0
corr = cov / denom # -1 ~ 1
return max(0.0, (corr + 1.0) / 2.0) # 0 ~ 1 정규화
def compute_heading_coherence(
cog_a: list[float],
cog_b: list[float],
threshold_deg: float = 30.0,
) -> float:
"""두 COG 시계열의 방향 동조율 (0~1).
angular diff < threshold_deg 비율.
시계열 길이가 다르면 짧은 기준.
데이터 부족(< 3)이면 0.0 반환.
"""
n = min(len(cog_a), len(cog_b))
if n < 3:
return 0.0
coherent = 0
for i in range(n):
diff = abs(cog_a[i] - cog_b[i])
if diff > 180.0:
diff = 360.0 - diff
if diff < threshold_deg:
coherent += 1
return coherent / n
def compute_proximity_ratio(
track_a: list[tuple[float, float]],
track_b: list[tuple[float, float]],
threshold_nm: float = 10.0,
) -> float:
"""두 궤적의 근접 지속비 (0~1).
시간 정렬된 포인트 쌍에서 haversine < threshold_nm 비율.
시계열 길이가 다르면 짧은 기준.
데이터 부족(< 2)이면 0.0 반환.
"""
n = min(len(track_a), len(track_b))
if n < 2:
return 0.0
close = 0
threshold_m = threshold_nm * 1852.0
for i in range(n):
dist = haversine_m(track_a[i][0], track_a[i][1],
track_b[i][0], track_b[i][1])
if dist < threshold_m:
close += 1
return close / n

파일 보기

@ -349,6 +349,66 @@ class VesselStore:
}
return result
def get_vessel_tracks(self, mmsis: list[str], hours: int = 24) -> dict[str, list[dict]]:
"""Return track points for given MMSIs within the specified hours window.
Returns dict mapping mmsi to list of {ts, lat, lon, sog, cog} dicts,
sorted by timestamp ascending.
"""
import datetime as _dt
now = datetime.now(timezone.utc)
cutoff_aware = now - _dt.timedelta(hours=hours)
cutoff_naive = cutoff_aware.replace(tzinfo=None)
result: dict[str, list[dict]] = {}
for mmsi in mmsis:
df = self._tracks.get(mmsi)
if df is None or len(df) == 0:
continue
ts_col = df['timestamp']
if hasattr(ts_col.dtype, 'tz') and ts_col.dtype.tz is not None:
mask = ts_col >= pd.Timestamp(cutoff_aware)
else:
mask = ts_col >= pd.Timestamp(cutoff_naive)
filtered = df[mask].sort_values('timestamp')
if filtered.empty:
continue
# Compute SOG/COG for this vessel's track
if len(filtered) >= 2:
track_with_sog = _compute_sog_cog(filtered.copy())
else:
track_with_sog = filtered.copy()
if 'sog' not in track_with_sog.columns:
track_with_sog['sog'] = track_with_sog.get('raw_sog', 0)
if 'cog' not in track_with_sog.columns:
track_with_sog['cog'] = 0
points = []
for _, row in track_with_sog.iterrows():
ts = row['timestamp']
# Convert to epoch ms
if hasattr(ts, 'timestamp'):
epoch_ms = int(ts.timestamp() * 1000)
else:
epoch_ms = int(pd.Timestamp(ts).timestamp() * 1000)
points.append({
'ts': epoch_ms,
'lat': float(row['lat']),
'lon': float(row['lon']),
'sog': float(row.get('sog', 0) or 0),
'cog': float(row.get('cog', 0) or 0),
})
if points:
result[mmsi] = points
return result
def get_chinese_mmsis(self) -> set:
"""Return the set of all Chinese vessel MMSIs (412*) currently in the store."""
return {m for m in self._tracks if m.startswith(_CHINESE_MMSI_PREFIX)}

파일 보기

@ -197,6 +197,9 @@ def execute_tool_call(call: dict) -> str:
if tool == 'get_knowledge':
return _get_knowledge(params)
if tool == 'query_gear_correlation':
return _query_gear_correlation(params)
return f'(알 수 없는 도구: {tool})'
@ -357,3 +360,54 @@ def _query_vessel_static(params: dict) -> str:
except Exception as e:
logger.error('vessel static query failed: %s', e)
return f'\n(정적정보 조회 실패: {e})\n'
def _query_gear_correlation(params: dict) -> str:
"""어구 그룹의 연관 선박/어구 조회."""
from db import kcgdb
group_key = params.get('group_key', '')
limit = int(params.get('limit', 10))
with kcgdb.get_conn() as conn:
cur = conn.cursor()
try:
cur.execute(
'SELECT target_name, target_mmsi, target_type, current_score, '
'streak_count, observation_count, proximity_ratio, visit_score, '
'heading_coherence, freeze_state '
'FROM kcg.gear_correlation_scores s '
'JOIN kcg.correlation_param_models m ON s.model_id = m.id '
'WHERE s.group_key = %s AND m.is_default = TRUE AND s.current_score >= 0.3 '
'ORDER BY s.current_score DESC LIMIT %s',
(group_key, limit),
)
rows = cur.fetchall()
except Exception:
return f'어구 그룹 "{group_key}"에 대한 연관성 데이터가 없습니다 (테이블 미생성).'
finally:
cur.close()
if not rows:
return f'어구 그룹 "{group_key}"에 대한 연관성 데이터가 없습니다.'
lines = [f'## {group_key} 연관 분석 (상위 {len(rows)}개, default 모델)']
for r in rows:
name, mmsi, ttype, score, streak, obs, prox, visit, heading, state = r
pct = score * 100
disp_name = name or mmsi
detail_parts = []
if prox is not None:
detail_parts.append(f'근접 {prox*100:.0f}%')
if visit is not None:
detail_parts.append(f'방문 {visit*100:.0f}%')
if heading is not None:
detail_parts.append(f'COG동조 {heading*100:.0f}%')
detail = ', '.join(detail_parts) if detail_parts else ''
lines.append(
f'- **{disp_name}** ({mmsi}, {ttype}): '
f'일치율 {pct:.1f}% (연속 {streak}회, 관측 {obs}회) '
f'[{detail}] 상태: {state}'
)
return '\n'.join(lines)

파일 보기

@ -0,0 +1,136 @@
"""gear_correlation_raw_metrics 파티션 유지보수.
APScheduler 일별 작업으로 실행:
- system_config에서 설정 읽기 (hot-reload, 프로세스 재시작 불필요)
- 미래 파티션 미리 생성
- 만료 파티션 DROP
- 미관측 점수 레코드 정리
"""
import logging
from datetime import date, datetime, timedelta
logger = logging.getLogger(__name__)
def _get_config_int(conn, key: str, default: int) -> int:
"""system_config에서 설정값 조회. 없으면 default."""
cur = conn.cursor()
try:
cur.execute(
"SELECT value::text FROM kcg.system_config WHERE key = %s",
(key,),
)
row = cur.fetchone()
return int(row[0].strip('"')) if row else default
except Exception:
return default
finally:
cur.close()
def _create_future_partitions(conn, days_ahead: int) -> int:
"""미래 N일 파티션 생성. 반환: 생성된 파티션 수."""
cur = conn.cursor()
created = 0
try:
for i in range(days_ahead + 1):
d = date.today() + timedelta(days=i)
partition_name = f'gear_correlation_raw_metrics_{d.strftime("%Y%m%d")}'
cur.execute(
"SELECT 1 FROM pg_class c "
"JOIN pg_namespace n ON n.oid = c.relnamespace "
"WHERE c.relname = %s AND n.nspname = 'kcg'",
(partition_name,),
)
if cur.fetchone() is None:
next_d = d + timedelta(days=1)
cur.execute(
f"CREATE TABLE IF NOT EXISTS kcg.{partition_name} "
f"PARTITION OF kcg.gear_correlation_raw_metrics "
f"FOR VALUES FROM ('{d.isoformat()}') TO ('{next_d.isoformat()}')"
)
created += 1
logger.info('created partition: kcg.%s', partition_name)
conn.commit()
except Exception as e:
conn.rollback()
logger.error('failed to create partitions: %s', e)
finally:
cur.close()
return created
def _drop_expired_partitions(conn, retention_days: int) -> int:
"""retention_days 초과 파티션 DROP. 반환: 삭제된 파티션 수."""
cutoff = date.today() - timedelta(days=retention_days)
cur = conn.cursor()
dropped = 0
try:
cur.execute(
"SELECT c.relname FROM pg_class c "
"JOIN pg_namespace n ON n.oid = c.relnamespace "
"WHERE c.relname LIKE 'gear_correlation_raw_metrics_%%' "
"AND n.nspname = 'kcg' AND c.relkind = 'r'"
)
for (name,) in cur.fetchall():
date_str = name.rsplit('_', 1)[-1]
try:
partition_date = datetime.strptime(date_str, '%Y%m%d').date()
except ValueError:
continue
if partition_date < cutoff:
cur.execute(f'DROP TABLE IF EXISTS kcg.{name}')
dropped += 1
logger.info('dropped expired partition: kcg.%s', name)
conn.commit()
except Exception as e:
conn.rollback()
logger.error('failed to drop partitions: %s', e)
finally:
cur.close()
return dropped
def _cleanup_stale_scores(conn, cleanup_days: int) -> int:
"""cleanup_days 이상 미관측 점수 레코드 삭제."""
cur = conn.cursor()
try:
cur.execute(
"DELETE FROM kcg.gear_correlation_scores "
"WHERE last_observed_at < NOW() - make_interval(days => %s)",
(cleanup_days,),
)
deleted = cur.rowcount
conn.commit()
return deleted
except Exception as e:
conn.rollback()
logger.error('failed to cleanup stale scores: %s', e)
return 0
finally:
cur.close()
def maintain_partitions():
"""일별 파티션 유지보수 — 스케줄러에서 호출.
system_config에서 설정을 매번 읽으므로
API를 통한 설정 변경이 다음 실행 즉시 반영됨.
"""
from db import kcgdb
with kcgdb.get_conn() as conn:
retention = _get_config_int(conn, 'partition.raw_metrics.retention_days', 7)
ahead = _get_config_int(conn, 'partition.raw_metrics.create_ahead_days', 3)
cleanup_days = _get_config_int(conn, 'partition.scores.cleanup_days', 30)
created = _create_future_partitions(conn, ahead)
dropped = _drop_expired_partitions(conn, retention)
cleaned = _cleanup_stale_scores(conn, cleanup_days)
logger.info(
'partition maintenance: %d created, %d dropped, %d stale scores cleaned '
'(retention=%dd, ahead=%dd, cleanup=%dd)',
created, dropped, cleaned, retention, ahead, cleanup_days,
)

파일 보기

@ -9,8 +9,8 @@ import pandas as pd
logger = logging.getLogger(__name__)
# 어구 이름 패턴
GEAR_PATTERN = re.compile(r'^(.+?)_(\d+)_(\d*)$')
# 어구 이름 패턴 — 공백/영숫자 인덱스/끝_ 허용
GEAR_PATTERN = re.compile(r'^(.+?)_(?=\S*\d)\S+(?:[_ ]\S*)*[_ ]*$|^(\d+)$')
GEAR_PATTERN_PCT = re.compile(r'^(.+?)%$')
_REGISTRY_CACHE_SEC = 3600
@ -139,9 +139,16 @@ class FleetTracker:
m = GEAR_PATTERN.match(name)
if m:
parent_name = m.group(1).strip()
idx1 = int(m.group(2))
idx2 = int(m.group(3)) if m.group(3) else None
# group(1): parent+index 패턴, group(2): 순수 숫자 패턴
if m.group(1):
parent_name = m.group(1).strip()
suffix = name[m.end(1):].strip(' _')
digits = re.findall(r'\d+', suffix)
idx1 = int(digits[0]) if len(digits) >= 1 else None
idx2 = int(digits[1]) if len(digits) >= 2 else None
else:
# 순수 숫자 이름 (예: 12345) — parent 없음, 인덱스만
idx1 = int(m.group(2))
else:
m2 = GEAR_PATTERN_PCT.match(name)
if m2:
@ -210,6 +217,22 @@ class FleetTracker:
)
logger.info('gear MMSI change: %s%s (name=%s)', old_mmsi_row[1], mmsi, name)
# 어피니티 점수 이전 (이전 MMSI → 새 MMSI)
try:
cur.execute(
"UPDATE kcg.gear_correlation_scores "
"SET target_mmsi = %s, updated_at = NOW() "
"WHERE target_mmsi = %s",
(mmsi, old_mmsi_row[1]),
)
if cur.rowcount > 0:
logger.info(
'transferred %d affinity scores: %s%s',
cur.rowcount, old_mmsi_row[1], mmsi,
)
except Exception as e:
logger.warning('affinity score transfer failed: %s', e)
cur.execute(
"""INSERT INTO kcg.gear_identity_log
(mmsi, name, parent_name, parent_mmsi, parent_vessel_id,

파일 보기

@ -68,3 +68,90 @@ def analysis_status():
def trigger_analysis(background_tasks: BackgroundTasks):
background_tasks.add_task(run_analysis_cycle)
return {'message': 'analysis cycle triggered'}
@app.get('/api/v1/correlation/{group_key:path}/tracks')
def get_correlation_tracks(
group_key: str,
hours: int = 24,
min_score: float = 0.3,
):
"""Return correlated vessels with their track history for map rendering.
Queries gear_correlation_scores (ALL active models) and enriches with
24h track data from in-memory vessel_store.
Each vessel includes which models detected it.
"""
from cache.vessel_store import vessel_store
try:
with kcgdb.get_conn() as conn:
cur = conn.cursor()
# Get correlated vessels from ALL active models
cur.execute("""
SELECT s.target_mmsi, s.target_type, s.target_name,
s.current_score, m.name AS model_name
FROM kcg.gear_correlation_scores s
JOIN kcg.correlation_param_models m ON s.model_id = m.id
WHERE s.group_key = %s
AND s.current_score >= %s
AND m.is_active = TRUE
ORDER BY s.current_score DESC
""", (group_key, min_score))
rows = cur.fetchall()
cur.close()
logger.info('correlation tracks: group_key=%r, min_score=%s, rows=%d',
group_key, min_score, len(rows))
if not rows:
return {'groupKey': group_key, 'vessels': []}
# Group by MMSI: collect all models per vessel, keep highest score
vessel_map: dict[str, dict] = {}
for row in rows:
mmsi = row[0]
model_name = row[4]
score = float(row[3])
if mmsi not in vessel_map:
vessel_map[mmsi] = {
'mmsi': mmsi,
'type': row[1],
'name': row[2] or '',
'score': score,
'models': {model_name: score},
}
else:
entry = vessel_map[mmsi]
entry['models'][model_name] = score
if score > entry['score']:
entry['score'] = score
mmsis = list(vessel_map.keys())
# Get tracks from vessel_store
tracks = vessel_store.get_vessel_tracks(mmsis, hours)
with_tracks = sum(1 for m in mmsis if m in tracks and len(tracks[m]) > 0)
logger.info('correlation tracks: %d unique mmsis, %d with track data, vessel_store._tracks has %d entries',
len(mmsis), with_tracks, len(vessel_store._tracks))
# Build response
vessels = []
for info in vessel_map.values():
track = tracks.get(info['mmsi'], [])
vessels.append({
'mmsi': info['mmsi'],
'name': info['name'],
'type': info['type'],
'score': info['score'],
'models': info['models'], # {modelName: score, ...}
'track': track,
})
return {'groupKey': group_key, 'vessels': vessels}
except Exception as e:
logger.warning('get_correlation_tracks failed for %s: %s', group_key, e)
return {'groupKey': group_key, 'vessels': []}

파일 보기

@ -75,7 +75,7 @@ def run_analysis_cycle():
return
# 4. 등록 선단 기반 fleet 분석
_gear_re = _re.compile(r'^.+_\d+_\d*$|%$')
_gear_re = _re.compile(r'^.+_(?=\S*\d)\S+(?:[_ ]\S*)*[_ ]*$|^\d+$|^.+%$')
with kcgdb.get_conn() as kcg_conn:
fleet_tracker.load_registry(kcg_conn)
@ -99,6 +99,8 @@ def run_analysis_cycle():
fleet_tracker.save_snapshot(vessel_dfs, kcg_conn)
gear_groups = []
# 4.5 그룹 폴리곤 생성 + 저장
try:
from algorithms.polygon_builder import detect_gear_groups, build_all_group_snapshots
@ -116,6 +118,23 @@ def run_analysis_cycle():
except Exception as e:
logger.warning('group polygon generation failed: %s', e)
# 4.7 어구 연관성 분석 (멀티모델 패턴 추적)
try:
from algorithms.gear_correlation import run_gear_correlation
corr_result = run_gear_correlation(
vessel_store=vessel_store,
gear_groups=gear_groups,
conn=kcg_conn,
)
logger.info(
'gear correlation: %d scores updated, %d raw metrics, %d models',
corr_result['updated'], corr_result['raw_inserted'],
corr_result['models'],
)
except Exception as e:
logger.warning('gear correlation failed: %s', e)
# 5. 선박별 추가 알고리즘 → AnalysisResult 생성
results = []
for c in classifications:
@ -329,6 +348,15 @@ def start_scheduler():
max_instances=1,
replace_existing=True,
)
# 파티션 유지보수 (매일 04:00)
from db.partition_manager import maintain_partitions
_scheduler.add_job(
maintain_partitions,
'cron', hour=4, minute=0,
id='partition_maintenance',
max_instances=1,
replace_existing=True,
)
_scheduler.start()
logger.info('scheduler started (interval=%dm)', settings.SCHEDULER_INTERVAL_MIN)