iran 백엔드 프록시 잔재 제거: - IranBackendClient dead class 삭제, AppProperties/application.yml iran-backend 블록 제거 - Frontend UI 라벨/주석/system-flow manifest deprecated 마킹 - CLAUDE.md 시스템 구성 다이어그램 최신화 백엔드 계층 분리: - AlertController/MasterDataController/AdminStatsController 에서 repository/JdbcTemplate 직접 주입 제거 - AlertService/MasterDataService/AdminStatsService 신규 계층 도입 + @Transactional(readOnly=true) - Proxy controller 의 @PostConstruct RestClient 생성 → RestClientConfig @Bean 으로 통합 감사 로그 보강: - EnforcementService createRecord/updateRecord/createPlan 에 @Auditable 추가 - VesselAnalysisGroupService.resolveParent 에 PARENT_RESOLVE 액션 기록 카탈로그 정합성: - performanceStatus 를 catalogRegistry 에 등록 (쇼케이스 자동 노출) - alertLevels 확장: isValidAlertLevel / isHighSeverity / getAlertLevelOrder - LiveMapView/DarkVesselDetection 시각 매핑(opacity/radius/tier score) 상수로 추출 - GearIdentification/vesselAnomaly 직접 분기를 타입 가드/헬퍼로 치환
403 lines
14 KiB
TypeScript
403 lines
14 KiB
TypeScript
/**
|
|
* useGearReplayLayers — 어구 궤적 리플레이 레이어 빌더 훅
|
|
*
|
|
* 검증된 리플레이 렌더링 패턴 (단일 렌더링 경로):
|
|
*
|
|
* 1. animationStore rAF 루프 → set({ currentTime }) 매 프레임 (gearReplayStore)
|
|
* 2. zustand.subscribe(currentTime) → renderFrame()
|
|
* - 재생 중: ~10fps 쓰로틀 + pendingRafId로 다음 프레임 보장 (프레임 드롭 방지)
|
|
* - seek/정지: 즉시 렌더
|
|
* 3. renderFrame() → 레이어 빌드 → overlay.setProps({ layers }) 직접 호출
|
|
*
|
|
* 레이어 구성:
|
|
* - PathLayer: 중심 궤적 (gold)
|
|
* - TripsLayer: 멤버 궤적 fade trail
|
|
* - IconLayer: 멤버 현재 위치 (보간)
|
|
* - PolygonLayer: 현재 폴리곤 (보간으로 확장/축소 애니메이션)
|
|
* - TextLayer: MMSI 라벨
|
|
*
|
|
* 제거된 것: 멤버 배경 PathLayer (TripsLayer와 중복), 멤버-중심 연결선 (불필요)
|
|
*/
|
|
import { useEffect, useRef, useCallback } from 'react';
|
|
import type { Layer } from 'deck.gl';
|
|
import { ScatterplotLayer, IconLayer, PolygonLayer, TextLayer } from 'deck.gl';
|
|
import type { MapboxOverlay } from '@deck.gl/mapbox';
|
|
import { useGearReplayStore } from '@stores/gearReplayStore';
|
|
import {
|
|
findFrameAtTime,
|
|
interpolateFromTripsData,
|
|
computeConvexHull,
|
|
type MemberPosition,
|
|
} from '@stores/gearReplayPreprocess';
|
|
import { createTripsLayer } from '@lib/map/layers/trips';
|
|
|
|
// ── SVG Data URI ──
|
|
|
|
const ICON_SIZE = 64;
|
|
|
|
const SHIP_URI = (() => {
|
|
const s = ICON_SIZE;
|
|
const svg = `<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>`;
|
|
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
|
|
})();
|
|
|
|
const GEAR_URI = (() => {
|
|
const s = ICON_SIZE;
|
|
const svg = `<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>`;
|
|
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
|
|
})();
|
|
|
|
// ── 색상 ──
|
|
|
|
const CYAN: [number, number, number, number] = [6, 182, 212, 220];
|
|
const AMBER: [number, number, number, number] = [245, 158, 11, 200];
|
|
const SLATE: [number, number, number, number] = [148, 163, 184, 120];
|
|
const POLYGON_FILL: [number, number, number, number] = [245, 158, 11, 30];
|
|
const POLYGON_STROKE: [number, number, number, number] = [245, 158, 11, 120];
|
|
|
|
const RENDER_INTERVAL_MS = 100; // ~10fps 쓰로틀
|
|
|
|
function memberIconColor(m: MemberPosition): [number, number, number, number] {
|
|
if (m.stale) return SLATE;
|
|
if (m.isParent) return CYAN;
|
|
if (m.isGear) return AMBER;
|
|
return [148, 163, 184, 200];
|
|
}
|
|
|
|
// ── 훅 ──
|
|
|
|
export function useGearReplayLayers(
|
|
overlayRef: React.RefObject<MapboxOverlay | null>,
|
|
buildBaseLayers: () => Layer[],
|
|
) {
|
|
const frameCursorRef = useRef(0);
|
|
// positionCursors — 멤버별 커서 (재생 시 O(1) 위치 탐색)
|
|
const memberCursorsRef = useRef(new Map<string, number>());
|
|
|
|
// buildBaseLayers를 최신 참조로 유지
|
|
const baseLayersRef = useRef(buildBaseLayers);
|
|
baseLayersRef.current = buildBaseLayers;
|
|
|
|
/**
|
|
* renderFrame — 보간 + 레이어 빌드 + overlay.setProps 직접 호출:
|
|
* 1. 현재 위치 계산 (보간)
|
|
* 2. 레이어 빌드
|
|
* 3. overlay.setProps({ layers }) 직접 호출
|
|
*/
|
|
const renderFrame = useCallback(() => {
|
|
const overlay = overlayRef.current;
|
|
if (!overlay) return;
|
|
|
|
const state = useGearReplayStore.getState();
|
|
const {
|
|
historyFrames, frameTimes, memberTripsData, memberMetadata,
|
|
currentTime, startTime, correlationItems,
|
|
candidateTripsData, candidateMetadata,
|
|
} = state;
|
|
|
|
if (historyFrames.length === 0) {
|
|
overlay.setProps({ layers: baseLayersRef.current() });
|
|
return;
|
|
}
|
|
|
|
// 현재 프레임 찾기 (폴리곤 보간용)
|
|
const { index: frameIdx, cursor: newCursor } = findFrameAtTime(
|
|
frameTimes, currentTime, frameCursorRef.current,
|
|
);
|
|
frameCursorRef.current = newCursor;
|
|
|
|
// 멤버 보간 — getCurrentVesselPositions 패턴:
|
|
// 프레임 기반이 아닌 멤버별 개별 타임라인에서 보간 → 빈 구간도 연속 보간
|
|
const relativeTime = currentTime - startTime;
|
|
const members = interpolateFromTripsData(
|
|
memberTripsData, memberMetadata, relativeTime, memberCursorsRef.current,
|
|
);
|
|
|
|
// 멤버 위치 기반 convex hull 폴리곤 (프레임 보간이 아닌 실시간 생성)
|
|
const hullRing = computeConvexHull(members);
|
|
|
|
const currentFrame = frameIdx >= 0 ? historyFrames[frameIdx] : null;
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const replayLayers: any[] = [];
|
|
|
|
// 1. TripsLayer — 멤버 궤적 fade trail
|
|
// TripsLayer가 자체적으로 부드러운 궤적 애니메이션 처리
|
|
if (memberTripsData.length > 0) {
|
|
replayLayers.push(createTripsLayer(
|
|
'replay-member-trails',
|
|
memberTripsData,
|
|
relativeTime,
|
|
3_600_000, // 1시간 fade trail
|
|
));
|
|
}
|
|
|
|
// 4. 멤버 현재 위치 IconLayer
|
|
const ships = members.filter(m => m.isParent);
|
|
const gears = members.filter(m => m.isGear);
|
|
const others = members.filter(m => !m.isParent && !m.isGear);
|
|
|
|
if (ships.length > 0) {
|
|
replayLayers.push(new IconLayer<MemberPosition>({
|
|
id: 'replay-member-ships',
|
|
data: ships,
|
|
pickable: true,
|
|
iconAtlas: SHIP_URI,
|
|
iconMapping: {
|
|
ship: { x: 0, y: 0, width: ICON_SIZE, height: ICON_SIZE, anchorY: ICON_SIZE * 0.62, mask: true },
|
|
},
|
|
getIcon: () => 'ship',
|
|
getPosition: d => [d.lon, d.lat],
|
|
getSize: 24,
|
|
getAngle: d => -(d.cog ?? 0),
|
|
getColor: d => memberIconColor(d),
|
|
sizeUnits: 'pixels',
|
|
sizeMinPixels: 10,
|
|
billboard: false,
|
|
}));
|
|
}
|
|
|
|
if (gears.length > 0) {
|
|
replayLayers.push(new IconLayer<MemberPosition>({
|
|
id: 'replay-member-gears',
|
|
data: gears,
|
|
pickable: true,
|
|
iconAtlas: GEAR_URI,
|
|
iconMapping: {
|
|
gear: { x: 0, y: 0, width: ICON_SIZE, height: ICON_SIZE, anchorY: ICON_SIZE / 2, mask: true },
|
|
},
|
|
getIcon: () => 'gear',
|
|
getPosition: d => [d.lon, d.lat],
|
|
getSize: 16,
|
|
getAngle: 0,
|
|
getColor: d => memberIconColor(d),
|
|
sizeUnits: 'pixels',
|
|
sizeMinPixels: 6,
|
|
billboard: false,
|
|
}));
|
|
}
|
|
|
|
if (others.length > 0) {
|
|
replayLayers.push(new IconLayer<MemberPosition>({
|
|
id: 'replay-member-others',
|
|
data: others,
|
|
pickable: true,
|
|
iconAtlas: SHIP_URI,
|
|
iconMapping: {
|
|
ship: { x: 0, y: 0, width: ICON_SIZE, height: ICON_SIZE, anchorY: ICON_SIZE * 0.62, mask: true },
|
|
},
|
|
getIcon: () => 'ship',
|
|
getPosition: d => [d.lon, d.lat],
|
|
getSize: 18,
|
|
getAngle: d => -(d.cog ?? 0),
|
|
getColor: d => memberIconColor(d),
|
|
sizeUnits: 'pixels',
|
|
sizeMinPixels: 8,
|
|
billboard: false,
|
|
}));
|
|
}
|
|
|
|
// 5. MMSI 라벨
|
|
if (members.length > 0) {
|
|
replayLayers.push(new TextLayer<MemberPosition>({
|
|
id: 'replay-member-labels',
|
|
data: members,
|
|
getPosition: d => [d.lon, d.lat],
|
|
getText: d => d.mmsi,
|
|
getColor: [255, 255, 255, 200],
|
|
getSize: 10,
|
|
getPixelOffset: [0, -18],
|
|
fontFamily: 'monospace',
|
|
fontWeight: 'bold',
|
|
outlineWidth: 2,
|
|
outlineColor: [0, 0, 0, 200],
|
|
sizeUnits: 'pixels',
|
|
sizeMinPixels: 8,
|
|
sizeMaxPixels: 12,
|
|
billboard: true,
|
|
}));
|
|
}
|
|
|
|
// 6. 멤버 위치 기반 convex hull 폴리곤
|
|
if (hullRing) {
|
|
replayLayers.push(new PolygonLayer({
|
|
id: 'replay-polygon',
|
|
data: [{ ring: hullRing }],
|
|
getPolygon: d => d.ring,
|
|
getFillColor: POLYGON_FILL,
|
|
getLineColor: POLYGON_STROKE,
|
|
getLineWidth: 2,
|
|
lineWidthUnits: 'pixels',
|
|
lineWidthMinPixels: 1,
|
|
filled: true,
|
|
stroked: true,
|
|
}));
|
|
}
|
|
|
|
// 7. 추론 후보 위치 (correlation)
|
|
if (correlationItems.length > 0 && currentFrame) {
|
|
const memberMap = new Map(currentFrame.members.map(m => [m.mmsi, m]));
|
|
const corrPositions = correlationItems
|
|
.filter(c => {
|
|
const m = memberMap.get(c.targetMmsi);
|
|
return m && m.lat != null && m.lon != null;
|
|
})
|
|
.map(c => {
|
|
const m = memberMap.get(c.targetMmsi)!;
|
|
return { ...c, lon: m.lon, lat: m.lat };
|
|
});
|
|
|
|
if (corrPositions.length > 0) {
|
|
replayLayers.push(new ScatterplotLayer({
|
|
id: 'replay-corr-positions',
|
|
data: corrPositions,
|
|
getPosition: d => [d.lon, d.lat],
|
|
getFillColor: d => {
|
|
const s = d.score;
|
|
if (s >= 0.72) return [16, 185, 129, 180] as [number, number, number, number];
|
|
if (s >= 0.5) return [245, 158, 11, 180] as [number, number, number, number];
|
|
return [100, 116, 139, 140] as [number, number, number, number];
|
|
},
|
|
getRadius: d => 5 + d.score * 8,
|
|
radiusUnits: 'pixels',
|
|
radiusMinPixels: 4,
|
|
lineWidthMinPixels: 1,
|
|
stroked: true,
|
|
getLineColor: [255, 255, 255, 120],
|
|
getLineWidth: 1,
|
|
pickable: true,
|
|
}));
|
|
}
|
|
}
|
|
|
|
// 8. 후보 선박 항적 TripsLayer + 현재 위치 IconLayer
|
|
if (candidateTripsData.length > 0) {
|
|
// 후보 선박 궤적 fade trail (emerald)
|
|
replayLayers.push(createTripsLayer(
|
|
'replay-candidate-trails',
|
|
candidateTripsData,
|
|
relativeTime,
|
|
3_600_000,
|
|
));
|
|
|
|
// 후보 선박 현재 위치 (개별 타임라인 보간)
|
|
const candPositions = interpolateFromTripsData(
|
|
candidateTripsData,
|
|
new Map([...candidateMetadata].map(([k, v]) => [k, { name: v.name, role: 'CANDIDATE', isParent: false }])),
|
|
relativeTime,
|
|
memberCursorsRef.current,
|
|
);
|
|
if (candPositions.length > 0) {
|
|
replayLayers.push(new IconLayer({
|
|
id: 'replay-candidate-ships',
|
|
data: candPositions,
|
|
pickable: true,
|
|
iconAtlas: SHIP_URI,
|
|
iconMapping: {
|
|
ship: { x: 0, y: 0, width: ICON_SIZE, height: ICON_SIZE, anchorY: ICON_SIZE * 0.62, mask: true },
|
|
},
|
|
getIcon: () => 'ship',
|
|
getPosition: (d: MemberPosition) => [d.lon, d.lat],
|
|
getSize: 22,
|
|
getAngle: (d: MemberPosition) => -(d.cog ?? 0),
|
|
getColor: [16, 185, 129, 220] as [number, number, number, number], // emerald
|
|
sizeUnits: 'pixels' as const,
|
|
sizeMinPixels: 10,
|
|
billboard: false,
|
|
}));
|
|
|
|
// 후보 선박 라벨
|
|
replayLayers.push(new TextLayer({
|
|
id: 'replay-candidate-labels',
|
|
data: candPositions,
|
|
getPosition: (d: MemberPosition) => [d.lon, d.lat],
|
|
getText: (d: MemberPosition) => {
|
|
const meta = candidateMetadata.get(d.mmsi);
|
|
return meta?.name || d.mmsi;
|
|
},
|
|
getColor: [16, 185, 129, 220],
|
|
getSize: 10,
|
|
getPixelOffset: [0, -18],
|
|
fontFamily: 'monospace',
|
|
fontWeight: 'bold' as const,
|
|
outlineWidth: 2,
|
|
outlineColor: [0, 0, 0, 200],
|
|
sizeUnits: 'pixels' as const,
|
|
sizeMinPixels: 8,
|
|
sizeMaxPixels: 12,
|
|
billboard: true,
|
|
}));
|
|
}
|
|
}
|
|
|
|
// 핵심: baseLayers + replayLayers 합쳐서 overlay.setProps() 직접 호출
|
|
const baseLayers = baseLayersRef.current();
|
|
overlay.setProps({ layers: [...baseLayers, ...replayLayers] });
|
|
}, [overlayRef]);
|
|
|
|
/**
|
|
* currentTime 구독 — 재생 시 쓰로틀 + seek 시 즉시 렌더
|
|
*
|
|
* 핵심: 재생 중 쓰로틀에 걸려도 pendingRafId로 다음 rAF에 반드시 렌더 예약
|
|
* → 프레임 드롭 없이 부드러운 애니메이션
|
|
*
|
|
* 가드 없이 항상 구독 — renderFrame 내부에서 historyFrames.length===0이면 baseLayers만 표시
|
|
*/
|
|
useEffect(() => {
|
|
let lastRenderTime = 0;
|
|
let pendingRafId: number | null = null;
|
|
|
|
const unsub = useGearReplayStore.subscribe(
|
|
(s) => s.currentTime,
|
|
() => {
|
|
// 데이터 없으면 무시
|
|
if (!useGearReplayStore.getState().groupKey) return;
|
|
|
|
const isPlaying = useGearReplayStore.getState().isPlaying;
|
|
// seek/정지: 즉시 렌더
|
|
if (!isPlaying) {
|
|
renderFrame();
|
|
return;
|
|
}
|
|
// 재생 중: 쓰로틀 + pending rAF
|
|
const now = performance.now();
|
|
if (now - lastRenderTime >= RENDER_INTERVAL_MS) {
|
|
lastRenderTime = now;
|
|
renderFrame();
|
|
} else if (!pendingRafId) {
|
|
pendingRafId = requestAnimationFrame(() => {
|
|
pendingRafId = null;
|
|
lastRenderTime = performance.now();
|
|
renderFrame();
|
|
});
|
|
}
|
|
},
|
|
);
|
|
return () => {
|
|
unsub();
|
|
if (pendingRafId) cancelAnimationFrame(pendingRafId);
|
|
};
|
|
}, [renderFrame]);
|
|
|
|
// groupKey 변경 구독
|
|
useEffect(() => {
|
|
const unsub = useGearReplayStore.subscribe(
|
|
s => s.groupKey,
|
|
(groupKey) => {
|
|
if (groupKey) {
|
|
frameCursorRef.current = 0;
|
|
memberCursorsRef.current.clear();
|
|
renderFrame();
|
|
} else {
|
|
// 리플레이 종료 → 기본 레이어 복원
|
|
const overlay = overlayRef.current;
|
|
if (overlay) {
|
|
overlay.setProps({ layers: baseLayersRef.current() });
|
|
}
|
|
}
|
|
},
|
|
);
|
|
return unsub;
|
|
}, [renderFrame, overlayRef]);
|
|
}
|