Files
GhiVideo_v4/client/src/components/overlay/StationOverlay.tsx
T
b23042andClaude Fable 5 67cf646d70 오버레이 — 선형 입체형(반투명 원통+측점 분할 점)·자세 필터·설정 파일명
선형 입체형(기본형/입체형 토글, display.json clFx):
- 진한 중심선 + 반투명 원통 셸(지름/거리 원근 폭, 3층 음영) — 오프스크린에
  불투명으로 그려 일괄 알파 합성(세그먼트 이음매 중첩·단차 제거)
- 선형 점: 측점(100m) 사이 N등분 위치(기본 10등분=10m, 옵션 clDotDiv 1~20,
  중간점 크게) — 궤적 측정점과 동일한 월드 고정 방식(근거리 역행 착시 없음)
- '선형 지름'(clTubeD) 옵션, 유동 대시는 진행방향 정렬 후 월드 고정 점으로 대체
- 커튼(면·수직살)은 제거

자세/포즈:
- 프레임 동기 데이터 포즈 평활 완전 제거(행 단위 ±0.5s 오해 교정) + One Euro
  속도적응 필터(직진 미세떨림 억제, 팬 즉시 추종 — route.json att_min_cut/att_beta)
- 측점·POI 라벨 화면 EMA 우회(즉시 투영 — 선형과 픽셀 고정)

기타:
- 카메라/디스플레이 PC 저장 파일명 = 환경설정 이름(CSV명 → 노선명 → 영상 base)
- 측점 팝업 표고(타원체고) 행, 구조물-POI 이중 라벨 제거, sen H 최대 100mm

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

