Files
b23042andClaude Fable 5 0a7044d79b 스테이션바 — 측점 검색 재설계(교차 방식·배지 일치)·시점/종점 표기 체계
검색 재설계 (5단계 진화):
- 전 구간(모든 세그먼트) 검색: 현재 세그먼트는 배지가 쓰는 데이터(viewedRef)에서
  부호 교차 탐지 → 착지 배지값 == 검색값(오차 0). 타 세그먼트는 CSV 인덱스
  (가상 fps·djmd dt 보정) → 세그먼트 자동 전환+시크
- 통과 = 값을 '가로지르는' 모든 순간(선형보간) — 종전 ±20m 창 방식은 호버 구간에서
  통과 병합·착지 흔들림을 유발해 제거. 3s 채터 병합, 최근접 폴백(TOL 이내)
- 같은 값 재Enter = 무조건 다음 통과(시크 정착 지연에도 순환), 랩어라운드

시점/종점 표기 체계:
- 배지: 시점 이전 '시점-NNN'(전환점 0 연속 카운트다운), 종점 이후 '종점+NNN'
  (지나온 거리+주시거리 — 단조 증가, 통과 순간과 연속), 횡이탈 '노선밖 NNN'
- 마크: '종점'(노선 끝 통과 위치), 종점 이후 시작/끝 마크 '종점+NNN' 표기(kmText),
  종점 이후 1000m 단위 마크(종점+1000 …), fmtKm 단수 측점 보존(6+998 → 7+000 왜곡 제거)
- ViewedPoint: viewOffM·offSide·beyondM 필드 추가

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 16:27:30 +09:00

