fix: 항적 토글 + 패널 레이아웃 + TripsLayer 색상 분리

항적 토글 역할 변경:
- 항상 ON: TripsLayer (애니메이션 트레일) + 센터 트레일 + 도트
- "항적" 토글: 전체 24h 정적 항적 PathLayer (멤버 + 연관 선박)
  ON 시 회색/연파랑 배경 경로 위에 고채도 TripsLayer 애니메이션

색상 계층:
- 정적 항적: 회색 [180,180,180,80] / 연파랑 [100,140,200,60]
- TripsLayer: 고채도 노랑 [255,200,60,220] / 고채도 파랑 [100,180,255,220]

패널 레이아웃:
- 토글 패널: position: sticky left: 0 (항상 좌측 고정)
- 모델 카드: 가로 스크롤 (maxWidth: calc(100vw - 340px))
- 다중 토글 유지, 화면 초과 시 스크롤

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
htlee 2026-03-31 09:40:06 +09:00
부모 5002105d18
커밋 6789f82e3b
2개의 변경된 파일76개의 추가작업 그리고 41개의 파일을 삭제

파일 보기

@ -203,7 +203,7 @@ const CorrelationPanel = ({
<div style={{
position: 'absolute',
bottom: historyActive ? 100 : 20,
left: 'calc(50% - 275px)',
left: 10,
display: 'flex',
gap: 6,
alignItems: 'flex-start',
@ -212,6 +212,9 @@ const CorrelationPanel = ({
fontSize: 10,
color: '#e2e8f0',
pointerEvents: 'auto',
maxWidth: 'calc(100vw - 340px)',
overflowX: 'auto',
overflowY: 'visible',
}}>
{/* 고정: 토글 패널 */}
<div style={{
@ -219,6 +222,8 @@ const CorrelationPanel = ({
border: '1px solid rgba(249,115,22,0.3)',
borderRadius: 8,
padding: '8px 10px',
position: 'sticky',
left: 0,
minWidth: 165,
flexShrink: 0,
boxShadow: '0 4px 16px rgba(0,0,0,0.5)',
@ -253,18 +258,13 @@ const CorrelationPanel = ({
const gc = modelItems.filter(c => c.targetType !== 'VESSEL').length;
return (
<label key={m.name} style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 9, cursor: 'pointer', marginBottom: 3 }}>
<input
type="checkbox"
checked={enabledModels.has(m.name)}
<input type="checkbox" checked={enabledModels.has(m.name)}
onChange={() => onEnabledModelsChange(prev => {
const next = new Set(prev);
if (next.has(m.name)) next.delete(m.name);
else next.add(m.name);
if (next.has(m.name)) next.delete(m.name); else next.add(m.name);
return next;
})}
style={{ accentColor: color, width: 11, height: 11 }}
title={m.name}
/>
style={{ accentColor: color, width: 11, height: 11 }} title={m.name} />
<span style={{ width: 8, height: 8, borderRadius: '50%', background: color, flexShrink: 0 }} />
<span style={{ color: '#e2e8f0', flex: 1 }}>{m.name}{m.isDefault ? '*' : ''}</span>
<span style={{ color: '#64748b', fontSize: 8 }}>{vc}{gc}</span>

파일 보기

@ -95,27 +95,25 @@ export function useGearReplayLayers(
const layers: Layer[] = [];
// ── Static layers (center trail + dots) ───────────────────────────────
// ── 항상 표시: 센터 트레일 + 도트 ──────────────────────────────────
// Center trail segments (PathLayer) — showTrails 제어
if (showTrails) {
for (let i = 0; i < centerTrailSegments.length; i++) {
const seg = centerTrailSegments[i];
if (seg.path.length < 2) continue;
layers.push(new PathLayer({
id: `replay-center-trail-${i}`,
data: [{ path: seg.path }],
getPath: (d: { path: [number, number][] }) => d.path,
getColor: seg.isInterpolated
? [249, 115, 22, 200]
: [251, 191, 36, 180],
widthMinPixels: 2,
}));
}
// Center trail segments (PathLayer) — 항상 ON
for (let i = 0; i < centerTrailSegments.length; i++) {
const seg = centerTrailSegments[i];
if (seg.path.length < 2) continue;
layers.push(new PathLayer({
id: `replay-center-trail-${i}`,
data: [{ path: seg.path }],
getPath: (d: { path: [number, number][] }) => d.path,
getColor: seg.isInterpolated
? [249, 115, 22, 200]
: [251, 191, 36, 180],
widthMinPixels: 2,
}));
}
// Center dots (real data only) — showTrails 제어
if (showTrails && centerDotsPositions.length > 0) {
// Center dots (real data only) — 항상 ON
if (centerDotsPositions.length > 0) {
layers.push(new ScatterplotLayer({
id: 'replay-center-dots',
data: centerDotsPositions,
@ -143,17 +141,54 @@ export function useGearReplayLayers(
const members = interpolateMemberPositions(state.historyFrames, frameIdx, ct);
const memberPts: [number, number][] = members.map(m => [m.lon, m.lat]);
// 1. Identity 모델: 멤버 트레일 + 폴리곤 + 마커 (enabledModels 체크)
// ── "항적" 토글: 전체 24h 경로 PathLayer (정적 배경) ─────────────
if (showTrails) {
// 멤버 전체 항적 (identity)
if (enabledModels.has('identity') && memberTripsData.length > 0) {
for (const trip of memberTripsData) {
if (trip.path.length < 2) continue;
layers.push(new PathLayer({
id: `replay-member-path-${trip.id}`,
data: [{ path: trip.path }],
getPath: (d: { path: [number, number][] }) => d.path,
getColor: [180, 180, 180, 80], // 낮은 채도 — TripsLayer보다 연하게
widthMinPixels: 1,
}));
}
}
// 연관 선박 전체 항적 (correlation)
if (correlationTripsData.length > 0) {
const activeMmsis = new Set<string>();
for (const [mn, items] of correlationByModel) {
if (!enabledModels.has(mn)) continue;
for (const c of items as GearCorrelationItem[]) {
if (enabledVessels.has(c.targetMmsi)) activeMmsis.add(c.targetMmsi);
}
}
for (const trip of correlationTripsData) {
if (!activeMmsis.has(trip.id) || trip.path.length < 2) continue;
layers.push(new PathLayer({
id: `replay-corr-path-${trip.id}`,
data: [{ path: trip.path }],
getPath: (d: { path: [number, number][] }) => d.path,
getColor: [100, 140, 200, 60], // 연한 파랑
widthMinPixels: 1,
}));
}
}
}
// 1. Identity 모델: TripsLayer + 폴리곤 + 마커 (항상 ON)
if (enabledModels.has('identity')) {
// TripsLayer — member trails (GPU animated, showTrails 제어)
if (showTrails && memberTripsData.length > 0) {
// TripsLayer — member trails (GPU animated, 항상 ON, 고채도)
if (memberTripsData.length > 0) {
layers.push(new TripsLayer({
id: 'replay-member-trails',
data: memberTripsData,
getPath: d => d.path,
getTimestamps: d => d.timestamps,
getColor: d => d.color,
widthMinPixels: 1.5,
getColor: [255, 200, 60, 220], // 고채도 노란색 (항적보다 밝게)
widthMinPixels: 2,
fadeTrail: true,
trailLength: TRAIL_LENGTH_MS,
currentTime: ct - st,
@ -177,9 +212,8 @@ export function useGearReplayLayers(
}
}
// 2. Correlation trails (GPU animated, showTrails + enabledModels 체크)
if (showTrails && correlationTripsData.length > 0) {
// 활성 모델에 속하는 선박의 트랙만 표시
// 2. Correlation TripsLayer (GPU animated, 항상 ON, 고채도)
if (correlationTripsData.length > 0) {
const activeMmsis = new Set<string>();
for (const [mn, items] of correlationByModel) {
if (!enabledModels.has(mn)) continue;
@ -194,8 +228,8 @@ export function useGearReplayLayers(
data: enabledTrips,
getPath: d => d.path,
getTimestamps: d => d.timestamps,
getColor: d => d.color,
widthMinPixels: 2,
getColor: [100, 180, 255, 220], // 고채도 파랑 (항적보다 밝게)
widthMinPixels: 2.5,
fadeTrail: true,
trailLength: TRAIL_LENGTH_MS,
currentTime: ct - st,
@ -342,18 +376,19 @@ export function useGearReplayLayers(
if (shouldLog) {
const trackHit = corrPositions.filter(p => corrTrackMap.has(p.mmsi)).length;
const liveHit = corrPositions.length - trackHit;
const sampleTrip = memberTripsData[0];
console.log('[GearReplay] renderFrame:', {
historyFrames: state.historyFrames.length,
correlationByModel: state.correlationByModel.size,
modelNames: [...state.correlationByModel.keys()],
memberTripsData: memberTripsData.length,
corrTripsData: correlationTripsData.length,
corrTrackMap: corrTrackMap.size,
enabledModels: [...enabledModels],
enabledVessels: enabledVessels.size,
showTrails, showLabels,
relTime: Math.round(relTime / 60000) + 'min',
currentTime: Math.round((ct - st) / 60000) + 'min (rel)',
members: members.length,
corrPositions: corrPositions.length,
posSource: `track:${trackHit} live:${liveHit}`,
memberTrip0: sampleTrip ? { id: sampleTrip.id, pts: sampleTrip.path.length, tsRange: `${Math.round(sampleTrip.timestamps[0]/60000)}~${Math.round(sampleTrip.timestamps[sampleTrip.timestamps.length-1]/60000)}min` } : 'none',
});
// 모델별 상세
for (const [mn, items] of state.correlationByModel) {