feat: 모델별 폴리곤 중심 경로 + 현재 중심점 렌더링

사전계산 (gearReplayPreprocess):
- buildModelCenterTrails(): 각 프레임에서 멤버+연관선박 위치 → 폴리곤 → 중심점
- 모델별 path[]/timestamps[] (PathLayer + 보간용)

스토어 (gearReplayStore):
- modelCenterTrails 필드 추가 (loadHistory/updateCorrelation에서 빌드)

렌더링 (useGearReplayLayers):
- PathLayer: 모델별 폴리곤 중심 경로 (연한 모델 색상, alpha 100)
- ScatterplotLayer: 현재 시간 중심점 (고채도 모델 색상, 흰 테두리)
- 모델 ON 시에만 표시 (enabledModels 체크)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
htlee 2026-03-31 09:52:17 +09:00
부모 5afa5d4be9
커밋 c97f964f93
3개의 변경된 파일138개의 추가작업 그리고 3개의 파일을 삭제

파일 보기

@ -64,6 +64,7 @@ export function useGearReplayLayers(
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);
@ -517,6 +518,46 @@ export function useGearReplayLayers(
}));
}
// 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;
layers.push(new ScatterplotLayer({
id: `replay-model-center-${trail.modelName}`,
data: [{ position: [cx, cy] as [number, number] }],
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,
}));
}
}
// 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> }>();
@ -581,7 +622,7 @@ export function useGearReplayLayers(
historyFrames, memberTripsData, correlationTripsData,
centerTrailSegments, centerDotsPositions,
enabledModels, enabledVessels, hoveredMmsi, correlationByModel,
showTrails, showLabels,
modelCenterTrails, showTrails, showLabels,
replayLayerRef, requestRender,
]);

파일 보기

@ -1,5 +1,6 @@
import type { HistoryFrame } from '../components/korea/fleetClusterTypes';
import type { CorrelationVesselTrack } from '../services/vesselAnalysis';
import type { CorrelationVesselTrack, GearCorrelationItem } from '../services/vesselAnalysis';
import { buildInterpPolygon } from '../components/korea/fleetClusterUtils';
export interface TripsLayerDatum {
id: string;
@ -233,3 +234,87 @@ export function interpolateMemberPositions(
);
});
}
/**
* .
* + ( ) .
*/
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;
}

파일 보기

@ -7,7 +7,9 @@ import {
buildCorrelationTripsData,
buildCenterTrailData,
buildSnapshotRanges,
buildModelCenterTrails,
} from './gearReplayPreprocess';
import type { ModelCenterTrail } from './gearReplayPreprocess';
// ── Pre-processed data types for deck.gl layers ──────────────────
@ -63,6 +65,7 @@ interface GearReplayState {
centerTrailSegments: CenterTrailSegment[];
centerDotsPositions: [number, number][];
snapshotRanges: number[];
modelCenterTrails: ModelCenterTrail[];
// Filter / display state
enabledModels: Set<string>;
@ -138,6 +141,7 @@ export const useGearReplayStore = create<GearReplayState>()(
centerTrailSegments: [],
centerDotsPositions: [],
snapshotRanges: [],
modelCenterTrails: [],
// Filter / display state
enabledModels: new Set<string>(),
@ -166,6 +170,8 @@ export const useGearReplayStore = create<GearReplayState>()(
byModel.set(c.modelName, list);
}
const modelTrails = buildModelCenterTrails(frames, corrTracks, byModel, enabledVessels, startTime);
set({
historyFrames: frames,
frameTimes,
@ -177,6 +183,7 @@ export const useGearReplayStore = create<GearReplayState>()(
centerTrailSegments: segments,
centerDotsPositions: dots,
snapshotRanges: ranges,
modelCenterTrails: modelTrails,
enabledModels,
enabledVessels,
correlationByModel: byModel,
@ -242,7 +249,8 @@ export const useGearReplayStore = create<GearReplayState>()(
corrTrips: corrTrips.length,
corrTracks: corrTracks.length,
});
set({ correlationByModel: byModel, correlationTripsData: corrTrips });
const modelTrails = buildModelCenterTrails(state.historyFrames, corrTracks, byModel, state.enabledVessels, state.startTime);
set({ correlationByModel: byModel, correlationTripsData: corrTrips, modelCenterTrails: modelTrails });
},
reset: () => {
@ -265,6 +273,7 @@ export const useGearReplayStore = create<GearReplayState>()(
centerTrailSegments: [],
centerDotsPositions: [],
snapshotRanges: [],
modelCenterTrails: [],
enabledModels: new Set<string>(),
enabledVessels: new Set<string>(),
hoveredMmsi: null,