UI 수정
기획안 반영 및 보완
This commit is contained in:
@@ -16,21 +16,8 @@ import {
|
||||
type CameraCoords,
|
||||
DEFAULT_CAMERA_PARAMS,
|
||||
} from '../../utils/geoProjection';
|
||||
|
||||
interface GeoPoint {
|
||||
title: string;
|
||||
category: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
z: number;
|
||||
type: 'poi' | 'station';
|
||||
}
|
||||
|
||||
interface CenterlinePoint {
|
||||
lat: number;
|
||||
lon: number;
|
||||
z: number;
|
||||
}
|
||||
import { useGeoStore } from '../../store/geoStore';
|
||||
import type { GeoPoint, CenterlinePoint } from '../../types/geo';
|
||||
|
||||
const VIDEO_FPS = 30000 / 1001;
|
||||
|
||||
@@ -39,6 +26,10 @@ interface Props {
|
||||
currentTime: number;
|
||||
fps: number;
|
||||
visible: boolean;
|
||||
/** 카메라 파라미터 패널 표시 (영상제어 토글). 기본 true. */
|
||||
showPanel?: boolean;
|
||||
/** 카메라 파라미터 패널 top(px) — 노선 배너 아래로 배치. 기본 72. */
|
||||
topPx?: number;
|
||||
}
|
||||
|
||||
// category → 이모지
|
||||
@@ -112,7 +103,7 @@ function ParamRow({ label, value, min, max, step, unit, decimals = 1, onChange }
|
||||
|
||||
// ── 메인 컴포넌트 ─────────────────────────────────────────────────────────────
|
||||
|
||||
export default function StationOverlay({ currentFrame, currentTime, fps, visible }: Props) {
|
||||
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 });
|
||||
|
||||
@@ -138,6 +129,8 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
|
||||
// 중심선 + 나침반 렌더 캐시 (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);
|
||||
@@ -173,43 +166,39 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
return best;
|
||||
}, []);
|
||||
|
||||
function updateWorldOrigin() {
|
||||
const st = allGeoStationsRef.current;
|
||||
const cl = allCenterlinePointsRef.current;
|
||||
if (st[0]) worldOriginRef.current = { lat: st[0].lat, lon: st[0].lon, alt: st[0].z };
|
||||
else if (cl[0]) worldOriginRef.current = { lat: cl[0].lat, lon: cl[0].lon, alt: cl[0].z };
|
||||
}
|
||||
// 클라이언트 지리정보 스토어 구독 (서버 /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(() => {
|
||||
if (!visible) return;
|
||||
fetch('/api/geo/pois').then(r => r.json()).then((data: GeoPoint[]) => {
|
||||
allGeoStationsRef.current = (data || []).filter(p => p.type === 'station')
|
||||
.sort((a, b) => stationOrder(a.title) - stationOrder(b.title));
|
||||
allPoisRef.current = (data || []).filter(p => p.type === 'poi');
|
||||
updateWorldOrigin();
|
||||
setGeoDataLoaded(true);
|
||||
}).catch(() => {});
|
||||
}, [visible]);
|
||||
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(() => {
|
||||
if (!visible) return;
|
||||
fetch('/api/geo/centerline').then(r => r.json()).then((data: CenterlinePoint[]) => {
|
||||
allCenterlinePointsRef.current = Array.isArray(data) ? data : [];
|
||||
updateWorldOrigin();
|
||||
setClDataLoaded(true);
|
||||
}).catch(() => {});
|
||||
}, [visible]);
|
||||
allCenterlinePointsRef.current = storeCenterline;
|
||||
setClDataLoaded(storeCenterline.length > 0);
|
||||
}, [storeCenterline]);
|
||||
|
||||
// 드론 프레임
|
||||
useEffect(() => {
|
||||
if (!visible || droneFramesLoaded) return;
|
||||
fetch('/api/geo/frames?step=1').then(r => r.json()).then((data: DroneFrameBasic[]) => {
|
||||
if (Array.isArray(data) && data.length > 0) {
|
||||
allDroneFramesRef.current = data;
|
||||
setDroneFramesLoaded(true);
|
||||
}
|
||||
}).catch(() => {});
|
||||
}, [visible, droneFramesLoaded]);
|
||||
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 => {
|
||||
@@ -307,7 +296,8 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
|
||||
// 현재 재생 시간 → 드론 프레임 ref 갱신
|
||||
useEffect(() => {
|
||||
if (!visible || !droneFramesLoaded) return;
|
||||
// 나침반이 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);
|
||||
@@ -324,10 +314,26 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
currentDroneFrameRef.current = best;
|
||||
setPanelDroneFrame(best);
|
||||
}
|
||||
}, [currentTime, visible, droneFramesLoaded]);
|
||||
}, [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();
|
||||
@@ -419,10 +425,9 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
if (!ctx) return;
|
||||
const { w: W, h: H } = canvasSizeRef.current;
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
if (!visibleRef.current) return;
|
||||
|
||||
const cache = renderCacheRef.current;
|
||||
if (!cache) return;
|
||||
if (visibleRef.current && cache) {
|
||||
|
||||
// 선로 중심선
|
||||
if (cache.centerlineSegs.length > 0) {
|
||||
@@ -508,11 +513,13 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
ctx.textBaseline = 'alphabetic';
|
||||
});
|
||||
}
|
||||
} // end if(visible && cache) — 중심선/측점/POI
|
||||
|
||||
// 나침반 HUD — 우측 상단 (상단 '카메라 파라미터' 버튼 아래로 띄워 하단 StationBar/배지와 겹침 방지)
|
||||
{
|
||||
const r = 38;
|
||||
const cx = W-54, cy = 52 + r;
|
||||
// 나침반 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();
|
||||
@@ -522,7 +529,7 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
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 = (cache.effectiveYaw-90)*Math.PI/180;
|
||||
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();
|
||||
@@ -532,29 +539,15 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
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-cache.hFovRad/2, yawRad+cache.hFovRad/2); ctx.closePath();
|
||||
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(`${((cache.effectiveYaw+360)%360).toFixed(1)}°`, cx, cy+r+2);
|
||||
ctx.fillText(`${((compass.effectiveYaw+360)%360).toFixed(1)}°`, cx, cy+r+2);
|
||||
ctx.textAlign = 'left'; ctx.textBaseline = 'alphabetic';
|
||||
}
|
||||
|
||||
// 범례
|
||||
if (cache.clCount > 0 || cache.poiCount > 0) {
|
||||
const lines = [
|
||||
...(cache.clCount > 0 ? [{ color: 'rgba(255,60,60,0.9)', text: `— 선로중심선 (${cache.clCount}점)` }] : []),
|
||||
...(cache.poiCount > 0 ? [{ color: '#64c8ff', text: `+ 지장물 ${cache.poiCount}개` }] : []),
|
||||
];
|
||||
// 좌상단 타임코드(HTML) 아래로 배치 (코너 HUD 정보 그룹)
|
||||
const legendTop = 34;
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.6)';
|
||||
ctx.fillRect(6, legendTop, 160, lines.length*15+6);
|
||||
lines.forEach(({ color, text }, i) => {
|
||||
ctx.font = '10px sans-serif'; ctx.fillStyle = color;
|
||||
ctx.fillText(text, 12, legendTop + 14 + i*15);
|
||||
});
|
||||
}
|
||||
// 범례(선로중심선/지장물 개수)는 좌상단 카메라파라미터·스테이션맵과 겹쳐 제거함.
|
||||
};
|
||||
|
||||
rafId = requestAnimationFrame(draw);
|
||||
@@ -566,11 +559,12 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="absolute inset-0 pointer-events-none z-20"
|
||||
style={{ width: '100%', height: '100%', display: visible ? undefined : 'none' }}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
/>
|
||||
<div className="absolute top-2 right-2 z-30 flex flex-col items-end gap-1">
|
||||
{showPanel && (
|
||||
<div className="absolute left-2 z-30 flex flex-col items-start gap-1" style={{ top: topPx }}>
|
||||
<button onClick={() => setShowControls(v => !v)}
|
||||
className="text-xs bg-black/70 hover:bg-black/90 text-gray-200 px-2 py-1 rounded border border-gray-500 shadow">
|
||||
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 && (
|
||||
@@ -618,6 +612,7 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user