1455 lines
71 KiB
TypeScript
Executable File
Raw Permalink 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 { DJI_VIRTUAL_FPS } from '../utils/geoData';
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) — 노선 밖(접근/이탈) 판별용.
* viewChain=카메라 시선 중심이 닿는 지면 교점의 체이니지(커서 배지용) —
* 드론 연직 체이니지(chain)보다 전방 주시거리(AGL/tan|pitch|)만큼 앞. */
interface ViewedPoint {
frameNum: number;
km: number;
chain: number;
viewChain: number;
/** 배지 표시 지점(시선/오프셋 지점)의 노선 이격(m) — 오프셋 미설정이면 offM 과 동일. */
viewOffM: number;
/** 노선밖일 때 위치 구분: -1=시점 이전(접근), +1=종점 이후(이탈), 0=노선 중간 횡이탈. */
offSide: number;
/** 종점 초과 거리(m) — 시선값(클램프 전)이 노선 끝을 넘은 양. 0=범위 안.
* 배지가 끝값(6+998 등)에 얼어붙는 대신 '노선밖 +NNN' 카운트업 표시에 사용. */
beyondM: number;
offM: number;
time: number;
}
/** 측점 폴리라인(평면 투영). 연속 체이니지 계산용. */
interface StationLine {
pts: { x: number; y: number; km: number }[];
k: number; // 경도→m 환산 (cos(lat0)*111000)
}
/** 노선 이탈 판정 거리(m) — 이 이상 벗어나면 접근/이탈 구간으로 취급. */
const OFF_ROUTE_M = 60;
/** 평면 투영 좌표(px,py)를 측점 폴리라인에 투영 → 연속 체이니지(m) + 수평거리(m). */
function projectXYChainage(px: number, py: number, line: StationLine): { chain: number; distM: number } {
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 };
}
/** 드론 lat/lon 을 측점 폴리라인에 투영 → 연속 체이니지(m) + 폴리라인까지 수평거리(m).
* 100m 양자화된 최근접측점과 달리 전진/후진 전환 시점이 실제 이동과 일치.
* distM 은 노선 밖(접근/이탈) 판별에 사용 — 밖에서는 chain 이 끝점에 클램프됨. */
function projectChainageDist(lat: number, lon: number, line: StationLine): { chain: number; distM: number } {
return projectXYChainage(lon * line.k, lat * 111000, line);
}
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;
/** 측점값 라벨 텍스트 오버라이드 — 종점 초과 마크('종점+136') 등 mileage 형식이 아닌 표기. */
kmText?: string;
}
interface StationBarProps {
currentTime: number;
/** 라이브 재생시간 ref (매 프레임 갱신). 커서/진행바를 React 리렌더 없이 직접 이동. */
timeRef?: RefObject<number>;
duration: number;
playing: boolean;
onTogglePlay: () => void;
onStop: () => void;
onCapture: () => void;
onSeek: (time: number) => void;
/** 전 구간 측점 검색 점프 — 다른 세그먼트의 통과 지점으로 전환+시크(세그index, 로컬초). */
onSeekSegment?: (segIndex: number, timeSec: 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,
onSeekSegment,
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 fullFrames = useGeoStore((s) => s.fullFrames);
const videoDurations = useGeoStore((s) => s.videoDurations);
const videoIndex = useGeoStore((s) => s.videoIndex);
const djmdDtSec = useGeoStore((s) => s.djmdDtSec);
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(현재 세그먼트)와 별개로, 전체 비행 프레임에서
// [전역시각 t, 배지값 vc(viewChain 규칙), 이격 off] 을 만들어 검색이
// 세그먼트 경계를 넘어 진행방향으로 순환하게 한다. (0.2s 표본)
const globalSearch = useMemo<{ t: number; vc: number; off: number }[] | null>(() => {
if (!fullFrames.length || !stationLine || !stations.length) return null;
if (!videoDurations.length || videoDurations.some((d) => d == null)) return null;
const totalDur = (videoDurations as number[]).reduce((a, b) => a + b, 0);
const lastFrame = fullFrames[fullFrames.length - 1].frame;
// 시간기반(DJI) 로그는 frame = 경과초×59.94 가 정확한 환산 — 영상 길이 합으로 나누면
// CSV 가 영상보다 긴 만큼(착륙 꼬리 등) 시간축이 압축되어 검색이 수 m 어긋난다.
// 프레임 간격이 1 초과(가상 fps 방식)면 상수 사용, 아니면(프레임 기반) 길이 비로 환산.
const stepF = fullFrames.length > 4 ? (fullFrames[4].frame - fullFrames[0].frame) / 4 : 1;
const fps = stepF > 1.5 ? DJI_VIRTUAL_FPS : lastFrame > 0 && totalDur > 0 ? lastFrame / totalDur : VIDEO_FPS;
const raw = (routeMeta?.routeInfo as Record<string, unknown> | undefined)?.station_lookahead
?? (routeMeta as unknown as Record<string, unknown> | null)?.station_lookahead;
const lookFixed: number | null =
typeof raw === 'number' && isFinite(raw) && raw > 0 ? raw
: typeof raw === 'string' && raw.trim() !== '' && isFinite(Number(raw)) && Number(raw) > 0 ? Number(raw)
: null;
const lookAuto = typeof raw === 'string' && raw.trim().toLowerCase() === 'auto';
const zProf = stations
.map((st) => ({ km: stationKm(st.title), z: st.z }))
.filter((p) => p.km >= 0 && p.z > 0)
.sort((a, b) => a.km - b.km);
const zAt = (km: number): number | null => {
if (zProf.length < 2) return null;
if (km <= zProf[0].km) return zProf[0].z;
if (km >= zProf[zProf.length - 1].km) return zProf[zProf.length - 1].z;
let lo = 0, hi = zProf.length - 1;
while (hi - lo > 1) { const m = (lo + hi) >> 1; if (zProf[m].km <= km) lo = m; else hi = m; }
const a = zProf[lo], b = zProf[hi];
const r = b.km > a.km ? (km - a.km) / (b.km - a.km) : 0;
return a.z + (b.z - a.z) * r;
};
const MAX_LOOK = 350;
const tangentAt = (km: number): { x: number; y: number } | null => {
const pts = stationLine.pts;
for (let i = 0; i < pts.length - 1; i++) {
const a = pts[i], b = pts[i + 1];
if ((km - a.km) * (km - b.km) <= 0 && a.km !== b.km) {
const sg = b.km > a.km ? 1 : -1;
const dx = (b.x - a.x) * sg, dy = (b.y - a.y) * sg;
const L = Math.hypot(dx, dy);
return L > 0 ? { x: dx / L, y: dy / L } : null;
}
}
return null;
};
let chMin = Infinity, chMax = -Infinity;
for (const p of stationLine.pts) { if (p.km < chMin) chMin = p.km; if (p.km > chMax) chMax = p.km; }
const out: { t: number; vc: number; off: number }[] = [];
for (let i = 0; i < fullFrames.length; i += 2) {
const f = fullFrames[i];
const pj = projectChainageDist(f.lat, f.lon, stationLine);
let vc = pj.chain, off = pj.distM;
if (lookFixed !== null || lookAuto) {
let lookDist: number | null = lookFixed;
if (lookAuto) {
const gz = zAt(pj.chain);
if (gz !== null && isFinite(f.altitude)) {
const agl = f.altitude - gz;
if (agl > 5 && agl < 500) {
const pd = isFinite(f.pitch) ? f.pitch : -15;
lookDist = pd <= -3 ? Math.min(MAX_LOOK, agl / Math.tan((-pd * Math.PI) / 180)) : MAX_LOOK;
}
}
}
if (lookDist !== null && lookDist > 0 && isFinite(f.yaw)) {
const over = Math.max(0, pj.distM - OFF_ROUTE_M);
const eff = lookDist - over;
if (eff >= 0) {
const tg = tangentAt(pj.chain);
if (tg) {
const yr = (f.yaw * Math.PI) / 180;
const dot = Math.sin(yr) * tg.x + Math.cos(yr) * tg.y;
vc = clamp(pj.chain + (dot >= 0 ? 1 : -1) * eff, chMin, chMax);
}
off = Math.min(pj.distM, OFF_ROUTE_M);
} else {
off = OFF_ROUTE_M - eff;
}
}
}
out.push({ t: f.frame / fps, vc, off });
}
return out;
}, [fullFrames, stationLine, stations, routeMeta, videoDurations]);
// 프레임별 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);
// ── 시선 중심 체이니지(viewChain) 준비 ─────────────────────────────
// route.json routeInfo.station_lookahead(m) 설정:
// 미설정/0 → 기존 그대로: 드론 연직(바로 아래) 체이니지 (기본)
// N(숫자) → 드론 체이니지 + 진행방향으로 N(m) 고정 오프셋
// "auto" → 자동: 주시거리 ≈ AGL / tan(|짐벌 피치|)
// (AGL = 드론고도 − 지면고도(측점 z 체이니지 보간, 동일 datum))
// 진행방향 = 카메라 yaw 벡터 · 노선 접선 내적 부호.
// 오프셋 활성 시 노선 밖(접근/이탈)에서는 '시선이 닿는 지점'을 노선에 투영해
// 그 지점이 노선 위(OFF_ROUTE_M 이내)면 배지가 측점값으로 전환된다.
const lookCfgRaw = (routeMeta?.routeInfo as Record<string, unknown> | undefined)?.station_lookahead
?? (routeMeta as unknown as Record<string, unknown> | null)?.station_lookahead;
const lookFixed: number | null =
typeof lookCfgRaw === 'number' && isFinite(lookCfgRaw) && lookCfgRaw > 0 ? lookCfgRaw
: typeof lookCfgRaw === 'string' && lookCfgRaw.trim() !== '' && isFinite(Number(lookCfgRaw)) && Number(lookCfgRaw) > 0 ? Number(lookCfgRaw)
: null;
const lookAuto = typeof lookCfgRaw === 'string' && lookCfgRaw.trim().toLowerCase() === 'auto';
const lookModeText = lookAuto ? '자동(카메라 시선 중심)' : lookFixed !== null ? `고정 +${lookFixed}m (진행방향)` : '미설정 (드론 연직 — 기존 동작)';
const zProf = stations
.map((st) => ({ km: stationKm(st.title), z: st.z }))
.filter((p) => p.km >= 0 && p.z > 0)
.sort((a, b) => a.km - b.km);
const zAt = (km: number): number | null => {
if (zProf.length < 2) return null;
if (km <= zProf[0].km) return zProf[0].z;
if (km >= zProf[zProf.length - 1].km) return zProf[zProf.length - 1].z;
let lo = 0, hi = zProf.length - 1;
while (hi - lo > 1) { const m = (lo + hi) >> 1; if (zProf[m].km <= km) lo = m; else hi = m; }
const a = zProf[lo], b = zProf[hi];
const r = b.km > a.km ? (km - a.km) / (b.km - a.km) : 0;
return a.z + (b.z - a.z) * r;
};
const MAX_LOOK = 350; // 전방 주시거리 상한(m) — POI 표시범위(기본 400)와 유사 수준
const tangentAt = (km: number): { x: number; y: number } | null => {
const pts = stationLine.pts;
for (let i = 0; i < pts.length - 1; i++) {
const a = pts[i], b = pts[i + 1];
if ((km - a.km) * (km - b.km) <= 0 && a.km !== b.km) {
const s = b.km > a.km ? 1 : -1; // +km(체이니지 증가) 방향 접선
const dx = (b.x - a.x) * s, dy = (b.y - a.y) * s;
const L = Math.hypot(dx, dy);
return L > 0 ? { x: dx / L, y: dy / L } : null;
}
}
return null;
};
// 노선 체이니지 범위(클램프용) — 측점 폴리라인 km 범위(z 프로파일 유무와 무관).
let chMin = Infinity, chMax = -Infinity;
for (const p of stationLine.pts) { if (p.km < chMin) chMin = p.km; if (p.km > chMax) chMax = p.km; }
for (let i = 0; i < n; i++) {
const f = frames[i];
const pj = projectChainageDist(f.lat, f.lon, stationLine);
let viewChain = pj.chain;
let viewOffM = pj.distM;
// 노선밖 위치 구분 — 투영이 시점/종점에 클램프되면 구간 밖(이전/이후), 아니면 횡이탈.
const offSide = pj.chain <= chMin + 2 ? -1 : pj.chain >= chMax - 2 ? 1 : 0;
// 종점 초과량 — 시선값이 끝에 클램프되어 배지가 얼어붙는 것을 '+NNN' 카운트업으로 대체.
let beyondM = 0;
if (lookFixed !== null || lookAuto) {
// 주시거리(m): 고정값 또는 자동(짐벌 피치·AGL)
let lookDist: number | null = lookFixed;
if (lookAuto) {
const gz = zAt(pj.chain);
if (gz !== null && isFinite(f.altitude)) {
const agl = f.altitude - gz;
if (agl > 5 && agl < 500) {
const pitchDeg = isFinite(f.pitch) ? f.pitch : -15;
lookDist = pitchDeg <= -3
? Math.min(MAX_LOOK, agl / Math.tan((-pitchDeg * Math.PI) / 180))
: MAX_LOOK; // 수평에 가까우면 상한 사용
}
}
}
if (lookDist !== null && lookDist > 0 && isFinite(f.yaw)) {
if (offSide === 1) {
// 종점 이후: 투영이 끝점에 클램프 — 이때 pj.distM 은 횡이격이 아니라
// '종점을 지나온 거리'다. 시선 지점 = 지나온 거리 + 주시거리 →
// 단조 증가 카운트업(통과 직전 시선값 chMax+lookDist 와 연속).
beyondM = pj.distM + lookDist;
viewChain = chMax;
viewOffM = 0; // 항상 '종점+' 표기 (노선밖 분기 방지)
} else {
// 연속 모델: 유효 주시거리 = lookDist − (노선 이격의 초과분).
// 노선 위(이격 ≤ OFF_ROUTE_M): 초과분 0 → 항상 +lookDist (횡 이동에 안정)
// 접근/이탈: 이격이 줄수록 유효 주시거리가 0→lookDist 로 늘어
// 배지가 0+000 에서 멈춤·점프 없이 진입 후 +lookDist 까지 매끄럽게 이어진다.
const over = Math.max(0, pj.distM - OFF_ROUTE_M);
const effLook = lookDist - over;
if (effLook >= 0) {
const t = tangentAt(pj.chain);
if (t) {
const yawRad = (f.yaw * Math.PI) / 180;
const dot = Math.sin(yawRad) * t.x + Math.cos(yawRad) * t.y;
const rawVC = pj.chain + (dot >= 0 ? 1 : -1) * effLook;
beyondM = Math.max(0, rawVC - chMax);
viewChain = clamp(rawVC, chMin, chMax);
}
viewOffM = Math.min(pj.distM, OFF_ROUTE_M); // 값 표시 상태(노선밖 아님)
} else {
// 아직 시선이 노선에 못 미침 — '노선밖 (남은 거리+θ)m'
viewOffM = OFF_ROUTE_M - effLook;
}
}
}
} else if (offSide === 1) {
// 오프셋 미설정: 물리 투영이 종점에 클램프 — 끝점까지의 직선거리로 카운트업
beyondM = pj.distM;
}
out[i] = {
frameNum: f.frame,
km: nearestStationKm(f, stations),
chain: pj.chain,
viewChain,
viewOffM,
offSide,
beyondM,
offM: pj.distM,
time: f.frame / videoFps,
};
}
// 진단: 세그먼트별 적용 확인용 — 중간 프레임 표본의 연직 대비 오프셋(Δ).
const midVp = out[Math.floor(out.length / 2)];
console.log(
`[스테이션바] 배지 오프셋: ${lookModeText} | 표본 Δ${Math.round(midVp.viewChain - midVp.chain) >= 0 ? '+' : ''}${Math.round(midVp.viewChain - midVp.chain)}m (${out.length}행)`,
);
// 이동거리 축(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, routeMeta]);
/** 현재 시간에 가장 가까운 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]);
// 현재 보는 측점값 — station_lookahead 미설정이면 드론 연직 체이니지(기존),
// 설정 시 진행방향 오프셋/시선 중심 지점의 체이니지(viewChain).
const realChain = useMemo<number | null>(() => {
if (!ready) return null;
const idx = viewedIdxAtTime(currentTime);
return idx >= 0 ? viewedRef.current[idx].viewChain : null;
}, [currentTime, ready, viewedIdxAtTime]);
// 배지 표시 지점의 노선 이격(m) — 접근/이탈 구간 '노선밖' 표기용.
// 오프셋 활성 시 '시선이 닿는 지점' 기준이라, 화면이 노선을 비추기 시작하면
// 드론이 아직 노선 밖이어도 배지가 측점값으로 먼저 전환된다.
const realOffM = useMemo<number | null>(() => {
if (!ready) return null;
const idx = viewedIdxAtTime(currentTime);
return idx >= 0 ? viewedRef.current[idx].viewOffM : null;
}, [currentTime, ready, viewedIdxAtTime]);
// 노선밖 위치 구분(-1 시점 이전 / +1 종점 이후 / 0 횡이탈) — 배지 부호 표기용.
const realOffSide = useMemo<number>(() => {
if (!ready) return 0;
const idx = viewedIdxAtTime(currentTime);
return idx >= 0 ? viewedRef.current[idx].offSide : 0;
}, [currentTime, ready, viewedIdxAtTime]);
// 종점 초과 거리(m) — 배지 '노선밖 +NNN' 카운트업.
const realBeyondM = useMemo<number>(() => {
if (!ready) return 0;
const idx = viewedIdxAtTime(currentTime);
return idx >= 0 ? viewedRef.current[idx].beyondM : 0;
}, [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" 로 표기해 측점이 안 변하는 이유를 드러낸다.
// 구간 밖 표기: 종점 이후 = '종점+NNN' 카운트업, 시점 이전 = '시점-NNN'(남은 거리 감소),
// 노선 중간 횡이탈 = '노선밖 NNN'(부호 없음, 3자리 이상이면 m 생략).
const cursorText = (() => {
if (realChain === null || realChain < 0) return '';
// 종점 초과 — 끝값(6+998 등) 고정 대신 종점 기준 증가 표시
if (realBeyondM > 0.5) {
return `종점+${String(Math.round(realBeyondM)).padStart(3, '0')}`;
}
if (realOffM !== null && realOffM > OFF_ROUTE_M) {
const off = Math.round(realOffM);
// 시점-/종점+ 카운트는 '값 표시 전환점'에서 0 이 되도록 진입 여유(OFF_ROUTE_M=60m)를
// 빼고 표시 — 종전엔 -062 에서 곧장 0+000 으로 건너뛰어 보였음(-001 까지 내려간 뒤 전환).
const rem = Math.max(1, off - OFF_ROUTE_M);
if (realOffSide < 0) return `시점-${String(rem).padStart(3, '0')}`;
if (realOffSide > 0) return `종점+${String(rem).padStart(3, '0')}`;
return `노선밖 ${off}${off < 100 ? 'm' : ''}`;
}
return 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 [];
// station_lookahead(시선/오프셋) 활성 여부 — viewChain 이 연직 체이니지와 다르면 활성.
// 활성 시 시설물 마크는 viewChain 기준으로만 배치(물리 폴백 금지).
const lookActive = arr.some((v) => Math.abs(v.viewChain - v.chain) > 1);
// 실좌표(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 (연속 체이니지 기준).
// 배지와 동일한 viewChain(오프셋/시선 기준) 사용 — station_lookahead 설정 시
// 마크(시점·측점 기반 구조물)가 배지 값과 같은 타이밍에 커서와 만난다.
// (미설정이면 viewChain=연직 체이니지 — 기존 동작 그대로)
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 등)에 클램프되어 '시점 통과'로 오인돼 마크가 앞에 찍히는 문제 방지.
if (arr[i].viewOffM > OFF_ROUTE_M) { i++; continue; }
if (Math.abs(arr[i].viewChain - targetM) < TH) {
let j = i, bj = i, bd = Math.abs(arr[i].viewChain - targetM);
while (j < arr.length && arr[j].viewOffM <= OFF_ROUTE_M && Math.abs(arr[j].viewChain - targetM) < TH) {
const d = Math.abs(arr[j].viewChain - 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;
};
// 종점(마지막 측점, 예: 6+998) 마크 — 배지가 노선 끝 값에 도달하는 위치에 표시.
// 이후 구간은 '종점+NNN' 카운트업이므로, 기준점이 되는 통과 위치를 바에 남긴다.
const appendEndMark = (marks: StructMark[]): StructMark[] => {
const valid = stations.filter(st => stationKm(st.title) >= 0);
if (!valid.length || !stationLine) return marks;
const last = valid.reduce((a, b) => (stationKm(a.title) >= stationKm(b.title) ? a : b));
const endM = stationKm(last.title);
for (const p of pxPassesAtMileage(endM)) {
marks.push({ px: p.px, title: '종점', category: '측점', km: endM, 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.viewOffM <= 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) {
// 종점 초과 지점이면 클램프된 끝값(6+998) 대신 '종점+NNN' 로 표기 —
// km 도 초과량을 더해 시작/끝 마크가 서로 100m 중복 제거에 걸리지 않게 한다.
const beyond = e.v.beyondM > 0.5 ? Math.round(e.v.beyondM) : 0;
const ch = Math.round(e.v.viewChain + beyond);
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: ch, kmExists: true,
...(beyond ? { kmText: `종점+${String(beyond).padStart(3, '0')}` } : {}),
});
}
return marks;
};
// 1km(1+000) 단위 측점 마크 — 매 1000m 측점의 통과 위치에 측점값 라벨(위)만 표시.
// title 은 빈 문자열 → 하단 이름 라벨/그룹핑 제외(Timeline 에서 스킵).
// 기존 마크(시점·시설물 등)와 24px 이내로 겹치면 생략(라벨 충돌 방지).
const appendKmStations = (marks: StructMark[]): StructMark[] => {
if (!stationLine) return marks;
let lo = Infinity, hi = -Infinity;
for (const p of stationLine.pts) { if (p.km < lo) lo = p.km; if (p.km > hi) hi = p.km; }
for (let m = Math.ceil(lo / 1000) * 1000; m <= hi; m += 1000) {
for (const p of pxPassesAtMileage(m)) {
if (marks.some((x) => Math.abs(x.px - p.px) < 24)) continue;
marks.push({ px: p.px, title: '', category: '측점', km: m, kmExists: true });
}
}
// 종점 이후 구간 — 1000m 단위 초과 지점 마크(종점+1000, +2000 …).
// beyondM 이 해당 값을 가로지르는 프레임(선형보간)에 측점값 라벨만 표시.
const va = viewedRef.current;
let maxBeyond = 0;
for (const v of va) if (v.beyondM > maxBeyond) maxBeyond = v.beyondM;
for (let bk = 1000; bk <= maxBeyond; bk += 1000) {
for (let i = 1; i < va.length; i++) {
const A = va[i - 1], B = va[i];
if (A.beyondM < bk && B.beyondM >= bk && B.beyondM > A.beyondM) {
const r = (bk - A.beyondM) / (B.beyondM - A.beyondM);
const px = pxAtTime(A.time + (B.time - A.time) * r);
if (px !== null && !marks.some((x) => Math.abs(x.px - px) < 24)) {
marks.push({ px, title: '', category: '측점', km: hi + bk, kmExists: true, kmText: `종점+${bk}` });
}
break;
}
}
}
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 통과에는 정상적으로 찍힌다. (교량/터널만 좌표근접 폴백 허용.)
// ※ station_lookahead(시선/오프셋) 활성 시 물리(드론 연직) 폴백 금지 —
// 구간 밖 시설물(예: 이전 세그먼트에서 이미 바라본 교량)이 물리 통과 위치로
// 끌려 들어와 배지 기준(viewChain 범위)에 없는 값이 바에 찍히는 것을 방지.
// 해당 시설물은 자기 viewChain 범위를 가진 세그먼트의 바에서 정상 표시된다.
if (!lookActive) {
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;
if (lookActive) {
passes = pxPassesAtMileage(mid); // 시선 기준 통과 — 범위 밖이면 미표시
} else {
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 appendKmStations(appendEndpointStations(placeUnreachedTerminal(appendEndMark(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 appendKmStations(appendEndpointStations(placeUnreachedTerminal(appendEndMark(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) => {
// ── 전 구간 검색 — 현재 위치에서 진행방향(시간 증가)으로 순환.
// 통과 = 값을 '가로지르는' 순간(부호 교차, 선형보간). 같은 값 연속 Enter = 다음 통과.
// ① 현재 세그먼트: 스테이션바(배지)가 쓰는 바로 그 데이터(viewedRef)에서 탐지 —
// 착지 시 배지값 == 검색값 (정의상 오차 0).
// ② 다른 세그먼트: 전 구간 CSV 인덱스(djmd dt 보정) — 현 세그 범위는 ①이 대체.
if (globalSearch && onSeekSegment) {
const TOLg = routeMeta?.routeInfo?.stationTolerance ?? 20;
const g = globalSearch;
const offs: number[] = [0];
for (const d of videoDurations) offs.push(offs[offs.length - 1] + (d ?? 0));
const dtC = djmdDtSec; // 영상시간 = CSV시간 dtC
const segBase = offs[videoIndex] ?? 0;
const segEnd = segBase + (videoDurations[videoIndex] ?? 0);
const passes: number[] = []; // 영상 전역 시각(초)
// ① 현재 세그먼트 — 배지 데이터(viewedRef) 교차
const arrV = viewedRef.current;
for (let i2 = 1; i2 < arrV.length; i2++) {
const A = arrV[i2 - 1], B = arrV[i2];
if (A.viewOffM > OFF_ROUTE_M || B.viewOffM > OFF_ROUTE_M) continue;
const da = A.viewChain - km, db = B.viewChain - km;
if (da === 0) passes.push(segBase + A.time);
else if (da * db < 0) {
passes.push(segBase + A.time + ((B.time - A.time) * (km - A.viewChain)) / (B.viewChain - A.viewChain));
}
}
// ② 다른 세그먼트 — CSV 인덱스 교차(dt 보정), 현 세그 범위 제외
for (let gi = 1; gi < g.length; gi++) {
const A = g[gi - 1], B = g[gi];
if (A.off > OFF_ROUTE_M || B.off > OFF_ROUTE_M) continue;
if (B.t - A.t > 1) continue;
const da = A.vc - km, db = B.vc - km;
let tc: number | null = null;
if (da === 0) tc = A.t;
else if (da * db < 0) tc = A.t + ((B.t - A.t) * (km - A.vc)) / (B.vc - A.vc);
if (tc === null) continue;
const tv = tc - dtC;
if (tv >= segBase - 0.5 && tv <= segEnd + 0.5) continue;
passes.push(tv);
}
// 교차가 전혀 없으면 최근접 폴백(현 세그 우선, TOL 이내)
if (!passes.length) {
let bt = -1, bd = TOLg;
for (let i2 = 0; i2 < arrV.length; i2++) {
if (arrV[i2].viewOffM > OFF_ROUTE_M) continue;
const d = Math.abs(arrV[i2].viewChain - km);
if (d < bd) { bd = d; bt = segBase + arrV[i2].time; }
}
if (bt < 0) {
for (let gi = 0; gi < g.length; gi++) {
if (g[gi].off > OFF_ROUTE_M) continue;
const d = Math.abs(g[gi].vc - km);
if (d < bd) { bd = d; bt = g[gi].t - dtC; }
}
}
if (bt < 0) return false;
passes.push(bt);
}
passes.sort((a, b) => a - b);
// 교차 채터(정지·GPS 미세 요동) 병합 — 3초 이내는 한 통과
const merged: number[] = [];
for (const t of passes) if (!merged.length || t - merged[merged.length - 1] > 3) merged.push(t);
console.log(`[검색] ${km}m: 통과 ${merged.length}곳 — ${merged.map((t) => t.toFixed(1) + 's').join(', ')}`);
const liveGlobal = segBase + (timeRef?.current ?? currentTime);
// 같은 값 재Enter = 무조건 '다음 통과'(시크 정착 지연에도 순환 유지)
const sameKm = !!jumpRef.current && Math.abs(jumpRef.current.km - km) < 1e-6;
const baseT = sameKm ? Math.max(jumpRef.current!.time, liveGlobal - 0.25) : liveGlobal;
const target = merged.find((t) => t > baseT + 1e-3) ?? merged[0];
let seg = 0;
while (seg + 1 < videoDurations.length && offs[seg + 1] <= target) seg++;
const segDur = videoDurations[seg];
const local = Math.max(0, Math.min(target - offs[seg], (segDur ?? Infinity) - 0.05));
jumpRef.current = { km, time: target };
if (seg === videoIndex) onSeek(local);
else onSeekSegment(seg, local);
return true;
}
// ── 폴백(전 구간 인덱스 불가: 영상 길이 미측정 등) — 현 세그먼트 배지 데이터에서
// 부호 교차 탐지(위 ① 과 동일 방식). 종전 ±20m '창' 방식은 오차·병합 문제로 제거됨.
const arr = viewedRef.current;
if (!arr.length || duration <= 0) return false;
const lookActive = arr.some((v) => Math.abs(v.viewChain - v.chain) > 1);
const okF = (v: ViewedPoint): boolean => !lookActive || v.viewOffM <= OFF_ROUTE_M;
const TOL = routeMeta?.routeInfo?.stationTolerance ?? 20;
const times: number[] = [];
for (let i = 1; i < arr.length; i++) {
const A = arr[i - 1], B = arr[i];
if (!okF(A) || !okF(B)) continue;
const da = A.viewChain - km, db = B.viewChain - km;
if (da === 0) times.push(A.time);
else if (da * db < 0) {
times.push(A.time + ((B.time - A.time) * (km - A.viewChain)) / (B.viewChain - A.viewChain));
}
}
// 교차 없음 → 최근접 폴백(TOL 이내), 그것도 없으면 '측점 없음'
if (!times.length) {
let bt = -1, bd = TOL;
for (const v of arr) {
if (!okF(v)) continue;
const d = Math.abs(v.viewChain - km);
if (d < bd) { bd = d; bt = v.time; }
}
if (bt < 0) return false;
times.push(bt);
}
times.sort((a, b) => a - b);
const passes: number[] = [];
for (const t of times) if (!passes.length || t - passes[passes.length - 1] > 3) passes.push(t);
const liveT = timeRef?.current ?? currentTime;
// 같은 값 재Enter = 무조건 다음 통과(시크 정착 지연에도 순환 유지), 끝나면 처음으로.
const sameKm = !!jumpRef.current && Math.abs(jumpRef.current.km - km) < 1e-6;
const baseT = sameKm ? Math.max(jumpRef.current!.time, liveT - 0.25) : liveT;
const target = passes.find((t) => t > baseT + 1e-3) ?? passes[0];
jumpRef.current = { km, time: target };
onSeek(clamp(target, 0, duration));
return true;
},
[duration, onSeek, onSeekSegment, routeMeta, currentTime, timeRef, globalSearch, videoDurations, videoIndex],
);
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>
);
}