/**
 * 지리정보 오버레이
 * 렌더링 최적화:
 *   - 텍스트(측점+POI): 데이터 로드 완료 시 전 프레임 Map 사전 계산 (requestIdleCallback)
 *     params 변경 시 500ms debounce 후 재계산
 *   - 중심선: 드론 프레임 변경 시 renderCacheRef 갱신 (per-frame, 나중에 최적화)
 *   - RAF 루프: Map 조회 + 캐시 읽기만 (계산 없음 → 60fps)
 */

import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react';
import {
  toCameraCoords,
  pixelFromCamera,
  groundPointFromPixel,
  type DroneFrameBasic,
  type CameraParams,
  type CameraCoords,
  DEFAULT_CAMERA_PARAMS,
} from '../../utils/geoProjection';
import { useGeoStore } from '../../store/geoStore';
import { useSettingsStore } from '../../store/settingsStore';
import { Minimap } from './Minimap';
import { MapCompass, type CompassPose } from './MapCompass';
import type { GeoPoint, CenterlinePoint, PoiOverrideMap, RouteStructure } from '../../types/geo';

const VIDEO_FPS = 30000 / 1001;

// 라벨 좌표 튐 방지 — 1프레임에 이 정규화 거리(화면폭/높이 비율) 이상 점프하면 이상치로
// 보고 갱신 무시(이전 위치 유지). 정상 팬 이동은 60fps에서 프레임당 훨씬 작다.
const REJECT_DIST = 0.12;
// 단, 시크/재등장 등 '진짜' 큰 이동에 영구히 갇히지 않도록 연속 거부 한계 후 수용.
const MAX_REJECT_FRAMES = 8;

// POI 라벨 겹침 억제 — 두 마커가 화면상 이 정규거리(가로 X / 세로 Y) 이내면 '겹침'으로 보고
// 드론에 더 가까운 것만 남기고 나머지는 숨긴다. (같은 건물의 여러 업체 등)
const POI_MERGE_X = 0.10;
const POI_MERGE_Y = 0.035;

type DispPos = { x: number; y: number; rej: number; vx: number; vy: number; rx?: number; ry?: number };

// djmd 프레임 동기 데이터일 때 포즈 평활 상한(±프레임). 위치·자세가 이미 매끄러워
// 긴 평활(기본 60 ≈ ±1s)은 회전 시 포즈 지연 → 오버레이 전체가 드론에 '딸려오는' 원인.
const POSE_SMOOTH_SYNCED_MAX = 5;

// 속도 적응형 평활(One Euro 방식) 상수.
//   떨림(노이즈)은 매 프레임 방향이 번갈아 → 평활속도≈0 → 강하게 평활(1배속에서 안정).
//   실제 이동은 방향이 일관 → 평활속도 큼 → alpha↑로 즉시 추종(빠른 배속에서 지연 없음).
const SMOOTH_VEL_BETA = 0.25;   // 속도 추정 평활(저크 제거)

/** 이상치 거부 + 속도적응 EMA.
 *  maxAlpha = 빠를 때 추종 상한(패널 EMA α), minAlpha = 정지/떨림 시 최소 추종(작을수록 강한 평활),
 *  speedRef = 이 속도(정규/프레임) 이상에서 alpha 가 maxAlpha 에 도달(클수록 더 강하게 평활). */
function smoothStep(prev: DispPos | undefined, tx: number, ty: number, maxAlpha: number, minAlpha: number, speedRef: number): DispPos {
  if (!prev) return { x: tx, y: ty, rej: 0, vx: 0, vy: 0 };
  const dx = tx - prev.x, dy = ty - prev.y;
  const d = Math.hypot(dx, dy);
  if (d > REJECT_DIST && prev.rej < MAX_REJECT_FRAMES) {
    return { x: prev.x, y: prev.y, rej: prev.rej + 1, vx: prev.vx, vy: prev.vy }; // 이상치: 위치 유지
  }
  // 속도 벡터 평활 → 떨림(왕복)은 상쇄돼 작게, 실제 이동은 유지.
  const vx = prev.vx + (dx - prev.vx) * SMOOTH_VEL_BETA;
  const vy = prev.vy + (dy - prev.vy) * SMOOTH_VEL_BETA;
  const speed = Math.hypot(vx, vy);
  const a = Math.min(maxAlpha, minAlpha + (maxAlpha - minAlpha) * Math.min(1, speed / Math.max(1e-4, speedRef)));
  return { x: prev.x + dx * a, y: prev.y + dy * a, rej: 0, vx, vy };
}

interface Props {
  currentFrame: number;
  currentTime: number;
  fps: number;
  visible: boolean;
  /** 영상 첫 프레임이 표시 가능해진 후 true. false면 오버레이를 그리지 않음(영상보다 먼저 표시 방지). */
  videoReady?: boolean;
  /** 영상 원본 해상도(px). object-fit:cover 크롭 영역 계산 → 오버레이를 영상에 정렬. 0이면 컨테이너 기준. */
  videoWidth?: number;
  videoHeight?: number;
  /** 영상 빈 곳 클릭(팝업 없을 때) → 재생/정지 토글. Video.js controls:false 라 직접 호출 필요. */
  onTogglePlay?: () => void;
  /** 카메라 파라미터 패널 표시 (영상제어 토글). 기본 true. */
  showPanel?: boolean;
  /** 카메라 파라미터 패널 top(px) — 노선 배너 아래로 배치. 기본 72. */
  topPx?: number;
  /** 하단 스테이션바 높이(px) — 우측 버튼을 좌측 패널과 같은 높이(bottom)에 맞추기 위함. */
  barHeight?: number;
  /** 부드러운 단조보간 재생시간(초) ref. VideoPlayer smoothTimeRef 전달 시
   *  라벨이 60fps로 매끄럽게 이동(일시정지·시크·배속 보정 포함). 없으면 prop 기반 추정. */
  timeRef?: React.MutableRefObject<number>;
}

// category → 이모지
const CATEGORY_EMOJI: Record<string, string> = {
  '터널': '🚇',
  '교량': '🌉',
  '역사': '🚉',
  '철도역': '🚉',
  '지장물': '🏢',
  '지장물L': '🏢',
  '측점': '📍',
  '구교': '🌉',
  '출입문': '🚪',
  '교차로': '🚦',
  '도로': '🛣️',
  '강.하천': '🌊',
  '마을': '🏘️',
  '산': '⛰️',
};

