선형 입체형(기본형/입체형 토글, 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>
2572 lines
155 KiB
TypeScript
2572 lines
155 KiB
TypeScript
/**
|
||
* 지리정보 오버레이
|
||
* 렌더링 최적화:
|
||
* - 텍스트(측점+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>
|
||
)}
|
||
</>
|
||
);
|
||
}
|