fix: 리플레이 IconLayer 전환 + 모델 배지 + correlation 동기화

- 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) <noreply@anthropic.com>
This commit is contained in:
htlee 2026-03-31 08:08:39 +09:00
부모 4cf29521a9
커밋 242fdb8034
4개의 변경된 파일139개의 추가작업 그리고 20개의 파일을 삭제

파일 보기

@ -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) => {

파일 보기

@ -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<MemberPosition>({
layers.push(new IconLayer<MemberPosition>({
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<CorrPosition>({
layers.push(new IconLayer<CorrPosition>({
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<CorrPosition>({
@ -391,6 +396,59 @@ export function useGearReplayLayers(
}));
}
// 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
if (enabledModels.has('identity')) {
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],
}));
}
}
replayLayerRef.current = layers;
requestRender();
}, [

파일 보기

@ -85,6 +85,7 @@ interface GearReplayState {
setEnabledModels: (models: Set<string>) => void;
setEnabledVessels: (vessels: Set<string>) => void;
setHoveredMmsi: (mmsi: string | null) => void;
updateCorrelation: (corrData: GearCorrelationItem[], corrTracks: CorrelationVesselTrack[]) => void;
reset: () => void;
}
@ -214,6 +215,19 @@ export const useGearReplayStore = create<GearReplayState>()(
setHoveredMmsi: (mmsi) => set({ hoveredMmsi: mmsi }),
updateCorrelation: (corrData, corrTracks) => {
const state = get();
if (state.historyFrames.length === 0) 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);
set({ correlationByModel: byModel, correlationTripsData: corrTrips });
},
reset: () => {
if (animationFrameId !== null) {
cancelAnimationFrame(animationFrameId);

파일 보기

@ -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 };