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:
부모
4cf29521a9
커밋
242fdb8034
@ -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);
|
||||
|
||||
40
frontend/src/utils/shipIconSvg.ts
Normal file
40
frontend/src/utils/shipIconSvg.ts
Normal file
@ -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 };
|
||||
불러오는 중...
Reference in New Issue
Block a user