From f09186a18727ca19b7656ce4e2f9173d50699554 Mon Sep 17 00:00:00 2001 From: htlee Date: Wed, 1 Apr 2026 09:01:03 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=EC=96=B4=EA=B5=AC=20=EB=A6=AC?= =?UTF-8?q?=ED=94=8C=EB=A0=88=EC=9D=B4=20=EC=84=9C=EB=B8=8C=ED=81=B4?= =?UTF-8?q?=EB=9F=AC=EC=8A=A4=ED=84=B0=20=EB=B6=84=EB=A6=AC=20=EB=A0=8C?= =?UTF-8?q?=EB=8D=94=EB=A7=81=20+=20=EC=9D=BC=EC=B9=98=EC=9C=A8=20?= =?UTF-8?q?=EA=B0=90=EC=87=A0=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 서브클러스터별 독립 폴리곤/센터/center trail 렌더링 - 반경 밖 이탈 선박 강제 감쇠 (OUT_OF_RANGE) - Backend correlation API에 sub_cluster_id 추가 - 모델 패널 5개 항상 표시, 드롭다운 기본값 70% - DISPLAY_STALE_SEC (time_bucket 기반) 폴리곤 노출 필터 - AIS 수집 bbox 122~132E/31~39N 확장 - historyActive 시 deck.gl 이중 렌더링 수정 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../kcg/domain/fleet/GroupPolygonService.java | 6 +- .../src/components/korea/CorrelationPanel.tsx | 23 +- .../components/korea/FleetClusterLayer.tsx | 76 ++++-- .../korea/HistoryReplayController.tsx | 3 +- frontend/src/components/korea/KoreaMap.tsx | 26 +- .../src/components/korea/fleetClusterTypes.ts | 17 +- .../src/components/korea/fleetClusterUtils.ts | 94 ++++++- .../korea/useFleetClusterGeoJson.ts | 55 +++-- .../src/hooks/useFleetClusterDeckLayers.ts | 4 +- frontend/src/hooks/useGearReplayLayers.ts | 233 +++++++++++------- frontend/src/services/vesselAnalysis.ts | 1 + frontend/src/stores/gearReplayPreprocess.ts | 179 ++++++++++---- prediction/algorithms/gear_correlation.py | 30 ++- prediction/algorithms/polygon_builder.py | 49 +++- prediction/cache/vessel_store.py | 1 + prediction/db/snpdb.py | 8 +- 16 files changed, 568 insertions(+), 237 deletions(-) diff --git a/backend/src/main/java/gc/mda/kcg/domain/fleet/GroupPolygonService.java b/backend/src/main/java/gc/mda/kcg/domain/fleet/GroupPolygonService.java index 160b885..0012c79 100644 --- a/backend/src/main/java/gc/mda/kcg/domain/fleet/GroupPolygonService.java +++ b/backend/src/main/java/gc/mda/kcg/domain/fleet/GroupPolygonService.java @@ -60,15 +60,16 @@ public class GroupPolygonService { private static final String GROUP_CORRELATIONS_SQL = """ WITH best_scores AS ( - SELECT DISTINCT ON (m.id, s.target_mmsi) + SELECT DISTINCT ON (m.id, s.sub_cluster_id, s.target_mmsi) 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, + s.sub_cluster_id, 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 WHERE s.group_key = ? AND s.current_score >= ? AND m.is_active = TRUE - ORDER BY m.id, s.target_mmsi, s.current_score DESC + ORDER BY m.id, s.sub_cluster_id, s.target_mmsi, s.current_score DESC ) SELECT bs.*, r.proximity_ratio, r.visit_score, r.heading_coherence @@ -120,6 +121,7 @@ public class GroupPolygonService { row.put("observations", rs.getInt("observation_count")); row.put("freezeState", rs.getString("freeze_state")); row.put("shadowBonus", rs.getDouble("shadow_bonus_total")); + row.put("subClusterId", rs.getInt("sub_cluster_id")); row.put("proximityRatio", rs.getObject("proximity_ratio")); row.put("visitScore", rs.getObject("visit_score")); row.put("headingCoherence", rs.getObject("heading_coherence")); diff --git a/frontend/src/components/korea/CorrelationPanel.tsx b/frontend/src/components/korea/CorrelationPanel.tsx index 0dfd5be..413ac59 100644 --- a/frontend/src/components/korea/CorrelationPanel.tsx +++ b/frontend/src/components/korea/CorrelationPanel.tsx @@ -292,23 +292,26 @@ const CorrelationPanel = ({ {memberCount} {correlationLoading &&
로딩...
} - {availableModels.map(m => { - const color = MODEL_COLORS[m.name] ?? '#94a3b8'; - const modelItems = correlationByModel.get(m.name) ?? []; + {_MODEL_ORDER.filter(mn => mn !== 'identity').map(mn => { + const color = MODEL_COLORS[mn] ?? '#94a3b8'; + const modelItems = correlationByModel.get(mn) ?? []; + const hasData = modelItems.length > 0; const vc = modelItems.filter(c => c.targetType === 'VESSEL').length; const gc = modelItems.filter(c => c.targetType !== 'VESSEL').length; + const am = availableModels.find(m => m.name === mn); return ( - ); })} diff --git a/frontend/src/components/korea/FleetClusterLayer.tsx b/frontend/src/components/korea/FleetClusterLayer.tsx index 3cd16e8..7b00ef5 100644 --- a/frontend/src/components/korea/FleetClusterLayer.tsx +++ b/frontend/src/components/korea/FleetClusterLayer.tsx @@ -42,7 +42,7 @@ function splitAndMergeHistory(history: GroupPolygonDto[]) { ([subClusterId, data]) => ({ subClusterId, ...data }), ); - // 2. 시간별 멤버 합산 프레임 (기존 리플레이 호환) + // 2. 시간별 그룹핑 후 서브클러스터 보존 const byTime = new Map(); for (const h of sorted) { const list = byTime.get(h.snapshotTime) ?? []; @@ -52,34 +52,63 @@ function splitAndMergeHistory(history: GroupPolygonDto[]) { const frames: GroupPolygonDto[] = []; for (const [, items] of byTime) { - if (items.length === 1) { - frames.push(items[0]); - continue; - } - const seen = new Set(); - const allMembers: GroupPolygonDto['members'] = []; - for (const item of items) { - for (const m of item.members) { - if (!seen.has(m.mmsi)) { seen.add(m.mmsi); allMembers.push(m); } + const allSameId = items.every(item => (item.subClusterId ?? 0) === 0); + + if (items.length === 1 || allSameId) { + // 단일 아이템 또는 모두 subClusterId=0: 통합 서브프레임 1개 + const base = items.length === 1 ? items[0] : (() => { + const seen = new Set(); + const allMembers: GroupPolygonDto['members'] = []; + for (const item of items) { + for (const m of item.members) { + if (!seen.has(m.mmsi)) { seen.add(m.mmsi); allMembers.push(m); } + } + } + const biggest = items.reduce((a, b) => (a.memberCount >= b.memberCount ? a : b)); + return { ...biggest, subClusterId: 0, members: allMembers, memberCount: allMembers.length }; + })(); + const subFrames: SubFrame[] = [{ + subClusterId: 0, + centerLon: base.centerLon, + centerLat: base.centerLat, + members: base.members, + memberCount: base.memberCount, + }]; + frames.push({ ...base, subFrames } as GroupPolygonDto & { subFrames: SubFrame[] }); + } else { + // 서로 다른 subClusterId: 각 아이템을 개별 서브프레임으로 보존 + const subFrames: SubFrame[] = items.map(item => ({ + subClusterId: item.subClusterId ?? 0, + centerLon: item.centerLon, + centerLat: item.centerLat, + members: item.members, + memberCount: item.memberCount, + })); + const seen = new Set(); + const allMembers: GroupPolygonDto['members'] = []; + for (const sf of subFrames) { + for (const m of sf.members) { + if (!seen.has(m.mmsi)) { seen.add(m.mmsi); allMembers.push(m); } + } } + const biggest = items.reduce((a, b) => (a.memberCount >= b.memberCount ? a : b)); + frames.push({ + ...biggest, + subClusterId: 0, + members: allMembers, + memberCount: allMembers.length, + centerLat: biggest.centerLat, + centerLon: biggest.centerLon, + subFrames, + } as GroupPolygonDto & { subFrames: SubFrame[] }); } - const biggest = items.reduce((a, b) => (a.memberCount >= b.memberCount ? a : b)); - frames.push({ - ...biggest, - subClusterId: 0, - members: allMembers, - memberCount: allMembers.length, - // 가장 큰 서브클러스터의 center 사용 (가중 평균 아닌 대표 center) - centerLat: biggest.centerLat, - centerLon: biggest.centerLon, - }); } - return { frames: fillGapFrames(frames), subClusterCenters }; + return { frames: fillGapFrames(frames as HistoryFrame[]), subClusterCenters }; } // ── 분리된 모듈 ── -import type { PickerCandidate, HoverTooltipState, GearPickerPopupState } from './fleetClusterTypes'; +import type { PickerCandidate, HoverTooltipState, GearPickerPopupState, SubFrame, HistoryFrame } from './fleetClusterTypes'; import { EMPTY_ANALYSIS } from './fleetClusterTypes'; import { fillGapFrames } from './fleetClusterUtils'; import { useFleetClusterGeoJson } from './useFleetClusterGeoJson'; @@ -521,8 +550,9 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS onClose={closeHistory} onFilterByScore={(minPct) => { // 전역: 모든 모델의 모든 대상에 적용 (모델 on/off 무관) + // null(전체) = 30% 이상 전부 ON (API minScore=0.3 기준) if (minPct === null) { - setEnabledVessels(new Set(correlationTracks.map(v => v.mmsi))); + setEnabledVessels(new Set(correlationTracks.filter(v => v.score >= 0.3).map(v => v.mmsi))); } else { const threshold = minPct / 100; const filtered = new Set(); diff --git a/frontend/src/components/korea/HistoryReplayController.tsx b/frontend/src/components/korea/HistoryReplayController.tsx index 04662f7..2ebbfc9 100644 --- a/frontend/src/components/korea/HistoryReplayController.tsx +++ b/frontend/src/components/korea/HistoryReplayController.tsx @@ -111,6 +111,7 @@ const HistoryReplayController = ({ onClose, onFilterByScore }: HistoryReplayCont | 일치율 { - 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="히스토리 타임라인" /> - {frameCount}건 - - - - {/* 컨트롤 행 2: 표시 옵션 */} -
+ style={{ ...btnStyle, fontSize: 12 }}>{isPlaying ? '⏸' : '▶'} + --:-- + | + style={showTrails ? btnActiveStyle : btnStyle} title="항적">항적 + style={showLabels ? btnActiveStyle : btnStyle} title="이름">이름 - | + style={focusMode ? { ...btnStyle, background: 'rgba(239,68,68,0.15)', color: '#f87171' } : btnStyle} + title="집중 모드">집중 + | + + + | + + | 일치율 - { onFilterByScore(e.target.value === '' ? null : Number(e.target.value)); }} + 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="일치율 필터"> @@ -130,6 +484,15 @@ const HistoryReplayController = ({ onClose, onFilterByScore }: HistoryReplayCont + + + {frameCount} + {has6hData && <> / {frameCount6h}} 건 + +
); diff --git a/frontend/src/hooks/useGearReplayLayers.ts b/frontend/src/hooks/useGearReplayLayers.ts index 503de03..010fd8d 100644 --- a/frontend/src/hooks/useGearReplayLayers.ts +++ b/frontend/src/hooks/useGearReplayLayers.ts @@ -71,6 +71,14 @@ export function useGearReplayLayers( const modelCenterTrails = useGearReplayStore(s => s.modelCenterTrails); const showTrails = useGearReplayStore(s => s.showTrails); const showLabels = useGearReplayStore(s => s.showLabels); + const show1hPolygon = useGearReplayStore(s => s.show1hPolygon); + const show6hPolygon = useGearReplayStore(s => s.show6hPolygon); + const historyFrames6h = useGearReplayStore(s => s.historyFrames6h); + const memberTripsData6h = useGearReplayStore(s => s.memberTripsData6h); + const centerTrailSegments6h = useGearReplayStore(s => s.centerTrailSegments6h); + const centerDotsPositions6h = useGearReplayStore(s => s.centerDotsPositions6h); + const subClusterCenters6h = useGearReplayStore(s => s.subClusterCenters6h); + const pinnedMmsis = useGearReplayStore(s => s.pinnedMmsis); const { fontScale } = useFontScale(); const fs = fontScale.analysis; const zoomLevel = useShipDeckStore(s => s.zoomLevel); @@ -158,14 +166,50 @@ export function useGearReplayLayers( } } + // ── 6h 센터 트레일 (정적, frameIdx와 무관) ─────────────────────────── + if (state.show6hPolygon) { + const hasSub6h = subClusterCenters6h.length > 0 && subClusterCenters6h.some(sc => sc.subClusterId > 0); + if (hasSub6h) { + for (const sc of subClusterCenters6h) { + if (sc.subClusterId === 0) continue; + if (sc.path.length < 2) continue; + layers.push(new PathLayer({ + id: `replay-6h-sub-center-${sc.subClusterId}`, + data: [{ path: sc.path }], + getPath: (d: { path: [number, number][] }) => d.path, + getColor: [147, 197, 253, 120] as [number, number, number, number], + widthMinPixels: 1.5, + })); + } + } else { + for (let i = 0; i < centerTrailSegments6h.length; i++) { + const seg = centerTrailSegments6h[i]; + if (seg.path.length < 2) continue; + layers.push(new PathLayer({ + id: `replay-6h-center-trail-${i}`, + data: [{ path: seg.path }], + getPath: (d: { path: [number, number][] }) => d.path, + getColor: [147, 197, 253, seg.isInterpolated ? 80 : 120] as [number, number, number, number], + widthMinPixels: 1.5, + })); + } + if (centerDotsPositions6h.length > 0) { + layers.push(new ScatterplotLayer({ + id: 'replay-6h-center-dots', + data: centerDotsPositions6h, + getPosition: (d: [number, number]) => d, + getFillColor: [147, 197, 253, 120] as [number, number, number, number], + getRadius: 80, + radiusUnits: 'meters', + radiusMinPixels: 2, + })); + } + } + } + // ── Dynamic layers (depend on currentTime) ──────────────────────────── - if (frameIdx < 0) { - // No valid frame at this time — only show static layers - replayLayerRef.current = layers; - requestRender(); - return; - } + if (frameIdx >= 0) { const frame = state.historyFrames[frameIdx]; const isStale = !!frame._longGap || !!frame._interp; @@ -484,6 +528,81 @@ export function useGearReplayLayers( } } + // 7b. Pinned highlight (툴팁 고정 시 해당 MMSI 강조) + if (state.pinnedMmsis.size > 0) { + const pinnedPositions: { position: [number, number] }[] = []; + for (const m of members) { + if (state.pinnedMmsis.has(m.mmsi)) pinnedPositions.push({ position: [m.lon, m.lat] }); + } + for (const c of corrPositions) { + if (state.pinnedMmsis.has(c.mmsi)) pinnedPositions.push({ position: [c.lon, c.lat] }); + } + if (pinnedPositions.length > 0) { + // glow + layers.push(new ScatterplotLayer({ + id: 'replay-pinned-glow', + data: pinnedPositions, + getPosition: (d: { position: [number, number] }) => d.position, + getFillColor: [255, 255, 255, 40], + getRadius: 350, + radiusUnits: 'meters', + radiusMinPixels: 12, + })); + // ring + layers.push(new ScatterplotLayer({ + id: 'replay-pinned-ring', + data: pinnedPositions, + getPosition: (d: { position: [number, number] }) => d.position, + getFillColor: [0, 0, 0, 0], + getRadius: 200, + radiusUnits: 'meters', + radiusMinPixels: 6, + stroked: true, + getLineColor: [255, 255, 255, 200], + lineWidthMinPixels: 1.5, + })); + } + + // pinned trails (correlation tracks) + const relTime = ct - st; + for (const trip of correlationTripsData) { + if (!state.pinnedMmsis.has(trip.id)) continue; + let clipIdx = trip.timestamps.length; + for (let i = 0; i < trip.timestamps.length; i++) { + if (trip.timestamps[i] > relTime) { clipIdx = i; break; } + } + const clippedPath = trip.path.slice(0, clipIdx); + if (clippedPath.length >= 2) { + layers.push(new PathLayer({ + id: `replay-pinned-trail-${trip.id}`, + data: [{ path: clippedPath }], + getPath: (d: { path: [number, number][] }) => d.path, + getColor: [255, 255, 255, 150], + widthMinPixels: 2.5, + })); + } + } + + // pinned member trails (identity tracks) + for (const trip of memberTripsData) { + if (!state.pinnedMmsis.has(trip.id)) continue; + let clipIdx = trip.timestamps.length; + for (let i = 0; i < trip.timestamps.length; i++) { + if (trip.timestamps[i] > relTime) { clipIdx = i; break; } + } + const clippedPath = trip.path.slice(0, clipIdx); + if (clippedPath.length >= 2) { + layers.push(new PathLayer({ + id: `replay-pinned-mtrail-${trip.id}`, + data: [{ path: clippedPath }], + getPath: (d: { path: [number, number][] }) => d.path, + getColor: [255, 200, 60, 180], + widthMinPixels: 2.5, + })); + } + } + } + // 8. Operational polygons (서브클러스터별 분리 — subClusterId 기반) for (const [mn, items] of correlationByModel) { if (!enabledModels.has(mn)) continue; @@ -654,24 +773,27 @@ export function useGearReplayLayers( [167, 139, 250, 255], ]; - for (const sf of subFrames) { - const sfMembers = interpolateSubFrameMembers(state.historyFrames, frameIdx, ct, sf.subClusterId); - const sfPts: [number, number][] = sfMembers.map(m => [m.lon, m.lat]); - const poly = buildInterpPolygon(sfPts); - if (!poly) continue; + // ── 1h 폴리곤 (진한색, 실선) ── + if (state.show1hPolygon) { + for (const sf of subFrames) { + const sfMembers = interpolateSubFrameMembers(state.historyFrames, frameIdx, ct, sf.subClusterId); + const sfPts: [number, number][] = sfMembers.map(m => [m.lon, m.lat]); + const poly = buildInterpPolygon(sfPts); + if (!poly) continue; - const ci = sf.subClusterId % SUB_POLY_COLORS.length; - layers.push(new PolygonLayer({ - id: `replay-identity-polygon-sub${sf.subClusterId}`, - data: [{ polygon: poly.coordinates }], - getPolygon: (d: { polygon: number[][][] }) => d.polygon, - getFillColor: isStale ? [148, 163, 184, 30] : SUB_POLY_COLORS[ci], - getLineColor: isStale ? [148, 163, 184, 100] : SUB_STROKE_COLORS[ci], - getLineWidth: isStale ? 1 : 2, - lineWidthMinPixels: 1, - filled: true, - stroked: true, - })); + const ci = sf.subClusterId % SUB_POLY_COLORS.length; + layers.push(new PolygonLayer({ + id: `replay-identity-polygon-1h-sub${sf.subClusterId}`, + data: [{ polygon: poly.coordinates }], + getPolygon: (d: { polygon: number[][][] }) => d.polygon, + getFillColor: isStale ? [148, 163, 184, 30] : SUB_POLY_COLORS[ci], + getLineColor: isStale ? [148, 163, 184, 100] : SUB_STROKE_COLORS[ci], + getLineWidth: isStale ? 1 : 2, + lineWidthMinPixels: 1, + filled: true, + stroked: true, + })); + } } // TripsLayer (멤버 트레일) @@ -717,13 +839,126 @@ export function useGearReplayLayers( })); } + } // end if (frameIdx >= 0) + + // ══ 6h Identity 레이어 (독립 — 1h/모델과 무관) ══ + if (state.show6hPolygon && state.historyFrames6h.length > 0) { + const { index: frameIdx6h } = findFrameAtTime(state.frameTimes6h, ct, 0); + if (frameIdx6h >= 0) { + const frame6h = state.historyFrames6h[frameIdx6h]; + const subFrames6h = frame6h.subFrames ?? [{ subClusterId: 0, centerLon: frame6h.centerLon, centerLat: frame6h.centerLat, members: frame6h.members, memberCount: frame6h.memberCount }]; + const members6h = interpolateMemberPositions(state.historyFrames6h, frameIdx6h, ct); + + // 6h 폴리곤 + for (const sf of subFrames6h) { + const sfMembers = interpolateSubFrameMembers(state.historyFrames6h, frameIdx6h, ct, sf.subClusterId); + const sfPts: [number, number][] = sfMembers.map(m => [m.lon, m.lat]); + const poly = buildInterpPolygon(sfPts); + if (!poly) continue; + layers.push(new PolygonLayer({ + id: `replay-6h-polygon-sub${sf.subClusterId}`, + data: [{ polygon: poly.coordinates }], + getPolygon: (d: { polygon: number[][][] }) => d.polygon, + getFillColor: [147, 197, 253, 25] as [number, number, number, number], + getLineColor: [147, 197, 253, 160] as [number, number, number, number], + getLineWidth: 1, + lineWidthMinPixels: 1, + filled: true, + stroked: true, + })); + } + + // 6h 멤버 아이콘 + if (members6h.length > 0) { + layers.push(new IconLayer({ + id: 'replay-6h-members', + data: members6h, + 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, 150]; + return [147, 197, 253, 200]; + }, + sizeUnits: 'pixels', + billboard: false, + })); + + // 6h 멤버 라벨 + if (showLabels) { + const clustered6h = clusterLabels(members6h, d => [d.lon, d.lat], zoomLevel); + layers.push(new TextLayer({ + id: 'replay-6h-member-labels', + data: clustered6h, + getPosition: d => [d.lon, d.lat], + getText: d => { + const prefix = d.isParent ? '\u2605 ' : ''; + return prefix + (d.name || d.mmsi); + }, + getColor: [147, 197, 253, 230] as [number, number, number, number], + getSize: 10 * fs, + getPixelOffset: [0, 14], + background: true, + getBackgroundColor: [0, 0, 0, 200] as [number, number, number, number], + backgroundPadding: [2, 1], + fontFamily: '"Fira Code Variable", monospace', + })); + } + } + + // 6h TripsLayer (항적 애니메이션) + if (memberTripsData6h.length > 0) { + layers.push(new TripsLayer({ + id: 'replay-6h-identity-trails', + data: memberTripsData6h, + getPath: d => d.path, + getTimestamps: d => d.timestamps, + getColor: [147, 197, 253, 180] as [number, number, number, number], + widthMinPixels: 2, + fadeTrail: true, + trailLength: TRAIL_LENGTH_MS, + currentTime: ct - st, + })); + } + + // 6h 센터 포인트 (서브클러스터별 보간) + for (const sf of subFrames6h) { + const nextFrame6h = frameIdx6h < state.historyFrames6h.length - 1 ? state.historyFrames6h[frameIdx6h + 1] : null; + const nextSf = nextFrame6h?.subFrames?.find(s => s.subClusterId === sf.subClusterId); + let cx = sf.centerLon, cy = sf.centerLat; + if (nextSf && nextFrame6h) { + const t0 = new Date(frame6h.snapshotTime).getTime(); + const t1 = new Date(nextFrame6h.snapshotTime).getTime(); + const r = t1 > t0 ? Math.max(0, Math.min(1, (ct - t0) / (t1 - t0))) : 0; + cx = sf.centerLon + (nextSf.centerLon - sf.centerLon) * r; + cy = sf.centerLat + (nextSf.centerLat - sf.centerLat) * r; + } + layers.push(new ScatterplotLayer({ + id: `replay-6h-center-sub${sf.subClusterId}`, + data: [{ position: [cx, cy] as [number, number] }], + getPosition: (d: { position: [number, number] }) => d.position, + getFillColor: [147, 197, 253, 200] as [number, number, number, number], + getRadius: 150, + radiusUnits: 'meters', + radiusMinPixels: 5, + stroked: true, + getLineColor: [255, 255, 255, 200] as [number, number, number, number], + lineWidthMinPixels: 1.5, + })); + } + } + } + replayLayerRef.current = layers; requestRender(); }, [ - historyFrames, memberTripsData, correlationTripsData, + historyFrames, historyFrames6h, memberTripsData, memberTripsData6h, correlationTripsData, centerTrailSegments, centerDotsPositions, + centerTrailSegments6h, centerDotsPositions6h, subClusterCenters6h, enabledModels, enabledVessels, hoveredMmsi, correlationByModel, - modelCenterTrails, subClusterCenters, showTrails, showLabels, fs, zoomLevel, + modelCenterTrails, subClusterCenters, showTrails, showLabels, + show1hPolygon, show6hPolygon, pinnedMmsis, fs, zoomLevel, replayLayerRef, requestRender, ]); @@ -778,8 +1013,20 @@ export function useGearReplayLayers( }, ); + // 1h/6h 토글 + pinnedMmsis 변경 시 즉시 렌더 + const unsubPolygonToggle = useGearReplayStore.subscribe( + s => [s.show1hPolygon, s.show6hPolygon] as const, + () => { debugLoggedRef.current = false; renderFrame(); }, + ); + const unsubPinned = useGearReplayStore.subscribe( + s => s.pinnedMmsis, + () => renderFrame(), + ); + return () => { unsub(); + unsubPolygonToggle(); + unsubPinned(); if (pendingRafId) cancelAnimationFrame(pendingRafId); }; }, [historyFrames, renderFrame]); diff --git a/frontend/src/services/vesselAnalysis.ts b/frontend/src/services/vesselAnalysis.ts index 57350dc..a1c470c 100644 --- a/frontend/src/services/vesselAnalysis.ts +++ b/frontend/src/services/vesselAnalysis.ts @@ -73,6 +73,7 @@ export interface GroupPolygonDto { zoneName: string | null; members: MemberInfo[]; color: string; + resolution?: '1h' | '6h'; } export async function fetchGroupPolygons(): Promise { diff --git a/frontend/src/stores/gearReplayStore.ts b/frontend/src/stores/gearReplayStore.ts index 94edf83..60a70c9 100644 --- a/frontend/src/stores/gearReplayStore.ts +++ b/frontend/src/stores/gearReplayStore.ts @@ -54,12 +54,21 @@ interface GearReplayState { endTime: number; playbackSpeed: number; - // Source data + // Source data (1h = primary identity polygon) historyFrames: HistoryFrame[]; frameTimes: number[]; selectedGroupKey: string | null; rawCorrelationTracks: CorrelationVesselTrack[]; + // 6h identity (독립 레이어 — 1h/모델과 무관) + historyFrames6h: HistoryFrame[]; + frameTimes6h: number[]; + memberTripsData6h: TripsLayerDatum[]; + centerTrailSegments6h: CenterTrailSegment[]; + centerDotsPositions6h: [number, number][]; + subClusterCenters6h: { subClusterId: number; path: [number, number][]; timestamps: number[] }[]; + snapshotRanges6h: number[]; + // Pre-computed layer data memberTripsData: TripsLayerDatum[]; correlationTripsData: TripsLayerDatum[]; @@ -79,6 +88,12 @@ interface GearReplayState { showTrails: boolean; showLabels: boolean; focusMode: boolean; // 리플레이 집중 모드 — 주변 라이브 정보 숨김 + show1hPolygon: boolean; // 1h 폴리곤 표시 (진한색/실선) + show6hPolygon: boolean; // 6h 폴리곤 표시 (옅은색/점선) + abLoop: boolean; // A-B 구간 반복 활성화 + abA: number; // A 지점 (epoch ms, 0 = 미설정) + abB: number; // B 지점 (epoch ms, 0 = 미설정) + pinnedMmsis: Set; // 툴팁 고정 시 강조할 MMSI 세트 // Actions loadHistory: ( @@ -87,6 +102,7 @@ interface GearReplayState { corrData: GearCorrelationItem[], enabledModels: Set, enabledVessels: Set, + frames6h?: HistoryFrame[], ) => void; play: () => void; pause: () => void; @@ -98,6 +114,12 @@ interface GearReplayState { setShowTrails: (show: boolean) => void; setShowLabels: (show: boolean) => void; setFocusMode: (focus: boolean) => void; + setShow1hPolygon: (show: boolean) => void; + setShow6hPolygon: (show: boolean) => void; + setAbLoop: (on: boolean) => void; + setAbA: (t: number) => void; + setAbB: (t: number) => void; + setPinnedMmsis: (mmsis: Set) => void; updateCorrelation: (corrData: GearCorrelationItem[], corrTracks: CorrelationVesselTrack[]) => void; reset: () => void; } @@ -118,7 +140,20 @@ export const useGearReplayStore = create()( const newTime = state.currentTime + delta * SPEED_FACTOR * state.playbackSpeed; - if (newTime >= state.endTime) { + // A-B 구간 반복 + if (state.abLoop && state.abA > 0 && state.abB > state.abA) { + if (newTime >= state.abB) { + set({ currentTime: state.abA }); + animationFrameId = requestAnimationFrame(animate); + return; + } + // A 이전이면 A로 점프 + if (newTime < state.abA) { + set({ currentTime: state.abA }); + animationFrameId = requestAnimationFrame(animate); + return; + } + } else if (newTime >= state.endTime) { set({ currentTime: state.startTime }); animationFrameId = requestAnimationFrame(animate); return; @@ -141,6 +176,13 @@ export const useGearReplayStore = create()( frameTimes: [], selectedGroupKey: null, rawCorrelationTracks: [], + historyFrames6h: [], + frameTimes6h: [], + memberTripsData6h: [], + centerTrailSegments6h: [], + centerDotsPositions6h: [], + subClusterCenters6h: [], + snapshotRanges6h: [], // Pre-computed layer data memberTripsData: [], @@ -159,20 +201,33 @@ export const useGearReplayStore = create()( showTrails: true, showLabels: true, focusMode: false, + show1hPolygon: true, + show6hPolygon: false, + abLoop: false, + abA: 0, + abB: 0, + pinnedMmsis: new Set(), correlationByModel: new Map(), // ── Actions ──────────────────────────────────────────────── - loadHistory: (frames, corrTracks, corrData, enabledModels, enabledVessels) => { + loadHistory: (frames, corrTracks, corrData, enabledModels, enabledVessels, frames6h) => { const startTime = Date.now() - 12 * 60 * 60 * 1000; const endTime = Date.now(); const frameTimes = frames.map(f => new Date(f.snapshotTime).getTime()); + const frameTimes6h = (frames6h ?? []).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); + // 6h 전처리 (동일한 빌드 함수) + const f6h = frames6h ?? []; + const memberTrips6h = f6h.length > 0 ? buildMemberTripsData(f6h, startTime) : []; + const { segments: seg6h, dots: dots6h } = f6h.length > 0 ? buildCenterTrailData(f6h) : { segments: [], dots: [] }; + const ranges6h = f6h.length > 0 ? buildSnapshotRanges(f6h, startTime, endTime) : []; + const byModel = new Map(); for (const c of corrData) { const list = byModel.get(c.modelName) ?? []; @@ -184,7 +239,13 @@ export const useGearReplayStore = create()( set({ historyFrames: frames, + historyFrames6h: f6h, frameTimes, + frameTimes6h, + memberTripsData6h: memberTrips6h, + centerTrailSegments6h: seg6h, + centerDotsPositions6h: dots6h, + snapshotRanges6h: ranges6h, startTime, endTime, currentTime: startTime, @@ -209,9 +270,9 @@ export const useGearReplayStore = create()( lastFrameTime = null; if (state.currentTime >= state.endTime) { - set({ isPlaying: true, currentTime: state.startTime }); + set({ isPlaying: true, currentTime: state.startTime, pinnedMmsis: new Set() }); } else { - set({ isPlaying: true }); + set({ isPlaying: true, pinnedMmsis: new Set() }); } animationFrameId = requestAnimationFrame(animate); @@ -247,6 +308,21 @@ export const useGearReplayStore = create()( setShowTrails: (show) => set({ showTrails: show }), setShowLabels: (show) => set({ showLabels: show }), setFocusMode: (focus) => set({ focusMode: focus }), + setShow1hPolygon: (show) => set({ show1hPolygon: show }), + setShow6hPolygon: (show) => set({ show6hPolygon: show }), + setAbLoop: (on) => { + const { startTime, endTime } = get(); + if (on && startTime > 0) { + // 기본 A-B: 전체 구간의 마지막 4시간 + const dur = endTime - startTime; + set({ abLoop: true, abA: endTime - Math.min(dur, 4 * 3600_000), abB: endTime }); + } else { + set({ abLoop: false, abA: 0, abB: 0 }); + } + }, + setAbA: (t) => set({ abA: t }), + setAbB: (t) => set({ abB: t }), + setPinnedMmsis: (mmsis) => set({ pinnedMmsis: mmsis }), updateCorrelation: (corrData, corrTracks) => { const state = get(); @@ -284,7 +360,14 @@ export const useGearReplayStore = create()( endTime: 0, playbackSpeed: 1, historyFrames: [], + historyFrames6h: [], frameTimes: [], + frameTimes6h: [], + memberTripsData6h: [], + centerTrailSegments6h: [], + centerDotsPositions6h: [], + subClusterCenters6h: [], + snapshotRanges6h: [], selectedGroupKey: null, rawCorrelationTracks: [], memberTripsData: [], @@ -301,6 +384,12 @@ export const useGearReplayStore = create()( showTrails: true, showLabels: true, focusMode: false, + show1hPolygon: true, + show6hPolygon: false, + abLoop: false, + abA: 0, + abB: 0, + pinnedMmsis: new Set(), correlationByModel: new Map(), }); }, diff --git a/prediction/algorithms/gear_correlation.py b/prediction/algorithms/gear_correlation.py index e690c83..8de28e6 100644 --- a/prediction/algorithms/gear_correlation.py +++ b/prediction/algorithms/gear_correlation.py @@ -18,6 +18,8 @@ from dataclasses import dataclass, field from datetime import datetime, timezone from typing import Optional +from algorithms.polygon_builder import _get_time_bucket_age + logger = logging.getLogger(__name__) @@ -479,7 +481,7 @@ def _compute_gear_active_ratio( gear_members: list[dict], all_positions: dict[str, dict], now: datetime, - stale_sec: float = 21600, + stale_sec: float = 3600, ) -> float: """어구 그룹의 활성 멤버 비율.""" if not gear_members: @@ -567,10 +569,23 @@ def run_gear_correlation( 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) + # 1h 활성 멤버 필터 (center/radius 계산용) + display_members = [ + m for m in members + if _get_time_bucket_age(m.get('mmsi'), all_positions, now) <= 3600 + ] + # fallback: < 2이면 time_bucket 최신 2개 유지 + if len(display_members) < 2 and len(members) >= 2: + display_members = sorted( + members, + key=lambda m: _get_time_bucket_age(m.get('mmsi'), all_positions, now), + )[:2] + active_members = display_members if len(display_members) >= 2 else members + + # 그룹 중심 + 반경 (1h 활성 멤버 기반) + center_lat = sum(m['lat'] for m in active_members) / len(active_members) + center_lon = sum(m['lon'] for m in active_members) / len(active_members) + group_radius = _compute_group_radius(active_members) # 어구 활성도 active_ratio = _compute_gear_active_ratio(members, all_positions, now) diff --git a/prediction/algorithms/polygon_builder.py b/prediction/algorithms/polygon_builder.py index 31fa738..78339fb 100644 --- a/prediction/algorithms/polygon_builder.py +++ b/prediction/algorithms/polygon_builder.py @@ -11,6 +11,9 @@ import math import re from datetime import datetime, timezone from typing import Optional +from zoneinfo import ZoneInfo + +import pandas as pd try: from shapely.geometry import MultiPoint, Point @@ -33,6 +36,23 @@ FLEET_BUFFER_DEG = 0.02 GEAR_BUFFER_DEG = 0.01 MIN_GEAR_GROUP_SIZE = 2 # 최소 어구 수 (비허가 구역 외) +_KST = ZoneInfo('Asia/Seoul') + + +def _get_time_bucket_age(mmsi: str, all_positions: dict, now: datetime) -> float: + """MMSI의 time_bucket 기반 age(초) 반환. 실패 시 inf.""" + pos = all_positions.get(mmsi) + tb = pos.get('time_bucket') if pos else None + if tb is None: + return float('inf') + try: + tb_dt = pd.Timestamp(tb) + if tb_dt.tzinfo is None: + tb_dt = tb_dt.tz_localize(_KST).tz_convert(timezone.utc) + return (now - tb_dt.to_pydatetime()).total_seconds() + except Exception: + return float('inf') + # 수역 내 어구 색상, 수역 외 어구 색상 _COLOR_GEAR_IN_ZONE = '#ef4444' _COLOR_GEAR_OUT_ZONE = '#f97316' @@ -159,7 +179,6 @@ def detect_gear_groups( 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) @@ -344,7 +363,6 @@ def build_all_group_snapshots( points: list[tuple[float, float]] = [] members: list[dict] = [] - newest_age = float('inf') for mmsi in mmsi_list: pos = all_positions.get(mmsi) if not pos: @@ -364,22 +382,11 @@ def build_all_group_snapshots( 'role': 'LEADER' if mmsi == mmsi_list[0] else 'MEMBER', 'isParent': False, }) - # 멤버 중 가장 최근 적재시간(time_bucket) 추적 - tb = pos.get('time_bucket') - if tb is not None: - try: - import pandas as pd - tb_dt = pd.Timestamp(tb) - if tb_dt.tzinfo is None: - from zoneinfo import ZoneInfo - tb_dt = tb_dt.tz_localize(ZoneInfo('Asia/Seoul')).tz_convert(timezone.utc) - tb_dt = tb_dt.to_pydatetime() - age = (now - tb_dt).total_seconds() - if age < newest_age: - newest_age = age - except Exception: - pass + newest_age = min( + (_get_time_bucket_age(m['mmsi'], all_positions, now) for m in members), + default=float('inf'), + ) # 2척 미만 또는 최근 적재가 DISPLAY_STALE_SEC 초과 → 폴리곤 미생성 if len(points) < 2 or newest_age > DISPLAY_STALE_SEC: continue @@ -403,124 +410,129 @@ def build_all_group_snapshots( 'color': _cluster_color(company_id), }) - # ── GEAR 타입: detect_gear_groups 결과 순회 ─────────────────── + # ── GEAR 타입: detect_gear_groups 결과 → 1h/6h 듀얼 스냅샷 ──── gear_groups = detect_gear_groups(vessel_store, now=now) for group in gear_groups: parent_name: str = group['parent_name'] parent_mmsi: Optional[str] = group['parent_mmsi'] - gear_members: list[dict] = group['members'] + gear_members: list[dict] = group['members'] # 6h STALE 기반 전체 멤버 - # 표시 기준: 그룹 멤버 중 가장 최근 적재(time_bucket)가 DISPLAY_STALE_SEC 이내여야 노출 - # time_bucket은 KST naive이므로 UTC로 변환 후 비교 - newest_age = float('inf') - for gm in gear_members: - gm_mmsi = gm.get('mmsi') - gm_pos = all_positions.get(gm_mmsi) if gm_mmsi else None - gm_tb = gm_pos.get('time_bucket') if gm_pos else None - if gm_tb is not None: - try: - import pandas as pd - tb_dt = pd.Timestamp(gm_tb) - if tb_dt.tzinfo is None: - # time_bucket은 KST (Asia/Seoul, UTC+9) - from zoneinfo import ZoneInfo - tb_dt = tb_dt.tz_localize(ZoneInfo('Asia/Seoul')).tz_convert(timezone.utc) - tb_dt = tb_dt.to_pydatetime() - except Exception: - continue - age = (now - tb_dt).total_seconds() - if age < newest_age: - newest_age = age - if newest_age > DISPLAY_STALE_SEC: + if not gear_members: continue - # 수역 분류: anchor(모선 or 첫 어구) 위치 기준 - anchor_lat: Optional[float] = None - anchor_lon: Optional[float] = None + # ── 1h 활성 멤버 필터 ── + display_members_1h = [ + gm for gm in gear_members + if _get_time_bucket_age(gm.get('mmsi'), all_positions, now) <= DISPLAY_STALE_SEC + ] + # fallback: 1h < 2이면 time_bucket 최신 2개 유지 (폴리곤 형태 보존) + if len(display_members_1h) < 2 and len(gear_members) >= 2: + sorted_by_age = sorted( + gear_members, + key=lambda gm: _get_time_bucket_age(gm.get('mmsi'), all_positions, now), + ) + display_members_1h = sorted_by_age[:2] - 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'] - - if anchor_lat is None and gear_members: - anchor_lat = gear_members[0]['lat'] - anchor_lon = gear_members[0]['lon'] - - if anchor_lat is None: - continue - - zone_info = classify_zone(float(anchor_lat), float(anchor_lon)) - in_zone = _is_in_zone(zone_info) - zone_id = zone_info.get('zone') if in_zone else None - zone_name = zone_info.get('zone_name') if in_zone else None - - # 비허가(수역 외) 어구: MIN_GEAR_GROUP_SIZE 미만 제외 - if not in_zone and len(gear_members) < MIN_GEAR_GROUP_SIZE: - continue - - # 폴리곤 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'] - # 모선이 어구 클러스터 내 최소 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 + # ── 6h 전체 멤버 노출 조건: 최신 적재가 STALE_SEC 이내 ── + newest_age_6h = min( + (_get_time_bucket_age(gm.get('mmsi'), all_positions, now) for gm in gear_members), + default=float('inf'), ) + display_members_6h = gear_members - # members JSONB 구성 - members_out: list[dict] = [] - # 모선 먼저 (근접 시에만) - if parent_nearby and parent_mmsi and parent_mmsi in all_positions: - parent_pos = all_positions[parent_mmsi] - members_out.append({ - 'mmsi': parent_mmsi, - 'name': parent_name, - 'lat': parent_pos['lat'], - 'lon': parent_pos['lon'], - 'sog': parent_pos.get('sog', 0), - 'cog': parent_pos.get('cog', 0), - 'role': 'PARENT', - 'isParent': True, + # ── resolution별 스냅샷 생성 ── + for resolution, members_for_snap in [('1h', display_members_1h), ('6h', display_members_6h)]: + if len(members_for_snap) < 2: + continue + # 6h: 최신 적재가 STALE_SEC(6h) 초과 시 스킵 + if resolution == '6h' and newest_age_6h > STALE_SEC: + continue + + # 수역 분류: anchor(모선 or 첫 멤버) 위치 기준 + anchor_lat: Optional[float] = None + anchor_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'] + + if anchor_lat is None and members_for_snap: + anchor_lat = members_for_snap[0]['lat'] + anchor_lon = members_for_snap[0]['lon'] + + if anchor_lat is None: + continue + + zone_info = classify_zone(float(anchor_lat), float(anchor_lon)) + in_zone = _is_in_zone(zone_info) + zone_id = zone_info.get('zone') if in_zone else None + zone_name = zone_info.get('zone_name') if in_zone else None + + # 비허가(수역 외) 어구: MIN_GEAR_GROUP_SIZE 미만 제외 + if not in_zone and len(members_for_snap) < MIN_GEAR_GROUP_SIZE: + continue + + # 폴리곤 points: 멤버 좌표 + 모선 좌표 (근접 시에만) + points = [(g['lon'], g['lat']) for g in members_for_snap] + 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 any(abs(g['lat'] - p_lat) <= MAX_DIST_DEG * 2 + and abs(g['lon'] - p_lon) <= MAX_DIST_DEG * 2 for g in members_for_snap): + 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 + ) + + # members JSONB 구성 + members_out: list[dict] = [] + if parent_nearby and parent_mmsi and parent_mmsi in all_positions: + parent_pos = all_positions[parent_mmsi] + members_out.append({ + 'mmsi': parent_mmsi, + 'name': parent_name, + 'lat': parent_pos['lat'], + 'lon': parent_pos['lon'], + 'sog': parent_pos.get('sog', 0), + 'cog': parent_pos.get('cog', 0), + 'role': 'PARENT', + 'isParent': True, + }) + for g in members_for_snap: + members_out.append({ + 'mmsi': g['mmsi'], + 'name': g['name'], + 'lat': g['lat'], + 'lon': g['lon'], + 'sog': g['sog'], + 'cog': g['cog'], + 'role': 'GEAR', + 'isParent': False, + }) + + color = _COLOR_GEAR_IN_ZONE if in_zone else _COLOR_GEAR_OUT_ZONE + + snapshots.append({ + 'group_type': 'GEAR_IN_ZONE' if in_zone else 'GEAR_OUT_ZONE', + 'group_key': parent_name, + 'group_label': parent_name, + 'sub_cluster_id': group.get('sub_cluster_id', 0), + 'resolution': resolution, + 'snapshot_time': now, + 'polygon_wkt': polygon_wkt, + 'center_wkt': center_wkt, + 'area_sq_nm': area_sq_nm, + 'member_count': len(members_out), + 'zone_id': zone_id, + 'zone_name': zone_name, + 'members': members_out, + 'color': color, }) - # 어구 목록 - for g in gear_members: - members_out.append({ - 'mmsi': g['mmsi'], - 'name': g['name'], - 'lat': g['lat'], - 'lon': g['lon'], - 'sog': g['sog'], - 'cog': g['cog'], - 'role': 'GEAR', - 'isParent': False, - }) - - color = _COLOR_GEAR_IN_ZONE if in_zone else _COLOR_GEAR_OUT_ZONE - - snapshots.append({ - 'group_type': 'GEAR_IN_ZONE' if in_zone else 'GEAR_OUT_ZONE', - 'group_key': parent_name, - 'group_label': parent_name, - 'sub_cluster_id': group.get('sub_cluster_id', 0), - 'snapshot_time': now, - 'polygon_wkt': polygon_wkt, - 'center_wkt': center_wkt, - 'area_sq_nm': area_sq_nm, - 'member_count': len(members_out), - 'zone_id': zone_id, - 'zone_name': zone_name, - 'members': members_out, - 'color': color, - }) return snapshots diff --git a/prediction/db/kcgdb.py b/prediction/db/kcgdb.py index 8297042..db55152 100644 --- a/prediction/db/kcgdb.py +++ b/prediction/db/kcgdb.py @@ -154,11 +154,11 @@ def save_group_snapshots(snapshots: list[dict]) -> int: insert_sql = """ INSERT INTO kcg.group_polygon_snapshots ( - group_type, group_key, group_label, sub_cluster_id, snapshot_time, + group_type, group_key, group_label, sub_cluster_id, resolution, snapshot_time, polygon, center_point, area_sq_nm, member_count, zone_id, zone_name, members, color ) VALUES ( - %s, %s, %s, %s, %s, + %s, %s, %s, %s, %s, %s, ST_GeomFromText(%s, 4326), ST_GeomFromText(%s, 4326), %s, %s, %s, %s, %s::jsonb, %s ) @@ -176,6 +176,7 @@ def save_group_snapshots(snapshots: list[dict]) -> int: s['group_key'], s['group_label'], s.get('sub_cluster_id', 0), + s.get('resolution', '6h'), s['snapshot_time'], s.get('polygon_wkt'), s.get('center_wkt'), -- 2.45.2 From 77efab8652800ab1eda65a8ae0f4d9ddbf3482de Mon Sep 17 00:00:00 2001 From: htlee Date: Wed, 1 Apr 2026 12:29:22 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20=ED=95=AD=EA=B3=B5=EA=B8=B0=20?= =?UTF-8?q?=EC=A4=8C=20=EC=8A=A4=EC=BC=80=EC=9D=BC=20+=20=EC=84=A0?= =?UTF-8?q?=EB=B0=95/=ED=95=AD=EA=B3=B5=EA=B8=B0=20=EC=8B=AC=EB=B3=BC=20?= =?UTF-8?q?=ED=81=AC=EA=B8=B0=20=EC=A1=B0=EC=A0=95=20=ED=8C=A8=EB=84=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 항공기 아이콘에 정수레벨 줌 기반 스케일 적용 (getZoomScale export) - 심볼 크기 조정: SymbolScaleContext + SymbolScalePanel (0.5~2.0x) - LayerPanel에 '심볼 크기' 섹션 추가 (선박/항공기 개별 조정) - localStorage 영속화 (mapSymbolScale) Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/App.tsx | 3 ++ frontend/src/components/common/LayerPanel.tsx | 2 + .../components/common/SymbolScalePanel.tsx | 43 +++++++++++++++++++ .../src/components/layers/AircraftLayer.tsx | 8 +++- frontend/src/contexts/SymbolScaleContext.tsx | 10 +++++ frontend/src/contexts/symbolScaleState.ts | 12 ++++++ frontend/src/hooks/useShipDeckLayers.ts | 9 ++-- frontend/src/hooks/useSymbolScale.ts | 6 +++ 8 files changed, 89 insertions(+), 4 deletions(-) create mode 100644 frontend/src/components/common/SymbolScalePanel.tsx create mode 100644 frontend/src/contexts/SymbolScaleContext.tsx create mode 100644 frontend/src/contexts/symbolScaleState.ts create mode 100644 frontend/src/hooks/useSymbolScale.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index bddd0be..ec2334b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,6 +10,7 @@ import LoginPage from './components/auth/LoginPage'; import CollectorMonitor from './components/common/CollectorMonitor'; import { SharedFilterProvider } from './contexts/SharedFilterContext'; import { FontScaleProvider } from './contexts/FontScaleContext'; +import { SymbolScaleProvider } from './contexts/SymbolScaleContext'; import { IranDashboard } from './components/iran/IranDashboard'; import { KoreaDashboard } from './components/korea/KoreaDashboard'; import './App.css'; @@ -67,6 +68,7 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) { return ( +
@@ -160,6 +162,7 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) { )}
+
); } diff --git a/frontend/src/components/common/LayerPanel.tsx b/frontend/src/components/common/LayerPanel.tsx index d9e9f2c..2fb3345 100644 --- a/frontend/src/components/common/LayerPanel.tsx +++ b/frontend/src/components/common/LayerPanel.tsx @@ -2,6 +2,7 @@ import { useState, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { useLocalStorageSet } from '../../hooks/useLocalStorage'; import { FontScalePanel } from './FontScalePanel'; +import { SymbolScalePanel } from './SymbolScalePanel'; // Aircraft category colors (matches AircraftLayer military fixed colors) const AC_CAT_COLORS: Record = { @@ -897,6 +898,7 @@ export function LayerPanel({ )} + ); } diff --git a/frontend/src/components/common/SymbolScalePanel.tsx b/frontend/src/components/common/SymbolScalePanel.tsx new file mode 100644 index 0000000..4ec6021 --- /dev/null +++ b/frontend/src/components/common/SymbolScalePanel.tsx @@ -0,0 +1,43 @@ +import { useState } from 'react'; +import { useSymbolScale } from '../../hooks/useSymbolScale'; +import type { SymbolScaleConfig } from '../../contexts/symbolScaleState'; + +const LABELS: Record = { + ship: '선박 심볼', + aircraft: '항공기 심볼', +}; + +export function SymbolScalePanel() { + const { symbolScale, setSymbolScale } = useSymbolScale(); + const [open, setOpen] = useState(false); + + const update = (key: keyof SymbolScaleConfig, val: number) => { + setSymbolScale({ ...symbolScale, [key]: Math.round(val * 10) / 10 }); + }; + + return ( +
+ + {open && ( +
+ {(Object.keys(LABELS) as (keyof SymbolScaleConfig)[]).map(key => ( +
+ + update(key, parseFloat(e.target.value))} /> + {symbolScale[key].toFixed(1)} +
+ ))} + +
+ )} +
+ ); +} diff --git a/frontend/src/components/layers/AircraftLayer.tsx b/frontend/src/components/layers/AircraftLayer.tsx index 874852d..13230d2 100644 --- a/frontend/src/components/layers/AircraftLayer.tsx +++ b/frontend/src/components/layers/AircraftLayer.tsx @@ -2,6 +2,9 @@ import { memo, useMemo, useState, useEffect } from 'react'; import { Marker, Popup, Source, Layer } from 'react-map-gl/maplibre'; import { useTranslation } from 'react-i18next'; import type { Aircraft, AircraftCategory } from '../../types'; +import { useShipDeckStore } from '../../stores/shipDeckStore'; +import { getZoomScale } from '../../hooks/useShipDeckLayers'; +import { useSymbolScale } from '../../hooks/useSymbolScale'; interface Props { aircraft: Aircraft[]; @@ -187,9 +190,12 @@ export function AircraftLayer({ aircraft, militaryOnly }: Props) { const AircraftMarker = memo(function AircraftMarker({ ac }: { ac: Aircraft }) { const { t } = useTranslation('ships'); const [showPopup, setShowPopup] = useState(false); + const zoomLevel = useShipDeckStore(s => s.zoomLevel); + const { symbolScale } = useSymbolScale(); const color = getAircraftColor(ac); const shape = getShape(ac); - const size = shape.w; + const zs = getZoomScale(zoomLevel); + const size = Math.round(shape.w * zs * symbolScale.aircraft / 0.8); const showLabel = ac.category === 'fighter' || ac.category === 'surveillance'; const strokeWidth = ac.category === 'fighter' || ac.category === 'military' ? 1.5 : 0.8; diff --git a/frontend/src/contexts/SymbolScaleContext.tsx b/frontend/src/contexts/SymbolScaleContext.tsx new file mode 100644 index 0000000..809e38a --- /dev/null +++ b/frontend/src/contexts/SymbolScaleContext.tsx @@ -0,0 +1,10 @@ +import type { ReactNode } from 'react'; +import { useLocalStorage } from '../hooks/useLocalStorage'; +import { SymbolScaleCtx, DEFAULT_SYMBOL_SCALE } from './symbolScaleState'; + +export type { SymbolScaleConfig } from './symbolScaleState'; + +export function SymbolScaleProvider({ children }: { children: ReactNode }) { + const [symbolScale, setSymbolScale] = useLocalStorage('mapSymbolScale', DEFAULT_SYMBOL_SCALE); + return {children}; +} diff --git a/frontend/src/contexts/symbolScaleState.ts b/frontend/src/contexts/symbolScaleState.ts new file mode 100644 index 0000000..d5aa5bf --- /dev/null +++ b/frontend/src/contexts/symbolScaleState.ts @@ -0,0 +1,12 @@ +import { createContext } from 'react'; + +export interface SymbolScaleConfig { + ship: number; + aircraft: number; +} + +export const DEFAULT_SYMBOL_SCALE: SymbolScaleConfig = { ship: 1.0, aircraft: 1.0 }; + +export const SymbolScaleCtx = createContext<{ symbolScale: SymbolScaleConfig; setSymbolScale: (c: SymbolScaleConfig) => void }>({ + symbolScale: DEFAULT_SYMBOL_SCALE, setSymbolScale: () => {}, +}); diff --git a/frontend/src/hooks/useShipDeckLayers.ts b/frontend/src/hooks/useShipDeckLayers.ts index c96ec5a..bdf1e88 100644 --- a/frontend/src/hooks/useShipDeckLayers.ts +++ b/frontend/src/hooks/useShipDeckLayers.ts @@ -9,6 +9,7 @@ import { getMarineTrafficCategory } from '../utils/marineTraffic'; import { getNationalityGroup } from './useKoreaData'; import { FONT_MONO } from '../styles/fonts'; import type { Ship, VesselAnalysisDto } from '../types'; +import { useSymbolScale } from './useSymbolScale'; // ── Constants ───────────────────────────────────────────────────────────────── @@ -19,7 +20,7 @@ const ZOOM_SCALE: Record = { }; const ZOOM_SCALE_DEFAULT = 4.2; // z14+ -function getZoomScale(zoom: number): number { +export function getZoomScale(zoom: number): number { if (zoom >= 14) return ZOOM_SCALE_DEFAULT; return ZOOM_SCALE[zoom] ?? ZOOM_SCALE_DEFAULT; } @@ -156,6 +157,8 @@ export function useShipDeckLayers( shipLayerRef: React.MutableRefObject, requestRender: () => void, ): void { + const { symbolScale } = useSymbolScale(); + const shipSymbolScale = symbolScale.ship; const renderFrame = useCallback(() => { const state = useShipDeckStore.getState(); @@ -170,7 +173,7 @@ export function useShipDeckLayers( return; } - const zoomScale = getZoomScale(zoomLevel); + const zoomScale = getZoomScale(zoomLevel) * shipSymbolScale; const layers: Layer[] = []; // 1. Build filtered ship render data (~3K ships, <1ms) @@ -316,7 +319,7 @@ export function useShipDeckLayers( shipLayerRef.current = layers; requestRender(); - }, [shipLayerRef, requestRender]); + }, [shipLayerRef, requestRender, shipSymbolScale]); // Subscribe to all relevant state changes useEffect(() => { diff --git a/frontend/src/hooks/useSymbolScale.ts b/frontend/src/hooks/useSymbolScale.ts new file mode 100644 index 0000000..d018b13 --- /dev/null +++ b/frontend/src/hooks/useSymbolScale.ts @@ -0,0 +1,6 @@ +import { useContext } from 'react'; +import { SymbolScaleCtx } from '../contexts/symbolScaleState'; + +export function useSymbolScale() { + return useContext(SymbolScaleCtx); +} -- 2.45.2 From 9200f45cb2f026b7daf4ac6436635671530f6750 Mon Sep 17 00:00:00 2001 From: htlee Date: Wed, 1 Apr 2026 12:32:08 +0900 Subject: [PATCH 4/4] =?UTF-8?q?docs:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/RELEASE-NOTES.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 365cd0d..2bbb43a 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,19 @@ ## [Unreleased] +### 추가 +- 어구 그룹 1h/6h 듀얼 폴리곤 (Python 듀얼 스냅샷 + DB resolution 컬럼 + Backend/Frontend 독립 렌더) +- 리플레이 컨트롤러 A-B 구간 반복 기능 +- 리플레이 프로그레스바 통합 (1h/6h 스냅샷 막대 + 호버 툴팁 + 클릭 고정) +- 리치 툴팁: 선박/어구 구분 + 모델 소속 컬러 표시 + 멤버 호버 강조 +- 항공기 아이콘 줌레벨 기반 스케일 적용 +- 심볼 크기 조정 패널 (선박/항공기 개별 0.5~2.0x) + +### 변경 +- 일치율 후보 탐색: 6h 멤버 → 1h 활성 멤버 기반 center/radius +- 일치율 반경 밖 이탈 선박 OUT_OF_RANGE 감쇠 적용 +- 폴리곤 노출: DISPLAY_STALE_SEC=1h time_bucket 기반 필터링 + ### 추가 - 실시간 선박 13K MapLibre → deck.gl IconLayer 전환 (useShipDeckLayers + shipDeckStore) - 선단/어구 폴리곤 MapLibre → deck.gl GeoJsonLayer 전환 (useFleetClusterDeckLayers) -- 2.45.2