UI 수정
기획안 반영 및 보완
This commit is contained in:
@@ -4,6 +4,12 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700;800&display=swap"
|
||||
/>
|
||||
<title>abcvideo — 대용량 동영상 플레이어</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -17,6 +17,9 @@ export default function App() {
|
||||
const [showHelp, setShowHelp] = useState(false);
|
||||
const [memoTime, setMemoTime] = useState(0);
|
||||
const [rightTab, setRightTab] = useState<'annotation' | 'geo' | 'station'>('annotation');
|
||||
// 좌/우 패널: UI 에서 숨김(코드 보존). 다시 표시하려면 true 로.
|
||||
const SHOW_LEFT_PANEL = false;
|
||||
const SHOW_RIGHT_PANEL = false;
|
||||
const playerRef = useRef<VideoPlayerHandle>(null);
|
||||
const rafRef = useRef<number>(0);
|
||||
|
||||
@@ -76,7 +79,8 @@ export default function App() {
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-gray-950 text-white overflow-hidden">
|
||||
{/* Left sidebar — top: video list, bottom: captures */}
|
||||
{/* Left sidebar — top: video list, bottom: captures (UI 숨김, 코드 보존) */}
|
||||
{SHOW_LEFT_PANEL && (
|
||||
<div className="w-56 flex-shrink-0 bg-gray-900 border-r border-gray-800 flex flex-col">
|
||||
<div className="px-4 py-3 border-b border-gray-800">
|
||||
<h1 className="text-sm font-bold text-white">abcvideo</h1>
|
||||
@@ -96,6 +100,7 @@ export default function App() {
|
||||
<CaptureList onSeek={handleSeek} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main — player + memo overlay */}
|
||||
<div className="flex-1 flex flex-col min-w-0 relative">
|
||||
@@ -115,7 +120,8 @@ export default function App() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right sidebar — annotation + geo */}
|
||||
{/* Right sidebar — annotation + geo (UI 숨김, 코드 보존) */}
|
||||
{SHOW_RIGHT_PANEL && (
|
||||
<div className="w-64 flex-shrink-0 bg-gray-900 border-l border-gray-800 flex flex-col">
|
||||
{/* 탭 헤더 */}
|
||||
<div className="flex border-b border-gray-800 flex-shrink-0">
|
||||
@@ -163,6 +169,7 @@ export default function App() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add annotation modal */}
|
||||
{showModal && (
|
||||
|
||||
@@ -5,37 +5,9 @@
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
|
||||
interface GeoPoint {
|
||||
title: string;
|
||||
category: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
z: number;
|
||||
type: 'poi' | 'station';
|
||||
}
|
||||
|
||||
interface FrameMatch {
|
||||
frame: number;
|
||||
time: number;
|
||||
bearingDiff: number;
|
||||
elevationDiff: number;
|
||||
distance: number;
|
||||
pixelX: number;
|
||||
pixelY: number;
|
||||
groupSize?: number;
|
||||
groupStart?: number;
|
||||
groupEnd?: number;
|
||||
}
|
||||
|
||||
interface PoiInFrame {
|
||||
poi: GeoPoint;
|
||||
bearingDiff: number;
|
||||
elevationDiff: number;
|
||||
distance: number;
|
||||
pixelX: number;
|
||||
pixelY: number;
|
||||
}
|
||||
import { useGeoStore } from '../../store/geoStore';
|
||||
import { findFramesForPoi, findPoisForFrame } from '../../utils/geoSearch';
|
||||
import type { GeoPoint, FrameMatch, PoiInFrame } from '../../types/geo';
|
||||
|
||||
interface Props {
|
||||
currentFrame: number;
|
||||
@@ -49,20 +21,23 @@ export default function GeoSearch({ currentFrame, fps, onSeekToFrame }: Props) {
|
||||
const [tab, setTab] = useState<Tab>('search');
|
||||
const [query, setQuery] = useState('');
|
||||
const [suggestions, setSuggestions] = useState<GeoPoint[]>([]);
|
||||
const [allPois, setAllPois] = useState<GeoPoint[]>([]);
|
||||
const [searchResult, setSearchResult] = useState<{ poi: GeoPoint; frames: FrameMatch[] } | null>(null);
|
||||
const [reverseResult, setReverseResult] = useState<PoiInFrame[] | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// POI 목록 로드 (자동완성용)
|
||||
useEffect(() => {
|
||||
fetch('/api/geo/pois')
|
||||
.then(r => r.json())
|
||||
.then(data => setAllPois(Array.isArray(data) ? data : []))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
// 클라이언트 지리정보 스토어 구독 (서버 /api/geo/* 대체)
|
||||
const loaded = useGeoStore(s => s.loaded);
|
||||
const frames = useGeoStore(s => s.frames);
|
||||
const pois = useGeoStore(s => s.pois);
|
||||
const stations = useGeoStore(s => s.stations);
|
||||
|
||||
// POI 목록 (자동완성용): 측점 + 건물 통합 (서버 /api/geo/pois 동치)
|
||||
const allPois = React.useMemo<GeoPoint[]>(
|
||||
() => (loaded ? [...stations, ...pois] : []),
|
||||
[loaded, stations, pois],
|
||||
);
|
||||
|
||||
// 자동완성 필터링
|
||||
useEffect(() => {
|
||||
@@ -71,45 +46,44 @@ export default function GeoSearch({ currentFrame, fps, onSeekToFrame }: Props) {
|
||||
setSuggestions(allPois.filter(p => p.title.toLowerCase().includes(q)).slice(0, 10));
|
||||
}, [query, allPois]);
|
||||
|
||||
// 건물/측점명으로 프레임 검색
|
||||
const handleSearch = useCallback(async (q?: string) => {
|
||||
// 건물/측점명으로 프레임 검색 (클라이언트 검색 — 서버 /api/geo/search 대체)
|
||||
const handleSearch = useCallback((q?: string) => {
|
||||
const searchQ = (q ?? query).trim();
|
||||
if (!searchQ) return;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
setSuggestions([]);
|
||||
try {
|
||||
const res = await fetch(`/api/geo/search?q=${encodeURIComponent(searchQ)}&margin=1.0&maxDist=1500`);
|
||||
const data = await res.json();
|
||||
if (!res.ok) { setError(data.error || '검색 실패'); setSearchResult(null); return; }
|
||||
setSearchResult(data);
|
||||
} catch {
|
||||
setError('서버 연결 실패');
|
||||
if (!loaded) { setError('폴더를 먼저 선택하세요'); setSearchResult(null); return; }
|
||||
const origin = useGeoStore.getState().origin;
|
||||
const combined = [...stations, ...pois];
|
||||
const result = findFramesForPoi(frames, combined, searchQ, 1.0, 1500, 0, origin);
|
||||
if (!result.poi) { setError('일치하는 건물/측점 없음'); setSearchResult(null); return; }
|
||||
setSearchResult({ poi: result.poi, frames: result.frames });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [query]);
|
||||
}, [query, loaded, frames, stations, pois]);
|
||||
|
||||
// 현재 프레임 역조회
|
||||
const handleReverse = useCallback(async () => {
|
||||
// 현재 프레임 역조회 (클라이언트 검색 — 서버 /api/geo/frame/{n} 대체)
|
||||
const handleReverse = useCallback(() => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const res = await fetch(`/api/geo/frame/${currentFrame}?margin=1.0`);
|
||||
const data = await res.json();
|
||||
if (!res.ok) { setError(data.error || '조회 실패'); setReverseResult(null); return; }
|
||||
setReverseResult(data.pois ?? []);
|
||||
} catch {
|
||||
setError('서버 연결 실패');
|
||||
if (!loaded) { setReverseResult([]); return; }
|
||||
const origin = useGeoStore.getState().origin;
|
||||
const combined = [...stations, ...pois];
|
||||
const result = findPoisForFrame(frames, combined, currentFrame, 1.0, 0, origin);
|
||||
setReverseResult(result.pois);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [currentFrame]);
|
||||
}, [currentFrame, loaded, frames, stations, pois]);
|
||||
|
||||
// 탭 전환 시 역조회 자동 실행
|
||||
// 탭 전환/프레임 변경/데이터 로드 시 역조회 자동 실행
|
||||
useEffect(() => {
|
||||
if (tab === 'reverse') handleReverse();
|
||||
}, [tab, currentFrame]);
|
||||
}, [tab, currentFrame, handleReverse]);
|
||||
|
||||
const formatDist = (m: number) =>
|
||||
m >= 1000 ? `${(m / 1000).toFixed(2)}km` : `${Math.round(m)}m`;
|
||||
|
||||
@@ -2,31 +2,14 @@
|
||||
* 측점 검증 패널
|
||||
* - 측점 목록을 클릭하면 해당 측점이 가장 잘 보이는 프레임으로 이동
|
||||
* - 이동 결과(거리, 화면 위치)를 표시하여 계산 정확도 검증
|
||||
*
|
||||
* 데이터 소스: 클라이언트 geoStore(폴더 선택으로 파싱). 폴더 미선택 시 빈 상태.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
|
||||
interface GeoPoint {
|
||||
title: string;
|
||||
category: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
z: number;
|
||||
type: 'poi' | 'station';
|
||||
}
|
||||
|
||||
interface FrameMatch {
|
||||
frame: number;
|
||||
time: number;
|
||||
bearingDiff: number;
|
||||
elevationDiff: number;
|
||||
distance: number;
|
||||
pixelX: number;
|
||||
pixelY: number;
|
||||
groupSize?: number;
|
||||
groupStart?: number;
|
||||
groupEnd?: number;
|
||||
}
|
||||
import React, { useState } from 'react';
|
||||
import { useGeoStore } from '../../store/geoStore';
|
||||
import { findFramesForPoi } from '../../utils/geoSearch';
|
||||
import type { GeoPoint, FrameMatch } from '../../types/geo';
|
||||
|
||||
interface StationResult {
|
||||
frames: FrameMatch[];
|
||||
@@ -38,43 +21,16 @@ interface Props {
|
||||
onSeekToFrame: (frame: number) => void;
|
||||
}
|
||||
|
||||
function stationOrder(title: string): number {
|
||||
const m = title.match(/(\d+)[Kk](\d+)/);
|
||||
if (!m) return 0;
|
||||
return parseInt(m[1]) * 1000 + parseInt(m[2]);
|
||||
}
|
||||
|
||||
export default function StationVerify({ fps, onSeekToFrame }: Props) {
|
||||
const [stations, setStations] = useState<GeoPoint[]>([]);
|
||||
// stations 는 이미 stationOrder 로 정렬되어 있다.
|
||||
const stations = useGeoStore(s => s.stations);
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<StationResult | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [seekedFrame, setSeekedFrame] = useState<number | null>(null);
|
||||
// 드론 프레임(위경도) — 측점 클릭 시 GPS 최근접 프레임 계산용.
|
||||
const framesRef = useRef<{ frame: number; lat: number; lon: number }[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/geo/pois')
|
||||
.then(r => r.json())
|
||||
.then((data: GeoPoint[]) => {
|
||||
const s = Array.isArray(data)
|
||||
? data.filter(p => p.type === 'station').sort((a, b) => stationOrder(a.title) - stationOrder(b.title))
|
||||
: [];
|
||||
setStations(s);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/geo/frames?step=1')
|
||||
.then(r => r.json())
|
||||
.then((d) => { framesRef.current = Array.isArray(d) ? d : []; })
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// 드론 GPS 가 측점에 가장 가까운 프레임 (StationBar 배지·실제 위치와 동일 기준).
|
||||
const nearestFrameForStation = (st: GeoPoint): number | null => {
|
||||
const fr = framesRef.current;
|
||||
const fr = useGeoStore.getState().frames;
|
||||
if (!fr.length) return null;
|
||||
let best = fr[0];
|
||||
let bd = (fr[0].lat - st.lat) ** 2 + (fr[0].lon - st.lon) ** 2;
|
||||
@@ -85,10 +41,9 @@ export default function StationVerify({ fps, onSeekToFrame }: Props) {
|
||||
return best.frame;
|
||||
};
|
||||
|
||||
const handleClick = async (station: GeoPoint) => {
|
||||
const handleClick = (station: GeoPoint) => {
|
||||
setSelected(station.title);
|
||||
setResult(null);
|
||||
setLoading(true);
|
||||
setSeekedFrame(null);
|
||||
// 영상은 드론 GPS 가 그 측점에 가장 가까운 프레임으로 이동한다.
|
||||
// (카메라 FOV 검색은 앞을 보는 카메라 특성상 ~200m 앞쪽으로 치우쳐 위치가 어긋남)
|
||||
@@ -98,15 +53,17 @@ export default function StationVerify({ fps, onSeekToFrame }: Props) {
|
||||
setSeekedFrame(gpsFrame);
|
||||
}
|
||||
// 검증 정보(카메라 시야 프레임/투영)는 참고용으로 표시.
|
||||
try {
|
||||
const res = await fetch(`/api/geo/search?q=${encodeURIComponent(station.title)}&margin=1.2&maxDist=2000`);
|
||||
const data = await res.json();
|
||||
setResult(res.ok && data.frames?.length ? data : { frames: [], poi: data.poi ?? station });
|
||||
} catch {
|
||||
setResult(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
const { frames, pois, stations: sts, origin } = useGeoStore.getState();
|
||||
const { poi, frames: matches } = findFramesForPoi(
|
||||
frames,
|
||||
[...sts, ...pois],
|
||||
station.title,
|
||||
1.2,
|
||||
2000,
|
||||
0,
|
||||
origin,
|
||||
);
|
||||
setResult({ frames: matches, poi: poi ?? station });
|
||||
};
|
||||
|
||||
const pixelQuality = (px: number, py: number) => {
|
||||
@@ -130,11 +87,10 @@ export default function StationVerify({ fps, onSeekToFrame }: Props) {
|
||||
{selected && (
|
||||
<div className="mx-2 my-2 p-2 bg-gray-800 rounded border border-gray-600 flex-shrink-0">
|
||||
<div className="text-xs font-bold text-white">{selected}</div>
|
||||
{loading && <div className="text-xs text-gray-400 mt-1">검색 중…</div>}
|
||||
{!loading && result && result.frames.length === 0 && (
|
||||
{result && result.frames.length === 0 && (
|
||||
<div className="text-xs text-red-400 mt-1">카메라 시야에 들어오는 프레임 없음</div>
|
||||
)}
|
||||
{!loading && result && result.frames.length > 0 && (() => {
|
||||
{result && result.frames.length > 0 && (() => {
|
||||
const f = result.frames[0];
|
||||
return (
|
||||
<>
|
||||
|
||||
117
client/src/components/overlay/RouteInfo.module.css
Normal file
117
client/src/components/overlay/RouteInfo.module.css
Normal file
@@ -0,0 +1,117 @@
|
||||
/* videoplayer/src/components/RouteInfo/RouteInfo.module.scss 1:1 이식 (plain CSS). */
|
||||
.panel {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 429.5px;
|
||||
height: 65px;
|
||||
overflow: hidden;
|
||||
z-index: 30;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.bg {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 429.5px;
|
||||
height: 65px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.direction,
|
||||
.routeName {
|
||||
position: absolute;
|
||||
left: 79.5px;
|
||||
margin: 0;
|
||||
font-family: 'Noto Sans KR', var(--font-ui, sans-serif);
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
-webkit-text-stroke: 2.5px rgb(13, 44, 36);
|
||||
paint-order: stroke fill;
|
||||
}
|
||||
|
||||
.direction {
|
||||
top: 8px;
|
||||
font-size: 20px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.routeName {
|
||||
top: 35px;
|
||||
font-size: 16.5px;
|
||||
letter-spacing: 0.03em;
|
||||
color: rgb(255, 132, 54);
|
||||
}
|
||||
|
||||
.lengthLabel,
|
||||
.durationLabel {
|
||||
position: absolute;
|
||||
margin: 0;
|
||||
font-family: 'Noto Sans KR', var(--font-ui, sans-serif);
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
color: #fff;
|
||||
text-shadow:
|
||||
0 0 4.5px rgb(0, 0, 0),
|
||||
0 0 4.5px rgb(0, 0, 0);
|
||||
}
|
||||
|
||||
.lengthLabel {
|
||||
left: 261.5px;
|
||||
top: 13px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.lengthValue,
|
||||
.durationValue {
|
||||
position: absolute;
|
||||
margin: 0;
|
||||
font-family: var(--font-ui, sans-serif);
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.05em;
|
||||
color: rgb(255, 132, 54);
|
||||
text-shadow:
|
||||
0 0 4.5px rgb(0, 0, 0),
|
||||
0 0 4.5px rgb(0, 0, 0);
|
||||
}
|
||||
|
||||
.lengthValue {
|
||||
left: 296px;
|
||||
top: 8px;
|
||||
width: 40px;
|
||||
text-align: right;
|
||||
font-size: 19px;
|
||||
}
|
||||
|
||||
.lengthUnit {
|
||||
position: absolute;
|
||||
left: 342px;
|
||||
top: 11px;
|
||||
margin: 0;
|
||||
font-family: 'Noto Sans KR', var(--font-ui, sans-serif);
|
||||
font-weight: 700;
|
||||
font-size: 16.5px;
|
||||
line-height: 1.2;
|
||||
color: #fff;
|
||||
text-shadow:
|
||||
0 0 4.5px rgb(0, 0, 0),
|
||||
0 0 4.5px rgb(0, 0, 0);
|
||||
}
|
||||
|
||||
.durationValue {
|
||||
left: 257.5px;
|
||||
top: 33px;
|
||||
width: 74px;
|
||||
text-align: right;
|
||||
font-size: 19px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.durationLabel {
|
||||
left: 337.5px;
|
||||
top: 35px;
|
||||
font-size: 16px;
|
||||
}
|
||||
94
client/src/components/overlay/RouteInfoOverlay.tsx
Normal file
94
client/src/components/overlay/RouteInfoOverlay.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { useGeoStore } from '../../store/geoStore';
|
||||
import { usePlayerStore } from '../../store/playerStore';
|
||||
import styles from './RouteInfo.module.css';
|
||||
|
||||
/** 정적 에셋 경로 (Vite base 반영). */
|
||||
const bgUrl = `${import.meta.env.BASE_URL}assets/title-panel-bg@2x.png`;
|
||||
|
||||
/** 원본 디자인 무대 가로폭(px). 배너는 이 기준으로 만들어졌다. */
|
||||
const STAGE_WIDTH = 1920;
|
||||
|
||||
/** 초 → "M분 S초". */
|
||||
function formatDuration(sec?: number | null): string {
|
||||
if (sec == null || !isFinite(sec) || sec <= 0) return '';
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = Math.round(sec % 60);
|
||||
return s > 0 ? `${m}분 ${s}초` : `${m}분`;
|
||||
}
|
||||
|
||||
/** "158k700" → 158700 (m). 매칭 실패 시 -1. */
|
||||
function stationKm(title: string): number {
|
||||
const m = title.match(/(\d+)[Kk](\d+)/);
|
||||
return m ? parseInt(m[1], 10) * 1000 + parseInt(m[2], 10) : -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 영상 좌상단 노선 정보 배너 — videoplayer 의 RouteInfo 디자인 이식.
|
||||
* 값은 모두 선택한 폴더에서 가져온다(하드코딩 없음).
|
||||
* - 연장(lengthKm): route.json 우선 → 측점 CSV 구간(min~max km) 계산 폴백.
|
||||
* - 소요(durationSec): route.json 우선 → 실제 영상 길이 폴백.
|
||||
* - 방향/노선명: CSV에 없는 정보 → route.json(routeInfo).
|
||||
* 표출할 값이 하나도 없으면 렌더하지 않는다.
|
||||
*/
|
||||
export default function RouteInfoOverlay() {
|
||||
const routeInfo = useGeoStore((s) => s.routeMeta?.routeInfo);
|
||||
const stations = useGeoStore((s) => s.stations);
|
||||
const videoDuration = usePlayerStore((s) => s.duration);
|
||||
|
||||
// 원본처럼 영상 폭/1920 비율로 배너를 스케일 (부모=영상 영역 폭 관측).
|
||||
const [scale, setScale] = useState(1);
|
||||
const roRef = useRef<ResizeObserver | null>(null);
|
||||
const setPanelRef = useCallback((el: HTMLDivElement | null) => {
|
||||
roRef.current?.disconnect();
|
||||
const parent = el?.parentElement;
|
||||
if (!parent) return;
|
||||
const update = () => setScale(parent.clientWidth / STAGE_WIDTH);
|
||||
update();
|
||||
const ro = new ResizeObserver(update);
|
||||
ro.observe(parent);
|
||||
roRef.current = ro;
|
||||
}, []);
|
||||
|
||||
const direction = routeInfo?.direction;
|
||||
const name = routeInfo?.name;
|
||||
|
||||
// 연장: route.json 우선 → 측점 구간 계산 폴백
|
||||
let lengthKm = routeInfo?.lengthKm ?? null;
|
||||
if (lengthKm == null && stations.length) {
|
||||
const kms = stations.map((s) => stationKm(s.title)).filter((k) => k >= 0);
|
||||
if (kms.length >= 2) {
|
||||
lengthKm = Math.round((Math.max(...kms) - Math.min(...kms)) / 10) / 100; // m→km, 소수2
|
||||
}
|
||||
}
|
||||
|
||||
// 소요시간: route.json 우선 → 실제 영상 길이 폴백
|
||||
const dur = formatDuration(routeInfo?.durationSec ?? videoDuration);
|
||||
|
||||
if (!direction && !name && lengthKm == null && !dur) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setPanelRef}
|
||||
className={styles.panel}
|
||||
style={{ transform: `scale(${scale})`, transformOrigin: 'top left' }}
|
||||
>
|
||||
<img className={styles.bg} src={bgUrl} alt="" />
|
||||
{direction && <p className={styles.direction}>{direction}</p>}
|
||||
{name && <p className={styles.routeName}>{name}</p>}
|
||||
{lengthKm != null && (
|
||||
<>
|
||||
<p className={styles.lengthLabel}>연장</p>
|
||||
<p className={styles.lengthValue}>{lengthKm}</p>
|
||||
<p className={styles.lengthUnit}>km</p>
|
||||
</>
|
||||
)}
|
||||
{dur && (
|
||||
<>
|
||||
<p className={styles.durationValue}>{dur}</p>
|
||||
<p className={styles.durationLabel}>소요</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,23 +2,19 @@ import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import {
|
||||
toCameraCoords,
|
||||
pixelFromCamera,
|
||||
type DroneFrameBasic,
|
||||
DEFAULT_CAMERA_PARAMS,
|
||||
} from '../../utils/geoProjection';
|
||||
|
||||
interface GeoPoint {
|
||||
title: string;
|
||||
category: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
z: number;
|
||||
type: string;
|
||||
}
|
||||
import { useGeoStore } from '../../store/geoStore';
|
||||
import type { GeoPoint } from '../../types/geo';
|
||||
|
||||
interface RoutePanelProps {
|
||||
currentTime: number;
|
||||
visible: boolean;
|
||||
onSeek: (time: number) => void;
|
||||
/** 상단 침범 방지: 컨테이너 top(px). 기본 위쪽(배너/카메라파라미터) 아래. */
|
||||
topPx?: number;
|
||||
/** 하단 침범 방지: 컨테이너 bottom(px). 기본 아래쪽(배속/토글/재생바) 위. */
|
||||
bottomPx?: number;
|
||||
}
|
||||
|
||||
const VIDEO_FPS = 30000 / 1001;
|
||||
@@ -31,6 +27,12 @@ function stationKm(title: string): number {
|
||||
return parseInt(m[1]) * 1000 + parseInt(m[2]);
|
||||
}
|
||||
|
||||
/** 미터값 → "158k160" (10m 단위, 재생바 배지와 동일 형식). */
|
||||
function formatKm10(m: number): string {
|
||||
const r = Math.round(m / 10) * 10;
|
||||
return `${Math.floor(r / 1000)}k${String(r % 1000).padStart(3, '0')}`;
|
||||
}
|
||||
|
||||
const CATEGORY_EMOJI: Record<string, string> = {
|
||||
'\uD130\uB110': '\uD83D\uDE87',
|
||||
'\uAD50\uB7C9': '\uD83C\uDF09',
|
||||
@@ -55,11 +57,14 @@ function poiKm(poi: GeoPoint, stations: GeoPoint[]): number {
|
||||
return Math.round(ka + (kb - ka) * t);
|
||||
}
|
||||
|
||||
export default function RoutePanel({ currentTime, visible, onSeek }: RoutePanelProps) {
|
||||
const [stations, setStations] = useState<GeoPoint[]>([]);
|
||||
const [pois, setPois] = useState<GeoPoint[]>([]);
|
||||
const [droneFramesLoaded, setDroneFramesLoaded] = useState(false);
|
||||
const allDroneFramesRef = useRef<DroneFrameBasic[]>([]);
|
||||
export default function RoutePanel({ currentTime, visible, onSeek, topPx = 90, bottomPx = 200 }: RoutePanelProps) {
|
||||
// 지리정보는 클라이언트 geoStore(폴더 선택 파싱 결과)에서 직접 읽는다.
|
||||
// 서버 /api/geo/* fetch 대체. 폴더 미선택 시 빈 배열 → idle 렌더.
|
||||
const loaded = useGeoStore(s => s.loaded);
|
||||
const stations = useGeoStore(s => s.stations);
|
||||
const pois = useGeoStore(s => s.pois);
|
||||
const droneFrames = useGeoStore(s => s.frames);
|
||||
const routeMeta = useGeoStore(s => s.routeMeta);
|
||||
const [currentKm, setCurrentKm] = useState(0);
|
||||
const [currentStationTitle, setCurrentStationTitle] = useState('');
|
||||
const [visibleRange, setVisibleRange] = useState<{ minKm: number; maxKm: number } | null>(null);
|
||||
@@ -69,29 +74,7 @@ export default function RoutePanel({ currentTime, visible, onSeek }: RoutePanelP
|
||||
const [dragYPct, setDragYPct] = useState(0);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
|
||||
// Load POIs and stations
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
fetch('/api/geo/pois')
|
||||
.then(r => r.json())
|
||||
.then((data: GeoPoint[]) => {
|
||||
setStations(data.filter(p => p.type === 'station'));
|
||||
setPois(data.filter(p => p.type === 'poi'));
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [visible]);
|
||||
|
||||
// Load drone frames when visible and not yet loaded
|
||||
useEffect(() => {
|
||||
if (!visible || droneFramesLoaded) return;
|
||||
fetch('/api/geo/frames?step=1')
|
||||
.then(r => r.json())
|
||||
.then((data: DroneFrameBasic[]) => {
|
||||
allDroneFramesRef.current = data;
|
||||
setDroneFramesLoaded(true);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [visible, droneFramesLoaded]);
|
||||
// POI/측점/드론 프레임은 위 geoStore 셀렉터로 구독한다(폴더 데이터 도착 시 자동 재렌더).
|
||||
|
||||
// 시점/종점: 역사(category=역사) POI 중 km 최소/최대
|
||||
useEffect(() => {
|
||||
@@ -113,8 +96,7 @@ export default function RoutePanel({ currentTime, visible, onSeek }: RoutePanelP
|
||||
|
||||
// Update current km and visible range based on currentTime
|
||||
useEffect(() => {
|
||||
if (!droneFramesLoaded) return;
|
||||
const frames = allDroneFramesRef.current;
|
||||
const frames = droneFrames;
|
||||
if (!frames.length || !stations.length) return;
|
||||
|
||||
// Find closest frame by time
|
||||
@@ -142,9 +124,26 @@ export default function RoutePanel({ currentTime, visible, onSeek }: RoutePanelP
|
||||
nearestDist = d;
|
||||
}
|
||||
}
|
||||
setCurrentKm(stationKm(nearestStation.title));
|
||||
setCurrentStationTitle(nearestStation.title);
|
||||
|
||||
// 현재 km = 측점 폴리라인 투영 연속 체이니지 (재생바 StationBar 와 동일 기준).
|
||||
const sorted = [...validStations].sort((a, b) => stationKm(a.title) - stationKm(b.title));
|
||||
const lat0 = sorted.reduce((s, p) => s + p.lat, 0) / sorted.length;
|
||||
const k = Math.cos((lat0 * Math.PI) / 180) * 111000;
|
||||
const pts = sorted.map(p => ({ x: p.lon * k, y: p.lat * 111000, km: stationKm(p.title) }));
|
||||
const cpx = closest.lon * k, cpy = closest.lat * 111000;
|
||||
let bestD = Infinity, bestKm = pts.length ? pts[0].km : 0;
|
||||
for (let i = 0; i < pts.length - 1; i++) {
|
||||
const a = pts[i], b = pts[i + 1];
|
||||
const dx = b.x - a.x, dy = b.y - a.y;
|
||||
const L2 = dx * dx + dy * dy;
|
||||
const t = L2 === 0 ? 0 : Math.max(0, Math.min(1, ((cpx - a.x) * dx + (cpy - a.y) * dy) / L2));
|
||||
const ex = a.x + dx * t, ey = a.y + dy * t;
|
||||
const dd = (cpx - ex) ** 2 + (cpy - ey) ** 2;
|
||||
if (dd < bestD) { bestD = dd; bestKm = a.km + (b.km - a.km) * t; }
|
||||
}
|
||||
setCurrentKm(bestKm);
|
||||
|
||||
// Calculate visible range (green box)
|
||||
const allPoints = [...validStations, ...pois];
|
||||
const visibleKms: number[] = [];
|
||||
@@ -165,7 +164,7 @@ export default function RoutePanel({ currentTime, visible, onSeek }: RoutePanelP
|
||||
} else {
|
||||
setVisibleRange(null);
|
||||
}
|
||||
}, [currentTime, droneFramesLoaded, stations, pois]);
|
||||
}, [currentTime, droneFrames, stations, pois]);
|
||||
|
||||
// Drag handling
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
@@ -212,7 +211,7 @@ export default function RoutePanel({ currentTime, visible, onSeek }: RoutePanelP
|
||||
}
|
||||
|
||||
// Find closest drone frame to that station's lat/lon
|
||||
const frames = allDroneFramesRef.current;
|
||||
const frames = droneFrames;
|
||||
if (!frames.length) return;
|
||||
let bestFrame = frames[0];
|
||||
let bestFrameDist = (bestFrame.lat - bestStation.lat) ** 2 + (bestFrame.lon - bestStation.lon) ** 2;
|
||||
@@ -232,10 +231,10 @@ export default function RoutePanel({ currentTime, visible, onSeek }: RoutePanelP
|
||||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
window.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [dragging, stations, pois, onSeek]);
|
||||
}, [dragging, stations, pois, droneFrames, onSeek]);
|
||||
|
||||
// Render guard
|
||||
if (!visible || stations.length === 0) return null;
|
||||
// Render guard — 폴더 미선택(!loaded) 또는 측점 없음이면 idle(미표시)
|
||||
if (!visible || !loaded || stations.length === 0) return null;
|
||||
const validStations = stations.filter(s => stationKm(s.title) >= 0);
|
||||
if (validStations.length < 2) return null;
|
||||
|
||||
@@ -254,7 +253,7 @@ export default function RoutePanel({ currentTime, visible, onSeek }: RoutePanelP
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="absolute w-28 border border-white/20 rounded-md z-30"
|
||||
style={{ left: 10, top: '12%', height: '68%', background: 'rgba(0,0,0,0.6)' }}
|
||||
style={{ left: 8, top: topPx, bottom: bottomPx, background: 'rgba(0,0,0,0.6)' }}
|
||||
>
|
||||
{/* Center vertical line */}
|
||||
<div
|
||||
@@ -265,13 +264,13 @@ export default function RoutePanel({ currentTime, visible, onSeek }: RoutePanelP
|
||||
{/* 높은 km 역명 — 상단 (대전조차장) */}
|
||||
<div className="absolute left-0 right-0 flex items-center gap-1" style={{ top: 4 }}>
|
||||
<div className="w-2 h-2 rounded-full bg-white/80 shrink-0" style={{ marginLeft: 29 }} />
|
||||
<span className="text-[11px] text-white/90 font-semibold truncate">{cleanTitle(routeEndTitle)}</span>
|
||||
<span className="text-[11px] text-white/90 font-semibold truncate">{routeMeta?.routeInfo?.endStationName || cleanTitle(routeEndTitle)}</span>
|
||||
</div>
|
||||
|
||||
{/* 낮은 km 역명 — 하단 (회덕) */}
|
||||
<div className="absolute left-0 right-0 flex items-center gap-1" style={{ bottom: 4 }}>
|
||||
<div className="w-2 h-2 rounded-full bg-white/80 shrink-0" style={{ marginLeft: 29 }} />
|
||||
<span className="text-[11px] text-white/90 font-semibold truncate">{cleanTitle(routeStartTitle)}</span>
|
||||
<span className="text-[11px] text-white/90 font-semibold truncate">{routeMeta?.routeInfo?.startStationName || cleanTitle(routeStartTitle)}</span>
|
||||
</div>
|
||||
|
||||
{/* 교량/터널 POIs — 겹침 방지: Y 간격 7% 미만이면 건너뜀 */}
|
||||
@@ -351,7 +350,7 @@ export default function RoutePanel({ currentTime, visible, onSeek }: RoutePanelP
|
||||
style={{ position: 'absolute', left: 44 }}
|
||||
className="bg-orange-500 text-white text-[11px] font-bold px-1.5 py-0.5 rounded whitespace-nowrap"
|
||||
>
|
||||
{cleanTitle(currentStationTitle)}
|
||||
{formatKm10(currentKm)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import React, { useRef, useImperativeHandle, forwardRef, useState } from 'react';
|
||||
import React, { useRef, useImperativeHandle, forwardRef, useState, useEffect } from 'react';
|
||||
import StationOverlay from '../overlay/StationOverlay';
|
||||
import RoutePanel from '../overlay/RoutePanel';
|
||||
import RouteInfoOverlay from '../overlay/RouteInfoOverlay';
|
||||
import 'video.js/dist/video-js.css';
|
||||
import { useVideoPlayer } from '../../hooks/useVideoPlayer';
|
||||
import { useFrameStep } from '../../hooks/useFrameStep';
|
||||
import { useKeyboard } from '../../hooks/useKeyboard';
|
||||
import { usePlayerStore } from '../../store/playerStore';
|
||||
import { useGeoStore } from '../../store/geoStore';
|
||||
import { captureFrame, downloadDataUrl } from '../../utils/frameCapture';
|
||||
import { secondsToTimecode, secondsToFrame } from '../../utils/timecode';
|
||||
import { useCaptureStore } from '../../store/captureStore';
|
||||
@@ -35,6 +37,79 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
|
||||
const { stepForward, stepBackward, fps } = useFrameStep(playerRef);
|
||||
const { currentTime, duration, playing, source, playbackRate } = usePlayerStore();
|
||||
|
||||
// 커서 매끄러운 이동:
|
||||
// - 라이브 시간(smoothTimeRef): 매 프레임 '단조' 벽시계 보간. StationBar가 ref로 읽어
|
||||
// 커서/진행바를 직접(transform) 갱신 → React 60fps 리렌더 없이 매끄럽게.
|
||||
// - state(smoothTime): 배지 숫자·색 변경용으로만 throttle(≈10fps) 갱신.
|
||||
const [smoothTime, setSmoothTime] = useState(0);
|
||||
const smoothTimeRef = useRef(0);
|
||||
const anchorRef = useRef({ media: 0, wall: 0 });
|
||||
const lastSetRef = useRef(0);
|
||||
useEffect(() => {
|
||||
let raf = 0;
|
||||
// 시크(클릭/드래그 이동) 시 커서를 즉시 그 위치로 재동기화.
|
||||
// (단조 보간은 앵커보다 앞쪽으로의 뒤로가기 시크를 무시하므로 별도 처리 필요)
|
||||
const onSeeked = (): void => {
|
||||
const p = playerRef.current;
|
||||
if (!p || p.isDisposed()) return;
|
||||
const t = p.currentTime() ?? 0;
|
||||
anchorRef.current = { media: t, wall: performance.now() };
|
||||
smoothTimeRef.current = t;
|
||||
lastSetRef.current = t;
|
||||
setSmoothTime(t);
|
||||
};
|
||||
playerRef.current?.on('seeked', onSeeked);
|
||||
const tick = (): void => {
|
||||
const p = playerRef.current;
|
||||
if (p && !p.isDisposed()) {
|
||||
const dur = p.duration() ?? 0;
|
||||
let t: number;
|
||||
if (p.paused()) {
|
||||
t = p.currentTime() ?? 0;
|
||||
anchorRef.current = { media: t, wall: performance.now() };
|
||||
} else {
|
||||
const a = anchorRef.current;
|
||||
const rate = p.playbackRate() ?? 1;
|
||||
let est = a.media + ((performance.now() - a.wall) / 1000) * rate;
|
||||
const real = p.currentTime() ?? 0;
|
||||
// 단조: 작은 역행은 무시(흔들림 방지). 뒤처짐(real이 앞섬) 또는 시크(뒤로)만 재동기화.
|
||||
if (real - est > 0.3 || real < a.media - 0.3) {
|
||||
est = real;
|
||||
anchorRef.current = { media: real, wall: performance.now() };
|
||||
}
|
||||
t = dur > 0 ? Math.min(est, dur) : est;
|
||||
}
|
||||
smoothTimeRef.current = t;
|
||||
if (Math.abs(t - lastSetRef.current) >= 0.1) {
|
||||
lastSetRef.current = t;
|
||||
setSmoothTime(t);
|
||||
}
|
||||
}
|
||||
raf = requestAnimationFrame(tick);
|
||||
};
|
||||
raf = requestAnimationFrame(tick);
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
playerRef.current?.off('seeked', onSeeked);
|
||||
};
|
||||
}, [playerRef, source]);
|
||||
const loadFromFolder = useGeoStore((s) => s.loadFromFolder);
|
||||
const geoLoaded = useGeoStore((s) => s.loaded);
|
||||
// 하단 도구 패널: UI에서 숨김(코드는 보존). 다시 표시하려면 true 로.
|
||||
const SHOW_TOOLBAR = false;
|
||||
|
||||
// 폴더 선택: 지리정보 파싱 + 영상 재생
|
||||
const handleSelectFolder = async (files: File[]) => {
|
||||
if (!files.length) return;
|
||||
try {
|
||||
const videoFile = await loadFromFolder(files);
|
||||
if (videoFile) loadLocalFile(videoFile);
|
||||
else console.warn('[geo] 폴더에 영상 파일(mp4/webm)이 없습니다 — 지리정보만 로드');
|
||||
} catch (err) {
|
||||
console.error('[geo] 폴더 로드 실패', err);
|
||||
}
|
||||
};
|
||||
|
||||
// Expose methods to parent via ref
|
||||
useImperativeHandle(ref, () => ({
|
||||
loadLocalFile,
|
||||
@@ -65,6 +140,33 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
|
||||
|
||||
const handleAddMemo = () => onAddMemo(currentTime);
|
||||
const [showStations, setShowStations] = useState(true);
|
||||
// 영상제어 토글: 카메라 파라미터·프레임상태·배속 표시 on/off
|
||||
const [showVideoControls, setShowVideoControls] = useState(true);
|
||||
// 좌하단 그룹을 재생바 위에 두기 위해 StationBar 높이를 동적 측정.
|
||||
const barWrapRef = useRef<HTMLDivElement>(null);
|
||||
const [barHeight, setBarHeight] = useState(0);
|
||||
useEffect(() => {
|
||||
const el = barWrapRef.current;
|
||||
if (!el) return;
|
||||
const update = (): void => setBarHeight(el.offsetHeight);
|
||||
update();
|
||||
const ro = new ResizeObserver(update);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, [geoLoaded]);
|
||||
// 노선 배너(429.5×65 @1920 비율) 높이만큼 카메라 파라미터를 아래로.
|
||||
const [stageWidth, setStageWidth] = useState(0);
|
||||
useEffect(() => {
|
||||
const el = wrapperRef.current;
|
||||
if (!el) return;
|
||||
const update = (): void => setStageWidth(el.clientWidth);
|
||||
update();
|
||||
const ro = new ResizeObserver(update);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
const paramTop = Math.round((stageWidth / 1920) * 65) + 10;
|
||||
const [showUtilBar, setShowUtilBar] = useState(false);
|
||||
|
||||
const handleTogglePlay = (): void => {
|
||||
const p = playerRef.current;
|
||||
@@ -108,63 +210,97 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
|
||||
return (
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className="relative bg-black w-full"
|
||||
className="relative bg-black w-full h-full"
|
||||
onDrop={handleDrop}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
>
|
||||
{/* 영상 영역 — 타임코드를 '영상 하단'에 앵커하기 위한 relative 래퍼 */}
|
||||
<div className="relative w-full">
|
||||
{/* Video.js container — data-vjs-player prevents extra wrapper per CLAUDE.md */}
|
||||
{/* 영상 영역 — 컨테이너 전체를 채우는 relative 래퍼 (사이니지: 화면 가득) */}
|
||||
<div className="relative w-full h-full">
|
||||
{/* Video.js container — 영상이 영역을 꽉 채우는 베이스 레이어 (object-fit:cover) */}
|
||||
{/* 영상 클릭 = 재생/일시정지 토글 (컨트롤바 숨김 상태) */}
|
||||
<div
|
||||
data-vjs-player
|
||||
ref={containerRef}
|
||||
className="w-full"
|
||||
className="absolute inset-0 w-full h-full"
|
||||
style={{ cursor: source ? 'pointer' : 'default' }}
|
||||
onClick={() => {
|
||||
if (source) handleTogglePlay();
|
||||
}}
|
||||
/>
|
||||
{/* 프레임/타임코드 — 영상 좌상단 코너 HUD (정보 그룹) */}
|
||||
{/* 노선 정보 배너 — 영상 좌상단 (route.json routeInfo) */}
|
||||
<RouteInfoOverlay />
|
||||
{/* 좌하단 그룹(재생바 위): 위→아래 = 배속 → 토글(측점선/영상제어) + 프레임정보 */}
|
||||
{source && (
|
||||
<div className="absolute top-2 left-2 bg-black/70 text-white text-xs px-2 py-1 rounded font-mono pointer-events-none z-30">
|
||||
{secondsToTimecode(currentTime)} | F{frame} | {fps}fps
|
||||
</div>
|
||||
)}
|
||||
{/* 재생 배속 — 영상 우하단 (항상 보임). 현재 배속은 파랗게 강조 */}
|
||||
{source && (
|
||||
<div className="absolute bottom-2 right-2 flex items-center gap-1 bg-black/70 px-2 py-1 rounded z-30">
|
||||
<span className="text-gray-400 text-xs">배속</span>
|
||||
{[1, 1.5, 2, 3, 4].map((r) => (
|
||||
<div
|
||||
className="absolute left-2 z-30 flex flex-col items-start gap-2"
|
||||
style={{ bottom: (barHeight || 130) + 8 }}
|
||||
>
|
||||
{/* 재생 배속 (영상제어 ON) */}
|
||||
{showVideoControls && (
|
||||
<div className="flex items-center gap-1 bg-black/70 px-2 py-1 rounded pointer-events-auto">
|
||||
<span className="text-gray-400 text-xs">배속</span>
|
||||
{[0.5, 1, 1.5, 2, 3, 4].map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
type="button"
|
||||
onClick={() => playerRef.current?.playbackRate(r)}
|
||||
className={`text-xs px-1.5 py-0.5 rounded font-semibold ${
|
||||
Math.abs(playbackRate - r) < 0.01
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-700 text-gray-200 hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
{r}x
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* 토글: 영상제어(카메라파라미터·프레임상태·배속) */}
|
||||
<div className="flex items-center gap-2 pointer-events-auto">
|
||||
<button
|
||||
key={r}
|
||||
type="button"
|
||||
onClick={() => playerRef.current?.playbackRate(r)}
|
||||
className={`text-xs px-1.5 py-0.5 rounded font-semibold ${
|
||||
Math.abs(playbackRate - r) < 0.01
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-700 text-gray-200 hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
{r}x
|
||||
</button>
|
||||
))}
|
||||
onClick={() => setShowVideoControls((v) => !v)}
|
||||
className={`text-xs px-2 py-1 rounded border font-semibold ${showVideoControls ? 'bg-blue-600/80 border-blue-400 text-white' : 'bg-black/70 border-gray-600 text-gray-300'}`}
|
||||
>영상제어 {showVideoControls ? 'ON' : 'OFF'}</button>
|
||||
{/* 프레임/타임코드 — 영상제어 ON 버튼 오른쪽 */}
|
||||
{showVideoControls && (
|
||||
<span className="bg-black/70 text-white text-xs px-2 py-1 rounded font-mono">
|
||||
{secondsToTimecode(currentTime)} | F{frame} | {fps}fps
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* 루트 패널 미니맵 — 영상 영역 기준(아래 StationBar 침범 방지) */}
|
||||
{/* 루트 패널 미니맵 — 위(배너+카메라파라미터)·아래(배속/토글/재생바) 침범 방지 */}
|
||||
<RoutePanel
|
||||
currentTime={currentTime}
|
||||
visible={showStations}
|
||||
onSeek={(time) => playerRef.current?.currentTime(time)}
|
||||
topPx={paramTop + 40}
|
||||
bottomPx={(barHeight || 130) + 90}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Empty state placeholder */}
|
||||
{/* Empty state placeholder — 가운데 반투명 폴더 선택 */}
|
||||
{!source && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center text-gray-500 pointer-events-none select-none" style={{ minHeight: '240px' }}>
|
||||
<div className="text-5xl mb-3">▶</div>
|
||||
<p className="text-lg">동영상을 드래그하거나 아래에서 선택하세요</p>
|
||||
<p className="text-sm mt-1">로컬 파일 또는 서버 영상 재생 지원</p>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center text-gray-400 pointer-events-none select-none" style={{ minHeight: '240px' }}>
|
||||
<div className="text-5xl mb-3 opacity-60">▶</div>
|
||||
<p className="text-lg">동영상 폴더를 드래그하거나 선택하세요</p>
|
||||
<p className="text-sm mt-1 mb-6">영상 + 측점/POI 데이터가 함께 로드됩니다</p>
|
||||
<label className="pointer-events-auto cursor-pointer bg-emerald-500/20 hover:bg-emerald-500/40 backdrop-blur-sm border border-emerald-300/40 text-white text-base font-medium px-7 py-3 rounded-xl shadow-lg transition-colors">
|
||||
폴더 선택
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
webkitdirectory=""
|
||||
directory=""
|
||||
multiple
|
||||
onChange={(e) => {
|
||||
const files = e.target.files;
|
||||
if (files?.length) void handleSelectFolder(Array.from(files));
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -174,11 +310,16 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
|
||||
currentTime={currentTime}
|
||||
fps={fps}
|
||||
visible={showStations}
|
||||
showPanel={showVideoControls && !!source}
|
||||
topPx={paramTop}
|
||||
/>
|
||||
|
||||
{/* 측점 기반 재생 바 — videoplayer-main 스테이션 바 이식 (시간 스크러버 대체) */}
|
||||
{/* 측점 기반 재생 바 — 영상 하단에 오버레이로 앵커 (폴더 로드 후) */}
|
||||
{geoLoaded && (
|
||||
<div ref={barWrapRef} className="absolute bottom-0 left-0 right-0 z-20">
|
||||
<StationBar
|
||||
currentTime={currentTime}
|
||||
currentTime={smoothTime}
|
||||
timeRef={smoothTimeRef}
|
||||
duration={duration}
|
||||
playing={playing}
|
||||
onTogglePlay={handleTogglePlay}
|
||||
@@ -188,18 +329,33 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
|
||||
showStations={showStations}
|
||||
onToggleStations={() => setShowStations((v) => !v)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* abcVideo 전용 유틸 행 (파일/프레임이동/HLS) */}
|
||||
<div className="flex items-center gap-2 p-2 bg-gray-900 flex-wrap">
|
||||
<label className="cursor-pointer bg-blue-600 hover:bg-blue-700 text-white text-sm px-3 py-1.5 rounded">
|
||||
파일 선택
|
||||
{/* abcVideo 전용 유틸 행 (파일/프레임이동/HLS) — UI 숨김(코드 보존, SHOW_TOOLBAR 로 제어) */}
|
||||
{SHOW_TOOLBAR && (
|
||||
<div className="bg-gray-900 border-t border-gray-800">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowUtilBar((v) => !v)}
|
||||
className="w-full flex items-center gap-1.5 px-2 py-1 text-xs text-gray-400 hover:text-white"
|
||||
>
|
||||
<span className={`inline-block transition-transform ${showUtilBar ? 'rotate-90' : ''}`}>▸</span>
|
||||
도구 {showUtilBar ? '접기' : '펼치기'}
|
||||
</button>
|
||||
{showUtilBar && (
|
||||
<div className="flex items-center gap-2 px-2 pb-2 flex-wrap">
|
||||
<label className="cursor-pointer bg-emerald-600 hover:bg-emerald-700 text-white text-sm px-3 py-1.5 rounded">
|
||||
폴더 선택
|
||||
<input
|
||||
type="file"
|
||||
accept="video/*"
|
||||
className="hidden"
|
||||
webkitdirectory=""
|
||||
directory=""
|
||||
multiple
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) loadLocalFile(file);
|
||||
const files = e.target.files;
|
||||
if (files?.length) void handleSelectFolder(Array.from(files));
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
@@ -245,7 +401,10 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
|
||||
<span className="text-gray-500 text-xs ml-auto hidden sm:inline">
|
||||
Space 재생 | ←/→ 5초 | J/L 10초 | ,/. 프레임 | Shift+S 캡처
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,13 +25,14 @@ export function useVideoPlayer(containerRef: React.RefObject<HTMLDivElement | nu
|
||||
if (!containerRef.current || playerRef.current) return;
|
||||
|
||||
const videoEl = document.createElement('video-js');
|
||||
videoEl.classList.add('vjs-big-play-centered', 'vjs-fluid');
|
||||
// fill: 컨테이너를 꽉 채움(사이니지처럼 화면 가득). object-fit:cover 로 비율 유지 크롭.
|
||||
videoEl.classList.add('vjs-big-play-centered', 'vjs-fill');
|
||||
containerRef.current.appendChild(videoEl);
|
||||
|
||||
const player = videojs(videoEl, {
|
||||
// 하단 시간 스크러버는 측점 기반 StationBar 로 대체하므로 Video.js 기본 컨트롤바 숨김
|
||||
controls: false,
|
||||
fluid: true,
|
||||
fill: true,
|
||||
responsive: true,
|
||||
playbackRates: [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2, 4],
|
||||
html5: { vhs: { overrideNative: true } },
|
||||
|
||||
@@ -12,3 +12,9 @@ body {
|
||||
background: #030712;
|
||||
color: #f9fafb;
|
||||
}
|
||||
|
||||
/* 영상이 컨테이너를 비율 유지하며 꽉 채움(사이니지 fill, 넘침만 크롭) */
|
||||
.video-js.vjs-fill,
|
||||
.video-js .vjs-tech {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { MouseEvent as ReactMouseEvent } from 'react';
|
||||
import type { MouseEvent as ReactMouseEvent, RefObject } from 'react';
|
||||
import type { DroneFrameBasic } from '../utils/geoProjection';
|
||||
import { useGeoStore } from '../store/geoStore';
|
||||
import {
|
||||
STAGE_WIDTH,
|
||||
TRACK_END_PX,
|
||||
TRACK_START_PX,
|
||||
TRACK_WIDTH_PX,
|
||||
renderX,
|
||||
trackXFromRender,
|
||||
} from './mocks/route';
|
||||
import { cssVars } from './utils/cssVars';
|
||||
import { formatMileage, mileageAtPx } from './utils/mileage';
|
||||
import { formatMileage } from './utils/mileage';
|
||||
import { PlaybackControls } from './components/PlaybackControls/PlaybackControls';
|
||||
import { Timeline } from './components/Timeline/Timeline';
|
||||
import { TimelineCursor } from './components/TimelineCursor/TimelineCursor';
|
||||
@@ -81,15 +83,18 @@ export interface KmLabel {
|
||||
px: number;
|
||||
text: string;
|
||||
}
|
||||
/** 구조물 마크 (교량/터널/역사). px는 통과 시점(시간축). */
|
||||
/** 구조물 마크 (교량/터널/역사). px는 통과 시점(시간축), km은 측점값(연속 체이니지, m). */
|
||||
export interface StructMark {
|
||||
px: number;
|
||||
title: string;
|
||||
category: string;
|
||||
km: number;
|
||||
}
|
||||
|
||||
interface StationBarProps {
|
||||
currentTime: number;
|
||||
/** 라이브 재생시간 ref (매 프레임 갱신). 커서/진행바를 React 리렌더 없이 직접 이동. */
|
||||
timeRef?: RefObject<number>;
|
||||
duration: number;
|
||||
playing: boolean;
|
||||
onTogglePlay: () => void;
|
||||
@@ -122,6 +127,23 @@ function nearestStationKm(f: DroneFrameBasic, stations: GeoPoint[]): number {
|
||||
return best;
|
||||
}
|
||||
|
||||
/** station 값(숫자=미터, "158k400" 문자열) → 미터. 실패 시 -1. */
|
||||
function mileageToMeters(v: number | string): number {
|
||||
if (typeof v === 'number') return v;
|
||||
const m = String(v).match(/(\d+)\s*[kK]\s*(\d+)/);
|
||||
if (m) return parseInt(m[1], 10) * 1000 + parseInt(m[2], 10);
|
||||
const n = parseInt(String(v).replace(/[^0-9]/g, ''), 10);
|
||||
return Number.isFinite(n) ? n : -1;
|
||||
}
|
||||
|
||||
/** 미터값 → "158k710" (10m 단위 반올림). 커서 배지용 십단위 표시. */
|
||||
function formatMileage10(m: number): string {
|
||||
const r = Math.round(m / 10) * 10;
|
||||
const km = Math.floor(r / 1000);
|
||||
const mm = r % 1000;
|
||||
return `${km}k${String(mm).padStart(3, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* abcVideo 실제 영상 데이터 기반 측점 바.
|
||||
* - 트랙 x축 = 프레임(시간) 진행. 구간 색 = 측점 km 증가(주황)/감소(하늘색).
|
||||
@@ -131,6 +153,7 @@ function nearestStationKm(f: DroneFrameBasic, stations: GeoPoint[]): number {
|
||||
*/
|
||||
export function StationBar({
|
||||
currentTime,
|
||||
timeRef,
|
||||
duration,
|
||||
playing,
|
||||
onTogglePlay,
|
||||
@@ -145,28 +168,18 @@ export function StationBar({
|
||||
const [scale, setScale] = useState(0.5);
|
||||
const draggingRef = useRef(false);
|
||||
|
||||
const [pois, setPois] = useState<GeoPoint[]>([]);
|
||||
const framesRef = useRef<DroneFrameBasic[]>([]);
|
||||
const [framesVersion, setFramesVersion] = useState(0);
|
||||
// 측점/POI/드론프레임은 폴더 선택으로 채워지는 geoStore에서 읽는다 (서버 /api/geo fetch 대체).
|
||||
const storePois = useGeoStore((s) => s.pois);
|
||||
const storeStations = useGeoStore((s) => s.stations);
|
||||
const pois = useMemo<GeoPoint[]>(
|
||||
() => [...storeStations, ...storePois],
|
||||
[storeStations, storePois],
|
||||
);
|
||||
const storeFrames = useGeoStore((s) => s.frames);
|
||||
const routeMeta = useGeoStore((s) => s.routeMeta);
|
||||
const viewedRef = useRef<ViewedPoint[]>([]);
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/geo/pois')
|
||||
.then((r) => r.json())
|
||||
.then((data: GeoPoint[]) => setPois(Array.isArray(data) ? data : []))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
fetch(`/api/geo/frames?step=${FRAME_STEP}`)
|
||||
.then((r) => r.json())
|
||||
.then((data: DroneFrameBasic[]) => {
|
||||
framesRef.current = Array.isArray(data) ? data : [];
|
||||
setFramesVersion((v) => v + 1);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const stations = useMemo(
|
||||
() => pois.filter((p) => p.type === 'station' && stationKm(p.title) >= 0),
|
||||
[pois],
|
||||
@@ -188,8 +201,8 @@ export function StationBar({
|
||||
// - km: 드론 GPS 최근접 측점 (배지 표시용, 좌측 RoutePanel 과 동일).
|
||||
// - chain: 측점 폴리라인 투영 연속 체이니지 (구간 방향용, 전환 타이밍 정확).
|
||||
useEffect(() => {
|
||||
const frames = framesRef.current;
|
||||
if (!frames.length || !stations.length || !stationLine) return;
|
||||
const frames = storeFrames;
|
||||
if (!frames.length || !stations.length || !stationLine) { setReady(false); return; }
|
||||
const out: ViewedPoint[] = new Array(frames.length);
|
||||
for (let i = 0; i < frames.length; i++) {
|
||||
const f = frames[i];
|
||||
@@ -202,7 +215,7 @@ export function StationBar({
|
||||
}
|
||||
viewedRef.current = out;
|
||||
setReady(true);
|
||||
}, [stations, stationLine, framesVersion]);
|
||||
}, [stations, stationLine, storeFrames]);
|
||||
|
||||
/** 현재 시간에 가장 가까운 precompute 인덱스. */
|
||||
const viewedIdxAtTime = useCallback((t: number): number => {
|
||||
@@ -221,11 +234,11 @@ export function StationBar({
|
||||
return best;
|
||||
}, []);
|
||||
|
||||
// 현재 보는 측점 km
|
||||
const realKm = useMemo<number | null>(() => {
|
||||
// 현재 보는 측점값 — 연속 체이니지(chain)로 10m 해상도 (km 필드는 100m 양자화).
|
||||
const realChain = useMemo<number | null>(() => {
|
||||
if (!ready) return null;
|
||||
const idx = viewedIdxAtTime(currentTime);
|
||||
return idx >= 0 ? viewedRef.current[idx].km : null;
|
||||
return idx >= 0 ? viewedRef.current[idx].chain : null;
|
||||
}, [currentTime, ready, viewedIdxAtTime]);
|
||||
|
||||
// 프레임(시간) 진행률 px — 커서의 단조 이동 기준.
|
||||
@@ -242,10 +255,10 @@ export function StationBar({
|
||||
// → 진행은 유니크한 프레임 기준, 표시(배지)만 현재 보는 측점(realKm)으로 한다.
|
||||
const cursorPx = progressPx;
|
||||
|
||||
// 커서 배지 = 폴더 데이터 기반 연속 체이니지(realChain)를 10m 단위로 표시.
|
||||
// mock ROUTE_LEGS(mileageAtPx) 의존 제거. 데이터 없으면 빈 문자열.
|
||||
const cursorText =
|
||||
realKm !== null && realKm >= 0
|
||||
? formatMileage(realKm)
|
||||
: formatMileage(mileageAtPx(cursorPx));
|
||||
realChain !== null && realChain >= 0 ? formatMileage10(realChain) : '';
|
||||
|
||||
// ── 데이터 기반 측점 바 ──────────────────────────────────────────
|
||||
// 시간(프레임) 진행을 트랙 px로 선형 변환.
|
||||
@@ -269,10 +282,15 @@ export function StationBar({
|
||||
// 방향은 연속 체이니지(chain)로 판정 — 100m 양자화 km은 전환을 ~13s 빨리 잡아 영상과 어긋남.
|
||||
const HYST = 100; // m — 이 이상 반대로 움직여야 방향 전환으로 인정 (GPS 노이즈 무시)
|
||||
const segs: BarSegment[] = [];
|
||||
const bounds: { chain: number; time: number }[] = [
|
||||
{ chain: arr[0].chain, time: arr[0].time },
|
||||
const bounds: { chain: number; time: number; turn: boolean }[] = [
|
||||
{ chain: arr[0].chain, time: arr[0].time, turn: false },
|
||||
];
|
||||
// 시작 방향을 실제 데이터(첫 유의미 이동)로 판정. 기본 증가 가정이 틀리면 첫 구간 색이 반대로 나옴.
|
||||
let dir: 1 | -1 = 1;
|
||||
for (let i = 1; i < arr.length; i++) {
|
||||
const d = arr[i].chain - arr[0].chain;
|
||||
if (Math.abs(d) >= HYST) { dir = d > 0 ? 1 : -1; break; }
|
||||
}
|
||||
let extCh = arr[0].chain;
|
||||
let extIdx = 0;
|
||||
let startIdx = 0;
|
||||
@@ -287,7 +305,7 @@ export function StationBar({
|
||||
endPx: pxAtTime(arr[extIdx].time),
|
||||
dir,
|
||||
});
|
||||
bounds.push({ chain: extCh, time: arr[extIdx].time });
|
||||
bounds.push({ chain: extCh, time: arr[extIdx].time, turn: true });
|
||||
dir = dir > 0 ? -1 : 1;
|
||||
startIdx = extIdx;
|
||||
extCh = c;
|
||||
@@ -299,76 +317,217 @@ export function StationBar({
|
||||
endPx: pxAtTime(arr[arr.length - 1].time),
|
||||
dir,
|
||||
});
|
||||
bounds.push({ chain: arr[arr.length - 1].chain, time: arr[arr.length - 1].time });
|
||||
// 라벨은 전환점 체이니지를 최근접 측점(100m)으로 스냅해 표시.
|
||||
bounds.push({ chain: arr[arr.length - 1].chain, time: arr[arr.length - 1].time, turn: false });
|
||||
// 전환(턴) 지점은 실제 위치를 10m 단위로, 시·종점 등 기본 라벨은 100m 단위로 표시.
|
||||
const labels: KmLabel[] = bounds.map((b) => ({
|
||||
px: pxAtTime(b.time),
|
||||
text: formatMileage(Math.round(b.chain / 100) * 100),
|
||||
text: b.turn ? formatMileage10(b.chain) : formatMileage(Math.round(b.chain / 100) * 100),
|
||||
}));
|
||||
return { barSegments: segs, kmLabels: labels };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ready, duration, framesVersion, pxAtTime]);
|
||||
}, [ready, duration, storeFrames, pxAtTime]);
|
||||
|
||||
// 구조물(교량/터널/역사) → 체이니지(최근접 측점 km) → 통과 시점 px.
|
||||
const structureMarks = useMemo<StructMark[]>(() => {
|
||||
const arr = viewedRef.current;
|
||||
if (!ready || !arr.length || duration <= 0 || !stations.length) return [];
|
||||
const cats = new Set(['교량', '터널', '역사']);
|
||||
const seen = new Set<string>();
|
||||
const out: StructMark[] = [];
|
||||
for (const p of pois) {
|
||||
if (!cats.has(p.category)) continue;
|
||||
// (상)/(하)/(인상) 등 변형은 같은 구조물 → base 이름으로 통합(중복 라벨 제거).
|
||||
const base = p.title.replace(/\s*[((].*$/, '').trim();
|
||||
if (seen.has(base)) continue;
|
||||
seen.add(base);
|
||||
const chain = nearestStationKm(
|
||||
{ lat: p.lat, lon: p.lon } as DroneFrameBasic,
|
||||
stations,
|
||||
);
|
||||
if (chain < 0) continue;
|
||||
// viewedRef의 km(최근접 측점)가 주어진 mileage(m)에 가장 가까운 프레임 시간 → px.
|
||||
const pxAtMileage = useCallback(
|
||||
(mileage: number): number | null => {
|
||||
const arr = viewedRef.current;
|
||||
if (!arr.length) return null;
|
||||
let best = -1;
|
||||
let bd = Infinity;
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
const d = Math.abs(arr[i].km - chain);
|
||||
const d = Math.abs(arr[i].km - mileage);
|
||||
if (d < bd) {
|
||||
bd = d;
|
||||
best = i;
|
||||
}
|
||||
}
|
||||
if (best >= 0)
|
||||
out.push({ px: pxAtTime(arr[best].time), title: base, category: p.category });
|
||||
return best >= 0 ? pxAtTime(arr[best].time) : null;
|
||||
},
|
||||
[pxAtTime],
|
||||
);
|
||||
|
||||
// 구조물(교량/터널/역사) 위치 = 실제 좌표(POI 위경도)에 드론 GPS 가 가장 가까워지는
|
||||
// 프레임의 시점 px. → 영상에 구조물이 실제 지나가는 순간과 일치.
|
||||
// route.json structures 의 이정값은 매칭되는 POI 가 없을 때만 폴백으로 사용.
|
||||
const structureMarks = useMemo<StructMark[]>(() => {
|
||||
const arr = viewedRef.current;
|
||||
const frames = storeFrames;
|
||||
if (!ready || !arr.length || duration <= 0 || !frames.length) return [];
|
||||
|
||||
// 실좌표(lat/lon)에 드론이 가까워지는 '모든 통과 구간'의 px 목록.
|
||||
// (드론이 같은 구조물을 2번 이상 지날 때 각 통과마다 마커를 찍기 위함)
|
||||
const pxPassesTo = (lat: number, lon: number, offsetM = 0): { px: number; km: number }[] => {
|
||||
const cosLat = Math.cos((lat * Math.PI) / 180);
|
||||
const ds: number[] = new Array(frames.length);
|
||||
let gmin = Infinity;
|
||||
for (let i = 0; i < frames.length; i++) {
|
||||
const dy = (frames[i].lat - lat) * 111000;
|
||||
const dx = (frames[i].lon - lon) * 111000 * cosLat;
|
||||
const d = Math.hypot(dx, dy); // m
|
||||
ds[i] = d;
|
||||
if (d < gmin) gmin = d;
|
||||
}
|
||||
if (!isFinite(gmin)) return [];
|
||||
const TH = Math.max(100, gmin * 3); // 통과 인정 거리(m)
|
||||
// 통과 프레임에서 진행방향으로 offsetM 만큼 이동(GPS 누적거리 기준).
|
||||
const shift = (idx: number): number => {
|
||||
if (!offsetM) return idx;
|
||||
const step = offsetM >= 0 ? 1 : -1;
|
||||
const target = Math.abs(offsetM);
|
||||
let fi = idx, acc = 0;
|
||||
while (acc < target) {
|
||||
const n = fi + step;
|
||||
if (n < 0 || n >= frames.length) break;
|
||||
const dy = (frames[n].lat - frames[fi].lat) * 111000;
|
||||
const dx = (frames[n].lon - frames[fi].lon) * 111000 * cosLat;
|
||||
acc += Math.hypot(dx, dy);
|
||||
fi = n;
|
||||
}
|
||||
return fi;
|
||||
};
|
||||
const out: { px: number; km: number }[] = [];
|
||||
let i = 0;
|
||||
while (i < frames.length) {
|
||||
if (ds[i] < TH) {
|
||||
let j = i, bj = i, bd = ds[i];
|
||||
while (j < frames.length && ds[j] < TH) {
|
||||
if (ds[j] < bd) { bd = ds[j]; bj = j; }
|
||||
j++;
|
||||
}
|
||||
const fi = shift(bj);
|
||||
const px = pxAtTime(frames[fi].frame / VIDEO_FPS);
|
||||
if (px !== null) out.push({ px, km: viewedRef.current[fi]?.chain ?? -1 });
|
||||
i = j;
|
||||
} else i++;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
// 측점값(미터)에 해당하는 모든 통과 지점 px (연속 체이니지 기준).
|
||||
const pxPassesAtMileage = (targetM: number): { px: number; km: number }[] => {
|
||||
const arr = viewedRef.current;
|
||||
if (!arr.length) return [];
|
||||
// 측점 인정 범위(m): route.json routeInfo.stationTolerance (폴더별), 기본 20.
|
||||
// 너무 크면 인접 통과가 합쳐져 마커가 밀린다 → 멀리 떨어진 통과만 안 잡힐 때 폴더에서 키울 것.
|
||||
const TH = routeMeta?.routeInfo?.stationTolerance ?? 20;
|
||||
const out: { px: number; km: number }[] = [];
|
||||
let i = 0;
|
||||
while (i < arr.length) {
|
||||
if (Math.abs(arr[i].chain - targetM) < TH) {
|
||||
let j = i, bj = i, bd = Math.abs(arr[i].chain - targetM);
|
||||
while (j < arr.length && Math.abs(arr[j].chain - targetM) < TH) {
|
||||
const d = Math.abs(arr[j].chain - targetM);
|
||||
if (d < bd) { bd = d; bj = j; }
|
||||
j++;
|
||||
}
|
||||
const px = pxAtTime(arr[bj].time);
|
||||
if (px !== null) out.push({ px, km: targetM });
|
||||
i = j;
|
||||
} else i++;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
// 구조물 후보 POI(교량/터널/역사) base 이름 → 실좌표.
|
||||
const cats = new Set(['교량', '터널', '역사']);
|
||||
const poiByName = new Map<string, { lat: number; lon: number; category: string }>();
|
||||
for (const p of pois) {
|
||||
if (!cats.has(p.category)) continue;
|
||||
const base = p.title.replace(/\s*[((].*$/, '').trim();
|
||||
if (!poiByName.has(base)) poiByName.set(base, { lat: p.lat, lon: p.lon, category: p.category });
|
||||
}
|
||||
|
||||
const metaStructures = routeMeta?.structures;
|
||||
if (metaStructures && metaStructures.length) {
|
||||
const out: StructMark[] = [];
|
||||
for (const s of metaStructures) {
|
||||
const cat = s.type === 'tunnel' ? '터널' : s.type === 'bridge' ? '교량' : '역사';
|
||||
const sBase = s.name.replace(/\s*[((].*$/, '').trim();
|
||||
// 이름 매칭 POI 실좌표 우선 → 없으면 route.json 이정값 폴백.
|
||||
const match =
|
||||
poiByName.get(sBase) ??
|
||||
[...poiByName.entries()].find(([k]) => k.includes(sBase) || sBase.includes(k))?.[1];
|
||||
// 우선순위: station(측점값) → 직접 좌표 → POI 이름매칭 → 이정값 폴백.
|
||||
// 각 통과 지점마다 마커(드론이 2번 지나면 2개). 동명 시설물은 각자 station 으로 구분.
|
||||
let passes: { px: number; km: number }[] = [];
|
||||
const off = s.offset ?? 0;
|
||||
if (s.station != null) {
|
||||
const sM = mileageToMeters(s.station);
|
||||
if (sM >= 0) passes = pxPassesAtMileage(sM);
|
||||
}
|
||||
if (!passes.length && s.lat != null && s.lon != null) passes = pxPassesTo(s.lat, s.lon, off);
|
||||
if (!passes.length && match) passes = pxPassesTo(match.lat, match.lon, off);
|
||||
if (!passes.length && s.startMileage != null && s.endMileage != null) {
|
||||
const mid = (s.startMileage + s.endMileage) / 2;
|
||||
const px = pxAtMileage(mid);
|
||||
if (px !== null) passes = [{ px, km: mid }];
|
||||
}
|
||||
for (const p of passes) out.push({ px: p.px, title: s.name, category: cat, km: p.km });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// 폴백: POI category 기반 (실좌표 GPS 근접, 모든 통과).
|
||||
const out: StructMark[] = [];
|
||||
for (const [base, info] of poiByName) {
|
||||
for (const p of pxPassesTo(info.lat, info.lon)) {
|
||||
out.push({ px: p.px, title: base, category: info.category, km: p.km });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ready, duration, framesVersion, pois, stations, pxAtTime]);
|
||||
}, [ready, duration, storeFrames, pois, stations, pxAtTime, routeMeta, pxAtMileage]);
|
||||
|
||||
// 방향 색 트랙을 CSS linear-gradient 로 구성. 구간 경계(드론 회전, 측점 변화 ~0,
|
||||
// 약 3~4초)는 딱딱한 경계 대신 부드러운 그라데이션으로 주황↔하늘색이 섞이게 한다.
|
||||
const trackGradient = useMemo<string>(() => {
|
||||
const segs = barSegments;
|
||||
if (!segs.length) return '';
|
||||
const FWD = '#ff8a25'; // 전진(km 증가) 주황
|
||||
const BWD = '#06a4c8'; // 후진(km 감소) 하늘색
|
||||
const col = (d: 1 | -1) => (d > 0 ? FWD : BWD);
|
||||
// 전환 폭 ≈ 4초 (회전 소요시간). 최소/최대 px 로 가시성 보장.
|
||||
const transPx = duration > 0 ? clamp((4 / duration) * TRACK_WIDTH_PX, 6, 40) : 10;
|
||||
const pct = (px: number) =>
|
||||
clamp(((px - TRACK_START_PX) / TRACK_WIDTH_PX) * 100, 0, 100);
|
||||
const stops: string[] = [`${col(segs[0].dir)} 0%`];
|
||||
for (let i = 1; i < segs.length; i++) {
|
||||
const b = segs[i].startPx;
|
||||
const w = Math.min(
|
||||
transPx / 2,
|
||||
(segs[i - 1].endPx - segs[i - 1].startPx) / 2,
|
||||
(segs[i].endPx - segs[i].startPx) / 2,
|
||||
);
|
||||
stops.push(`${col(segs[i - 1].dir)} ${pct(b - w).toFixed(2)}%`);
|
||||
stops.push(`${col(segs[i].dir)} ${pct(b + w).toFixed(2)}%`);
|
||||
}
|
||||
stops.push(`${col(segs[segs.length - 1].dir)} 100%`);
|
||||
return `linear-gradient(to right, ${stops.join(', ')})`;
|
||||
}, [barSegments, duration]);
|
||||
// 방향 색 트랙 gradient 빌더(전진/후진 색을 받아 구성). 구간 경계는 부드럽게 섞는다.
|
||||
const buildGradient = useCallback(
|
||||
(FWD: string, BWD: string): string => {
|
||||
const segs = barSegments;
|
||||
if (!segs.length) return '';
|
||||
const col = (d: 1 | -1) => (d > 0 ? FWD : BWD);
|
||||
const pct = (px: number) =>
|
||||
clamp(((px - TRACK_START_PX) / TRACK_WIDTH_PX) * 100, 0, 100);
|
||||
// 전환점에서 색이 '한번에' 바뀌도록 하드 스톱(같은 위치에 두 색).
|
||||
const stops: string[] = [`${col(segs[0].dir)} 0%`];
|
||||
for (let i = 1; i < segs.length; i++) {
|
||||
const bp = pct(segs[i].startPx).toFixed(2);
|
||||
stops.push(`${col(segs[i - 1].dir)} ${bp}%`);
|
||||
stops.push(`${col(segs[i].dir)} ${bp}%`);
|
||||
}
|
||||
stops.push(`${col(segs[segs.length - 1].dir)} 100%`);
|
||||
return `linear-gradient(to right, ${stops.join(', ')})`;
|
||||
},
|
||||
[barSegments, duration],
|
||||
);
|
||||
// 통과 구간 음영 그라데이션(videoplayer 원본): 구간마다 좌→우 3색 셰이딩, 구간 경계는 하드.
|
||||
// 전진 주황: #ffc257 → #ff8a25 → #ff7b1b / 역방향 청록: #5ca887 → #35a7a7 → #06a4c8
|
||||
const buildShaded = useCallback(
|
||||
(fwd: [string, string, string], bwd: [string, string, string]): string => {
|
||||
const segs = barSegments;
|
||||
if (!segs.length) return '';
|
||||
const cols = (d: 1 | -1) => (d > 0 ? fwd : bwd);
|
||||
const pct = (px: number) =>
|
||||
clamp(((px - TRACK_START_PX) / TRACK_WIDTH_PX) * 100, 0, 100);
|
||||
const stops: string[] = [];
|
||||
for (const s of segs) {
|
||||
const a = pct(s.startPx);
|
||||
const b = pct(s.endPx);
|
||||
const c = cols(s.dir);
|
||||
stops.push(`${c[0]} ${a.toFixed(2)}%`);
|
||||
stops.push(`${c[1]} ${((a + b) / 2).toFixed(2)}%`);
|
||||
stops.push(`${c[2]} ${b.toFixed(2)}%`);
|
||||
}
|
||||
return `linear-gradient(to right, ${stops.join(', ')})`;
|
||||
},
|
||||
[barSegments],
|
||||
);
|
||||
// 통과(지나간): 구간별 음영 그라데이션. 미통과: 단색(전진 회색 / 역방향 청회색).
|
||||
const trackGradient = useMemo(
|
||||
() => buildShaded(['#ffc257', '#ff8a25', '#ff7b1b'], ['#5ca887', '#35a7a7', '#06a4c8']),
|
||||
[buildShaded],
|
||||
);
|
||||
const trackGradientIdle = useMemo(() => buildGradient('#7a7a7a', '#637789'), [buildGradient]);
|
||||
// 방향(색)이 바뀌는 전환점 px — 구분선 위치.
|
||||
const dividers = useMemo(() => barSegments.slice(1).map((s) => s.startPx), [barSegments]);
|
||||
|
||||
// 현재 위치가 역방향(km 감소, 하늘색) 구간이면 커서도 파란색.
|
||||
const currentReverse = useMemo<boolean>(() => {
|
||||
@@ -390,6 +549,43 @@ export function StationBar({
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
// 구조물 아이콘(3-slice PNG) 상태별 이미지를 미리 로드해, passed 전환 시
|
||||
// 새 이미지 로딩으로 인한 깜빡임(잠시 사라짐)을 방지한다.
|
||||
useEffect(() => {
|
||||
const base = import.meta.env.BASE_URL;
|
||||
for (const type of ['bridge', 'tunnel']) {
|
||||
for (const st of ['upcoming', 'passed', 'revisit']) {
|
||||
for (const slice of ['left', 'center', 'right']) {
|
||||
const img = new Image();
|
||||
img.src = `${base}assets/route-segment/${type}/${type}-${st}-${slice}.png`;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const st of ['upcoming', 'passed', 'revisit']) {
|
||||
const img = new Image();
|
||||
img.src = `${base}assets/route-segment/terminal/circle-${st}.png`;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 커서/진행바 매끄러운 이동: 라이브 시간(timeRef)을 매 프레임 읽어 CSS 변수만 직접 갱신.
|
||||
// React 리렌더(배지/색)는 currentTime(throttle)으로 별도 처리 → 4K 디코딩 중에도 부드러움.
|
||||
useEffect(() => {
|
||||
if (!timeRef) return;
|
||||
let raf = 0;
|
||||
const tick = (): void => {
|
||||
const el = wrapRef.current;
|
||||
if (el && duration > 0) {
|
||||
const t = timeRef.current ?? 0;
|
||||
const pos = TRACK_START_PX + clamp(t / duration, 0, 1) * TRACK_WIDTH_PX;
|
||||
el.style.setProperty('--pos-px', `${pos}px`);
|
||||
el.style.setProperty('--cursor-x', `${renderX(pos)}px`);
|
||||
}
|
||||
raf = requestAnimationFrame(tick);
|
||||
};
|
||||
raf = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [timeRef, duration]);
|
||||
|
||||
// 클릭/드래그 트랙 px → 시간(프레임) 선형 변환으로 seek (시간축 일관).
|
||||
const seekToTrackX = useCallback(
|
||||
(trackX: number) => {
|
||||
@@ -435,15 +631,30 @@ export function StationBar({
|
||||
[seekFromClientX],
|
||||
);
|
||||
|
||||
// 터미널 역명: route.json routeInfo 우선, 없으면 측점(stationKm 정렬) 첫/끝 title.
|
||||
const { startStationName, endStationName } = useMemo(() => {
|
||||
const sorted = [...stations].sort(
|
||||
(a, b) => stationKm(a.title) - stationKm(b.title),
|
||||
);
|
||||
const firstTitle = sorted.length ? sorted[0].title : '';
|
||||
const lastTitle = sorted.length ? sorted[sorted.length - 1].title : '';
|
||||
return {
|
||||
startStationName: routeMeta?.routeInfo?.startStationName ?? firstTitle,
|
||||
endStationName: routeMeta?.routeInfo?.endStationName ?? lastTitle,
|
||||
};
|
||||
}, [stations, routeMeta]);
|
||||
|
||||
// 측점입력(예: 158k200) → 그 측점을 보는(최근접) 프레임으로 seek (실데이터 기반).
|
||||
const handleJumpToMileage = useCallback(
|
||||
(km: number) => {
|
||||
const arr = viewedRef.current;
|
||||
if (!arr.length || duration <= 0) return;
|
||||
// 연속 체이니지(chain) 기준으로 입력 측점에 가장 가까운 프레임 탐색.
|
||||
// (km은 100m 양자화 최근접 측점이라 입력값과 오차 발생)
|
||||
let best = -1;
|
||||
let bd = Infinity;
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
const d = Math.abs(arr[i].km - km);
|
||||
const d = Math.abs(arr[i].chain - km);
|
||||
if (d < bd) {
|
||||
bd = d;
|
||||
best = i;
|
||||
@@ -475,12 +686,15 @@ export function StationBar({
|
||||
posPx={cursorPx}
|
||||
onSeekDown={handleSeekDown}
|
||||
trackGradient={trackGradient}
|
||||
trackGradientIdle={trackGradientIdle}
|
||||
dividers={dividers}
|
||||
kmLabels={kmLabels}
|
||||
structures={structureMarks}
|
||||
startStationName={startStationName}
|
||||
endStationName={endStationName}
|
||||
/>
|
||||
</div>
|
||||
<TimelineCursor
|
||||
posPx={cursorPx}
|
||||
mileageText={cursorText}
|
||||
reverse={currentReverse}
|
||||
onSeekDown={handleSeekDown}
|
||||
|
||||
@@ -206,9 +206,9 @@
|
||||
|
||||
.mileageRow {
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
left: 0;
|
||||
top: 6px;
|
||||
width: 1912px;
|
||||
width: 1920px;
|
||||
height: 13px;
|
||||
white-space: nowrap;
|
||||
z-index: 3;
|
||||
@@ -220,6 +220,7 @@
|
||||
top: 51px;
|
||||
width: 1920px;
|
||||
height: 26px;
|
||||
font-family: 'Noto Sans KR', sans-serif;
|
||||
font-size: 16.5px;
|
||||
font-weight: 800;
|
||||
line-height: 24px;
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { Fragment } from 'react';
|
||||
import type { MouseEvent } from 'react';
|
||||
import { RouteSegment } from '../RouteSegment/RouteSegment';
|
||||
import type { StructureType, StructureSegment } from '../../types/timeline';
|
||||
import {
|
||||
TRACK_RENDER_END_PX,
|
||||
TRACK_RENDER_START_PX,
|
||||
@@ -11,18 +14,43 @@ import { cssVars, px } from '../../utils/cssVars';
|
||||
import { MileageMarker } from '../MileageMarker/MileageMarker';
|
||||
import styles from './Timeline.module.scss';
|
||||
|
||||
/** 미터값 → "158k160" (10m 단위). 음수면 빈 문자열. */
|
||||
function fmtKm(m: number): string {
|
||||
if (m < 0) return '';
|
||||
const r = Math.round(m / 10) * 10;
|
||||
return `${Math.floor(r / 1000)}k${String(r % 1000).padStart(3, '0')}`;
|
||||
}
|
||||
|
||||
interface TimelineProps {
|
||||
posPx: number;
|
||||
onSeekDown: (e: MouseEvent<HTMLDivElement>) => void;
|
||||
/** 방향 색 트랙 CSS gradient (전진=주황/후진=하늘색, 회전구간 부드러운 전환). */
|
||||
/** 통과 구간 트랙 gradient (전진=주황/후진=청록). */
|
||||
trackGradient: string;
|
||||
/** 미통과 구간 트랙 gradient (전진=회색/후진=청회색). */
|
||||
trackGradientIdle: string;
|
||||
/** 색(방향)이 바뀌는 전환점 px(스테이지 좌표) — 구분선 위치. */
|
||||
dividers: number[];
|
||||
/** 데이터 기반 측점값 라벨 (방향 전환점·시종점). */
|
||||
kmLabels: KmLabel[];
|
||||
/** 데이터 기반 구조물 (교량/터널/역사). */
|
||||
structures: StructMark[];
|
||||
/** 시점 역명 (route.json routeInfo 우선, 없으면 첫 측점 title). */
|
||||
startStationName?: string;
|
||||
/** 종점 역명 (route.json routeInfo 우선, 없으면 끝 측점 title). */
|
||||
endStationName?: string;
|
||||
}
|
||||
|
||||
export function Timeline({ posPx, onSeekDown, trackGradient, kmLabels, structures }: TimelineProps) {
|
||||
export function Timeline({
|
||||
posPx,
|
||||
onSeekDown,
|
||||
trackGradient,
|
||||
trackGradientIdle,
|
||||
dividers,
|
||||
kmLabels,
|
||||
structures,
|
||||
startStationName,
|
||||
endStationName,
|
||||
}: TimelineProps) {
|
||||
// 라벨 겹침 방지: x px 순으로 gap px 이내는 1개만 표시.
|
||||
const dedup = <T extends { px: number }>(items: T[], gap: number): T[] => {
|
||||
const sorted = [...items].sort((a, b) => a.px - b.px);
|
||||
@@ -32,7 +60,6 @@ export function Timeline({ posPx, onSeekDown, trackGradient, kmLabels, structure
|
||||
}
|
||||
return out;
|
||||
};
|
||||
const labels = dedup(kmLabels, 28);
|
||||
// 구조물명은 텍스트가 길어 더 넓은 간격으로 (겹침 방지).
|
||||
const structs = dedup(structures, 90);
|
||||
|
||||
@@ -41,7 +68,7 @@ export function Timeline({ posPx, onSeekDown, trackGradient, kmLabels, structure
|
||||
{/* 압축 밖 레이어: 상단 그라데이션과 역명 리더선 */}
|
||||
<div className={styles.trackFade} />
|
||||
|
||||
{/* 역명 레이어: 트랙 시작/끝(단일 소스) 기준 신탄진·대전 배치 */}
|
||||
{/* 역명 레이어: 트랙 시작/끝(단일 소스) 기준 시·종점 역명 배치 (route.json/측점 유래) */}
|
||||
<div
|
||||
className={styles.stationLayer}
|
||||
style={cssVars({
|
||||
@@ -49,12 +76,13 @@ export function Timeline({ posPx, onSeekDown, trackGradient, kmLabels, structure
|
||||
'--track-end': px(TRACK_RENDER_END_PX),
|
||||
})}
|
||||
>
|
||||
<div className={styles.stationStart}>신탄진</div>
|
||||
<div className={styles.dotStart} />
|
||||
<div className={styles.leaderStart} />
|
||||
<div className={styles.leaderEnd} />
|
||||
<div className={styles.dotEnd} />
|
||||
<div className={styles.stationEnd}>대전</div>
|
||||
{/* 좌측(시점)=역방향 하늘색, 우측(종점)=정방향 주황 */}
|
||||
<div className={styles.stationStart} style={{ color: '#06a4c8' }}>{startStationName ?? ''}</div>
|
||||
<div className={styles.dotStart} style={{ background: '#06a4c8' }} />
|
||||
<div className={styles.leaderStart} style={cssVars({ '--color-station-leader': '#06a4c8' })} />
|
||||
<div className={styles.leaderEnd} style={cssVars({ '--color-station-leader': '#ff8a25' })} />
|
||||
<div className={styles.dotEnd} style={{ background: '#ff8a25' }} />
|
||||
<div className={styles.stationEnd} style={{ color: '#ff8a25' }}>{endStationName ?? ''}</div>
|
||||
</div>
|
||||
|
||||
{/* 트랙 본체 (가로 압축 래퍼) */}
|
||||
@@ -76,7 +104,7 @@ export function Timeline({ posPx, onSeekDown, trackGradient, kmLabels, structure
|
||||
{/* 데이터 기반 색 트랙(그라데이션): 전진=주황 / 후진=하늘색.
|
||||
전체는 저톤(드론 순/역방향 미리보기), 재생되어 커서가 지나간 구간은 원래 색으로 복원. */}
|
||||
<div className={styles.track}>
|
||||
{/* 미재생: 방향색 저톤 (전체 폭) */}
|
||||
{/* 미재생(미통과): 회색/청회색 (전체 폭) */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
@@ -84,21 +112,20 @@ export function Timeline({ posPx, onSeekDown, trackGradient, kmLabels, structure
|
||||
top: 0,
|
||||
height: '100%',
|
||||
width: px(TRACK_WIDTH_PX),
|
||||
background: trackGradient,
|
||||
opacity: 0.18,
|
||||
background: trackGradientIdle,
|
||||
opacity: 1,
|
||||
borderRadius: 'inherit',
|
||||
}}
|
||||
/>
|
||||
{/* 재생된 구간: 원래 색 복원 (커서까지 clip) */}
|
||||
{/* 재생된 구간: 원래 색 복원 (커서까지 clip). 폭은 CSS 변수(--pos-px)로 매 프레임
|
||||
직접 갱신되어 React 리렌더 없이 부드럽게 늘어난다. */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
height: '100%',
|
||||
width: px(
|
||||
Math.max(0, Math.min(TRACK_WIDTH_PX, posPx - TRACK_START_PX)),
|
||||
),
|
||||
width: `max(0px, min(${TRACK_WIDTH_PX}px, calc(var(--pos-px, ${TRACK_START_PX}px) - ${TRACK_START_PX}px)))`,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
@@ -114,30 +141,115 @@ export function Timeline({ posPx, onSeekDown, trackGradient, kmLabels, structure
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 측점값 라벨 (데이터) */}
|
||||
<div className={styles.mileageRow}>
|
||||
{labels.map((l, i) => (
|
||||
<MileageMarker
|
||||
key={i}
|
||||
marker={{ id: `km-${i}`, value: l.text, left: l.px, mileage: 0 }}
|
||||
{/* 색(방향) 전환점 구분선 — videoplayer leg 솔기와 동일(검정+흰색 점선) */}
|
||||
{dividers.map((dpx, i) => (
|
||||
<div
|
||||
key={`div-${i}`}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: dpx - TRACK_START_PX,
|
||||
top: 0,
|
||||
width: 0,
|
||||
height: '100%',
|
||||
transform: 'translateX(-1px)',
|
||||
borderLeft: '1px dashed rgba(0, 0, 0, 0.7)',
|
||||
borderRight: '1px dashed rgba(255, 255, 255, 0.3)',
|
||||
zIndex: 3,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 구조물 라벨 (데이터: 교량/터널/역사) */}
|
||||
{/* 측점값 라벨 — 각 시설물 측점값(아이콘 위) */}
|
||||
<div className={styles.mileageRow}>
|
||||
{structs.map((s, i) =>
|
||||
s.km >= 0 ? (
|
||||
<MileageMarker
|
||||
key={`stkm-${i}`}
|
||||
marker={{ id: `stkm-${i}`, value: fmtKm(s.km), left: s.px, mileage: 0 }}
|
||||
/>
|
||||
) : null,
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 구조물 아이콘 — 재생바 위에 겹쳐 표시 (videoplayer 3-slice) */}
|
||||
<div style={{ position: 'absolute', inset: 0, zIndex: 4, pointerEvents: 'none' }}>
|
||||
{structs.map((s, i) => {
|
||||
// 두 상태(무채색 upcoming / 유채색 passed) 아이콘을 겹쳐두고 opacity 로 전환 →
|
||||
// 배경이미지 교체가 없어 전환 순간 깜빡임(사라짐)이 없다.
|
||||
// 역사/터미널 → 원형 아이콘
|
||||
if (s.category === '역사' || s.category === '역') {
|
||||
const D = 18;
|
||||
// 라벨과 동일 트리거(라벨 W=22 → s.px-11). 아이콘·라벨 동시 전환.
|
||||
const passed = posPx >= s.px - 11;
|
||||
const base = import.meta.env.BASE_URL;
|
||||
const common = {
|
||||
position: 'absolute' as const,
|
||||
left: s.px - D / 2,
|
||||
top: 40.5 - D / 2,
|
||||
width: D,
|
||||
height: D,
|
||||
transition: 'opacity 0.12s',
|
||||
};
|
||||
return (
|
||||
<Fragment key={i}>
|
||||
<img src={`${base}assets/route-segment/terminal/circle-upcoming.png`} alt="" style={{ ...common, opacity: passed ? 0 : 1 }} />
|
||||
<img src={`${base}assets/route-segment/terminal/circle-passed.png`} alt="" style={{ ...common, opacity: passed ? 1 : 0 }} />
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
const segType: StructureType | null =
|
||||
s.category === '터널' ? 'tunnel' : s.category === '교량' ? 'bridge' : null;
|
||||
if (!segType) return null;
|
||||
// 원본(videoplayer) 치수: 교량 22×16/cap6/top32.5, 터널 24×14/cap10/top33.75
|
||||
const isTunnel = segType === 'tunnel';
|
||||
const W = isTunnel ? 24 : 22;
|
||||
const H = isTunnel ? 14 : 16;
|
||||
const CAP = isTunnel ? 10 : 6;
|
||||
const TOP = isTunnel ? 33.75 : 32.5;
|
||||
const passed = posPx >= s.px - W / 2;
|
||||
const seg: StructureSegment = {
|
||||
id: `st-${i}`,
|
||||
type: segType,
|
||||
label: s.title,
|
||||
startMileage: 0,
|
||||
endMileage: 0,
|
||||
left: s.px - W / 2,
|
||||
top: TOP,
|
||||
width: W,
|
||||
height: H,
|
||||
capWidth: CAP,
|
||||
centerHeight: H,
|
||||
passedAtPx: 0,
|
||||
};
|
||||
const layer = (op: number) =>
|
||||
({ position: 'absolute' as const, inset: 0, opacity: op, transition: 'opacity 0.12s' });
|
||||
return (
|
||||
<Fragment key={i}>
|
||||
<div style={layer(passed ? 0 : 1)}><RouteSegment segment={seg} state="upcoming" /></div>
|
||||
<div style={layer(passed ? 1 : 0)}><RouteSegment segment={seg} state="passed" /></div>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 구조물명 라벨 (바 아래) — 통과 시 아이콘과 함께 색 변경 */}
|
||||
<div className={styles.labelRow}>
|
||||
{structs.map((s, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`${styles.segmentLabel} ${styles.neutral}`}
|
||||
style={cssVars({ '--x': px(s.px) })}
|
||||
title={`${s.category} · ${s.title}`}
|
||||
>
|
||||
{s.title}
|
||||
</div>
|
||||
))}
|
||||
{structs.map((s, i) => {
|
||||
const W = s.category === '터널' ? 24 : 22;
|
||||
const passed = posPx >= s.px - W / 2;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`${styles.segmentLabel} ${passed ? styles.accent : styles.neutral}`}
|
||||
style={cssVars({ '--x': px(s.px) })}
|
||||
title={`${s.category} · ${s.title} (${fmtKm(s.km)})`}
|
||||
>
|
||||
{s.title}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className={styles.seekArea} onMouseDown={onSeekDown} />
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
.cursor {
|
||||
position: absolute;
|
||||
left: var(--cursor-x);
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
transform: translateX(var(--cursor-x, 0px));
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.line {
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import type { MouseEvent } from 'react';
|
||||
import { renderX } from '../../mocks/route';
|
||||
import { cssVars, px } from '../../utils/cssVars';
|
||||
import styles from './TimelineCursor.module.scss';
|
||||
|
||||
interface TimelineCursorProps {
|
||||
posPx: number;
|
||||
mileageText: string;
|
||||
/** 현재 위치가 역방향(km 감소) 구간이면 커서를 파란색으로. */
|
||||
reverse?: boolean;
|
||||
@@ -12,12 +9,10 @@ interface TimelineCursorProps {
|
||||
onSeekDown: (e: MouseEvent<HTMLDivElement>) => void;
|
||||
}
|
||||
|
||||
export function TimelineCursor({ posPx, mileageText, reverse, onSeekDown }: TimelineCursorProps) {
|
||||
// 커서 x 위치(--cursor-x)는 상위(StationBar wrapRef)에서 rAF로 직접 갱신한다(transform).
|
||||
export function TimelineCursor({ mileageText, reverse, onSeekDown }: TimelineCursorProps) {
|
||||
return (
|
||||
<div
|
||||
className={`${styles.cursor} ${reverse ? styles.reverse : ''}`}
|
||||
style={cssVars({ '--cursor-x': px(renderX(posPx)) })}
|
||||
>
|
||||
<div className={`${styles.cursor} ${reverse ? styles.reverse : ''}`}>
|
||||
<div className={styles.line} onMouseDown={onSeekDown} />
|
||||
<div className={styles.badge} onMouseDown={onSeekDown}>{mileageText}</div>
|
||||
</div>
|
||||
|
||||
69
client/src/store/geoStore.ts
Normal file
69
client/src/store/geoStore.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* 클라이언트 지리정보 스토어 (Zustand)
|
||||
*
|
||||
* 폴더 선택으로 파싱한 드론 프레임 / POI / 측점 / 중심선 / ENU 원점을 보관한다.
|
||||
* 4개 소비 컴포넌트(GeoSearch / StationVerify / StationOverlay / RoutePanel)가
|
||||
* 이 스토어를 단일 소스로 구독한다(서버 /api/geo/* 대체).
|
||||
*
|
||||
* playerStore.ts 패턴을 따른다.
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
import type {
|
||||
DroneFrame,
|
||||
GeoPoint,
|
||||
CenterlinePoint,
|
||||
GeoOrigin,
|
||||
RouteMeta,
|
||||
} from '../types/geo';
|
||||
import { loadFolderGeoData } from '../utils/geoData';
|
||||
|
||||
interface GeoStore {
|
||||
loaded: boolean;
|
||||
frames: DroneFrame[];
|
||||
pois: GeoPoint[]; // type==='poi'
|
||||
stations: GeoPoint[]; // type==='station' (stationOrder 정렬)
|
||||
centerline: CenterlinePoint[];
|
||||
origin: GeoOrigin | null;
|
||||
baseName: string | null;
|
||||
routeMeta: RouteMeta | null;
|
||||
|
||||
/**
|
||||
* 폴더 선택 파일에서 지리정보를 파싱해 스토어에 적재한다.
|
||||
* 발견한 영상 File 을 반환(없으면 null) — 호출자가 loadLocalFile 로 재생.
|
||||
*/
|
||||
loadFromFolder: (files: FileList | File[]) => Promise<File | null>;
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
const EMPTY = {
|
||||
loaded: false,
|
||||
frames: [] as DroneFrame[],
|
||||
pois: [] as GeoPoint[],
|
||||
stations: [] as GeoPoint[],
|
||||
centerline: [] as CenterlinePoint[],
|
||||
origin: null as GeoOrigin | null,
|
||||
baseName: null as string | null,
|
||||
routeMeta: null as RouteMeta | null,
|
||||
};
|
||||
|
||||
export const useGeoStore = create<GeoStore>((set) => ({
|
||||
...EMPTY,
|
||||
|
||||
loadFromFolder: async (files) => {
|
||||
const data = await loadFolderGeoData(files);
|
||||
set({
|
||||
loaded: true,
|
||||
frames: data.frames,
|
||||
pois: data.pois,
|
||||
stations: data.stations,
|
||||
centerline: data.centerline,
|
||||
origin: data.origin,
|
||||
baseName: data.baseName,
|
||||
routeMeta: data.routeMeta,
|
||||
});
|
||||
return data.videoFile;
|
||||
},
|
||||
|
||||
clear: () => set({ ...EMPTY }),
|
||||
}));
|
||||
25
client/src/types/dom.d.ts
vendored
Normal file
25
client/src/types/dom.d.ts
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* 비표준 DOM 속성 보강
|
||||
*
|
||||
* `<input type="file" webkitdirectory>` 폴더 선택과
|
||||
* `File.webkitRelativePath` 가 TypeScript 에서 타입체크되도록 확장한다.
|
||||
* (lib.dom 에 webkitRelativePath 는 있으나 webkitdirectory/directory 속성은 없음)
|
||||
*/
|
||||
|
||||
import 'react';
|
||||
|
||||
declare module 'react' {
|
||||
interface InputHTMLAttributes<T> {
|
||||
// 폴더 선택용 비표준 속성 (Chrome/Firefox/Edge)
|
||||
webkitdirectory?: string;
|
||||
directory?: string;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLInputElement {
|
||||
webkitdirectory: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
124
client/src/types/geo.ts
Normal file
124
client/src/types/geo.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* 지리정보 공유 타입 (클라이언트)
|
||||
*
|
||||
* 서버 server/src/services/geoMatch.ts 의 타입을 그대로 미러링한다.
|
||||
* 4개 소비 컴포넌트(GeoSearch / StationVerify / StationOverlay / RoutePanel)와
|
||||
* geoStore / geoSearch / geoData 가 공유한다.
|
||||
*
|
||||
* 주의: DroneFrame 은 utils/geoProjection.ts 의 DroneFrameBasic 와 필드가 동일하다
|
||||
* (frame/lat/lon/altitude/yaw/pitch/roll/focalLen). 오버레이 투영 유틸이
|
||||
* DroneFrameBasic 을 받으므로, DroneFrame 은 그 타입에 그대로 대입 가능하다.
|
||||
*/
|
||||
|
||||
/** 드론 프레임 (frame별 위경도/자세) — geoMatch.ts DroneFrame 동치 */
|
||||
export interface DroneFrame {
|
||||
frame: number;
|
||||
lat: number;
|
||||
lon: number;
|
||||
altitude: number;
|
||||
yaw: number; // 기수방향 (North=0, 시계방향, degrees)
|
||||
pitch: number; // 카메라 틸트 (음수=아래, degrees)
|
||||
roll: number;
|
||||
focalLen: number; // 35mm 환산 초점거리 (mm)
|
||||
}
|
||||
|
||||
/** POI/측점 지리점 — geoMatch.ts GeoPoint 동치 */
|
||||
export interface GeoPoint {
|
||||
title: string;
|
||||
category: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
z: number; // 표고 (m)
|
||||
type: 'poi' | 'station';
|
||||
}
|
||||
|
||||
/** 의미상 별칭 (POI = GeoPoint, Station = GeoPoint with type==='station') */
|
||||
export type Poi = GeoPoint;
|
||||
export type Station = GeoPoint;
|
||||
|
||||
/** 선로 중심선 점 — geoMatch.ts CenterlinePoint 동치 */
|
||||
export interface CenterlinePoint {
|
||||
lat: number;
|
||||
lon: number;
|
||||
z: number; // 타원체고(h)
|
||||
}
|
||||
|
||||
/** 건물/측점명 검색 결과 프레임 — geoMatch.ts FrameMatch 동치 (+ 그룹 메타) */
|
||||
export interface FrameMatch {
|
||||
frame: number;
|
||||
time: number; // 초 단위 (frame / fps)
|
||||
bearingDiff: number; // 수평 각도차 (degrees)
|
||||
elevationDiff: number; // 수직 각도차 (degrees)
|
||||
distance: number; // 수평 거리 (m)
|
||||
pixelX: number; // 0~1 정규화
|
||||
pixelY: number; // 0~1 정규화
|
||||
groupSize?: number; // 연속 구간 프레임 수
|
||||
groupStart?: number; // 구간 시작 프레임
|
||||
groupEnd?: number; // 구간 끝 프레임
|
||||
}
|
||||
|
||||
/** 프레임→POI 역조회 결과 항목 — geoMatch.ts PoiInFrame 동치 */
|
||||
export interface PoiInFrame {
|
||||
poi: GeoPoint;
|
||||
bearingDiff: number;
|
||||
elevationDiff: number;
|
||||
distance: number;
|
||||
pixelX: number;
|
||||
pixelY: number;
|
||||
}
|
||||
|
||||
/** ENU 월드 원점 (첫 측점 기준) */
|
||||
export interface GeoOrigin {
|
||||
lat: number;
|
||||
lon: number;
|
||||
alt: number;
|
||||
}
|
||||
|
||||
/** 노선 구조물 (교량/터널) — route.json structures 항목 */
|
||||
export interface RouteStructure {
|
||||
id: string;
|
||||
/** bridge=교량, tunnel=터널, station(또는 그 외)=역사. */
|
||||
type: 'bridge' | 'tunnel' | 'station';
|
||||
name: string;
|
||||
/** 선택(최우선): 이 측점값에 시설물을 배치. 숫자(미터, 158400) 또는 "158k400" 문자열.
|
||||
* 동명 시설물은 각자의 station 값으로 구분 표시된다. */
|
||||
station?: number | string;
|
||||
/** 선택: 구조물 실좌표. station 없을 때 이 좌표로 배치(POI 매칭 불필요). */
|
||||
lat?: number;
|
||||
lon?: number;
|
||||
/** 선택: 위치는 POI 실좌표(이름 매칭)로 계산. 이정값은 매칭 실패 시 폴백용. */
|
||||
startMileage?: number;
|
||||
endMileage?: number;
|
||||
/** 선택: POI/좌표 기반 위치를 진행방향으로 ±N미터 미세조정. */
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
/** 노선 기본 정보 — route.json routeInfo */
|
||||
export interface RouteInfo {
|
||||
name?: string;
|
||||
direction?: string;
|
||||
lengthKm?: number;
|
||||
durationSec?: number;
|
||||
startStationName?: string;
|
||||
endStationName?: string;
|
||||
/** 측점 인정 범위(m): station 위치를 이 거리 이내로 지날 때 마커 표시. 기본 40. 폴더별 조절. */
|
||||
stationTolerance?: number;
|
||||
}
|
||||
|
||||
/** 폴더 보조 파일 <base>.route.json (없으면 route.json) 파싱 결과 */
|
||||
export interface RouteMeta {
|
||||
routeInfo?: RouteInfo;
|
||||
structures?: RouteStructure[];
|
||||
}
|
||||
|
||||
/** loadFolderGeoData 파싱 결과 */
|
||||
export interface FolderGeoData {
|
||||
videoFile: File | null;
|
||||
baseName: string | null;
|
||||
frames: DroneFrame[];
|
||||
pois: GeoPoint[]; // type==='poi'
|
||||
stations: GeoPoint[]; // type==='station' (stationOrder 정렬)
|
||||
centerline: CenterlinePoint[];
|
||||
origin: GeoOrigin | null;
|
||||
routeMeta: RouteMeta | null;
|
||||
}
|
||||
299
client/src/utils/geoData.ts
Normal file
299
client/src/utils/geoData.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* 클라이언트 폴더 기반 지리정보 로딩 + CSV 파싱
|
||||
*
|
||||
* `<input type="file" webkitdirectory>` 로 선택된 File[] 에서
|
||||
* 영상 / 드론 CSV / center.csv / building POI·측점 CSV 를 찾아 파싱한다.
|
||||
*
|
||||
* 인코딩(서버 geoMatch.ts readCsvUtf8 대응):
|
||||
* - 드론 <base>.csv : UTF-8 (+BOM)
|
||||
* - POI / 측점 / center.csv : EUC-KR → TextDecoder('euc-kr')
|
||||
*
|
||||
* center.csv 는 헤더가 EUC-KR 로 깨지므로 위치 인덱스(1=lat, 2=lon, 5=z)로 파싱한다.
|
||||
* 영상명 비의존 — '회덕' 하드코딩 없음. base 는 영상 파일명에서 유도한다.
|
||||
*/
|
||||
|
||||
import type {
|
||||
DroneFrame,
|
||||
GeoPoint,
|
||||
CenterlinePoint,
|
||||
GeoOrigin,
|
||||
FolderGeoData,
|
||||
RouteMeta,
|
||||
} from '../types/geo';
|
||||
import { stationOrder, getWorldOrigin } from './geoSearch';
|
||||
|
||||
// ── CSV 파싱 헬퍼 (geoMatch.ts:162-173 포팅) ──────────────────────────
|
||||
|
||||
export function parseCsvLine(line: string): string[] {
|
||||
const result: string[] = [];
|
||||
let current = '';
|
||||
let inQuotes = false;
|
||||
for (const ch of line) {
|
||||
if (ch === '"') {
|
||||
inQuotes = !inQuotes;
|
||||
} else if (ch === ',' && !inQuotes) {
|
||||
result.push(current.trim());
|
||||
current = '';
|
||||
} else {
|
||||
current += ch;
|
||||
}
|
||||
}
|
||||
result.push(current.trim());
|
||||
return result;
|
||||
}
|
||||
|
||||
/** ArrayBuffer → 문자열. UTF-8 BOM 제거, EUC-KR 는 TextDecoder('euc-kr'). */
|
||||
export function decodeBytes(buf: ArrayBuffer, encoding: 'utf-8' | 'euc-kr'): string {
|
||||
const text = new TextDecoder(encoding).decode(buf);
|
||||
// UTF-8/EUC-KR 디코딩 후 남을 수 있는 BOM(U+FEFF) 제거
|
||||
return text.replace(/^/, '');
|
||||
}
|
||||
|
||||
/** File → 파싱된 행 배열. */
|
||||
export async function readCsv(
|
||||
file: File,
|
||||
encoding: 'utf-8' | 'euc-kr',
|
||||
): Promise<string[][]> {
|
||||
const buf = await file.arrayBuffer();
|
||||
const text = decodeBytes(buf, encoding);
|
||||
return text
|
||||
.split(/\r?\n/)
|
||||
.filter(Boolean)
|
||||
.map(parseCsvLine);
|
||||
}
|
||||
|
||||
// ── 파일 식별 헬퍼 ────────────────────────────────────────────────────
|
||||
|
||||
/** File 의 폴더 내 상대경로 (webkitRelativePath 우선, 없으면 name). */
|
||||
function relPath(f: File): string {
|
||||
return f.webkitRelativePath || f.name;
|
||||
}
|
||||
|
||||
/** 경로의 마지막 세그먼트(파일명). */
|
||||
function baseNameOf(p: string): string {
|
||||
const parts = p.split('/');
|
||||
return parts[parts.length - 1];
|
||||
}
|
||||
|
||||
/** building/ 하위 파일 여부 (마지막 디렉토리 세그먼트가 building). */
|
||||
function isInBuilding(f: File): boolean {
|
||||
const parts = relPath(f).split('/');
|
||||
return parts.length >= 2 && parts[parts.length - 2].toLowerCase() === 'building';
|
||||
}
|
||||
|
||||
const VIDEO_EXT = /\.(mp4|webm)$/i;
|
||||
|
||||
/** 영상 파일 찾기 (mp4/webm, building 제외). */
|
||||
export function findVideoFile(files: File[]): File | null {
|
||||
return (
|
||||
files.find((f) => !isInBuilding(f) && VIDEO_EXT.test(baseNameOf(relPath(f)))) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
/** 영상 파일명에서 base(확장자 제거) 추출. */
|
||||
export function deriveBaseName(videoFile: File | null): string | null {
|
||||
if (!videoFile) return null;
|
||||
return baseNameOf(relPath(videoFile)).replace(VIDEO_EXT, '');
|
||||
}
|
||||
|
||||
// ── 파서 (geoMatch.ts loader 포팅) ────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 드론 프레임 CSV 파싱 (UTF-8 BOM, 헤더 이름 인덱스). geoMatch.ts:289-302
|
||||
* 루트(building 제외)의 base.csv 를 식별한다. base 가 없으면 루트 .csv 중
|
||||
* POI/측점/center 가 아닌 첫 파일을 사용한다(영상명 비의존 폴백).
|
||||
*/
|
||||
export async function parseDroneFrames(
|
||||
files: File[],
|
||||
baseName: string | null,
|
||||
): Promise<DroneFrame[]> {
|
||||
const rootCsv = files.filter((f) => {
|
||||
if (isInBuilding(f)) return false;
|
||||
const name = baseNameOf(relPath(f));
|
||||
if (!/\.csv$/i.test(name)) return false;
|
||||
if (/POI|측점/i.test(name)) return false;
|
||||
if (name.toLowerCase() === 'center.csv') return false;
|
||||
return true;
|
||||
});
|
||||
if (!rootCsv.length) return [];
|
||||
|
||||
// base 일치 우선, 없으면 첫 루트 csv
|
||||
const droneFile =
|
||||
(baseName && rootCsv.find((f) => baseNameOf(relPath(f)) === `${baseName}.csv`)) ||
|
||||
rootCsv[0];
|
||||
|
||||
const rows = await readCsv(droneFile, 'utf-8');
|
||||
if (rows.length < 2) return [];
|
||||
|
||||
const header = rows[0].map((h) => h.trim().replace(/^/, ''));
|
||||
const fi = (name: string) => header.indexOf(name);
|
||||
|
||||
return rows
|
||||
.slice(1)
|
||||
.map((r) => ({
|
||||
frame: parseInt(r[fi('frame_cnt')], 10),
|
||||
lat: parseFloat(r[fi('latitude')]),
|
||||
lon: parseFloat(r[fi('longitude')]),
|
||||
altitude: parseFloat(r[fi('altitude')]),
|
||||
yaw: parseFloat(r[fi('yaw')]),
|
||||
pitch: parseFloat(r[fi('pitch')]),
|
||||
roll: parseFloat(r[fi('roll')]),
|
||||
focalLen: parseFloat(r[fi('focal_len')]),
|
||||
}))
|
||||
.filter((f) => !isNaN(f.lat));
|
||||
}
|
||||
|
||||
/**
|
||||
* POI + 측점 CSV 파싱 (building/, EUC-KR, 헤더 이름 인덱스). geoMatch.ts:321-357
|
||||
* POI: category = category_clean || '건물', type='poi'
|
||||
* 측점: category = '측점'(강제), type='station'
|
||||
* 타원체고 버전 우선 규칙 유지.
|
||||
*/
|
||||
export async function parsePois(files: File[]): Promise<GeoPoint[]> {
|
||||
const buildingFiles = files.filter((f) => isInBuilding(f));
|
||||
const result: GeoPoint[] = [];
|
||||
|
||||
const nameOf = (f: File) => baseNameOf(relPath(f));
|
||||
|
||||
// POI 위경도 파일 (_타원체고 버전 우선)
|
||||
const allPoiFiles = buildingFiles.filter(
|
||||
(f) => nameOf(f).includes('POI') && nameOf(f).includes('위경도'),
|
||||
);
|
||||
const poiFiles =
|
||||
allPoiFiles.filter((f) => nameOf(f).includes('타원체고')).length > 0
|
||||
? allPoiFiles.filter((f) => nameOf(f).includes('타원체고'))
|
||||
: allPoiFiles.filter((f) => !nameOf(f).includes('타원체고'));
|
||||
|
||||
for (const f of poiFiles) {
|
||||
const rows = await readCsv(f, 'euc-kr');
|
||||
if (rows.length < 2) continue;
|
||||
const header = rows[0].map((h) => h.trim().replace(/^/, ''));
|
||||
const fi = (name: string) => header.indexOf(name);
|
||||
for (const r of rows.slice(1)) {
|
||||
const lat = parseFloat(r[fi('lat')]);
|
||||
const lon = parseFloat(r[fi('lon')]);
|
||||
if (isNaN(lat) || isNaN(lon)) continue;
|
||||
result.push({
|
||||
title: r[fi('title')] || '',
|
||||
category: r[fi('category_clean')] || '건물',
|
||||
lat,
|
||||
lon,
|
||||
z: parseFloat(r[fi('z')]) || 0,
|
||||
type: 'poi',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 측점 위경도 파일
|
||||
const stationFiles = buildingFiles.filter(
|
||||
(f) => nameOf(f).includes('측점') && nameOf(f).includes('위경도'),
|
||||
);
|
||||
for (const f of stationFiles) {
|
||||
const rows = await readCsv(f, 'euc-kr');
|
||||
if (rows.length < 2) continue;
|
||||
const header = rows[0].map((h) => h.trim().replace(/^/, ''));
|
||||
const fi = (name: string) => header.indexOf(name);
|
||||
for (const r of rows.slice(1)) {
|
||||
const lat = parseFloat(r[fi('lat')]);
|
||||
const lon = parseFloat(r[fi('lon')]);
|
||||
if (isNaN(lat) || isNaN(lon)) continue;
|
||||
result.push({
|
||||
title: r[fi('title')] || '',
|
||||
category: '측점',
|
||||
lat,
|
||||
lon,
|
||||
z: parseFloat(r[fi('z')]) || 0,
|
||||
type: 'station',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* center.csv 파싱 (EUC-KR, 위치 인덱스 1=lat, 2=lon, 5=z). geoMatch.ts:253-257
|
||||
*/
|
||||
export async function parseCenterline(files: File[]): Promise<CenterlinePoint[]> {
|
||||
const file = files.find(
|
||||
(f) => !isInBuilding(f) && baseNameOf(relPath(f)).toLowerCase() === 'center.csv',
|
||||
);
|
||||
if (!file) return [];
|
||||
|
||||
const rows = await readCsv(file, 'euc-kr');
|
||||
if (rows.length < 2) return [];
|
||||
|
||||
return rows
|
||||
.slice(1)
|
||||
.map((r) => ({
|
||||
lat: parseFloat(r[1]),
|
||||
lon: parseFloat(r[2]),
|
||||
z: parseFloat(r[5]),
|
||||
}))
|
||||
.filter((p) => !isNaN(p.lat) && !isNaN(p.lon) && !isNaN(p.z));
|
||||
}
|
||||
|
||||
/**
|
||||
* 폴더 보조 파일 route.json 파싱 (UTF-8, JSON).
|
||||
* `<base>.route.json` 우선(case-insensitive), 없으면 `route.json`.
|
||||
* building/ 제외, 루트 파일만. 파싱 실패 시 null.
|
||||
*/
|
||||
export async function parseRouteMeta(
|
||||
files: File[],
|
||||
baseName: string | null,
|
||||
): Promise<RouteMeta | null> {
|
||||
const rootJson = files.filter(
|
||||
(f) => !isInBuilding(f) && /\.json$/i.test(baseNameOf(relPath(f))),
|
||||
);
|
||||
if (!rootJson.length) return null;
|
||||
|
||||
const wantBase = baseName ? `${baseName}.route.json`.toLowerCase() : null;
|
||||
const file =
|
||||
(wantBase &&
|
||||
rootJson.find((f) => baseNameOf(relPath(f)).toLowerCase() === wantBase)) ||
|
||||
rootJson.find((f) => baseNameOf(relPath(f)).toLowerCase() === 'route.json');
|
||||
if (!file) return null;
|
||||
|
||||
try {
|
||||
const text = await file.text();
|
||||
return JSON.parse(text) as RouteMeta;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 통합 로더 ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 폴더 내 파일에서 영상 + 지리정보를 모두 파싱한다.
|
||||
* 반환값을 geoStore.loadFromFolder 가 스토어에 적재한다.
|
||||
*/
|
||||
export async function loadFolderGeoData(
|
||||
input: FileList | File[],
|
||||
): Promise<FolderGeoData> {
|
||||
const files = Array.from(input);
|
||||
|
||||
const videoFile = findVideoFile(files);
|
||||
const baseName = deriveBaseName(videoFile);
|
||||
|
||||
const [frames, allPoints, centerline, routeMeta] = await Promise.all([
|
||||
parseDroneFrames(files, baseName),
|
||||
parsePois(files),
|
||||
parseCenterline(files),
|
||||
parseRouteMeta(files, baseName),
|
||||
]);
|
||||
|
||||
const pois = allPoints.filter((p) => p.type === 'poi');
|
||||
const stations = allPoints
|
||||
.filter((p) => p.type === 'station')
|
||||
.sort((a, b) => stationOrder(a.title) - stationOrder(b.title));
|
||||
|
||||
let origin: GeoOrigin | null = null;
|
||||
if (stations.length || frames.length) {
|
||||
origin = getWorldOrigin(frames, allPoints);
|
||||
} else if (centerline.length) {
|
||||
origin = { lat: centerline[0].lat, lon: centerline[0].lon, alt: centerline[0].z };
|
||||
}
|
||||
|
||||
return { videoFile, baseName, frames, pois, stations, centerline, origin, routeMeta };
|
||||
}
|
||||
255
client/src/utils/geoSearch.ts
Normal file
255
client/src/utils/geoSearch.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* 클라이언트 지리정보 검색 알고리즘
|
||||
*
|
||||
* 서버 server/src/services/geoMatch.ts 의 검색/투영 로직을 그대로 포팅한다.
|
||||
* (검색 UI 결과를 서버 응답과 동일하게 유지하기 위해, 오버레이용 proj4 기반
|
||||
* geoProjection.ts 와는 별개의 단순 ENU(cos-lat 근사) 투영을 사용한다.)
|
||||
*
|
||||
* 싱글턴 캐시(loadFrames/loadPois) 대신 데이터를 인자로 받는 순수 함수로 변경했다.
|
||||
* 입력 데이터는 geoStore 에서 읽어 넘긴다.
|
||||
*/
|
||||
|
||||
import type {
|
||||
DroneFrame,
|
||||
GeoPoint,
|
||||
FrameMatch,
|
||||
PoiInFrame,
|
||||
GeoOrigin,
|
||||
} from '../types/geo';
|
||||
|
||||
// ── 카메라/지구 상수 (geoMatch.ts 동일) ──────────────────────────────
|
||||
const SENSOR_W_MM = 36;
|
||||
const SENSOR_H_MM = 24;
|
||||
const R_EARTH = 6371000;
|
||||
const DEFAULT_FPS = 30;
|
||||
const GAP = 30;
|
||||
|
||||
export function toRad(deg: number): number {
|
||||
return (deg * Math.PI) / 180;
|
||||
}
|
||||
|
||||
/** 위경도+표고 → 월드 ENU (m). refLat/refLon/refAlt = 원점. geoMatch.ts:78-87 */
|
||||
export function geoToEnu(
|
||||
lat: number, lon: number, alt: number,
|
||||
refLat: number, refLon: number, refAlt: number,
|
||||
): [number, number, number] {
|
||||
const cosRef = Math.cos(toRad(refLat));
|
||||
const e = toRad(lon - refLon) * cosRef * R_EARTH; // East (m)
|
||||
const n = toRad(lat - refLat) * R_EARTH; // North (m)
|
||||
const u = alt - refAlt; // Up (m)
|
||||
return [e, n, u];
|
||||
}
|
||||
|
||||
/** 월드 ENU 벡터 + 카메라 자세 → 정규화 픽셀. geoMatch.ts:90-124 */
|
||||
export function projectEnu(
|
||||
relEnu: [number, number, number],
|
||||
yawDeg: number,
|
||||
pitchDeg: number,
|
||||
focalMm: number,
|
||||
yawOffset = 0,
|
||||
): { px: number; py: number; cx: number; cy: number; cz: number } | null {
|
||||
const yaw = toRad(yawDeg + yawOffset);
|
||||
const pitch = toRad(pitchDeg);
|
||||
const cosY = Math.cos(yaw), sinY = Math.sin(yaw);
|
||||
const cosP = Math.cos(pitch), sinP = Math.sin(pitch);
|
||||
|
||||
const fwd: readonly [number, number, number] = [sinY * cosP, cosY * cosP, sinP];
|
||||
const right: readonly [number, number, number] = [cosY, -sinY, 0];
|
||||
const up: readonly [number, number, number] = [
|
||||
right[1] * fwd[2] - right[2] * fwd[1],
|
||||
right[2] * fwd[0] - right[0] * fwd[2],
|
||||
right[0] * fwd[1] - right[1] * fwd[0],
|
||||
];
|
||||
|
||||
const dot = (a: readonly [number, number, number], b: readonly [number, number, number]) =>
|
||||
a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
||||
|
||||
const cx = dot(relEnu, right);
|
||||
const cy = dot(relEnu, up);
|
||||
const cz = dot(relEnu, fwd);
|
||||
|
||||
if (cz <= 0) return null;
|
||||
|
||||
const f = focalMm || 24;
|
||||
const px = 0.5 + (cx / cz) * (f / SENSOR_W_MM);
|
||||
const py = 0.5 - (cy / cz) * (f / SENSOR_H_MM);
|
||||
return { px, py, cx, cy, cz };
|
||||
}
|
||||
|
||||
/** DroneFrame + POI + 공통 기준점 → 픽셀. geoMatch.ts:127-156 */
|
||||
export function project3D(
|
||||
drone: DroneFrame,
|
||||
poi: { lat: number; lon: number; z: number },
|
||||
yawOffset = 0,
|
||||
origin?: GeoOrigin,
|
||||
): { px: number; py: number; dist: number; h: number; v: number; inFov: boolean } | null {
|
||||
const ref = origin ?? { lat: drone.lat, lon: drone.lon, alt: drone.altitude };
|
||||
|
||||
const stEnu = geoToEnu(poi.lat, poi.lon, poi.z, ref.lat, ref.lon, ref.alt);
|
||||
const drEnu = geoToEnu(drone.lat, drone.lon, drone.altitude, ref.lat, ref.lon, ref.alt);
|
||||
const relEnu: [number, number, number] = [
|
||||
stEnu[0] - drEnu[0],
|
||||
stEnu[1] - drEnu[1],
|
||||
stEnu[2] - drEnu[2],
|
||||
];
|
||||
const dist = Math.sqrt(relEnu[0] ** 2 + relEnu[1] ** 2);
|
||||
|
||||
const res = projectEnu(relEnu, drone.yaw, drone.pitch, drone.focalLen || 24, yawOffset);
|
||||
if (!res) return null;
|
||||
|
||||
const h = Math.atan2(res.cx, res.cz) * (180 / Math.PI);
|
||||
const v = Math.atan2(res.cy, res.cz) * (180 / Math.PI);
|
||||
const inFov = res.px >= 0 && res.px <= 1 && res.py >= 0 && res.py <= 1;
|
||||
return {
|
||||
px: Math.max(0, Math.min(1, res.px)),
|
||||
py: Math.max(0, Math.min(1, res.py)),
|
||||
dist, h, v, inFov,
|
||||
};
|
||||
}
|
||||
|
||||
/** 측점 km 정렬 키. geoMatch.ts:371-375 */
|
||||
export function stationOrder(title: string): number {
|
||||
const m = title.match(/(\d+)[Kk](\d+)/);
|
||||
if (!m) return 0;
|
||||
return parseInt(m[1]) * 1000 + parseInt(m[2]);
|
||||
}
|
||||
|
||||
/** 첫 번째 측점 위치를 ENU 원점으로 반환. geoMatch.ts:378-387 */
|
||||
export function getWorldOrigin(frames: DroneFrame[], pois: GeoPoint[]): GeoOrigin {
|
||||
const stations = pois
|
||||
.filter((p) => p.type === 'station')
|
||||
.sort((a, b) => stationOrder(a.title) - stationOrder(b.title));
|
||||
if (stations.length) {
|
||||
return { lat: stations[0].lat, lon: stations[0].lon, alt: stations[0].z };
|
||||
}
|
||||
return frames[0]
|
||||
? { lat: frames[0].lat, lon: frames[0].lon, alt: frames[0].altitude }
|
||||
: { lat: 0, lon: 0, alt: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* 건물/측점명 검색 → 매칭 POI + 카메라 시야 프레임 목록. geoMatch.ts:399-468
|
||||
* pois 는 POI+측점 통합 배열을 넘긴다(검색 대상 전체).
|
||||
*/
|
||||
export function findFramesForPoi(
|
||||
frames: DroneFrame[],
|
||||
pois: GeoPoint[],
|
||||
query: string,
|
||||
marginFactor = 1.0,
|
||||
maxDist = 2000,
|
||||
yawOffset = 0,
|
||||
origin?: GeoOrigin | null,
|
||||
): { poi: GeoPoint | null; frames: FrameMatch[] } {
|
||||
const q = query.trim().toLowerCase();
|
||||
const poi = pois.find((p) => p.title.toLowerCase().includes(q));
|
||||
if (!poi) return { poi: null, frames: [] };
|
||||
|
||||
const matches: FrameMatch[] = [];
|
||||
const ref = origin ?? getWorldOrigin(frames, pois);
|
||||
|
||||
for (const f of frames) {
|
||||
const res = project3D(f, poi, yawOffset, ref);
|
||||
if (!res) continue;
|
||||
if (res.dist > maxDist) continue;
|
||||
|
||||
const halfW = 0.5 * marginFactor;
|
||||
const halfH = 0.5 * marginFactor;
|
||||
const rawPx = 0.5 + (res.px - 0.5);
|
||||
const rawPy = 0.5 + (res.py - 0.5);
|
||||
if (Math.abs(rawPx - 0.5) > halfW || Math.abs(rawPy - 0.5) > halfH) continue;
|
||||
|
||||
matches.push({
|
||||
frame: f.frame,
|
||||
time: f.frame / DEFAULT_FPS,
|
||||
bearingDiff: res.h,
|
||||
elevationDiff: res.v,
|
||||
distance: res.dist,
|
||||
pixelX: res.px,
|
||||
pixelY: res.py,
|
||||
});
|
||||
}
|
||||
|
||||
matches.sort((a, b) => a.frame - b.frame);
|
||||
|
||||
const groups: FrameMatch[][] = [];
|
||||
let group: FrameMatch[] = [];
|
||||
for (const m of matches) {
|
||||
if (group.length === 0 || m.frame - group[group.length - 1].frame <= GAP) {
|
||||
group.push(m);
|
||||
} else {
|
||||
groups.push(group);
|
||||
group = [m];
|
||||
}
|
||||
}
|
||||
if (group.length > 0) groups.push(group);
|
||||
|
||||
const best = groups.map((g) => {
|
||||
const groupStart = g[0].frame;
|
||||
const groupEnd = g[g.length - 1].frame;
|
||||
g.sort(
|
||||
(a, b) =>
|
||||
a.bearingDiff ** 2 + a.elevationDiff ** 2 - (b.bearingDiff ** 2 + b.elevationDiff ** 2),
|
||||
);
|
||||
return { ...g[0], groupSize: g.length, groupStart, groupEnd };
|
||||
});
|
||||
|
||||
best.sort(
|
||||
(a, b) =>
|
||||
a.bearingDiff ** 2 + a.elevationDiff ** 2 - (b.bearingDiff ** 2 + b.elevationDiff ** 2),
|
||||
);
|
||||
|
||||
return { poi, frames: best };
|
||||
}
|
||||
|
||||
/**
|
||||
* 프레임 번호 → 해당 프레임에서 카메라 시야에 들어오는 POI/측점 목록. geoMatch.ts:473-512
|
||||
*/
|
||||
export function findPoisForFrame(
|
||||
frames: DroneFrame[],
|
||||
pois: GeoPoint[],
|
||||
frameNum: number,
|
||||
marginFactor = 1.0,
|
||||
yawOffset = 0,
|
||||
origin?: GeoOrigin | null,
|
||||
): { droneFrame: DroneFrame | null; pois: PoiInFrame[] } {
|
||||
const drone =
|
||||
frames.find((f) => f.frame === frameNum) ??
|
||||
(() => {
|
||||
let best = frames[0];
|
||||
let bestD = Math.abs((best?.frame ?? 0) - frameNum);
|
||||
for (const f of frames) {
|
||||
const d = Math.abs(f.frame - frameNum);
|
||||
if (d < bestD) {
|
||||
bestD = d;
|
||||
best = f;
|
||||
}
|
||||
if (d === 0) break;
|
||||
}
|
||||
return best;
|
||||
})();
|
||||
if (!drone) return { droneFrame: null, pois: [] };
|
||||
|
||||
const result: PoiInFrame[] = [];
|
||||
const ref = origin ?? getWorldOrigin(frames, pois);
|
||||
|
||||
for (const poi of pois) {
|
||||
const res = project3D(drone, poi, yawOffset, ref);
|
||||
if (!res) continue;
|
||||
|
||||
const halfW = 0.5 * marginFactor;
|
||||
const halfH = 0.5 * marginFactor;
|
||||
if (Math.abs(res.px - 0.5) > halfW || Math.abs(res.py - 0.5) > halfH) continue;
|
||||
|
||||
result.push({
|
||||
poi,
|
||||
bearingDiff: res.h,
|
||||
elevationDiff: res.v,
|
||||
distance: res.dist,
|
||||
pixelX: res.px,
|
||||
pixelY: res.py,
|
||||
});
|
||||
}
|
||||
|
||||
result.sort((a, b) => a.distance - b.distance);
|
||||
return { droneFrame: drone, pois: result };
|
||||
}
|
||||
Reference in New Issue
Block a user