- djmd(영상 내장 텔레메트리) 프레임 동기 위치/고도: MP4 stbl 선별 파싱(djmdTrack.ts), CSV 시각정렬 오차(평균 3.2m) 제거 — 세그먼트별 백그라운드 정밀화(frameSynced 플래그) - 자세 시간축 재정렬: djmd 위치 교차상관으로 CSV↔영상 오프셋 자동 추정(실측 -0.33s, 잔차 0.24m) 후 yaw/pitch/roll 재샘플(yaw 최단각) — 옆 지물 미끄러짐 해소 - 포즈 평활 자동 제한: 프레임 동기 데이터는 ±5프레임 상한(기존 ±60=±1s) — 회전 시 오버레이 전체가 드론에 딸려오는 지연 제거 - 선형-측점 정합: 측점 위치를 선형 정점으로 삽입(3D 이격≤0.012m) + 정점 z 를 측점 체이니지 보간으로 재정렬, 측점 라벨 화면 EMA 제거(선과 동일한 즉시 투영) — 선형이 측점 POI 를 픽셀 단위로 통과 - 드론높이 모드 높이 규칙 통일: 선형도 '드론고도-이격거리' 평면 적용(라벨과 동일) - 카메라 offZ 잔재 교정: 표고 체계 정비 이전 드래그 보정값(-18.3m)이 이중 보정이 되어 원거리 POI 가 접근할수록 밀리던 원인 — 경로 1-1 camera.json 0 으로 교정(.bak 백업) - 로드 UX: 폴더 로드 시 정지 상태로 대기(처음부터), 데이터 교체 시 UI 초기 상태 복원 - 라벨: 측점 팝업 표고(타원체고) 행 추가, 구조물-POI 이중 라벨 제거(어음천교), 시간동기 보정 슬라이더(timeOffsetSec) - 측점 표기 0+000 형식 전환(생성 6곳·파서 4곳 신구 호환) + 스테이션바 양끝 100m 내 시설물 없으면 시작/끝 스테이션 마크 - 단축키: F2 개발자 모드(dev_mode 1↔0, 세션 한정), A 선형 표시 토글 - 인프라: 54000 포트 재부팅 자동화(portproxy 직접 IP + 로그온 갱신, pm2 resurrect) - docs/history: 작업 이력 23건 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1065 lines
48 KiB
TypeScript
Executable File
1065 lines
48 KiB
TypeScript
Executable File
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||
import type { MouseEvent as ReactMouseEvent, RefObject } from 'react';
|
||
import type { DroneFrameBasic } from '../utils/geoProjection';
|
||
import { useGeoStore } from '../store/geoStore';
|
||
import { useSettingsStore, isGradeVisible } from '../store/settingsStore';
|
||
import {
|
||
STAGE_WIDTH,
|
||
TRACK_END_PX,
|
||
TRACK_START_PX,
|
||
TRACK_WIDTH_PX,
|
||
renderX,
|
||
trackXFromRender,
|
||
} from './mocks/route';
|
||
import { cssVars } from './utils/cssVars';
|
||
import { formatMileage } from './utils/mileage';
|
||
import { PlaybackControls } from './components/PlaybackControls/PlaybackControls';
|
||
import { Timeline } from './components/Timeline/Timeline';
|
||
import { TimelineCursor } from './components/TimelineCursor/TimelineCursor';
|
||
import styles from './StationBar.module.scss';
|
||
import './tokens.css';
|
||
|
||
/** 영상 실제 fps (RoutePanel·StationOverlay 와 동일 기준). */
|
||
const VIDEO_FPS = 30000 / 1001;
|
||
/** 프레임→현재측점 precompute 시 프레임 샘플 간격(영상 step). */
|
||
const FRAME_STEP = 10;
|
||
|
||
interface GeoPoint {
|
||
title: string;
|
||
category: string;
|
||
lat: number;
|
||
lon: number;
|
||
z: number;
|
||
type: string;
|
||
}
|
||
|
||
/** 프레임별 precompute 결과. km=배지용 최근접측점, chain=구간방향용 연속 체이니지,
|
||
* offM=측점 폴리라인까지 수평거리(m) — 노선 밖(접근/이탈) 판별용. */
|
||
interface ViewedPoint {
|
||
frameNum: number;
|
||
km: number;
|
||
chain: number;
|
||
offM: number;
|
||
time: number;
|
||
}
|
||
|
||
/** 측점 폴리라인(평면 투영). 연속 체이니지 계산용. */
|
||
interface StationLine {
|
||
pts: { x: number; y: number; km: number }[];
|
||
k: number; // 경도→m 환산 (cos(lat0)*111000)
|
||
}
|
||
|
||
/** 드론 lat/lon 을 측점 폴리라인에 투영 → 연속 체이니지(m) + 폴리라인까지 수평거리(m).
|
||
* 100m 양자화된 최근접측점과 달리 전진/후진 전환 시점이 실제 이동과 일치.
|
||
* distM 은 노선 밖(접근/이탈) 판별에 사용 — 밖에서는 chain 이 끝점에 클램프됨. */
|
||
function projectChainageDist(lat: number, lon: number, line: StationLine): { chain: number; distM: number } {
|
||
const px = lon * line.k;
|
||
const py = lat * 111000;
|
||
const pts = line.pts;
|
||
let bestD = Infinity;
|
||
let bestKm = pts.length ? pts[0].km : -1;
|
||
for (let i = 0; i < pts.length - 1; i++) {
|
||
const a = pts[i];
|
||
const b = pts[i + 1];
|
||
const dx = b.x - a.x;
|
||
const dy = b.y - a.y;
|
||
const L2 = dx * dx + dy * dy;
|
||
const t = L2 === 0 ? 0 : Math.max(0, Math.min(1, ((px - a.x) * dx + (py - a.y) * dy) / L2));
|
||
const cx = a.x + dx * t;
|
||
const cy = a.y + dy * t;
|
||
const d = (px - cx) ** 2 + (py - cy) ** 2;
|
||
if (d < bestD) {
|
||
bestD = d;
|
||
bestKm = a.km + (b.km - a.km) * t;
|
||
}
|
||
}
|
||
return { chain: bestKm, distM: isFinite(bestD) ? Math.sqrt(bestD) : Infinity };
|
||
}
|
||
|
||
function projectChainage(lat: number, lon: number, line: StationLine): number {
|
||
return projectChainageDist(lat, lon, line).chain;
|
||
}
|
||
|
||
/** 데이터 기반 트랙 구간: km 증가(dir 1, 주황) / 감소(dir -1, 하늘색). px는 시간축. */
|
||
export interface BarSegment {
|
||
startPx: number;
|
||
endPx: number;
|
||
dir: 1 | -1;
|
||
}
|
||
/** 측점값 라벨 (방향 전환점·시종점). px는 시간축. */
|
||
export interface KmLabel {
|
||
px: number;
|
||
text: string;
|
||
}
|
||
/** 구조물 마크 (교량/터널/역사). px는 통과 시점(시간축), km은 측점값(연속 체이니지, m). */
|
||
export interface StructMark {
|
||
px: number;
|
||
title: string;
|
||
category: string;
|
||
km: number;
|
||
/** 원본 속성(KMZ/CSV) — 마커 클릭 팝업 표시용. */
|
||
props?: { k: string; v: string }[];
|
||
/** 종점역인데 영상이 도착하지 못함 → 끝에 '미도착' 스타일(속 빈 링)로 표시. */
|
||
unreached?: boolean;
|
||
/** 표시 측점값이 그 위치에 실제로 존재하는가. false=좌표 반경만으로 잡힌 통과(값 라벨·검색 제외). */
|
||
kmExists?: boolean;
|
||
}
|
||
|
||
interface StationBarProps {
|
||
currentTime: number;
|
||
/** 라이브 재생시간 ref (매 프레임 갱신). 커서/진행바를 React 리렌더 없이 직접 이동. */
|
||
timeRef?: RefObject<number>;
|
||
duration: number;
|
||
playing: boolean;
|
||
onTogglePlay: () => void;
|
||
onStop: () => void;
|
||
onCapture: () => void;
|
||
onSeek: (time: number) => void;
|
||
showStations: boolean;
|
||
onToggleStations: () => void;
|
||
}
|
||
|
||
const clamp = (v: number, lo: number, hi: number): number =>
|
||
Math.min(hi, Math.max(lo, v));
|
||
|
||
function stationKm(title: string): number {
|
||
const m = title.match(/(\d+)\s*[Kk+]\s*(\d+)/);
|
||
return m ? parseInt(m[1], 10) * 1000 + parseInt(m[2], 10) : -1;
|
||
}
|
||
|
||
/** 드론 GPS 최근접 측점 km (FOV 안에 측점이 없을 때 폴백). */
|
||
function nearestStationKm(f: DroneFrameBasic, stations: GeoPoint[]): number {
|
||
let best = -1;
|
||
let bd = Infinity;
|
||
for (const st of stations) {
|
||
const d = (f.lat - st.lat) ** 2 + (f.lon - st.lon) ** 2;
|
||
if (d < bd) {
|
||
bd = d;
|
||
best = stationKm(st.title);
|
||
}
|
||
}
|
||
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;
|
||
}
|
||
|
||
/** 미터값 → "158+710" (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}+${String(mm).padStart(3, '0')}`;
|
||
}
|
||
|
||
/** 미터값 → "158+163" (1m 단위) — 커서 배지용. 노선밖 표기(1m)와 갱신 밀도를 맞춘다. */
|
||
function formatMileage1(m: number): string {
|
||
const r = Math.round(m);
|
||
const km = Math.floor(r / 1000);
|
||
const mm = r % 1000;
|
||
return `${km}+${String(mm).padStart(3, '0')}`;
|
||
}
|
||
|
||
/**
|
||
* abcVideo 실제 영상 데이터 기반 측점 바.
|
||
* - 트랙 x축 = 프레임(시간) 진행. 구간 색 = 측점 km 증가(주황)/감소(하늘색).
|
||
* - 측점값 라벨·구조물(교량/터널/역사) = 재생 파일의 측점/POI 데이터로 배치.
|
||
* - 커서 = 프레임 진행, 배지 = 드론 GPS 최근접 측점.
|
||
* - 클릭/드래그/측점입력 seek = 시간(프레임) 기준 이동.
|
||
*/
|
||
export function StationBar({
|
||
currentTime,
|
||
timeRef,
|
||
duration,
|
||
playing,
|
||
onTogglePlay,
|
||
onStop,
|
||
onCapture,
|
||
onSeek,
|
||
showStations,
|
||
onToggleStations,
|
||
}: StationBarProps) {
|
||
const wrapRef = useRef<HTMLDivElement>(null);
|
||
const stageRef = useRef<HTMLDivElement>(null);
|
||
const [scale, setScale] = useState(0.5);
|
||
const draggingRef = useRef(false);
|
||
|
||
// 측점/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 directionChanges = useGeoStore((s) => s.directionChanges);
|
||
// v2.0: 구조물은 geoStore.structures(CSV 03)교량/04)터널/06)구교 + route.json 보정) 를 우선 사용.
|
||
const storeStructuresRaw = useGeoStore((s) => s.structures);
|
||
// 시설종별 필터(설정 스토어) — 체크 변경 시 즉시 반영(구독).
|
||
const gradeFilter = useSettingsStore((s) => s.gradeFilter);
|
||
const poiOverlapExclude = useSettingsStore((s) => s.poiOverlapExclude);
|
||
const storeStructures = useMemo(
|
||
() => storeStructuresRaw.filter((s) => isGradeVisible(s.grade, gradeFilter)),
|
||
[storeStructuresRaw, gradeFilter],
|
||
);
|
||
const viewedRef = useRef<ViewedPoint[]>([]);
|
||
// 이동거리축: time[i] ↔ frac[i](누적 이동거리 비율 0~1, 단조 비감소).
|
||
// 드론의 '실제 이동거리'를 옆으로 펴서 배치 → 같은 측점이라도 이동량만큼 떨어져 보인다.
|
||
// 호버(드론 이동 無) 구간은 frac 정체 → 커서 정지. 방향(전진/후진)은 barSegments 색 리본으로 구분.
|
||
// 이동거리(누적) 축: time[i]↔frac[i](누적 이동거리 비율 0~1, 단조 비감소) → 커서 항상 우측 이동.
|
||
// depChain=출발측점, arrChain=도착측점 → 방향색 기준(도착방향=주황/반대=파랑).
|
||
const chainRef = useRef<{ time: Float64Array; frac: Float64Array; depChain: number; arrChain: number } | null>(null);
|
||
const [ready, setReady] = useState(false);
|
||
|
||
const stations = useMemo(
|
||
() => pois.filter((p) => p.type === 'station' && stationKm(p.title) >= 0),
|
||
[pois],
|
||
);
|
||
|
||
// 측점 폴리라인(평면 투영) — 연속 체이니지 계산용.
|
||
const stationLine = useMemo<StationLine | null>(() => {
|
||
if (!stations.length) return null;
|
||
const sorted = [...stations].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;
|
||
return {
|
||
pts: sorted.map((p) => ({ x: p.lon * k, y: p.lat * 111000, km: stationKm(p.title) })),
|
||
k,
|
||
};
|
||
}, [stations]);
|
||
|
||
// 프레임별 precompute (frames·stations 준비되면 1회).
|
||
// - km: 드론 GPS 최근접 측점 (배지 표시용, 좌측 RoutePanel 과 동일).
|
||
// - chain: 측점 폴리라인 투영 연속 체이니지 (구간 방향용, 전환 타이밍 정확).
|
||
// FPS는 고정값(29.97)이 아니라 실데이터(마지막 프레임 번호 / 재생시간)에서 유도 → 영상별 자동 적응.
|
||
// 데이터 미비 시에만 VIDEO_FPS(29.97) 폴백. (frame/fps = 절대시간)
|
||
const videoFps = useMemo<number>(() => {
|
||
const last = storeFrames.length ? storeFrames[storeFrames.length - 1].frame : 0;
|
||
return last > 0 && duration > 0 ? last / duration : VIDEO_FPS;
|
||
}, [storeFrames, duration]);
|
||
|
||
useEffect(() => {
|
||
const frames = storeFrames;
|
||
if (!frames.length || !stations.length || !stationLine) {
|
||
// 측점 없는 데이터셋(예: 제주 DJI 로그) 폴백 — 측점 기반 기능(배지/구간색/마크)은
|
||
// ready=false 로 비활성이지만, 이동거리축(chainRef)은 드론 GPS 누적 이동거리로 구성한다.
|
||
// → 커서 진행도가 시간 선형이 아닌 실제 이동량을 따라가 호버(정지) 시 커서도 멈춘다.
|
||
setReady(false);
|
||
viewedRef.current = [];
|
||
if (!frames.length) { chainRef.current = null; return; }
|
||
const n = frames.length;
|
||
const W = 8; // 평활 반폭(프레임) — GPS 지터가 이동거리로 누적되는 것 방지(측점 경로와 동일 정책)
|
||
const preLat = new Float64Array(n + 1);
|
||
const preLon = new Float64Array(n + 1);
|
||
for (let i = 0; i < n; i++) {
|
||
preLat[i + 1] = preLat[i] + frames[i].lat;
|
||
preLon[i + 1] = preLon[i] + frames[i].lon;
|
||
}
|
||
const cosLat = Math.cos((frames[0].lat * Math.PI) / 180);
|
||
const time = new Float64Array(n);
|
||
const frac = new Float64Array(n);
|
||
let cum = 0, pLat = 0, pLon = 0;
|
||
for (let i = 0; i < n; i++) {
|
||
const lo = Math.max(0, i - W), hi = Math.min(n, i + W + 1);
|
||
const la = (preLat[hi] - preLat[lo]) / (hi - lo);
|
||
const ln = (preLon[hi] - preLon[lo]) / (hi - lo);
|
||
if (i > 0) cum += Math.hypot((la - pLat) * 111000, (ln - pLon) * 111000 * cosLat);
|
||
pLat = la; pLon = ln;
|
||
time[i] = frames[i].frame / videoFps;
|
||
frac[i] = cum;
|
||
}
|
||
const total = cum > 0 ? cum : 1;
|
||
for (let i = 0; i < n; i++) frac[i] /= total;
|
||
// depChain/arrChain 은 방향색(측점 기반) 전용 — 폴백에선 의미 없음(단조 증가 가정값).
|
||
chainRef.current = { time, frac, depChain: 0, arrChain: 1 };
|
||
return;
|
||
}
|
||
const n = frames.length;
|
||
const out: ViewedPoint[] = new Array(n);
|
||
for (let i = 0; i < n; i++) {
|
||
const f = frames[i];
|
||
const pj = projectChainageDist(f.lat, f.lon, stationLine);
|
||
out[i] = {
|
||
frameNum: f.frame,
|
||
km: nearestStationKm(f, stations),
|
||
chain: pj.chain,
|
||
offM: pj.distM,
|
||
time: f.frame / videoFps,
|
||
};
|
||
}
|
||
// 이동거리 축(frac): 체이니지 |Δ| 가 아니라 GPS 실이동거리 누적(±W 이동평균 평활).
|
||
// 체이니지 투영은 노선 밖(접근/이탈 구간)에서 폴리라인 끝점(0k000 등)에 클램프되어
|
||
// 드론이 움직여도 커서가 멈추는 문제가 있었음(제주 — 비행 시작점이 노선 밖 700m).
|
||
// GPS 누적거리는 호버 시 정체(커서 정지) + 노선 안팎 무관하게 실제 이동을 따라간다.
|
||
// depChain/arrChain(앞·뒤 5% 평균, 체이니지 기준)은 방향색 판정에만 사용.
|
||
const W = 8; // 평활 반폭(프레임)
|
||
const pre = new Float64Array(n + 1);
|
||
const preLat = new Float64Array(n + 1);
|
||
const preLon = new Float64Array(n + 1);
|
||
for (let i = 0; i < n; i++) {
|
||
pre[i + 1] = pre[i] + out[i].chain;
|
||
preLat[i + 1] = preLat[i] + frames[i].lat;
|
||
preLon[i + 1] = preLon[i] + frames[i].lon;
|
||
}
|
||
const cosLat = Math.cos((frames[0].lat * Math.PI) / 180);
|
||
const time = new Float64Array(n);
|
||
const sm = new Float64Array(n);
|
||
const frac = new Float64Array(n);
|
||
let cum = 0, pLat = 0, pLon = 0;
|
||
for (let i = 0; i < n; i++) {
|
||
const lo = Math.max(0, i - W), hi = Math.min(n, i + W + 1);
|
||
sm[i] = (pre[hi] - pre[lo]) / (hi - lo);
|
||
const la = (preLat[hi] - preLat[lo]) / (hi - lo);
|
||
const ln = (preLon[hi] - preLon[lo]) / (hi - lo);
|
||
if (i > 0) cum += Math.hypot((la - pLat) * 111000, (ln - pLon) * 111000 * cosLat);
|
||
pLat = la; pLon = ln;
|
||
time[i] = out[i].time;
|
||
frac[i] = cum;
|
||
}
|
||
const total = cum > 0 ? cum : 1;
|
||
for (let i = 0; i < n; i++) frac[i] /= total;
|
||
const seg = Math.max(1, Math.floor(n * 0.05));
|
||
let dep = 0, arr = 0;
|
||
for (let i = 0; i < seg; i++) { dep += sm[i]; arr += sm[n - 1 - i]; }
|
||
dep /= seg; arr /= seg;
|
||
if (Math.abs(arr - dep) < 1) arr = dep + 1; // 퇴화 방지
|
||
chainRef.current = { time, frac, depChain: dep, arrChain: arr };
|
||
viewedRef.current = out;
|
||
setReady(true);
|
||
}, [stations, stationLine, storeFrames, videoFps]);
|
||
|
||
/** 현재 시간에 가장 가까운 precompute 인덱스. */
|
||
const viewedIdxAtTime = useCallback((t: number): number => {
|
||
const arr = viewedRef.current;
|
||
if (!arr.length) return -1;
|
||
const target = t * videoFps;
|
||
let best = 0;
|
||
let bd = Math.abs(arr[0].frameNum - target);
|
||
for (let i = 1; i < arr.length; i++) {
|
||
const d = Math.abs(arr[i].frameNum - target);
|
||
if (d < bd) {
|
||
bd = d;
|
||
best = i;
|
||
}
|
||
}
|
||
return best;
|
||
}, [videoFps]);
|
||
|
||
// 현재 보는 측점값 — 연속 체이니지(chain)로 10m 해상도 (km 필드는 100m 양자화).
|
||
const realChain = useMemo<number | null>(() => {
|
||
if (!ready) return null;
|
||
const idx = viewedIdxAtTime(currentTime);
|
||
return idx >= 0 ? viewedRef.current[idx].chain : null;
|
||
}, [currentTime, ready, viewedIdxAtTime]);
|
||
|
||
// 현재 드론의 노선(측점 폴리라인) 이탈 거리(m) — 접근/이탈 구간 배지 표기용.
|
||
const realOffM = useMemo<number | null>(() => {
|
||
if (!ready) return null;
|
||
const idx = viewedIdxAtTime(currentTime);
|
||
return idx >= 0 ? viewedRef.current[idx].offM : null;
|
||
}, [currentTime, ready, viewedIdxAtTime]);
|
||
|
||
// 종점역 '미도착' 폭(px) — 영상이 종점역 도착 전 끝날 때, 트랙 우측에 이만큼 비워둔다.
|
||
// 영상 타임라인은 [START … END−endGapPx] 에만 매핑되고, 종점역 마커는 END(미도착)에 둔다.
|
||
// 폭 = 미도착거리(m) / 노선길이(m) × 트랙폭 (옵션 2: 실제 비례). 안전상 15%로 상한.
|
||
const endGapPx = useMemo<number>(() => {
|
||
const gapM = routeMeta?.routeInfo?.endStationGapMeters ?? 0;
|
||
const lenM = (routeMeta?.routeInfo?.lengthKm ?? 0) * 1000;
|
||
if (gapM <= 0 || lenM <= 0) return 0;
|
||
return Math.min(TRACK_WIDTH_PX * 0.15, (gapM / lenM) * TRACK_WIDTH_PX);
|
||
}, [routeMeta]);
|
||
// 시간→px 변환에 쓰는 유효 트랙폭 (미도착폭 제외).
|
||
const timeTrackWidth = TRACK_WIDTH_PX - endGapPx;
|
||
|
||
// 노선 진행방향 '상'|'하' — 동명 시설물의 (상)/(하) 변형 중 어느 쪽을 바에 표출할지 결정.
|
||
// 1순위 route.json direction, 없으면 directionChanges(상행↔하행 전환점)에서 가장 오래 지속된 방향.
|
||
const routeDirCh = useMemo<'상' | '하' | null>(() => {
|
||
const d = routeMeta?.routeInfo?.direction ?? '';
|
||
const up = d.includes('상'), down = d.includes('하');
|
||
if (up && !down) return '상';
|
||
if (down && !up) return '하';
|
||
const dc = directionChanges;
|
||
if (dc && dc.length && duration > 0) {
|
||
const acc: Record<string, number> = {};
|
||
let prevT = 0;
|
||
let cur = dc[0].from;
|
||
for (const c of dc) {
|
||
acc[cur] = (acc[cur] ?? 0) + Math.max(0, c.atSeconds - prevT);
|
||
prevT = c.atSeconds;
|
||
cur = c.to;
|
||
}
|
||
acc[cur] = (acc[cur] ?? 0) + Math.max(0, duration - prevT);
|
||
let best = '', bv = -1;
|
||
for (const k in acc) if (acc[k] > bv) { bv = acc[k]; best = k; }
|
||
if (best.includes('상') && !best.includes('하')) return '상';
|
||
if (best.includes('하') && !best.includes('상')) return '하';
|
||
}
|
||
return null;
|
||
}, [routeMeta, directionChanges, duration]);
|
||
|
||
// 진행도(이동거리축): 시간 t → 누적 이동거리 비율(frac, 0~1). chainRef 미구성 시 시간선형 폴백.
|
||
// chainRef 는 측점 유무와 무관하게 드론 프레임만 있으면 구성된다(위 폴백) → ready 비의존.
|
||
const cumFracAtTime = useCallback(
|
||
(t: number): number => {
|
||
const c = chainRef.current;
|
||
if (!c || duration <= 0)
|
||
return duration > 0 ? clamp(t / duration, 0, 1) : 0;
|
||
const { time, frac } = c;
|
||
const n = time.length;
|
||
if (n === 0) return 0;
|
||
if (t <= time[0]) return frac[0];
|
||
if (t >= time[n - 1]) return frac[n - 1];
|
||
let lo = 0, hi = n - 1;
|
||
while (hi - lo > 1) { const m = (lo + hi) >> 1; if (time[m] <= t) lo = m; else hi = m; }
|
||
const span = time[hi] - time[lo];
|
||
const r = span > 0 ? (t - time[lo]) / span : 0;
|
||
return frac[lo] + (frac[hi] - frac[lo]) * r;
|
||
},
|
||
[ready, duration],
|
||
);
|
||
// 역변환(이동거리 비율 → 시간): 바 클릭 seek 용. frac 단조 비감소 → 이진 탐색.
|
||
// cumFracAtTime 과 동일하게 ready 비의존(측점 없는 폴백 chainRef 도 사용).
|
||
const timeAtFrac = useCallback(
|
||
(f: number): number => {
|
||
const c = chainRef.current;
|
||
if (!c || duration <= 0) return clamp(f, 0, 1) * duration;
|
||
const { time, frac } = c;
|
||
const n = frac.length;
|
||
if (n === 0) return 0;
|
||
const ff = clamp(f, 0, 1);
|
||
if (ff <= frac[0]) return time[0];
|
||
if (ff >= frac[n - 1]) return time[n - 1];
|
||
let lo = 0, hi = n - 1;
|
||
while (hi - lo > 1) { const m = (lo + hi) >> 1; if (frac[m] <= ff) lo = m; else hi = m; }
|
||
const span = frac[hi] - frac[lo];
|
||
const r = span > 0 ? (ff - frac[lo]) / span : 0;
|
||
return time[lo] + (time[hi] - time[lo]) * r;
|
||
},
|
||
[ready, duration],
|
||
);
|
||
|
||
// ── 데이터 기반 측점 바 ──────────────────────────────────────────
|
||
// 시간(프레임) → 트랙 px (이동거리축). 커서·구간색·구조물 마커가 이 매핑을 공유한다.
|
||
const pxAtTime = useCallback(
|
||
(t: number): number =>
|
||
TRACK_START_PX + clamp(cumFracAtTime(t), 0, 1) * timeTrackWidth,
|
||
[cumFracAtTime, timeTrackWidth],
|
||
);
|
||
|
||
// 커서 위치 = 이동거리축 진행도 → 전진/후진 무관하게 항상 우측으로 이동. 색만 방향에 따라 바뀜.
|
||
const progressPx = pxAtTime(currentTime);
|
||
const cursorPx = progressPx;
|
||
|
||
// 커서 배지 = 폴더 데이터 기반 연속 체이니지(realChain)를 10m 단위로 표시. 데이터 없으면 빈 문자열.
|
||
// 드론이 노선(측점 폴리라인)에서 OFF_ROUTE_M 이상 벗어난 구간(접근/이탈)은 체이니지가
|
||
// 끝점에 클램프되어 무의미 → "노선밖 ○○m" 로 표기해 측점이 안 변하는 이유를 드러낸다.
|
||
const OFF_ROUTE_M = 60;
|
||
const cursorText =
|
||
realChain !== null && realChain >= 0
|
||
? (realOffM !== null && realOffM > OFF_ROUTE_M
|
||
? `노선밖 ${Math.round(realOffM)}m`
|
||
: formatMileage1(realChain))
|
||
: '';
|
||
|
||
// viewedRef(프레임→측점 km) 추이로 구간 색·전환점 산출.
|
||
// km 증가 구간 = dir 1(주황), 감소 구간 = dir -1(하늘색). HYST로 최근접 지터 무시.
|
||
const { barSegments, kmLabels } = useMemo<{
|
||
barSegments: BarSegment[];
|
||
kmLabels: KmLabel[];
|
||
}>(() => {
|
||
const arr = viewedRef.current;
|
||
const n = arr.length;
|
||
if (!ready || !n || duration <= 0)
|
||
return { barSegments: [], kmLabels: [] };
|
||
// 측점값(chain)을 ±W 이동평균으로 평활 → GPS 지터 제거. 평활했으므로 HYST를 작게 잡아
|
||
// '목적지에 가까워짐/멀어짐'의 작은 추세 전환(20m+)도 구간으로 잡는다. (커서 배지 = 그 구간 색)
|
||
const W = 8;
|
||
const pre = new Float64Array(n + 1);
|
||
for (let i = 0; i < n; i++) pre[i + 1] = pre[i] + arr[i].chain;
|
||
const sm = new Float64Array(n);
|
||
for (let i = 0; i < n; i++) {
|
||
const lo = Math.max(0, i - W), hi = Math.min(n, i + W + 1);
|
||
sm[i] = (pre[hi] - pre[lo]) / (hi - lo);
|
||
}
|
||
const HYST = 10; // m — 평활 후 기준. 이 이상 반대로 움직이면 방향(가까워짐↔멀어짐) 전환.
|
||
const segs: BarSegment[] = [];
|
||
const bounds: { chain: number; time: number; turn: boolean }[] = [
|
||
{ chain: sm[0], time: arr[0].time, turn: false },
|
||
];
|
||
// 시작 방향을 실제 데이터(첫 유의미 이동)로 판정.
|
||
let dir: 1 | -1 = 1;
|
||
for (let i = 1; i < n; i++) {
|
||
const d = sm[i] - sm[0];
|
||
if (Math.abs(d) >= HYST) { dir = d > 0 ? 1 : -1; break; }
|
||
}
|
||
let extCh = sm[0];
|
||
let extIdx = 0;
|
||
let startIdx = 0;
|
||
for (let i = 1; i < n; i++) {
|
||
const c = sm[i];
|
||
if (dir > 0 ? c > extCh : c < extCh) {
|
||
extCh = c;
|
||
extIdx = i;
|
||
} else if (dir > 0 ? extCh - c >= HYST : c - extCh >= HYST) {
|
||
segs.push({
|
||
startPx: pxAtTime(arr[startIdx].time),
|
||
endPx: pxAtTime(arr[extIdx].time),
|
||
dir,
|
||
});
|
||
bounds.push({ chain: extCh, time: arr[extIdx].time, turn: true });
|
||
dir = dir > 0 ? -1 : 1;
|
||
startIdx = extIdx;
|
||
extCh = c;
|
||
extIdx = i;
|
||
}
|
||
}
|
||
segs.push({
|
||
startPx: pxAtTime(arr[startIdx].time),
|
||
endPx: pxAtTime(arr[n - 1].time),
|
||
dir,
|
||
});
|
||
bounds.push({ chain: sm[n - 1], time: arr[n - 1].time, turn: false });
|
||
// 전환(턴) 지점은 실제 위치를 10m 단위로, 시·종점 등 기본 라벨은 100m 단위로 표시.
|
||
const labels: KmLabel[] = bounds.map((b) => ({
|
||
px: pxAtTime(b.time),
|
||
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, storeFrames, pxAtTime]);
|
||
|
||
// 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 - mileage);
|
||
if (d < bd) {
|
||
bd = d;
|
||
best = i;
|
||
}
|
||
}
|
||
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 [];
|
||
// 이 영상(세그먼트)에서 실제로 근접 통과하지 않은 구조물은 마크 생략.
|
||
// 종전엔 TH 가 최근접거리에 비례해 늘어나(gmin×3) 수 km 밖 구조물도 최근접
|
||
// 프레임(세그먼트 끝)에 마크가 찍혀, 실제 통과 구조물과 겹쳐 보였음
|
||
// (제주: 세그1 끝 귀덕천교 위에 금성천교·어음천교가 겹침 — 이들은 세그3 통과분).
|
||
// 해당 구조물은 통과하는 세그먼트의 바에서 정상 표시된다.
|
||
const PASS_MAX_M = 300;
|
||
if (gmin > PASS_MAX_M) 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 / videoFps);
|
||
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) {
|
||
// 노선 밖(접근/이탈) 프레임 제외 — 폴리라인 밖에선 체이니지가 끝점(0 등)에 클램프되어
|
||
// 접근 구간 전체가 '시점(0k000) 통과'로 오인돼 마크가 바 맨 앞에 찍히는 문제 방지.
|
||
if (arr[i].offM > OFF_ROUTE_M) { i++; continue; }
|
||
if (Math.abs(arr[i].chain - targetM) < TH) {
|
||
let j = i, bj = i, bd = Math.abs(arr[i].chain - targetM);
|
||
while (j < arr.length && arr[j].offM <= OFF_ROUTE_M && 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;
|
||
};
|
||
|
||
// 시점(첫 측점, 예: 0k000) 마크 — 드론이 노선 시작점을 통과하는 위치에 측점값 라벨 표시.
|
||
const appendStartMark = (marks: StructMark[]): StructMark[] => {
|
||
const valid = stations.filter(st => stationKm(st.title) >= 0);
|
||
if (!valid.length || !stationLine) return marks;
|
||
const first = valid.reduce((a, b) => (stationKm(a.title) <= stationKm(b.title) ? a : b));
|
||
const startM = stationKm(first.title);
|
||
for (const p of pxPassesAtMileage(startM)) {
|
||
// 측점값 라벨(위)이 "0k000" 을 이미 표시하므로 제목(아래)은 "시점" 만 — 중복 방지.
|
||
marks.push({ px: p.px, title: '시점', category: '측점', km: startM, kmExists: true });
|
||
}
|
||
return marks;
|
||
};
|
||
|
||
// 바 양끝 시작/끝 스테이션 — 각 끝 100m(체이니지) 이내에 다른 마크가 없으면
|
||
// 영상 시작·끝 지점의 측점값 마크를 추가(양끝이 비어 시작/끝 위치가 모호한 것 방지).
|
||
// 노선 밖(접근/이탈) 프레임은 체이니지가 클램프되므로 첫/마지막 '노선 위' 프레임 기준.
|
||
const appendEndpointStations = (marks: StructMark[]): StructMark[] => {
|
||
const va = viewedRef.current;
|
||
if (!va.length) return marks;
|
||
const onRoute = va.filter(v => v.offM <= OFF_ROUTE_M);
|
||
if (!onRoute.length) return marks;
|
||
const ends = [
|
||
{ v: onRoute[0], title: '시작' },
|
||
{ v: onRoute[onRoute.length - 1], title: '끝' },
|
||
];
|
||
for (const e of ends) {
|
||
const ch = e.v.chain;
|
||
if (marks.some(m => Math.abs(m.km - ch) <= 100)) continue;
|
||
const px = pxAtTime(e.v.time);
|
||
if (px === null) continue;
|
||
marks.push({ px, title: e.title, category: '측점', km: Math.round(ch), kmExists: true });
|
||
}
|
||
return marks;
|
||
};
|
||
|
||
// 구조물 후보 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 });
|
||
}
|
||
|
||
// 실제 통과 마커는 '이동/삭제 없이' 직교 통과 위치에 그대로 둔다(드론이 종점 야드에서 162080을
|
||
// 여러 번 지나면 각 통과 자리에 마커 유지 → 검색과 일치).
|
||
// 종점(최우측 역사)이 '미도착'(endGapPx>0)이면, 기존 마커를 옮기지 않고 트랙 끝(TRACK_END,
|
||
// 실제 종점 측점 위치)에 '미도착' 종점 마커를 하나 '추가'한다. → 드론이 못 간 종점 162080도
|
||
// 끝에 표시되고, 지나간 162080 통과들도 실제 위치에 그대로 남는다.
|
||
const placeUnreachedTerminal = (marks: StructMark[]): StructMark[] => {
|
||
if (endGapPx <= 0) return marks;
|
||
let hiPx = -Infinity;
|
||
let term: StructMark | null = null;
|
||
for (const m of marks) {
|
||
if ((m.category === '역사' || m.category === '역') && m.px > hiPx) { hiPx = m.px; term = m; }
|
||
}
|
||
if (term) marks.push({ ...term, px: TRACK_END_PX, unreached: true });
|
||
return marks;
|
||
};
|
||
|
||
// 경로상 시설물만: 시설물명에 방향표기((상…/(하…)가 있으면 영상 진행방향과 일치하는 것만.
|
||
// 하행이면 '(상…' 제외, 상행이면 '(하…' 제외. (예: 회덕천교(상)/(상인상)은 하행 영상에서 숨김)
|
||
// 진행방향(routeDirCh)의 반대 변형은 바에서 제외 → 상행이면 (상), 하행이면 (하) 만 남는다.
|
||
const oppDirCh = routeDirCh === '하' ? '상' : routeDirCh === '상' ? '하' : '';
|
||
const isOppositeDir = (name: string): boolean =>
|
||
!!oppDirCh && new RegExp(`[((]${oppDirCh}`).test(name);
|
||
|
||
// v2.0: CSV 유래 구조물(storeStructures) 우선. route.json 보정은 geoData 에서 이미 병합됨.
|
||
if (storeStructures && storeStructures.length) {
|
||
const out: StructMark[] = [];
|
||
for (const s of storeStructures) {
|
||
// 구교(06)는 영상 오버레이에 표출 → 스테이션바에서는 제외(교량/터널만).
|
||
if (s.category === '구교') continue;
|
||
if (isOppositeDir(s.name)) continue; // 반대 방향(다른 선로) 시설물 제외
|
||
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(측점값) → 좌표를 측점선에 투영한 '측점 기준' 탐지 → 좌표 근접(최후 폴백) → 이정값.
|
||
// 각 통과 지점마다 마커(드론이 2번 지나면 2개). 동명 시설물은 각자 station 으로 구분.
|
||
// ※ 좌표 근접(pxPassesTo)은 출발점 부근 등에서 엉뚱한 '조기 통과'를 잡아 마커가 앞쪽(좌측)으로
|
||
// 잘못 배치될 수 있다 → 좌표를 측점값으로 환산해 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 && stationLine)
|
||
passes = pxPassesAtMileage(projectChainage(s.lat, s.lon, stationLine));
|
||
if (!passes.length && match && stationLine)
|
||
passes = pxPassesAtMileage(projectChainage(match.lat, match.lon, stationLine));
|
||
// 역사(역)은 좌표근접(pxPassesTo) 폴백을 쓰지 않는다 → '드론 실제 측점(chain)이 역 측점과
|
||
// 일치(pxPassesAtMileage)'하는 통과에서만 마커를 찍는다. 그래야:
|
||
// ① 좌표만 가까운(측점 다른) 조차장 재진입(드론 162k210인데 대전조차장 162k080)은 안 찍히고,
|
||
// ② 진짜 162k080 통과에는 정상적으로 찍힌다. (교량/터널만 좌표근접 폴백 허용.)
|
||
if (!passes.length && cat !== '역사' && s.lat != null && s.lon != null) passes = pxPassesTo(s.lat, s.lon, off);
|
||
if (!passes.length && cat !== '역사' && 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 }];
|
||
}
|
||
// 역사 km 라벨 = '역 좌표 자체'의 측점값(드론 최근접 프레임 chain 아님).
|
||
// 역은 바 양끝에 스냅되므로, 시작/끝 커서값(= 역 위치 측점)과 일치시키기 위함.
|
||
const stationKmVal = cat === '역사' && s.lat != null && s.lon != null && stationLine
|
||
? projectChainage(s.lat, s.lon, stationLine) : null;
|
||
const existTol = routeMeta?.routeInfo?.stationTolerance ?? 20;
|
||
for (const p of passes) {
|
||
// 이 통과들은 pxPassesAtMileage(측점 일치) 또는 이정값 기반이라 이미 '실제 측점 일치' 통과.
|
||
// (역사는 좌표근접 폴백을 위에서 배제.) kmExists 는 라벨/검색 참고용으로만 유지.
|
||
const kmExists = stationKmVal == null || Math.abs(p.km - stationKmVal) <= existTol;
|
||
out.push({ px: p.px, title: s.name, category: cat, km: stationKmVal ?? p.km, kmExists, props: s.props });
|
||
}
|
||
}
|
||
return appendEndpointStations(placeUnreachedTerminal(appendStartMark(out)));
|
||
}
|
||
|
||
// 폴백: POI category 기반 (실좌표 GPS 근접, 모든 통과).
|
||
const out: StructMark[] = [];
|
||
for (const [base, info] of poiByName) {
|
||
if (isOppositeDir(base)) continue; // 반대 방향(다른 선로) 시설물 제외
|
||
const stationKmVal = info.category === '역사' && stationLine
|
||
? projectChainage(info.lat, info.lon, stationLine) : null;
|
||
const existTol = routeMeta?.routeInfo?.stationTolerance ?? 20;
|
||
// 측점 기준 탐지 우선(통과순서 정확), 측점선 없으면 좌표 근접 폴백.
|
||
const ps = stationLine
|
||
? pxPassesAtMileage(projectChainage(info.lat, info.lon, stationLine))
|
||
: pxPassesTo(info.lat, info.lon);
|
||
for (const p of ps) {
|
||
const kmExists = stationKmVal == null || Math.abs(p.km - stationKmVal) <= existTol;
|
||
out.push({ px: p.px, title: base, category: info.category, km: stationKmVal ?? p.km, kmExists });
|
||
}
|
||
}
|
||
return appendEndpointStations(placeUnreachedTerminal(appendStartMark(out)));
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [ready, duration, storeFrames, pois, stations, pxAtTime, routeMeta, routeDirCh, storeStructures, pxAtMileage, endGapPx, stationLine, videoFps]);
|
||
|
||
// 여정 정방향(목적지=도착 방향): 측점값이 출발(depChain)→도착(arrChain) 으로 변하는 방향.
|
||
// 그 방향으로 움직이는 구간=정방향(황색), 반대=역방향(청색).
|
||
const forwardDir = useMemo<1 | -1>(() => {
|
||
const c = chainRef.current;
|
||
if (!ready || !c) return 1;
|
||
return c.arrChain >= c.depChain ? 1 : -1;
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [ready, storeFrames]);
|
||
|
||
// 방향 색 트랙 gradient — 이동거리축은 구간 px가 좌→우 단조라 gradient 1장으로 충분(경량).
|
||
// 정방향(도착방향)=FWD, 역방향=BWD. 구간 경계는 하드 스톱.
|
||
const buildGradient = useCallback(
|
||
(FWD: string, BWD: string): string => {
|
||
const segs = barSegments;
|
||
if (!segs.length) return '';
|
||
const col = (d: 1 | -1) => (d === forwardDir ? 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, forwardDir],
|
||
);
|
||
// 통과 음영(구간마다 좌→우 3색). 정방향 주황 / 역방향 청록.
|
||
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 === forwardDir ? 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), b = pct(s.endPx), 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, forwardDir],
|
||
);
|
||
// 통과(지나간): 음영. 미통과: 단색(정방향 회색 / 역방향 청회색).
|
||
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]);
|
||
|
||
// 커서 배지 색 = 현재 위치한 '구간' 색과 동일(바 배경과 일치). 역방향(목적지 반대) 구간이면 파란색.
|
||
// → 배지·바 배경이 항상 같은 색(기존 방식). 구간 색은 forwardDir 기준 정/역방향.
|
||
const currentReverse = useMemo<boolean>(() => {
|
||
for (const s of barSegments) {
|
||
if (cursorPx >= s.startPx && cursorPx <= s.endPx) return s.dir !== forwardDir;
|
||
}
|
||
return false;
|
||
}, [barSegments, cursorPx, forwardDir]);
|
||
|
||
// 컨테이너 폭 기준 균등 스케일
|
||
useEffect(() => {
|
||
const el = wrapRef.current;
|
||
if (!el) return;
|
||
const update = (): void =>
|
||
setScale((el.clientWidth || STAGE_WIDTH) / STAGE_WIDTH);
|
||
update();
|
||
const ro = new ResizeObserver(update);
|
||
ro.observe(el);
|
||
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 = pxAtTime(t); // 이동거리축: 현재 진행도 → 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, pxAtTime]);
|
||
|
||
// 클릭/드래그 트랙 px → 이동거리 비율 → 시간(역변환)으로 seek (이동거리축 일관).
|
||
const seekToTrackX = useCallback(
|
||
(trackX: number) => {
|
||
if (duration <= 0) return;
|
||
const px = clamp(trackX, TRACK_START_PX, TRACK_END_PX);
|
||
const targetTime = timeAtFrac((px - TRACK_START_PX) / timeTrackWidth);
|
||
onSeek(clamp(targetTime, 0, duration));
|
||
},
|
||
[duration, onSeek, timeTrackWidth, timeAtFrac],
|
||
);
|
||
|
||
const seekFromClientX = useCallback(
|
||
(clientX: number) => {
|
||
const stage = stageRef.current;
|
||
if (!stage) return;
|
||
const rect = stage.getBoundingClientRect();
|
||
const stageX = ((clientX - rect.left) / rect.width) * STAGE_WIDTH;
|
||
seekToTrackX(trackXFromRender(stageX));
|
||
},
|
||
[seekToTrackX],
|
||
);
|
||
|
||
useEffect(() => {
|
||
const move = (e: globalThis.MouseEvent): void => {
|
||
if (draggingRef.current) seekFromClientX(e.clientX);
|
||
};
|
||
const up = (): void => {
|
||
draggingRef.current = false;
|
||
};
|
||
window.addEventListener('mousemove', move);
|
||
window.addEventListener('mouseup', up);
|
||
return () => {
|
||
window.removeEventListener('mousemove', move);
|
||
window.removeEventListener('mouseup', up);
|
||
};
|
||
}, [seekFromClientX]);
|
||
|
||
const handleSeekDown = useCallback(
|
||
(e: ReactMouseEvent<HTMLDivElement>) => {
|
||
draggingRef.current = true;
|
||
seekFromClientX(e.clientX);
|
||
},
|
||
[seekFromClientX],
|
||
);
|
||
|
||
// 터미널 역명: route.json routeInfo.startStationName/endStationName 값. 없으면 측점 첫/끝 폴백.
|
||
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 (실데이터 기반).
|
||
// 해당 영상이 커버하는 측점(체이니지) 범위 밖이면 무시(seek 안 함).
|
||
// 측점 검색 순환 상태 — 같은 측점값을 연속 Enter 할 때 '직전 점프 위치'에서 다음으로 이어가기 위함.
|
||
const jumpRef = useRef<{ km: number; time: number } | null>(null);
|
||
const handleJumpToMileage = useCallback(
|
||
(km: number) => {
|
||
const arr = viewedRef.current;
|
||
if (!arr.length || duration <= 0) return false;
|
||
// 커버 측점 범위 산출 (구간 밖 입력은 무시).
|
||
let lo = Infinity, hi = -Infinity;
|
||
for (let i = 0; i < arr.length; i++) {
|
||
const c = arr[i].chain;
|
||
if (c < lo) lo = c;
|
||
if (c > hi) hi = c;
|
||
}
|
||
const MARGIN = 20;
|
||
if (km < lo - MARGIN || km > hi + MARGIN) return false;
|
||
|
||
// 측점 검색은 '드론의 실제 투영 측점(chain)' 기준으로만 이동한다(마커에 '표시된' 라벨 기준 아님).
|
||
// → 입력값과 허용오차(stationTolerance 기본 20m) 안인 프레임들을 '연속구간=1통과'로 묶어,
|
||
// 그 통과 지점들로만 순환 이동. 표시 라벨만 162080이고 실제 위치는 다른(예: 162210 조차장
|
||
// 좌표근접) 마커로는 절대 튀지 않는다.
|
||
// 통과가 0개면(그 측점을 실제로 안 지남) 이동하지 않고 false → 입력창에 '측점 없음' 안내.
|
||
const TOL = routeMeta?.routeInfo?.stationTolerance ?? 20;
|
||
const times: number[] = [];
|
||
{
|
||
let i = 0;
|
||
while (i < arr.length) {
|
||
if (Math.abs(arr[i].chain - km) < TOL) {
|
||
let j = i, bj = i, bd = Math.abs(arr[i].chain - km);
|
||
while (j < arr.length && Math.abs(arr[j].chain - km) < TOL) {
|
||
const d = Math.abs(arr[j].chain - km);
|
||
if (d < bd) { bd = d; bj = j; }
|
||
j++;
|
||
}
|
||
times.push(arr[bj].time);
|
||
i = j;
|
||
} else i++;
|
||
}
|
||
}
|
||
if (!times.length) return false;
|
||
// 시간 오름차순 + 근접 중복 제거(같은 통과가 두 경로/마커로 잡힌 경우).
|
||
times.sort((a, b) => a - b);
|
||
const passes: number[] = [];
|
||
for (const t of times) if (!passes.length || t - passes[passes.length - 1] > 0.3) passes.push(t);
|
||
|
||
// 기준 시각: 같은 측점을 '직전 점프 위치 그대로'에서 다시 Enter 하면 연속(다음 통과),
|
||
// 재생/클릭으로 커서가 움직였거나 다른 측점이면 현재 커서(라이브) 시각에서 새로 시작.
|
||
const liveT = timeRef?.current ?? currentTime;
|
||
const same = !!jumpRef.current
|
||
&& Math.abs(jumpRef.current.km - km) < 1e-6
|
||
&& Math.abs(liveT - jumpRef.current.time) < 0.1;
|
||
const baseT = same ? jumpRef.current!.time : liveT;
|
||
// 통과방향(시간 증가)으로 baseT 다음 통과. 끝까지 가면 처음으로 순환.
|
||
const EPS = 1e-3;
|
||
const target = passes.find((t) => t > baseT + EPS) ?? passes[0];
|
||
|
||
jumpRef.current = { km, time: target };
|
||
onSeek(clamp(target, 0, duration));
|
||
return true;
|
||
},
|
||
[duration, onSeek, routeMeta, currentTime, timeRef],
|
||
);
|
||
|
||
return (
|
||
<div
|
||
ref={wrapRef}
|
||
className={styles.wrap}
|
||
style={cssVars({ '--bar-scale': scale })}
|
||
>
|
||
<div ref={stageRef} className={styles.layer}>
|
||
<div className={styles.bottomBar}>
|
||
<PlaybackControls
|
||
playing={playing}
|
||
onTogglePlay={onTogglePlay}
|
||
onStop={onStop}
|
||
onCapture={onCapture}
|
||
onJumpToMileage={handleJumpToMileage}
|
||
lineOn={showStations}
|
||
onToggleLine={onToggleStations}
|
||
/>
|
||
<Timeline
|
||
posPx={cursorPx}
|
||
onSeekDown={handleSeekDown}
|
||
trackGradient={trackGradient}
|
||
trackGradientIdle={trackGradientIdle}
|
||
dividers={dividers}
|
||
kmLabels={kmLabels}
|
||
structures={structureMarks}
|
||
startStationName={startStationName}
|
||
endStationName={endStationName}
|
||
endGapPx={endGapPx}
|
||
routeDir={routeDirCh}
|
||
overlapExclude={poiOverlapExclude}
|
||
/>
|
||
</div>
|
||
<TimelineCursor
|
||
mileageText={cursorText}
|
||
reverse={currentReverse}
|
||
onSeekDown={handleSeekDown}
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|