Files
GhiVideo/client/src/components/overlay/StationOverlay.tsx
T
b23042andClaude Opus 4.8 e9052143d7 feat: 스테이션바 측점 정확도 개선 + 영상 컨트롤 UI 통합 + 종합 기술문서
스테이션바/측점(체이니지)
- 역 재진입 마커 누락 수정: 측점기준 탐지에 좌표근접(pxPassesTo) 합집합 보강(중복 24px 제거)
- kmExists 판정: 좌표 반경만으로 잡힌 통과는 측점값 라벨/검색에서 제외(마커 동그라미는 유지)
- 측점값 라벨 겹침 숨김(좌측 우선), 동일항목 브래킷 클러스터
- 측점 검색 순환(통과방향 다음→끝에서 처음), 마커 기준 후보 산출, 입력값 유지/blur 삭제
- 연속 체이니지 수직투영 유틸 분리(client/src/utils/chainage.ts), Minimap 컴포넌트 추가

영상 컨트롤 UI
- 선형/드론궤적/좌측패널 토글을 재생바로 통합(settingsStore 공유·persist)
- 드론 GPS·고도 HUD 상시 표시, 경로표고·투명도 항상 노출, 겹침제외 버튼 숨김
- 재생바 요소 재배치(좌측패널→선형→드론궤적→드론위치정보→시설등급), 활성색 amber 통일

문서/도구
- 종합 기술명세서(인계+특허 기초) md/html/pdf 3종, UI개선 보고서 3종
- md→html→pdf 변환 스크립트(scripts/md2docs.sh) + 스타일(docs/assets/report.css)
- 작업 히스토리 다수 추가(docs/history/)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 14:56:27 +09:00

1640 lines
94 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 지리정보 오버레이
* 렌더링 최적화:
* - 텍스트(측점+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 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 });
// 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);
// 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; }, [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(물결 방지)
// 라벨 히트박스(아이콘+글자 영역, 화면 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 + 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 팝업을 자동 생성/제거.
// (캔버스 텍스트는 흐려서 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,
});
});
const toRemoveIds = cur.filter(p => p.auto && !vis.has(p.title)).map(p => p.id);
if (toAdd.length || toRemoveIds.length) {
setInfoPopups(prev => {
const kept = prev.filter(p => !toRemoveIds.includes(p.id));
const ids = new Set(kept.map(p => p.id));
return [...kept, ...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 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;
let best = frames[0], bestIdx = 0, bestD = Math.abs((best.frame ?? 0) / VIDEO_FPS - currentTime);
for (let i = 0; i < frames.length; i++) {
const d = Math.abs(frames[i].frame / VIDEO_FPS - currentTime);
if (d < bestD) { bestD = d; best = frames[i]; bestIdx = i; }
if (bestD < 1 / VIDEO_FPS / 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 }[] = [];
const cache = renderCacheRef.current;
if (cache) {
// 부드러운 시간(timeRef, 60fps 보간) → 연속 프레임번호. 선/궤적·라벨 공통 사용.
const estTime = timeRef
? timeRef.current
: currentTimeSecRef.current + (performance.now() - timeUpdateWallRef.current) / 1000;
const estFrame = estTime * VIDEO_FPS;
// 연속 보간 포즈(라인·라벨 공통) — 매 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° 누적 언랩(점프 방지).
if (minimapRef.current) {
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 target = -(rawYaw + paramsRef.current.yawOffset);
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 });
});
}
// 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 });
ctx.textBaseline = 'alphabetic';
// 컴팩트 팝업 자동표시: 단일 라벨만(동일좌표 다중행은 팝업 겹침 방지 위해 제외 → 클릭 시 개별 표시).
if (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);
}
} // 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);
// 라벨이 없거나(필터됨) 보이는 컨테이너 밖(cover 크롭 포함)이면 '사라짐'으로 간주.
const sx = disp ? offX + disp.x * dispW : 0, sy = disp ? offY + disp.y * dispH : 0;
const off = !disp || sx < -16 || sx > W + 16 || sy < -16 || sy > H + 16;
if (off) {
const m = (popupMissRef.current.get(id) ?? 0) + 1;
popupMissRef.current.set(id, m);
if (m >= 4) toRemove.push(id); // 4프레임(~0.07s) 연속 밖이면 제거(라벨과 함께 사라짐)
else el.style.visibility = 'hidden';
return;
}
popupMissRef.current.set(id, 0);
const ph = el.offsetHeight || 120;
const pw = el.offsetWidth || 240;
// 기본은 라벨 '아래'. 아래 공간이 부족하면(화면 하단 근접) 라벨 '위'로 플립 →
// 라벨과 겹치거나 화면 밖으로 잘리는 것을 방지. 위로도 부족하면 화면 안으로 클램프.
const GAP = 16;
const tx = Math.min(Math.max(8, sx - 10), Math.max(8, W - pw - 4));
let ty = sy + GAP; // 아래
if (ty + ph > H - 2) {
const aboveTy = sy - GAP - ph; // 위로 플립 (팝업 하단이 라벨 위 ~GAP 지점)
ty = aboveTy >= 2 ? aboveTy : Math.max(2, H - 2 - ph);
}
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); });
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 && <Minimap ref={minimapRef} />}
{/* 라벨 속성 팝업(다중) — 위치는 RAF 가 라벨을 따라 갱신. DOM 이라 텍스트 선명.
컴팩트(기본 5필드) ↔ 전체 토글은 팝업 클릭. 자동(auto) 팝업은 ✕ 없음(가시 구조물 따라 표시). */}
{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) 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 + 16), (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>
);
})}
{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>
<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>
)}
</>
);
}