Files
DefVideo/client/src/components/overlay/StationOverlay.tsx
b23042 819065a8f5 UI 수정
기획안 반영 및 보완
2026-06-19 14:40:47 +09:00

619 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 지리정보 오버레이
* 렌더링 최적화:
* - 텍스트(측점+POI): 데이터 로드 완료 시 전 프레임 Map 사전 계산 (requestIdleCallback)
* params 변경 시 500ms debounce 후 재계산
* - 중심선: 드론 프레임 변경 시 renderCacheRef 갱신 (per-frame, 나중에 최적화)
* - RAF 루프: Map 조회 + 캐시 읽기만 (계산 없음 → 60fps)
*/
import React, { useEffect, useRef, useState, useCallback } from 'react';
import {
toCameraCoords,
pixelFromCamera,
type DroneFrameBasic,
type CameraParams,
type CameraCoords,
DEFAULT_CAMERA_PARAMS,
} from '../../utils/geoProjection';
import { useGeoStore } from '../../store/geoStore';
import type { GeoPoint, CenterlinePoint } from '../../types/geo';
const VIDEO_FPS = 30000 / 1001;
interface Props {
currentFrame: number;
currentTime: number;
fps: number;
visible: boolean;
/** 카메라 파라미터 패널 표시 (영상제어 토글). 기본 true. */
showPanel?: boolean;
/** 카메라 파라미터 패널 top(px) — 노선 배너 아래로 배치. 기본 72. */
topPx?: number;
}
// category → 이모지
const CATEGORY_EMOJI: Record<string, string> = {
'터널': '🚇',
'교량': '🌉',
'역사': '🚉',
'지장물': '🏢',
'측점': '📍',
};
// 텍스트 사전 계산 캐시 (Map<frameNum, LabelCache>)
interface LabelCache {
stationLabels: { sx: number; sy: number; title: string }[];
poiMarkers: { x: number; y: number; title: string; category: string }[];
}
// 중심선 + 나침반 렌더 캐시 (per-frame, renderCacheRef)
interface RenderCache {
centerlineSegs: [number, number, number, number][];
effectiveYaw: number;
hFovRad: number;
clCount: number;
poiCount: number;
}
const cleanTitle = (t: string) => t.replace(/\s*\([상하]\)\s*$/, '').trim();
function stationOrder(title: string): number {
const m = title.match(/(\d+)[Kk](\d+)/);
if (!m) return 0;
return parseInt(m[1]) * 1000 + parseInt(m[2]);
}
// ── ParamRow ─────────────────────────────────────────────────────────────────
interface ParamRowProps {
label: string; value: number; min: number; max: number; step: number;
unit: string; decimals?: number; onChange: (v: number) => void;
}
function ParamRow({ label, value, min, max, step, unit, decimals = 1, onChange }: ParamRowProps) {
const fmt = useCallback((v: number) => v.toFixed(decimals), [decimals]);
const [text, setText] = useState(() => fmt(value));
const prevRef = useRef(value);
useEffect(() => {
if (prevRef.current !== value) { prevRef.current = value; setText(fmt(value)); }
}, [value, fmt]);
const commit = (s: string) => {
const n = parseFloat(s);
if (!isNaN(n)) {
const c = Math.max(min, Math.min(max, n));
onChange(c); setText(fmt(c)); prevRef.current = c;
} else setText(fmt(value));
};
return (
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-gray-400 w-12 shrink-0 text-right">{label}</span>
<input type="range" min={min} max={max} step={step} value={value}
onChange={e => { const v = parseFloat(e.target.value); onChange(v); prevRef.current = v; setText(fmt(v)); }}
className="flex-1 h-1 accent-yellow-400 cursor-pointer" />
<input type="number" min={min} max={max} step={step} value={text}
onChange={e => setText(e.target.value)}
onBlur={e => commit(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') commit((e.target as HTMLInputElement).value); if (e.key === 'Escape') setText(fmt(value)); }}
className="w-16 bg-black/60 border border-gray-700 rounded px-1 py-0.5 text-right font-mono text-yellow-300 text-[11px] [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" />
<span className="text-gray-500 text-[10px] w-5 shrink-0">{unit}</span>
</div>
);
}
// ── 메인 컴포넌트 ─────────────────────────────────────────────────────────────
export default function StationOverlay({ currentFrame, currentTime, fps, visible, showPanel = true, topPx = 72 }: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const canvasSizeRef = useRef({ w: 0, h: 0 });
// 데이터 ref
const allDroneFramesRef = useRef<DroneFrameBasic[]>([]);
const allCenterlinePointsRef = useRef<CenterlinePoint[]>([]);
const allGeoStationsRef = useRef<GeoPoint[]>([]);
const allPoisRef = useRef<GeoPoint[]>([]);
// 현재 상태 ref
const currentDroneFrameRef = useRef<DroneFrameBasic | null>(null);
const currentFrameNumRef = useRef<number>(0); // RAF에서 Map 조회용
const currentFrameIdxRef = useRef<number>(0); // smoothFrame용 배열 인덱스
const currentTimeSecRef = useRef<number>(0); // 마지막으로 알려진 재생 시간
const timeUpdateWallRef = useRef<number>(performance.now()); // currentTime 갱신된 시각
const paramsRef = useRef<CameraParams>(DEFAULT_CAMERA_PARAMS);
const visibleRef = useRef(visible);
const worldOriginRef = useRef<{ lat: number; lon: number; alt: number } | undefined>(undefined);
// 텍스트 사전 계산 Map
const labelMapRef = useRef<Map<number, LabelCache>>(new Map());
const precomputeIdRef = useRef(0); // 진행 중 계산 취소용
// 중심선 + 나침반 렌더 캐시 (per-frame)
const renderCacheRef = useRef<RenderCache | null>(null);
// 나침반 전용(측점선 visible 무관 항상 갱신·표시)
const compassRef = useRef<{ effectiveYaw: number; hFovRad: number } | null>(null);
// UI state
const [params, setParams] = useState<CameraParams>(DEFAULT_CAMERA_PARAMS);
const [smoothHalf, setSmoothHalf] = useState(10);
const smoothHalfRef = useRef(10);
const [emaAlpha, setEmaAlpha] = useState(0.01);
const emaAlphaRef = useRef(0.01);
// 표시 위치 EMA 상태 (RAF 내부 유지)
const displayedStRef = useRef<Map<string, { x: number; y: number }>>(new Map());
const displayedPoiRef = useRef<Map<string, { x: number; y: number }>>(new Map());
const [showControls, setShowControls] = useState(false);
const [droneFramesLoaded, setDroneFramesLoaded] = useState(false);
const [geoDataLoaded, setGeoDataLoaded] = useState(false);
const [clDataLoaded, setClDataLoaded] = useState(false);
const [panelDroneFrame, setPanelDroneFrame] = useState<DroneFrameBasic | null>(null);
useEffect(() => { paramsRef.current = params; }, [params]);
useEffect(() => { visibleRef.current = visible; }, [visible]);
useEffect(() => { smoothHalfRef.current = smoothHalf; }, [smoothHalf]);
useEffect(() => { emaAlphaRef.current = emaAlpha; }, [emaAlpha]);
const setParam = useCallback(<K extends keyof CameraParams>(key: K, val: CameraParams[K]) =>
setParams(prev => ({ ...prev, [key]: val })), []);
const nearestCL = useCallback((lat: number, lon: number): CenterlinePoint | null => {
const pts = allCenterlinePointsRef.current;
if (!pts.length) return null;
let best = pts[0], bestD = (best.lat - lat) ** 2 + (best.lon - lon) ** 2;
for (const pt of pts) {
const d = (pt.lat - lat) ** 2 + (pt.lon - lon) ** 2;
if (d < bestD) { bestD = d; best = pt; }
}
return best;
}, []);
// 클라이언트 지리정보 스토어 구독 (서버 /api/geo/* 대체)
const storeStations = useGeoStore(s => s.stations);
const storePois = useGeoStore(s => s.pois);
const storeCenterline = useGeoStore(s => s.centerline);
const storeFrames = useGeoStore(s => s.frames);
const storeOrigin = useGeoStore(s => s.origin);
// 측점 (stationOrder 정렬 — 스토어가 이미 정렬해 두지만 방어적으로 동일 순서 보장)
useEffect(() => {
allGeoStationsRef.current = [...storeStations]
.sort((a, b) => stationOrder(a.title) - stationOrder(b.title));
allPoisRef.current = storePois;
setGeoDataLoaded(storeStations.length > 0 || storePois.length > 0);
}, [storeStations, storePois]);
// 중심선
useEffect(() => {
allCenterlinePointsRef.current = storeCenterline;
setClDataLoaded(storeCenterline.length > 0);
}, [storeCenterline]);
// 드론 프레임
useEffect(() => {
allDroneFramesRef.current = storeFrames;
setDroneFramesLoaded(storeFrames.length > 0);
}, [storeFrames]);
// ENU 월드 원점 — 스토어 origin 직접 사용 (서버 getWorldOrigin 대체)
useEffect(() => {
worldOriginRef.current = storeOrigin
? { lat: storeOrigin.lat, lon: storeOrigin.lon, alt: storeOrigin.alt }
: undefined;
}, [storeOrigin]);
// 드론 프레임 이동 평균 (GPS/자세 노이즈 제거)
const smoothFrame = useCallback((frames: DroneFrameBasic[], i: number, halfWin: number): DroneFrameBasic => {
const lo = Math.max(0, i - halfWin);
const hi = Math.min(frames.length - 1, i + halfWin);
const n = hi - lo + 1;
let lat = 0, lon = 0, altitude = 0, pitch = 0, roll = 0, sinYaw = 0, cosYaw = 0;
for (let k = lo; k <= hi; k++) {
const f = frames[k];
lat += f.lat; lon += f.lon; altitude += f.altitude;
pitch += f.pitch; roll += f.roll;
const yr = f.yaw * Math.PI / 180;
sinYaw += Math.sin(yr); cosYaw += Math.cos(yr);
}
return {
...frames[i],
lat: lat / n, lon: lon / n, altitude: altitude / n,
pitch: pitch / n, roll: roll / n,
yaw: Math.atan2(sinYaw / n, cosYaw / n) * 180 / Math.PI,
};
}, []);
// 텍스트 사전 계산 — requestIdleCallback으로 백그라운드 실행
const startLabelPrecompute = useCallback((currentParams: CameraParams, currentSmoothHalf: number) => {
const id = ++precomputeIdRef.current;
const newMap = new Map<number, LabelCache>();
const frames = allDroneFramesRef.current;
const allSt = allGeoStationsRef.current;
const allPoi = allPoisRef.current;
const worldOrigin = worldOriginRef.current;
const CLIP_Z = 0.1;
const SMOOTH_HALF = currentSmoothHalf;
if (!frames.length) return;
const t0 = performance.now();
let idx = 0;
const CHUNK = 200; // 한 번에 처리할 프레임 수
const step = () => {
if (precomputeIdRef.current !== id) return; // 취소됨
const end = Math.min(idx + CHUNK, frames.length);
while (idx < end) {
const drone = smoothFrame(frames, idx++, SMOOTH_HALF);
const stationLabels: LabelCache['stationLabels'] = [];
for (const st of allSt) {
const snap = nearestCL(st.lat, st.lon);
const cc = toCameraCoords(drone, snap?.lat ?? st.lat, snap?.lon ?? st.lon, snap?.z ?? st.z, currentParams, worldOrigin);
if (cc.Zc < CLIP_Z) continue;
const { pxRaw, pyRaw } = pixelFromCamera(cc, currentParams);
if (pxRaw < -0.05 || pxRaw > 1.05 || pyRaw < -0.05 || pyRaw > 1.05) continue;
stationLabels.push({ sx: pxRaw, sy: pyRaw, title: st.title });
}
const poiMarkers: LabelCache['poiMarkers'] = [];
for (const poi of allPoi) {
const poiZ = nearestCL(poi.lat, poi.lon)?.z ?? poi.z;
const cc = toCameraCoords(drone, poi.lat, poi.lon, poiZ, currentParams, worldOrigin);
if (cc.Zc < CLIP_Z) continue;
const { pxRaw, pyRaw } = pixelFromCamera(cc, currentParams);
if (pxRaw < -0.02 || pxRaw > 1.02 || pyRaw < -0.02 || pyRaw > 1.02) continue;
poiMarkers.push({ x: pxRaw, y: pyRaw, title: poi.title, category: poi.category });
}
newMap.set(drone.frame, { stationLabels, poiMarkers });
}
if (idx < frames.length) {
requestIdleCallback(step, { timeout: 200 });
} else {
labelMapRef.current = newMap;
console.log(
`[labelMap] complete ${(performance.now() - t0).toFixed(0)}ms | ${frames.length} frames × ${allSt.length + allPoi.length} items`
);
}
};
requestIdleCallback(step, { timeout: 200 });
}, [nearestCL]);
// 모든 데이터 로드 완료 시 사전 계산 시작
useEffect(() => {
if (!droneFramesLoaded || !geoDataLoaded || !clDataLoaded) return;
startLabelPrecompute(paramsRef.current, smoothHalf);
}, [droneFramesLoaded, geoDataLoaded, clDataLoaded, startLabelPrecompute, smoothHalf]);
// params / smoothHalf 변경 시 사전 계산 재시작 (500ms debounce)
useEffect(() => {
if (!droneFramesLoaded || !geoDataLoaded || !clDataLoaded) return;
const timer = setTimeout(() => startLabelPrecompute(params, smoothHalf), 500);
return () => clearTimeout(timer);
}, [params, smoothHalf, droneFramesLoaded, geoDataLoaded, clDataLoaded, startLabelPrecompute]);
// 현재 재생 시간 → 드론 프레임 ref 갱신
useEffect(() => {
// 나침반이 visible 무관 갱신되도록 visible 게이트 제거(프레임 탐색은 가벼움).
if (!droneFramesLoaded) return;
const frames = allDroneFramesRef.current;
if (!frames.length) return;
let best = frames[0], bestIdx = 0, bestD = Math.abs((best.frame ?? 0) / VIDEO_FPS - currentTime);
for (let i = 0; i < frames.length; i++) {
const d = Math.abs(frames[i].frame / VIDEO_FPS - currentTime);
if (d < bestD) { bestD = d; best = frames[i]; bestIdx = i; }
if (bestD < 1 / VIDEO_FPS / 2) break;
}
currentFrameNumRef.current = best.frame;
currentFrameIdxRef.current = bestIdx;
currentTimeSecRef.current = currentTime;
timeUpdateWallRef.current = performance.now();
if (currentDroneFrameRef.current?.frame !== best.frame) {
currentDroneFrameRef.current = best;
setPanelDroneFrame(best);
}
}, [currentTime, droneFramesLoaded]);
// 중심선 + 나침반 캐시 빌드 (per-frame, 텍스트 계산 없음)
useEffect(() => {
// 나침반(effectiveYaw/hFovRad)은 측점선 visible 과 무관하게 항상 갱신.
const cur = currentDroneFrameRef.current;
if (cur) {
const p = paramsRef.current;
const fr = allDroneFramesRef.current;
const d = fr.length
? smoothFrame(fr, currentFrameIdxRef.current, smoothHalfRef.current)
: cur;
compassRef.current = {
effectiveYaw: d.yaw + p.yawOffset,
hFovRad: 2 * Math.atan((p.sensorW ?? 36) / (2 * p.focalLen)),
};
} else {
compassRef.current = null;
}
if (!currentDroneFrameRef.current || !visible) { renderCacheRef.current = null; return; }
const t0 = performance.now();
// 중심선도 smoothFrame 적용 (텍스트와 동일한 스무딩)
const frames = allDroneFramesRef.current;
const drone = frames.length
? smoothFrame(frames, currentFrameIdxRef.current, smoothHalfRef.current)
: currentDroneFrameRef.current;
const params = paramsRef.current;
const worldOrigin = worldOriginRef.current;
const allCL = allCenterlinePointsRef.current;
const CLIP_Z = 0.1;
const SCREEN_M = 200;
// 선로 중심선
const camPts: CameraCoords[] = allCL.map(pt =>
toCameraCoords(drone, pt.lat, pt.lon, pt.z, params, worldOrigin)
);
const centerlineSegs: [number, number, number, number][] = [];
for (let i = 0; i < camPts.length - 1; i++) {
const c1 = camPts[i], c2 = camPts[i + 1];
const z1 = c1.Zc, z2 = c2.Zc;
if (z1 < CLIP_Z && z2 < CLIP_Z) continue;
let px1: number, py1: number, px2: number, py2: number;
if (z1 >= CLIP_Z && z2 >= CLIP_Z) {
const p1 = pixelFromCamera(c1, params), p2 = pixelFromCamera(c2, params);
px1 = p1.pxRaw; py1 = p1.pyRaw; px2 = p2.pxRaw; py2 = p2.pyRaw;
} else {
const t = (CLIP_Z - z1) / (z2 - z1);
const cClip: CameraCoords = { Xc: c1.Xc + t*(c2.Xc-c1.Xc), Yc: c1.Yc + t*(c2.Yc-c1.Yc), Zc: CLIP_Z };
if (z1 < CLIP_Z) {
const p1 = pixelFromCamera(cClip, params), p2 = pixelFromCamera(c2, params);
px1 = p1.pxRaw; py1 = p1.pyRaw; px2 = p2.pxRaw; py2 = p2.pyRaw;
} else {
const p1 = pixelFromCamera(c1, params), p2 = pixelFromCamera(cClip, params);
px1 = p1.pxRaw; py1 = p1.pyRaw; px2 = p2.pxRaw; py2 = p2.pyRaw;
}
}
const oc = (px: number, py: number) =>
(px < -0.1 ? 1 : 0) | (px > 1.1 ? 2 : 0) |
(py < -0.1 ? 4 : 0) | (py > 1.1 ? 8 : 0);
if (oc(px1, py1) & oc(px2, py2)) continue;
centerlineSegs.push([px1, py1, px2, py2]);
}
renderCacheRef.current = {
centerlineSegs,
effectiveYaw: drone.yaw + params.yawOffset,
hFovRad: 2 * Math.atan((params.sensorW ?? 36) / (2 * params.focalLen)),
clCount: allCL.length,
poiCount: allPoisRef.current.length,
};
const elapsed = performance.now() - t0;
console.log(
`[cache] ${elapsed.toFixed(1)}ms | CL segs=${centerlineSegs.length}/${allCL.length} | frame=${drone.frame}`
);
}, [panelDroneFrame, params, visible]);
// ResizeObserver
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const parent = canvas.parentElement;
if (!parent) return;
const ro = new ResizeObserver(entries => {
for (const e of entries) {
canvas.width = Math.round(e.contentRect.width);
canvas.height = Math.round(e.contentRect.height);
canvasSizeRef.current = { w: canvas.width, h: canvas.height };
}
});
ro.observe(parent);
canvas.width = parent.clientWidth;
canvas.height = parent.clientHeight;
canvasSizeRef.current = { w: canvas.width, h: canvas.height };
return () => ro.disconnect();
}, []);
// RAF 렌더 루프 — 계산 없이 캐시/Map 조회만
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
let rafId = 0;
const draw = () => {
rafId = requestAnimationFrame(draw);
const ctx = canvas.getContext('2d');
if (!ctx) return;
const { w: W, h: H } = canvasSizeRef.current;
ctx.clearRect(0, 0, W, H);
const cache = renderCacheRef.current;
if (visibleRef.current && cache) {
// 선로 중심선
if (cache.centerlineSegs.length > 0) {
ctx.strokeStyle = 'rgba(255,50,50,0.85)';
ctx.lineWidth = 3;
ctx.setLineDash([]);
ctx.beginPath();
for (const [px1, py1, px2, py2] of cache.centerlineSegs) {
ctx.moveTo(px1*W, py1*H);
ctx.lineTo(px2*W, py2*H);
}
ctx.stroke();
}
// 텍스트 — Map 조회 + 프레임 간 보간 (30fps 데이터 → 60fps 부드러운 표시)
const frameNum = currentFrameNumRef.current;
// performance.now()로 마지막 prop 업데이트 이후 경과 시간을 더해 현재 재생 위치 추정
const elapsed = (performance.now() - timeUpdateWallRef.current) / 1000;
const estTime = currentTimeSecRef.current + elapsed;
const frac = Math.min(0.999, (estTime * VIDEO_FPS) - frameNum); // 0~0.999
const labelsA = labelMapRef.current.get(frameNum);
const labelsB = labelMapRef.current.get(frameNum + 1);
// 두 프레임 사이 픽셀 좌표 보간
const interpY = (a: number, b: number | undefined) => b !== undefined ? a + (b - a) * frac : a;
if (labelsA) {
const α = emaAlphaRef.current;
// 측점 라벨 — 보간 후 EMA 적용
ctx.font = 'bold 18px monospace';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
labelsA.stationLabels.forEach((stA, i) => {
const stB = labelsB?.stationLabels[i];
const targetX = interpY(stA.sx, stB?.sx);
const targetY = interpY(stA.sy, stB?.sy);
const prev = displayedStRef.current.get(stA.title);
const dispX = prev ? prev.x + (targetX - prev.x) * α : targetX;
const dispY = prev ? prev.y + (targetY - prev.y) * α : targetY;
displayedStRef.current.set(stA.title, { x: dispX, y: dispY });
const x = dispX*W, y = dispY*H;
// 마커 선
ctx.strokeStyle = 'rgba(255,100,100,0.95)'; ctx.lineWidth = 2.5;
ctx.beginPath(); ctx.moveTo(x, y-10); ctx.lineTo(x, y+10); ctx.stroke();
// 텍스트 테두리
const lx = Math.max(2, x + 8);
ctx.strokeStyle = 'rgba(0,0,0,0.85)'; ctx.lineWidth = 4;
ctx.lineJoin = 'round';
ctx.strokeText(cleanTitle(stA.title), lx, y);
// 텍스트 본문
ctx.fillStyle = 'rgba(255,200,200,1.0)';
ctx.fillText(cleanTitle(stA.title), lx, y);
});
// POI 마커 — 보간 후 EMA 적용
ctx.font = 'bold 20px sans-serif';
labelsA.poiMarkers.forEach((poiA, i) => {
const poiB = labelsB?.poiMarkers[i];
const targetX = interpY(poiA.x, poiB?.x);
const targetY = interpY(poiA.y, poiB?.y);
const prev = displayedPoiRef.current.get(poiA.title);
const dispX = prev ? prev.x + (targetX - prev.x) * α : targetX;
const dispY = prev ? prev.y + (targetY - prev.y) * α : targetY;
displayedPoiRef.current.set(poiA.title, { x: dispX, y: dispY });
const px = dispX*W, py = dispY*H, r = 10;
// 십자 마커
ctx.strokeStyle = '#64c8ff'; ctx.lineWidth = 2.5;
ctx.beginPath();
ctx.moveTo(px-r, py); ctx.lineTo(px+r, py);
ctx.moveTo(px, py-r); ctx.lineTo(px, py+r);
ctx.stroke();
// 이모지 + 텍스트
const emoji = CATEGORY_EMOJI[poiA.category] ?? '📌';
const label = `${emoji} ${cleanTitle(poiA.title)}`;
const lx = Math.max(2, px + 14);
ctx.strokeStyle = 'rgba(0,0,0,0.85)'; ctx.lineWidth = 4;
ctx.lineJoin = 'round';
ctx.textBaseline = 'middle';
ctx.strokeText(label, lx, py);
ctx.fillStyle = '#64c8ff';
ctx.fillText(label, lx, py);
ctx.textBaseline = 'alphabetic';
});
}
} // end if(visible && cache) — 중심선/측점/POI
// 나침반 HUD — 우측 상단, 측점선 토글과 무관하게 항상 표시
const compass = compassRef.current;
if (compass) {
const r = 52;
const cx = W - (r + 14), cy = 16 + r;
ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI*2);
ctx.fillStyle = 'rgba(0,0,0,0.55)'; ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.25)'; ctx.lineWidth = 1; ctx.stroke();
ctx.font = 'bold 9px sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
for (const [label, deg] of [['N',0],['E',90],['S',180],['W',270]] as const) {
const rad = (deg-90)*Math.PI/180;
ctx.fillStyle = label==='N' ? '#ff6060' : 'rgba(255,255,255,0.5)';
ctx.fillText(label, cx+Math.cos(rad)*(r-9), cy+Math.sin(rad)*(r-9));
}
const yawRad = (compass.effectiveYaw-90)*Math.PI/180;
const tx = cx+Math.cos(yawRad)*(r-14), ty = cy+Math.sin(yawRad)*(r-14);
ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(tx, ty);
ctx.strokeStyle = '#ffd700'; ctx.lineWidth = 2.5; ctx.stroke();
const ha = 0.42, hl = 9;
ctx.beginPath();
ctx.moveTo(tx, ty); ctx.lineTo(tx-hl*Math.cos(yawRad-ha), ty-hl*Math.sin(yawRad-ha));
ctx.moveTo(tx, ty); ctx.lineTo(tx-hl*Math.cos(yawRad+ha), ty-hl*Math.sin(yawRad+ha));
ctx.strokeStyle = '#ffd700'; ctx.lineWidth = 2; ctx.stroke();
ctx.beginPath(); ctx.moveTo(cx, cy);
ctx.arc(cx, cy, r-2, yawRad-compass.hFovRad/2, yawRad+compass.hFovRad/2); ctx.closePath();
ctx.fillStyle = 'rgba(255,215,0,0.12)'; ctx.fill();
ctx.strokeStyle = 'rgba(255,215,0,0.35)'; ctx.lineWidth = 1; ctx.stroke();
ctx.font = '9px monospace'; ctx.textBaseline = 'top'; ctx.fillStyle = '#ffd700';
ctx.fillText(`${((compass.effectiveYaw+360)%360).toFixed(1)}°`, cx, cy+r+2);
ctx.textAlign = 'left'; ctx.textBaseline = 'alphabetic';
}
// 범례(선로중심선/지장물 개수)는 좌상단 카메라파라미터·스테이션맵과 겹쳐 제거함.
};
rafId = requestAnimationFrame(draw);
return () => cancelAnimationFrame(rafId);
}, []);
return (
<>
<canvas
ref={canvasRef}
className="absolute inset-0 pointer-events-none z-20"
style={{ width: '100%', height: '100%' }}
/>
{showPanel && (
<div className="absolute left-2 z-30 flex flex-col items-start gap-1" style={{ top: topPx }}>
<button onClick={() => setShowControls(v => !v)}
className="w-28 text-center text-[10px] whitespace-nowrap bg-black/70 hover:bg-black/90 text-gray-200 px-1 py-1 rounded border border-gray-500 shadow">
{showControls ? '▲ 카메라 파라미터' : '▼ 카메라 파라미터'}
</button>
{showControls && (
<div className="bg-black/90 border border-gray-600 rounded p-3 text-white w-72 select-none max-h-[80vh] overflow-y-auto shadow-xl">
<div className="text-[10px] text-gray-500 uppercase tracking-wider mb-1.5 border-b border-gray-700 pb-2 mb-2"> <span className="text-gray-600">( 500ms )</span></div>
<div className="mb-3 space-y-2">
<ParamRow label="smooth" value={smoothHalf} min={0} max={60} step={1} unit="fr" decimals={0} onChange={v => setSmoothHalf(Math.round(v))} />
<div className="text-[10px] text-gray-600 text-right">±{smoothHalf}fr = ±{(smoothHalf * 1000 / (30000/1001)).toFixed(0)}ms</div>
<ParamRow label="EMA α" value={emaAlpha} min={0.01} max={1.0} step={0.01} unit="" decimals={2} onChange={v => setEmaAlpha(v)} />
<div className="text-[10px] text-gray-600 text-right">α={emaAlpha.toFixed(2)} lag{(1000/60*(1/emaAlpha - 1)).toFixed(0)}ms</div>
</div>
<div className="text-[10px] text-gray-500 uppercase tracking-wider mb-1.5"> <span className="ml-1 text-gray-600">(SRT + offset)</span></div>
<div className="space-y-2 mb-3">
<ParamRow label="Yaw ±" value={params.yawOffset} min={-180} max={180} step={0.1} unit="°" decimals={1} onChange={v => setParam('yawOffset', v)} />
<ParamRow label="Pitch ±" value={params.pitch} min={-45} max={45} step={0.1} unit="°" decimals={1} onChange={v => setParam('pitch', v)} />
<ParamRow label="Roll ±" value={params.roll} min={-45} max={45} step={0.1} unit="°" decimals={1} onChange={v => setParam('roll', v)} />
</div>
<div className="text-[10px] text-gray-500 uppercase tracking-wider mb-1.5 border-t border-gray-700 pt-2"> ( GPS )</div>
<div className="space-y-2 mb-3">
<ParamRow label="off X" value={params.offX} min={-500} max={500} step={0.1} unit="m" decimals={1} onChange={v => setParam('offX', v)} />
<ParamRow label="off Y" value={params.offY} min={-500} max={500} step={0.1} unit="m" decimals={1} onChange={v => setParam('offY', v)} />
<ParamRow label="off Z" value={params.offZ} min={-200} max={200} step={0.1} unit="m" decimals={1} onChange={v => setParam('offZ', v)} />
</div>
<div className="text-[10px] text-gray-500 uppercase tracking-wider mb-1.5 border-t border-gray-700 pt-2"> (··)</div>
<div className="space-y-2 mb-3">
<ParamRow label="f" value={params.focalLen} min={10} max={100} step={0.1} unit="mm" decimals={1} onChange={v => setParam('focalLen', v)} />
<ParamRow label="cx₀" value={params.cx0} min={-0.5} max={0.5} step={0.005} unit="" decimals={3} onChange={v => setParam('cx0', v)} />
<ParamRow label="cy₀" value={params.cy0} min={-0.5} max={0.5} step={0.005} unit="" decimals={3} onChange={v => setParam('cy0', v)} />
<ParamRow label="sen W" value={params.sensorW} min={10} max={50} step={0.05} unit="mm" decimals={2} onChange={v => setParam('sensorW', v)} />
<ParamRow label="sen H" value={params.sensorH} min={6} max={36} step={0.05} unit="mm" decimals={2} onChange={v => setParam('sensorH', v)} />
</div>
{panelDroneFrame && (
<div className="border-t border-gray-700 pt-2 mb-2 text-[10px] text-gray-400 font-mono space-y-0.5">
<div>yaw: {((panelDroneFrame.yaw+params.yawOffset+360)%360).toFixed(1)}° pitch: {(panelDroneFrame.pitch+params.pitch).toFixed(1)}° roll: {(panelDroneFrame.roll+params.roll).toFixed(1)}°</div>
<div>f: {params.focalLen.toFixed(1)}mm hFOV: {(2*Math.atan((params.sensorW??36)/(2*params.focalLen))*180/Math.PI).toFixed(1)}°</div>
<div>offX: {params.offX.toFixed(1)}m offY: {params.offY.toFixed(1)}m offZ: {params.offZ.toFixed(1)}m</div>
</div>
)}
<div className="border-t border-gray-700 pt-2 flex items-center justify-end">
<button onClick={() => setParams({ ...DEFAULT_CAMERA_PARAMS })}
className="text-[11px] text-gray-400 hover:text-white border border-gray-600 hover:border-gray-400 px-2 py-0.5 rounded transition-colors">
</button>
</div>
</div>
)}
</div>
)}
</>
);
}