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 … 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" 로 표기해 측점이 안 변하는 이유를 드러낸다.
  // 구간 밖 표기: 종점 이후 = '종점+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>
  );
}
