Files
GhiVideo_v4/client/src/stationbar/StationBar.tsx
T
b23042andClaude Fable 5 c64c57b58e djmd 프레임 동기·오버레이 지면고정·선형-측점 정합·UI 단축키
- 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>
2026-07-14 17:59:43 +09:00

1065 lines
48 KiB
TypeScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 … ENDendGapPx] 에만 매핑되고, 종점역 마커는 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>
);
}