/** 라벨 색 밝기 판정 — 어두운 색이면 외곽선을 밝게 반전(가독성). */
function isDarkLabelColor(hex: string): boolean {
  const m = hex.match(/^#([0-9a-f]{6})$/i);
  if (!m) return false;
  const n = parseInt(m[1], 16);
  const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
  return 0.299 * r + 0.587 * g + 0.114 * b < 96;
}

// 영상 오버레이엔 모든 선로 구조물(교량/터널/구교)을 라벨로 표출한다.
// 시설등급(시설종별) 표시 필터는 하단 스테이션바·RoutePanel 전용 → 영상은 항상 전부 표출.
// (역사=철도역은 type='station'이라 여기 미포함 → 스테이션바 전용 유지.)
const OVERLAY_STRUCT_CATEGORIES = new Set(['구교', '교량', '터널']);

// 화면표시 옵션 기본값 — 우측 하단 "기본값" 버튼으로 일괄 복귀. (useState 초기값과 단일 출처 공유.)
const DISPLAY_DEFAULTS = {
  showCenterline: true,
  showDronePath: true,
  dronePathZ: 58,
  dronePathAlpha: 0.75,
  smoothHalf: 60,
  emaAlpha: 1.0,
  smoothMinAlpha: 0.12,  // 정지/떨림 시 최소 추종(작을수록 강한 평활)
  smoothSpeedRef: 0.010, // 즉시추종 기준 속도(클수록 더 강하게 평활)
  maxPoiRange: 1000,
  // false = 지면고도 기준(POI를 지면고도 z에 고정 → gap=드론고도−지면고도 자동변동, 드론 오르내려도 지물에 붙음).
  //         표고 미상(z=0) 라벨은 '경로 표고' 평면에 앵커 → 궤적과 같은 슬라이더로 함께 보정됨.
  // true = 드론−이격거리 고정(gap 상수). 드론 고도 변동 시 부정확.
  // 기본 false: 실측 z(회덕)는 지물에 정확히 붙고, 미상(제주)은 경로표고로 일괄 보정 가능.
  poiDroneHeight: false,
  droneHeightDrop: 24,
};

// 텍스트 사전 계산 캐시 (Map<frameNum, LabelCache>)
// 라벨 가시집합(프레임별) — 화면좌표가 아니라 '월드좌표'를 저장한다. RAF 가 매 프레임 연속
// 포즈(poseAt)로 직접 투영 → 라인처럼 부드럽게 이동. (precompute = 가시성/겹침 결정 전용)
interface LabelCache {
  stationLabels: { title: string; lat: number; lon: number; z: number }[];
  poiMarkers: { title: string; label: string; category: string; lat: number; lon: number; gz: number; compact: { k: string; v: string }[]; labelRow: number; labelRowCount: number; color?: string }[];
}

// 중심선 + 나침반 렌더 캐시 (per-frame, renderCacheRef)
interface RenderCache {
  // 선/궤적은 RAF에서 매 프레임 보간 포즈로 직접 투영(부드러운 갱신) → 여기 미보관.
  effectiveYaw: number;
  hFovRad: number;
  clCount: number;
  poiCount: number;
}

const cleanTitle = (t: string) => t.replace(/\s*\([상하]\)\s*$/, '').trim();

/** 구조물 base 이름(괄호 변형 제거): '회덕제1가도교(하)' → '회덕제1가도교'. 상/하 형제 판정용. */
const baseStruct = (t: string): string => t.replace(/\s*[(（].*$/, '').trim();

/** 속성에 '구분' 데이터가 있으면 그 값을 라벨로 사용(없으면 undefined). */
const guboonOf = (props?: { k: string; v: string }[]): string | undefined => {
  const v = props?.find((p) => p.k === '구분')?.v;
  return v && v.trim() ? v.trim() : undefined;
};

/**
 * 컴팩트 팝업용 기본 3필드: 시설종별 / 구조형식(상부구조형식) / 연장(m).
 * 존재하는 것만, 이 순서로 반환. 라벨 옆 항상표시용(클릭 시 전체 팝업).
 */
const compactFieldsOf = (props?: { k: string; v: string }[]): { k: string; v: string }[] => {
  if (!props) return [];
  const out: { k: string; v: string }[] = [];
  const pick = (label: string, pred: (k: string) => boolean): void => {
    const f = props.find((p) => pred(p.k));
    if (f?.v?.trim()) out.push({ k: label, v: f.v.trim() });
  };
  pick('시설종별', (k) => k === '시설종별');
  pick('구조형식', (k) => /구조형식/.test(k));
  pick('연장(m)', (k) => /연장/.test(k));
  pick('폭(m)', (k) => /폭/.test(k));
  pick('용도', (k) => /용도/.test(k));
  pick('준공연도', (k) => /준공/.test(k));
  return out;
};

/** 컴팩트에 들어가는 키인지(전체 보기에서 중복 제거 + 순서 정렬용). */
const isCompactKey = (k: string): boolean =>
  k === '시설종별' || /구조형식/.test(k) || /연장/.test(k) || /폭/.test(k) || /용도/.test(k) || /준공/.test(k);

/** 전체 보기 필드 순서 = 컴팩트(정해진 순서) 먼저, 그 뒤 나머지 속성(원본 순서). 클릭 전후 순서 일치. */
const orderedFullFields = (
  compact: { k: string; v: string }[],
  props: { k: string; v: string }[],
): { k: string; v: string }[] => [...compact, ...props.filter((p) => !isCompactKey(p.k))];

function stationOrder(title: string): number {
  const m = title.match(/(\d+)\s*[Kk+]\s*(\d+)/);
  if (!m) return 0;
  return parseInt(m[1]) * 1000 + parseInt(m[2]);
}

// ── ParamRow ─────────────────────────────────────────────────────────────────

// 라벨 옆 작은 ⓘ — 마우스 오버 시 네이티브 툴팁으로 설명 표시(세로 공간 절약).
function InfoTip({ text }: { text: string }) {
  return (
    <span title={text} className="ml-1 cursor-help text-gray-600 hover:text-gray-300 align-middle">ⓘ</span>
  );
}

interface ParamRowProps {
  label: string; value: number; min: number; max: number; step: number;
  unit: string; decimals?: number; onChange: (v: number) => void;
  /** 있으면 라벨에 점선 밑줄+마우스오버 툴팁(설명). 별도 설명 div 대체 → 패널 높이 절약. */
  tip?: string;
}

function ParamRow({ label, value, min, max, step, unit, decimals = 1, onChange, tip }: ParamRowProps) {
  const fmt = useCallback((v: number) => v.toFixed(decimals), [decimals]);
  const [text, setText] = useState(() => fmt(value));
  const prevRef = useRef(value);
  useEffect(() => {
    if (prevRef.current !== value) { prevRef.current = value; setText(fmt(value)); }
  }, [value, fmt]);
  const commit = (s: string) => {
    const n = parseFloat(s);
    if (!isNaN(n)) {
      const c = Math.max(min, Math.min(max, n));
      onChange(c); setText(fmt(c)); prevRef.current = c;
    } else setText(fmt(value));
  };
  // 마우스 휠: 위 = +step, 아래 = −step (예: 카메라 파라미터 0.1 단위).
  // 슬라이더와 숫자 값 입력칸 양쪽 위에서 동작. 패널 스크롤을 막아야 하므로
  // non-passive 네이티브 리스너로 부착(React onWheel 은 passive).
  const sliderRef = useRef<HTMLInputElement>(null);
  const numRef = useRef<HTMLInputElement>(null);
  const wheelStateRef = useRef({ value, min, max, step, decimals, onChange });
  useEffect(() => { wheelStateRef.current = { value, min, max, step, decimals, onChange }; });
  useEffect(() => {
    const els = [sliderRef.current, numRef.current].filter((el): el is HTMLInputElement => !!el);
    if (!els.length) return;
    const onWheel = (e: WheelEvent): void => {
      e.preventDefault();
      e.stopPropagation();
      const s = wheelStateRef.current;
      const dir = e.deltaY < 0 ? 1 : -1;
      // 부동소수점 누적 오차 방지 — 표시 자릿수로 반올림
      const next = parseFloat(
        Math.max(s.min, Math.min(s.max, s.value + dir * s.step)).toFixed(Math.max(s.decimals, 3)),
      );
      if (next !== s.value) s.onChange(next);
    };
    els.forEach(el => el.addEventListener('wheel', onWheel, { passive: false }));
    return () => els.forEach(el => el.removeEventListener('wheel', onWheel));
  }, []);
  return (
    <div className="flex items-center gap-1.5 text-[11px]">
      <span
        title={tip}
        className={`text-gray-400 w-12 shrink-0 text-right ${tip ? 'cursor-help underline decoration-dotted decoration-gray-600 underline-offset-2' : ''}`}
      >{label}</span>
      <input ref={sliderRef} type="range" min={min} max={max} step={step} value={value}
        onChange={e => { const v = parseFloat(e.target.value); onChange(v); prevRef.current = v; setText(fmt(v)); }}
        className="flex-1 h-1 accent-yellow-400 cursor-pointer" />
      <input ref={numRef} type="number" min={min} max={max} step={step} value={text}
        onChange={e => setText(e.target.value)}
        onBlur={e => commit(e.target.value)}
        onKeyDown={e => { if (e.key === 'Enter') commit((e.target as HTMLInputElement).value); if (e.key === 'Escape') setText(fmt(value)); }}
        className="w-16 bg-black/60 border border-gray-700 rounded px-1 py-0.5 text-right font-mono text-yellow-300 text-[11px] [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" />
      <span className="text-gray-500 text-[10px] w-5 shrink-0">{unit}</span>
    </div>
  );
}

// ── 메인 컴포넌트 ─────────────────────────────────────────────────────────────

export default function StationOverlay({ currentFrame, currentTime, fps, visible, videoReady = true, videoWidth = 0, videoHeight = 0, onTogglePlay, showPanel = true, topPx = 72, barHeight = 0, timeRef }: Props) {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const canvasSizeRef = useRef({ w: 0, h: 0 });

  // 데이터 ref
  const allDroneFramesRef = useRef<DroneFrameBasic[]>([]);
  const allCenterlinePointsRef = useRef<CenterlinePoint[]>([]);
  const allGeoStationsRef = useRef<GeoPoint[]>([]);
  const allPoisRef = useRef<GeoPoint[]>([]);
  const allStructuresRef = useRef<GeoPoint[]>([]);  // 교량/터널/구교 → POI처럼 표시

  // 현재 상태 ref
  const currentDroneFrameRef = useRef<DroneFrameBasic | null>(null);
  const currentFrameNumRef = useRef<number>(0);       // RAF에서 Map 조회용
  const currentFrameIdxRef = useRef<number>(0);       // smoothFrame용 배열 인덱스
  const currentTimeSecRef = useRef<number>(0);        // 마지막으로 알려진 재생 시간
  const timeUpdateWallRef = useRef<number>(performance.now()); // currentTime 갱신된 시각
  const paramsRef = useRef<CameraParams>(DEFAULT_CAMERA_PARAMS);
  const visibleRef = useRef(visible);
  const videoReadyRef = useRef(videoReady);
  const videoSizeRef = useRef({ w: videoWidth, h: videoHeight });
  const barHeightRef = useRef(barHeight);  // 하단 스테이션바 높이(px) — 팝업/라벨 유효 하단 = H − barHeight
  useEffect(() => { barHeightRef.current = barHeight; }, [barHeight]);
  // 영상 실제 fps — 부모(VideoPlayer)가 데이터(마지막 프레임 번호)+영상길이로 계산해 prop 으로 전달.
  //   프레임번호↔시간 변환의 기준(고정 29.97 대신 영상별 자동). 0/미정이면 VIDEO_FPS 폴백.
  const fpsRef = useRef(fps > 0 ? fps : VIDEO_FPS);
  useEffect(() => { if (fps > 0) fpsRef.current = fps; }, [fps]);
  // object-fit:cover 변환 (RAF에서 매 프레임 갱신, 포인터 핸들러도 참조). 정규(0~1, 영상프레임) → 화면 px.
  const coverRef = useRef({ offX: 0, offY: 0, dispW: 0, dispH: 0, W: 0, H: 0 });
  const worldOriginRef = useRef<{ lat: number; lon: number; alt: number } | undefined>(undefined);

  // 텍스트 사전 계산 Map
  const labelMapRef = useRef<Map<number, LabelCache>>(new Map());
  const precomputeIdRef = useRef(0);  // 진행 중 계산 취소용
  // 캐시 키(드론 프레임 번호, 오름차순) — RAF 가 최근접 키를 이진탐색으로 조회.
  const labelKeysRef = useRef<number[]>([]);
  // 직전 사전계산의 frames 참조 — 바뀌면(세그먼트 전환) 옛 캐시를 복사하지 않고 새로 시작.
  const lastPrecomputeFramesRef = useRef<DroneFrameBasic[] | null>(null);

  // 중심선 + 나침반 렌더 캐시 (per-frame)
  const renderCacheRef = useRef<RenderCache | null>(null);
  // 나침반 전용(측점선 visible 무관 항상 갱신·표시)
  const compassRef = useRef<{ effectiveYaw: number; hFovRad: number } | null>(null);
  // 나침반 미니맵(DOM) root — RAF 에서 --rot(=-yaw) 갱신. minimapRotRef=누적 회전(360° 언랩).
  const minimapRef = useRef<HTMLDivElement>(null);
  const minimapRotRef = useRef(0);
  // 지도 나침반용 현재 위치/방위 — RAF 가 매 프레임 갱신, MapCompass 가 읽음.
  const mapPoseRef = useRef<CompassPose>({ lat: 0, lon: 0, yaw: 0 });

  // UI state
  const [params, setParams] = useState<CameraParams>(DEFAULT_CAMERA_PARAMS);
  const [smoothHalf, setSmoothHalf] = useState(DISPLAY_DEFAULTS.smoothHalf);
  const smoothHalfRef = useRef(10);
  // 화면 EMA — 0.01은 과평활(지연 ~1.65s)로 카메라 이동 시 라벨이 늦게 따라옴.
  // 입력은 이미 smoothFrame(±10fr)으로 평활되므로 화면 EMA는 가볍게(0.4, 지연 ~25ms).
  const [emaAlpha, setEmaAlpha] = useState(DISPLAY_DEFAULTS.emaAlpha);
  const emaAlphaRef = useRef(0.4);
  // 속도적응 평활 — 떨림 억제 강도/기준속도(라벨·팝업 1배속 떨림 튜닝).
  const [smoothMinAlpha, setSmoothMinAlpha] = useState(DISPLAY_DEFAULTS.smoothMinAlpha);
  const smoothMinAlphaRef = useRef(DISPLAY_DEFAULTS.smoothMinAlpha);
  const [smoothSpeedRef, setSmoothSpeedRef] = useState(DISPLAY_DEFAULTS.smoothSpeedRef);
  const smoothSpeedRefRef = useRef(DISPLAY_DEFAULTS.smoothSpeedRef);
  // POI 표시 범위(m) — 드론 위치와의 수평 직선거리(distH, 고도차 무시). 이 범위 안이면 표시,
  //   밖이면 제외. (이전: 진행방향 앞/옆 비등방 → 단일 반경으로 통합.) 패널에서 실시간 조절.
  const [maxPoiRange, setMaxPoiRange] = useState(DISPLAY_DEFAULTS.maxPoiRange);
  const maxPoiRangeRef = useRef(DISPLAY_DEFAULTS.maxPoiRange);
  // 비교 테스트: POI 높이를 선로표고(false) 대신 '드론 고도 − N미터'(true)로. N=droneHeightDrop.
  const [poiDroneHeight, setPoiDroneHeight] = useState(DISPLAY_DEFAULTS.poiDroneHeight);
  const [droneHeightDrop, setDroneHeightDrop] = useState(DISPLAY_DEFAULTS.droneHeightDrop);
  // 드론 경로 가시화 — 드론의 GPS 경로(lat/lon)를 지정 표고 z 에 투영해 선으로 표시.
  // 선형/드론궤적 토글은 설정 스토어로 공유 → VideoPlayer 하단 바 버튼에서도 제어.
  const showDronePath = useSettingsStore(s => s.showDronePath);
  const setShowDronePath = useSettingsStore(s => s.setShowDronePath);
  const [dronePathZ, setDronePathZ] = useState(DISPLAY_DEFAULTS.dronePathZ);   // 기본 경로 표고
  const showDronePathRef = useRef(true);
  const dronePathZRef = useRef(78);
  useEffect(() => { showDronePathRef.current = showDronePath; }, [showDronePath]);
  useEffect(() => { dronePathZRef.current = dronePathZ; }, [dronePathZ]);
  // 선형(중심선) 독립 토글 + 드론경로 투명도(0~1) — 토글은 설정 스토어 공유(바 버튼 제어).
  const showCenterline = useSettingsStore(s => s.showCenterline);
  const setShowCenterline = useSettingsStore(s => s.setShowCenterline);
  const showCenterlineRef = useRef(true);
  useEffect(() => { showCenterlineRef.current = showCenterline; }, [showCenterline]);
  const [dronePathAlpha, setDronePathAlpha] = useState(DISPLAY_DEFAULTS.dronePathAlpha);
  const dronePathAlphaRef = useRef(0.75);
  useEffect(() => { dronePathAlphaRef.current = dronePathAlpha; }, [dronePathAlpha]);
  // 측점 진단 토글 버튼은 삭제됨. (HUD 표시는 VideoPlayer 에서 showStationDiag 구독)
  // POI 위치 편집(드래그) 모드
  const [editMode, setEditMode] = useState(false);
  const editModeRef = useRef(false);
  // FOV(초점) 보정 모드: POI 라벨을 실제 위치로 끌면 그 대응점으로 focal 을 역산.
  const [fovMode, setFovMode] = useState(false);
  const fovModeRef = useRef(false);
  // Yaw 보정 모드: POI 라벨을 실제 위치(좌우)로 끌면 필요한 Yaw± 를 역산.
  const [yawMode, setYawMode] = useState(false);
  const yawModeRef = useRef(false);
  // 라벨 속성 팝업(다중) — 항상 활성(모드 버튼 없음). 라벨 위에서만 캔버스가 입력을 받아 클릭→팝업.
  //   id=`${kind}:${title}`. 위치는 RAF 가 라벨 displayed 좌표로 매 프레임 갱신(재생 중 따라 이동).
  type InfoPopup = { id: string; kind: 'poi' | 'station'; title: string; category: string; lat: number; lon: number; z: number; dist: number | null; sx: number; sy: number; props?: { k: string; v: string }[];
    /** 컴팩트 기본필드(시설종별/구조형식/연장/용도/준공연도). 있으면 라벨 옆 자동표시. */
    compact?: { k: string; v: string }[];
    /** true=전체 속성, false/undefined=컴팩트. 팝업 클릭으로 토글. */
    expanded?: boolean;
    /** true=가시 구조물에 따라 자동생성/제거(✕ 없음). */
    auto?: boolean };
  const [infoPopups, setInfoPopups] = useState<InfoPopup[]>([]);
  const infoPopupsRef = useRef<InfoPopup[]>([]);
  useEffect(() => {
    infoPopupsRef.current = infoPopups;
    // 팝업 제거 경로가 여럿(RAF off-4프레임 / sync toRemoveIds / 수동 ✕·ESC·빈곳클릭)인데
    //   보조 맵 정리는 RAF 경로만 했었다 → sync/수동 제거 시 popupPosRef 잔존. 그러면 재추가 때
    //   hidden-mount 가드(!popupPosRef.has)가 무력화돼 라벨 없이 팝업만 stale 위치에 번쩍인다.
    //   → infoPopups 변경 시 사라진 id 의 보조 맵을 일괄 정리(단일 출처). (popupElsRef 는 ref 콜백이 정리.)
    const ids = new Set(infoPopups.map(p => p.id));
    for (const id of popupPosRef.current.keys()) if (!ids.has(id)) popupPosRef.current.delete(id);
    for (const id of popupMissRef.current.keys()) if (!ids.has(id)) popupMissRef.current.delete(id);
    for (const id of popupFlipRef.current.keys()) if (!ids.has(id)) popupFlipRef.current.delete(id);
  }, [infoPopups]);
  // 영상 진행 방향('상'|'하') — 겹치는 상/하 형제 구조물 중 이 방향을 우선 표시. (값 주입은 아래 routeMeta 구독 후)
  const routeDirRef = useRef<'상' | '하' | null>(null);
  // 이번 프레임 가시 컴팩트-구조물 (title → {category, compact}). RAF 가 채우고 sync 인터벌이 읽음.
  const visStructRef = useRef<Map<string, { category: string; compact: { k: string; v: string }[] }>>(new Map());
  const popupElsRef = useRef<Map<string, HTMLDivElement>>(new Map());
  const popupMissRef = useRef<Map<string, number>>(new Map());  // 라벨이 화면 밖인 연속 프레임 수(제거 유예)
  const popupPosRef = useRef<Map<string, { x: number; y: number }>>(new Map());  // 팝업 위치 EMA(물결 방지)
  const popupFlipRef = useRef<Map<string, 'above' | 'below'>>(new Map());  // 팝업 배치면(위/아래) — 히스테리시스로 경계 왕복(번쩍임) 방지
  // 라벨 히트박스(아이콘+글자 영역, 화면 px) — RAF 가 매 프레임 갱신, 클릭/호버 히트테스트에 사용.
  const labelHitRef = useRef<{ kind: 'poi' | 'station'; title: string; x0: number; y0: number; x1: number; y1: number }[]>([]);
  const hasPopupRef = useRef(false);
  const onTogglePlayRef = useRef<(() => void) | undefined>(undefined);
  const poiDroneHeightRef = useRef(DISPLAY_DEFAULTS.poiDroneHeight);
  const droneHeightDropRef = useRef(DISPLAY_DEFAULTS.droneHeightDrop);
  const overridesRef = useRef<PoiOverrideMap>({});
  // POI 라벨이 연속으로 화면 안에 그려진 프레임 수(title→streak). 팝업 '추가'에 히스테리시스를 줘
  //   경계에서 라벨이 미세하게 깜빡일 때(1~2프레임) 팝업이 재생성돼 번쩍이는 것을 막는다.
  const poiOnScreenStreakRef = useRef<Map<string, number>>(new Map());
  // 드래그 상태: 선택 POI + lat/lon(고정) + 시작 z + 시작 화면좌표. 드롭 시 세로위치로 z 역산.
  const dragRef = useRef<{ title: string; lat: number; lon: number; z0: number; sx: number; sy: number } | null>(null);
  const dragPosRef = useRef<{ x: number; y: number } | null>(null);
  const [dragTitle, setDragTitle] = useState<string | null>(null);
  const [demBusy, setDemBusy] = useState(false);
  // 선택된 POI의 표고 직접 조절 (슬라이더). selZBase=선로표고(슬라이더 범위 기준).
  const [selPoi, setSelPoi] = useState<string | null>(null);
  const [selZ, setSelZ] = useState(0);
  const [selZBase, setSelZBase] = useState(42);
  // 표시 위치 EMA 상태 (RAF 내부 유지). rej = 연속 이상치 거부 횟수.
  const displayedStRef = useRef<Map<string, DispPos>>(new Map());
  const displayedPoiRef = useRef<Map<string, DispPos>>(new Map());
  const [showControls, setShowControls] = useState(false);
  const [showDisplay, setShowDisplay] = useState(false);
  const [droneFramesLoaded, setDroneFramesLoaded] = useState(false);
  const [geoDataLoaded, setGeoDataLoaded] = useState(false);
  const [clDataLoaded, setClDataLoaded] = useState(false);
  const [panelDroneFrame, setPanelDroneFrame] = useState<DroneFrameBasic | null>(null);

  useEffect(() => { paramsRef.current = params; }, [params]);
  useEffect(() => { visibleRef.current = visible; }, [visible]);
  useEffect(() => { videoReadyRef.current = videoReady; }, [videoReady]);
  useEffect(() => { videoSizeRef.current = { w: videoWidth, h: videoHeight }; }, [videoWidth, videoHeight]);
  useEffect(() => { smoothHalfRef.current = smoothHalf; }, [smoothHalf]);
  useEffect(() => { emaAlphaRef.current = emaAlpha; }, [emaAlpha]);
  useEffect(() => { smoothMinAlphaRef.current = smoothMinAlpha; }, [smoothMinAlpha]);
  useEffect(() => { smoothSpeedRefRef.current = smoothSpeedRef; }, [smoothSpeedRef]);
  useEffect(() => { maxPoiRangeRef.current = maxPoiRange; }, [maxPoiRange]);
  useEffect(() => { editModeRef.current = editMode; }, [editMode]);
  useEffect(() => { fovModeRef.current = fovMode; }, [fovMode]);
  useEffect(() => { yawModeRef.current = yawMode; }, [yawMode]);

  // 컴팩트 자동 팝업 동기화 — visStructRef(연속 온스크린 라벨) 마다 라벨 옆 DOM 팝업을 자동 '생성'만.
  //   제거는 RAF 가 단일 권한(onScreenLabels + 4프레임 유예)으로 담당 → add/remove 충돌(번쩍임) 제거.
  // (캔버스 텍스트는 흐려서 DOM 팝업으로 선명하게. 위치는 RAF 가 displayed 좌표로 매 프레임 갱신.)
  // 60fps 루프에서 setState 남발 방지 → 150ms 인터벌로 가시집합 변화시에만 갱신.
  useEffect(() => {
    const tick = (): void => {
      const vis = visStructRef.current;
      const cur = infoPopupsRef.current;
      const c = coverRef.current;
      const toAdd: InfoPopup[] = [];
      vis.forEach((info, title) => {
        const id = `poi:${title}`;
        if (cur.some(p => p.id === id)) return;
        const disp = displayedPoiRef.current.get(title);
        if (!disp) return;
        const obj = allStructuresRef.current.concat(allPoisRef.current).find(p => p.title === title);
        toAdd.push({
          id, kind: 'poi', title, category: info.category,
          lat: obj?.lat ?? 0, lon: obj?.lon ?? 0, z: 0, dist: null,
          sx: c.offX + disp.x * c.dispW, sy: c.offY + disp.y * c.dispH,
          props: obj?.props, compact: info.compact, auto: true, expanded: false,
        });
      });
      if (toAdd.length) {
        setInfoPopups(prev => {
          const ids = new Set(prev.map(p => p.id));
          return [...prev, ...toAdd.filter(p => !ids.has(p.id))];
        });
      }
    };
    const iv = setInterval(tick, 150);
    return () => clearInterval(iv);
  }, []);

  // ESC → 클릭으로 연/펼친 팝업만 닫기(수동 팝업 제거 + 펼친 것 컴팩트로 접기).
  //   기본 자동 컴팩트 팝업은 유지(닫지 않음).
  useEffect(() => {
    const onKey = (e: KeyboardEvent): void => {
      if (e.key !== 'Escape') return;
      if (infoPopupsRef.current.some(p => !p.auto || p.expanded)) {
        setInfoPopups(prev => prev.filter(p => p.auto).map(p => ({ ...p, expanded: false })));
      }
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, []);
  useEffect(() => { hasPopupRef.current = infoPopups.length > 0; }, [infoPopups]);
  useEffect(() => { onTogglePlayRef.current = onTogglePlay; }, [onTogglePlay]);
  useEffect(() => { poiDroneHeightRef.current = poiDroneHeight; }, [poiDroneHeight]);
  useEffect(() => { droneHeightDropRef.current = droneHeightDrop; }, [droneHeightDrop]);

  const setParam = useCallback(<K extends keyof CameraParams>(key: K, val: CameraParams[K]) =>
    setParams(prev => ({ ...prev, [key]: val })), []);

  const nearestCL = useCallback((lat: number, lon: number): CenterlinePoint | null => {
    const pts = allCenterlinePointsRef.current;
    if (!pts.length) return null;
    let best = pts[0], bestD = (best.lat - lat) ** 2 + (best.lon - lon) ** 2;
    for (const pt of pts) {
      const d = (pt.lat - lat) ** 2 + (pt.lon - lon) ** 2;
      if (d < bestD) { bestD = d; best = pt; }
    }
    return best;
  }, []);

  /** 중심선 '세그먼트 투영' 최근접점 — 정점 스냅이 아니라 선 위의 정확한 위치.
   *  LineString 중심선(예: 제주 146정점)은 직선 구간 정점이 성겨서, 정점 스냅(nearestCL)을
   *  측점 라벨에 쓰면 0k200~0k700 같은 직선 구간 측점들이 굽이 정점으로 뭉쳐 버린다. */
  const projectCL = useCallback((lat: number, lon: number): CenterlinePoint | null => {
    const pts = allCenterlinePointsRef.current;
    if (pts.length < 2) return nearestCL(lat, lon);
    const k = Math.cos((lat * Math.PI) / 180);
    const px = lon * k, py = lat;
    let bestD = Infinity, bl = pts[0].lat, bo = pts[0].lon, bz = pts[0].z;
    for (let i = 0; i < pts.length - 1; i++) {
      const a = pts[i], b = pts[i + 1];
      const ax = a.lon * k, ay = a.lat, bx = b.lon * k, by = b.lat;
      const dx = bx - ax, dy = by - ay;
      const L2 = dx * dx + dy * dy;
      const t = L2 === 0 ? 0 : Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / L2));
      const cx = ax + dx * t, cy = ay + dy * t;
      const d = (px - cx) ** 2 + (py - cy) ** 2;
      if (d < bestD) { bestD = d; bl = cy; bo = cx / k; bz = a.z + (b.z - a.z) * t; }
    }
    return { lat: bl, lon: bo, z: bz };
  }, [nearestCL]);

  // 클라이언트 지리정보 스토어 구독 (서버 /api/geo/* 대체)
  const storeStations = useGeoStore(s => s.stations);
  const storePois = useGeoStore(s => s.pois);
  const storeCenterline = useGeoStore(s => s.centerline);
  const storeFrames = useGeoStore(s => s.frames);
  const storeFrameSynced = useGeoStore(s => s.frameSynced);
  const frameSyncedRef = useRef(false);
  useEffect(() => { frameSyncedRef.current = storeFrameSynced; }, [storeFrameSynced]);
  // 포즈 평활 유효 반폭 — 프레임 동기(djmd) 데이터는 상한 제한(회전 시 포즈 지연 제거).
  const poseHalf = useCallback((): number =>
    frameSyncedRef.current ? Math.min(smoothHalfRef.current, POSE_SMOOTH_SYNCED_MAX) : smoothHalfRef.current,
  []);
  const storeOrigin = useGeoStore(s => s.origin);
  const storeStructures = useGeoStore(s => s.structures);
  const routeMeta = useGeoStore(s => s.routeMeta);
  useEffect(() => {
    const d = routeMeta?.routeInfo?.direction ?? '';
    routeDirRef.current = d.includes('하') ? '하' : d.includes('상') ? '상' : null;
  }, [routeMeta]);
  // POI 겹침제외 토글(설정 스토어). false면 겹침 억제 없이 모든 POI 표시.
  const compassType = useSettingsStore(s => s.compassType);
  const setCompassType = useSettingsStore(s => s.setCompassType);
  const compassMapStyle = useSettingsStore(s => s.compassMapStyle);
  const setCompassMapStyle = useSettingsStore(s => s.setCompassMapStyle);
  const poiOverlapExclude = useSettingsStore(s => s.poiOverlapExclude);
  const overlapExcludeRef = useRef(true);
  useEffect(() => { overlapExcludeRef.current = poiOverlapExclude; }, [poiOverlapExclude]);
  // 좌측 노선 패널(RoutePanel) 표시 토글은 하단 재생바(VideoPlayer)로 이동.
  const storeBaseName = useGeoStore(s => s.baseName);
  const storeCameraInfo = useGeoStore(s => s.cameraInfo);
  const storeCameraJson = useGeoStore(s => s.cameraJson);
  const storeDisplayJson = useGeoStore(s => s.displayJson);
  const storePoiOverrides = useGeoStore(s => s.poiOverrides);
  const setPoiOverride = useGeoStore(s => s.setPoiOverride);
  const setPoiOverrides = useGeoStore(s => s.setPoiOverrides);
  const clearPoiOverride = useGeoStore(s => s.clearPoiOverride);
  // 경로 표고 슬라이더 상한 — 데이터 최대 표고 + 여유 (없으면 60 폴백).
  // 중심선이 없거나 z=0 인 데이터셋(예: 제주 DJI 로그)은 드론 고도(ASL)가 유일한 상한 근거 —
  // 지면은 드론보다 낮으므로 max(드론고도)까지 열어두면 어떤 지형이든 슬라이더로 도달 가능.
  const pathZMax = useMemo(() => {
    let m = 0;
    for (const c of storeCenterline) if (typeof c.z === 'number' && c.z > m) m = c.z;
    for (const f of storeFrames) if (typeof f.altitude === 'number' && f.altitude > m) m = f.altitude;
    return m > 0 ? Math.ceil(m + 10) : 60;
  }, [storeCenterline, storeFrames]);
  useEffect(() => { overridesRef.current = storePoiOverrides; }, [storePoiOverrides]);

  // ── 보정값 영속화 (baseName별 localStorage) ──────────────────────────────
  // 카메라 각도/내부표정(Yaw±/Pitch±/focal 등) + 표시설정을 데이터셋별로 저장·복원.
  // 한 번 튜닝하면 새로고침/재로드해도 유지 → "처음부터 맞게" 표시.
  const calibLoadedFor = useRef<string | null>(null);
  // 이 데이터셋에 저장된 보정값이 있었는지 — 카메라 감지값 자동 적용 여부 판단(사용자 튜닝 우선).
  const calibHadSavedRef = useRef(false);
  useEffect(() => {
    if (!storeBaseName || calibLoadedFor.current === storeBaseName) return;
    calibLoadedFor.current = storeBaseName;
    try {
      const raw = localStorage.getItem(`ghivideo:calib:${storeBaseName}`);
      calibHadSavedRef.current = !!raw;
      if (!raw) return;
      const c = JSON.parse(raw);
      if (c.params && typeof c.params === 'object') setParams(p => ({ ...p, ...c.params }));
      // maxPoiRange 우선, 없으면 구버전(maxPoiFront) 값으로 마이그레이션.
      if (typeof c.maxPoiRange === 'number') setMaxPoiRange(c.maxPoiRange);
      else if (typeof c.maxPoiFront === 'number') setMaxPoiRange(c.maxPoiFront);
      if (typeof c.smoothHalf === 'number') setSmoothHalf(c.smoothHalf);
      if (typeof c.emaAlpha === 'number') setEmaAlpha(c.emaAlpha);
      if (typeof c.smoothMinAlpha === 'number') setSmoothMinAlpha(c.smoothMinAlpha);
      if (typeof c.smoothSpeedRef === 'number') setSmoothSpeedRef(c.smoothSpeedRef);
    } catch { /* noop */ }
  }, [storeBaseName]);
  useEffect(() => {
    if (!storeBaseName) return;
    const t = setTimeout(() => {
      try {
        localStorage.setItem(`ghivideo:calib:${storeBaseName}`, JSON.stringify({
          params, maxPoiRange, smoothHalf, emaAlpha, smoothMinAlpha, smoothSpeedRef,
        }));
      } catch { /* noop */ }
    }, 800);
    return () => clearTimeout(t);
  }, [storeBaseName, params, maxPoiRange, smoothHalf, emaAlpha, smoothMinAlpha, smoothSpeedRef]);

  // 영상(djmd)에서 감지한 카메라 초점거리 자동 적용.
  // 사용자가 f 를 직접 튜닝한 값(기본값 24 가 아닌 저장값)만 우선하고, 그 외(첫 로드,
  // 또는 저장은 됐지만 f 가 기본값 그대로인 경우)는 감지값을 적용한다.
  // (보정 자동저장은 폴더를 열기만 해도 기본값으로 저장되므로 '저장 존재' 만으론 튜닝 여부 판단 불가)
  const camAppliedFor = useRef<string | null>(null);
  useEffect(() => {
    if (!storeBaseName || !storeCameraInfo?.focalLen35) return;
    if (camAppliedFor.current === storeBaseName) return;
    camAppliedFor.current = storeBaseName;
    const f = storeCameraInfo.focalLen35;
    const model = storeCameraInfo.model;
    setParams(p => {
      const focalTuned = calibHadSavedRef.current &&
        Math.abs(p.focalLen - DEFAULT_CAMERA_PARAMS.focalLen) > 0.01;
      if (focalTuned) {
        console.log(`[camera] ${model} 감지 (${f}mm) — 사용자 튜닝 f=${p.focalLen}mm 유지 (패널 '적용'으로 교체 가능)`);
        return p;
      }
      console.log(`[camera] ${model} 감지 → focal ${f}mm 자동 적용`);
      return { ...p, focalLen: f };
    });
  }, [storeBaseName, storeCameraInfo]);

  // 폴더의 <영상 base>.camera.json 카메라 파라미터 — 최우선 적용(localStorage/감지값 위에).
  // 이 effect 는 calib 복원·감지값 적용 effect 뒤에 선언되어 함수형 업데이트가 마지막에 반영된다.
  const camJsonAppliedFor = useRef<string | null>(null);
  useEffect(() => {
    if (!storeBaseName || !storeCameraJson) return;
    if (camJsonAppliedFor.current === storeBaseName) return;
    camJsonAppliedFor.current = storeBaseName;
    const cam = storeCameraJson;
    setParams(p => {
      const next = { ...p };
      for (const [k, v] of Object.entries(cam)) {
        if (k in next && typeof v === 'number' && isFinite(v)) (next as unknown as Record<string, number>)[k] = v;
      }
      return next;
    });
    console.log('[camera] <base>.camera.json 파라미터 적용(최우선):', cam);
  }, [storeBaseName, storeCameraJson]);

  // 현재 카메라 파라미터를 사용자 PC 에 `camera.json`(단순 이름 규칙)으로 다운로드 저장.
  // Yaw±(자동추정/드래그 역산 결과) 포함 전체 파라미터가 담긴다.
  // 받은 파일을 영상 폴더에 넣어두면 다음 폴더 선택 때 최우선으로 자동 적용된다.
  const [camSaveState, setCamSaveState] = useState<'idle' | 'ok'>('idle');
  const saveCameraToPc = useCallback(() => {
    const geo = useGeoStore.getState();
    const payload = {
      camera: paramsRef.current,
      model: geo.cameraInfo?.model,
      savedAt: new Date().toISOString(),
    };
    const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = 'camera.json';
    a.click();
    setTimeout(() => URL.revokeObjectURL(url), 5000);
    setCamSaveState('ok');
    setTimeout(() => setCamSaveState('idle'), 2500);
  }, []);

  // 폴더의 <영상 base>.display.json 화면표시 옵션 자동 적용 — 'PC에 저장' 버튼과 왕복.
  // localStorage 복원(calib effect)보다 뒤에 선언되어 파일 값이 우선 반영된다.
  const dispJsonAppliedFor = useRef<string | null>(null);
  useEffect(() => {
    if (!storeBaseName || !storeDisplayJson) return;
    if (dispJsonAppliedFor.current === storeBaseName) return;
    dispJsonAppliedFor.current = storeBaseName;
    const d = storeDisplayJson;
    const num = (k: string): number | undefined =>
      typeof d[k] === 'number' && isFinite(d[k] as number) ? (d[k] as number) : undefined;
    const bool = (k: string): boolean | undefined =>
      typeof d[k] === 'boolean' ? (d[k] as boolean) : undefined;
    if (num('dronePathZ') !== undefined) setDronePathZ(num('dronePathZ')!);
    if (num('dronePathAlpha') !== undefined) setDronePathAlpha(num('dronePathAlpha')!);
    if (num('smoothHalf') !== undefined) setSmoothHalf(num('smoothHalf')!);
    if (num('emaAlpha') !== undefined) setEmaAlpha(num('emaAlpha')!);
    if (num('smoothMinAlpha') !== undefined) setSmoothMinAlpha(num('smoothMinAlpha')!);
    if (num('smoothSpeedRef') !== undefined) setSmoothSpeedRef(num('smoothSpeedRef')!);
    if (num('maxPoiRange') !== undefined) setMaxPoiRange(num('maxPoiRange')!);
    if (bool('poiDroneHeight') !== undefined) setPoiDroneHeight(bool('poiDroneHeight')!);
    if (num('droneHeightDrop') !== undefined) setDroneHeightDrop(num('droneHeightDrop')!);
    const ss = useSettingsStore.getState();
    if (bool('showCenterline') !== undefined) ss.setShowCenterline(bool('showCenterline')!);
    if (bool('showDronePath') !== undefined) ss.setShowDronePath(bool('showDronePath')!);
    if (bool('showRoutePanel') !== undefined) ss.setShowRoutePanel(bool('showRoutePanel')!);
    if (bool('poiOverlapExclude') !== undefined) ss.setPoiOverlapExclude(bool('poiOverlapExclude')!);
    if (d.compassType === 'analog' || d.compassType === 'map') ss.setCompassType(d.compassType);
    if (d.compassMapStyle === 'street' || d.compassMapStyle === 'sat') ss.setCompassMapStyle(d.compassMapStyle);
    if (d.gradeFilter && typeof d.gradeFilter === 'object') {
      for (const [g, v] of Object.entries(d.gradeFilter as Record<string, unknown>)) {
        if (typeof v === 'boolean') ss.setGradeFilter(g, v);
      }
    }
    console.log('[display] <base>.display.json 화면표시 옵션 적용:', d);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [storeBaseName, storeDisplayJson]);

  // 현재 화면표시 옵션을 사용자 PC 에 `display.json`(단순 이름 규칙)으로 다운로드 저장.
  // 받은 파일을 영상 폴더에 넣어두면 다음 폴더 선택 때 자동 적용된다.
  const [dispSaveState, setDispSaveState] = useState<'idle' | 'ok'>('idle');
  const saveDisplayToPc = useCallback(() => {
    const ss = useSettingsStore.getState();
    const payload = {
      display: {
        dronePathZ, dronePathAlpha, smoothHalf, emaAlpha, smoothMinAlpha, smoothSpeedRef,
        maxPoiRange, poiDroneHeight, droneHeightDrop,
        showCenterline: ss.showCenterline,
        showDronePath: ss.showDronePath,
        showRoutePanel: ss.showRoutePanel,
        poiOverlapExclude: ss.poiOverlapExclude,
        compassType: ss.compassType,
        compassMapStyle: ss.compassMapStyle,
        gradeFilter: ss.gradeFilter,
      },
      savedAt: new Date().toISOString(),
    };
    const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = 'display.json';
    a.click();
    setTimeout(() => URL.revokeObjectURL(url), 5000);
    setDispSaveState('ok');
    setTimeout(() => setDispSaveState('idle'), 2500);
  }, [dronePathZ, dronePathAlpha, smoothHalf, emaAlpha, smoothMinAlpha, smoothSpeedRef, maxPoiRange, poiDroneHeight, droneHeightDrop]);

  // Yaw 자동 추정 — 전진 비행 구간에서 GPS 진행 방위(정확) vs 짐벌 헤딩(나침반 오차 포함)의
  // 차이 중앙값을 Yaw± 로 설정. 카메라가 진행방향을 보며 촬영하는 도로 추종 비행 가정(근사치).
  const autoEstimateYaw = useCallback(() => {
    const frames = allDroneFramesRef.current;
    if (!frames || frames.length < 30) { alert('드론 로그가 부족해 자동 추정할 수 없습니다.'); return; }
    const fps = fpsRef.current;
    const cosLat = Math.cos((frames[0].lat * Math.PI) / 180);
    const STEP = 10; // 진행방위 계산 창(≈1초) — GPS 지터 평활
    const diffs: number[] = [];
    for (let i = STEP; i < frames.length; i++) {
      const a = frames[i - STEP], b = frames[i];
      const dt = (b.frame - a.frame) / fps;
      if (dt <= 0) continue;
      const dx = (b.lon - a.lon) * 111000 * cosLat;
      const dy = (b.lat - a.lat) * 111000;
      if (Math.hypot(dx, dy) / dt < 2) continue;             // 정지/호버 구간 제외
      const course = (Math.atan2(dx, dy) * 180) / Math.PI;    // 진행 방위 (North=0, CW)
      let d = course - frames[i - (STEP >> 1)].yaw;            // 진행방위 − 짐벌 헤딩
      d = ((d + 540) % 360) - 180;
      if (Math.abs(d) < 60) diffs.push(d);                     // 옆을 보며 찍은 구간 제외
    }
    if (diffs.length < 20) { alert('전진 촬영 구간이 부족해 자동 추정할 수 없습니다.'); return; }
    diffs.sort((x, y) => x - y);
    const med = diffs[diffs.length >> 1];
    const next = Math.round(med * 10) / 10;
    setParam('yawOffset', next);
    console.log(`[yaw] 자동 추정: 유효 샘플 ${diffs.length}개, 중앙값 ${med.toFixed(2)}° → Yaw± ${next}° 적용`);
  }, [setParam]);

  // POI 보정(override: DEM 표고/드래그)도 baseName별 자동 저장·복원 → 새로고침해도 유지.
  const ovLoadedFor = useRef<string | null>(null);
  useEffect(() => {
    if (!storeBaseName || ovLoadedFor.current === storeBaseName) return;
    ovLoadedFor.current = storeBaseName;
    try {
      const raw = localStorage.getItem(`ghivideo:poiov:${storeBaseName}`);
      if (!raw) return;
      const saved = JSON.parse(raw);
      if (saved && typeof saved === 'object') {
        // 폴더 파일 override 위에 localStorage(최근 편집) 병합.
        setPoiOverrides({ ...useGeoStore.getState().poiOverrides, ...saved });
      }
    } catch { /* noop */ }
  }, [storeBaseName, setPoiOverrides]);
  useEffect(() => {
    if (!storeBaseName) return;
    const t = setTimeout(() => {
      try { localStorage.setItem(`ghivideo:poiov:${storeBaseName}`, JSON.stringify(storePoiOverrides)); }
      catch { /* noop */ }
    }, 600);
    return () => clearTimeout(t);
  }, [storeBaseName, storePoiOverrides]);

  // 측점 (stationOrder 정렬 — 스토어가 이미 정렬해 두지만 방어적으로 동일 순서 보장)
  useEffect(() => {
    allGeoStationsRef.current = [...storeStations]
      .sort((a, b) => stationOrder(a.title) - stationOrder(b.title));
    allPoisRef.current = storePois;
    setGeoDataLoaded(storeStations.length > 0 || storePois.length > 0);
  }, [storeStations, storePois]);

  // 구조물(교량/터널/구교)을 영상 오버레이에 POI처럼 표시(거리필터 적용).
  //   - 영상은 시설등급 무관 모두 표출(신대천교(복) 등 포함). 스테이션바엔 시설등급 필터로 별도 제어.
  // lat/lon 없는 항목(측점기반 배치 등)도 제외.
  // 신포맷(지장물.kml '타입') 교량/터널은 POI 로도 등록됨 — 같은 이름이 POI 에 있으면
  // 영상 라벨이 이중 표출(파란 POI + 빨간 구조물, z 도 달라 위치 분리)되므로 구조물 쪽 제외.
  // (스테이션바 구조물 마크는 storeStructures 직접 사용이라 영향 없음)
  useEffect(() => {
    const poiTitles = new Set(storePois.map(p => p.title));
    allStructuresRef.current = storeStructures
      .filter((s): s is RouteStructure & { lat: number; lon: number } =>
        s.category != null && OVERLAY_STRUCT_CATEGORIES.has(s.category) &&
        typeof s.lat === 'number' && typeof s.lon === 'number' &&
        !poiTitles.has(s.name))
      .map(s => ({
        title: s.name,
        category: s.category ?? '구교',
        lat: s.lat, lon: s.lon, z: 0, type: 'poi' as const,
        props: s.props,
      }));
  }, [storeStructures, storePois]);

  // 중심선
  useEffect(() => {
    allCenterlinePointsRef.current = storeCenterline;
    setClDataLoaded(storeCenterline.length > 0);
  }, [storeCenterline]);

  // 드론 프레임
  useEffect(() => {
    allDroneFramesRef.current = storeFrames;
    // 세그먼트 전환 시 현재 프레임 추적 초기화 — 스테일 인덱스(이전 세그먼트 끝)로
    // 사전계산 시작 구간이 엉뚱해지는 것 방지. 다음 currentTime 틱에 정확히 재계산된다.
    currentFrameIdxRef.current = 0;
    currentFrameNumRef.current = storeFrames[0]?.frame ?? 0;
    setDroneFramesLoaded(storeFrames.length > 0);
  }, [storeFrames]);

  // ENU 월드 원점 — 스토어 origin 직접 사용 (서버 getWorldOrigin 대체)
  useEffect(() => {
    worldOriginRef.current = storeOrigin
      ? { lat: storeOrigin.lat, lon: storeOrigin.lon, alt: storeOrigin.alt }
      : undefined;
  }, [storeOrigin]);

  // 드론 프레임 이동 평균 (GPS/자세 노이즈 제거)
  // ※ 에지(회전 경계) 보존 적응형 창: 중심 프레임 yaw 와 차이가 임계(YAW_EDGE) 이내인
  //   인접 프레임까지만 좌우로 확장(최대 halfWin). 회전 구간이 직선 평균에 섞이지 않아:
  //     - 회전 중엔 창이 좁아 실제 자세를 즉시 추종(지연 없음),
  //     - 회전 종료 후엔 회전 프레임을 배제한 채 창이 '한 프레임씩' 부드럽게 재확장(튐 없음).
  //   직선에선 yaw 가 거의 일정 → 풀창(±halfWin)으로 GPS/자세 노이즈 억제.
  //   (이전 '변화율 기반 축소'는 회전 종료 시 창이 한꺼번에 재확장돼 회전 프레임이 다시 섞여
  //    한 번 튀는 부작용이 있었음 → 경계 보존 방식으로 교체.)
  const smoothFrame = useCallback((frames: DroneFrameBasic[], i: number, halfWin: number): DroneFrameBasic => {
    let lo = i, hi = i;
    if (frames.length > 1 && halfWin > 0) {
      const YAW_EDGE = 8; // deg — 중심 대비 이 이상 벌어지면 다른(회전) 구간으로 보고 평균 제외
      const yc = frames[i].yaw;
      const near = (idx: number) => Math.abs(((frames[idx].yaw - yc + 540) % 360) - 180) <= YAW_EDGE;
      const loMin = Math.max(0, i - halfWin);
      const hiMax = Math.min(frames.length - 1, i + halfWin);
      while (lo > loMin && near(lo - 1)) lo--;
      while (hi < hiMax && near(hi + 1)) hi++;
    }
    const n = hi - lo + 1;
    let lat = 0, lon = 0, altitude = 0, pitch = 0, roll = 0, sinYaw = 0, cosYaw = 0;
    for (let k = lo; k <= hi; k++) {
      const f = frames[k];
      lat += f.lat; lon += f.lon; altitude += f.altitude;
      pitch += f.pitch; roll += f.roll;
      const yr = f.yaw * Math.PI / 180;
      sinYaw += Math.sin(yr); cosYaw += Math.cos(yr);
    }
    return {
      ...frames[i],
      lat: lat / n, lon: lon / n, altitude: altitude / n,
      pitch: pitch / n, roll: roll / n,
      yaw: Math.atan2(sinYaw / n, cosYaw / n) * 180 / Math.PI,
    };
  }, []);

  // 선/궤적 투영 — RAF에서 매 프레임 보간 포즈로 호출(부드러운 갱신). refs만 읽으므로 stable.
  const buildLines = useCallback((drone: DroneFrameBasic) => {
    const params = paramsRef.current;
    const worldOrigin = worldOriginRef.current;
    const allCL = allCenterlinePointsRef.current;
    const frames = allDroneFramesRef.current;
    const CLIP_Z = 0.1;
    const oc = (px: number, py: number) =>
      (px < -0.1 ? 1 : 0) | (px > 1.1 ? 2 : 0) | (py < -0.1 ? 4 : 0) | (py > 1.1 ? 8 : 0);
    const segFrom = (c1: CameraCoords, c2: CameraCoords): [number, number, number, number] | null => {
      const z1 = c1.Zc, z2 = c2.Zc;
      if (z1 < CLIP_Z && z2 < CLIP_Z) return null;
      let px1: number, py1: number, px2: number, py2: number;
      if (z1 >= CLIP_Z && z2 >= CLIP_Z) {
        const p1 = pixelFromCamera(c1, params), p2 = pixelFromCamera(c2, params);
        px1 = p1.pxRaw; py1 = p1.pyRaw; px2 = p2.pxRaw; py2 = p2.pyRaw;
      } else {
        const t = (CLIP_Z - z1) / (z2 - z1);
        const cClip: CameraCoords = { Xc: c1.Xc + t*(c2.Xc-c1.Xc), Yc: c1.Yc + t*(c2.Yc-c1.Yc), Zc: CLIP_Z };
        if (z1 < CLIP_Z) {
          const p1 = pixelFromCamera(cClip, params), p2 = pixelFromCamera(c2, params);
          px1 = p1.pxRaw; py1 = p1.pyRaw; px2 = p2.pxRaw; py2 = p2.pyRaw;
        } else {
          const p1 = pixelFromCamera(c1, params), p2 = pixelFromCamera(cClip, params);
          px1 = p1.pxRaw; py1 = p1.pyRaw; px2 = p2.pxRaw; py2 = p2.pyRaw;
        }
      }
      if (oc(px1, py1) & oc(px2, py2)) return null;
      return [px1, py1, px2, py2];
    };

    const centerlineSegs: [number, number, number, number][] = [];
    if (showCenterlineRef.current && allCL.length) {
      // 표고 미상(z≤0) 중심선(예: 제주 KML 선형 — 고도 전부 0)은 '경로 표고' 평면에 앵커 —
      // 측점/POI 라벨과 동일 기준. z=0 그대로 투영하면 해수면 높이로 그려져 크게 어긋난다.
      const CL_Z = dronePathZRef.current;
      // 선형은 '표출된 마지막 측점'까지만 — 표시 반경(maxPoiRange) 안에 들어온 측점 중
      // 가장 먼 것의 거리(+10m)로 클립. 경계를 가로지르는 세그먼트는 경계 지점에서 잘라 그린다
      // (정점 간격이 긴 직선 구간에서 세그먼트 통째 그리기로 마지막 측점 너머 긴 꼬리가 남던 문제).
      const MAX_R = maxPoiRangeRef.current;
      const cosLat = Math.cos((drone.lat * Math.PI) / 180);
      let clipR = 0;
      for (const st of allGeoStationsRef.current) {
        const d = Math.hypot((st.lat - drone.lat) * 111000, (st.lon - drone.lon) * 111000 * cosLat);
        if (d <= MAX_R && d > clipR) clipR = d;
      }
      clipR = clipR > 0 ? Math.min(MAX_R, clipR + 10) : MAX_R;
      // 드론 기준 동서/남북(m) 좌표 — 세그먼트·클립원 교점 계산용
      const enOf = (p: { lat: number; lon: number }): [number, number] => [
        (p.lon - drone.lon) * 111000 * cosLat,
        (p.lat - drone.lat) * 111000,
      ];
      // P0→P1 세그먼트가 반지름 clipR 원과 만나는 t(0~1) 목록(오름차순, 최대 2개).
      const clipTs = (e0: [number, number], e1: [number, number]): number[] => {
        const dx = e1[0] - e0[0], dy = e1[1] - e0[1];
        const a = dx * dx + dy * dy;
        if (a === 0) return [];
        const b = 2 * (e0[0] * dx + e0[1] * dy);
        const c = e0[0] * e0[0] + e0[1] * e0[1] - clipR * clipR;
        const disc = b * b - 4 * a * c;
        if (disc < 0) return [];
        const sq = Math.sqrt(disc);
        return [(-b - sq) / (2 * a), (-b + sq) / (2 * a)].filter((t) => t >= 0 && t <= 1);
      };
      // 드론높이 모드: 측점/POI 라벨과 동일한 '드론고도 − 이격거리' 평면에 투영 —
      // 옵션 적용 시 라벨만 이동하고 선형이 지형 z 에 남아 서로 분리되는 것을 방지
      // (측점 POI 와 빨간 선형이 항상 같은 선상에 놓이도록 높이 규칙 통일).
      const planeZ = poiDroneHeightRef.current
        ? drone.altitude - (params.geoidOffset ?? 0) - droneHeightDropRef.current
        : null;
      const projCL = (lat: number, lon: number, z: number): CameraCoords =>
        toCameraCoords(drone, lat, lon, planeZ ?? (z > 0 ? z : CL_Z), params, worldOrigin);
      for (let i = 1; i < allCL.length; i++) {
        const p0 = allCL[i - 1], p1 = allCL[i];
        const e0 = enOf(p0), e1 = enOf(p1);
        const in0 = Math.hypot(e0[0], e0[1]) <= clipR;
        const in1 = Math.hypot(e1[0], e1[1]) <= clipR;
        const lerp = (t: number) => ({
          lat: p0.lat + (p1.lat - p0.lat) * t,
          lon: p0.lon + (p1.lon - p0.lon) * t,
          z: p0.z + (p1.z - p0.z) * t,
        });
        let a0 = p0, a1 = p1;
        if (!in0 && !in1) {
          // 양끝 다 밖 — 단, 긴 직선 세그먼트(KML 정점 간격 수백 m)가 클립 원을 '관통'하는
          // 경우가 흔하므로(0k700 부근 선 실종 원인), 두 교점 사이 구간(현)은 그린다.
          const ts = clipTs(e0, e1);
          if (ts.length < 2) continue;              // 원과 안 만남 → 생략
          a0 = lerp(ts[0]); a1 = lerp(ts[1]);
        } else if (in0 !== in1) {
          // 경계 교차 → 교점까지만
          const ts = clipTs(e0, e1);
          if (ts.length) {
            const cut = lerp(in0 ? ts[ts.length - 1] : ts[0]);
            if (in0) a1 = cut; else a0 = cut;
          }
        }
        const s = segFrom(projCL(a0.lat, a0.lon, a0.z), projCL(a1.lat, a1.lon, a1.z));
        if (s) centerlineSegs.push(s);
      }
    }

    const dronePathPts: [number, number][] = [];
    if (showDronePathRef.current && frames.length) {
      const Z = dronePathZRef.current;
      const BACK = 60, FWD = 1500, STEP = 3, PW = 15, MAX_FWD = 200;
      const cur = currentFrameIdxRef.current;
      const lo = Math.max(0, cur - BACK), hi = Math.min(frames.length - 1, cur + FWD);
      const sm = (i: number): [number, number] => {
        let la = 0, lo2 = 0, n = 0;
        for (let k = Math.max(0, i - PW); k <= Math.min(frames.length - 1, i + PW); k++) { la += frames[k].lat; lo2 += frames[k].lon; n++; }
        return [la / n, lo2 / n];
      };
      for (let i = lo; i <= hi; i += STEP) {
        const [la, lo2] = sm(i);
        const cc = toCameraCoords(drone, la, lo2, Z, params, worldOrigin);
        if ((cc.fwd ?? 0) > MAX_FWD) break;
        if (cc.Zc < CLIP_Z) continue;
        const p = pixelFromCamera(cc, params);
        dronePathPts.push([p.pxRaw, p.pyRaw]);
      }
    }
    return { centerlineSegs, dronePathPts };
  }, []);

  // 보간 포즈 — estFrame(연속 프레임번호)에서 앞뒤 smoothFrame 선형보간(yaw는 최단각). RAF에서 호출.
  // 자세(yaw/pitch/roll)는 attLeadSec(자세 선행) 만큼 앞선 시점에서 별도 샘플 —
  // 짐벌 로그의 기록 지연으로 팬(회전) 시 오버레이가 늦게 따라오는 이질감 보정.
  const poseAt = useCallback((estFrame: number): DroneFrameBasic | null => {
    const arr = allDroneFramesRef.current;
    if (!arr.length) return null;
    const h = poseHalf();
    const sample = (ef: number): { a: DroneFrameBasic; b: DroneFrameBasic; frac: number } => {
      let lo = 0, hi = arr.length - 1;
      while (lo < hi) { const m = (lo + hi) >> 1; if (arr[m].frame < ef) lo = m + 1; else hi = m; }
      const i2 = lo, i1 = Math.max(0, lo - 1);
      const f1 = arr[i1].frame, f2 = arr[i2].frame;
      const frac = f2 > f1 ? Math.min(1, Math.max(0, (ef - f1) / (f2 - f1))) : 0;
      return { a: smoothFrame(arr, i1, h), b: smoothFrame(arr, i2, h), frac };
    };
    const p = sample(estFrame);
    const leadSec = paramsRef.current.attLeadSec ?? 0;
    const q = leadSec !== 0 ? sample(estFrame + leadSec * fpsRef.current) : p;
    const L = (x: number, y: number) => x + (y - x) * p.frac;
    const A = (x: number, y: number) => x + (y - x) * q.frac;
    let dy = q.b.yaw - q.a.yaw; dy = ((dy + 540) % 360) - 180;   // 최단각
    return {
      ...p.a,
      frame: estFrame,
      lat: L(p.a.lat, p.b.lat), lon: L(p.a.lon, p.b.lon), altitude: L(p.a.altitude, p.b.altitude),
      pitch: A(q.a.pitch, q.b.pitch), roll: A(q.a.roll, q.b.roll),
      yaw: q.a.yaw + dy * q.frac,
    };
  }, [smoothFrame, poseHalf]);

  // 텍스트 사전 계산 — requestIdleCallback으로 백그라운드 실행
  const startLabelPrecompute = useCallback((currentParams: CameraParams, currentSmoothHalf: number, currentMaxRange: number, currentDroneHeight: boolean, currentDroneDrop: number) => {
    const id = ++precomputeIdRef.current;
    const frames = allDroneFramesRef.current;
    // 재계산(같은 데이터, 슬라이더 변경 등)이면 옛 맵을 복사해 시작 → 빈 구간 없이 갱신.
    // 단, frames 가 바뀐 경우(분할영상 세그먼트 전환)는 옛 캐시가 '다른 구간'의 값이므로
    // 복사하지 않고 비워서 시작 — 신·구 캐시가 섞여 라벨 집합이 프레임마다 뒤바뀌는
    // 깜빡임(두 번째 영상 재생 시)을 방지한다.
    const framesChanged = lastPrecomputeFramesRef.current !== frames;
    lastPrecomputeFramesRef.current = frames;
    const newMap = new Map<number, LabelCache>(
      !framesChanged && labelMapRef.current.size ? labelMapRef.current : undefined,
    );
    if (framesChanged) labelMapRef.current = newMap; // 스테일 캐시 즉시 차단
    labelKeysRef.current = frames.map(f => f.frame);  // RAF 최근접 키 조회용(오름차순)
    const allSt = allGeoStationsRef.current;
    // 모든 POI 표출(철도역 포함) + 영상용 구조물(구교/교량/터널). 철도역은 별도로 스테이션바에도 표출.
    const allPoi = allPoisRef.current.concat(allStructuresRef.current);
    const worldOrigin = worldOriginRef.current;
    const CLIP_Z = 0.1;
    const SMOOTH_HALF = currentSmoothHalf;
    const MAX_RANGE = currentMaxRange;  // POI 표시 범위 — 드론 수평 직선거리(m)
    // 표고 미상(z=0) 라벨의 앵커 평면 — 드론 궤적과 동일한 '경로 표고' 값.
    // 사용자가 경로표고 슬라이더로 궤적을 도로에 맞추면 측점/POI 라벨도 함께 맞는다.
    const PATH_Z = dronePathZRef.current;

    if (!frames.length) return;

    // 프레임 불변 값은 루프 밖에서 1회만 계산 — 측점의 중심선 투영(projectCL)과 POI 지면고도(gz)는
    // 드론 프레임과 무관하다. 프레임 루프 안에서 매번 계산하면(측점71×선분145×프레임 수천)
    // 재생 중 idle 청크가 메인 스레드를 막아 영상 딜레이·라벨 떨림을 유발한다.
    const snappedSt = allSt.map(st => {
      const snap = projectCL(st.lat, st.lon);
      const szRaw = snap?.z ?? st.z;
      return {
        title: st.title,
        lat: snap?.lat ?? st.lat,
        lon: snap?.lon ?? st.lon,
        // 표고 미상(0 이하)이면 경로표고 평면에 앵커 — 공중부양/시차 밀림 방지.
        z: szRaw > 0 ? szRaw : PATH_Z,
      };
    });
    const gzOf = new Map<string, number>();
    for (const poi of allPoi) {
      // 지면고도 gz 우선순위: 보정값(드래그) → **POI 자체 z(실측)** → 중심선 z → 경로표고 앵커.
      // 종전엔 중심선 스냅이 POI z 보다 우선 — 도로에서 떨어진 지물이 도로 높이에 붙어
      // 원거리에서 계속 밀리다 근거리에서만 맞는 증상의 원인. 실측 z 보유 데이터셋 기준으로 교정.
      const clz = nearestCL(poi.lat, poi.lon)?.z;
      gzOf.set(poi.title, overridesRef.current[poi.title]?.z
        ?? (poi.z > 0 ? poi.z : undefined)
        ?? (clz !== undefined && clz > 0 ? clz : undefined)
        ?? PATH_Z);
    }

    const t0 = performance.now();
    const total = frames.length;
    // 현재 재생 프레임부터 순환 처리 → 화면에 보이는 구간을 먼저 계산(재생 즉시 라벨 표시).
    const startIdx = Math.min(total - 1, Math.max(0, currentFrameIdxRef.current));
    // 초기 로드/재계산 모두 첫 청크(현재 프레임 포함) 후 즉시 노출(아래). 재계산은 옛 맵 복사로 시작.
    let processed = 0;      // 처리한 프레임 수(0..total)
    let published = false;
    const CHUNK = 200;  // 한 번에 처리할 프레임 수

    const step = () => {
      if (precomputeIdRef.current !== id) return;  // 취소됨

      const end = Math.min(processed + CHUNK, total);
      while (processed < end) {
        const idx = (startIdx + processed) % total;  // startIdx→끝→0→… 순환
        processed++;
        const drone = smoothFrame(frames, idx,
          frameSyncedRef.current ? Math.min(SMOOTH_HALF, POSE_SMOOTH_SYNCED_MAX) : SMOOTH_HALF);

        const stationLabels: LabelCache['stationLabels'] = [];
        for (const sp of snappedSt) {
          const slat = sp.lat, slon = sp.lon, sz = sp.z;
          // 가시성 판정도 드로우와 동일 높이 규칙 — 드론높이 모드면 드론고도 − 이격거리.
          const stZ = currentDroneHeight
            ? drone.altitude - (currentParams.geoidOffset ?? 0) - currentDroneDrop
            : sz;
          const cc = toCameraCoords(drone, slat, slon, stZ, currentParams, worldOrigin);
          if (cc.Zc < CLIP_Z) continue;
          // 측점도 POI 와 동일하게 표시 범위 제한 — 원거리(수 km) 측점이 수평선에 뭉치는 것 방지.
          if ((cc.distH ?? 0) > MAX_RANGE) continue;
          const { pxRaw, pyRaw } = pixelFromCamera(cc, currentParams);
          // 포함 경계 ±0.15 로 넉넉히 — 경계에 걸친 라벨이 호버(정지) 중 GPS 지터로
          // 캐시 프레임마다 들락거리며 깜빡이는 것 방지. 실제 화면 밖 컬링은 드로우가 담당.
          if (pxRaw < -0.15 || pxRaw > 1.15 || pyRaw < -0.15 || pyRaw > 1.15) continue;
          // 화면좌표 대신 (스냅된)월드좌표 저장 → RAF 가 연속 포즈로 재투영.
          stationLabels.push({ title: sp.title, lat: slat, lon: slon, z: sz });
        }

        // 후보 수집 (거리 dist + 월드좌표 + 지면고도 gz) → 겹침 억제용
        const poiCand: { x: number; y: number; title: string; label: string; category: string; dist: number; lat: number; lon: number; gz: number; compact: { k: string; v: string }[]; color?: string }[] = [];
        for (const poi of allPoi) {
          // 지면고도 gz — 프레임 불변이므로 루프 밖에서 1회 계산(gzOf).
          const gz = gzOf.get(poi.title) ?? PATH_Z;
          // 가시성 판정용 poiZ: 드론높이 모드면 드론−이격거리, 아니면 gz.
          const poiZ = currentDroneHeight
            ? drone.altitude - (currentParams.geoidOffset ?? 0) - currentDroneDrop
            : gz;
          const cc = toCameraCoords(drone, poi.lat, poi.lon, poiZ, currentParams, worldOrigin);
          if (cc.Zc < CLIP_Z) continue;
          // 표시 범위: 드론과의 수평 직선거리(distH)가 범위 안이면 표시, 밖이면 제외.
          if ((cc.distH ?? 0) > MAX_RANGE) continue;
          const { pxRaw, pyRaw } = pixelFromCamera(cc, currentParams);
          // 포함 경계 ±0.15 — 경계 걸침 라벨의 호버 중 10Hz 깜빡임 방지(화면 밖 컬링은 드로우 담당).
          if (pxRaw < -0.15 || pxRaw > 1.15 || pyRaw < -0.15 || pyRaw > 1.15) continue;
          poiCand.push({ x: pxRaw, y: pyRaw, title: poi.title, label: guboonOf(poi.props) ?? cleanTitle(poi.title), category: poi.category, dist: cc.distH ?? 0, lat: poi.lat, lon: poi.lon, gz, compact: compactFieldsOf(poi.props), ...(poi.labelColor ? { color: poi.labelColor } : {}) });
        }
        // 겹침 억제: KAKAO_RAIL(철도역/역사) 라벨을 최우선 유지 → 겹치면 일반 POI 가 숨겨진다.
        // 같은 우선순위 내에서는 가까운(dist 작은) 것 우선. 임계 이내면 뒤(낮은우선/먼) 것 숨김.
        // 거리차 1m 미만은 제목순 고정 — 호버(정지) 중 GPS 지터로 승자가 프레임마다 뒤바뀌며
        // 라벨 쌍이 교대로 깜빡이는 것 방지.
        const railRank = (cat: string): number => (cat === '철도역' || cat === '역사' ? 0 : 1);
        poiCand.sort((a, b) =>
          railRank(a.category) - railRank(b.category) ||
          (Math.abs(a.dist - b.dist) > 1 ? a.dist - b.dist : (a.title < b.title ? -1 : a.title > b.title ? 1 : 0)));
        // 겹침 시 진행방향 변형 우선: 같은 base 형제(예: 회덕제1가도교 상/하)가 겹치면
        //   하행='(하)' / 상행='(상)' 쪽을 남긴다(드론이 실제 지나는 선로). 무관한 POI엔 영향 없음.
        // 진행방향 변형 태그(닫는 괄호까지) — 하행='(하)', 상행='(상)'. '(하인상)' 등은 제외.
        const dirTag = routeDirRef.current === '상' ? '(상)' : routeDirRef.current === '하' ? '(하)' : '';
        const coordKey = (c: { lat: number; lon: number }): string => `${c.lat.toFixed(6)},${c.lon.toFixed(6)}`;
        const accepted: typeof poiCand = [];
        if (!overlapExcludeRef.current) {
          // 겹침제외 OFF → 겹침 억제 없이 모든 POI 표시(동일좌표는 세로 행으로 쌓임).
          accepted.push(...poiCand);
        } else {
          // 겹침제외 ON → 겹친 것(동일좌표 형제 포함) 1개만. 형제면 진행방향((하)/(상))을 우선.
          for (const c of poiCand) {
            let overlapIdx = -1;
            for (let i = 0; i < accepted.length; i++) {
              const k = accepted[i];
              if (Math.abs(k.x - c.x) < POI_MERGE_X && Math.abs(k.y - c.y) < POI_MERGE_Y) { overlapIdx = i; break; }
            }
            if (overlapIdx < 0) { accepted.push(c); continue; }
            // 형제 + c가 방향 변형, 기존은 아니면 c로 교체.
            const k = accepted[overlapIdx];
            if (dirTag && baseStruct(c.title) === baseStruct(k.title) && c.title.includes(dirTag) && !k.title.includes(dirTag)) {
              accepted[overlapIdx] = c;
            }
          }
        }
        // 동일좌표 그룹에 행 번호 부여(위→아래 순서대로). 4개면 각 라벨이 4행으로 안 겹치게 배치.
        const groupCount = new Map<string, number>();
        for (const a of accepted) groupCount.set(coordKey(a), (groupCount.get(coordKey(a)) ?? 0) + 1);
        const groupSeen = new Map<string, number>();
        // 통과분만 월드좌표로 저장(RAF 가 재투영). labelRow/Count = 동일좌표 라벨 세로 배치.
        const poiMarkers: LabelCache['poiMarkers'] = accepted.map(c => {
          const ck = coordKey(c);
          const row = groupSeen.get(ck) ?? 0;
          groupSeen.set(ck, row + 1);
          return { title: c.title, label: c.label, category: c.category, lat: c.lat, lon: c.lon, gz: c.gz, compact: c.compact, labelRow: row, labelRowCount: groupCount.get(ck) ?? 1, ...(c.color ? { color: c.color } : {}) };
        });

        newMap.set(drone.frame, { stationLabels, poiMarkers });
      }

      // 초기 로드/재계산 모두: 첫 청크(현재 프레임 포함) 채우면 즉시 노출 → 토글 즉시 반영.
      // 재계산은 옛 맵 복사로 시작했으므로 빈 구간 없이 나머지 청크가 덮어쓰며 갱신된다.
      if (!published) { labelMapRef.current = newMap; published = true; }

      if (processed < total) {
        requestIdleCallback(step, { timeout: 200 });
      } else {
        console.log(
          `[labelMap] complete ${(performance.now() - t0).toFixed(0)}ms | ${frames.length} frames × ${allSt.length + allPoi.length} items`
        );
      }
    };

    // 첫 청크(현재 프레임 포함)는 동기 실행 → 토글/재계산이 '정지 중에도' 즉시 반영.
    //   (idle 대기 시 정지 상태에서 갱신이 지연/누락되던 문제 해결.) 나머지는 step 내부에서 idle 로 이어감.
    step();
  }, [nearestCL, projectCL]);

  // 모든 데이터 로드 완료 시 사전 계산 시작
  // storeFrames 의존 필수: 분할 영상 세그먼트 전환(loadSegment)으로 frames 가 교체되면
  // 라벨 캐시(프레임별 가시 POI)를 새 구간 기준으로 재계산해야 함 — 없으면 이전 세그먼트
  // 캐시가 남아 두 번째 영상부터 시설물이 표출되지 않는다.
  useEffect(() => {
    if (!droneFramesLoaded || !geoDataLoaded || !clDataLoaded) return;
    startLabelPrecompute(paramsRef.current, smoothHalf, maxPoiRange, poiDroneHeight, droneHeightDrop);
  }, [droneFramesLoaded, geoDataLoaded, clDataLoaded, startLabelPrecompute, smoothHalf, maxPoiRange, poiDroneHeight, droneHeightDrop, poiOverlapExclude, dronePathZ, storeFrames]);

  // params / smoothHalf / maxPoiDist / 경로표고(표고 미상 라벨 앵커) 변경 시 사전 계산 재시작 (500ms debounce)
  // ※ storeFrames(세그먼트 전환)는 위의 즉시 effect 가 담당 — 여기 넣으면 전환마다 이중 재계산.
  useEffect(() => {
    if (!droneFramesLoaded || !geoDataLoaded || !clDataLoaded) return;
    const timer = setTimeout(() => startLabelPrecompute(params, smoothHalf, maxPoiRange, poiDroneHeight, droneHeightDrop), 500);
    return () => clearTimeout(timer);
  }, [params, smoothHalf, maxPoiRange, poiDroneHeight, droneHeightDrop, storePois, storeStructures, droneFramesLoaded, geoDataLoaded, clDataLoaded, startLabelPrecompute, poiOverlapExclude, dronePathZ]);

  // 현재 재생 시간 → 드론 프레임 ref 갱신
  useEffect(() => {
    // 나침반이 visible 무관 갱신되도록 visible 게이트 제거(프레임 탐색은 가벼움).
    if (!droneFramesLoaded) return;
    const frames = allDroneFramesRef.current;
    if (!frames.length) return;
    const vfps = fpsRef.current;
    let best = frames[0], bestIdx = 0, bestD = Math.abs((best.frame ?? 0) / vfps - currentTime);
    for (let i = 0; i < frames.length; i++) {
      const d = Math.abs(frames[i].frame / vfps - currentTime);
      if (d < bestD) { bestD = d; best = frames[i]; bestIdx = i; }
      if (bestD < 1 / vfps / 2) break;
    }
    currentFrameNumRef.current = best.frame;
    currentFrameIdxRef.current = bestIdx;
    currentTimeSecRef.current = currentTime;
    timeUpdateWallRef.current = performance.now();
    if (currentDroneFrameRef.current?.frame !== best.frame) {
      currentDroneFrameRef.current = best;
      setPanelDroneFrame(best);
    }
  }, [currentTime, droneFramesLoaded]);

  // 중심선 + 나침반 캐시 빌드 (per-frame, 텍스트 계산 없음)
  useEffect(() => {
    // 나침반(effectiveYaw/hFovRad)은 측점선 visible 과 무관하게 항상 갱신.
    const cur = currentDroneFrameRef.current;
    if (cur) {
      const p = paramsRef.current;
      const fr = allDroneFramesRef.current;
      const d = fr.length
        ? smoothFrame(fr, currentFrameIdxRef.current, poseHalf())
        : cur;
      compassRef.current = {
        effectiveYaw: d.yaw + p.yawOffset,
        hFovRad: 2 * Math.atan((p.sensorW ?? 36) / (2 * p.focalLen)),
      };
    } else {
      compassRef.current = null;
    }

    // 선/궤적은 RAF가 매 프레임 직접 투영하므로 여기선 캐시 '준비됨' 표식 + 메타데이터만.
    if (!currentDroneFrameRef.current) { renderCacheRef.current = null; return; }
    const frames = allDroneFramesRef.current;
    const drone = frames.length
      ? smoothFrame(frames, currentFrameIdxRef.current, poseHalf())
      : currentDroneFrameRef.current;
    const params = paramsRef.current;
    renderCacheRef.current = {
      effectiveYaw: drone.yaw + params.yawOffset,
      hFovRad: 2 * Math.atan((params.sensorW ?? 36) / (2 * params.focalLen)),
      clCount: allCenterlinePointsRef.current.length,
      poiCount: allPoisRef.current.length,
    };
  }, [panelDroneFrame, params]);

  // ResizeObserver
  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const parent = canvas.parentElement;
    if (!parent) return;
    const ro = new ResizeObserver(entries => {
      for (const e of entries) {
        canvas.width = Math.round(e.contentRect.width);
        canvas.height = Math.round(e.contentRect.height);
        canvasSizeRef.current = { w: canvas.width, h: canvas.height };
      }
    });
    ro.observe(parent);
    canvas.width = parent.clientWidth;
    canvas.height = parent.clientHeight;
    canvasSizeRef.current = { w: canvas.width, h: canvas.height };
    return () => ro.disconnect();
  }, []);

  // RAF 렌더 루프 — 계산 없이 캐시/Map 조회만
  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    let rafId = 0;

    const draw = () => {
      rafId = requestAnimationFrame(draw);
      const ctx = canvas.getContext('2d');
      if (!ctx) return;
      const { w: W, h: H } = canvasSizeRef.current;
      ctx.clearRect(0, 0, W, H);

      // 영상 첫 프레임이 표시되기 전엔 오버레이(라벨/선/나침반)를 그리지 않음 → 라벨이 영상보다 먼저 뜨는 것 방지.
      if (!videoReadyRef.current) return;

      // object-fit:cover 정렬 — 영상은 컨테이너를 비율유지·크롭하며 채우므로(index.css), 오버레이도
      // 같은 cover 사각형에 매핑해야 영상과 일치한다. 영상 해상도(vW,vH) 없으면 컨테이너 기준 폴백.
      const { w: vW, h: vH } = videoSizeRef.current;
      let dispW = W, dispH = H, offX = 0, offY = 0;
      if (vW > 0 && vH > 0) {
        const s = Math.max(W / vW, H / vH);              // cover: 더 큰 배율로 채움(넘침=크롭)
        dispW = vW * s; dispH = vH * s;
        offX = (W - dispW) / 2; offY = (H - dispH) / 2;  // 음수 = 화면 밖(크롭)
      }
      coverRef.current = { offX, offY, dispW, dispH, W, H };
      const vx = (nx: number) => offX + nx * dispW;       // 정규(0~1, 영상프레임) → 화면 px
      const vy = (ny: number) => offY + ny * dispH;

      // 이번 프레임 라벨 히트박스(아이콘+글자, 화면 px) 수집 → 끝에서 labelHitRef 갱신.
      const hitBoxes: { kind: 'poi' | 'station'; title: string; x0: number; y0: number; x1: number; y1: number }[] = [];
      // 이번 프레임에 '실제로 화면 안에 그려진' 라벨 id(`kind:title`) 집합. 팝업 표시의 단일 기준 —
      //   십자마커/텍스트가 뷰포트 안에 있을 때만 채운다. 팝업은 이 집합에 있을 때만 보이게 해
      //   "라벨은 화면 밖(안 보임)인데 팝업만 clamp 되어 뜨는" 문제를 원천 차단한다.
      const onScreenLabels = new Set<string>();
      // 유효 하단 = 화면 높이 − 스테이션바 높이. 팝업/라벨 가시 기준을 '모니터'가 아니라
      //   '영상 재생 영역(스테이션바 위쪽)' 으로 잡는다. (라벨이 바 밑으로 내려가면 사라지게)
      const HB = H - barHeightRef.current;
      // 히트박스가 '영상 영역(0..W, 0..HB)' 과 겹치는지(라벨이 눈에 보이는지). -2px 안쪽 요구로 경계 애매함 제거.
      const labelOnScreen = (x0: number, y0: number, x1: number, y1: number): boolean =>
        x1 >= 2 && x0 <= W - 2 && y1 >= 2 && y0 <= HB - 2;

      const cache = renderCacheRef.current;
      if (cache) {

      // 부드러운 시간(timeRef, 60fps 보간) → 연속 프레임번호. 선/궤적·라벨 공통 사용.
      // 시간동기(timeOffsetSec): 영상↔비행로그의 시각 오프셋 보정 — POI 가 진행방향으로
      // 일괄 밀릴 때 조정(+ = 로그를 뒤로, − = 앞으로). camera.json 에 저장되는 보정값.
      const estTime = (timeRef
        ? timeRef.current
        : currentTimeSecRef.current + (performance.now() - timeUpdateWallRef.current) / 1000)
        + (paramsRef.current.timeOffsetSec ?? 0);
      const estFrame = estTime * fpsRef.current;

      // 연속 보간 포즈(라인·라벨 공통) — 매 RAF 프레임 직접 투영해 부드럽게.
      const dronePose = poseAt(estFrame) ?? (allDroneFramesRef.current.length
        ? smoothFrame(allDroneFramesRef.current, currentFrameIdxRef.current, poseHalf())
        : currentDroneFrameRef.current!);
      const lines = buildLines(dronePose);

      // 나침반 미니맵(heading-up) 회전 — 영상과 즉시 동기. dronePose.yaw 는 ±smoothHalf(기본 60fr ≈2s)
      //   평활이라 회전 시 지연 → 가벼운 평활(±3fr)의 yaw 를 따로 보간해 사용. 360° 누적 언랩(점프 방지).
      {
        const arr = allDroneFramesRef.current;
        let rawYaw = dronePose.yaw;
        if (arr.length) {
          let lo = 0, hi = arr.length - 1;
          while (lo < hi) { const m = (lo + hi) >> 1; if (arr[m].frame < estFrame) lo = m + 1; else hi = m; }
          const i2 = lo, i1 = Math.max(0, lo - 1);
          const f1 = arr[i1].frame, f2 = arr[i2].frame;
          const frac = f2 > f1 ? Math.min(1, Math.max(0, (estFrame - f1) / (f2 - f1))) : 0;
          const a = smoothFrame(arr, i1, 3), b = smoothFrame(arr, i2, 3); // ±3fr 가벼운 평활
          let dy = b.yaw - a.yaw; dy = ((dy + 540) % 360) - 180;
          rawYaw = a.yaw + dy * frac;
        }
        const headingDeg = rawYaw + paramsRef.current.yawOffset;  // 실제 방위(0=N, 시계+)
        // 지도 나침반(노스업)용 현재 위치/방위 — MapCompass 가 읽음.
        mapPoseRef.current.lat = dronePose.lat;
        mapPoseRef.current.lon = dronePose.lon;
        mapPoseRef.current.yaw = headingDeg;
        // 아날로그 나침반(heading-up) 회전 — 렌더 중일 때만. 360° 누적 언랩(점프 방지).
        if (minimapRef.current) {
          const target = -headingDeg;
          const delta = (((target - minimapRotRef.current) % 360) + 540) % 360 - 180;
          minimapRotRef.current += delta;
          minimapRef.current.style.setProperty('--rot', `${minimapRotRef.current}deg`);
        }
      }

      // 선로 중심선 (선형) — 독립 토글
      if (showCenterlineRef.current && lines.centerlineSegs.length > 0) {
        ctx.strokeStyle = 'rgba(255,50,50,0.85)';
        ctx.lineWidth = 3;
        ctx.setLineDash([]);
        ctx.beginPath();
        for (const [px1, py1, px2, py2] of lines.centerlineSegs) {
          ctx.moveTo(vx(px1), vy(py1));
          ctx.lineTo(vx(px2), vy(py2));
        }
        ctx.stroke();
      }

      // 드론 경로 — 하나의 연속 폴리라인(둥근 연결)으로 부드럽게 + 작은 점. 독립 토글 + 투명도
      const dpa = dronePathAlphaRef.current;
      const pts = lines.dronePathPts;
      if (pts.length > 1) {
        ctx.lineJoin = 'round';
        ctx.lineCap = 'round';
        ctx.setLineDash([]);
        // 어두운 밑선(대비) → 배경 위에서도 끊김 없이 잘 보임
        ctx.strokeStyle = `rgba(0,0,0,${0.35 * dpa})`;
        ctx.lineWidth = 5;
        ctx.beginPath();
        ctx.moveTo(vx(pts[0][0]), vy(pts[0][1]));
        for (let k = 1; k < pts.length; k++) ctx.lineTo(vx(pts[k][0]), vy(pts[k][1]));
        ctx.stroke();
        // 본선
        ctx.strokeStyle = `rgba(80,200,255,${dpa})`;
        ctx.lineWidth = 3;
        ctx.beginPath();
        ctx.moveTo(vx(pts[0][0]), vy(pts[0][1]));
        for (let k = 1; k < pts.length; k++) ctx.lineTo(vx(pts[k][0]), vy(pts[k][1]));
        ctx.stroke();
      }
      // 측정점 dot — 작고 듬성하게(선 흐름 방해 X)
      if (pts.length > 0) {
        ctx.fillStyle = `rgba(255,230,90,${dpa})`;
        for (let k = 0; k < pts.length; k += 4) {
          ctx.beginPath();
          ctx.arc(vx(pts[k][0]), vy(pts[k][1]), 1.8, 0, Math.PI*2);
          ctx.fill();
        }
      }

      visStructRef.current.clear();   // 매 프레임 가시 컴팩트-구조물 재수집(라벨 off 시 빈 상태)
      if (visibleRef.current) {
      // 가시집합만 precompute 에서 가져오고(어떤 라벨을 그릴지), 좌표는 RAF 가 연속 포즈로 직접
      // 투영 → 라인처럼 부드럽게 이동(정수프레임 보간/프레임키 의존 제거).
      // 조회는 '최근접 캐시 키' 이진탐색으로 고정 — 종전의 floor 정확일치 + 폴백(4Hz 갱신 스테일 키)
      // 조합은 프레임마다 서로 다른 캐시 항목을 교대로 그려 라벨 집합이 뒤바뀌는 깜빡임을 유발했음.
      let labelsA: LabelCache | undefined;
      {
        const keys = labelKeysRef.current;
        if (keys.length) {
          let lo = 0, hi = keys.length - 1;
          while (hi - lo > 1) { const m = (lo + hi) >> 1; if (keys[m] <= estFrame) lo = m; else hi = m; }
          const ki = Math.abs(keys[hi] - estFrame) < Math.abs(estFrame - keys[lo]) ? hi : lo;
          // 채워지는 중(청크 진행)이면 인접 키로 소폭 폴백
          for (let d = 0; d <= 3 && !labelsA; d++) {
            labelsA = labelMapRef.current.get(keys[ki + d] ?? NaN) ?? labelMapRef.current.get(keys[ki - d] ?? NaN);
          }
        }
        if (!labelsA) labelsA = labelMapRef.current.get(Math.floor(estFrame)) ?? labelMapRef.current.get(currentFrameNumRef.current);
      }

      if (labelsA) {
        const α = emaAlphaRef.current;
        const params = paramsRef.current;
        const worldOrigin = worldOriginRef.current;
        const CLIP_Z = 0.1;
        const seenSt = new Set<string>();
        const seenPoi = new Set<string>();

        // 측점 라벨 — 연속 포즈로 직접 투영 → 이상치 거부 → EMA.
        //   선형(중심선) 토글과 함께 ON/OFF (빨간 스테이션 라벨 = 선형의 일부).
        if (showCenterlineRef.current) {
        ctx.font = 'bold 18px monospace';
        ctx.textAlign = 'left';
        ctx.textBaseline = 'middle';
        labelsA.stationLabels.forEach((stA) => {
          // 측점도 POI 와 동일한 높이 규칙 — 드론높이 모드면 '드론고도 − 이격거리' 평면
          // (이격거리 슬라이더가 측점 라벨에도 반영), 지면고도 모드면 측점 표고(stA.z — 경로표고 앵커 포함).
          const stZ = poiDroneHeightRef.current
            ? dronePose.altitude - (params.geoidOffset ?? 0) - droneHeightDropRef.current
            : stA.z;
          const cc = toCameraCoords(dronePose, stA.lat, stA.lon, stZ, params, worldOrigin);
          if (cc.Zc < CLIP_Z) return;
          const { pxRaw, pyRaw } = pixelFromCamera(cc, params);
          const prevDst = displayedStRef.current.get(stA.title);
          // 측점 라벨은 선형 실선과 픽셀 단위로 붙어 다녀야 하므로 화면 EMA 없이 즉시 투영
          // (실선도 매 프레임 즉시 투영 — djmd 프레임 동기 이후 입력이 매끄러워 평활 불필요.
          //  반올림 떨림은 아래 0.75px 히스테리시스가 계속 흡수).
          const d: DispPos = { x: pxRaw, y: pyRaw, rej: 0, vx: 0, vy: 0 };
          seenSt.add(stA.title);
          // 픽셀 히스테리시스: 0.75px 이상 움직일 때만 정수 위치 갱신 → 저속(1배속) 반올림 깜빡임 제거.
          const sx0 = vx(d.x), sy0 = vy(d.y);
          const x = (prevDst?.rx !== undefined && Math.abs(sx0 - prevDst.rx) <= 0.75) ? prevDst.rx : Math.round(sx0);
          const y = (prevDst?.ry !== undefined && Math.abs(sy0 - prevDst.ry) <= 0.75) ? prevDst.ry : Math.round(sy0);
          d.rx = x; d.ry = y;
          displayedStRef.current.set(stA.title, d);
          // 마커 선
          ctx.strokeStyle = 'rgba(255,100,100,0.95)'; ctx.lineWidth = 2.5;
          ctx.beginPath(); ctx.moveTo(x, y-10); ctx.lineTo(x, y+10); ctx.stroke();
          // 텍스트 테두리
          const lx = Math.max(2, x + 8);
          ctx.strokeStyle = 'rgba(0,0,0,0.85)'; ctx.lineWidth = 4;
          ctx.lineJoin = 'round';
          ctx.strokeText(cleanTitle(stA.title), lx, y);
          // 텍스트 본문
          ctx.fillStyle = 'rgba(255,200,200,1.0)';
          ctx.fillText(cleanTitle(stA.title), lx, y);
          // 히트박스(마커 선 ~ 글자 끝)
          const tw = ctx.measureText(cleanTitle(stA.title)).width;
          hitBoxes.push({ kind: 'station', title: stA.title, x0: x - 8, y0: y - 12, x1: lx + tw + 2, y1: y + 12 });
          if (labelOnScreen(x - 8, y - 12, lx + tw + 2, y + 12)) onScreenLabels.add(`station:${stA.title}`);
        });
        }

        // POI 마커 — 연속 포즈로 직접 투영 → 이상치 거부 → EMA
        ctx.font = 'bold 20px sans-serif';
        labelsA.poiMarkers.forEach((poiA) => {
          // 드론높이 모드면 연속 포즈 고도−이격거리, 아니면 저장된 지면고도 gz.
          const pz = poiDroneHeightRef.current
            ? dronePose.altitude - (params.geoidOffset ?? 0) - droneHeightDropRef.current
            : poiA.gz;
          const cc = toCameraCoords(dronePose, poiA.lat, poiA.lon, pz, params, worldOrigin);
          if (cc.Zc < CLIP_Z) return;
          const { pxRaw, pyRaw } = pixelFromCamera(cc, params);
          const prevDpoi = displayedPoiRef.current.get(poiA.title);
          // 프레임 동기(djmd) 데이터는 입력이 매끄러워 화면 EMA 불필요 — 즉시 투영으로
          // 팬(회전) 시 라벨이 늦게 따라오는 이질감 제거(선형·측점과 동일 타이밍).
          // 비동기 데이터셋(CSV 지터)만 기존 속도적응 EMA 유지.
          const d = frameSyncedRef.current
            ? { x: pxRaw, y: pyRaw, rej: 0, vx: 0, vy: 0 }
            : smoothStep(prevDpoi, pxRaw, pyRaw, α, smoothMinAlphaRef.current, smoothSpeedRefRef.current);
          seenPoi.add(poiA.title);
          // 픽셀 히스테리시스: 0.75px 이상 움직일 때만 정수 위치 갱신 → 저속(1배속) 깜빡임 제거.
          const psx0 = vx(d.x), psy0 = vy(d.y);
          const px = (prevDpoi?.rx !== undefined && Math.abs(psx0 - prevDpoi.rx) <= 0.75) ? prevDpoi.rx : Math.round(psx0);
          const py = (prevDpoi?.ry !== undefined && Math.abs(psy0 - prevDpoi.ry) <= 0.75) ? prevDpoi.ry : Math.round(psy0);
          d.rx = px; d.ry = py;
          displayedPoiRef.current.set(poiA.title, d);
          // 텍스트 박스만 표출(십자 마커/이모지 아이콘 없음):
          //   배경 = KML 텍스트박스_색상(poiA.color, 없으면 기본 하늘색), 글자 = 흰색.
          //   박스는 POI 지점(px)에 가로 중앙 정렬.
          const boxCol = poiA.color ?? '#64c8ff';
          // 라벨: 속성에 '구분'이 있으면 그 값(원문 그대로), 없으면 cleanTitle(title). poiCand에서 확정.
          const label = poiA.label;
          // 동일좌표 라벨 세로 배치: 행 중앙을 지점에 맞춰 위→아래로 안 겹치게 오프셋.
          const ROW_H = 28;
          const labelY = py + (poiA.labelRow - (poiA.labelRowCount - 1) / 2) * ROW_H;
          const tw = ctx.measureText(label).width;
          // 박스 높이 = 글자 크기(20px) × 1.2 — 라벨 상하 여유. 행 간격(ROW_H)도 같이 벌림.
          const PAD_X = 7, BOX_H = 24;
          const bx0 = Math.max(2, px - tw / 2 - PAD_X);
          const bx1 = bx0 + tw + PAD_X * 2;
          const by0 = labelY - BOX_H / 2, by1 = labelY + BOX_H / 2;
          ctx.fillStyle = boxCol;
          ctx.beginPath();
          if (typeof ctx.roundRect === 'function') ctx.roundRect(bx0, by0, bx1 - bx0, BOX_H, 5);
          else ctx.rect(bx0, by0, bx1 - bx0, BOX_H);
          ctx.fill();
          ctx.fillStyle = '#ffffff';
          ctx.textBaseline = 'middle';
          ctx.fillText(label, bx0 + PAD_X, labelY);
          // 히트박스 = 텍스트 박스 영역 (어디를 눌러도 선택)
          hitBoxes.push({ kind: 'poi', title: poiA.title, x0: bx0, y0: by0 - 2, x1: bx1 + 2, y1: by1 + 2 });
          const poiVisible = labelOnScreen(bx0, by0, bx1, by1);
          if (poiVisible) onScreenLabels.add(`poi:${poiA.title}`);
          // 연속 온스크린 프레임 수 갱신(화면 밖이면 0으로 리셋).
          const streak = poiVisible ? (poiOnScreenStreakRef.current.get(poiA.title) ?? 0) + 1 : 0;
          poiOnScreenStreakRef.current.set(poiA.title, streak);
          ctx.textBaseline = 'alphabetic';
          // 컴팩트 팝업 자동생성 후보 등록(sync 인터벌이 읽음). 조건:
          //   ① 라벨이 '연속' ADD_STREAK 프레임 이상 화면 안(경계 깜빡임에 팝업 재생성 방지),
          //   ② 컴팩트 필드 있음, ③ 단일 라벨(동일좌표 다중행 제외 → 클릭 시 개별 표시).
          const ADD_STREAK = 6;  // ~100ms 안정적으로 보여야 팝업 생성
          if (streak >= ADD_STREAK && poiA.compact.length && poiA.labelRowCount <= 1) visStructRef.current.set(poiA.title, { category: poiA.category, compact: poiA.compact });
        });

        // 이번 프레임에 안 보인 라벨의 표시상태 제거 → 재등장 시 새 위치에서 시작
        // (오래된 좌표가 남아 이상치 거부로 늦게 뜨는 것 방지)
        for (const k of displayedStRef.current.keys()) if (!seenSt.has(k)) displayedStRef.current.delete(k);
        for (const k of displayedPoiRef.current.keys()) if (!seenPoi.has(k)) displayedPoiRef.current.delete(k);
        // poiMarkers 에서 빠진 POI 는 streak 도 정리(재등장 시 0부터 다시 쌓임).
        for (const k of poiOnScreenStreakRef.current.keys()) if (!seenPoi.has(k)) poiOnScreenStreakRef.current.delete(k);
      }
      } // end if(visible) — 측점/POI 라벨
      } // end if(cache) — 중심선/드론경로/측점/POI

      // 편집/FOV 보정 모드: 드래그 중 마커 피드백 (마우스 따라다님)
      if ((editModeRef.current || fovModeRef.current || yawModeRef.current) && dragRef.current && dragPosRef.current) {
        const dx = vx(dragPosRef.current.x), dy = vy(dragPosRef.current.y);
        ctx.strokeStyle = '#ffe14d'; ctx.lineWidth = 2;
        ctx.beginPath(); ctx.arc(dx, dy, 14, 0, Math.PI * 2); ctx.stroke();
        ctx.beginPath();
        ctx.moveTo(dx - 22, dy); ctx.lineTo(dx + 22, dy);
        ctx.moveTo(dx, dy - 22); ctx.lineTo(dx, dy + 22);
        ctx.stroke();
        ctx.fillStyle = '#ffe14d'; ctx.font = 'bold 13px sans-serif';
        ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
        ctx.strokeStyle = 'rgba(0,0,0,0.85)'; ctx.lineWidth = 4; ctx.lineJoin = 'round';
        ctx.strokeText(dragRef.current.title, dx + 26, dy);
        ctx.fillText(dragRef.current.title, dx + 26, dy);
        ctx.textAlign = 'left'; ctx.textBaseline = 'alphabetic';
      }



      // 라벨 속성 팝업 위치 — 해당 라벨의 현재 표시좌표를 따라 매 프레임 갱신(재생 중 이동).
      //   라벨이 화면에서 사라지면(필터/화면밖) 팝업 숨김, 다시 보이면 표시.
      if (popupElsRef.current.size) {
        const toRemove: string[] = [];
        popupElsRef.current.forEach((el, id) => {
          const sep = id.indexOf(':');
          const kind = id.slice(0, sep), title = id.slice(sep + 1);
          const disp = (kind === 'poi' ? displayedPoiRef.current : displayedStRef.current).get(title);
          // 팝업 표시 = '이 프레임에 라벨이 실제로 화면 안에 그려졌는가'(onScreenLabels)로만 판정.
          //   좌표 ±16 슬랙 + clamp 조합은 라벨(특히 텍스트)이 화면 밖인데 팝업만 clamp 되어 뜨는
          //   문제를 만들었음 → 라벨 가시성과 완전히 일치시킨다.
          const sx = disp ? offX + disp.x * dispW : 0, sy = disp ? offY + disp.y * dispH : 0;
          const off = !disp || !onScreenLabels.has(id);
          if (off) {
            // 라벨이 화면에서 벗어나면 '즉시' 제거(유예 없음) → 하단 이탈 즉시 사라짐.
            //   유예를 두면 그 사이 흔들림으로 라벨이 경계로 잠깐 되돌아올 때 팝업이 다시 보였다 사라짐.
            //   재등장은 sync 의 ADD_STREAK(연속 안정 프레임) 게이트가 막는다(짧은 복귀로는 재생성 안 됨).
            el.style.visibility = 'hidden';
            toRemove.push(id);
            return;
          }
          const ph = el.offsetHeight || 120;
          const pw = el.offsetWidth || 240;
          // 라벨(십자+글자)은 sy 를 중심으로 상하 ~LABEL_HALF 만큼 차지한다. GAP 을 sy(중심) 기준으로
          //   잡으면 라벨 높이만큼 먹혀 팝업이 글자에 붙는다 → 라벨 '바깥 가장자리' 기준으로 띄운다.
          const LABEL_HALF = 15;             // 라벨 반높이(px) — sy 중심 ± 이 값이 글자/아이콘 영역
          const GAP = 12;                    // 라벨 가장자리 ~ 팝업 사이 실제 여백
          // POI 라벨은 지점(sx) 기준 가로 중앙 정렬 박스 → 팝업도 박스 중앙 아래로 정렬.
          // 측점 라벨은 마커 오른쪽으로 그려지므로 기존(좌측 여유 10px) 유지.
          const txRaw = kind === 'poi' ? sx - pw / 2 : sx - 10;
          const tx = Math.min(Math.max(8, txRaw), Math.max(8, W - pw - 4));
          // 기본은 라벨 '아래'. 아래 공간이 부족하면(화면 하단 근접) 라벨 '위'로 플립.
          //   ※ 히스테리시스(sticky): 경계에서 sy 가 미세하게 오르내려도 위↔아래로 왕복하지 않도록,
          //     '위'로 간 뒤엔 아래에 HYST 이상 여유가 생겨야만 '아래'로 복귀. (왕복=팝업 순간이동=번쩍임 방지)
          // 팝업은 '항상 라벨 아래'(flip 없음, 하단 클램프 없음). 라벨을 따라 자유롭게 내려가다
          //   스테이션바 영역으로 진입하면 팝업 컨테이너의 overflow-hidden(HB 아래 클립)이 시각적으로
          //   가린다 → 바 뒤로 슬라이드되는 것처럼 보임. 라벨이 바 밑(HB)으로 내려가면 위 off 판정으로
          //   팝업 제거(라벨과 함께 사라짐). (이전 flip=라벨 위로 튐 / clamp=바에 걸침 문제 모두 제거)
          let ty = Math.max(2, sy + LABEL_HALF + GAP);
          el.style.visibility = 'visible';
          const pp = popupPosRef.current.get(id);
          const PA = 0.18;
          // flip(위↔아래) 같은 큰 점프는 스냅 → 라벨 위를 천천히 통과하는 잔상 방지.
          const bigJump = !!pp && Math.abs(ty - pp.y) > ph * 0.7;
          const nx = pp ? pp.x + (tx - pp.x) * PA : tx;
          const ny = (pp && !bigJump) ? pp.y + (ty - pp.y) * PA : ty;
          popupPosRef.current.set(id, { x: nx, y: ny });
          el.style.left = `${Math.round(nx)}px`;
          el.style.top = `${Math.round(ny)}px`;
        });
        if (toRemove.length) {
          toRemove.forEach(id => { popupMissRef.current.delete(id); popupPosRef.current.delete(id); popupFlipRef.current.delete(id); });
          setInfoPopups(prev => prev.filter(p => !toRemove.includes(p.id)));
        }
      }

      labelHitRef.current = hitBoxes;   // 이번 프레임 라벨 히트박스 확정(라벨 없으면 빈 배열)

      // 범례(선로중심선/지장물 개수)는 좌상단 카메라파라미터·스테이션맵과 겹쳐 제거함.
    };

    rafId = requestAnimationFrame(draw);
    return () => cancelAnimationFrame(rafId);
  }, []);

  // ── POI 편집(드래그) ───────────────────────────────────────────────────────
  const fileInputRef = useRef<HTMLInputElement>(null);

  // 현재 프레임의 평활 드론 자세 (역투영 기준)
  const getSmoothedDrone = useCallback((): DroneFrameBasic | null => {
    const frames = allDroneFramesRef.current;
    if (frames.length) return smoothFrame(frames, currentFrameIdxRef.current, poseHalf());
    return currentDroneFrameRef.current;
  }, [smoothFrame, poseHalf]);

  // 화면 클릭좌표(client px) → 영상프레임 정규좌표(0~1). object-fit:cover 역변환(coverRef).
  const clientToVideoNorm = useCallback((clientX: number, clientY: number): { x: number; y: number } => {
    const canvas = canvasRef.current;
    if (!canvas) return { x: 0, y: 0 };
    const rect = canvas.getBoundingClientRect();
    const c = coverRef.current;
    const cxpx = ((clientX - rect.left) / rect.width) * (c.W || rect.width);
    const cypx = ((clientY - rect.top) / rect.height) * (c.H || rect.height);
    return {
      x: c.dispW ? (cxpx - c.offX) / c.dispW : (clientX - rect.left) / rect.width,
      y: c.dispH ? (cypx - c.offY) / c.dispH : (clientY - rect.top) / rect.height,
    };
  }, []);

  // 라벨 히트테스트 — 아이콘+글자 바운딩박스(화면 px) 안인지. 정규좌표(영상프레임) 입력을 px로 변환.
  const hitTestLabel = useCallback((nx: number, ny: number): { title: string; kind: 'poi' | 'station' } | null => {
    const c = coverRef.current;
    const cxpx = c.offX + nx * c.dispW, cypx = c.offY + ny * c.dispH;
    const boxes = labelHitRef.current;
    for (let i = boxes.length - 1; i >= 0; i--) {   // 나중에 그린(위) 라벨 우선
      const b = boxes[i];
      if (cxpx >= b.x0 && cxpx <= b.x1 && cypx >= b.y0 && cypx <= b.y1) return { title: b.title, kind: b.kind };
    }
    return null;
  }, []);

  const onPoiPointerDown = useCallback((e: React.PointerEvent<HTMLCanvasElement>) => {
    const { x: nx, y: ny } = clientToVideoNorm(e.clientX, e.clientY);

    // 편집/세로화각 보정: POI 선택 + 드래그
    if (editModeRef.current || fovModeRef.current || yawModeRef.current) {
      const canvas = canvasRef.current; if (!canvas) return;
      const hit = hitTestLabel(nx, ny);
      if (!hit) return;
      const poi = allPoisRef.current.concat(allStructuresRef.current).find(p => p.title === hit.title);
      if (!poi) return;
      const baseZ = (poi.z > 0 ? poi.z : nearestCL(poi.lat, poi.lon)?.z) ?? poi.z;  // 실측 z 우선(가시 gz 와 동일 규칙)
      const poiZ = overridesRef.current[hit.title] ? poi.z : baseZ;
      setSelPoi(hit.title); setSelZ(poiZ); setSelZBase(baseZ);
      dragRef.current = { title: hit.title, lat: poi.lat, lon: poi.lon, z0: poiZ, sx: nx, sy: ny };
      dragPosRef.current = { x: nx, y: ny };
      setDragTitle(hit.title);
      try { canvas.setPointerCapture(e.pointerId); } catch { /* noop */ }
      return;
    }

    // 기본: 캔버스가 모든 클릭을 받음 →
    //   - 라벨 클릭: 팝업 있으면 전체↔컴팩트 토글, 없으면 전체 팝업 추가.
    //   - 빈 곳 클릭: 펼쳐진/수동 팝업 정리(컴팩트로), 없으면 재생/정지 토글.
    const hit = hitTestLabel(nx, ny);
    if (!hit) {
      const cur = infoPopupsRef.current;
      if (cur.some(p => p.expanded || !p.auto)) {
        setInfoPopups(prev => prev.filter(p => p.auto).map(p => ({ ...p, expanded: false })));
      } else {
        onTogglePlayRef.current?.();
      }
      return;
    }
    const id = `${hit.kind}:${hit.title}`;
    // 이미 팝업이 있으면(자동 컴팩트 포함) 클릭으로 전체↔컴팩트 토글.
    if (infoPopupsRef.current.some(p => p.id === id)) {
      setInfoPopups(prev => prev.map(p => p.id === id ? { ...p, expanded: !p.expanded } : p));
      return;
    }
    const obj = hit.kind === 'poi'
      ? allPoisRef.current.concat(allStructuresRef.current).find(p => p.title === hit.title)
      : allGeoStationsRef.current.find(s => s.title === hit.title);
    if (!obj) return;
    const groundZ = overridesRef.current[hit.title]?.z ?? (obj.z > 0 ? obj.z : nearestCL(obj.lat, obj.lon)?.z) ?? obj.z;  // 실측 z 우선
    const drone = getSmoothedDrone();
    const dist = drone
      ? toCameraCoords(drone, obj.lat, obj.lon, groundZ, paramsRef.current, worldOriginRef.current).distH ?? null
      : null;
    const disp = (hit.kind === 'poi' ? displayedPoiRef.current : displayedStRef.current).get(hit.title)!;
    const c = coverRef.current;
    const compact = compactFieldsOf(obj.props);
    setInfoPopups(prev => [...prev, {
      id, kind: hit.kind, title: hit.title, category: obj.category, lat: obj.lat, lon: obj.lon, z: groundZ, dist,
      sx: c.offX + disp.x * c.dispW, sy: c.offY + disp.y * c.dispH, props: obj.props,
      compact, auto: false, expanded: compact.length === 0,   // 컴팩트필드 없으면 바로 전체
    }]);
  }, [nearestCL, clientToVideoNorm, getSmoothedDrone, hitTestLabel]);

  const onPoiPointerMove = useCallback((e: React.PointerEvent<HTMLCanvasElement>) => {
    if (!dragRef.current) return;
    dragPosRef.current = clientToVideoNorm(e.clientX, e.clientY);
  }, [clientToVideoNorm]);

  // 캔버스 입력/커서 — Video.js controls:false 라 영상은 클릭을 처리 안 함 → 캔버스가 모든 클릭을
  //   받아 직접 분기(라벨=팝업 / 빈 곳=팝업닫기 or 재생토글). 커서는 라벨 위에서만 손모양.
  useEffect(() => {
    const canvas = canvasRef.current; if (!canvas) return;
    const parent = canvas.parentElement; if (!parent) return;
    // 영상이 준비되기 전(폴더 선택 화면 등)엔 입력을 받지 않음 → 하위 UI(폴더 선택/드롭) 클릭 통과.
    if (!videoReady) { canvas.style.pointerEvents = 'none'; return; }
    canvas.style.pointerEvents = 'auto';
    canvas.style.cursor = (editMode || fovMode || yawMode) ? 'move' : 'default';
    if (editMode || fovMode || yawMode) { setInfoPopups([]); return; }
    const onMove = (e: MouseEvent) => {
      const { x: nx, y: ny } = clientToVideoNorm(e.clientX, e.clientY);
      canvas.style.cursor = hitTestLabel(nx, ny) ? 'pointer' : 'default';
    };
    parent.addEventListener('mousemove', onMove);
    return () => { parent.removeEventListener('mousemove', onMove); };
  }, [videoReady, editMode, fovMode, yawMode, clientToVideoNorm, hitTestLabel]);

  const onPoiPointerUp = useCallback(() => {
    const drag = dragRef.current, pos = dragPosRef.current;
    dragRef.current = null; dragPosRef.current = null; setDragTitle(null);
    if (!drag || !pos) return;
    // 거의 안 움직였으면 '클릭=선택'으로 보고 변경 안 함.
    if (Math.hypot(pos.x - drag.sx, pos.y - drag.sy) < 0.012) return;
    const drone = getSmoothedDrone(); if (!drone) return;
    const p = paramsRef.current, wo = worldOriginRef.current;

    // ── Yaw(방위 오프셋) 보정 ──
    // 드롭한 화면점의 '가로'(px)만 맞춘다. 라벨의 절대 방위는 고정이므로,
    //   현재 카메라축 기준 수평각 θ0 = atan2(Xc, Zc) 과 드롭 지점이 요구하는
    //   수평각 θ1 = atan((px* − 0.5 − cx0)·sW / f) 의 차이만큼 카메라 방위를 돌리면 됨:
    //   yawOffset_new = yawOffset_old + (θ0 − θ1)
    if (yawModeRef.current) {
      const poiZ = poiDroneHeightRef.current
        ? drone.altitude - (p.geoidOffset ?? 0) - droneHeightDropRef.current
        : drag.z0;
      const cc = toCameraCoords(drone, drag.lat, drag.lon, poiZ, p, wo);
      if (cc.Zc < 0.1) return;
      const th0 = Math.atan2(cc.Xc, cc.Zc);
      const th1 = Math.atan(((pos.x - 0.5 - (p.cx0 ?? 0)) * (p.sensorW ?? 36)) / p.focalLen);
      const dYaw = (th0 - th1) * 180 / Math.PI;
      if (!isFinite(dYaw) || Math.abs(dYaw) > 45) { alert('보정량이 비정상적으로 큽니다 — 올바른 지물 위치로 끌었는지 확인하세요.'); return; }
      const next = Math.max(-180, Math.min(180, (p.yawOffset ?? 0) + dYaw));
      setParam('yawOffset', Math.round(next * 10) / 10);
      console.log(`[yaw] 드래그 역산: Δ${dYaw.toFixed(2)}° → Yaw± ${next.toFixed(1)}°`);
      return;
    }

    // ── 세로 화각(sensorH) 보정 ──
    // 드롭한 화면점의 '세로'(py)만 맞춘다. 가로(f/sW)는 이미 정확하므로 건드리지 않음
    //   (f 를 바꾸면 가로까지 틀어져 스테이션선이 좌우로 벗어남).
    //   py = 0.5 + cy0 + (Yc/Zc)·(f/sH)  →  sH = (Yc/Zc)·f / (py* − 0.5 − cy0)
    // sH 만 바꾸므로 세로 배율(상하 벌어짐)만 교정됨. 중심에서 충분히 떨어진 POI 로 보정.
    if (fovModeRef.current) {
      // 렌더와 동일한 poiZ (드론높이 모드면 드론−이격거리, 아니면 선택 z).
      const poiZ = poiDroneHeightRef.current
        ? drone.altitude - (p.geoidOffset ?? 0) - droneHeightDropRef.current
        : drag.z0;
      const cc = toCameraCoords(drone, drag.lat, drag.lon, poiZ, p, wo);
      if (cc.Zc < 0.1) return;
      const b = cc.Yc / cc.Zc;
      const v = pos.y - 0.5 - (p.cy0 ?? 0);
      // 화면 세로 중심 근처면(v≈0) 역산이 불안정 → 무시(중심에서 떨어진 POI 사용 안내).
      if (Math.abs(v) < 0.03 || Math.abs(b) < 1e-6) { alert('세로 중심에서 더 떨어진 POI로 보정하세요(위/아래).'); return; }
      const sHNew = Math.max(6, Math.min(36, (b * p.focalLen) / v));
      setParam('sensorH', sHNew);
      return;
    }

    // ── 위치(lat/lon) 보정 ── 드롭한 화면점을 지면(현재 z)과 교차해 좌표 갱신.
    const ll = groundPointFromPixel(drone, pos.x, pos.y, drag.z0, p, wo);
    if (ll) setPoiOverride(drag.title, { lat: ll.lat, lon: ll.lon, z: drag.z0 });
  }, [getSmoothedDrone, setPoiOverride, setParam]);

  // 선택된 POI의 표고를 슬라이더로 직접 설정 (lat/lon 고정).
  const setSelectedZ = useCallback((z: number) => {
    setSelZ(z);
    if (!selPoi) return;
    const poi = allPoisRef.current.concat(allStructuresRef.current).find(p => p.title === selPoi);
    if (poi) setPoiOverride(selPoi, { lat: poi.lat, lon: poi.lon, z });
  }, [selPoi, setPoiOverride]);

  const exportOverrides = useCallback(() => {
    const data = { baseName: storeBaseName, overrides: storePoiOverrides };
    const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = `${storeBaseName ?? 'poi'}_poi_overrides.json`;
    a.click();
    URL.revokeObjectURL(url);
  }, [storeBaseName, storePoiOverrides]);

  const importOverridesFile = useCallback((file: File) => {
    file.text().then(t => {
      try {
        const obj = JSON.parse(t);
        const map = (obj && typeof obj === 'object' && obj.overrides) ? obj.overrides : obj;
        setPoiOverrides(map as PoiOverrideMap);
      } catch { alert('보정 파일 파싱 실패'); }
    });
  }, [setPoiOverrides]);

  // DEM 표고 자동적용 — 현재 POI/구조물 좌표로 지면고도(open-meteo, EGM2008 정표고≈측점)를
  // 일괄 조회해 override z로 설정. 선로표고 가정 대신 실제 지형고도 → 멀리서 밀리는 슬라이드 완화.
  // (90m DEM이라 절벽/구조물 인접은 부정확할 수 있음 → 그런 건 드래그로 미세보정.)
  const applyDemElevations = useCallback(async () => {
    const all = allPoisRef.current.concat(allStructuresRef.current);
    if (!all.length) { alert('POI가 없습니다'); return; }
    setDemBusy(true);
    try {
      const next: PoiOverrideMap = { ...useGeoStore.getState().poiOverrides };
      const CH = 100;
      for (let i = 0; i < all.length; i += CH) {
        const chunk = all.slice(i, i + CH);
        const lat = chunk.map(p => p.lat).join(',');
        const lon = chunk.map(p => p.lon).join(',');
        // 서버 프록시(같은 출처) — COEP/CSP로 외부 직접 호출이 막혀 서버가 중계.
        const r = await fetch(`/api/elevation?lat=${lat}&lon=${lon}`);
        const j = await r.json();
        const elev = j?.elevation;
        if (Array.isArray(elev)) {
          chunk.forEach((p, k) => {
            if (typeof elev[k] !== 'number') return;
            // DEM(SRTM30m)이 그 좌표 지점의 지면고도 → 그대로 사용(구글 지면고도와 일치).
            // (이전엔 선로표고로 상한을 걸었으나, 실제 지형 상승까지 깎아 부정확 → 제거.
            //  드물게 옥상고도로 뜨는 건물은 드래그/표고슬라이더로 개별 보정.)
            next[p.title] = { lat: p.lat, lon: p.lon, z: elev[k] };
          });
        }
      }
      setPoiOverrides(next);
    } catch (e) {
      alert('DEM 표고 조회 실패 (인터넷/CORS 확인): ' + e);
    } finally {
      setDemBusy(false);
    }
  }, [setPoiOverrides]);

  // 화면표시 옵션 일괄 복귀 — 환경설정 파일(*.display.json)이 폴더에 있으면 **그 파일 값**으로,
  // 없으면 DISPLAY_DEFAULTS 로 복원 (파일에 없는 항목도 기본값). POI 보정/편집모드는 별도 버튼 유지.
  const resetDisplayDefaults = useCallback(() => {
    const d = (useGeoStore.getState().displayJson ?? {}) as Record<string, unknown>;
    const num = (k: string, def: number): number =>
      typeof d[k] === 'number' && isFinite(d[k] as number) ? (d[k] as number) : def;
    const bool = (k: string, def: boolean): boolean =>
      typeof d[k] === 'boolean' ? (d[k] as boolean) : def;
    setShowCenterline(bool('showCenterline', DISPLAY_DEFAULTS.showCenterline));
    setShowDronePath(bool('showDronePath', DISPLAY_DEFAULTS.showDronePath));
    setDronePathZ(num('dronePathZ', DISPLAY_DEFAULTS.dronePathZ));
    setDronePathAlpha(num('dronePathAlpha', DISPLAY_DEFAULTS.dronePathAlpha));
    setSmoothHalf(num('smoothHalf', DISPLAY_DEFAULTS.smoothHalf));
    setEmaAlpha(num('emaAlpha', DISPLAY_DEFAULTS.emaAlpha));
    setSmoothMinAlpha(num('smoothMinAlpha', DISPLAY_DEFAULTS.smoothMinAlpha));
    setSmoothSpeedRef(num('smoothSpeedRef', DISPLAY_DEFAULTS.smoothSpeedRef));
    setMaxPoiRange(num('maxPoiRange', DISPLAY_DEFAULTS.maxPoiRange));
    setPoiDroneHeight(bool('poiDroneHeight', DISPLAY_DEFAULTS.poiDroneHeight));
    setDroneHeightDrop(num('droneHeightDrop', DISPLAY_DEFAULTS.droneHeightDrop));
  }, []);

  // 카메라 파라미터 초기화 — 환경설정 파일(*.camera.json)이 폴더에 있으면 **그 파일 값**으로,
  // 없으면 DEFAULT_CAMERA_PARAMS 로 복원 (파일에 없는 항목도 기본값).
  const resetCameraParams = useCallback(() => {
    const cam = useGeoStore.getState().cameraJson;
    const next = { ...DEFAULT_CAMERA_PARAMS };
    if (cam) {
      for (const [k, v] of Object.entries(cam)) {
        if (k in next && typeof v === 'number' && isFinite(v)) {
          (next as unknown as Record<string, number>)[k] = v;
        }
      }
    }
    setParams(next);
  }, []);

  return (
    <>
      <canvas
        ref={canvasRef}
        className="absolute inset-0 z-20"
        style={{ width: '100%', height: '100%' }}
        onPointerDown={onPoiPointerDown}
        onPointerMove={onPoiPointerMove}
        onPointerUp={onPoiPointerUp}
        onPointerCancel={onPoiPointerUp}
      />

      {/* 나침반 미니맵 — 우측 상단(기존 캔버스 나침반 대체). 핀은 RAF 가 드론 방위로 회전. */}
      {geoDataLoaded && (compassType === 'map'
        ? <MapCompass poseRef={mapPoseRef} />
        : <Minimap ref={minimapRef} />)}

      {/* 라벨 속성 팝업(다중) — 위치는 RAF 가 라벨을 따라 갱신. DOM 이라 텍스트 선명.
          컴팩트(기본 5필드) ↔ 전체 토글은 팝업 클릭. 자동(auto) 팝업은 ✕ 없음(가시 구조물 따라 표시).
          ▶ 클립 컨테이너: 높이 = 영상영역(H − barHeight). overflow-hidden 으로 스테이션바 영역을 가려
            팝업이 라벨 따라 내려가면 바 뒤로 슬라이드되어 사라짐(flip/걸침 없이). */}
      <div className="absolute left-0 right-0 top-0 overflow-hidden pointer-events-none z-40" style={{ bottom: barHeight }}>
      {infoPopups.map(pp => {
        const showFull = pp.expanded || !pp.compact || pp.compact.length === 0;
        // 전체 보기: 컴팩트 항목을 같은 순서로 맨 위에 + 나머지 속성 → 클릭 전/후 순서 일치.
        const fields = showFull
          ? (pp.props && pp.props.length > 0 ? orderedFullFields(pp.compact ?? [], pp.props) : null)
          : pp.compact!;
        return (
        <div
          key={pp.id}
          ref={el => {
            if (el) {
              // RAF 가 위치를 확정(popupPosRef 등록)하기 전인 '신규' 팝업은 숨겨서 mount →
              //   화면 밖 라벨의 팝업이 직전(stale) 위치에 한 프레임 번쩍이는 것 방지.
              //   RAF 가 온스크린 확인 후 visibility:visible + 위치 지정(off 면 계속 숨김→제거).
              //   visibility 는 RAF 가 명령형으로만 관리(style prop 미포함) → 재렌더에도 유지.
              if (!popupPosRef.current.has(pp.id)) el.style.visibility = 'hidden';
              popupElsRef.current.set(pp.id, el);
            } else popupElsRef.current.delete(pp.id);
          }}
          onClick={() => setInfoPopups(prev => prev.map(p => p.id === pp.id ? { ...p, expanded: !p.expanded } : p))}
          title={showFull ? '클릭 → 접기' : '클릭 → 전체 항목'}
          className="absolute z-40 max-w-[260px] bg-black/90 border border-emerald-500/70 rounded-md shadow-xl text-white text-[11px] px-2 py-1.5 pointer-events-auto cursor-pointer"
          style={{ left: Math.min(Math.max(8, pp.sx - 10), (canvasSizeRef.current.w || 9999) - 240), top: Math.min(Math.max(8, pp.sy + 27), (canvasSizeRef.current.h || 9999) - 140) }}
        >
          {!pp.auto && (
            <button onClick={e => { e.stopPropagation(); setInfoPopups(prev => prev.filter(p => p.id !== pp.id)); }} className="absolute top-0.5 right-1 text-gray-400 hover:text-white leading-none text-[12px]">✕</button>
          )}
          <div className="grid grid-cols-[auto_1fr] gap-x-2 gap-y-0.5 font-mono text-[10px] text-gray-300 max-h-[45vh] overflow-y-auto">
            {fields ? (
              fields.map((p, i) => (
                <React.Fragment key={i}>
                  <span className="text-gray-500 whitespace-nowrap">{p.k}</span>
                  <span className="break-all">{p.v}</span>
                </React.Fragment>
              ))
            ) : (
              <>
                <span className="text-gray-500">위도</span><span>{pp.lat.toFixed(6)}</span>
                <span className="text-gray-500">경도</span><span>{pp.lon.toFixed(6)}</span>
              </>
            )}
            {/* 표고(z) — KML 좌표 3번째 성분(타원체고). 속성표(SimpleData)에 없어서 별도 표기.
                (지장물의 '높이' 속성은 구조물 높이라 표고와 구분. 컴팩트 팝업은 간결 유지) */}
            {showFull && pp.z > 0 && (
              <>
                <span className="text-gray-500 whitespace-nowrap">표고</span>
                <span className="break-all">{pp.z.toFixed(1)} m (타원체고)</span>
              </>
            )}
          </div>
        </div>
        );
      })}
      </div>

      {showPanel && (
      <div className="absolute right-2 z-30 flex flex-col-reverse items-end gap-2" style={{ bottom: (barHeight || 130) + 8 }}>
        {/* 버튼 순서 = 패널 위치와 일치: 카메라(왼쪽 패널) → 화면표시(오른쪽 패널) */}
        <div className="flex gap-2">
          <button onClick={() => setShowControls(v => !v)}
            className={`text-xs whitespace-nowrap px-2 py-1 rounded border ${showControls ? 'bg-amber-400 border-amber-300 text-black' : 'bg-black/70 hover:bg-black/90 text-gray-300 border-gray-600'}`}>
            {showControls ? '▲ 카메라 파라미터' : '▼ 카메라 파라미터'}
          </button>
          <button onClick={() => setShowDisplay(v => !v)}
            className={`text-xs whitespace-nowrap px-2 py-1 rounded border ${showDisplay ? 'bg-amber-400 border-amber-300 text-black' : 'bg-black/70 hover:bg-black/90 text-gray-300 border-gray-600'}`}>
            {showDisplay ? '▲ 화면표시 옵션' : '▼ 화면표시 옵션'}
          </button>
        </div>

        {/* 두 패널을 가로로 나란히(우→좌) 펼침 — 둘 다 열어도 세로로 안 쌓여 화면 위로 안 넘침. */}
        <div className="flex flex-row-reverse items-end gap-2">
        {/* ── 화면표시 옵션 (카메라 외 모든 표시 설정) ── */}
        {showDisplay && (
          <div className="bg-black/90 border border-sky-700 rounded p-3 text-white w-72 select-none max-h-[80vh] overflow-y-auto shadow-xl">
            <div className="flex items-center justify-between border-b border-gray-700 pb-2 mb-2">
              <span className="text-[10px] text-gray-500 uppercase tracking-wider">표시 토글</span>
              {/* 화면표시 옵션 전체를 display.json 으로 PC 저장 — 폴더에 넣어두면 자동 적용 */}
              <button
                type="button"
                onClick={saveDisplayToPc}
                className={`text-[10px] px-1.5 py-0.5 rounded border ${dispSaveState === 'ok' ? 'border-emerald-400 bg-emerald-700/60 text-emerald-100' : 'border-gray-600 bg-gray-700 hover:bg-gray-600 text-gray-200'}`}
                title="현재 화면표시 옵션 전체(토글/슬라이더/시설등급 등)를 display.json 으로 PC에 다운로드 — 영상 폴더에 넣어두면 폴더 선택 시 자동 적용"
              >{dispSaveState === 'ok' ? '저장됨 ✓' : 'PC에 저장'}</button>
            </div>
            {/* 나침반 타입 — 눈금(analog) / 지도(OSM, 노스업) 선택 */}
            <div className="flex items-center gap-1.5 text-[11px] mb-3">
              <span className="text-gray-400 w-12 shrink-0 text-right">나침반</span>
              <div className="flex-1 flex gap-1">
                <button
                  onClick={() => setCompassType('analog')}
                  className={`flex-1 px-2 py-1 rounded border text-[11px] ${compassType === 'analog' ? 'bg-amber-400 border-amber-300 text-black' : 'bg-black/60 border-gray-700 text-gray-300 hover:bg-black/80'}`}
                >눈금</button>
                <button
                  onClick={() => setCompassType('map')}
                  className={`flex-1 px-2 py-1 rounded border text-[11px] ${compassType === 'map' ? 'bg-amber-400 border-amber-300 text-black' : 'bg-black/60 border-gray-700 text-gray-300 hover:bg-black/80'}`}
                  title="지도 위 현재 위치(노스업). 인터넷 필요."
                >지도</button>
              </div>
            </div>
            {/* 지도 나침반 배경 — 일반(OSM) / 위성(Esri). 지도 타입일 때만 노출. */}
            {compassType === 'map' && (
              <div className="flex items-center gap-1.5 text-[11px] mb-3">
                <span className="text-gray-400 w-12 shrink-0 text-right">지도</span>
                <div className="flex-1 flex gap-1">
                  <button
                    onClick={() => setCompassMapStyle('street')}
                    className={`flex-1 px-2 py-1 rounded border text-[11px] ${compassMapStyle === 'street' ? 'bg-amber-400 border-amber-300 text-black' : 'bg-black/60 border-gray-700 text-gray-300 hover:bg-black/80'}`}
                  >일반</button>
                  <button
                    onClick={() => setCompassMapStyle('sat')}
                    className={`flex-1 px-2 py-1 rounded border text-[11px] ${compassMapStyle === 'sat' ? 'bg-amber-400 border-amber-300 text-black' : 'bg-black/60 border-gray-700 text-gray-300 hover:bg-black/80'}`}
                    title="Esri World Imagery 위성 영상"
                  >위성</button>
                </div>
              </div>
            )}
            <div className="space-y-2 mb-3">
              {/* 선형/드론궤적/좌측패널 토글은 하단 재생바(VideoPlayer)로 이동. 측점 진단 버튼은 삭제. */}
              {/* 경로표고·투명도는 드론궤적 ON/OFF 무관하게 항상 표시. */}
              <ParamRow label="경로 표고" tip={`GPS 측정점(노란 점)을 표고 ${dronePathZ}m 에 투영·연결`} value={dronePathZ} min={-10} max={pathZMax} step={1} unit="m" decimals={0} onChange={v => setDronePathZ(Math.round(v))} />
              <ParamRow label="투명도" tip={`드론 궤적 선/점 투명도 α=${dronePathAlpha.toFixed(2)}`} value={dronePathAlpha} min={0.1} max={1.0} step={0.05} unit="" decimals={2} onChange={v => setDronePathAlpha(v)} />
            </div>
            <div className="text-[10px] text-gray-500 uppercase tracking-wider mb-1.5 border-t border-gray-700 pt-2">스무딩 <span className="text-gray-600">(재계산 500ms 후)</span></div>
            <div className="mb-3 space-y-2">
              <ParamRow label="smooth" tip={`±${smoothHalf}fr = ±${(smoothHalf * 1000 / (30000/1001)).toFixed(0)}ms · 드론 자세 이동평균 반폭(최대) — 회전 중엔 자동 축소로 즉시 추종`} value={smoothHalf} min={0} max={60} step={1} unit="fr" decimals={0} onChange={v => setSmoothHalf(Math.round(v))} />
              <ParamRow label="EMA α" tip={`α=${emaAlpha.toFixed(2)} → lag≈${(1000/60*(1/emaAlpha - 1)).toFixed(0)}ms · 화면좌표 평활 상한(빠를 때 추종 한계, ↑반응/↓부드러움)`} value={emaAlpha} min={0.01} max={1.0} step={0.01} unit="" decimals={2} onChange={v => setEmaAlpha(v)} />
              <ParamRow label="떨림억제" tip="정지/느릴 때 최소 추종(α min). 작을수록 1배속 떨림이 더 강하게 평활됨(지연 약간↑). 기본 0.12" value={smoothMinAlpha} min={0.02} max={0.6} step={0.01} unit="" decimals={2} onChange={v => setSmoothMinAlpha(v)} />
              <ParamRow label="반응속도" tip="이 화면속도(정규/프레임) 이상이면 즉시 추종. 키우면 더 강하게 평활(느린 이동까지 부드럽게), 줄이면 더 빨리 반응. 기본 0.010" value={smoothSpeedRef} min={0.002} max={0.04} step={0.001} unit="" decimals={3} onChange={v => setSmoothSpeedRef(v)} />
            </div>
            <div className="text-[10px] text-gray-500 uppercase tracking-wider mb-1.5 border-t border-gray-700 pt-2">POI 필터 / 높이</div>
            <div className="mb-3 space-y-2">
              <ParamRow label="POI 범위" tip="드론 위치와의 수평 직선거리 — 이 범위 안의 POI만 표시(밖은 제외)" value={maxPoiRange} min={10} max={1000} step={10} unit="m" decimals={0} onChange={v => setMaxPoiRange(Math.round(v))} />
              <button onClick={() => setPoiDroneHeight(v => !v)}
                className={`w-full text-[11px] px-2 py-1 rounded border transition-colors ${poiDroneHeight ? 'bg-amber-500/80 border-amber-300 text-black font-bold' : 'bg-black/60 border-gray-600 text-gray-200 hover:border-gray-400'}`}>
                {poiDroneHeight ? `■ 드론 높이와 POI 이격거리 ${droneHeightDrop}m` : '▶ 드론 높이와 POI 이격거리'}
              </button>
              {poiDroneHeight && (
                <ParamRow label="이격거리" tip={`POI를 드론보다 ${droneHeightDrop}m 아래에 배치 (값↑ = POI 아래로)`} value={droneHeightDrop} min={0} max={300} step={1} unit="m" decimals={0} onChange={v => setDroneHeightDrop(Math.round(v))} />
              )}
            </div>
            <div className="text-[10px] text-gray-500 uppercase tracking-wider mb-1.5 border-t border-gray-700 pt-2">POI 위치 편집 (마우스)
              <InfoTip text="드래그=수평위치 이동(가까운 프레임에서). 클릭 후 표고 슬라이더=높이. DEM(SRTM 30m)으로 각 POI 지점의 지면고도 적용(구글 지면고도와 일치). 옥상으로 뜨는 건물은 드래그/슬라이더로 보정. 자동저장." />
            </div>
            <div className="space-y-2">
              <button onClick={() => { setEditMode(v => { const nv = !v; if (nv) { setFovMode(false); setYawMode(false); } return nv; }); }}
                className={`w-full text-[11px] px-2 py-1 rounded border transition-colors ${editMode ? 'bg-yellow-500/80 border-yellow-300 text-black font-bold' : 'bg-black/60 border-gray-600 text-gray-200 hover:border-gray-400'}`}>
                {editMode ? '■ 편집 종료' : '✎ 편집 모드 (POI 위치/표고)'}
              </button>
              <button onClick={() => { setFovMode(v => { const nv = !v; if (nv) { setEditMode(false); setYawMode(false); } return nv; }); }}
                className={`w-full text-[11px] px-2 py-1 rounded border transition-colors ${fovMode ? 'bg-purple-500/80 border-purple-300 text-black font-bold' : 'bg-black/60 border-gray-600 text-gray-200 hover:border-gray-400'}`}>
                {fovMode ? '■ 세로화각 보정 종료' : '🎯 세로화각 보정 — POI를 실제 위치(상하)로 드래그'}
                <InfoTip text="라벨이 멀리(위)/가까이(아래)서 상하로 어긋날 때 사용. POI 하나를 영상 속 실제 위치로 끌면 세로 화각(sensorH)만 역산해 상하 정렬. 가로(초점 f)는 안 건드려 빨간 중심선이 좌우로 안 틀어짐. 세로 중심에서 충분히 떨어진(위/아래) POI로 보정." />
              </button>
              <button onClick={() => { setYawMode(v => { const nv = !v; if (nv) { setEditMode(false); setFovMode(false); } return nv; }); }}
                className={`w-full text-[11px] px-2 py-1 rounded border transition-colors ${yawMode ? 'bg-sky-500/80 border-sky-300 text-black font-bold' : 'bg-black/60 border-gray-600 text-gray-200 hover:border-gray-400'}`}>
                {yawMode ? '■ Yaw 보정 종료' : '🧭 Yaw 보정 — POI를 실제 위치(좌우)로 드래그'}
                <InfoTip text="라벨 전체가 좌우로 치우칠 때 사용. POI 하나를 영상 속 실제 지물 위치로 끌면 필요한 Yaw±(방위 오프셋)를 역산해 자동 설정. 자동추정(비행경로) 후 마무리 미세조정에 적합." />
              </button>
              <div className="text-[10px] text-gray-600">ℹ️ 라벨 클릭 → 속성 팝업(여러 개 누적, 재생 따라 이동). 빈 곳 클릭 → 팝업 닫기, 팝업 없으면 재생/정지.</div>
              <div className="text-[10px] text-gray-500">
                {dragTitle ? `드래그 중: ${dragTitle}`
                  : fovMode ? `🎯 POI를 실제 위치로 끌면 세로화각 자동보정 (현재 senH=${(params.sensorH ?? 20.25).toFixed(2)}mm)`
                  : yawMode ? `🧭 POI를 실제 위치(좌우)로 끌면 Yaw 자동보정 (현재 Yaw±=${params.yawOffset.toFixed(1)}°)`
                  : `보정된 POI ${Object.keys(storePoiOverrides).length}개`}
              </div>
              {editMode && selPoi && (
                <div className="border border-sky-800 rounded p-1.5 bg-sky-950/40 space-y-1">
                  <div className="text-[10px] text-sky-300 truncate">선택: {cleanTitle(selPoi)}</div>
                  <ParamRow label="표고 z" value={selZ} min={Math.round(selZBase - 30)} max={Math.round(selZBase + 12)} step={0.5} unit="m" decimals={1} onChange={setSelectedZ} />
                  <div className="flex items-center justify-between text-[10px] text-gray-500">
                    <span>선로표고 {selZBase.toFixed(1)}m</span>
                    <button onClick={() => { if (selPoi) { clearPoiOverride(selPoi); setSelZ(selZBase); } }}
                      className="text-gray-400 hover:text-red-300 border border-gray-700 rounded px-1">이 POI 해제</button>
                  </div>
                </div>
              )}
              <div className="flex gap-1">
                <button onClick={exportOverrides}
                  className="flex-1 text-[11px] px-2 py-0.5 rounded border border-gray-600 text-gray-200 hover:border-gray-400">내보내기</button>
                <button onClick={() => fileInputRef.current?.click()}
                  className="flex-1 text-[11px] px-2 py-0.5 rounded border border-gray-600 text-gray-200 hover:border-gray-400">가져오기</button>
              </div>
              <input ref={fileInputRef} type="file" accept=".json,application/json" className="hidden"
                onChange={e => { const f = e.target.files?.[0]; if (f) importOverridesFile(f); e.target.value = ''; }} />
              <div className="flex gap-1">
                <button onClick={applyDemElevations} disabled={demBusy}
                  className="flex-1 text-[11px] px-2 py-1 rounded border border-sky-700 bg-sky-900/40 text-sky-200 hover:border-sky-400 disabled:opacity-50">
                  {demBusy ? 'DEM 조회 중…' : '🌐 DEM 표고 자동적용'}
                </button>
                <button onClick={() => { if (confirm('POI 위치/표고 보정을 모두 초기화할까요? (선로표고로 복귀)')) setPoiOverrides({}); }}
                  className="text-[11px] px-2 py-1 rounded border border-gray-600 text-gray-300 hover:border-red-400 hover:text-red-300">
                  보정 초기화
                </button>
              </div>
            </div>
            <div className="border-t border-gray-700 pt-2 mt-3 flex items-center justify-end">
              <button onClick={resetDisplayDefaults}
                title="환경설정 파일(*.display.json)이 폴더에 있으면 그 값으로, 없으면 기본값으로 복원 (POI 보정은 별도)"
                className="text-[11px] text-gray-400 hover:text-white border border-gray-600 hover:border-gray-400 px-2 py-0.5 rounded transition-colors">
                기본값
              </button>
            </div>
          </div>
        )}

        {/* ── 카메라 파라미터 (드론 카메라 관련만) ── */}
        {showControls && (
          <div className="bg-black/90 border border-gray-600 rounded p-3 text-white w-72 select-none max-h-[80vh] overflow-y-auto shadow-xl">
            {/* 헤더 + 카메라 파라미터 PC 저장 — 화면표시 옵션 패널과 동일 위치(우상단)에 배치 */}
            <div className="flex items-center justify-between border-b border-gray-700 pb-2 mb-2">
              <span className="text-[10px] text-gray-500 uppercase tracking-wider">자세 보정 오프셋<span className="ml-1 text-gray-600">(SRT + offset)</span></span>
              <button
                type="button"
                onClick={saveCameraToPc}
                className={`text-[10px] px-1.5 py-0.5 rounded border ${camSaveState === 'ok' ? 'border-emerald-400 bg-emerald-700/60 text-emerald-100' : 'border-gray-600 bg-gray-700 hover:bg-gray-600 text-gray-200'}`}
                title="현재 카메라 파라미터 전체(Yaw±/f/센서 등)를 camera.json 으로 PC에 다운로드 — 영상 폴더에 넣어두면 폴더 선택 시 자동 적용"
              >{camSaveState === 'ok' ? '저장됨 ✓' : 'PC에 저장'}</button>
            </div>
            <div className="space-y-2 mb-3">
              <ParamRow label="시간동기" tip="영상↔비행로그 시각 오프셋(초). POI 전체가 진행방향으로 밀리면 조정 (+: 라벨을 진행방향 앞으로 / −: 뒤로). '풀 붙임' 정합의 핵심 — camera.json 에 저장됨" value={params.timeOffsetSec ?? 0} min={-2} max={2} step={0.05} unit="s" decimals={2} onChange={v => setParam('timeOffsetSec', v)} />
              <ParamRow label="자세 선행" tip="짐벌 자세 로그의 기록 지연 보정(초). 영상이 좌우로 팬(회전)할 때 라벨·선형이 늦게 따라오면 +, 앞서가면 −. 위치는 그대로 두고 자세만 앞선 시점을 참조 — camera.json 에 저장됨" value={params.attLeadSec ?? 0} min={-1} max={1} step={0.01} unit="s" decimals={2} onChange={v => setParam('attLeadSec', v)} />
              <ParamRow label="Yaw ±" value={params.yawOffset} min={-180} max={180} step={0.1} unit="°" decimals={1} onChange={v => setParam('yawOffset', v)} />
              {/* Yaw 자동 추정(2단계 보정의 1단계) — 대략 맞춘 뒤 'Yaw 보정' 드래그 모드로 마무리 */}
              <button
                type="button"
                onClick={autoEstimateYaw}
                className="w-full text-[10px] px-2 py-0.5 rounded border border-gray-600 bg-gray-800 hover:bg-gray-700 text-gray-300"
                title="전진 비행 구간의 GPS 진행 방위와 짐벌 헤딩 차이(중앙값)로 Yaw± 자동 설정 (근사) — 이후 'Yaw 보정' 드래그로 미세 조정"
              >Yaw 자동추정 (비행경로 기준)</button>
              <ParamRow label="Pitch ±" value={params.pitch} min={-45} max={45} step={0.1} unit="°" decimals={1} onChange={v => setParam('pitch', v)} />
              <ParamRow label="Roll ±" value={params.roll} min={-45} max={45} step={0.1} unit="°" decimals={1} onChange={v => setParam('roll', v)} />
            </div>
            <div className="text-[10px] text-gray-500 uppercase tracking-wider mb-1.5 border-t border-gray-700 pt-2">위치 보정 (드론 GPS 오프셋)</div>
            <div className="space-y-2 mb-3">
              <ParamRow label="off X" value={params.offX} min={-500} max={500} step={0.1} unit="m" decimals={1} onChange={v => setParam('offX', v)} />
              <ParamRow label="off Y" value={params.offY} min={-500} max={500} step={0.1} unit="m" decimals={1} onChange={v => setParam('offY', v)} />
              <ParamRow label="off Z" value={params.offZ} min={-200} max={200} step={0.1} unit="m" decimals={1} onChange={v => setParam('offZ', v)} />
              <ParamRow label="지오이드" value={params.geoidOffset} min={0} max={200} step={0.1} unit="m" decimals={1} onChange={v => setParam('geoidOffset', v)} />
              <div className="text-[10px] text-gray-600 text-right">정표고→타원체고 (대전≈25.8)</div>
            </div>
            <div className="text-[10px] text-gray-500 uppercase tracking-wider mb-1.5 border-t border-gray-700 pt-2">내부표정 (초점·주점·센서)</div>
            {/* 영상(djmd) 자동 감지 정보 — PC 저장은 패널 헤더 버튼 하나로 통일(중복 제거) */}
            <div className="flex items-center justify-between gap-1 mb-1.5 text-[10px] text-gray-400">
              <span className="truncate" title="영상 메타데이터(djmd)에서 감지된 카메라 정보">
                📷 {storeCameraInfo
                  ? `${storeCameraInfo.model}${storeCameraInfo.focalLen35 ? ` · ${storeCameraInfo.focalLen35}mm` : ''}${storeCameraInfo.fps ? ` · ${storeCameraInfo.fps}fps` : ''}`
                  : '카메라 미감지'}
              </span>
              {storeCameraInfo?.focalLen35 !== undefined && (
                <button
                  type="button"
                  onClick={() => setParams(p => ({ ...p, focalLen: storeCameraInfo!.focalLen35! }))}
                  className="shrink-0 px-1.5 py-0.5 rounded border border-gray-600 bg-gray-700 hover:bg-gray-600 text-gray-200"
                  title={`감지된 초점거리 ${storeCameraInfo!.focalLen35}mm 를 f 에 적용`}
                >적용</button>
              )}
            </div>
            <div className="space-y-2 mb-3">
              <ParamRow label="f" value={params.focalLen} min={10} max={100} step={0.1} unit="mm" decimals={1} onChange={v => setParam('focalLen', v)} />
              <ParamRow label="cx₀" value={params.cx0} min={-0.5} max={0.5} step={0.005} unit="" decimals={3} onChange={v => setParam('cx0', v)} />
              <ParamRow label="cy₀" value={params.cy0} min={-0.5} max={0.5} step={0.005} unit="" decimals={3} onChange={v => setParam('cy0', v)} />
              <ParamRow label="sen W" value={params.sensorW} min={10} max={50} step={0.05} unit="mm" decimals={2} onChange={v => setParam('sensorW', v)} />
              <ParamRow label="sen H" value={params.sensorH} min={6} max={100} step={0.05} unit="mm" decimals={2} onChange={v => setParam('sensorH', v)} />
            </div>
            {panelDroneFrame && (
              <div className="border-t border-gray-700 pt-2 mb-2 text-[10px] text-gray-400 font-mono space-y-0.5">
                <div>yaw: {((panelDroneFrame.yaw+params.yawOffset+360)%360).toFixed(1)}° pitch: {(panelDroneFrame.pitch+params.pitch).toFixed(1)}° roll: {(panelDroneFrame.roll+params.roll).toFixed(1)}°</div>
                <div>f: {params.focalLen.toFixed(1)}mm hFOV: {(2*Math.atan((params.sensorW??36)/(2*params.focalLen))*180/Math.PI).toFixed(1)}°</div>
                <div>offX: {params.offX.toFixed(1)}m offY: {params.offY.toFixed(1)}m offZ: {params.offZ.toFixed(1)}m</div>
                <div>
                  영상 {videoWidth || '?'}×{videoHeight || '?'}
                  {videoWidth > 0 && videoHeight > 0 && (
                    <span className="text-gray-500"> ({(videoWidth / videoHeight).toFixed(3)} · cover 정렬)</span>
                  )}
                </div>
              </div>
            )}
            <div className="border-t border-gray-700 pt-2 flex items-center justify-end">
              <button onClick={resetCameraParams}
                title="환경설정 파일(*.camera.json)이 폴더에 있으면 그 값으로, 없으면 앱 기본값으로 복원"
                className="text-[11px] text-gray-400 hover:text-white border border-gray-600 hover:border-gray-400 px-2 py-0.5 rounded transition-colors">
                초기화
              </button>
            </div>
          </div>
        )}
        </div>
      </div>
      )}
    </>
  );
}
