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:
부모
5afa5d4be9
커밋
c97f964f93
@ -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,
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user