From 242fdb80349f36e396ef633d55ee38b76a3e856e Mon Sep 17 00:00:00 2001 From: htlee Date: Tue, 31 Mar 2026 08:08:39 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EB=A6=AC=ED=94=8C=EB=A0=88=EC=9D=B4=20I?= =?UTF-8?q?conLayer=20=EC=A0=84=ED=99=98=20+=20=EB=AA=A8=EB=8D=B8=20?= =?UTF-8?q?=EB=B0=B0=EC=A7=80=20+=20correlation=20=EB=8F=99=EA=B8=B0?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ScatterplotLayer → IconLayer (ship-triangle/gear-diamond SVG 정적 캐시) - shipIconSvg.ts: MapLibre와 동일한 삼각형/마름모 SVG + mask 모드 - 선박 COG 회전 반영 (getAngle), 어구는 회전 없음 - 모델별 색상 배지 ScatterplotLayer 추가 (각 모델 offset) - correlation 데이터 비동기 로드 후 store.updateCorrelation() 동기화 - CorrPosition에 cog 필드 추가 (세그먼트 방향 계산) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/korea/FleetClusterLayer.tsx | 7 ++ frontend/src/hooks/useGearReplayLayers.ts | 98 +++++++++++++++---- frontend/src/stores/gearReplayStore.ts | 14 +++ frontend/src/utils/shipIconSvg.ts | 40 ++++++++ 4 files changed, 139 insertions(+), 20 deletions(-) create mode 100644 frontend/src/utils/shipIconSvg.ts diff --git a/frontend/src/components/korea/FleetClusterLayer.tsx b/frontend/src/components/korea/FleetClusterLayer.tsx index a7b4819..4d3f3ec 100644 --- a/frontend/src/components/korea/FleetClusterLayer.tsx +++ b/frontend/src/components/korea/FleetClusterLayer.tsx @@ -97,6 +97,13 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS useGearReplayStore.getState().setHoveredMmsi(hoveredTarget?.mmsi ?? null); }, [hoveredTarget]); + // ── correlation 데이터 → store 동기화 (비동기 로드 후 반영) ── + useEffect(() => { + if (correlationData.length > 0 || correlationTracks.length > 0) { + useGearReplayStore.getState().updateCorrelation(correlationData, correlationTracks); + } + }, [correlationData, correlationTracks]); + // ── ESC 키 ── useEffect(() => { const onKeyDown = (e: KeyboardEvent) => { diff --git a/frontend/src/hooks/useGearReplayLayers.ts b/frontend/src/hooks/useGearReplayLayers.ts index c7a8f50..c28b65b 100644 --- a/frontend/src/hooks/useGearReplayLayers.ts +++ b/frontend/src/hooks/useGearReplayLayers.ts @@ -1,13 +1,14 @@ import { useEffect, useRef, useCallback } from 'react'; import type { Layer } from '@deck.gl/core'; import { TripsLayer } from '@deck.gl/geo-layers'; -import { ScatterplotLayer, PathLayer, PolygonLayer, TextLayer } from '@deck.gl/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_COLORS } from '../components/korea/fleetClusterConstants'; +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 ───────────────────────────────────────────────────────────────── @@ -32,6 +33,7 @@ interface CorrPosition { name: string; lon: number; lat: number; + cog: number; color: [number, number, number, number]; isVessel: boolean; } @@ -193,23 +195,22 @@ export function useGearReplayLayers( lineWidthMinPixels: 2, })); - // 5. Member position markers + // 5. Member position markers (IconLayer — ship-triangle / gear-diamond) if (members.length > 0) { - layers.push(new ScatterplotLayer({ + layers.push(new IconLayer({ id: 'replay-members', data: members, getPosition: d => [d.lon, d.lat], - getFillColor: d => { - if (d.stale) return [100, 116, 139, 150]; + 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]; }, - getRadius: d => d.isParent ? 150 : d.isGear ? 80 : 120, - radiusUnits: 'meters', - radiusMinPixels: 3, - stroked: true, - getLineColor: [0, 0, 0, 150], - lineWidthMinPixels: 0.5, + sizeUnits: 'pixels', + billboard: false, })); // Member labels @@ -263,12 +264,17 @@ export function useGearReplayLayers( const ratio = ts[hi] !== ts[lo] ? (relTime - ts[lo]) / (ts[hi] - ts[lo]) : 0; const lon = path[lo][0] + (path[hi][0] - path[lo][0]) * ratio; const lat = path[lo][1] + (path[hi][1] - path[lo][1]) * ratio; + // heading from segment direction + const dx = path[hi][0] - path[lo][0]; + const dy = path[hi][1] - path[lo][1]; + const cog = (dx === 0 && dy === 0) ? 0 : (Math.atan2(dx, dy) * 180 / Math.PI + 360) % 360; corrPositions.push({ mmsi: c.targetMmsi, name: c.targetName || c.targetMmsi, lon, lat, + cog, color: [r, g, b, 230], isVessel: c.targetType === 'VESSEL', }); @@ -276,17 +282,16 @@ export function useGearReplayLayers( } if (corrPositions.length > 0) { - layers.push(new ScatterplotLayer({ + layers.push(new IconLayer({ id: 'replay-corr-vessels', data: corrPositions, getPosition: d => [d.lon, d.lat], - getFillColor: d => d.color, - getRadius: d => d.isVessel ? 130 : 80, - radiusUnits: 'meters', - radiusMinPixels: 3, - stroked: true, - getLineColor: [0, 0, 0, 150], - lineWidthMinPixels: 1, + 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, })); layers.push(new TextLayer({ @@ -391,6 +396,59 @@ export function useGearReplayLayers( })); } + // 9. Model badges (small colored dots next to each vessel/gear per model) + { + const badgeTargets = new Map }>(); + + // Identity model: group members + if (enabledModels.has('identity')) { + for (const m of members) { + const e = badgeTargets.get(m.mmsi) ?? { lon: m.lon, lat: m.lat, models: new Set() }; + 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() }; + 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], + })); + } + } + replayLayerRef.current = layers; requestRender(); }, [ diff --git a/frontend/src/stores/gearReplayStore.ts b/frontend/src/stores/gearReplayStore.ts index eab5e8d..b91a8e9 100644 --- a/frontend/src/stores/gearReplayStore.ts +++ b/frontend/src/stores/gearReplayStore.ts @@ -85,6 +85,7 @@ interface GearReplayState { setEnabledModels: (models: Set) => void; setEnabledVessels: (vessels: Set) => void; setHoveredMmsi: (mmsi: string | null) => void; + updateCorrelation: (corrData: GearCorrelationItem[], corrTracks: CorrelationVesselTrack[]) => void; reset: () => void; } @@ -214,6 +215,19 @@ export const useGearReplayStore = create()( setHoveredMmsi: (mmsi) => set({ hoveredMmsi: mmsi }), + updateCorrelation: (corrData, corrTracks) => { + const state = get(); + if (state.historyFrames.length === 0) return; + const byModel = new Map(); + 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); + set({ correlationByModel: byModel, correlationTripsData: corrTrips }); + }, + reset: () => { if (animationFrameId !== null) { cancelAnimationFrame(animationFrameId); diff --git a/frontend/src/utils/shipIconSvg.ts b/frontend/src/utils/shipIconSvg.ts new file mode 100644 index 0000000..11c4554 --- /dev/null +++ b/frontend/src/utils/shipIconSvg.ts @@ -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 */ +function createGearDiamondSvg(): string { + const s = ICON_SIZE; + return ` + + `; +} + +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 };