2572 lines
155 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 { MapCompass, type CompassPose } from './MapCompass';
import type { GeoPoint, CenterlinePoint, PoiOverrideMap, RouteStructure } from '../../types/geo';
const VIDEO_FPS = 30000 / 1001;
// 라벨 좌표 튐 방지 — 1프레임에 이 정규화 거리(화면폭/높이 비율) 이상 점프하면 이상치로
// 보고 갱신 무시(이전 위치 유지). 정상 팬 이동은 60fps에서 프레임당 훨씬 작다.
const REJECT_DIST = 0.12;
// 단, 시크/재등장 등 '진짜' 큰 이동에 영구히 갇히지 않도록 연속 거부 한계 후 수용.
const MAX_REJECT_FRAMES = 8;
// POI 라벨 겹침 억제 — 두 마커가 화면상 이 정규거리(가로 X / 세로 Y) 이내면 '겹침'으로 보고
// 드론에 더 가까운 것만 남기고 나머지는 숨긴다. (같은 건물의 여러 업체 등)
const POI_MERGE_X = 0.10;
const POI_MERGE_Y = 0.035;
type DispPos = { x: number; y: number; rej: number; vx: number; vy: number; rx?: number; ry?: number };
// djmd 프레임 동기 데이터일 때 포즈 평활 상한.
// 주의: smoothFrame 의 halfWin 단위는 '프레임 배열 행'(CSV 10Hz 행)이라 ±5 = ±0.5초 —
// 팬(회전) 시작·종료 때 라벨이 늦게 출발하고 늦게 정렬되는 원인이었음.
// 프레임 동기 데이터는 위치(djmd)·자세(10Hz 선형보간) 모두 이미 매끄러워 평활 불필요 → 0(끔).
const POSE_SMOOTH_SYNCED_MAX = 0;
// 속도 적응형 평활(One Euro 방식) 상수.
// 떨림(노이즈)은 매 프레임 방향이 번갈아 → 평활속도≈0 → 강하게 평활(1배속에서 안정).
// 실제 이동은 방향이 일관 → 평활속도 큼 → alpha↑로 즉시 추종(빠른 배속에서 지연 없음).
const SMOOTH_VEL_BETA = 0.25; // 속도 추정 평활(저크 제거)
/** 이상치 거부 + 속도적응 EMA.
* maxAlpha = 빠를 때 추종 상한(패널 EMA α), minAlpha = 정지/떨림 시 최소 추종(작을수록 강한 평활),
* speedRef = 이 속도(정규/프레임) 이상에서 alpha 가 maxAlpha 에 도달(클수록 더 강하게 평활). */
function smoothStep(prev: DispPos | undefined, tx: number, ty: number, maxAlpha: number, minAlpha: number, speedRef: number): DispPos {
if (!prev) return { x: tx, y: ty, rej: 0, vx: 0, vy: 0 };
const dx = tx - prev.x, dy = ty - prev.y;
const d = Math.hypot(dx, dy);
if (d > REJECT_DIST && prev.rej < MAX_REJECT_FRAMES) {
return { x: prev.x, y: prev.y, rej: prev.rej + 1, vx: prev.vx, vy: prev.vy }; // 이상치: 위치 유지
}
// 속도 벡터 평활 → 떨림(왕복)은 상쇄돼 작게, 실제 이동은 유지.
const vx = prev.vx + (dx - prev.vx) * SMOOTH_VEL_BETA;
const vy = prev.vy + (dy - prev.vy) * SMOOTH_VEL_BETA;
const speed = Math.hypot(vx, vy);
const a = Math.min(maxAlpha, minAlpha + (maxAlpha - minAlpha) * Math.min(1, speed / Math.max(1e-4, speedRef)));
return { x: prev.x + dx * a, y: prev.y + dy * a, rej: 0, vx, vy };
}
interface Props {
currentFrame: number;
currentTime: number;
fps: number;
visible: boolean;
/** 영상 첫 프레임이 표시 가능해진 후 true. false면 오버레이를 그리지 않음(영상보다 먼저 표시 방지). */
videoReady?: boolean;
/** 영상 원본 해상도(px). object-fit:cover 크롭 영역 계산 → 오버레이를 영상에 정렬. 0이면 컨테이너 기준. */
videoWidth?: number;
videoHeight?: number;
/** 영상 빈 곳 클릭(팝업 없을 때) → 재생/정지 토글. Video.js controls:false 라 직접 호출 필요. */
onTogglePlay?: () => void;
/** 카메라 파라미터 패널 표시 (영상제어 토글). 기본 true. */
showPanel?: boolean;
/** 카메라 파라미터 패널 top(px) — 노선 배너 아래로 배치. 기본 72. */
topPx?: number;
/** 하단 스테이션바 높이(px) — 우측 버튼을 좌측 패널과 같은 높이(bottom)에 맞추기 위함. */
barHeight?: number;
/** 부드러운 단조보간 재생시간(초) ref. VideoPlayer smoothTimeRef 전달 시
* 라벨이 60fps로 매끄럽게 이동(일시정지·시크·배속 보정 포함). 없으면 prop 기반 추정. */
timeRef?: React.MutableRefObject<number>;
}
// category → 이모지
const CATEGORY_EMOJI: Record<string, string> = {
'터널': '🚇',
'교량': '🌉',
'역사': '🚉',
'철도역': '🚉',
'지장물': '🏢',
'지장물L': '🏢',
'측점': '📍',
'구교': '🌉',
'출입문': '🚪',
'교차로': '🚦',
'도로': '🛣️',
'강.하천': '🌊',
'마을': '🏘️',
'산': '⛰️',
};
/** 라벨 색 밝기 판정 — 어두운 색이면 외곽선을 밝게 반전(가독성). */
function isDarkLabelColor(hex: string): boolean {
const m = hex.match(/^#([0-9a-f]{6})$/i);
if (!m) return false;
const n = parseInt(m[1], 16);
const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
return 0.299 * r + 0.587 * g + 0.114 * b < 96;
}
// 영상 오버레이엔 모든 선로 구조물(교량/터널/구교)을 라벨로 표출한다.
// 시설등급(시설종별) 표시 필터는 하단 스테이션바·RoutePanel 전용 → 영상은 항상 전부 표출.
// (역사=철도역은 type='station'이라 여기 미포함 → 스테이션바 전용 유지.)
const OVERLAY_STRUCT_CATEGORIES = new Set(['구교', '교량', '터널']);
// 화면표시 옵션 기본값 — 우측 하단 "기본값" 버튼으로 일괄 복귀. (useState 초기값과 단일 출처 공유.)
const DISPLAY_DEFAULTS = {
showCenterline: true,
showDronePath: true,
dronePathZ: 58,
dronePathAlpha: 0.75,
clFx: true, // 선형 표시 형태 — true=입체형(반투명 원통+유동 대시), false=기본형(단순 선)
clTubeD: 1.2, // 선형 튜브 지름(m) — 입체형에서 원통의 실제 지름
clDotDiv: 10, // 측점 사이 점 분할 수(N등분 — 10이면 10m 단위). 1=점 없음
smoothHalf: 60,
emaAlpha: 1.0,
smoothMinAlpha: 0.12, // 정지/떨림 시 최소 추종(작을수록 강한 평활)
smoothSpeedRef: 0.010, // 즉시추종 기준 속도(클수록 더 강하게 평활)
maxPoiRange: 1000,
// false = 지면고도 기준(POI를 지면고도 z에 고정 → gap=드론고도−지면고도 자동변동, 드론 오르내려도 지물에 붙음).
// 표고 미상(z=0) 라벨은 '경로 표고' 평면에 앵커 → 궤적과 같은 슬라이더로 함께 보정됨.
// true = 드론−이격거리 고정(gap 상수). 드론 고도 변동 시 부정확.
// 기본 false: 실측 z(회덕)는 지물에 정확히 붙고, 미상(제주)은 경로표고로 일괄 보정 가능.
poiDroneHeight: false,
droneHeightDrop: 24,
};
// 텍스트 사전 계산 캐시 (Map<frameNum, LabelCache>)
// 라벨 가시집합(프레임별) — 화면좌표가 아니라 '월드좌표'를 저장한다. RAF 가 매 프레임 연속
// 포즈(poseAt)로 직접 투영 → 라인처럼 부드럽게 이동. (precompute = 가시성/겹침 결정 전용)
interface LabelCache {
stationLabels: { title: string; lat: number; lon: number; z: number }[];
poiMarkers: { title: string; label: string; category: string; lat: number; lon: number; gz: number; compact: { k: string; v: string }[]; labelRow: number; labelRowCount: number; color?: string }[];
}
// 중심선 + 나침반 렌더 캐시 (per-frame, renderCacheRef)
interface RenderCache {
// 선/궤적은 RAF에서 매 프레임 보간 포즈로 직접 투영(부드러운 갱신) → 여기 미보관.
effectiveYaw: number;
hFovRad: number;
clCount: number;
poiCount: number;
}
const cleanTitle = (t: string) => t.replace(/\s*\([상하]\)\s*$/, '').trim();
/** 구조물 base 이름(괄호 변형 제거): '회덕제1가도교(하)' → '회덕제1가도교'. 상/하 형제 판정용. */
const baseStruct = (t: string): string => t.replace(/\s*[(].*$/, '').trim();
/** 속성에 '구분' 데이터가 있으면 그 값을 라벨로 사용(없으면 undefined). */
const guboonOf = (props?: { k: string; v: string }[]): string | undefined => {
const v = props?.find((p) => p.k === '구분')?.v;
return v && v.trim() ? v.trim() : undefined;
};
/**
* 컴팩트 팝업용 기본 3필드: 시설종별 / 구조형식(상부구조형식) / 연장(m).
* 존재하는 것만, 이 순서로 반환. 라벨 옆 항상표시용(클릭 시 전체 팝업).
*/
const compactFieldsOf = (props?: { k: string; v: string }[]): { k: string; v: string }[] => {
if (!props) return [];
const out: { k: string; v: string }[] = [];
const pick = (label: string, pred: (k: string) => boolean): void => {
const f = props.find((p) => pred(p.k));
if (f?.v?.trim()) out.push({ k: label, v: f.v.trim() });
};
pick('시설종별', (k) => k === '시설종별');
pick('구조형식', (k) => /구조형식/.test(k));
pick('연장(m)', (k) => /연장/.test(k));
pick('폭(m)', (k) => /폭/.test(k));
pick('용도', (k) => /용도/.test(k));
pick('준공연도', (k) => /준공/.test(k));
return out;
};
/** 컴팩트에 들어가는 키인지(전체 보기에서 중복 제거 + 순서 정렬용). */
const isCompactKey = (k: string): boolean =>
k === '시설종별' || /구조형식/.test(k) || /연장/.test(k) || /폭/.test(k) || /용도/.test(k) || /준공/.test(k);
/** 전체 보기 필드 순서 = 컴팩트(정해진 순서) 먼저, 그 뒤 나머지 속성(원본 순서). 클릭 전후 순서 일치. */
const orderedFullFields = (
compact: { k: string; v: string }[],
props: { k: string; v: string }[],
): { k: string; v: string }[] => [...compact, ...props.filter((p) => !isCompactKey(p.k))];
function stationOrder(title: string): number {
const m = title.match(/(\d+)\s*[Kk+]\s*(\d+)/);
if (!m) return 0;
return parseInt(m[1]) * 1000 + parseInt(m[2]);
}
// ── ParamRow ─────────────────────────────────────────────────────────────────
// 라벨 옆 작은 ⓘ — 마우스 오버 시 네이티브 툴팁으로 설명 표시(세로 공간 절약).
function InfoTip({ text }: { text: string }) {
return (
<span title={text} className="ml-1 cursor-help text-gray-600 hover:text-gray-300 align-middle"></span>
);
}
interface ParamRowProps {
label: string; value: number; min: number; max: number; step: number;
unit: string; decimals?: number; onChange: (v: number) => void;
/** 있으면 라벨에 점선 밑줄+마우스오버 툴팁(설명). 별도 설명 div 대체 → 패널 높이 절약. */
tip?: string;
}
function ParamRow({ label, value, min, max, step, unit, decimals = 1, onChange, tip }: ParamRowProps) {
const fmt = useCallback((v: number) => v.toFixed(decimals), [decimals]);
const [text, setText] = useState(() => fmt(value));
const prevRef = useRef(value);
useEffect(() => {
if (prevRef.current !== value) { prevRef.current = value; setText(fmt(value)); }
}, [value, fmt]);
const commit = (s: string) => {
const n = parseFloat(s);
if (!isNaN(n)) {
const c = Math.max(min, Math.min(max, n));
onChange(c); setText(fmt(c)); prevRef.current = c;
} else setText(fmt(value));
};
// 마우스 휠: 위 = +step, 아래 = step (예: 카메라 파라미터 0.1 단위).
// 슬라이더와 숫자 값 입력칸 양쪽 위에서 동작. 패널 스크롤을 막아야 하므로
// non-passive 네이티브 리스너로 부착(React onWheel 은 passive).
const sliderRef = useRef<HTMLInputElement>(null);
const numRef = useRef<HTMLInputElement>(null);
const wheelStateRef = useRef({ value, min, max, step, decimals, onChange });
useEffect(() => { wheelStateRef.current = { value, min, max, step, decimals, onChange }; });
useEffect(() => {
const els = [sliderRef.current, numRef.current].filter((el): el is HTMLInputElement => !!el);
if (!els.length) return;
const onWheel = (e: WheelEvent): void => {
e.preventDefault();
e.stopPropagation();
const s = wheelStateRef.current;
const dir = e.deltaY < 0 ? 1 : -1;
// 부동소수점 누적 오차 방지 — 표시 자릿수로 반올림
const next = parseFloat(
Math.max(s.min, Math.min(s.max, s.value + dir * s.step)).toFixed(Math.max(s.decimals, 3)),
);
if (next !== s.value) s.onChange(next);
};
els.forEach(el => el.addEventListener('wheel', onWheel, { passive: false }));
return () => els.forEach(el => el.removeEventListener('wheel', onWheel));
}, []);
return (
<div className="flex items-center gap-1.5 text-[11px]">
<span
title={tip}
className={`text-gray-400 w-12 shrink-0 text-right ${tip ? 'cursor-help underline decoration-dotted decoration-gray-600 underline-offset-2' : ''}`}
>{label}</span>
<input ref={sliderRef} type="range" min={min} max={max} step={step} value={value}
onChange={e => { const v = parseFloat(e.target.value); onChange(v); prevRef.current = v; setText(fmt(v)); }}
className="flex-1 h-1 accent-yellow-400 cursor-pointer" />
<input ref={numRef} type="number" min={min} max={max} step={step} value={text}
onChange={e => setText(e.target.value)}
onBlur={e => commit(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') commit((e.target as HTMLInputElement).value); if (e.key === 'Escape') setText(fmt(value)); }}
className="w-16 bg-black/60 border border-gray-700 rounded px-1 py-0.5 text-right font-mono text-yellow-300 text-[11px] [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" />
<span className="text-gray-500 text-[10px] w-5 shrink-0">{unit}</span>
</div>
);
}
// ── 메인 컴포넌트 ─────────────────────────────────────────────────────────────
export default function StationOverlay({ currentFrame, currentTime, fps, visible, videoReady = true, videoWidth = 0, videoHeight = 0, onTogglePlay, showPanel = true, topPx = 72, barHeight = 0, timeRef }: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const canvasSizeRef = useRef({ w: 0, h: 0 });
// 데이터 ref
const allDroneFramesRef = useRef<DroneFrameBasic[]>([]);
const allCenterlinePointsRef = useRef<CenterlinePoint[]>([]);
const clCumRef = useRef<number[]>([]); // 중심선 정점 누적거리(m) — 선형 점 배치용
const clStaAnchorsRef = useRef<{ cum: number; km: number }[]>([]); // 측점 체이니지 ↔ 누적거리 앵커
const tubeCanvasRef = useRef<HTMLCanvasElement | null>(null); // 원통 셸 오프스크린(불투명→일괄 알파 합성)
const allGeoStationsRef = useRef<GeoPoint[]>([]);
const allPoisRef = useRef<GeoPoint[]>([]);
const allStructuresRef = useRef<GeoPoint[]>([]); // 교량/터널/구교 → POI처럼 표시
// 현재 상태 ref
const currentDroneFrameRef = useRef<DroneFrameBasic | null>(null);
const currentFrameNumRef = useRef<number>(0); // RAF에서 Map 조회용
const currentFrameIdxRef = useRef<number>(0); // smoothFrame용 배열 인덱스
const currentTimeSecRef = useRef<number>(0); // 마지막으로 알려진 재생 시간
const timeUpdateWallRef = useRef<number>(performance.now()); // currentTime 갱신된 시각
const paramsRef = useRef<CameraParams>(DEFAULT_CAMERA_PARAMS);
const visibleRef = useRef(visible);
const videoReadyRef = useRef(videoReady);
const videoSizeRef = useRef({ w: videoWidth, h: videoHeight });
const barHeightRef = useRef(barHeight); // 하단 스테이션바 높이(px) — 팝업/라벨 유효 하단 = H barHeight
useEffect(() => { barHeightRef.current = barHeight; }, [barHeight]);
// 영상 실제 fps — 부모(VideoPlayer)가 데이터(마지막 프레임 번호)+영상길이로 계산해 prop 으로 전달.
// 프레임번호↔시간 변환의 기준(고정 29.97 대신 영상별 자동). 0/미정이면 VIDEO_FPS 폴백.
const fpsRef = useRef(fps > 0 ? fps : VIDEO_FPS);
useEffect(() => { if (fps > 0) fpsRef.current = fps; }, [fps]);
// object-fit:cover 변환 (RAF에서 매 프레임 갱신, 포인터 핸들러도 참조). 정규(0~1, 영상프레임) → 화면 px.
const coverRef = useRef({ offX: 0, offY: 0, dispW: 0, dispH: 0, W: 0, H: 0 });
const worldOriginRef = useRef<{ lat: number; lon: number; alt: number } | undefined>(undefined);
// 텍스트 사전 계산 Map
const labelMapRef = useRef<Map<number, LabelCache>>(new Map());
const precomputeIdRef = useRef(0); // 진행 중 계산 취소용
// 캐시 키(드론 프레임 번호, 오름차순) — RAF 가 최근접 키를 이진탐색으로 조회.
const labelKeysRef = useRef<number[]>([]);
// 직전 사전계산의 frames 참조 — 바뀌면(세그먼트 전환) 옛 캐시를 복사하지 않고 새로 시작.
const lastPrecomputeFramesRef = useRef<DroneFrameBasic[] | null>(null);
// 중심선 + 나침반 렌더 캐시 (per-frame)
const renderCacheRef = useRef<RenderCache | null>(null);
// 나침반 전용(측점선 visible 무관 항상 갱신·표시)
const compassRef = useRef<{ effectiveYaw: number; hFovRad: number } | null>(null);
// 나침반 미니맵(DOM) root — RAF 에서 --rot(=-yaw) 갱신. minimapRotRef=누적 회전(360° 언랩).
const minimapRef = useRef<HTMLDivElement>(null);
const minimapRotRef = useRef(0);
// 지도 나침반용 현재 위치/방위 — RAF 가 매 프레임 갱신, MapCompass 가 읽음.
const mapPoseRef = useRef<CompassPose>({ lat: 0, lon: 0, yaw: 0 });
// UI state
const [params, setParams] = useState<CameraParams>(DEFAULT_CAMERA_PARAMS);
const [smoothHalf, setSmoothHalf] = useState(DISPLAY_DEFAULTS.smoothHalf);
const smoothHalfRef = useRef(10);
// 화면 EMA — 0.01은 과평활(지연 ~1.65s)로 카메라 이동 시 라벨이 늦게 따라옴.
// 입력은 이미 smoothFrame(±10fr)으로 평활되므로 화면 EMA는 가볍게(0.4, 지연 ~25ms).
const [emaAlpha, setEmaAlpha] = useState(DISPLAY_DEFAULTS.emaAlpha);
const emaAlphaRef = useRef(0.4);
// 속도적응 평활 — 떨림 억제 강도/기준속도(라벨·팝업 1배속 떨림 튜닝).
const [smoothMinAlpha, setSmoothMinAlpha] = useState(DISPLAY_DEFAULTS.smoothMinAlpha);
const smoothMinAlphaRef = useRef(DISPLAY_DEFAULTS.smoothMinAlpha);
const [smoothSpeedRef, setSmoothSpeedRef] = useState(DISPLAY_DEFAULTS.smoothSpeedRef);
const smoothSpeedRefRef = useRef(DISPLAY_DEFAULTS.smoothSpeedRef);
// POI 표시 범위(m) — 드론 위치와의 수평 직선거리(distH, 고도차 무시). 이 범위 안이면 표시,
// 밖이면 제외. (이전: 진행방향 앞/옆 비등방 → 단일 반경으로 통합.) 패널에서 실시간 조절.
const [maxPoiRange, setMaxPoiRange] = useState(DISPLAY_DEFAULTS.maxPoiRange);
const maxPoiRangeRef = useRef(DISPLAY_DEFAULTS.maxPoiRange);
// 비교 테스트: POI 높이를 선로표고(false) 대신 '드론 고도 N미터'(true)로. N=droneHeightDrop.
const [poiDroneHeight, setPoiDroneHeight] = useState(DISPLAY_DEFAULTS.poiDroneHeight);
const [droneHeightDrop, setDroneHeightDrop] = useState(DISPLAY_DEFAULTS.droneHeightDrop);
// 드론 경로 가시화 — 드론의 GPS 경로(lat/lon)를 지정 표고 z 에 투영해 선으로 표시.
// 선형/드론궤적 토글은 설정 스토어로 공유 → VideoPlayer 하단 바 버튼에서도 제어.
const showDronePath = useSettingsStore(s => s.showDronePath);
const setShowDronePath = useSettingsStore(s => s.setShowDronePath);
const [dronePathZ, setDronePathZ] = useState(DISPLAY_DEFAULTS.dronePathZ); // 기본 경로 표고
const [clFx, setClFx] = useState(DISPLAY_DEFAULTS.clFx); // 선형 입체 효과 on/off
const clFxRef = useRef(DISPLAY_DEFAULTS.clFx);
const [clTubeD, setClTubeD] = useState(DISPLAY_DEFAULTS.clTubeD); // 선형 튜브 지름(m)
const clTubeDRef = useRef(DISPLAY_DEFAULTS.clTubeD);
const [clDotDiv, setClDotDiv] = useState(DISPLAY_DEFAULTS.clDotDiv); // 측점 분할 수
const clDotDivRef = useRef(DISPLAY_DEFAULTS.clDotDiv);
const showDronePathRef = useRef(true);
const dronePathZRef = useRef(78);
useEffect(() => { showDronePathRef.current = showDronePath; }, [showDronePath]);
useEffect(() => { dronePathZRef.current = dronePathZ; }, [dronePathZ]);
useEffect(() => { clFxRef.current = clFx; }, [clFx]);
useEffect(() => { clTubeDRef.current = clTubeD; }, [clTubeD]);
useEffect(() => { clDotDivRef.current = clDotDiv; }, [clDotDiv]);
// 선형(중심선) 독립 토글 + 드론경로 투명도(0~1) — 토글은 설정 스토어 공유(바 버튼 제어).
const showCenterline = useSettingsStore(s => s.showCenterline);
const setShowCenterline = useSettingsStore(s => s.setShowCenterline);
const showCenterlineRef = useRef(true);
useEffect(() => { showCenterlineRef.current = showCenterline; }, [showCenterline]);
const [dronePathAlpha, setDronePathAlpha] = useState(DISPLAY_DEFAULTS.dronePathAlpha);
const dronePathAlphaRef = useRef(0.75);
useEffect(() => { dronePathAlphaRef.current = dronePathAlpha; }, [dronePathAlpha]);
// 측점 진단 토글 버튼은 삭제됨. (HUD 표시는 VideoPlayer 에서 showStationDiag 구독)
// POI 위치 편집(드래그) 모드
const [editMode, setEditMode] = useState(false);
const editModeRef = useRef(false);
// FOV(초점) 보정 모드: POI 라벨을 실제 위치로 끌면 그 대응점으로 focal 을 역산.
const [fovMode, setFovMode] = useState(false);
const fovModeRef = useRef(false);
// Yaw 보정 모드: POI 라벨을 실제 위치(좌우)로 끌면 필요한 Yaw± 를 역산.
const [yawMode, setYawMode] = useState(false);
const yawModeRef = useRef(false);
// 라벨 속성 팝업(다중) — 항상 활성(모드 버튼 없음). 라벨 위에서만 캔버스가 입력을 받아 클릭→팝업.
// id=`${kind}:${title}`. 위치는 RAF 가 라벨 displayed 좌표로 매 프레임 갱신(재생 중 따라 이동).
type InfoPopup = { id: string; kind: 'poi' | 'station'; title: string; category: string; lat: number; lon: number; z: number; dist: number | null; sx: number; sy: number; props?: { k: string; v: string }[];
/** 컴팩트 기본필드(시설종별/구조형식/연장/용도/준공연도). 있으면 라벨 옆 자동표시. */
compact?: { k: string; v: string }[];
/** true=전체 속성, false/undefined=컴팩트. 팝업 클릭으로 토글. */
expanded?: boolean;
/** true=가시 구조물에 따라 자동생성/제거(✕ 없음). */
auto?: boolean };
const [infoPopups, setInfoPopups] = useState<InfoPopup[]>([]);
const infoPopupsRef = useRef<InfoPopup[]>([]);
useEffect(() => {
infoPopupsRef.current = infoPopups;
// 팝업 제거 경로가 여럿(RAF off-4프레임 / sync toRemoveIds / 수동 ✕·ESC·빈곳클릭)인데
// 보조 맵 정리는 RAF 경로만 했었다 → sync/수동 제거 시 popupPosRef 잔존. 그러면 재추가 때
// hidden-mount 가드(!popupPosRef.has)가 무력화돼 라벨 없이 팝업만 stale 위치에 번쩍인다.
// → infoPopups 변경 시 사라진 id 의 보조 맵을 일괄 정리(단일 출처). (popupElsRef 는 ref 콜백이 정리.)
const ids = new Set(infoPopups.map(p => p.id));
for (const id of popupPosRef.current.keys()) if (!ids.has(id)) popupPosRef.current.delete(id);
for (const id of popupMissRef.current.keys()) if (!ids.has(id)) popupMissRef.current.delete(id);
for (const id of popupFlipRef.current.keys()) if (!ids.has(id)) popupFlipRef.current.delete(id);
}, [infoPopups]);
// 영상 진행 방향('상'|'하') — 겹치는 상/하 형제 구조물 중 이 방향을 우선 표시. (값 주입은 아래 routeMeta 구독 후)
const routeDirRef = useRef<'상' | '하' | null>(null);
// 이번 프레임 가시 컴팩트-구조물 (title → {category, compact}). RAF 가 채우고 sync 인터벌이 읽음.
const visStructRef = useRef<Map<string, { category: string; compact: { k: string; v: string }[] }>>(new Map());
const popupElsRef = useRef<Map<string, HTMLDivElement>>(new Map());
const popupMissRef = useRef<Map<string, number>>(new Map()); // 라벨이 화면 밖인 연속 프레임 수(제거 유예)
const popupPosRef = useRef<Map<string, { x: number; y: number }>>(new Map()); // 팝업 위치 EMA(물결 방지)
const popupFlipRef = useRef<Map<string, 'above' | 'below'>>(new Map()); // 팝업 배치면(위/아래) — 히스테리시스로 경계 왕복(번쩍임) 방지
// 라벨 히트박스(아이콘+글자 영역, 화면 px) — RAF 가 매 프레임 갱신, 클릭/호버 히트테스트에 사용.
const labelHitRef = useRef<{ kind: 'poi' | 'station'; title: string; x0: number; y0: number; x1: number; y1: number }[]>([]);
const hasPopupRef = useRef(false);
const onTogglePlayRef = useRef<(() => void) | undefined>(undefined);
const poiDroneHeightRef = useRef(DISPLAY_DEFAULTS.poiDroneHeight);
const droneHeightDropRef = useRef(DISPLAY_DEFAULTS.droneHeightDrop);
const overridesRef = useRef<PoiOverrideMap>({});
// POI 라벨이 연속으로 화면 안에 그려진 프레임 수(title→streak). 팝업 '추가'에 히스테리시스를 줘
// 경계에서 라벨이 미세하게 깜빡일 때(1~2프레임) 팝업이 재생성돼 번쩍이는 것을 막는다.
const poiOnScreenStreakRef = useRef<Map<string, number>>(new Map());
// 드래그 상태: 선택 POI + lat/lon(고정) + 시작 z + 시작 화면좌표. 드롭 시 세로위치로 z 역산.
const dragRef = useRef<{ title: string; lat: number; lon: number; z0: number; sx: number; sy: number } | null>(null);
const dragPosRef = useRef<{ x: number; y: number } | null>(null);
const [dragTitle, setDragTitle] = useState<string | null>(null);
const [demBusy, setDemBusy] = useState(false);
// 선택된 POI의 표고 직접 조절 (슬라이더). selZBase=선로표고(슬라이더 범위 기준).
const [selPoi, setSelPoi] = useState<string | null>(null);
const [selZ, setSelZ] = useState(0);
const [selZBase, setSelZBase] = useState(42);
// 표시 위치 EMA 상태 (RAF 내부 유지). rej = 연속 이상치 거부 횟수.
const displayedStRef = useRef<Map<string, DispPos>>(new Map());
const displayedPoiRef = useRef<Map<string, DispPos>>(new Map());
const [showControls, setShowControls] = useState(false);
const [showDisplay, setShowDisplay] = useState(false);
const [droneFramesLoaded, setDroneFramesLoaded] = useState(false);
const [geoDataLoaded, setGeoDataLoaded] = useState(false);
const [clDataLoaded, setClDataLoaded] = useState(false);
const [panelDroneFrame, setPanelDroneFrame] = useState<DroneFrameBasic | null>(null);
useEffect(() => { paramsRef.current = params; }, [params]);
useEffect(() => { visibleRef.current = visible; }, [visible]);
useEffect(() => { videoReadyRef.current = videoReady; }, [videoReady]);
useEffect(() => { videoSizeRef.current = { w: videoWidth, h: videoHeight }; }, [videoWidth, videoHeight]);
useEffect(() => { smoothHalfRef.current = smoothHalf; }, [smoothHalf]);
useEffect(() => { emaAlphaRef.current = emaAlpha; }, [emaAlpha]);
useEffect(() => { smoothMinAlphaRef.current = smoothMinAlpha; }, [smoothMinAlpha]);
useEffect(() => { smoothSpeedRefRef.current = smoothSpeedRef; }, [smoothSpeedRef]);
useEffect(() => { maxPoiRangeRef.current = maxPoiRange; }, [maxPoiRange]);
useEffect(() => { editModeRef.current = editMode; }, [editMode]);
useEffect(() => { fovModeRef.current = fovMode; }, [fovMode]);
useEffect(() => { yawModeRef.current = yawMode; }, [yawMode]);
// 컴팩트 자동 팝업 동기화 — visStructRef(연속 온스크린 라벨) 마다 라벨 옆 DOM 팝업을 자동 '생성'만.
// 제거는 RAF 가 단일 권한(onScreenLabels + 4프레임 유예)으로 담당 → add/remove 충돌(번쩍임) 제거.
// (캔버스 텍스트는 흐려서 DOM 팝업으로 선명하게. 위치는 RAF 가 displayed 좌표로 매 프레임 갱신.)
// 60fps 루프에서 setState 남발 방지 → 150ms 인터벌로 가시집합 변화시에만 갱신.
useEffect(() => {
const tick = (): void => {
const vis = visStructRef.current;
const cur = infoPopupsRef.current;
const c = coverRef.current;
const toAdd: InfoPopup[] = [];
vis.forEach((info, title) => {
const id = `poi:${title}`;
if (cur.some(p => p.id === id)) return;
const disp = displayedPoiRef.current.get(title);
if (!disp) return;
const obj = allStructuresRef.current.concat(allPoisRef.current).find(p => p.title === title);
toAdd.push({
id, kind: 'poi', title, category: info.category,
lat: obj?.lat ?? 0, lon: obj?.lon ?? 0, z: 0, dist: null,
sx: c.offX + disp.x * c.dispW, sy: c.offY + disp.y * c.dispH,
props: obj?.props, compact: info.compact, auto: true, expanded: false,
});
});
if (toAdd.length) {
setInfoPopups(prev => {
const ids = new Set(prev.map(p => p.id));
return [...prev, ...toAdd.filter(p => !ids.has(p.id))];
});
}
};
const iv = setInterval(tick, 150);
return () => clearInterval(iv);
}, []);
// ESC → 클릭으로 연/펼친 팝업만 닫기(수동 팝업 제거 + 펼친 것 컴팩트로 접기).
// 기본 자동 컴팩트 팝업은 유지(닫지 않음).
useEffect(() => {
const onKey = (e: KeyboardEvent): void => {
if (e.key !== 'Escape') return;
if (infoPopupsRef.current.some(p => !p.auto || p.expanded)) {
setInfoPopups(prev => prev.filter(p => p.auto).map(p => ({ ...p, expanded: false })));
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, []);
useEffect(() => { hasPopupRef.current = infoPopups.length > 0; }, [infoPopups]);
useEffect(() => { onTogglePlayRef.current = onTogglePlay; }, [onTogglePlay]);
useEffect(() => { poiDroneHeightRef.current = poiDroneHeight; }, [poiDroneHeight]);
useEffect(() => { droneHeightDropRef.current = droneHeightDrop; }, [droneHeightDrop]);
const setParam = useCallback(<K extends keyof CameraParams>(key: K, val: CameraParams[K]) =>
setParams(prev => ({ ...prev, [key]: val })), []);
const nearestCL = useCallback((lat: number, lon: number): CenterlinePoint | null => {
const pts = allCenterlinePointsRef.current;
if (!pts.length) return null;
let best = pts[0], bestD = (best.lat - lat) ** 2 + (best.lon - lon) ** 2;
for (const pt of pts) {
const d = (pt.lat - lat) ** 2 + (pt.lon - lon) ** 2;
if (d < bestD) { bestD = d; best = pt; }
}
return best;
}, []);
/** 중심선 '세그먼트 투영' 최근접점 — 정점 스냅이 아니라 선 위의 정확한 위치.
* LineString 중심선(예: 제주 146정점)은 직선 구간 정점이 성겨서, 정점 스냅(nearestCL)을
* 측점 라벨에 쓰면 0k200~0k700 같은 직선 구간 측점들이 굽이 정점으로 뭉쳐 버린다. */
const projectCL = useCallback((lat: number, lon: number): CenterlinePoint | null => {
const pts = allCenterlinePointsRef.current;
if (pts.length < 2) return nearestCL(lat, lon);
const k = Math.cos((lat * Math.PI) / 180);
const px = lon * k, py = lat;
let bestD = Infinity, bl = pts[0].lat, bo = pts[0].lon, bz = pts[0].z;
for (let i = 0; i < pts.length - 1; i++) {
const a = pts[i], b = pts[i + 1];
const ax = a.lon * k, ay = a.lat, bx = b.lon * k, by = b.lat;
const dx = bx - ax, dy = by - ay;
const L2 = dx * dx + dy * dy;
const t = L2 === 0 ? 0 : Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / L2));
const cx = ax + dx * t, cy = ay + dy * t;
const d = (px - cx) ** 2 + (py - cy) ** 2;
if (d < bestD) { bestD = d; bl = cy; bo = cx / k; bz = a.z + (b.z - a.z) * t; }
}
return { lat: bl, lon: bo, z: bz };
}, [nearestCL]);
// 클라이언트 지리정보 스토어 구독 (서버 /api/geo/* 대체)
const storeStations = useGeoStore(s => s.stations);
const storePois = useGeoStore(s => s.pois);
const storeCenterline = useGeoStore(s => s.centerline);
const storeFrames = useGeoStore(s => s.frames);
const storeFrameSynced = useGeoStore(s => s.frameSynced);
const frameSyncedRef = useRef(false);
useEffect(() => { frameSyncedRef.current = storeFrameSynced; }, [storeFrameSynced]);
// 포즈 평활 유효 반폭 — 프레임 동기(djmd) 데이터는 상한 제한(회전 시 포즈 지연 제거).
const poseHalf = useCallback((): number =>
frameSyncedRef.current ? Math.min(smoothHalfRef.current, POSE_SMOOTH_SYNCED_MAX) : smoothHalfRef.current,
[]);
const storeOrigin = useGeoStore(s => s.origin);
const storeStructures = useGeoStore(s => s.structures);
const routeMeta = useGeoStore(s => s.routeMeta);
useEffect(() => {
const d = routeMeta?.routeInfo?.direction ?? '';
routeDirRef.current = d.includes('하') ? '하' : d.includes('상') ? '상' : null;
}, [routeMeta]);
// POI 겹침제외 토글(설정 스토어). false면 겹침 억제 없이 모든 POI 표시.
const compassType = useSettingsStore(s => s.compassType);
const setCompassType = useSettingsStore(s => s.setCompassType);
const compassMapStyle = useSettingsStore(s => s.compassMapStyle);
const setCompassMapStyle = useSettingsStore(s => s.setCompassMapStyle);
const poiOverlapExclude = useSettingsStore(s => s.poiOverlapExclude);
const overlapExcludeRef = useRef(true);
useEffect(() => { overlapExcludeRef.current = poiOverlapExclude; }, [poiOverlapExclude]);
// 좌측 노선 패널(RoutePanel) 표시 토글은 하단 재생바(VideoPlayer)로 이동.
const storeBaseName = useGeoStore(s => s.baseName);
const storeCameraInfo = useGeoStore(s => s.cameraInfo);
const storeCameraJson = useGeoStore(s => s.cameraJson);
const storeDisplayJson = useGeoStore(s => s.displayJson);
const storePoiOverrides = useGeoStore(s => s.poiOverrides);
const setPoiOverride = useGeoStore(s => s.setPoiOverride);
const setPoiOverrides = useGeoStore(s => s.setPoiOverrides);
const clearPoiOverride = useGeoStore(s => s.clearPoiOverride);
// 경로 표고 슬라이더 상한 — 데이터 최대 표고 + 여유 (없으면 60 폴백).
// 중심선이 없거나 z=0 인 데이터셋(예: 제주 DJI 로그)은 드론 고도(ASL)가 유일한 상한 근거 —
// 지면은 드론보다 낮으므로 max(드론고도)까지 열어두면 어떤 지형이든 슬라이더로 도달 가능.
const pathZMax = useMemo(() => {
let m = 0;
for (const c of storeCenterline) if (typeof c.z === 'number' && c.z > m) m = c.z;
for (const f of storeFrames) if (typeof f.altitude === 'number' && f.altitude > m) m = f.altitude;
return m > 0 ? Math.ceil(m + 10) : 60;
}, [storeCenterline, storeFrames]);
useEffect(() => { overridesRef.current = storePoiOverrides; }, [storePoiOverrides]);
// ── 보정값 영속화 (baseName별 localStorage) ──────────────────────────────
// 카메라 각도/내부표정(Yaw±/Pitch±/focal 등) + 표시설정을 데이터셋별로 저장·복원.
// 한 번 튜닝하면 새로고침/재로드해도 유지 → "처음부터 맞게" 표시.
const calibLoadedFor = useRef<string | null>(null);
// 이 데이터셋에 저장된 보정값이 있었는지 — 카메라 감지값 자동 적용 여부 판단(사용자 튜닝 우선).
const calibHadSavedRef = useRef(false);
useEffect(() => {
if (!storeBaseName || calibLoadedFor.current === storeBaseName) return;
calibLoadedFor.current = storeBaseName;
try {
const raw = localStorage.getItem(`ghivideo:calib:${storeBaseName}`);
calibHadSavedRef.current = !!raw;
if (!raw) return;
const c = JSON.parse(raw);
if (c.params && typeof c.params === 'object') setParams(p => ({ ...p, ...c.params }));
// maxPoiRange 우선, 없으면 구버전(maxPoiFront) 값으로 마이그레이션.
if (typeof c.maxPoiRange === 'number') setMaxPoiRange(c.maxPoiRange);
else if (typeof c.maxPoiFront === 'number') setMaxPoiRange(c.maxPoiFront);
if (typeof c.smoothHalf === 'number') setSmoothHalf(c.smoothHalf);
if (typeof c.emaAlpha === 'number') setEmaAlpha(c.emaAlpha);
if (typeof c.smoothMinAlpha === 'number') setSmoothMinAlpha(c.smoothMinAlpha);
if (typeof c.smoothSpeedRef === 'number') setSmoothSpeedRef(c.smoothSpeedRef);
} catch { /* noop */ }
}, [storeBaseName]);
useEffect(() => {
if (!storeBaseName) return;
const t = setTimeout(() => {
try {
localStorage.setItem(`ghivideo:calib:${storeBaseName}`, JSON.stringify({
params, maxPoiRange, smoothHalf, emaAlpha, smoothMinAlpha, smoothSpeedRef,
}));
} catch { /* noop */ }
}, 800);
return () => clearTimeout(t);
}, [storeBaseName, params, maxPoiRange, smoothHalf, emaAlpha, smoothMinAlpha, smoothSpeedRef]);
// 영상(djmd)에서 감지한 카메라 초점거리 자동 적용.
// 사용자가 f 를 직접 튜닝한 값(기본값 24 가 아닌 저장값)만 우선하고, 그 외(첫 로드,
// 또는 저장은 됐지만 f 가 기본값 그대로인 경우)는 감지값을 적용한다.
// (보정 자동저장은 폴더를 열기만 해도 기본값으로 저장되므로 '저장 존재' 만으론 튜닝 여부 판단 불가)
const camAppliedFor = useRef<string | null>(null);
useEffect(() => {
if (!storeBaseName || !storeCameraInfo?.focalLen35) return;
if (camAppliedFor.current === storeBaseName) return;
camAppliedFor.current = storeBaseName;
const f = storeCameraInfo.focalLen35;
const model = storeCameraInfo.model;
setParams(p => {
const focalTuned = calibHadSavedRef.current &&
Math.abs(p.focalLen - DEFAULT_CAMERA_PARAMS.focalLen) > 0.01;
if (focalTuned) {
console.log(`[camera] ${model} 감지 (${f}mm) — 사용자 튜닝 f=${p.focalLen}mm 유지 (패널 '적용'으로 교체 가능)`);
return p;
}
console.log(`[camera] ${model} 감지 → focal ${f}mm 자동 적용`);
return { ...p, focalLen: f };
});
}, [storeBaseName, storeCameraInfo]);
// 폴더의 <영상 base>.camera.json 카메라 파라미터 — 최우선 적용(localStorage/감지값 위에).
// 이 effect 는 calib 복원·감지값 적용 effect 뒤에 선언되어 함수형 업데이트가 마지막에 반영된다.
const camJsonAppliedFor = useRef<string | null>(null);
useEffect(() => {
if (!storeBaseName || !storeCameraJson) return;
if (camJsonAppliedFor.current === storeBaseName) return;
camJsonAppliedFor.current = storeBaseName;
const cam = storeCameraJson;
setParams(p => {
const next = { ...p };
for (const [k, v] of Object.entries(cam)) {
if (k in next && typeof v === 'number' && isFinite(v)) (next as unknown as Record<string, number>)[k] = v;
}
return next;
});
console.log('[camera] <base>.camera.json 파라미터 적용(최우선):', cam);
}, [storeBaseName, storeCameraJson]);
// 현재 카메라 파라미터를 사용자 PC 에 `camera.json`(단순 이름 규칙)으로 다운로드 저장.
// Yaw±(자동추정/드래그 역산 결과) 포함 전체 파라미터가 담긴다.
// 받은 파일을 영상 폴더에 넣어두면 다음 폴더 선택 때 최우선으로 자동 적용된다.
const [camSaveState, setCamSaveState] = useState<'idle' | 'ok'>('idle');
// 환경설정 파일 이름 — 폴더의 CSV(비행로그) 이름 → route.json 노선명 → 영상 base 순.
// 예: "제주 중산간도로 경로 1-1.camera.json" (로더의 *.camera.json 규칙으로 자동 인식)
const settingsBaseName = useCallback((): string => {
const geo = useGeoStore.getState();
const csv = geo.folderFiles.find((f) => /\.csv$/i.test(f.name));
if (csv) return csv.name.replace(/\.csv$/i, '');
const rn = geo.routeMeta?.routeInfo?.name;
if (rn && rn.trim()) return rn.trim();
return geo.baseName ?? 'settings';
}, []);
const saveCameraToPc = useCallback(() => {
const geo = useGeoStore.getState();
const payload = {
camera: paramsRef.current,
model: geo.cameraInfo?.model,
savedAt: new Date().toISOString(),
};
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${settingsBaseName()}.camera.json`;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 5000);
setCamSaveState('ok');
setTimeout(() => setCamSaveState('idle'), 2500);
}, [settingsBaseName]);
// 폴더의 <영상 base>.display.json 화면표시 옵션 자동 적용 — 'PC에 저장' 버튼과 왕복.
// localStorage 복원(calib effect)보다 뒤에 선언되어 파일 값이 우선 반영된다.
const dispJsonAppliedFor = useRef<string | null>(null);
useEffect(() => {
if (!storeBaseName || !storeDisplayJson) return;
if (dispJsonAppliedFor.current === storeBaseName) return;
dispJsonAppliedFor.current = storeBaseName;
const d = storeDisplayJson;
const num = (k: string): number | undefined =>
typeof d[k] === 'number' && isFinite(d[k] as number) ? (d[k] as number) : undefined;
const bool = (k: string): boolean | undefined =>
typeof d[k] === 'boolean' ? (d[k] as boolean) : undefined;
if (num('dronePathZ') !== undefined) setDronePathZ(num('dronePathZ')!);
if (bool('clFx') !== undefined) setClFx(bool('clFx')!);
if (num('clTubeD') !== undefined) setClTubeD(num('clTubeD')!);
if (num('clDotDiv') !== undefined) setClDotDiv(num('clDotDiv')!);
if (num('dronePathAlpha') !== undefined) setDronePathAlpha(num('dronePathAlpha')!);
if (num('smoothHalf') !== undefined) setSmoothHalf(num('smoothHalf')!);
if (num('emaAlpha') !== undefined) setEmaAlpha(num('emaAlpha')!);
if (num('smoothMinAlpha') !== undefined) setSmoothMinAlpha(num('smoothMinAlpha')!);
if (num('smoothSpeedRef') !== undefined) setSmoothSpeedRef(num('smoothSpeedRef')!);
if (num('maxPoiRange') !== undefined) setMaxPoiRange(num('maxPoiRange')!);
if (bool('poiDroneHeight') !== undefined) setPoiDroneHeight(bool('poiDroneHeight')!);
if (num('droneHeightDrop') !== undefined) setDroneHeightDrop(num('droneHeightDrop')!);
const ss = useSettingsStore.getState();
if (bool('showCenterline') !== undefined) ss.setShowCenterline(bool('showCenterline')!);
if (bool('showDronePath') !== undefined) ss.setShowDronePath(bool('showDronePath')!);
if (bool('showRoutePanel') !== undefined) ss.setShowRoutePanel(bool('showRoutePanel')!);
if (bool('poiOverlapExclude') !== undefined) ss.setPoiOverlapExclude(bool('poiOverlapExclude')!);
if (d.compassType === 'analog' || d.compassType === 'map') ss.setCompassType(d.compassType);
if (d.compassMapStyle === 'street' || d.compassMapStyle === 'sat') ss.setCompassMapStyle(d.compassMapStyle);
if (d.gradeFilter && typeof d.gradeFilter === 'object') {
for (const [g, v] of Object.entries(d.gradeFilter as Record<string, unknown>)) {
if (typeof v === 'boolean') ss.setGradeFilter(g, v);
}
}
console.log('[display] <base>.display.json 화면표시 옵션 적용:', d);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [storeBaseName, storeDisplayJson]);
// 현재 화면표시 옵션을 사용자 PC 에 `display.json`(단순 이름 규칙)으로 다운로드 저장.
// 받은 파일을 영상 폴더에 넣어두면 다음 폴더 선택 때 자동 적용된다.
const [dispSaveState, setDispSaveState] = useState<'idle' | 'ok'>('idle');
const saveDisplayToPc = useCallback(() => {
const ss = useSettingsStore.getState();
const payload = {
display: {
dronePathZ, dronePathAlpha, clFx, clTubeD, clDotDiv, smoothHalf, emaAlpha, smoothMinAlpha, smoothSpeedRef,
maxPoiRange, poiDroneHeight, droneHeightDrop,
showCenterline: ss.showCenterline,
showDronePath: ss.showDronePath,
showRoutePanel: ss.showRoutePanel,
poiOverlapExclude: ss.poiOverlapExclude,
compassType: ss.compassType,
compassMapStyle: ss.compassMapStyle,
gradeFilter: ss.gradeFilter,
},
savedAt: new Date().toISOString(),
};
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${settingsBaseName()}.display.json`;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 5000);
setDispSaveState('ok');
setTimeout(() => setDispSaveState('idle'), 2500);
}, [dronePathZ, dronePathAlpha, clFx, clTubeD, clDotDiv, smoothHalf, emaAlpha, smoothMinAlpha, smoothSpeedRef, maxPoiRange, poiDroneHeight, droneHeightDrop, settingsBaseName]);
// Yaw 자동 추정 — 전진 비행 구간에서 GPS 진행 방위(정확) vs 짐벌 헤딩(나침반 오차 포함)의
// 차이 중앙값을 Yaw± 로 설정. 카메라가 진행방향을 보며 촬영하는 도로 추종 비행 가정(근사치).
const autoEstimateYaw = useCallback(() => {
const frames = allDroneFramesRef.current;
if (!frames || frames.length < 30) { alert('드론 로그가 부족해 자동 추정할 수 없습니다.'); return; }
const fps = fpsRef.current;
const cosLat = Math.cos((frames[0].lat * Math.PI) / 180);
const STEP = 10; // 진행방위 계산 창(≈1초) — GPS 지터 평활
const diffs: number[] = [];
for (let i = STEP; i < frames.length; i++) {
const a = frames[i - STEP], b = frames[i];
const dt = (b.frame - a.frame) / fps;
if (dt <= 0) continue;
const dx = (b.lon - a.lon) * 111000 * cosLat;
const dy = (b.lat - a.lat) * 111000;
if (Math.hypot(dx, dy) / dt < 2) continue; // 정지/호버 구간 제외
const course = (Math.atan2(dx, dy) * 180) / Math.PI; // 진행 방위 (North=0, CW)
let d = course - frames[i - (STEP >> 1)].yaw; // 진행방위 짐벌 헤딩
d = ((d + 540) % 360) - 180;
if (Math.abs(d) < 60) diffs.push(d); // 옆을 보며 찍은 구간 제외
}
if (diffs.length < 20) { alert('전진 촬영 구간이 부족해 자동 추정할 수 없습니다.'); return; }
diffs.sort((x, y) => x - y);
const med = diffs[diffs.length >> 1];
const next = Math.round(med * 10) / 10;
setParam('yawOffset', next);
console.log(`[yaw] 자동 추정: 유효 샘플 ${diffs.length}개, 중앙값 ${med.toFixed(2)}° → Yaw± ${next}° 적용`);
}, [setParam]);
// POI 보정(override: DEM 표고/드래그)도 baseName별 자동 저장·복원 → 새로고침해도 유지.
const ovLoadedFor = useRef<string | null>(null);
useEffect(() => {
if (!storeBaseName || ovLoadedFor.current === storeBaseName) return;
ovLoadedFor.current = storeBaseName;
try {
const raw = localStorage.getItem(`ghivideo:poiov:${storeBaseName}`);
if (!raw) return;
const saved = JSON.parse(raw);
if (saved && typeof saved === 'object') {
// 폴더 파일 override 위에 localStorage(최근 편집) 병합.
setPoiOverrides({ ...useGeoStore.getState().poiOverrides, ...saved });
}
} catch { /* noop */ }
}, [storeBaseName, setPoiOverrides]);
useEffect(() => {
if (!storeBaseName) return;
const t = setTimeout(() => {
try { localStorage.setItem(`ghivideo:poiov:${storeBaseName}`, JSON.stringify(storePoiOverrides)); }
catch { /* noop */ }
}, 600);
return () => clearTimeout(t);
}, [storeBaseName, storePoiOverrides]);
// 측점 (stationOrder 정렬 — 스토어가 이미 정렬해 두지만 방어적으로 동일 순서 보장)
useEffect(() => {
allGeoStationsRef.current = [...storeStations]
.sort((a, b) => stationOrder(a.title) - stationOrder(b.title));
allPoisRef.current = storePois;
setGeoDataLoaded(storeStations.length > 0 || storePois.length > 0);
}, [storeStations, storePois]);
// 구조물(교량/터널/구교)을 영상 오버레이에 POI처럼 표시(거리필터 적용).
// - 영상은 시설등급 무관 모두 표출(신대천교(복) 등 포함). 스테이션바엔 시설등급 필터로 별도 제어.
// lat/lon 없는 항목(측점기반 배치 등)도 제외.
// 신포맷(지장물.kml '타입') 교량/터널은 POI 로도 등록됨 — 같은 이름이 POI 에 있으면
// 영상 라벨이 이중 표출(파란 POI + 빨간 구조물, z 도 달라 위치 분리)되므로 구조물 쪽 제외.
// (스테이션바 구조물 마크는 storeStructures 직접 사용이라 영향 없음)
useEffect(() => {
const poiTitles = new Set(storePois.map(p => p.title));
allStructuresRef.current = storeStructures
.filter((s): s is RouteStructure & { lat: number; lon: number } =>
s.category != null && OVERLAY_STRUCT_CATEGORIES.has(s.category) &&
typeof s.lat === 'number' && typeof s.lon === 'number' &&
!poiTitles.has(s.name))
.map(s => ({
title: s.name,
category: s.category ?? '구교',
lat: s.lat, lon: s.lon, z: 0, type: 'poi' as const,
props: s.props,
}));
}, [storeStructures, storePois]);
// 중심선 (+ 누적거리, 측점 체이니지 앵커 — 10m 단위 선형 점 배치용)
useEffect(() => {
allCenterlinePointsRef.current = storeCenterline;
const cum: number[] = [0];
if (storeCenterline.length > 1) {
const k = Math.cos((storeCenterline[0].lat * Math.PI) / 180) * 111000;
for (let i = 1; i < storeCenterline.length; i++) {
const a = storeCenterline[i - 1], b = storeCenterline[i];
cum.push(cum[i - 1] + Math.hypot((b.lon - a.lon) * k, (b.lat - a.lat) * 111000));
}
}
clCumRef.current = cum;
// 측점(체이니지 값) ↔ 폴리라인 누적거리 앵커 — 측점 정점이 선형에 삽입되어 있어
// 최근접 정점(5m 이내)이 곧 그 측점 위치. 점을 '측점 기준 10m 단위'로 정렬하는 근거.
const anchors: { cum: number; km: number }[] = [];
if (storeCenterline.length > 1 && storeStations.length) {
const k2 = Math.cos((storeCenterline[0].lat * Math.PI) / 180) * 111000;
for (const st of storeStations) {
const m = st.title.match(/(\d+)\s*[Kk+]\s*(\d+)/);
if (!m) continue;
const km = parseInt(m[1], 10) * 1000 + parseInt(m[2], 10);
let bi = -1, bd = Infinity;
for (let i = 0; i < storeCenterline.length; i++) {
const p = storeCenterline[i];
const d = Math.hypot((p.lon - st.lon) * k2, (p.lat - st.lat) * 111000);
if (d < bd) { bd = d; bi = i; }
}
if (bi >= 0 && bd < 5) anchors.push({ cum: cum[bi], km });
}
anchors.sort((a, b) => a.cum - b.cum);
}
clStaAnchorsRef.current = anchors;
setClDataLoaded(storeCenterline.length > 0);
}, [storeCenterline, storeStations]);
// 드론 프레임
useEffect(() => {
allDroneFramesRef.current = storeFrames;
// 세그먼트 전환 시 현재 프레임 추적 초기화 — 스테일 인덱스(이전 세그먼트 끝)로
// 사전계산 시작 구간이 엉뚱해지는 것 방지. 다음 currentTime 틱에 정확히 재계산된다.
currentFrameIdxRef.current = 0;
currentFrameNumRef.current = storeFrames[0]?.frame ?? 0;
setDroneFramesLoaded(storeFrames.length > 0);
}, [storeFrames]);
// ENU 월드 원점 — 스토어 origin 직접 사용 (서버 getWorldOrigin 대체)
useEffect(() => {
worldOriginRef.current = storeOrigin
? { lat: storeOrigin.lat, lon: storeOrigin.lon, alt: storeOrigin.alt }
: undefined;
}, [storeOrigin]);
// 드론 프레임 이동 평균 (GPS/자세 노이즈 제거)
// ※ 에지(회전 경계) 보존 적응형 창: 중심 프레임 yaw 와 차이가 임계(YAW_EDGE) 이내인
// 인접 프레임까지만 좌우로 확장(최대 halfWin). 회전 구간이 직선 평균에 섞이지 않아:
// - 회전 중엔 창이 좁아 실제 자세를 즉시 추종(지연 없음),
// - 회전 종료 후엔 회전 프레임을 배제한 채 창이 '한 프레임씩' 부드럽게 재확장(튐 없음).
// 직선에선 yaw 가 거의 일정 → 풀창(±halfWin)으로 GPS/자세 노이즈 억제.
// (이전 '변화율 기반 축소'는 회전 종료 시 창이 한꺼번에 재확장돼 회전 프레임이 다시 섞여
// 한 번 튀는 부작용이 있었음 → 경계 보존 방식으로 교체.)
const smoothFrame = useCallback((frames: DroneFrameBasic[], i: number, halfWin: number): DroneFrameBasic => {
let lo = i, hi = i;
if (frames.length > 1 && halfWin > 0) {
const YAW_EDGE = 8; // deg — 중심 대비 이 이상 벌어지면 다른(회전) 구간으로 보고 평균 제외
const yc = frames[i].yaw;
const near = (idx: number) => Math.abs(((frames[idx].yaw - yc + 540) % 360) - 180) <= YAW_EDGE;
const loMin = Math.max(0, i - halfWin);
const hiMax = Math.min(frames.length - 1, i + halfWin);
while (lo > loMin && near(lo - 1)) lo--;
while (hi < hiMax && near(hi + 1)) hi++;
}
const n = hi - lo + 1;
let lat = 0, lon = 0, altitude = 0, pitch = 0, roll = 0, sinYaw = 0, cosYaw = 0;
for (let k = lo; k <= hi; k++) {
const f = frames[k];
lat += f.lat; lon += f.lon; altitude += f.altitude;
pitch += f.pitch; roll += f.roll;
const yr = f.yaw * Math.PI / 180;
sinYaw += Math.sin(yr); cosYaw += Math.cos(yr);
}
return {
...frames[i],
lat: lat / n, lon: lon / n, altitude: altitude / n,
pitch: pitch / n, roll: roll / n,
yaw: Math.atan2(sinYaw / n, cosYaw / n) * 180 / Math.PI,
};
}, []);
// 선/궤적 투영 — RAF에서 매 프레임 보간 포즈로 호출(부드러운 갱신). refs만 읽으므로 stable.
const buildLines = useCallback((drone: DroneFrameBasic) => {
const params = paramsRef.current;
const worldOrigin = worldOriginRef.current;
const allCL = allCenterlinePointsRef.current;
const frames = allDroneFramesRef.current;
const CLIP_Z = 0.1;
const oc = (px: number, py: number) =>
(px < -0.1 ? 1 : 0) | (px > 1.1 ? 2 : 0) | (py < -0.1 ? 4 : 0) | (py > 1.1 ? 8 : 0);
const segFrom = (c1: CameraCoords, c2: CameraCoords): [number, number, number, number] | null => {
const z1 = c1.Zc, z2 = c2.Zc;
if (z1 < CLIP_Z && z2 < CLIP_Z) return null;
let px1: number, py1: number, px2: number, py2: number;
if (z1 >= CLIP_Z && z2 >= CLIP_Z) {
const p1 = pixelFromCamera(c1, params), p2 = pixelFromCamera(c2, params);
px1 = p1.pxRaw; py1 = p1.pyRaw; px2 = p2.pxRaw; py2 = p2.pyRaw;
} else {
const t = (CLIP_Z - z1) / (z2 - z1);
const cClip: CameraCoords = { Xc: c1.Xc + t*(c2.Xc-c1.Xc), Yc: c1.Yc + t*(c2.Yc-c1.Yc), Zc: CLIP_Z };
if (z1 < CLIP_Z) {
const p1 = pixelFromCamera(cClip, params), p2 = pixelFromCamera(c2, params);
px1 = p1.pxRaw; py1 = p1.pyRaw; px2 = p2.pxRaw; py2 = p2.pyRaw;
} else {
const p1 = pixelFromCamera(c1, params), p2 = pixelFromCamera(cClip, params);
px1 = p1.pxRaw; py1 = p1.pyRaw; px2 = p2.pxRaw; py2 = p2.pyRaw;
}
}
if (oc(px1, py1) & oc(px2, py2)) return null;
return [px1, py1, px2, py2];
};
const centerlineSegs: [number, number, number, number][] = [];
// 튜브 레일(입체형) — 세그먼트를 ~15m 분할, 분할점 사이 [x1,y1,x2,y2,평균거리m].
// 폭은 그리기에서 지름/거리 원근 환산.
const railTubes: number[][] = [];
// 유동 펄스 — 체이니지(월드)에 고정된 점 [x,y,거리m]. 화면 대시는 근거리에서
// 지면 대비 역행처럼 보이므로, 월드 좌표로 실속도 이동시켜 항상 '앞으로' 흐르게.
const railPulses: number[][] = [];
if (showCenterlineRef.current && allCL.length) {
// 표고 미상(z≤0) 중심선(예: 제주 KML 선형 — 고도 전부 0)은 '경로 표고' 평면에 앵커 —
// 측점/POI 라벨과 동일 기준. z=0 그대로 투영하면 해수면 높이로 그려져 크게 어긋난다.
const CL_Z = dronePathZRef.current;
// 선형은 '표출된 마지막 측점'까지만 — 표시 반경(maxPoiRange) 안에 들어온 측점 중
// 가장 먼 것의 거리(+10m)로 클립. 경계를 가로지르는 세그먼트는 경계 지점에서 잘라 그린다
// (정점 간격이 긴 직선 구간에서 세그먼트 통째 그리기로 마지막 측점 너머 긴 꼬리가 남던 문제).
const MAX_R = maxPoiRangeRef.current;
const cosLat = Math.cos((drone.lat * Math.PI) / 180);
let clipR = 0;
for (const st of allGeoStationsRef.current) {
const d = Math.hypot((st.lat - drone.lat) * 111000, (st.lon - drone.lon) * 111000 * cosLat);
if (d <= MAX_R && d > clipR) clipR = d;
}
clipR = clipR > 0 ? Math.min(MAX_R, clipR + 10) : MAX_R;
// 드론 기준 동서/남북(m) 좌표 — 세그먼트·클립원 교점 계산용
const enOf = (p: { lat: number; lon: number }): [number, number] => [
(p.lon - drone.lon) * 111000 * cosLat,
(p.lat - drone.lat) * 111000,
];
// P0→P1 세그먼트가 반지름 clipR 원과 만나는 t(0~1) 목록(오름차순, 최대 2개).
const clipTs = (e0: [number, number], e1: [number, number]): number[] => {
const dx = e1[0] - e0[0], dy = e1[1] - e0[1];
const a = dx * dx + dy * dy;
if (a === 0) return [];
const b = 2 * (e0[0] * dx + e0[1] * dy);
const c = e0[0] * e0[0] + e0[1] * e0[1] - clipR * clipR;
const disc = b * b - 4 * a * c;
if (disc < 0) return [];
const sq = Math.sqrt(disc);
return [(-b - sq) / (2 * a), (-b + sq) / (2 * a)].filter((t) => t >= 0 && t <= 1);
};
// 드론높이 모드: 측점/POI 라벨과 동일한 '드론고도 − 이격거리' 평면에 투영 —
// 옵션 적용 시 라벨만 이동하고 선형이 지형 z 에 남아 서로 분리되는 것을 방지
// (측점 POI 와 빨간 선형이 항상 같은 선상에 놓이도록 높이 규칙 통일).
const planeZ = poiDroneHeightRef.current
? drone.altitude - (params.geoidOffset ?? 0) - droneHeightDropRef.current
: null;
const projCL = (lat: number, lon: number, z: number): CameraCoords =>
toCameraCoords(drone, lat, lon, planeZ ?? (z > 0 ? z : CL_Z), params, worldOrigin);
// 진행방향 벡터(동/북) — 유동 대시가 '가는 방향'으로 흐르도록 세그먼트 정렬 기준.
// 실제 이동(±3행 GPS 변위) 우선, 이동이 미미(호버)하면 카메라 yaw 폴백.
let travelE = Math.sin((drone.yaw * Math.PI) / 180);
let travelN = Math.cos((drone.yaw * Math.PI) / 180);
{
const fr = allDroneFramesRef.current;
const ci = currentFrameIdxRef.current;
if (fr.length > 6) {
const A = fr[Math.max(0, ci - 3)], B = fr[Math.min(fr.length - 1, ci + 3)];
const de = (B.lon - A.lon) * cosLat, dn = B.lat - A.lat;
if (Math.hypot(de, dn) * 111000 > 1) { travelE = de; travelN = dn; }
}
}
for (let i = 1; i < allCL.length; i++) {
const p0 = allCL[i - 1], p1 = allCL[i];
const e0 = enOf(p0), e1 = enOf(p1);
const in0 = Math.hypot(e0[0], e0[1]) <= clipR;
const in1 = Math.hypot(e1[0], e1[1]) <= clipR;
const lerp = (t: number) => ({
lat: p0.lat + (p1.lat - p0.lat) * t,
lon: p0.lon + (p1.lon - p0.lon) * t,
z: p0.z + (p1.z - p0.z) * t,
});
let a0 = p0, a1 = p1;
if (!in0 && !in1) {
// 양끝 다 밖 — 단, 긴 직선 세그먼트(KML 정점 간격 수백 m)가 클립 원을 '관통'하는
// 경우가 흔하므로(0k700 부근 선 실종 원인), 두 교점 사이 구간(현)은 그린다.
const ts = clipTs(e0, e1);
if (ts.length < 2) continue; // 원과 안 만남 → 생략
a0 = lerp(ts[0]); a1 = lerp(ts[1]);
} else if (in0 !== in1) {
// 경계 교차 → 교점까지만
const ts = clipTs(e0, e1);
if (ts.length) {
const cut = lerp(in0 ? ts[ts.length - 1] : ts[0]);
if (in0) a1 = cut; else a0 = cut;
}
}
// 세그먼트를 진행방향(뒤→앞)으로 정렬 — 대시 흐름이 항상 '가는 방향'이 되게.
if ((e1[0] - e0[0]) * travelE + (e1[1] - e0[1]) * travelN < 0) {
const tmp = a0; a0 = a1; a1 = tmp;
}
const cT0 = projCL(a0.lat, a0.lon, a0.z);
const cT1 = projCL(a1.lat, a1.lon, a1.z);
const s = segFrom(cT0, cT1);
if (s) {
centerlineSegs.push(s);
// 커튼: 세그먼트를 ~15m 단위로 분할, 각 경계에서 윗변(z)·아랫변(z−H)을 투영.
// 분할 경계마다 수직살을 세워 원근 수렴(멀수록 촘촘·짧아짐)으로 입체감을 만든다.
if (clFxRef.current) {
const e0c = enOf(a0), e1c = enOf(a1);
const segLen = Math.hypot(e1c[0] - e0c[0], e1c[1] - e0c[1]);
const N = Math.max(1, Math.min(40, Math.ceil(segLen / 15)));
const zt0 = planeZ ?? (a0.z > 0 ? a0.z : CL_Z);
const zt1 = planeZ ?? (a1.z > 0 ? a1.z : CL_Z);
let prev: { xt: number; yt: number; d: number } | null = null;
for (let k = 0; k <= N; k++) {
const t = k / N;
const wla = a0.lat + (a1.lat - a0.lat) * t;
const wlo = a0.lon + (a1.lon - a0.lon) * t;
const wzt = zt0 + (zt1 - zt0) * t;
const cT = toCameraCoords(drone, wla, wlo, wzt, params, worldOrigin);
if (cT.Zc < CLIP_Z) { prev = null; continue; }
const pT = pixelFromCamera(cT, params);
const cur = { xt: pT.pxRaw, yt: pT.pyRaw, d: cT.distH ?? 0 };
if (prev && !(oc(prev.xt, prev.yt) & oc(cur.xt, cur.yt))) {
railTubes.push([prev.xt, prev.yt, cur.xt, cur.yt, (prev.d + cur.d) / 2]);
}
prev = cur;
}
}
}
}
// 선형 점 — 측점(100m) 사이를 십등분한 '10m 단위' 체이니지 고정점(궤적 측정점 방식).
// 사용자가 10m 간격으로 검토 가능. 50m 지점은 약간 크게(중간 확인). 100m 지점은
// 측점 눈금이 이미 있어 생략. 측점 앵커 없으면 20m 등간격 폴백.
if (clFxRef.current) {
const cum = clCumRef.current;
if (cum.length === allCL.length && cum.length > 1) {
const anchors = clStaAnchorsRef.current;
const targets: { s: number; big: boolean }[] = [];
const div = Math.max(1, Math.round(clDotDivRef.current));
if (anchors.length >= 2 && div > 1) {
// 인접 측점 구간을 div 등분 — 등분점(측점 자체 제외)에 점, 중간(½)은 크게.
for (let ai = 0; ai < anchors.length - 1; ai++) {
const A = anchors[ai], B = anchors[ai + 1];
if (A.km === B.km) continue;
for (let j = 1; j < div; j++) {
const tt = j / div;
targets.push({ s: A.cum + tt * (B.cum - A.cum), big: div % 2 === 0 && j === div / 2 });
}
}
} else if (anchors.length >= 2) {
// div=1 — 점 없음
} else {
const total = cum[cum.length - 1];
for (let sb = 0; sb <= total; sb += 20) targets.push({ s: sb, big: false });
}
const total = cum[cum.length - 1];
for (const tg of targets) {
const sPos = tg.s;
if (sPos < 0 || sPos > total) continue;
let lo = 0, hi = cum.length - 1;
while (hi - lo > 1) { const m = (lo + hi) >> 1; if (cum[m] <= sPos) lo = m; else hi = m; }
const tt = cum[hi] > cum[lo] ? (sPos - cum[lo]) / (cum[hi] - cum[lo]) : 0;
const la = allCL[lo].lat + (allCL[hi].lat - allCL[lo].lat) * tt;
const ln = allCL[lo].lon + (allCL[hi].lon - allCL[lo].lon) * tt;
const zz = allCL[lo].z + (allCL[hi].z - allCL[lo].z) * tt;
const e = enOf({ lat: la, lon: ln });
if (Math.hypot(e[0], e[1]) > clipR) continue;
const cc = toCameraCoords(drone, la, ln, planeZ ?? (zz > 0 ? zz : CL_Z), params, worldOrigin);
if (cc.Zc < CLIP_Z) continue;
const p = pixelFromCamera(cc, params);
if (oc(p.pxRaw, p.pyRaw)) continue;
railPulses.push([p.pxRaw, p.pyRaw, cc.distH ?? 0, tg.big ? 1 : 0]);
}
}
}
}
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, railTubes, railPulses, dronePathPts };
}, []);
// 보간 포즈 — estFrame(연속 프레임번호)에서 앞뒤 smoothFrame 선형보간(yaw는 최단각). RAF에서 호출.
// 자세(yaw/pitch/roll)는 attLeadSec(자세 선행) 만큼 앞선 시점에서 별도 샘플 —
// 짐벌 로그의 기록 지연으로 팬(회전) 시 오버레이가 늦게 따라오는 이질감 보정.
const poseAt = useCallback((estFrame: number): DroneFrameBasic | null => {
const arr = allDroneFramesRef.current;
if (!arr.length) return null;
const h = poseHalf();
const sample = (ef: number): { a: DroneFrameBasic; b: DroneFrameBasic; frac: number } => {
let lo = 0, hi = arr.length - 1;
while (lo < hi) { const m = (lo + hi) >> 1; if (arr[m].frame < ef) lo = m + 1; else hi = m; }
const i2 = lo, i1 = Math.max(0, lo - 1);
const f1 = arr[i1].frame, f2 = arr[i2].frame;
const frac = f2 > f1 ? Math.min(1, Math.max(0, (ef - f1) / (f2 - f1))) : 0;
return { a: smoothFrame(arr, i1, h), b: smoothFrame(arr, i2, h), frac };
};
const p = sample(estFrame);
const leadSec = paramsRef.current.attLeadSec ?? 0;
const q = leadSec !== 0 ? sample(estFrame + leadSec * fpsRef.current) : p;
const L = (x: number, y: number) => x + (y - x) * p.frac;
const A = (x: number, y: number) => x + (y - x) * q.frac;
let dy = q.b.yaw - q.a.yaw; dy = ((dy + 540) % 360) - 180; // 최단각
return {
...p.a,
frame: estFrame,
lat: L(p.a.lat, p.b.lat), lon: L(p.a.lon, p.b.lon), altitude: L(p.a.altitude, p.b.altitude),
pitch: A(q.a.pitch, q.b.pitch), roll: A(q.a.roll, q.b.roll),
yaw: q.a.yaw + dy * q.frac,
};
}, [smoothFrame, poseHalf]);
// ── 자세 속도적응 필터(One Euro) — frameSynced 전용, RAF 에서 dronePose 에 적용 ──
// 평활 제거(POSE_SMOOTH_SYNCED_MAX=0) 후 남는 자세 미세 잡음(짐벌 피치 정수 양자화 등)이
// 직진 시 원거리 라벨 떨림으로 보임 → 변화 느릴 때만 강하게 평활(MIN_CUT), 팬처럼 빠르면
// 컷오프가 속도에 비례해 올라가 즉시 추종(지연 없음). 위치(djmd)는 잡음 없어 미적용.
// 필터 강도 — route.json routeInfo 로 폴더별 조정:
// att_min_cut(Hz, 기본 0.8): 저속 변화 평활 강도 — 작을수록 떨림 억제 강함(0=필터 끔 아님, 0.1~3 권장)
// att_beta(기본 0.5): 각속도(°/s)당 컷오프 증가 — 클수록 회전 추종 민감
const attMinCutRef = useRef(0.8);
const attBetaRef = useRef(0.5);
useEffect(() => {
const ri = routeMeta?.routeInfo as Record<string, unknown> | undefined;
const num = (v: unknown): number | null =>
typeof v === 'number' && isFinite(v) ? v
: typeof v === 'string' && v.trim() !== '' && isFinite(Number(v)) ? Number(v) : null;
attMinCutRef.current = num(ri?.att_min_cut) ?? 0.8;
attBetaRef.current = num(ri?.att_beta) ?? 0.5;
if (ri?.att_min_cut !== undefined || ri?.att_beta !== undefined) {
console.log(`[오버레이] 자세 필터: min_cut ${attMinCutRef.current}Hz, beta ${attBetaRef.current} (route.json)`);
}
}, [routeMeta]);
const attFilterRef = useRef<{ yaw: number; pitch: number; roll: number; dyaw: number; dpitch: number; droll: number; ts: number } | null>(null);
const filterAttitude = useCallback((p: DroneFrameBasic): DroneFrameBasic => {
if (!frameSyncedRef.current) { attFilterRef.current = null; return p; }
const now = performance.now() / 1000;
const st = attFilterRef.current;
const angDiff = (a: number, b: number): number => ((a - b + 540) % 360) - 180;
// 초기화/시크·세그먼트 전환(큰 점프)/장시간 정지 후에는 필터 리셋
if (!st || now - st.ts > 0.5 || Math.abs(angDiff(p.yaw, st.yaw)) > 30) {
attFilterRef.current = { yaw: p.yaw, pitch: p.pitch, roll: p.roll, dyaw: 0, dpitch: 0, droll: 0, ts: now };
return p;
}
const dt = Math.max(1e-3, Math.min(0.1, now - st.ts));
const alphaOf = (fc: number): number => { const r = 2 * Math.PI * fc * dt; return r / (r + 1); };
const MIN_CUT = attMinCutRef.current; // Hz — 저속 변화 평활 강도(작을수록 강한 평활)
const BETA = attBetaRef.current; // 속도(°/s)당 컷오프 증가 — 팬 시 즉시 추종
const D_CUT = 1.0; // 속도 추정 평활
const one = (val: number, prev: number, dprev: number, ang: boolean): [number, number] => {
const diff = ang ? angDiff(val, prev) : val - prev;
const d = dprev + alphaOf(D_CUT) * (diff / dt - dprev);
const a = alphaOf(MIN_CUT + BETA * Math.abs(d));
return [prev + a * diff, d];
};
const [yaw, dyaw] = one(p.yaw, st.yaw, st.dyaw, true);
const [pitch, dpitch] = one(p.pitch, st.pitch, st.dpitch, false);
const [roll, droll] = one(p.roll, st.roll, st.droll, false);
attFilterRef.current = { yaw, pitch, roll, dyaw, dpitch, droll, ts: now };
return { ...p, yaw, pitch, roll };
}, []);
// 텍스트 사전 계산 — requestIdleCallback으로 백그라운드 실행
const startLabelPrecompute = useCallback((currentParams: CameraParams, currentSmoothHalf: number, currentMaxRange: number, currentDroneHeight: boolean, currentDroneDrop: number) => {
const id = ++precomputeIdRef.current;
const frames = allDroneFramesRef.current;
// 재계산(같은 데이터, 슬라이더 변경 등)이면 옛 맵을 복사해 시작 → 빈 구간 없이 갱신.
// 단, frames 가 바뀐 경우(분할영상 세그먼트 전환)는 옛 캐시가 '다른 구간'의 값이므로
// 복사하지 않고 비워서 시작 — 신·구 캐시가 섞여 라벨 집합이 프레임마다 뒤바뀌는
// 깜빡임(두 번째 영상 재생 시)을 방지한다.
const framesChanged = lastPrecomputeFramesRef.current !== frames;
lastPrecomputeFramesRef.current = frames;
const newMap = new Map<number, LabelCache>(
!framesChanged && labelMapRef.current.size ? labelMapRef.current : undefined,
);
if (framesChanged) labelMapRef.current = newMap; // 스테일 캐시 즉시 차단
labelKeysRef.current = frames.map(f => f.frame); // RAF 최근접 키 조회용(오름차순)
const allSt = allGeoStationsRef.current;
// 모든 POI 표출(철도역 포함) + 영상용 구조물(구교/교량/터널). 철도역은 별도로 스테이션바에도 표출.
const allPoi = allPoisRef.current.concat(allStructuresRef.current);
const worldOrigin = worldOriginRef.current;
const CLIP_Z = 0.1;
const SMOOTH_HALF = currentSmoothHalf;
const MAX_RANGE = currentMaxRange; // POI 표시 범위 — 드론 수평 직선거리(m)
// 표고 미상(z=0) 라벨의 앵커 평면 — 드론 궤적과 동일한 '경로 표고' 값.
// 사용자가 경로표고 슬라이더로 궤적을 도로에 맞추면 측점/POI 라벨도 함께 맞는다.
const PATH_Z = dronePathZRef.current;
if (!frames.length) return;
// 프레임 불변 값은 루프 밖에서 1회만 계산 — 측점의 중심선 투영(projectCL)과 POI 지면고도(gz)는
// 드론 프레임과 무관하다. 프레임 루프 안에서 매번 계산하면(측점71×선분145×프레임 수천)
// 재생 중 idle 청크가 메인 스레드를 막아 영상 딜레이·라벨 떨림을 유발한다.
const snappedSt = allSt.map(st => {
const snap = projectCL(st.lat, st.lon);
const szRaw = snap?.z ?? st.z;
return {
title: st.title,
lat: snap?.lat ?? st.lat,
lon: snap?.lon ?? st.lon,
// 표고 미상(0 이하)이면 경로표고 평면에 앵커 — 공중부양/시차 밀림 방지.
z: szRaw > 0 ? szRaw : PATH_Z,
};
});
const gzOf = new Map<string, number>();
for (const poi of allPoi) {
// 지면고도 gz 우선순위: 보정값(드래그) → **POI 자체 z(실측)** → 중심선 z → 경로표고 앵커.
// 종전엔 중심선 스냅이 POI z 보다 우선 — 도로에서 떨어진 지물이 도로 높이에 붙어
// 원거리에서 계속 밀리다 근거리에서만 맞는 증상의 원인. 실측 z 보유 데이터셋 기준으로 교정.
const clz = nearestCL(poi.lat, poi.lon)?.z;
gzOf.set(poi.title, overridesRef.current[poi.title]?.z
?? (poi.z > 0 ? poi.z : undefined)
?? (clz !== undefined && clz > 0 ? clz : undefined)
?? PATH_Z);
}
const t0 = performance.now();
const total = frames.length;
// 현재 재생 프레임부터 순환 처리 → 화면에 보이는 구간을 먼저 계산(재생 즉시 라벨 표시).
const startIdx = Math.min(total - 1, Math.max(0, currentFrameIdxRef.current));
// 초기 로드/재계산 모두 첫 청크(현재 프레임 포함) 후 즉시 노출(아래). 재계산은 옛 맵 복사로 시작.
let processed = 0; // 처리한 프레임 수(0..total)
let published = false;
const CHUNK = 200; // 한 번에 처리할 프레임 수
const step = () => {
if (precomputeIdRef.current !== id) return; // 취소됨
const end = Math.min(processed + CHUNK, total);
while (processed < end) {
const idx = (startIdx + processed) % total; // startIdx→끝→0→… 순환
processed++;
const drone = smoothFrame(frames, idx,
frameSyncedRef.current ? Math.min(SMOOTH_HALF, POSE_SMOOTH_SYNCED_MAX) : SMOOTH_HALF);
const stationLabels: LabelCache['stationLabels'] = [];
for (const sp of snappedSt) {
const slat = sp.lat, slon = sp.lon, sz = sp.z;
// 가시성 판정도 드로우와 동일 높이 규칙 — 드론높이 모드면 드론고도 − 이격거리.
const stZ = currentDroneHeight
? drone.altitude - (currentParams.geoidOffset ?? 0) - currentDroneDrop
: sz;
const cc = toCameraCoords(drone, slat, slon, stZ, currentParams, worldOrigin);
if (cc.Zc < CLIP_Z) continue;
// 측점도 POI 와 동일하게 표시 범위 제한 — 원거리(수 km) 측점이 수평선에 뭉치는 것 방지.
if ((cc.distH ?? 0) > MAX_RANGE) continue;
const { pxRaw, pyRaw } = pixelFromCamera(cc, currentParams);
// 포함 경계 ±0.15 로 넉넉히 — 경계에 걸친 라벨이 호버(정지) 중 GPS 지터로
// 캐시 프레임마다 들락거리며 깜빡이는 것 방지. 실제 화면 밖 컬링은 드로우가 담당.
if (pxRaw < -0.15 || pxRaw > 1.15 || pyRaw < -0.15 || pyRaw > 1.15) continue;
// 화면좌표 대신 (스냅된)월드좌표 저장 → RAF 가 연속 포즈로 재투영.
stationLabels.push({ title: sp.title, lat: slat, lon: slon, z: sz });
}
// 후보 수집 (거리 dist + 월드좌표 + 지면고도 gz) → 겹침 억제용
const poiCand: { x: number; y: number; title: string; label: string; category: string; dist: number; lat: number; lon: number; gz: number; compact: { k: string; v: string }[]; color?: string }[] = [];
for (const poi of allPoi) {
// 지면고도 gz — 프레임 불변이므로 루프 밖에서 1회 계산(gzOf).
const gz = gzOf.get(poi.title) ?? PATH_Z;
// 가시성 판정용 poiZ: 드론높이 모드면 드론−이격거리, 아니면 gz.
const poiZ = currentDroneHeight
? drone.altitude - (currentParams.geoidOffset ?? 0) - currentDroneDrop
: gz;
const cc = toCameraCoords(drone, poi.lat, poi.lon, poiZ, currentParams, worldOrigin);
if (cc.Zc < CLIP_Z) continue;
// 표시 범위: 드론과의 수평 직선거리(distH)가 범위 안이면 표시, 밖이면 제외.
if ((cc.distH ?? 0) > MAX_RANGE) continue;
const { pxRaw, pyRaw } = pixelFromCamera(cc, currentParams);
// 포함 경계 ±0.15 — 경계 걸침 라벨의 호버 중 10Hz 깜빡임 방지(화면 밖 컬링은 드로우 담당).
if (pxRaw < -0.15 || pxRaw > 1.15 || pyRaw < -0.15 || pyRaw > 1.15) continue;
poiCand.push({ x: pxRaw, y: pyRaw, title: poi.title, label: guboonOf(poi.props) ?? cleanTitle(poi.title), category: poi.category, dist: cc.distH ?? 0, lat: poi.lat, lon: poi.lon, gz, compact: compactFieldsOf(poi.props), ...(poi.labelColor ? { color: poi.labelColor } : {}) });
}
// 겹침 억제: KAKAO_RAIL(철도역/역사) 라벨을 최우선 유지 → 겹치면 일반 POI 가 숨겨진다.
// 같은 우선순위 내에서는 가까운(dist 작은) 것 우선. 임계 이내면 뒤(낮은우선/먼) 것 숨김.
// 거리차 1m 미만은 제목순 고정 — 호버(정지) 중 GPS 지터로 승자가 프레임마다 뒤바뀌며
// 라벨 쌍이 교대로 깜빡이는 것 방지.
const railRank = (cat: string): number => (cat === '철도역' || cat === '역사' ? 0 : 1);
poiCand.sort((a, b) =>
railRank(a.category) - railRank(b.category) ||
(Math.abs(a.dist - b.dist) > 1 ? a.dist - b.dist : (a.title < b.title ? -1 : a.title > b.title ? 1 : 0)));
// 겹침 시 진행방향 변형 우선: 같은 base 형제(예: 회덕제1가도교 상/하)가 겹치면
// 하행='(하)' / 상행='(상)' 쪽을 남긴다(드론이 실제 지나는 선로). 무관한 POI엔 영향 없음.
// 진행방향 변형 태그(닫는 괄호까지) — 하행='(하)', 상행='(상)'. '(하인상)' 등은 제외.
const dirTag = routeDirRef.current === '상' ? '(상)' : routeDirRef.current === '하' ? '(하)' : '';
const coordKey = (c: { lat: number; lon: number }): string => `${c.lat.toFixed(6)},${c.lon.toFixed(6)}`;
const accepted: typeof poiCand = [];
if (!overlapExcludeRef.current) {
// 겹침제외 OFF → 겹침 억제 없이 모든 POI 표시(동일좌표는 세로 행으로 쌓임).
accepted.push(...poiCand);
} else {
// 겹침제외 ON → 겹친 것(동일좌표 형제 포함) 1개만. 형제면 진행방향((하)/(상))을 우선.
for (const c of poiCand) {
let overlapIdx = -1;
for (let i = 0; i < accepted.length; i++) {
const k = accepted[i];
if (Math.abs(k.x - c.x) < POI_MERGE_X && Math.abs(k.y - c.y) < POI_MERGE_Y) { overlapIdx = i; break; }
}
if (overlapIdx < 0) { accepted.push(c); continue; }
// 형제 + c가 방향 변형, 기존은 아니면 c로 교체.
const k = accepted[overlapIdx];
if (dirTag && baseStruct(c.title) === baseStruct(k.title) && c.title.includes(dirTag) && !k.title.includes(dirTag)) {
accepted[overlapIdx] = c;
}
}
}
// 동일좌표 그룹에 행 번호 부여(위→아래 순서대로). 4개면 각 라벨이 4행으로 안 겹치게 배치.
const groupCount = new Map<string, number>();
for (const a of accepted) groupCount.set(coordKey(a), (groupCount.get(coordKey(a)) ?? 0) + 1);
const groupSeen = new Map<string, number>();
// 통과분만 월드좌표로 저장(RAF 가 재투영). labelRow/Count = 동일좌표 라벨 세로 배치.
const poiMarkers: LabelCache['poiMarkers'] = accepted.map(c => {
const ck = coordKey(c);
const row = groupSeen.get(ck) ?? 0;
groupSeen.set(ck, row + 1);
return { title: c.title, label: c.label, category: c.category, lat: c.lat, lon: c.lon, gz: c.gz, compact: c.compact, labelRow: row, labelRowCount: groupCount.get(ck) ?? 1, ...(c.color ? { color: c.color } : {}) };
});
newMap.set(drone.frame, { stationLabels, poiMarkers });
}
// 초기 로드/재계산 모두: 첫 청크(현재 프레임 포함) 채우면 즉시 노출 → 토글 즉시 반영.
// 재계산은 옛 맵 복사로 시작했으므로 빈 구간 없이 나머지 청크가 덮어쓰며 갱신된다.
if (!published) { labelMapRef.current = newMap; published = true; }
if (processed < total) {
requestIdleCallback(step, { timeout: 200 });
} else {
console.log(
`[labelMap] complete ${(performance.now() - t0).toFixed(0)}ms | ${frames.length} frames × ${allSt.length + allPoi.length} items`
);
}
};
// 첫 청크(현재 프레임 포함)는 동기 실행 → 토글/재계산이 '정지 중에도' 즉시 반영.
// (idle 대기 시 정지 상태에서 갱신이 지연/누락되던 문제 해결.) 나머지는 step 내부에서 idle 로 이어감.
step();
}, [nearestCL, projectCL]);
// 모든 데이터 로드 완료 시 사전 계산 시작
// storeFrames 의존 필수: 분할 영상 세그먼트 전환(loadSegment)으로 frames 가 교체되면
// 라벨 캐시(프레임별 가시 POI)를 새 구간 기준으로 재계산해야 함 — 없으면 이전 세그먼트
// 캐시가 남아 두 번째 영상부터 시설물이 표출되지 않는다.
useEffect(() => {
if (!droneFramesLoaded || !geoDataLoaded || !clDataLoaded) return;
startLabelPrecompute(paramsRef.current, smoothHalf, maxPoiRange, poiDroneHeight, droneHeightDrop);
}, [droneFramesLoaded, geoDataLoaded, clDataLoaded, startLabelPrecompute, smoothHalf, maxPoiRange, poiDroneHeight, droneHeightDrop, poiOverlapExclude, dronePathZ, storeFrames]);
// params / smoothHalf / maxPoiDist / 경로표고(표고 미상 라벨 앵커) 변경 시 사전 계산 재시작 (500ms debounce)
// ※ storeFrames(세그먼트 전환)는 위의 즉시 effect 가 담당 — 여기 넣으면 전환마다 이중 재계산.
useEffect(() => {
if (!droneFramesLoaded || !geoDataLoaded || !clDataLoaded) return;
const timer = setTimeout(() => startLabelPrecompute(params, smoothHalf, maxPoiRange, poiDroneHeight, droneHeightDrop), 500);
return () => clearTimeout(timer);
}, [params, smoothHalf, maxPoiRange, poiDroneHeight, droneHeightDrop, storePois, storeStructures, droneFramesLoaded, geoDataLoaded, clDataLoaded, startLabelPrecompute, poiOverlapExclude, dronePathZ]);
// 현재 재생 시간 → 드론 프레임 ref 갱신
useEffect(() => {
// 나침반이 visible 무관 갱신되도록 visible 게이트 제거(프레임 탐색은 가벼움).
if (!droneFramesLoaded) return;
const frames = allDroneFramesRef.current;
if (!frames.length) return;
const vfps = fpsRef.current;
let best = frames[0], bestIdx = 0, bestD = Math.abs((best.frame ?? 0) / vfps - currentTime);
for (let i = 0; i < frames.length; i++) {
const d = Math.abs(frames[i].frame / vfps - currentTime);
if (d < bestD) { bestD = d; best = frames[i]; bestIdx = i; }
if (bestD < 1 / vfps / 2) break;
}
currentFrameNumRef.current = best.frame;
currentFrameIdxRef.current = bestIdx;
currentTimeSecRef.current = currentTime;
timeUpdateWallRef.current = performance.now();
if (currentDroneFrameRef.current?.frame !== best.frame) {
currentDroneFrameRef.current = best;
setPanelDroneFrame(best);
}
}, [currentTime, droneFramesLoaded]);
// 중심선 + 나침반 캐시 빌드 (per-frame, 텍스트 계산 없음)
useEffect(() => {
// 나침반(effectiveYaw/hFovRad)은 측점선 visible 과 무관하게 항상 갱신.
const cur = currentDroneFrameRef.current;
if (cur) {
const p = paramsRef.current;
const fr = allDroneFramesRef.current;
const d = fr.length
? smoothFrame(fr, currentFrameIdxRef.current, poseHalf())
: cur;
compassRef.current = {
effectiveYaw: d.yaw + p.yawOffset,
hFovRad: 2 * Math.atan((p.sensorW ?? 36) / (2 * p.focalLen)),
};
} else {
compassRef.current = null;
}
// 선/궤적은 RAF가 매 프레임 직접 투영하므로 여기선 캐시 '준비됨' 표식 + 메타데이터만.
if (!currentDroneFrameRef.current) { renderCacheRef.current = null; return; }
const frames = allDroneFramesRef.current;
const drone = frames.length
? smoothFrame(frames, currentFrameIdxRef.current, poseHalf())
: currentDroneFrameRef.current;
const params = paramsRef.current;
renderCacheRef.current = {
effectiveYaw: drone.yaw + params.yawOffset,
hFovRad: 2 * Math.atan((params.sensorW ?? 36) / (2 * params.focalLen)),
clCount: allCenterlinePointsRef.current.length,
poiCount: allPoisRef.current.length,
};
}, [panelDroneFrame, params]);
// ResizeObserver
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const parent = canvas.parentElement;
if (!parent) return;
const ro = new ResizeObserver(entries => {
for (const e of entries) {
canvas.width = Math.round(e.contentRect.width);
canvas.height = Math.round(e.contentRect.height);
canvasSizeRef.current = { w: canvas.width, h: canvas.height };
}
});
ro.observe(parent);
canvas.width = parent.clientWidth;
canvas.height = parent.clientHeight;
canvasSizeRef.current = { w: canvas.width, h: canvas.height };
return () => ro.disconnect();
}, []);
// RAF 렌더 루프 — 계산 없이 캐시/Map 조회만
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
let rafId = 0;
const draw = () => {
rafId = requestAnimationFrame(draw);
const ctx = canvas.getContext('2d');
if (!ctx) return;
const { w: W, h: H } = canvasSizeRef.current;
ctx.clearRect(0, 0, W, H);
// 영상 첫 프레임이 표시되기 전엔 오버레이(라벨/선/나침반)를 그리지 않음 → 라벨이 영상보다 먼저 뜨는 것 방지.
if (!videoReadyRef.current) return;
// object-fit:cover 정렬 — 영상은 컨테이너를 비율유지·크롭하며 채우므로(index.css), 오버레이도
// 같은 cover 사각형에 매핑해야 영상과 일치한다. 영상 해상도(vW,vH) 없으면 컨테이너 기준 폴백.
const { w: vW, h: vH } = videoSizeRef.current;
let dispW = W, dispH = H, offX = 0, offY = 0;
if (vW > 0 && vH > 0) {
const s = Math.max(W / vW, H / vH); // cover: 더 큰 배율로 채움(넘침=크롭)
dispW = vW * s; dispH = vH * s;
offX = (W - dispW) / 2; offY = (H - dispH) / 2; // 음수 = 화면 밖(크롭)
}
coverRef.current = { offX, offY, dispW, dispH, W, H };
const vx = (nx: number) => offX + nx * dispW; // 정규(0~1, 영상프레임) → 화면 px
const vy = (ny: number) => offY + ny * dispH;
// 이번 프레임 라벨 히트박스(아이콘+글자, 화면 px) 수집 → 끝에서 labelHitRef 갱신.
const hitBoxes: { kind: 'poi' | 'station'; title: string; x0: number; y0: number; x1: number; y1: number }[] = [];
// 이번 프레임에 '실제로 화면 안에 그려진' 라벨 id(`kind:title`) 집합. 팝업 표시의 단일 기준 —
// 십자마커/텍스트가 뷰포트 안에 있을 때만 채운다. 팝업은 이 집합에 있을 때만 보이게 해
// "라벨은 화면 밖(안 보임)인데 팝업만 clamp 되어 뜨는" 문제를 원천 차단한다.
const onScreenLabels = new Set<string>();
// 유효 하단 = 화면 높이 − 스테이션바 높이. 팝업/라벨 가시 기준을 '모니터'가 아니라
// '영상 재생 영역(스테이션바 위쪽)' 으로 잡는다. (라벨이 바 밑으로 내려가면 사라지게)
const HB = H - barHeightRef.current;
// 히트박스가 '영상 영역(0..W, 0..HB)' 과 겹치는지(라벨이 눈에 보이는지). -2px 안쪽 요구로 경계 애매함 제거.
const labelOnScreen = (x0: number, y0: number, x1: number, y1: number): boolean =>
x1 >= 2 && x0 <= W - 2 && y1 >= 2 && y0 <= HB - 2;
const cache = renderCacheRef.current;
if (cache) {
// 부드러운 시간(timeRef, 60fps 보간) → 연속 프레임번호. 선/궤적·라벨 공통 사용.
// 시간동기(timeOffsetSec): 영상↔비행로그의 시각 오프셋 보정 — POI 가 진행방향으로
// 일괄 밀릴 때 조정(+ = 로그를 뒤로, = 앞으로). camera.json 에 저장되는 보정값.
const estTime = (timeRef
? timeRef.current
: currentTimeSecRef.current + (performance.now() - timeUpdateWallRef.current) / 1000)
+ (paramsRef.current.timeOffsetSec ?? 0);
const estFrame = estTime * fpsRef.current;
// 연속 보간 포즈(라인·라벨 공통) — 매 RAF 프레임 직접 투영해 부드럽게.
const dronePose = filterAttitude(poseAt(estFrame) ?? (allDroneFramesRef.current.length
? smoothFrame(allDroneFramesRef.current, currentFrameIdxRef.current, poseHalf())
: currentDroneFrameRef.current!));
const lines = buildLines(dronePose);
// 나침반 미니맵(heading-up) 회전 — 영상과 즉시 동기. dronePose.yaw 는 ±smoothHalf(기본 60fr ≈2s)
// 평활이라 회전 시 지연 → 가벼운 평활(±3fr)의 yaw 를 따로 보간해 사용. 360° 누적 언랩(점프 방지).
{
const arr = allDroneFramesRef.current;
let rawYaw = dronePose.yaw;
if (arr.length) {
let lo = 0, hi = arr.length - 1;
while (lo < hi) { const m = (lo + hi) >> 1; if (arr[m].frame < estFrame) lo = m + 1; else hi = m; }
const i2 = lo, i1 = Math.max(0, lo - 1);
const f1 = arr[i1].frame, f2 = arr[i2].frame;
const frac = f2 > f1 ? Math.min(1, Math.max(0, (estFrame - f1) / (f2 - f1))) : 0;
const a = smoothFrame(arr, i1, 3), b = smoothFrame(arr, i2, 3); // ±3fr 가벼운 평활
let dy = b.yaw - a.yaw; dy = ((dy + 540) % 360) - 180;
rawYaw = a.yaw + dy * frac;
}
const headingDeg = rawYaw + paramsRef.current.yawOffset; // 실제 방위(0=N, 시계+)
// 지도 나침반(노스업)용 현재 위치/방위 — MapCompass 가 읽음.
mapPoseRef.current.lat = dronePose.lat;
mapPoseRef.current.lon = dronePose.lon;
mapPoseRef.current.yaw = headingDeg;
// 아날로그 나침반(heading-up) 회전 — 렌더 중일 때만. 360° 누적 언랩(점프 방지).
if (minimapRef.current) {
const target = -headingDeg;
const delta = (((target - minimapRotRef.current) % 360) + 540) % 360 - 180;
minimapRotRef.current += delta;
minimapRef.current.style.setProperty('--rot', `${minimapRotRef.current}deg`);
}
}
// 선로 중심선 (선형) — 독립 토글. '홀로그램 펜스' 입체 표현:
// ① 그라데이션 커튼(위 진함→아래 소멸) ② 수직살(원근 수렴 + 원거리 페이드)
// ③ 상단 레일 글로우 ④ 진행방향 유동 대시(흐르는 빛)
if (showCenterlineRef.current && lines.centerlineSegs.length > 0 && !clFxRef.current) {
// 입체 효과 꺼짐 — 종전처럼 단순 선만
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();
} else if (showCenterlineRef.current && lines.centerlineSegs.length > 0) {
ctx.setLineDash([]);
// 상단 레일 — 튜브(지름 있는 원형 단면): 각크기(지름/거리)를 픽셀 폭으로 환산해
// 가까울수록 굵고 멀수록 가늘게 + 3중 스트로크(외곽 어둡게/본체/하이라이트)로 원통 음영.
const railPath = (): void => {
ctx.beginPath();
for (const [px1, py1, px2, py2] of lines.centerlineSegs) {
ctx.moveTo(vx(px1), vy(py1));
ctx.lineTo(vx(px2), vy(py2));
}
};
// 반투명 원통 셸 — 중심의 진한 실선을 감싸는 유리관 느낌(다층 저알파 스트로크).
// 폭은 지름/거리 원근 환산, 안쪽으로 갈수록 은은히 밝아져 원통 볼륨감.
if (lines.railTubes.length > 0) {
const pp = paramsRef.current;
const hfov = 2 * Math.atan((pp.sensorW ?? 36) / (2 * pp.focalLen));
const pxPerRad = (coverRef.current?.dispW || canvasSizeRef.current.w || 1920) / hfov;
const D = clTubeDRef.current;
// 이음매 중첩 제거: 반투명 조각을 직접 겹치면 이음/폭 단차마다 알파가 중첩돼
// 잘려 보인다 → 오프스크린에 '불투명'으로 그린 뒤 전체를 한 번에 알파 합성.
const cv = ctx.canvas;
let oc2 = tubeCanvasRef.current;
if (!oc2 || oc2.width !== cv.width || oc2.height !== cv.height) {
oc2 = document.createElement('canvas');
oc2.width = cv.width;
oc2.height = cv.height;
tubeCanvasRef.current = oc2;
}
const otx = oc2.getContext('2d');
if (otx) {
otx.clearRect(0, 0, oc2.width, oc2.height);
otx.lineCap = 'round';
const passes: Array<[number, string]> = [
[1.0, 'rgb(150,30,30)'],
[0.66, 'rgb(225,60,58)'],
[0.34, 'rgb(255,140,125)'],
];
// 폭 버킷(0.5px)별로 묶어 스트로크 횟수 절감(불투명이라 겹침 무해)
for (const [fw, col] of passes) {
otx.strokeStyle = col;
const buckets = new Map<number, number[][]>();
for (const tb of lines.railTubes) {
const w = Math.min(44, Math.max(2, (D / Math.max(5, tb[4])) * pxPerRad)) * fw;
const key = Math.round(w * 2);
let arr = buckets.get(key);
if (!arr) { arr = []; buckets.set(key, arr); }
arr.push(tb);
}
for (const [key, arr] of buckets) {
otx.lineWidth = key / 2;
otx.beginPath();
for (const tb of arr) {
otx.moveTo(vx(tb[0]), vy(tb[1]));
otx.lineTo(vx(tb[2]), vy(tb[3]));
}
otx.stroke();
}
}
ctx.save();
ctx.globalAlpha = 0.32;
ctx.drawImage(oc2, 0, 0);
ctx.restore();
}
} else {
ctx.strokeStyle = 'rgba(255,50,50,0.20)';
ctx.lineWidth = 8;
railPath();
ctx.stroke();
}
// 중심선(코어) — 항상 진한 실선. 반투명 원통이 이 선을 감싼다.
ctx.strokeStyle = 'rgba(205,25,25,0.95)';
ctx.lineWidth = 2.5;
railPath();
ctx.stroke();
// 선형 점 — 측점 체이니지 10m 단위 고정점(궤적 측정점과 동일 질감).
// 50m 지점은 약간 크게 — 측점 사이 중간 위치를 한눈에 확인.
if (lines.railPulses.length > 0) {
ctx.fillStyle = 'rgba(255,230,90,0.9)';
for (const q of lines.railPulses) {
ctx.beginPath();
ctx.arc(vx(q[0]), vy(q[1]), q[3] ? 2.8 : 1.8, 0, Math.PI * 2);
ctx.fill();
}
}
}
// 드론 경로 — 하나의 연속 폴리라인(둥근 연결)으로 부드럽게 + 작은 점. 독립 토글 + 투명도
const dpa = dronePathAlphaRef.current;
const pts = lines.dronePathPts;
if (pts.length > 1) {
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.setLineDash([]);
// 어두운 밑선(대비) → 배경 위에서도 끊김 없이 잘 보임
ctx.strokeStyle = `rgba(0,0,0,${0.35 * dpa})`;
ctx.lineWidth = 5;
ctx.beginPath();
ctx.moveTo(vx(pts[0][0]), vy(pts[0][1]));
for (let k = 1; k < pts.length; k++) ctx.lineTo(vx(pts[k][0]), vy(pts[k][1]));
ctx.stroke();
// 본선
ctx.strokeStyle = `rgba(80,200,255,${dpa})`;
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(vx(pts[0][0]), vy(pts[0][1]));
for (let k = 1; k < pts.length; k++) ctx.lineTo(vx(pts[k][0]), vy(pts[k][1]));
ctx.stroke();
}
// 측정점 dot — 작고 듬성하게(선 흐름 방해 X)
if (pts.length > 0) {
ctx.fillStyle = `rgba(255,230,90,${dpa})`;
for (let k = 0; k < pts.length; k += 4) {
ctx.beginPath();
ctx.arc(vx(pts[k][0]), vy(pts[k][1]), 1.8, 0, Math.PI*2);
ctx.fill();
}
}
visStructRef.current.clear(); // 매 프레임 가시 컴팩트-구조물 재수집(라벨 off 시 빈 상태)
if (visibleRef.current) {
// 가시집합만 precompute 에서 가져오고(어떤 라벨을 그릴지), 좌표는 RAF 가 연속 포즈로 직접
// 투영 → 라인처럼 부드럽게 이동(정수프레임 보간/프레임키 의존 제거).
// 조회는 '최근접 캐시 키' 이진탐색으로 고정 — 종전의 floor 정확일치 + 폴백(4Hz 갱신 스테일 키)
// 조합은 프레임마다 서로 다른 캐시 항목을 교대로 그려 라벨 집합이 뒤바뀌는 깜빡임을 유발했음.
let labelsA: LabelCache | undefined;
{
const keys = labelKeysRef.current;
if (keys.length) {
let lo = 0, hi = keys.length - 1;
while (hi - lo > 1) { const m = (lo + hi) >> 1; if (keys[m] <= estFrame) lo = m; else hi = m; }
const ki = Math.abs(keys[hi] - estFrame) < Math.abs(estFrame - keys[lo]) ? hi : lo;
// 채워지는 중(청크 진행)이면 인접 키로 소폭 폴백
for (let d = 0; d <= 3 && !labelsA; d++) {
labelsA = labelMapRef.current.get(keys[ki + d] ?? NaN) ?? labelMapRef.current.get(keys[ki - d] ?? NaN);
}
}
if (!labelsA) labelsA = labelMapRef.current.get(Math.floor(estFrame)) ?? labelMapRef.current.get(currentFrameNumRef.current);
}
if (labelsA) {
const α = emaAlphaRef.current;
const params = paramsRef.current;
const worldOrigin = worldOriginRef.current;
const CLIP_Z = 0.1;
const seenSt = new Set<string>();
const seenPoi = new Set<string>();
// 측점 라벨 — 연속 포즈로 직접 투영 → 이상치 거부 → EMA.
// 선형(중심선) 토글과 함께 ON/OFF (빨간 스테이션 라벨 = 선형의 일부).
if (showCenterlineRef.current) {
ctx.font = 'bold 18px monospace';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
labelsA.stationLabels.forEach((stA) => {
// 측점도 POI 와 동일한 높이 규칙 — 드론높이 모드면 '드론고도 − 이격거리' 평면
// (이격거리 슬라이더가 측점 라벨에도 반영), 지면고도 모드면 측점 표고(stA.z — 경로표고 앵커 포함).
const stZ = poiDroneHeightRef.current
? dronePose.altitude - (params.geoidOffset ?? 0) - droneHeightDropRef.current
: stA.z;
const cc = toCameraCoords(dronePose, stA.lat, stA.lon, stZ, params, worldOrigin);
if (cc.Zc < CLIP_Z) return;
const { pxRaw, pyRaw } = pixelFromCamera(cc, params);
const prevDst = displayedStRef.current.get(stA.title);
// 측점 라벨은 선형 실선과 픽셀 단위로 붙어 다녀야 하므로 화면 EMA 없이 즉시 투영
// (실선도 매 프레임 즉시 투영 — djmd 프레임 동기 이후 입력이 매끄러워 평활 불필요.
// 반올림 떨림은 아래 0.75px 히스테리시스가 계속 흡수).
const d: DispPos = { x: pxRaw, y: pyRaw, rej: 0, vx: 0, vy: 0 };
seenSt.add(stA.title);
// 픽셀 히스테리시스: 0.75px 이상 움직일 때만 정수 위치 갱신 → 저속(1배속) 반올림 깜빡임 제거.
const sx0 = vx(d.x), sy0 = vy(d.y);
const x = (prevDst?.rx !== undefined && Math.abs(sx0 - prevDst.rx) <= 0.75) ? prevDst.rx : Math.round(sx0);
const y = (prevDst?.ry !== undefined && Math.abs(sy0 - prevDst.ry) <= 0.75) ? prevDst.ry : Math.round(sy0);
d.rx = x; d.ry = y;
displayedStRef.current.set(stA.title, d);
// 마커 선
ctx.strokeStyle = 'rgba(255,100,100,0.95)'; ctx.lineWidth = 2.5;
ctx.beginPath(); ctx.moveTo(x, y-10); ctx.lineTo(x, y+10); ctx.stroke();
// 텍스트 테두리
const lx = Math.max(2, x + 8);
ctx.strokeStyle = 'rgba(0,0,0,0.85)'; ctx.lineWidth = 4;
ctx.lineJoin = 'round';
ctx.strokeText(cleanTitle(stA.title), lx, y);
// 텍스트 본문
ctx.fillStyle = 'rgba(255,200,200,1.0)';
ctx.fillText(cleanTitle(stA.title), lx, y);
// 히트박스(마커 선 ~ 글자 끝)
const tw = ctx.measureText(cleanTitle(stA.title)).width;
hitBoxes.push({ kind: 'station', title: stA.title, x0: x - 8, y0: y - 12, x1: lx + tw + 2, y1: y + 12 });
if (labelOnScreen(x - 8, y - 12, lx + tw + 2, y + 12)) onScreenLabels.add(`station:${stA.title}`);
});
}
// POI 마커 — 연속 포즈로 직접 투영 → 이상치 거부 → EMA
ctx.font = 'bold 20px sans-serif';
labelsA.poiMarkers.forEach((poiA) => {
// 드론높이 모드면 연속 포즈 고도−이격거리, 아니면 저장된 지면고도 gz.
const pz = poiDroneHeightRef.current
? dronePose.altitude - (params.geoidOffset ?? 0) - droneHeightDropRef.current
: poiA.gz;
const cc = toCameraCoords(dronePose, poiA.lat, poiA.lon, pz, params, worldOrigin);
if (cc.Zc < CLIP_Z) return;
const { pxRaw, pyRaw } = pixelFromCamera(cc, params);
const prevDpoi = displayedPoiRef.current.get(poiA.title);
// 프레임 동기(djmd) 데이터는 입력이 매끄러워 화면 EMA 불필요 — 즉시 투영으로
// 팬(회전) 시 라벨이 늦게 따라오는 이질감 제거(선형·측점과 동일 타이밍).
// 비동기 데이터셋(CSV 지터)만 기존 속도적응 EMA 유지.
const d = frameSyncedRef.current
? { x: pxRaw, y: pyRaw, rej: 0, vx: 0, vy: 0 }
: smoothStep(prevDpoi, pxRaw, pyRaw, α, smoothMinAlphaRef.current, smoothSpeedRefRef.current);
seenPoi.add(poiA.title);
// 픽셀 히스테리시스: 0.75px 이상 움직일 때만 정수 위치 갱신 → 저속(1배속) 깜빡임 제거.
const psx0 = vx(d.x), psy0 = vy(d.y);
const px = (prevDpoi?.rx !== undefined && Math.abs(psx0 - prevDpoi.rx) <= 0.75) ? prevDpoi.rx : Math.round(psx0);
const py = (prevDpoi?.ry !== undefined && Math.abs(psy0 - prevDpoi.ry) <= 0.75) ? prevDpoi.ry : Math.round(psy0);
d.rx = px; d.ry = py;
displayedPoiRef.current.set(poiA.title, d);
// 텍스트 박스만 표출(십자 마커/이모지 아이콘 없음):
// 배경 = KML 텍스트박스_색상(poiA.color, 없으면 기본 하늘색), 글자 = 흰색.
// 박스는 POI 지점(px)에 가로 중앙 정렬.
const boxCol = poiA.color ?? '#64c8ff';
// 라벨: 속성에 '구분'이 있으면 그 값(원문 그대로), 없으면 cleanTitle(title). poiCand에서 확정.
const label = poiA.label;
// 동일좌표 라벨 세로 배치: 행 중앙을 지점에 맞춰 위→아래로 안 겹치게 오프셋.
const ROW_H = 28;
const labelY = py + (poiA.labelRow - (poiA.labelRowCount - 1) / 2) * ROW_H;
const tw = ctx.measureText(label).width;
// 박스 높이 = 글자 크기(20px) × 1.2 — 라벨 상하 여유. 행 간격(ROW_H)도 같이 벌림.
const PAD_X = 7, BOX_H = 24;
const bx0 = Math.max(2, px - tw / 2 - PAD_X);
const bx1 = bx0 + tw + PAD_X * 2;
const by0 = labelY - BOX_H / 2, by1 = labelY + BOX_H / 2;
ctx.fillStyle = boxCol;
ctx.beginPath();
if (typeof ctx.roundRect === 'function') ctx.roundRect(bx0, by0, bx1 - bx0, BOX_H, 5);
else ctx.rect(bx0, by0, bx1 - bx0, BOX_H);
ctx.fill();
ctx.fillStyle = '#ffffff';
ctx.textBaseline = 'middle';
ctx.fillText(label, bx0 + PAD_X, labelY);
// 히트박스 = 텍스트 박스 영역 (어디를 눌러도 선택)
hitBoxes.push({ kind: 'poi', title: poiA.title, x0: bx0, y0: by0 - 2, x1: bx1 + 2, y1: by1 + 2 });
const poiVisible = labelOnScreen(bx0, by0, bx1, by1);
if (poiVisible) onScreenLabels.add(`poi:${poiA.title}`);
// 연속 온스크린 프레임 수 갱신(화면 밖이면 0으로 리셋).
const streak = poiVisible ? (poiOnScreenStreakRef.current.get(poiA.title) ?? 0) + 1 : 0;
poiOnScreenStreakRef.current.set(poiA.title, streak);
ctx.textBaseline = 'alphabetic';
// 컴팩트 팝업 자동생성 후보 등록(sync 인터벌이 읽음). 조건:
// ① 라벨이 '연속' ADD_STREAK 프레임 이상 화면 안(경계 깜빡임에 팝업 재생성 방지),
// ② 컴팩트 필드 있음, ③ 단일 라벨(동일좌표 다중행 제외 → 클릭 시 개별 표시).
const ADD_STREAK = 6; // ~100ms 안정적으로 보여야 팝업 생성
if (streak >= ADD_STREAK && poiA.compact.length && poiA.labelRowCount <= 1) visStructRef.current.set(poiA.title, { category: poiA.category, compact: poiA.compact });
});
// 이번 프레임에 안 보인 라벨의 표시상태 제거 → 재등장 시 새 위치에서 시작
// (오래된 좌표가 남아 이상치 거부로 늦게 뜨는 것 방지)
for (const k of displayedStRef.current.keys()) if (!seenSt.has(k)) displayedStRef.current.delete(k);
for (const k of displayedPoiRef.current.keys()) if (!seenPoi.has(k)) displayedPoiRef.current.delete(k);
// poiMarkers 에서 빠진 POI 는 streak 도 정리(재등장 시 0부터 다시 쌓임).
for (const k of poiOnScreenStreakRef.current.keys()) if (!seenPoi.has(k)) poiOnScreenStreakRef.current.delete(k);
}
} // end if(visible) — 측점/POI 라벨
} // end if(cache) — 중심선/드론경로/측점/POI
// 편집/FOV 보정 모드: 드래그 중 마커 피드백 (마우스 따라다님)
if ((editModeRef.current || fovModeRef.current || yawModeRef.current) && dragRef.current && dragPosRef.current) {
const dx = vx(dragPosRef.current.x), dy = vy(dragPosRef.current.y);
ctx.strokeStyle = '#ffe14d'; ctx.lineWidth = 2;
ctx.beginPath(); ctx.arc(dx, dy, 14, 0, Math.PI * 2); ctx.stroke();
ctx.beginPath();
ctx.moveTo(dx - 22, dy); ctx.lineTo(dx + 22, dy);
ctx.moveTo(dx, dy - 22); ctx.lineTo(dx, dy + 22);
ctx.stroke();
ctx.fillStyle = '#ffe14d'; ctx.font = 'bold 13px sans-serif';
ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
ctx.strokeStyle = 'rgba(0,0,0,0.85)'; ctx.lineWidth = 4; ctx.lineJoin = 'round';
ctx.strokeText(dragRef.current.title, dx + 26, dy);
ctx.fillText(dragRef.current.title, dx + 26, dy);
ctx.textAlign = 'left'; ctx.textBaseline = 'alphabetic';
}
// 라벨 속성 팝업 위치 — 해당 라벨의 현재 표시좌표를 따라 매 프레임 갱신(재생 중 이동).
// 라벨이 화면에서 사라지면(필터/화면밖) 팝업 숨김, 다시 보이면 표시.
if (popupElsRef.current.size) {
const toRemove: string[] = [];
popupElsRef.current.forEach((el, id) => {
const sep = id.indexOf(':');
const kind = id.slice(0, sep), title = id.slice(sep + 1);
const disp = (kind === 'poi' ? displayedPoiRef.current : displayedStRef.current).get(title);
// 팝업 표시 = '이 프레임에 라벨이 실제로 화면 안에 그려졌는가'(onScreenLabels)로만 판정.
// 좌표 ±16 슬랙 + clamp 조합은 라벨(특히 텍스트)이 화면 밖인데 팝업만 clamp 되어 뜨는
// 문제를 만들었음 → 라벨 가시성과 완전히 일치시킨다.
const sx = disp ? offX + disp.x * dispW : 0, sy = disp ? offY + disp.y * dispH : 0;
const off = !disp || !onScreenLabels.has(id);
if (off) {
// 라벨이 화면에서 벗어나면 '즉시' 제거(유예 없음) → 하단 이탈 즉시 사라짐.
// 유예를 두면 그 사이 흔들림으로 라벨이 경계로 잠깐 되돌아올 때 팝업이 다시 보였다 사라짐.
// 재등장은 sync 의 ADD_STREAK(연속 안정 프레임) 게이트가 막는다(짧은 복귀로는 재생성 안 됨).
el.style.visibility = 'hidden';
toRemove.push(id);
return;
}
const ph = el.offsetHeight || 120;
const pw = el.offsetWidth || 240;
// 라벨(십자+글자)은 sy 를 중심으로 상하 ~LABEL_HALF 만큼 차지한다. GAP 을 sy(중심) 기준으로
// 잡으면 라벨 높이만큼 먹혀 팝업이 글자에 붙는다 → 라벨 '바깥 가장자리' 기준으로 띄운다.
const LABEL_HALF = 15; // 라벨 반높이(px) — sy 중심 ± 이 값이 글자/아이콘 영역
const GAP = 12; // 라벨 가장자리 ~ 팝업 사이 실제 여백
// POI 라벨은 지점(sx) 기준 가로 중앙 정렬 박스 → 팝업도 박스 중앙 아래로 정렬.
// 측점 라벨은 마커 오른쪽으로 그려지므로 기존(좌측 여유 10px) 유지.
const txRaw = kind === 'poi' ? sx - pw / 2 : sx - 10;
const tx = Math.min(Math.max(8, txRaw), Math.max(8, W - pw - 4));
// 기본은 라벨 '아래'. 아래 공간이 부족하면(화면 하단 근접) 라벨 '위'로 플립.
// ※ 히스테리시스(sticky): 경계에서 sy 가 미세하게 오르내려도 위↔아래로 왕복하지 않도록,
// '위'로 간 뒤엔 아래에 HYST 이상 여유가 생겨야만 '아래'로 복귀. (왕복=팝업 순간이동=번쩍임 방지)
// 팝업은 '항상 라벨 아래'(flip 없음, 하단 클램프 없음). 라벨을 따라 자유롭게 내려가다
// 스테이션바 영역으로 진입하면 팝업 컨테이너의 overflow-hidden(HB 아래 클립)이 시각적으로
// 가린다 → 바 뒤로 슬라이드되는 것처럼 보임. 라벨이 바 밑(HB)으로 내려가면 위 off 판정으로
// 팝업 제거(라벨과 함께 사라짐). (이전 flip=라벨 위로 튐 / clamp=바에 걸침 문제 모두 제거)
let ty = Math.max(2, sy + LABEL_HALF + GAP);
el.style.visibility = 'visible';
const pp = popupPosRef.current.get(id);
const PA = 0.18;
// flip(위↔아래) 같은 큰 점프는 스냅 → 라벨 위를 천천히 통과하는 잔상 방지.
const bigJump = !!pp && Math.abs(ty - pp.y) > ph * 0.7;
const nx = pp ? pp.x + (tx - pp.x) * PA : tx;
const ny = (pp && !bigJump) ? pp.y + (ty - pp.y) * PA : ty;
popupPosRef.current.set(id, { x: nx, y: ny });
el.style.left = `${Math.round(nx)}px`;
el.style.top = `${Math.round(ny)}px`;
});
if (toRemove.length) {
toRemove.forEach(id => { popupMissRef.current.delete(id); popupPosRef.current.delete(id); popupFlipRef.current.delete(id); });
setInfoPopups(prev => prev.filter(p => !toRemove.includes(p.id)));
}
}
labelHitRef.current = hitBoxes; // 이번 프레임 라벨 히트박스 확정(라벨 없으면 빈 배열)
// 범례(선로중심선/지장물 개수)는 좌상단 카메라파라미터·스테이션맵과 겹쳐 제거함.
};
rafId = requestAnimationFrame(draw);
return () => cancelAnimationFrame(rafId);
}, []);
// ── POI 편집(드래그) ───────────────────────────────────────────────────────
const fileInputRef = useRef<HTMLInputElement>(null);
// 현재 프레임의 평활 드론 자세 (역투영 기준)
const getSmoothedDrone = useCallback((): DroneFrameBasic | null => {
const frames = allDroneFramesRef.current;
if (frames.length) return smoothFrame(frames, currentFrameIdxRef.current, poseHalf());
return currentDroneFrameRef.current;
}, [smoothFrame, poseHalf]);
// 화면 클릭좌표(client px) → 영상프레임 정규좌표(0~1). object-fit:cover 역변환(coverRef).
const clientToVideoNorm = useCallback((clientX: number, clientY: number): { x: number; y: number } => {
const canvas = canvasRef.current;
if (!canvas) return { x: 0, y: 0 };
const rect = canvas.getBoundingClientRect();
const c = coverRef.current;
const cxpx = ((clientX - rect.left) / rect.width) * (c.W || rect.width);
const cypx = ((clientY - rect.top) / rect.height) * (c.H || rect.height);
return {
x: c.dispW ? (cxpx - c.offX) / c.dispW : (clientX - rect.left) / rect.width,
y: c.dispH ? (cypx - c.offY) / c.dispH : (clientY - rect.top) / rect.height,
};
}, []);
// 라벨 히트테스트 — 아이콘+글자 바운딩박스(화면 px) 안인지. 정규좌표(영상프레임) 입력을 px로 변환.
const hitTestLabel = useCallback((nx: number, ny: number): { title: string; kind: 'poi' | 'station' } | null => {
const c = coverRef.current;
const cxpx = c.offX + nx * c.dispW, cypx = c.offY + ny * c.dispH;
const boxes = labelHitRef.current;
for (let i = boxes.length - 1; i >= 0; i--) { // 나중에 그린(위) 라벨 우선
const b = boxes[i];
if (cxpx >= b.x0 && cxpx <= b.x1 && cypx >= b.y0 && cypx <= b.y1) return { title: b.title, kind: b.kind };
}
return null;
}, []);
const onPoiPointerDown = useCallback((e: React.PointerEvent<HTMLCanvasElement>) => {
const { x: nx, y: ny } = clientToVideoNorm(e.clientX, e.clientY);
// 편집/세로화각 보정: POI 선택 + 드래그
if (editModeRef.current || fovModeRef.current || yawModeRef.current) {
const canvas = canvasRef.current; if (!canvas) return;
const hit = hitTestLabel(nx, ny);
if (!hit) return;
const poi = allPoisRef.current.concat(allStructuresRef.current).find(p => p.title === hit.title);
if (!poi) return;
const baseZ = (poi.z > 0 ? poi.z : nearestCL(poi.lat, poi.lon)?.z) ?? poi.z; // 실측 z 우선(가시 gz 와 동일 규칙)
const poiZ = overridesRef.current[hit.title] ? poi.z : baseZ;
setSelPoi(hit.title); setSelZ(poiZ); setSelZBase(baseZ);
dragRef.current = { title: hit.title, lat: poi.lat, lon: poi.lon, z0: poiZ, sx: nx, sy: ny };
dragPosRef.current = { x: nx, y: ny };
setDragTitle(hit.title);
try { canvas.setPointerCapture(e.pointerId); } catch { /* noop */ }
return;
}
// 기본: 캔버스가 모든 클릭을 받음 →
// - 라벨 클릭: 팝업 있으면 전체↔컴팩트 토글, 없으면 전체 팝업 추가.
// - 빈 곳 클릭: 펼쳐진/수동 팝업 정리(컴팩트로), 없으면 재생/정지 토글.
const hit = hitTestLabel(nx, ny);
if (!hit) {
const cur = infoPopupsRef.current;
if (cur.some(p => p.expanded || !p.auto)) {
setInfoPopups(prev => prev.filter(p => p.auto).map(p => ({ ...p, expanded: false })));
} else {
onTogglePlayRef.current?.();
}
return;
}
const id = `${hit.kind}:${hit.title}`;
// 이미 팝업이 있으면(자동 컴팩트 포함) 클릭으로 전체↔컴팩트 토글.
if (infoPopupsRef.current.some(p => p.id === id)) {
setInfoPopups(prev => prev.map(p => p.id === id ? { ...p, expanded: !p.expanded } : p));
return;
}
const obj = hit.kind === 'poi'
? allPoisRef.current.concat(allStructuresRef.current).find(p => p.title === hit.title)
: allGeoStationsRef.current.find(s => s.title === hit.title);
if (!obj) return;
const groundZ = overridesRef.current[hit.title]?.z ?? (obj.z > 0 ? obj.z : nearestCL(obj.lat, obj.lon)?.z) ?? obj.z; // 실측 z 우선
const drone = getSmoothedDrone();
const dist = drone
? toCameraCoords(drone, obj.lat, obj.lon, groundZ, paramsRef.current, worldOriginRef.current).distH ?? null
: null;
const disp = (hit.kind === 'poi' ? displayedPoiRef.current : displayedStRef.current).get(hit.title)!;
const c = coverRef.current;
const compact = compactFieldsOf(obj.props);
setInfoPopups(prev => [...prev, {
id, kind: hit.kind, title: hit.title, category: obj.category, lat: obj.lat, lon: obj.lon, z: groundZ, dist,
sx: c.offX + disp.x * c.dispW, sy: c.offY + disp.y * c.dispH, props: obj.props,
compact, auto: false, expanded: compact.length === 0, // 컴팩트필드 없으면 바로 전체
}]);
}, [nearestCL, clientToVideoNorm, getSmoothedDrone, hitTestLabel]);
const onPoiPointerMove = useCallback((e: React.PointerEvent<HTMLCanvasElement>) => {
if (!dragRef.current) return;
dragPosRef.current = clientToVideoNorm(e.clientX, e.clientY);
}, [clientToVideoNorm]);
// 캔버스 입력/커서 — Video.js controls:false 라 영상은 클릭을 처리 안 함 → 캔버스가 모든 클릭을
// 받아 직접 분기(라벨=팝업 / 빈 곳=팝업닫기 or 재생토글). 커서는 라벨 위에서만 손모양.
useEffect(() => {
const canvas = canvasRef.current; if (!canvas) return;
const parent = canvas.parentElement; if (!parent) return;
// 영상이 준비되기 전(폴더 선택 화면 등)엔 입력을 받지 않음 → 하위 UI(폴더 선택/드롭) 클릭 통과.
if (!videoReady) { canvas.style.pointerEvents = 'none'; return; }
canvas.style.pointerEvents = 'auto';
canvas.style.cursor = (editMode || fovMode || yawMode) ? 'move' : 'default';
if (editMode || fovMode || yawMode) { setInfoPopups([]); return; }
const onMove = (e: MouseEvent) => {
const { x: nx, y: ny } = clientToVideoNorm(e.clientX, e.clientY);
canvas.style.cursor = hitTestLabel(nx, ny) ? 'pointer' : 'default';
};
parent.addEventListener('mousemove', onMove);
return () => { parent.removeEventListener('mousemove', onMove); };
}, [videoReady, editMode, fovMode, yawMode, clientToVideoNorm, hitTestLabel]);
const onPoiPointerUp = useCallback(() => {
const drag = dragRef.current, pos = dragPosRef.current;
dragRef.current = null; dragPosRef.current = null; setDragTitle(null);
if (!drag || !pos) return;
// 거의 안 움직였으면 '클릭=선택'으로 보고 변경 안 함.
if (Math.hypot(pos.x - drag.sx, pos.y - drag.sy) < 0.012) return;
const drone = getSmoothedDrone(); if (!drone) return;
const p = paramsRef.current, wo = worldOriginRef.current;
// ── Yaw(방위 오프셋) 보정 ──
// 드롭한 화면점의 '가로'(px)만 맞춘다. 라벨의 절대 방위는 고정이므로,
// 현재 카메라축 기준 수평각 θ0 = atan2(Xc, Zc) 과 드롭 지점이 요구하는
// 수평각 θ1 = atan((px* 0.5 cx0)·sW / f) 의 차이만큼 카메라 방위를 돌리면 됨:
// yawOffset_new = yawOffset_old + (θ0 θ1)
if (yawModeRef.current) {
const poiZ = poiDroneHeightRef.current
? drone.altitude - (p.geoidOffset ?? 0) - droneHeightDropRef.current
: drag.z0;
const cc = toCameraCoords(drone, drag.lat, drag.lon, poiZ, p, wo);
if (cc.Zc < 0.1) return;
const th0 = Math.atan2(cc.Xc, cc.Zc);
const th1 = Math.atan(((pos.x - 0.5 - (p.cx0 ?? 0)) * (p.sensorW ?? 36)) / p.focalLen);
const dYaw = (th0 - th1) * 180 / Math.PI;
if (!isFinite(dYaw) || Math.abs(dYaw) > 45) { alert('보정량이 비정상적으로 큽니다 — 올바른 지물 위치로 끌었는지 확인하세요.'); return; }
const next = Math.max(-180, Math.min(180, (p.yawOffset ?? 0) + dYaw));
setParam('yawOffset', Math.round(next * 10) / 10);
console.log(`[yaw] 드래그 역산: Δ${dYaw.toFixed(2)}° → Yaw± ${next.toFixed(1)}°`);
return;
}
// ── 세로 화각(sensorH) 보정 ──
// 드롭한 화면점의 '세로'(py)만 맞춘다. 가로(f/sW)는 이미 정확하므로 건드리지 않음
// (f 를 바꾸면 가로까지 틀어져 스테이션선이 좌우로 벗어남).
// py = 0.5 + cy0 + (Yc/Zc)·(f/sH) → sH = (Yc/Zc)·f / (py* 0.5 cy0)
// sH 만 바꾸므로 세로 배율(상하 벌어짐)만 교정됨. 중심에서 충분히 떨어진 POI 로 보정.
if (fovModeRef.current) {
// 렌더와 동일한 poiZ (드론높이 모드면 드론−이격거리, 아니면 선택 z).
const poiZ = poiDroneHeightRef.current
? drone.altitude - (p.geoidOffset ?? 0) - droneHeightDropRef.current
: drag.z0;
const cc = toCameraCoords(drone, drag.lat, drag.lon, poiZ, p, wo);
if (cc.Zc < 0.1) return;
const b = cc.Yc / cc.Zc;
const v = pos.y - 0.5 - (p.cy0 ?? 0);
// 화면 세로 중심 근처면(v≈0) 역산이 불안정 → 무시(중심에서 떨어진 POI 사용 안내).
if (Math.abs(v) < 0.03 || Math.abs(b) < 1e-6) { alert('세로 중심에서 더 떨어진 POI로 보정하세요(위/아래).'); return; }
const sHNew = Math.max(6, Math.min(36, (b * p.focalLen) / v));
setParam('sensorH', sHNew);
return;
}
// ── 위치(lat/lon) 보정 ── 드롭한 화면점을 지면(현재 z)과 교차해 좌표 갱신.
const ll = groundPointFromPixel(drone, pos.x, pos.y, drag.z0, p, wo);
if (ll) setPoiOverride(drag.title, { lat: ll.lat, lon: ll.lon, z: drag.z0 });
}, [getSmoothedDrone, setPoiOverride, setParam]);
// 선택된 POI의 표고를 슬라이더로 직접 설정 (lat/lon 고정).
const setSelectedZ = useCallback((z: number) => {
setSelZ(z);
if (!selPoi) return;
const poi = allPoisRef.current.concat(allStructuresRef.current).find(p => p.title === selPoi);
if (poi) setPoiOverride(selPoi, { lat: poi.lat, lon: poi.lon, z });
}, [selPoi, setPoiOverride]);
const exportOverrides = useCallback(() => {
const data = { baseName: storeBaseName, overrides: storePoiOverrides };
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${storeBaseName ?? 'poi'}_poi_overrides.json`;
a.click();
URL.revokeObjectURL(url);
}, [storeBaseName, storePoiOverrides]);
const importOverridesFile = useCallback((file: File) => {
file.text().then(t => {
try {
const obj = JSON.parse(t);
const map = (obj && typeof obj === 'object' && obj.overrides) ? obj.overrides : obj;
setPoiOverrides(map as PoiOverrideMap);
} catch { alert('보정 파일 파싱 실패'); }
});
}, [setPoiOverrides]);
// DEM 표고 자동적용 — 현재 POI/구조물 좌표로 지면고도(open-meteo, EGM2008 정표고≈측점)를
// 일괄 조회해 override z로 설정. 선로표고 가정 대신 실제 지형고도 → 멀리서 밀리는 슬라이드 완화.
// (90m DEM이라 절벽/구조물 인접은 부정확할 수 있음 → 그런 건 드래그로 미세보정.)
const applyDemElevations = useCallback(async () => {
const all = allPoisRef.current.concat(allStructuresRef.current);
if (!all.length) { alert('POI가 없습니다'); return; }
setDemBusy(true);
try {
const next: PoiOverrideMap = { ...useGeoStore.getState().poiOverrides };
const CH = 100;
for (let i = 0; i < all.length; i += CH) {
const chunk = all.slice(i, i + CH);
const lat = chunk.map(p => p.lat).join(',');
const lon = chunk.map(p => p.lon).join(',');
// 서버 프록시(같은 출처) — COEP/CSP로 외부 직접 호출이 막혀 서버가 중계.
const r = await fetch(`/api/elevation?lat=${lat}&lon=${lon}`);
const j = await r.json();
const elev = j?.elevation;
if (Array.isArray(elev)) {
chunk.forEach((p, k) => {
if (typeof elev[k] !== 'number') return;
// DEM(SRTM30m)이 그 좌표 지점의 지면고도 → 그대로 사용(구글 지면고도와 일치).
// (이전엔 선로표고로 상한을 걸었으나, 실제 지형 상승까지 깎아 부정확 → 제거.
// 드물게 옥상고도로 뜨는 건물은 드래그/표고슬라이더로 개별 보정.)
next[p.title] = { lat: p.lat, lon: p.lon, z: elev[k] };
});
}
}
setPoiOverrides(next);
} catch (e) {
alert('DEM 표고 조회 실패 (인터넷/CORS 확인): ' + e);
} finally {
setDemBusy(false);
}
}, [setPoiOverrides]);
// 화면표시 옵션 일괄 복귀 — 환경설정 파일(*.display.json)이 폴더에 있으면 **그 파일 값**으로,
// 없으면 DISPLAY_DEFAULTS 로 복원 (파일에 없는 항목도 기본값). POI 보정/편집모드는 별도 버튼 유지.
const resetDisplayDefaults = useCallback(() => {
const d = (useGeoStore.getState().displayJson ?? {}) as Record<string, unknown>;
const num = (k: string, def: number): number =>
typeof d[k] === 'number' && isFinite(d[k] as number) ? (d[k] as number) : def;
const bool = (k: string, def: boolean): boolean =>
typeof d[k] === 'boolean' ? (d[k] as boolean) : def;
setShowCenterline(bool('showCenterline', DISPLAY_DEFAULTS.showCenterline));
setShowDronePath(bool('showDronePath', DISPLAY_DEFAULTS.showDronePath));
setDronePathZ(num('dronePathZ', DISPLAY_DEFAULTS.dronePathZ));
setClFx(bool('clFx', DISPLAY_DEFAULTS.clFx));
setClTubeD(num('clTubeD', DISPLAY_DEFAULTS.clTubeD));
setClDotDiv(num('clDotDiv', DISPLAY_DEFAULTS.clDotDiv));
setDronePathAlpha(num('dronePathAlpha', DISPLAY_DEFAULTS.dronePathAlpha));
setSmoothHalf(num('smoothHalf', DISPLAY_DEFAULTS.smoothHalf));
setEmaAlpha(num('emaAlpha', DISPLAY_DEFAULTS.emaAlpha));
setSmoothMinAlpha(num('smoothMinAlpha', DISPLAY_DEFAULTS.smoothMinAlpha));
setSmoothSpeedRef(num('smoothSpeedRef', DISPLAY_DEFAULTS.smoothSpeedRef));
setMaxPoiRange(num('maxPoiRange', DISPLAY_DEFAULTS.maxPoiRange));
setPoiDroneHeight(bool('poiDroneHeight', DISPLAY_DEFAULTS.poiDroneHeight));
setDroneHeightDrop(num('droneHeightDrop', DISPLAY_DEFAULTS.droneHeightDrop));
}, []);
// 카메라 파라미터 초기화 — 환경설정 파일(*.camera.json)이 폴더에 있으면 **그 파일 값**으로,
// 없으면 DEFAULT_CAMERA_PARAMS 로 복원 (파일에 없는 항목도 기본값).
const resetCameraParams = useCallback(() => {
const cam = useGeoStore.getState().cameraJson;
const next = { ...DEFAULT_CAMERA_PARAMS };
if (cam) {
for (const [k, v] of Object.entries(cam)) {
if (k in next && typeof v === 'number' && isFinite(v)) {
(next as unknown as Record<string, number>)[k] = v;
}
}
}
setParams(next);
}, []);
return (
<>
<canvas
ref={canvasRef}
className="absolute inset-0 z-20"
style={{ width: '100%', height: '100%' }}
onPointerDown={onPoiPointerDown}
onPointerMove={onPoiPointerMove}
onPointerUp={onPoiPointerUp}
onPointerCancel={onPoiPointerUp}
/>
{/* 나침반 미니맵 — 우측 상단(기존 캔버스 나침반 대체). 핀은 RAF 가 드론 방위로 회전. */}
{geoDataLoaded && (compassType === 'map'
? <MapCompass poseRef={mapPoseRef} />
: <Minimap ref={minimapRef} />)}
{/* 라벨 속성 팝업(다중) — 위치는 RAF 가 라벨을 따라 갱신. DOM 이라 텍스트 선명.
컴팩트(기본 5필드) ↔ 전체 토글은 팝업 클릭. 자동(auto) 팝업은 ✕ 없음(가시 구조물 따라 표시).
▶ 클립 컨테이너: 높이 = 영상영역(H barHeight). overflow-hidden 으로 스테이션바 영역을 가려
팝업이 라벨 따라 내려가면 바 뒤로 슬라이드되어 사라짐(flip/걸침 없이). */}
<div className="absolute left-0 right-0 top-0 overflow-hidden pointer-events-none z-40" style={{ bottom: barHeight }}>
{infoPopups.map(pp => {
const showFull = pp.expanded || !pp.compact || pp.compact.length === 0;
// 전체 보기: 컴팩트 항목을 같은 순서로 맨 위에 + 나머지 속성 → 클릭 전/후 순서 일치.
const fields = showFull
? (pp.props && pp.props.length > 0 ? orderedFullFields(pp.compact ?? [], pp.props) : null)
: pp.compact!;
return (
<div
key={pp.id}
ref={el => {
if (el) {
// RAF 가 위치를 확정(popupPosRef 등록)하기 전인 '신규' 팝업은 숨겨서 mount →
// 화면 밖 라벨의 팝업이 직전(stale) 위치에 한 프레임 번쩍이는 것 방지.
// RAF 가 온스크린 확인 후 visibility:visible + 위치 지정(off 면 계속 숨김→제거).
// visibility 는 RAF 가 명령형으로만 관리(style prop 미포함) → 재렌더에도 유지.
if (!popupPosRef.current.has(pp.id)) el.style.visibility = 'hidden';
popupElsRef.current.set(pp.id, el);
} else popupElsRef.current.delete(pp.id);
}}
onClick={() => setInfoPopups(prev => prev.map(p => p.id === pp.id ? { ...p, expanded: !p.expanded } : p))}
title={showFull ? '클릭 → 접기' : '클릭 → 전체 항목'}
className="absolute z-40 max-w-[260px] bg-black/90 border border-emerald-500/70 rounded-md shadow-xl text-white text-[11px] px-2 py-1.5 pointer-events-auto cursor-pointer"
style={{ left: Math.min(Math.max(8, pp.sx - 10), (canvasSizeRef.current.w || 9999) - 240), top: Math.min(Math.max(8, pp.sy + 27), (canvasSizeRef.current.h || 9999) - 140) }}
>
{!pp.auto && (
<button onClick={e => { e.stopPropagation(); setInfoPopups(prev => prev.filter(p => p.id !== pp.id)); }} className="absolute top-0.5 right-1 text-gray-400 hover:text-white leading-none text-[12px]"></button>
)}
<div className="grid grid-cols-[auto_1fr] gap-x-2 gap-y-0.5 font-mono text-[10px] text-gray-300 max-h-[45vh] overflow-y-auto">
{fields ? (
fields.map((p, i) => (
<React.Fragment key={i}>
<span className="text-gray-500 whitespace-nowrap">{p.k}</span>
<span className="break-all">{p.v}</span>
</React.Fragment>
))
) : (
<>
<span className="text-gray-500">위도</span><span>{pp.lat.toFixed(6)}</span>
<span className="text-gray-500">경도</span><span>{pp.lon.toFixed(6)}</span>
</>
)}
{/* 표고(z) — KML 좌표 3번째 성분(타원체고). 속성표(SimpleData)에 없어서 별도 표기.
(지장물의 '높이' 속성은 구조물 높이라 표고와 구분. 컴팩트 팝업은 간결 유지) */}
{showFull && pp.z > 0 && (
<>
<span className="text-gray-500 whitespace-nowrap">표고</span>
<span className="break-all">{pp.z.toFixed(1)} m (타원체고)</span>
</>
)}
</div>
</div>
);
})}
</div>
{showPanel && (
<div className="absolute right-2 z-30 flex flex-col-reverse items-end gap-2" style={{ bottom: (barHeight || 130) + 8 }}>
{/* 버튼 순서 = 패널 위치와 일치: 카메라(왼쪽 패널) → 화면표시(오른쪽 패널) */}
<div className="flex gap-2">
<button onClick={() => setShowControls(v => !v)}
className={`text-xs whitespace-nowrap px-2 py-1 rounded border ${showControls ? 'bg-amber-400 border-amber-300 text-black' : 'bg-black/70 hover:bg-black/90 text-gray-300 border-gray-600'}`}>
{showControls ? '▲ 카메라 파라미터' : '▼ 카메라 파라미터'}
</button>
<button onClick={() => setShowDisplay(v => !v)}
className={`text-xs whitespace-nowrap px-2 py-1 rounded border ${showDisplay ? 'bg-amber-400 border-amber-300 text-black' : 'bg-black/70 hover:bg-black/90 text-gray-300 border-gray-600'}`}>
{showDisplay ? '▲ 화면표시 옵션' : '▼ 화면표시 옵션'}
</button>
</div>
{/* 두 패널을 가로로 나란히(우→좌) 펼침 — 둘 다 열어도 세로로 안 쌓여 화면 위로 안 넘침. */}
<div className="flex flex-row-reverse items-end gap-2">
{/* ── 화면표시 옵션 (카메라 외 모든 표시 설정) ── */}
{showDisplay && (
<div className="bg-black/90 border border-sky-700 rounded p-3 text-white w-72 select-none max-h-[80vh] overflow-y-auto shadow-xl">
<div className="flex items-center justify-between border-b border-gray-700 pb-2 mb-2">
<span className="text-[10px] text-gray-500 uppercase tracking-wider">표시 토글</span>
{/* 화면표시 옵션 전체를 display.json 으로 PC 저장 — 폴더에 넣어두면 자동 적용 */}
<button
type="button"
onClick={saveDisplayToPc}
className={`text-[10px] px-1.5 py-0.5 rounded border ${dispSaveState === 'ok' ? 'border-emerald-400 bg-emerald-700/60 text-emerald-100' : 'border-gray-600 bg-gray-700 hover:bg-gray-600 text-gray-200'}`}
title="현재 화면표시 옵션 전체(토글/슬라이더/시설등급 등)를 display.json 으로 PC에 다운로드 — 영상 폴더에 넣어두면 폴더 선택 시 자동 적용"
>{dispSaveState === 'ok' ? '저장됨 ✓' : 'PC에 저장'}</button>
</div>
{/* 나침반 타입 — 눈금(analog) / 지도(OSM, 노스업) 선택 */}
<div className="flex items-center gap-1.5 text-[11px] mb-3">
<span className="text-gray-400 w-12 shrink-0 text-right">나침반</span>
<div className="flex-1 flex gap-1">
<button
onClick={() => setCompassType('analog')}
className={`flex-1 px-2 py-1 rounded border text-[11px] ${compassType === 'analog' ? 'bg-amber-400 border-amber-300 text-black' : 'bg-black/60 border-gray-700 text-gray-300 hover:bg-black/80'}`}
>눈금</button>
<button
onClick={() => setCompassType('map')}
className={`flex-1 px-2 py-1 rounded border text-[11px] ${compassType === 'map' ? 'bg-amber-400 border-amber-300 text-black' : 'bg-black/60 border-gray-700 text-gray-300 hover:bg-black/80'}`}
title="지도 위 현재 위치(노스업). 인터넷 필요."
>지도</button>
</div>
</div>
{/* 지도 나침반 배경 — 일반(OSM) / 위성(Esri). 지도 타입일 때만 노출. */}
{compassType === 'map' && (
<div className="flex items-center gap-1.5 text-[11px] mb-3">
<span className="text-gray-400 w-12 shrink-0 text-right">지도</span>
<div className="flex-1 flex gap-1">
<button
onClick={() => setCompassMapStyle('street')}
className={`flex-1 px-2 py-1 rounded border text-[11px] ${compassMapStyle === 'street' ? 'bg-amber-400 border-amber-300 text-black' : 'bg-black/60 border-gray-700 text-gray-300 hover:bg-black/80'}`}
>일반</button>
<button
onClick={() => setCompassMapStyle('sat')}
className={`flex-1 px-2 py-1 rounded border text-[11px] ${compassMapStyle === 'sat' ? 'bg-amber-400 border-amber-300 text-black' : 'bg-black/60 border-gray-700 text-gray-300 hover:bg-black/80'}`}
title="Esri World Imagery 위성 영상"
>위성</button>
</div>
</div>
)}
<div className="space-y-2 mb-3">
{/* 선형/드론궤적/좌측패널 토글은 하단 재생바(VideoPlayer)로 이동. 측점 진단 버튼은 삭제. */}
{/* 경로표고·투명도는 드론궤적 ON/OFF 무관하게 항상 표시. */}
<ParamRow label="경로 표고" tip={`GPS 측정점(노란 점)을 표고 ${dronePathZ}m 에 투영·연결`} value={dronePathZ} min={-10} max={pathZMax} step={1} unit="m" decimals={0} onChange={v => setDronePathZ(Math.round(v))} />
<button onClick={() => setClFx(v => !v)}
className={`w-full text-[11px] px-2 py-1 rounded border transition-colors ${clFx ? 'bg-red-500/70 border-red-300 text-white font-bold' : 'bg-black/60 border-gray-600 text-gray-200 hover:border-gray-400'}`}>
{clFx ? '■ 선형 입체형 (원통 + 유동 대시)' : '▶ 선형 기본형 (단순 선)'}
</button>
{clFx && (
<>
<ParamRow label="선형 지름" tip="입체형 원통의 실제 지름(m) — 가까울수록 굵고 멀수록 가늘게 원근 표현. display.json(clTubeD) 저장" value={clTubeD} min={0.2} max={5} step={0.1} unit="m" decimals={1} onChange={v => setClTubeD(v)} />
<ParamRow label="측점 분할" tip="측점(100m) 사이를 N등분한 위치에 노란 점 표시 — 10이면 10m 단위. 1 = 점 없음. 중간(½) 지점은 크게. display.json(clDotDiv) 저장" value={clDotDiv} min={1} max={20} step={1} unit="등분" decimals={0} onChange={v => setClDotDiv(Math.round(v))} />
</>
)}
<ParamRow label="투명도" tip={`드론 궤적 선/점 투명도 α=${dronePathAlpha.toFixed(2)}`} value={dronePathAlpha} min={0.1} max={1.0} step={0.05} unit="" decimals={2} onChange={v => setDronePathAlpha(v)} />
</div>
<div className="text-[10px] text-gray-500 uppercase tracking-wider mb-1.5 border-t border-gray-700 pt-2">스무딩 <span className="text-gray-600">(재계산 500ms )</span></div>
<div className="mb-3 space-y-2">
<ParamRow label="smooth" tip={${smoothHalf}fr = ±${(smoothHalf * 1000 / (30000/1001)).toFixed(0)}ms · 드론 자세 이동평균 반폭(최대) — 회전 중엔 자동 축소로 즉시 추종`} value={smoothHalf} min={0} max={60} step={1} unit="fr" decimals={0} onChange={v => setSmoothHalf(Math.round(v))} />
<ParamRow label="EMA α" tip={`α=${emaAlpha.toFixed(2)} → lag≈${(1000/60*(1/emaAlpha - 1)).toFixed(0)}ms · 화면좌표 평활 상한(빠를 때 추종 한계, ↑반응/↓부드러움)`} value={emaAlpha} min={0.01} max={1.0} step={0.01} unit="" decimals={2} onChange={v => setEmaAlpha(v)} />
<ParamRow label="떨림억제" tip="정지/느릴 때 최소 추종(α min). 작을수록 1배속 떨림이 더 강하게 평활됨(지연 약간↑). 기본 0.12" value={smoothMinAlpha} min={0.02} max={0.6} step={0.01} unit="" decimals={2} onChange={v => setSmoothMinAlpha(v)} />
<ParamRow label="반응속도" tip="이 화면속도(정규/프레임) 이상이면 즉시 추종. 키우면 더 강하게 평활(느린 이동까지 부드럽게), 줄이면 더 빨리 반응. 기본 0.010" value={smoothSpeedRef} min={0.002} max={0.04} step={0.001} unit="" decimals={3} onChange={v => setSmoothSpeedRef(v)} />
</div>
<div className="text-[10px] text-gray-500 uppercase tracking-wider mb-1.5 border-t border-gray-700 pt-2">POI 필터 / 높이</div>
<div className="mb-3 space-y-2">
<ParamRow label="POI 범위" tip="드론 위치와의 수평 직선거리 — 이 범위 안의 POI만 표시(밖은 제외)" value={maxPoiRange} min={10} max={1000} step={10} unit="m" decimals={0} onChange={v => setMaxPoiRange(Math.round(v))} />
<button onClick={() => setPoiDroneHeight(v => !v)}
className={`w-full text-[11px] px-2 py-1 rounded border transition-colors ${poiDroneHeight ? 'bg-amber-500/80 border-amber-300 text-black font-bold' : 'bg-black/60 border-gray-600 text-gray-200 hover:border-gray-400'}`}>
{poiDroneHeight ? `■ 드론 높이와 POI 이격거리 ${droneHeightDrop}m` : '▶ 드론 높이와 POI 이격거리'}
</button>
{poiDroneHeight && (
<ParamRow label="이격거리" tip={`POI를 드론보다 ${droneHeightDrop}m 아래에 배치 (값↑ = POI 아래로)`} value={droneHeightDrop} min={0} max={300} step={1} unit="m" decimals={0} onChange={v => setDroneHeightDrop(Math.round(v))} />
)}
</div>
<div className="text-[10px] text-gray-500 uppercase tracking-wider mb-1.5 border-t border-gray-700 pt-2">POI 위치 편집 (마우스)
<InfoTip text="드래그=수평위치 이동(가까운 프레임에서). 클릭 후 표고 슬라이더=높이. DEM(SRTM 30m)으로 각 POI 지점의 지면고도 적용(구글 지면고도와 일치). 옥상으로 뜨는 건물은 드래그/슬라이더로 보정. 자동저장." />
</div>
<div className="space-y-2">
<button onClick={() => { setEditMode(v => { const nv = !v; if (nv) { setFovMode(false); setYawMode(false); } return nv; }); }}
className={`w-full text-[11px] px-2 py-1 rounded border transition-colors ${editMode ? 'bg-yellow-500/80 border-yellow-300 text-black font-bold' : 'bg-black/60 border-gray-600 text-gray-200 hover:border-gray-400'}`}>
{editMode ? '■ 편집 종료' : '✎ 편집 모드 (POI 위치/표고)'}
</button>
<button onClick={() => { setFovMode(v => { const nv = !v; if (nv) { setEditMode(false); setYawMode(false); } return nv; }); }}
className={`w-full text-[11px] px-2 py-1 rounded border transition-colors ${fovMode ? 'bg-purple-500/80 border-purple-300 text-black font-bold' : 'bg-black/60 border-gray-600 text-gray-200 hover:border-gray-400'}`}>
{fovMode ? '■ 세로화각 보정 종료' : '🎯 세로화각 보정 — POI를 실제 위치(상하)로 드래그'}
<InfoTip text="라벨이 멀리(위)/가까이(아래)서 상하로 어긋날 때 사용. POI 하나를 영상 속 실제 위치로 끌면 세로 화각(sensorH)만 역산해 상하 정렬. 가로(초점 f)는 안 건드려 빨간 중심선이 좌우로 안 틀어짐. 세로 중심에서 충분히 떨어진(위/아래) POI로 보정." />
</button>
<button onClick={() => { setYawMode(v => { const nv = !v; if (nv) { setEditMode(false); setFovMode(false); } return nv; }); }}
className={`w-full text-[11px] px-2 py-1 rounded border transition-colors ${yawMode ? 'bg-sky-500/80 border-sky-300 text-black font-bold' : 'bg-black/60 border-gray-600 text-gray-200 hover:border-gray-400'}`}>
{yawMode ? '■ Yaw 보정 종료' : '🧭 Yaw 보정 — POI를 실제 위치(좌우)로 드래그'}
<InfoTip text="라벨 전체가 좌우로 치우칠 때 사용. POI 하나를 영상 속 실제 지물 위치로 끌면 필요한 Yaw±(방위 오프셋)를 역산해 자동 설정. 자동추정(비행경로) 후 마무리 미세조정에 적합." />
</button>
<div className="text-[10px] text-gray-600"> 라벨 클릭 속성 팝업(여러 누적, 재생 따라 이동). 클릭 팝업 닫기, 팝업 없으면 재생/정지.</div>
<div className="text-[10px] text-gray-500">
{dragTitle ? `드래그 중: ${dragTitle}`
: fovMode ? `🎯 POI를 실제 위치로 끌면 세로화각 자동보정 (현재 senH=${(params.sensorH ?? 20.25).toFixed(2)}mm)`
: yawMode ? `🧭 POI를 실제 위치(좌우)로 끌면 Yaw 자동보정 (현재 Yaw±=${params.yawOffset.toFixed(1)}°)`
: `보정된 POI ${Object.keys(storePoiOverrides).length}개`}
</div>
{editMode && selPoi && (
<div className="border border-sky-800 rounded p-1.5 bg-sky-950/40 space-y-1">
<div className="text-[10px] text-sky-300 truncate">선택: {cleanTitle(selPoi)}</div>
<ParamRow label="표고 z" value={selZ} min={Math.round(selZBase - 30)} max={Math.round(selZBase + 12)} step={0.5} unit="m" decimals={1} onChange={setSelectedZ} />
<div className="flex items-center justify-between text-[10px] text-gray-500">
<span>선로표고 {selZBase.toFixed(1)}m</span>
<button onClick={() => { if (selPoi) { clearPoiOverride(selPoi); setSelZ(selZBase); } }}
className="text-gray-400 hover:text-red-300 border border-gray-700 rounded px-1"> POI 해제</button>
</div>
</div>
)}
<div className="flex gap-1">
<button onClick={exportOverrides}
className="flex-1 text-[11px] px-2 py-0.5 rounded border border-gray-600 text-gray-200 hover:border-gray-400">내보내기</button>
<button onClick={() => fileInputRef.current?.click()}
className="flex-1 text-[11px] px-2 py-0.5 rounded border border-gray-600 text-gray-200 hover:border-gray-400">가져오기</button>
</div>
<input ref={fileInputRef} type="file" accept=".json,application/json" className="hidden"
onChange={e => { const f = e.target.files?.[0]; if (f) importOverridesFile(f); e.target.value = ''; }} />
<div className="flex gap-1">
<button onClick={applyDemElevations} disabled={demBusy}
className="flex-1 text-[11px] px-2 py-1 rounded border border-sky-700 bg-sky-900/40 text-sky-200 hover:border-sky-400 disabled:opacity-50">
{demBusy ? 'DEM 조회 중…' : '🌐 DEM 표고 자동적용'}
</button>
<button onClick={() => { if (confirm('POI 위치/표고 보정을 모두 초기화할까요? (선로표고로 복귀)')) setPoiOverrides({}); }}
className="text-[11px] px-2 py-1 rounded border border-gray-600 text-gray-300 hover:border-red-400 hover:text-red-300">
보정 초기화
</button>
</div>
</div>
<div className="border-t border-gray-700 pt-2 mt-3 flex items-center justify-end">
<button onClick={resetDisplayDefaults}
title="환경설정 파일(*.display.json)이 폴더에 있으면 그 값으로, 없으면 기본값으로 복원 (POI 보정은 별도)"
className="text-[11px] text-gray-400 hover:text-white border border-gray-600 hover:border-gray-400 px-2 py-0.5 rounded transition-colors">
기본값
</button>
</div>
</div>
)}
{/* ── 카메라 파라미터 (드론 카메라 관련만) ── */}
{showControls && (
<div className="bg-black/90 border border-gray-600 rounded p-3 text-white w-72 select-none max-h-[80vh] overflow-y-auto shadow-xl">
{/* 헤더 + 카메라 파라미터 PC 저장 — 화면표시 옵션 패널과 동일 위치(우상단)에 배치 */}
<div className="flex items-center justify-between border-b border-gray-700 pb-2 mb-2">
<span className="text-[10px] text-gray-500 uppercase tracking-wider">자세 보정 오프셋<span className="ml-1 text-gray-600">(SRT + offset)</span></span>
<button
type="button"
onClick={saveCameraToPc}
className={`text-[10px] px-1.5 py-0.5 rounded border ${camSaveState === 'ok' ? 'border-emerald-400 bg-emerald-700/60 text-emerald-100' : 'border-gray-600 bg-gray-700 hover:bg-gray-600 text-gray-200'}`}
title="현재 카메라 파라미터 전체(Yaw±/f/센서 등)를 camera.json 으로 PC에 다운로드 — 영상 폴더에 넣어두면 폴더 선택 시 자동 적용"
>{camSaveState === 'ok' ? '저장됨 ✓' : 'PC에 저장'}</button>
</div>
<div className="space-y-2 mb-3">
<ParamRow label="시간동기" tip="영상↔비행로그 시각 오프셋(초). POI 전체가 진행방향으로 밀리면 조정 (+: 라벨을 진행방향 앞으로 / −: 뒤로). '풀 붙임' 정합의 핵심 — camera.json 에 저장됨" value={params.timeOffsetSec ?? 0} min={-2} max={2} step={0.05} unit="s" decimals={2} onChange={v => setParam('timeOffsetSec', v)} />
<ParamRow label="자세 선행" tip="짐벌 자세 로그의 기록 지연 보정(초). 영상이 좌우로 팬(회전)할 때 라벨·선형이 늦게 따라오면 +, 앞서가면 −. 위치는 그대로 두고 자세만 앞선 시점을 참조 — camera.json 에 저장됨" value={params.attLeadSec ?? 0} min={-1} max={1} step={0.01} unit="s" decimals={2} onChange={v => setParam('attLeadSec', v)} />
<ParamRow label="Yaw ±" value={params.yawOffset} min={-180} max={180} step={0.1} unit="°" decimals={1} onChange={v => setParam('yawOffset', v)} />
{/* Yaw 자동 추정(2단계 보정의 1단계) — 대략 맞춘 뒤 'Yaw 보정' 드래그 모드로 마무리 */}
<button
type="button"
onClick={autoEstimateYaw}
className="w-full text-[10px] px-2 py-0.5 rounded border border-gray-600 bg-gray-800 hover:bg-gray-700 text-gray-300"
title="전진 비행 구간의 GPS 진행 방위와 짐벌 헤딩 차이(중앙값)로 Yaw± 자동 설정 (근사) — 이후 'Yaw 보정' 드래그로 미세 조정"
>Yaw 자동추정 (비행경로 기준)</button>
<ParamRow label="Pitch ±" value={params.pitch} min={-45} max={45} step={0.1} unit="°" decimals={1} onChange={v => setParam('pitch', v)} />
<ParamRow label="Roll ±" value={params.roll} min={-45} max={45} step={0.1} unit="°" decimals={1} onChange={v => setParam('roll', v)} />
</div>
<div className="text-[10px] text-gray-500 uppercase tracking-wider mb-1.5 border-t border-gray-700 pt-2">위치 보정 (드론 GPS 오프셋)</div>
<div className="space-y-2 mb-3">
<ParamRow label="off X" value={params.offX} min={-500} max={500} step={0.1} unit="m" decimals={1} onChange={v => setParam('offX', v)} />
<ParamRow label="off Y" value={params.offY} min={-500} max={500} step={0.1} unit="m" decimals={1} onChange={v => setParam('offY', v)} />
<ParamRow label="off Z" value={params.offZ} min={-200} max={200} step={0.1} unit="m" decimals={1} onChange={v => setParam('offZ', v)} />
<ParamRow label="지오이드" value={params.geoidOffset} min={0} max={200} step={0.1} unit="m" decimals={1} onChange={v => setParam('geoidOffset', v)} />
<div className="text-[10px] text-gray-600 text-right">정표고→타원체고 (대전≈25.8)</div>
</div>
<div className="text-[10px] text-gray-500 uppercase tracking-wider mb-1.5 border-t border-gray-700 pt-2">내부표정 (초점·주점·센서)</div>
{/* 영상(djmd) 자동 감지 정보 — PC 저장은 패널 헤더 버튼 하나로 통일(중복 제거) */}
<div className="flex items-center justify-between gap-1 mb-1.5 text-[10px] text-gray-400">
<span className="truncate" title="영상 메타데이터(djmd)에서 감지된 카메라 정보">
📷 {storeCameraInfo
? `${storeCameraInfo.model}${storeCameraInfo.focalLen35 ? ` · ${storeCameraInfo.focalLen35}mm` : ''}${storeCameraInfo.fps ? ` · ${storeCameraInfo.fps}fps` : ''}`
: '카메라 미감지'}
</span>
{storeCameraInfo?.focalLen35 !== undefined && (
<button
type="button"
onClick={() => setParams(p => ({ ...p, focalLen: storeCameraInfo!.focalLen35! }))}
className="shrink-0 px-1.5 py-0.5 rounded border border-gray-600 bg-gray-700 hover:bg-gray-600 text-gray-200"
title={`감지된 초점거리 ${storeCameraInfo!.focalLen35}mm 를 f 에 적용`}
>적용</button>
)}
</div>
<div className="space-y-2 mb-3">
<ParamRow label="f" value={params.focalLen} min={10} max={100} step={0.1} unit="mm" decimals={1} onChange={v => setParam('focalLen', v)} />
<ParamRow label="cx₀" value={params.cx0} min={-0.5} max={0.5} step={0.005} unit="" decimals={3} onChange={v => setParam('cx0', v)} />
<ParamRow label="cy₀" value={params.cy0} min={-0.5} max={0.5} step={0.005} unit="" decimals={3} onChange={v => setParam('cy0', v)} />
<ParamRow label="sen W" value={params.sensorW} min={10} max={50} step={0.05} unit="mm" decimals={2} onChange={v => setParam('sensorW', v)} />
<ParamRow label="sen H" value={params.sensorH} min={6} max={100} step={0.05} unit="mm" decimals={2} onChange={v => setParam('sensorH', v)} />
</div>
{panelDroneFrame && (
<div className="border-t border-gray-700 pt-2 mb-2 text-[10px] text-gray-400 font-mono space-y-0.5">
<div>yaw: {((panelDroneFrame.yaw+params.yawOffset+360)%360).toFixed(1)}° pitch: {(panelDroneFrame.pitch+params.pitch).toFixed(1)}° roll: {(panelDroneFrame.roll+params.roll).toFixed(1)}°</div>
<div>f: {params.focalLen.toFixed(1)}mm hFOV: {(2*Math.atan((params.sensorW??36)/(2*params.focalLen))*180/Math.PI).toFixed(1)}°</div>
<div>offX: {params.offX.toFixed(1)}m offY: {params.offY.toFixed(1)}m offZ: {params.offZ.toFixed(1)}m</div>
<div>
영상 {videoWidth || '?'}×{videoHeight || '?'}
{videoWidth > 0 && videoHeight > 0 && (
<span className="text-gray-500"> ({(videoWidth / videoHeight).toFixed(3)} · cover 정렬)</span>
)}
</div>
</div>
)}
<div className="border-t border-gray-700 pt-2 flex items-center justify-end">
<button onClick={resetCameraParams}
title="환경설정 파일(*.camera.json)이 폴더에 있으면 그 값으로, 없으면 앱 기본값으로 복원"
className="text-[11px] text-gray-400 hover:text-white border border-gray-600 hover:border-gray-400 px-2 py-0.5 rounded transition-colors">
초기화
</button>
</div>
</div>
)}
</div>
</div>
)}
</>
);
}