UI 수정

기획안 반영 및 보완
This commit is contained in:
b23042
2026-06-19 14:35:19 +09:00
committed by 한성일
parent 7d06e384bf
commit 819065a8f5
27 changed files with 2474 additions and 461 deletions

View File

@@ -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>
)}
</>
);
}