/** * 지리정보 오버레이 * 렌더링 최적화: * - 텍스트(측점+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 = { '터널': '🚇', '교량': '🌉', '역사': '🚉', '지장물': '🏢', '측점': '📍', }; // 텍스트 사전 계산 캐시 (Map) 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 (
{label} { const v = parseFloat(e.target.value); onChange(v); prevRef.current = v; setText(fmt(v)); }} className="flex-1 h-1 accent-yellow-400 cursor-pointer" /> 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" /> {unit}
); } // ── 메인 컴포넌트 ───────────────────────────────────────────────────────────── export default function StationOverlay({ currentFrame, currentTime, fps, visible, showPanel = true, topPx = 72 }: Props) { const canvasRef = useRef(null); const canvasSizeRef = useRef({ w: 0, h: 0 }); // 데이터 ref const allDroneFramesRef = useRef([]); const allCenterlinePointsRef = useRef([]); const allGeoStationsRef = useRef([]); const allPoisRef = useRef([]); // 현재 상태 ref const currentDroneFrameRef = useRef(null); const currentFrameNumRef = useRef(0); // RAF에서 Map 조회용 const currentFrameIdxRef = useRef(0); // smoothFrame용 배열 인덱스 const currentTimeSecRef = useRef(0); // 마지막으로 알려진 재생 시간 const timeUpdateWallRef = useRef(performance.now()); // currentTime 갱신된 시각 const paramsRef = useRef(DEFAULT_CAMERA_PARAMS); const visibleRef = useRef(visible); const worldOriginRef = useRef<{ lat: number; lon: number; alt: number } | undefined>(undefined); // 텍스트 사전 계산 Map const labelMapRef = useRef>(new Map()); const precomputeIdRef = useRef(0); // 진행 중 계산 취소용 // 중심선 + 나침반 렌더 캐시 (per-frame) const renderCacheRef = useRef(null); // 나침반 전용(측점선 visible 무관 항상 갱신·표시) const compassRef = useRef<{ effectiveYaw: number; hFovRad: number } | null>(null); // UI state const [params, setParams] = useState(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>(new Map()); const displayedPoiRef = useRef>(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(null); useEffect(() => { paramsRef.current = params; }, [params]); useEffect(() => { visibleRef.current = visible; }, [visible]); useEffect(() => { smoothHalfRef.current = smoothHalf; }, [smoothHalf]); useEffect(() => { emaAlphaRef.current = emaAlpha; }, [emaAlpha]); const setParam = useCallback((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(); 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 ( <> {showPanel && (
{showControls && (
스무딩 (재계산 500ms 후)
setSmoothHalf(Math.round(v))} />
±{smoothHalf}fr = ±{(smoothHalf * 1000 / (30000/1001)).toFixed(0)}ms
setEmaAlpha(v)} />
α={emaAlpha.toFixed(2)} → lag≈{(1000/60*(1/emaAlpha - 1)).toFixed(0)}ms
자세 보정 오프셋(SRT + offset)
setParam('yawOffset', v)} /> setParam('pitch', v)} /> setParam('roll', v)} />
위치 보정 (드론 GPS 오프셋)
setParam('offX', v)} /> setParam('offY', v)} /> setParam('offZ', v)} />
내부표정 (초점·주점·센서)
setParam('focalLen', v)} /> setParam('cx0', v)} /> setParam('cy0', v)} /> setParam('sensorW', v)} /> setParam('sensorH', v)} />
{panelDroneFrame && (
yaw: {((panelDroneFrame.yaw+params.yawOffset+360)%360).toFixed(1)}° pitch: {(panelDroneFrame.pitch+params.pitch).toFixed(1)}° roll: {(panelDroneFrame.roll+params.roll).toFixed(1)}°
f: {params.focalLen.toFixed(1)}mm hFOV: {(2*Math.atan((params.sensorW??36)/(2*params.focalLen))*180/Math.PI).toFixed(1)}°
offX: {params.offX.toFixed(1)}m offY: {params.offY.toFixed(1)}m offZ: {params.offZ.toFixed(1)}m
)}
)}
)} ); }