/**
 * 지리정보 오버레이
 * 렌더링 최적화:
 *   - 텍스트(측점+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 };

// 속도 적응형 평활(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> = {
  '터널': '🚇',
  '교량': '🌉',
  '역사': '🚉',
  '철도역': '🚉',
  '지장물': '🏢',
  '측점': '📍',
  '구교': '🌉',
  '출입문': '🚪',
};

// 영상 오버레이엔 모든 선로 구조물(교량/터널/구교)을 라벨로 표출한다.
// 시설등급(시설종별) 표시 필터는 하단 스테이션바·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=드론고도−지면고도 자동변동, 드론 오르내려도 지물에 붙음).
  // true = 드론−이격거리 고정(gap 상수). 드론 고도 변동 시 부정확.
  poiDroneHeight: true,
  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 }[];
}

// 중심선 + 나침반 렌더 캐시 (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+)[Kk](\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));
  };
  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 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 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);  // 진행 중 계산 취소용

  // 중심선 + 나침반 렌더 캐시 (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);
  // 라벨 속성 팝업(다중) — 항상 활성(모드 버튼 없음). 라벨 위에서만 캔버스가 입력을 받아 클릭→팝업.
  //   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]);

  // 컴팩트 자동 팝업 동기화 — 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;
  }, []);

  // 클라이언트 지리정보 스토어 구독 (서버 /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 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 storePoiOverrides = useGeoStore(s => s.poiOverrides);
  const setPoiOverride = useGeoStore(s => s.setPoiOverride);
  const setPoiOverrides = useGeoStore(s => s.setPoiOverrides);
  const clearPoiOverride = useGeoStore(s => s.clearPoiOverride);
  // 경로 표고 슬라이더 상한 — 데이터(중심선) 최대 지면표고 + 여유 (없으면 60 폴백)
  const pathZMax = useMemo(() => {
    let m = 0;
    for (const c of storeCenterline) if (typeof c.z === 'number' && c.z > m) m = c.z;
    return m > 0 ? Math.ceil(m + 10) : 60;
  }, [storeCenterline]);
  useEffect(() => { overridesRef.current = storePoiOverrides; }, [storePoiOverrides]);

  // ── 보정값 영속화 (baseName별 localStorage) ──────────────────────────────
  // 카메라 각도/내부표정(Yaw±/Pitch±/focal 등) + 표시설정을 데이터셋별로 저장·복원.
  // 한 번 튜닝하면 새로고침/재로드해도 유지 → "처음부터 맞게" 표시.
  const calibLoadedFor = useRef<string | null>(null);
  useEffect(() => {
    if (!storeBaseName || calibLoadedFor.current === storeBaseName) return;
    calibLoadedFor.current = storeBaseName;
    try {
      const raw = localStorage.getItem(`ghivideo:calib:${storeBaseName}`);
      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]);

  // 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 없는 항목(측점기반 배치 등)도 제외.
  useEffect(() => {
    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')
      .map(s => ({
        title: s.name,
        category: s.category ?? '구교',
        lat: s.lat, lon: s.lon, z: 0, type: 'poi' as const,
        props: s.props,
      }));
  }, [storeStructures]);

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

  // 드론 프레임
  useEffect(() => {
    allDroneFramesRef.current = storeFrames;
    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) {
      let prev: CameraCoords | null = null;
      for (let i = 0; i < allCL.length; i++) {
        const cc = toCameraCoords(drone, allCL[i].lat, allCL[i].lon, allCL[i].z, params, worldOrigin);
        if (prev) { const s = segFrom(prev, cc); if (s) centerlineSegs.push(s); }
        prev = cc;
      }
    }

    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에서 호출.
  const poseAt = useCallback((estFrame: number): DroneFrameBasic | null => {
    const arr = allDroneFramesRef.current;
    if (!arr.length) return null;
    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 h = smoothHalfRef.current;
    const a = smoothFrame(arr, i1, h), b = smoothFrame(arr, i2, h);
    const L = (x: number, y: number) => x + (y - x) * frac;
    let dy = b.yaw - a.yaw; dy = ((dy + 540) % 360) - 180;   // 최단각
    return {
      ...a,
      frame: estFrame,
      lat: L(a.lat, b.lat), lon: L(a.lon, b.lon), altitude: L(a.altitude, b.altitude),
      pitch: L(a.pitch, b.pitch), roll: L(a.roll, b.roll),
      yaw: a.yaw + dy * frac,
    };
  }, [smoothFrame]);

  // 텍스트 사전 계산 — requestIdleCallback으로 백그라운드 실행
  const startLabelPrecompute = useCallback((currentParams: CameraParams, currentSmoothHalf: number, currentMaxRange: number, currentDroneHeight: boolean, currentDroneDrop: number) => {
    const id = ++precomputeIdRef.current;
    // 재계산(기존 맵 보유)이면 옛 맵을 복사해 시작 → 첫 청크(현재 프레임 포함) 후 즉시 노출,
    // 이후 청크가 프레임을 덮어써 전 구간이 새 설정으로 갱신(빈 구간 없음). 초기 로드는 빈 맵.
    const newMap = new Map<number, LabelCache>(labelMapRef.current.size ? labelMapRef.current : undefined);
    const frames = allDroneFramesRef.current;
    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)

    if (!frames.length) return;

    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, SMOOTH_HALF);

        const stationLabels: LabelCache['stationLabels'] = [];
        for (const st of allSt) {
          const snap = nearestCL(st.lat, st.lon);
          const slat = snap?.lat ?? st.lat, slon = snap?.lon ?? st.lon, sz = snap?.z ?? st.z;
          const cc = toCameraCoords(drone, slat, slon, sz, currentParams, worldOrigin);
          if (cc.Zc < CLIP_Z) continue;
          const { pxRaw, pyRaw } = pixelFromCamera(cc, currentParams);
          if (pxRaw < -0.05 || pxRaw > 1.05 || pyRaw < -0.05 || pyRaw > 1.05) continue;
          // 화면좌표 대신 (스냅된)월드좌표 저장 → RAF 가 연속 포즈로 재투영.
          stationLabels.push({ title: st.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 }[] }[] = [];
        for (const poi of allPoi) {
          // 지면고도 gz: 보정값(DEM/드래그) 또는 최근접 선로표고. RAF 투영 기준값.
          const gz = overridesRef.current[poi.title]?.z ?? nearestCL(poi.lat, poi.lon)?.z ?? poi.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);
          if (pxRaw < -0.02 || pxRaw > 1.02 || pyRaw < -0.02 || pyRaw > 1.02) 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) });
        }
        // 겹침 억제: KAKAO_RAIL(철도역/역사) 라벨을 최우선 유지 → 겹치면 일반 POI 가 숨겨진다.
        // 같은 우선순위 내에서는 가까운(dist 작은) 것 우선. 임계 이내면 뒤(낮은우선/먼) 것 숨김.
        const railRank = (cat: string): number => (cat === '철도역' || cat === '역사' ? 0 : 1);
        poiCand.sort((a, b) => railRank(a.category) - railRank(b.category) || a.dist - b.dist);
        // 겹침 시 진행방향 변형 우선: 같은 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 };
        });

        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]);

  // 모든 데이터 로드 완료 시 사전 계산 시작
  useEffect(() => {
    if (!droneFramesLoaded || !geoDataLoaded || !clDataLoaded) return;
    startLabelPrecompute(paramsRef.current, smoothHalf, maxPoiRange, poiDroneHeight, droneHeightDrop);
  }, [droneFramesLoaded, geoDataLoaded, clDataLoaded, startLabelPrecompute, smoothHalf, maxPoiRange, poiDroneHeight, droneHeightDrop, poiOverlapExclude]);

  // params / smoothHalf / maxPoiDist 변경 시 사전 계산 재시작 (500ms debounce)
  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]);

  // 현재 재생 시간 → 드론 프레임 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, smoothHalfRef.current)
        : 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, smoothHalfRef.current)
      : 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 보간) → 연속 프레임번호. 선/궤적·라벨 공통 사용.
      const estTime = timeRef
        ? timeRef.current
        : currentTimeSecRef.current + (performance.now() - timeUpdateWallRef.current) / 1000;
      const estFrame = estTime * fpsRef.current;

      // 연속 보간 포즈(라인·라벨 공통) — 매 RAF 프레임 직접 투영해 부드럽게.
      const dronePose = poseAt(estFrame) ?? (allDroneFramesRef.current.length
        ? smoothFrame(allDroneFramesRef.current, currentFrameIdxRef.current, smoothHalfRef.current)
        : 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 가 연속 포즈로 직접
      // 투영 → 라인처럼 부드럽게 이동(정수프레임 보간/프레임키 의존 제거).
      const baseFrame = Math.floor(estFrame);
      const labelsA = labelMapRef.current.get(baseFrame) ?? 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) => {
          const cc = toCameraCoords(dronePose, stA.lat, stA.lon, stA.z, params, worldOrigin);
          if (cc.Zc < CLIP_Z) return;
          const { pxRaw, pyRaw } = pixelFromCamera(cc, params);
          const prevDst = displayedStRef.current.get(stA.title);
          const d = smoothStep(prevDst, pxRaw, pyRaw, α, smoothMinAlphaRef.current, smoothSpeedRefRef.current);
          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);
          const d = 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);
          const r = 10;
          // 십자 마커
          ctx.strokeStyle = '#64c8ff'; ctx.lineWidth = 2.5;
          ctx.beginPath();
          ctx.moveTo(px-r, py); ctx.lineTo(px+r, py);
          ctx.moveTo(px, py-r); ctx.lineTo(px, py+r);
          ctx.stroke();
          // 이모지 + 텍스트
          const emoji = CATEGORY_EMOJI[poiA.category] ?? '📌';
          // 라벨: 속성에 '구분'이 있으면 그 값(원문 그대로), 없으면 cleanTitle(title). poiCand에서 확정.
          const label = `${emoji} ${poiA.label}`;
          const lx = Math.max(2, px + 14);
          // 동일좌표 라벨 세로 배치: 행 중앙을 십자에 맞춰 위→아래로 안 겹치게 오프셋.
          const ROW_H = 24;
          const labelY = py + (poiA.labelRow - (poiA.labelRowCount - 1) / 2) * ROW_H;
          ctx.strokeStyle = 'rgba(0,0,0,0.85)'; ctx.lineWidth = 4;
          ctx.lineJoin = 'round';
          ctx.textBaseline = 'middle';
          ctx.strokeText(label, lx, labelY);
          ctx.fillStyle = '#64c8ff';
          ctx.fillText(label, lx, labelY);
          // 히트박스(십자 마커 ~ 글자 끝) — 아이콘·글자 어디를 눌러도 선택되게. (라벨 행 위치 기준)
          const tw = ctx.measureText(label).width;
          hitBoxes.push({ kind: 'poi', title: poiA.title, x0: px - r, y0: labelY - 13, x1: lx + tw + 2, y1: labelY + 13 });
          const poiVisible = labelOnScreen(px - r, labelY - 13, lx + tw + 2, labelY + 13);
          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) && 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;                    // 라벨 가장자리 ~ 팝업 사이 실제 여백
          const tx = Math.min(Math.max(8, sx - 10), 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, smoothHalfRef.current);
    return currentDroneFrameRef.current;
  }, [smoothFrame]);

  // 화면 클릭좌표(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) {
      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 = nearestCL(poi.lat, poi.lon)?.z ?? poi.z;
      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 ?? nearestCL(obj.lat, obj.lon)?.z ?? obj.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) ? 'move' : 'default';
    if (editMode || fovMode) { 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, 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;

    // ── 세로 화각(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_DEFAULTS 로 리셋(POI 보정/편집모드는 별도 버튼 유지).
  const resetDisplayDefaults = useCallback(() => {
    setShowCenterline(DISPLAY_DEFAULTS.showCenterline);
    setShowDronePath(DISPLAY_DEFAULTS.showDronePath);
    setDronePathZ(DISPLAY_DEFAULTS.dronePathZ);
    setDronePathAlpha(DISPLAY_DEFAULTS.dronePathAlpha);
    setSmoothHalf(DISPLAY_DEFAULTS.smoothHalf);
    setEmaAlpha(DISPLAY_DEFAULTS.emaAlpha);
    setSmoothMinAlpha(DISPLAY_DEFAULTS.smoothMinAlpha);
    setSmoothSpeedRef(DISPLAY_DEFAULTS.smoothSpeedRef);
    setMaxPoiRange(DISPLAY_DEFAULTS.maxPoiRange);
    setPoiDroneHeight(DISPLAY_DEFAULTS.poiDroneHeight);
    setDroneHeightDrop(DISPLAY_DEFAULTS.droneHeightDrop);
  }, []);

  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>
              </>
            )}
          </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={() => 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>
          <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>
        </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="text-[10px] text-gray-500 uppercase tracking-wider mb-1.5 border-b border-gray-700 pb-2 mb-2">표시 토글</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={120} 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); 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); 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>
              <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)`
                  : `보정된 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="화면표시 옵션을 기본값으로 되돌립니다 (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">
            <div className="text-[10px] text-gray-500 uppercase tracking-wider mb-1.5 border-b border-gray-700 pb-2 mb-2">자세 보정 오프셋<span className="ml-1 text-gray-600">(SRT + offset)</span></div>
            <div className="space-y-2 mb-3">
              <ParamRow label="Yaw ±" value={params.yawOffset} min={-180} max={180} step={0.1} unit="°" decimals={1} onChange={v => setParam('yawOffset', v)} />
              <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={50} 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>
            <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={36} 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={() => setParams({ ...DEFAULT_CAMERA_PARAMS })}
                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>
      )}
    </>
  );
}
