스테이션바/측점(체이니지) - 역 재진입 마커 누락 수정: 측점기준 탐지에 좌표근접(pxPassesTo) 합집합 보강(중복 24px 제거) - kmExists 판정: 좌표 반경만으로 잡힌 통과는 측점값 라벨/검색에서 제외(마커 동그라미는 유지) - 측점값 라벨 겹침 숨김(좌측 우선), 동일항목 브래킷 클러스터 - 측점 검색 순환(통과방향 다음→끝에서 처음), 마커 기준 후보 산출, 입력값 유지/blur 삭제 - 연속 체이니지 수직투영 유틸 분리(client/src/utils/chainage.ts), Minimap 컴포넌트 추가 영상 컨트롤 UI - 선형/드론궤적/좌측패널 토글을 재생바로 통합(settingsStore 공유·persist) - 드론 GPS·고도 HUD 상시 표시, 경로표고·투명도 항상 노출, 겹침제외 버튼 숨김 - 재생바 요소 재배치(좌측패널→선형→드론궤적→드론위치정보→시설등급), 활성색 amber 통일 문서/도구 - 종합 기술명세서(인계+특허 기초) md/html/pdf 3종, UI개선 보고서 3종 - md→html→pdf 변환 스크립트(scripts/md2docs.sh) + 스타일(docs/assets/report.css) - 작업 히스토리 다수 추가(docs/history/) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1640 lines
94 KiB
TypeScript
1640 lines
94 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 type { GeoPoint, CenterlinePoint, PoiOverrideMap, RouteStructure } from '../../types/geo';
|
||
|
||
const VIDEO_FPS = 30000 / 1001;
|
||
|
||
// 라벨 좌표 튐 방지 — 1프레임에 이 정규화 거리(화면폭/높이 비율) 이상 점프하면 이상치로
|
||
// 보고 갱신 무시(이전 위치 유지). 정상 팬 이동은 60fps에서 프레임당 훨씬 작다.
|
||
const REJECT_DIST = 0.12;
|
||
// 단, 시크/재등장 등 '진짜' 큰 이동에 영구히 갇히지 않도록 연속 거부 한계 후 수용.
|
||
const MAX_REJECT_FRAMES = 8;
|
||
|
||
// POI 라벨 겹침 억제 — 두 마커가 화면상 이 정규거리(가로 X / 세로 Y) 이내면 '겹침'으로 보고
|
||
// 드론에 더 가까운 것만 남기고 나머지는 숨긴다. (같은 건물의 여러 업체 등)
|
||
const POI_MERGE_X = 0.10;
|
||
const POI_MERGE_Y = 0.035;
|
||
|
||
type DispPos = { x: number; y: number; rej: number; vx: number; vy: number; rx?: number; ry?: number };
|
||
|
||
// 속도 적응형 평활(One Euro 방식) 상수.
|
||
// 떨림(노이즈)은 매 프레임 방향이 번갈아 → 평활속도≈0 → 강하게 평활(1배속에서 안정).
|
||
// 실제 이동은 방향이 일관 → 평활속도 큼 → alpha↑로 즉시 추종(빠른 배속에서 지연 없음).
|
||
const SMOOTH_VEL_BETA = 0.25; // 속도 추정 평활(저크 제거)
|
||
|
||
/** 이상치 거부 + 속도적응 EMA.
|
||
* maxAlpha = 빠를 때 추종 상한(패널 EMA α), minAlpha = 정지/떨림 시 최소 추종(작을수록 강한 평활),
|
||
* speedRef = 이 속도(정규/프레임) 이상에서 alpha 가 maxAlpha 에 도달(클수록 더 강하게 평활). */
|
||
function smoothStep(prev: DispPos | undefined, tx: number, ty: number, maxAlpha: number, minAlpha: number, speedRef: number): DispPos {
|
||
if (!prev) return { x: tx, y: ty, rej: 0, vx: 0, vy: 0 };
|
||
const dx = tx - prev.x, dy = ty - prev.y;
|
||
const d = Math.hypot(dx, dy);
|
||
if (d > REJECT_DIST && prev.rej < MAX_REJECT_FRAMES) {
|
||
return { x: prev.x, y: prev.y, rej: prev.rej + 1, vx: prev.vx, vy: prev.vy }; // 이상치: 위치 유지
|
||
}
|
||
// 속도 벡터 평활 → 떨림(왕복)은 상쇄돼 작게, 실제 이동은 유지.
|
||
const vx = prev.vx + (dx - prev.vx) * SMOOTH_VEL_BETA;
|
||
const vy = prev.vy + (dy - prev.vy) * SMOOTH_VEL_BETA;
|
||
const speed = Math.hypot(vx, vy);
|
||
const a = Math.min(maxAlpha, minAlpha + (maxAlpha - minAlpha) * Math.min(1, speed / Math.max(1e-4, speedRef)));
|
||
return { x: prev.x + dx * a, y: prev.y + dy * a, rej: 0, vx, vy };
|
||
}
|
||
|
||
interface Props {
|
||
currentFrame: number;
|
||
currentTime: number;
|
||
fps: number;
|
||
visible: boolean;
|
||
/** 영상 첫 프레임이 표시 가능해진 후 true. false면 오버레이를 그리지 않음(영상보다 먼저 표시 방지). */
|
||
videoReady?: boolean;
|
||
/** 영상 원본 해상도(px). object-fit:cover 크롭 영역 계산 → 오버레이를 영상에 정렬. 0이면 컨테이너 기준. */
|
||
videoWidth?: number;
|
||
videoHeight?: number;
|
||
/** 영상 빈 곳 클릭(팝업 없을 때) → 재생/정지 토글. Video.js controls:false 라 직접 호출 필요. */
|
||
onTogglePlay?: () => void;
|
||
/** 카메라 파라미터 패널 표시 (영상제어 토글). 기본 true. */
|
||
showPanel?: boolean;
|
||
/** 카메라 파라미터 패널 top(px) — 노선 배너 아래로 배치. 기본 72. */
|
||
topPx?: number;
|
||
/** 하단 스테이션바 높이(px) — 우측 버튼을 좌측 패널과 같은 높이(bottom)에 맞추기 위함. */
|
||
barHeight?: number;
|
||
/** 부드러운 단조보간 재생시간(초) ref. VideoPlayer smoothTimeRef 전달 시
|
||
* 라벨이 60fps로 매끄럽게 이동(일시정지·시크·배속 보정 포함). 없으면 prop 기반 추정. */
|
||
timeRef?: React.MutableRefObject<number>;
|
||
}
|
||
|
||
// category → 이모지
|
||
const CATEGORY_EMOJI: Record<string, string> = {
|
||
'터널': '🚇',
|
||
'교량': '🌉',
|
||
'역사': '🚉',
|
||
'철도역': '🚉',
|
||
'지장물': '🏢',
|
||
'측점': '📍',
|
||
'구교': '🌉',
|
||
'출입문': '🚪',
|
||
};
|
||
|
||
// 영상 오버레이엔 모든 선로 구조물(교량/터널/구교)을 라벨로 표출한다.
|
||
// 시설등급(시설종별) 표시 필터는 하단 스테이션바·RoutePanel 전용 → 영상은 항상 전부 표출.
|
||
// (역사=철도역은 type='station'이라 여기 미포함 → 스테이션바 전용 유지.)
|
||
const OVERLAY_STRUCT_CATEGORIES = new Set(['구교', '교량', '터널']);
|
||
|
||
// 화면표시 옵션 기본값 — 우측 하단 "기본값" 버튼으로 일괄 복귀. (useState 초기값과 단일 출처 공유.)
|
||
const DISPLAY_DEFAULTS = {
|
||
showCenterline: true,
|
||
showDronePath: true,
|
||
dronePathZ: 58,
|
||
dronePathAlpha: 0.75,
|
||
smoothHalf: 60,
|
||
emaAlpha: 1.0,
|
||
smoothMinAlpha: 0.12, // 정지/떨림 시 최소 추종(작을수록 강한 평활)
|
||
smoothSpeedRef: 0.010, // 즉시추종 기준 속도(클수록 더 강하게 평활)
|
||
maxPoiRange: 1000,
|
||
// false = 지면고도 기준(POI를 지면고도 z에 고정 → gap=드론고도−지면고도 자동변동, 드론 오르내려도 지물에 붙음).
|
||
// true = 드론−이격거리 고정(gap 상수). 드론 고도 변동 시 부정확.
|
||
poiDroneHeight: true,
|
||
droneHeightDrop: 24,
|
||
};
|
||
|
||
// 텍스트 사전 계산 캐시 (Map<frameNum, LabelCache>)
|
||
// 라벨 가시집합(프레임별) — 화면좌표가 아니라 '월드좌표'를 저장한다. RAF 가 매 프레임 연속
|
||
// 포즈(poseAt)로 직접 투영 → 라인처럼 부드럽게 이동. (precompute = 가시성/겹침 결정 전용)
|
||
interface LabelCache {
|
||
stationLabels: { title: string; lat: number; lon: number; z: number }[];
|
||
poiMarkers: { title: string; label: string; category: string; lat: number; lon: number; gz: number; compact: { k: string; v: string }[]; labelRow: number; labelRowCount: number }[];
|
||
}
|
||
|
||
// 중심선 + 나침반 렌더 캐시 (per-frame, renderCacheRef)
|
||
interface RenderCache {
|
||
// 선/궤적은 RAF에서 매 프레임 보간 포즈로 직접 투영(부드러운 갱신) → 여기 미보관.
|
||
effectiveYaw: number;
|
||
hFovRad: number;
|
||
clCount: number;
|
||
poiCount: number;
|
||
}
|
||
|
||
const cleanTitle = (t: string) => t.replace(/\s*\([상하]\)\s*$/, '').trim();
|
||
|
||
/** 구조물 base 이름(괄호 변형 제거): '회덕제1가도교(하)' → '회덕제1가도교'. 상/하 형제 판정용. */
|
||
const baseStruct = (t: string): string => t.replace(/\s*[((].*$/, '').trim();
|
||
|
||
/** 속성에 '구분' 데이터가 있으면 그 값을 라벨로 사용(없으면 undefined). */
|
||
const guboonOf = (props?: { k: string; v: string }[]): string | undefined => {
|
||
const v = props?.find((p) => p.k === '구분')?.v;
|
||
return v && v.trim() ? v.trim() : undefined;
|
||
};
|
||
|
||
/**
|
||
* 컴팩트 팝업용 기본 3필드: 시설종별 / 구조형식(상부구조형식) / 연장(m).
|
||
* 존재하는 것만, 이 순서로 반환. 라벨 옆 항상표시용(클릭 시 전체 팝업).
|
||
*/
|
||
const compactFieldsOf = (props?: { k: string; v: string }[]): { k: string; v: string }[] => {
|
||
if (!props) return [];
|
||
const out: { k: string; v: string }[] = [];
|
||
const pick = (label: string, pred: (k: string) => boolean): void => {
|
||
const f = props.find((p) => pred(p.k));
|
||
if (f?.v?.trim()) out.push({ k: label, v: f.v.trim() });
|
||
};
|
||
pick('시설종별', (k) => k === '시설종별');
|
||
pick('구조형식', (k) => /구조형식/.test(k));
|
||
pick('연장(m)', (k) => /연장/.test(k));
|
||
pick('폭(m)', (k) => /폭/.test(k));
|
||
pick('용도', (k) => /용도/.test(k));
|
||
pick('준공연도', (k) => /준공/.test(k));
|
||
return out;
|
||
};
|
||
|
||
/** 컴팩트에 들어가는 키인지(전체 보기에서 중복 제거 + 순서 정렬용). */
|
||
const isCompactKey = (k: string): boolean =>
|
||
k === '시설종별' || /구조형식/.test(k) || /연장/.test(k) || /폭/.test(k) || /용도/.test(k) || /준공/.test(k);
|
||
|
||
/** 전체 보기 필드 순서 = 컴팩트(정해진 순서) 먼저, 그 뒤 나머지 속성(원본 순서). 클릭 전후 순서 일치. */
|
||
const orderedFullFields = (
|
||
compact: { k: string; v: string }[],
|
||
props: { k: string; v: string }[],
|
||
): { k: string; v: string }[] => [...compact, ...props.filter((p) => !isCompactKey(p.k))];
|
||
|
||
function stationOrder(title: string): number {
|
||
const m = title.match(/(\d+)[Kk](\d+)/);
|
||
if (!m) return 0;
|
||
return parseInt(m[1]) * 1000 + parseInt(m[2]);
|
||
}
|
||
|
||
// ── ParamRow ─────────────────────────────────────────────────────────────────
|
||
|
||
// 라벨 옆 작은 ⓘ — 마우스 오버 시 네이티브 툴팁으로 설명 표시(세로 공간 절약).
|
||
function InfoTip({ text }: { text: string }) {
|
||
return (
|
||
<span title={text} className="ml-1 cursor-help text-gray-600 hover:text-gray-300 align-middle">ⓘ</span>
|
||
);
|
||
}
|
||
|
||
interface ParamRowProps {
|
||
label: string; value: number; min: number; max: number; step: number;
|
||
unit: string; decimals?: number; onChange: (v: number) => void;
|
||
/** 있으면 라벨에 점선 밑줄+마우스오버 툴팁(설명). 별도 설명 div 대체 → 패널 높이 절약. */
|
||
tip?: string;
|
||
}
|
||
|
||
function ParamRow({ label, value, min, max, step, unit, decimals = 1, onChange, tip }: ParamRowProps) {
|
||
const fmt = useCallback((v: number) => v.toFixed(decimals), [decimals]);
|
||
const [text, setText] = useState(() => fmt(value));
|
||
const prevRef = useRef(value);
|
||
useEffect(() => {
|
||
if (prevRef.current !== value) { prevRef.current = value; setText(fmt(value)); }
|
||
}, [value, fmt]);
|
||
const commit = (s: string) => {
|
||
const n = parseFloat(s);
|
||
if (!isNaN(n)) {
|
||
const c = Math.max(min, Math.min(max, n));
|
||
onChange(c); setText(fmt(c)); prevRef.current = c;
|
||
} else setText(fmt(value));
|
||
};
|
||
return (
|
||
<div className="flex items-center gap-1.5 text-[11px]">
|
||
<span
|
||
title={tip}
|
||
className={`text-gray-400 w-12 shrink-0 text-right ${tip ? 'cursor-help underline decoration-dotted decoration-gray-600 underline-offset-2' : ''}`}
|
||
>{label}</span>
|
||
<input type="range" min={min} max={max} step={step} value={value}
|
||
onChange={e => { const v = parseFloat(e.target.value); onChange(v); prevRef.current = v; setText(fmt(v)); }}
|
||
className="flex-1 h-1 accent-yellow-400 cursor-pointer" />
|
||
<input type="number" min={min} max={max} step={step} value={text}
|
||
onChange={e => setText(e.target.value)}
|
||
onBlur={e => commit(e.target.value)}
|
||
onKeyDown={e => { if (e.key === 'Enter') commit((e.target as HTMLInputElement).value); if (e.key === 'Escape') setText(fmt(value)); }}
|
||
className="w-16 bg-black/60 border border-gray-700 rounded px-1 py-0.5 text-right font-mono text-yellow-300 text-[11px] [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" />
|
||
<span className="text-gray-500 text-[10px] w-5 shrink-0">{unit}</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── 메인 컴포넌트 ─────────────────────────────────────────────────────────────
|
||
|
||
export default function StationOverlay({ currentFrame, currentTime, fps, visible, videoReady = true, videoWidth = 0, videoHeight = 0, onTogglePlay, showPanel = true, topPx = 72, barHeight = 0, timeRef }: Props) {
|
||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||
const canvasSizeRef = useRef({ w: 0, h: 0 });
|
||
|
||
// 데이터 ref
|
||
const allDroneFramesRef = useRef<DroneFrameBasic[]>([]);
|
||
const allCenterlinePointsRef = useRef<CenterlinePoint[]>([]);
|
||
const allGeoStationsRef = useRef<GeoPoint[]>([]);
|
||
const allPoisRef = useRef<GeoPoint[]>([]);
|
||
const allStructuresRef = useRef<GeoPoint[]>([]); // 교량/터널/구교 → POI처럼 표시
|
||
|
||
// 현재 상태 ref
|
||
const currentDroneFrameRef = useRef<DroneFrameBasic | null>(null);
|
||
const currentFrameNumRef = useRef<number>(0); // RAF에서 Map 조회용
|
||
const currentFrameIdxRef = useRef<number>(0); // smoothFrame용 배열 인덱스
|
||
const currentTimeSecRef = useRef<number>(0); // 마지막으로 알려진 재생 시간
|
||
const timeUpdateWallRef = useRef<number>(performance.now()); // currentTime 갱신된 시각
|
||
const paramsRef = useRef<CameraParams>(DEFAULT_CAMERA_PARAMS);
|
||
const visibleRef = useRef(visible);
|
||
const videoReadyRef = useRef(videoReady);
|
||
const videoSizeRef = useRef({ w: videoWidth, h: videoHeight });
|
||
// object-fit:cover 변환 (RAF에서 매 프레임 갱신, 포인터 핸들러도 참조). 정규(0~1, 영상프레임) → 화면 px.
|
||
const coverRef = useRef({ offX: 0, offY: 0, dispW: 0, dispH: 0, W: 0, H: 0 });
|
||
const worldOriginRef = useRef<{ lat: number; lon: number; alt: number } | undefined>(undefined);
|
||
|
||
// 텍스트 사전 계산 Map
|
||
const labelMapRef = useRef<Map<number, LabelCache>>(new Map());
|
||
const precomputeIdRef = useRef(0); // 진행 중 계산 취소용
|
||
|
||
// 중심선 + 나침반 렌더 캐시 (per-frame)
|
||
const renderCacheRef = useRef<RenderCache | null>(null);
|
||
// 나침반 전용(측점선 visible 무관 항상 갱신·표시)
|
||
const compassRef = useRef<{ effectiveYaw: number; hFovRad: number } | null>(null);
|
||
// 나침반 미니맵(DOM) root — RAF 에서 --rot(=-yaw) 갱신. minimapRotRef=누적 회전(360° 언랩).
|
||
const minimapRef = useRef<HTMLDivElement>(null);
|
||
const minimapRotRef = useRef(0);
|
||
|
||
// UI state
|
||
const [params, setParams] = useState<CameraParams>(DEFAULT_CAMERA_PARAMS);
|
||
const [smoothHalf, setSmoothHalf] = useState(DISPLAY_DEFAULTS.smoothHalf);
|
||
const smoothHalfRef = useRef(10);
|
||
// 화면 EMA — 0.01은 과평활(지연 ~1.65s)로 카메라 이동 시 라벨이 늦게 따라옴.
|
||
// 입력은 이미 smoothFrame(±10fr)으로 평활되므로 화면 EMA는 가볍게(0.4, 지연 ~25ms).
|
||
const [emaAlpha, setEmaAlpha] = useState(DISPLAY_DEFAULTS.emaAlpha);
|
||
const emaAlphaRef = useRef(0.4);
|
||
// 속도적응 평활 — 떨림 억제 강도/기준속도(라벨·팝업 1배속 떨림 튜닝).
|
||
const [smoothMinAlpha, setSmoothMinAlpha] = useState(DISPLAY_DEFAULTS.smoothMinAlpha);
|
||
const smoothMinAlphaRef = useRef(DISPLAY_DEFAULTS.smoothMinAlpha);
|
||
const [smoothSpeedRef, setSmoothSpeedRef] = useState(DISPLAY_DEFAULTS.smoothSpeedRef);
|
||
const smoothSpeedRefRef = useRef(DISPLAY_DEFAULTS.smoothSpeedRef);
|
||
// POI 표시 범위(m) — 드론 위치와의 수평 직선거리(distH, 고도차 무시). 이 범위 안이면 표시,
|
||
// 밖이면 제외. (이전: 진행방향 앞/옆 비등방 → 단일 반경으로 통합.) 패널에서 실시간 조절.
|
||
const [maxPoiRange, setMaxPoiRange] = useState(DISPLAY_DEFAULTS.maxPoiRange);
|
||
const maxPoiRangeRef = useRef(DISPLAY_DEFAULTS.maxPoiRange);
|
||
// 비교 테스트: POI 높이를 선로표고(false) 대신 '드론 고도 − N미터'(true)로. N=droneHeightDrop.
|
||
const [poiDroneHeight, setPoiDroneHeight] = useState(DISPLAY_DEFAULTS.poiDroneHeight);
|
||
const [droneHeightDrop, setDroneHeightDrop] = useState(DISPLAY_DEFAULTS.droneHeightDrop);
|
||
// 드론 경로 가시화 — 드론의 GPS 경로(lat/lon)를 지정 표고 z 에 투영해 선으로 표시.
|
||
// 선형/드론궤적 토글은 설정 스토어로 공유 → VideoPlayer 하단 바 버튼에서도 제어.
|
||
const showDronePath = useSettingsStore(s => s.showDronePath);
|
||
const setShowDronePath = useSettingsStore(s => s.setShowDronePath);
|
||
const [dronePathZ, setDronePathZ] = useState(DISPLAY_DEFAULTS.dronePathZ); // 기본 경로 표고
|
||
const showDronePathRef = useRef(true);
|
||
const dronePathZRef = useRef(78);
|
||
useEffect(() => { showDronePathRef.current = showDronePath; }, [showDronePath]);
|
||
useEffect(() => { dronePathZRef.current = dronePathZ; }, [dronePathZ]);
|
||
// 선형(중심선) 독립 토글 + 드론경로 투명도(0~1) — 토글은 설정 스토어 공유(바 버튼 제어).
|
||
const showCenterline = useSettingsStore(s => s.showCenterline);
|
||
const setShowCenterline = useSettingsStore(s => s.setShowCenterline);
|
||
const showCenterlineRef = useRef(true);
|
||
useEffect(() => { showCenterlineRef.current = showCenterline; }, [showCenterline]);
|
||
const [dronePathAlpha, setDronePathAlpha] = useState(DISPLAY_DEFAULTS.dronePathAlpha);
|
||
const dronePathAlphaRef = useRef(0.75);
|
||
useEffect(() => { dronePathAlphaRef.current = dronePathAlpha; }, [dronePathAlpha]);
|
||
// 측점 진단 토글 버튼은 삭제됨. (HUD 표시는 VideoPlayer 에서 showStationDiag 구독)
|
||
// POI 위치 편집(드래그) 모드
|
||
const [editMode, setEditMode] = useState(false);
|
||
const editModeRef = useRef(false);
|
||
// FOV(초점) 보정 모드: POI 라벨을 실제 위치로 끌면 그 대응점으로 focal 을 역산.
|
||
const [fovMode, setFovMode] = useState(false);
|
||
const fovModeRef = useRef(false);
|
||
// 라벨 속성 팝업(다중) — 항상 활성(모드 버튼 없음). 라벨 위에서만 캔버스가 입력을 받아 클릭→팝업.
|
||
// id=`${kind}:${title}`. 위치는 RAF 가 라벨 displayed 좌표로 매 프레임 갱신(재생 중 따라 이동).
|
||
type InfoPopup = { id: string; kind: 'poi' | 'station'; title: string; category: string; lat: number; lon: number; z: number; dist: number | null; sx: number; sy: number; props?: { k: string; v: string }[];
|
||
/** 컴팩트 기본필드(시설종별/구조형식/연장/용도/준공연도). 있으면 라벨 옆 자동표시. */
|
||
compact?: { k: string; v: string }[];
|
||
/** true=전체 속성, false/undefined=컴팩트. 팝업 클릭으로 토글. */
|
||
expanded?: boolean;
|
||
/** true=가시 구조물에 따라 자동생성/제거(✕ 없음). */
|
||
auto?: boolean };
|
||
const [infoPopups, setInfoPopups] = useState<InfoPopup[]>([]);
|
||
const infoPopupsRef = useRef<InfoPopup[]>([]);
|
||
useEffect(() => { infoPopupsRef.current = infoPopups; }, [infoPopups]);
|
||
// 영상 진행 방향('상'|'하') — 겹치는 상/하 형제 구조물 중 이 방향을 우선 표시. (값 주입은 아래 routeMeta 구독 후)
|
||
const routeDirRef = useRef<'상' | '하' | null>(null);
|
||
// 이번 프레임 가시 컴팩트-구조물 (title → {category, compact}). RAF 가 채우고 sync 인터벌이 읽음.
|
||
const visStructRef = useRef<Map<string, { category: string; compact: { k: string; v: string }[] }>>(new Map());
|
||
const popupElsRef = useRef<Map<string, HTMLDivElement>>(new Map());
|
||
const popupMissRef = useRef<Map<string, number>>(new Map()); // 라벨이 화면 밖인 연속 프레임 수(제거 유예)
|
||
const popupPosRef = useRef<Map<string, { x: number; y: number }>>(new Map()); // 팝업 위치 EMA(물결 방지)
|
||
// 라벨 히트박스(아이콘+글자 영역, 화면 px) — RAF 가 매 프레임 갱신, 클릭/호버 히트테스트에 사용.
|
||
const labelHitRef = useRef<{ kind: 'poi' | 'station'; title: string; x0: number; y0: number; x1: number; y1: number }[]>([]);
|
||
const hasPopupRef = useRef(false);
|
||
const onTogglePlayRef = useRef<(() => void) | undefined>(undefined);
|
||
const poiDroneHeightRef = useRef(DISPLAY_DEFAULTS.poiDroneHeight);
|
||
const droneHeightDropRef = useRef(DISPLAY_DEFAULTS.droneHeightDrop);
|
||
const overridesRef = useRef<PoiOverrideMap>({});
|
||
// 드래그 상태: 선택 POI + lat/lon(고정) + 시작 z + 시작 화면좌표. 드롭 시 세로위치로 z 역산.
|
||
const dragRef = useRef<{ title: string; lat: number; lon: number; z0: number; sx: number; sy: number } | null>(null);
|
||
const dragPosRef = useRef<{ x: number; y: number } | null>(null);
|
||
const [dragTitle, setDragTitle] = useState<string | null>(null);
|
||
const [demBusy, setDemBusy] = useState(false);
|
||
// 선택된 POI의 표고 직접 조절 (슬라이더). selZBase=선로표고(슬라이더 범위 기준).
|
||
const [selPoi, setSelPoi] = useState<string | null>(null);
|
||
const [selZ, setSelZ] = useState(0);
|
||
const [selZBase, setSelZBase] = useState(42);
|
||
// 표시 위치 EMA 상태 (RAF 내부 유지). rej = 연속 이상치 거부 횟수.
|
||
const displayedStRef = useRef<Map<string, DispPos>>(new Map());
|
||
const displayedPoiRef = useRef<Map<string, DispPos>>(new Map());
|
||
const [showControls, setShowControls] = useState(false);
|
||
const [showDisplay, setShowDisplay] = useState(false);
|
||
const [droneFramesLoaded, setDroneFramesLoaded] = useState(false);
|
||
const [geoDataLoaded, setGeoDataLoaded] = useState(false);
|
||
const [clDataLoaded, setClDataLoaded] = useState(false);
|
||
const [panelDroneFrame, setPanelDroneFrame] = useState<DroneFrameBasic | null>(null);
|
||
|
||
useEffect(() => { paramsRef.current = params; }, [params]);
|
||
useEffect(() => { visibleRef.current = visible; }, [visible]);
|
||
useEffect(() => { videoReadyRef.current = videoReady; }, [videoReady]);
|
||
useEffect(() => { videoSizeRef.current = { w: videoWidth, h: videoHeight }; }, [videoWidth, videoHeight]);
|
||
useEffect(() => { smoothHalfRef.current = smoothHalf; }, [smoothHalf]);
|
||
useEffect(() => { emaAlphaRef.current = emaAlpha; }, [emaAlpha]);
|
||
useEffect(() => { smoothMinAlphaRef.current = smoothMinAlpha; }, [smoothMinAlpha]);
|
||
useEffect(() => { smoothSpeedRefRef.current = smoothSpeedRef; }, [smoothSpeedRef]);
|
||
useEffect(() => { maxPoiRangeRef.current = maxPoiRange; }, [maxPoiRange]);
|
||
useEffect(() => { editModeRef.current = editMode; }, [editMode]);
|
||
useEffect(() => { fovModeRef.current = fovMode; }, [fovMode]);
|
||
|
||
// 컴팩트 자동 팝업 동기화 — 가시 구조물(visStructRef)마다 라벨 옆 DOM 팝업을 자동 생성/제거.
|
||
// (캔버스 텍스트는 흐려서 DOM 팝업으로 선명하게. 위치는 RAF 가 displayed 좌표로 매 프레임 갱신.)
|
||
// 60fps 루프에서 setState 남발 방지 → 150ms 인터벌로 가시집합 변화시에만 갱신.
|
||
useEffect(() => {
|
||
const tick = (): void => {
|
||
const vis = visStructRef.current;
|
||
const cur = infoPopupsRef.current;
|
||
const c = coverRef.current;
|
||
const toAdd: InfoPopup[] = [];
|
||
vis.forEach((info, title) => {
|
||
const id = `poi:${title}`;
|
||
if (cur.some(p => p.id === id)) return;
|
||
const disp = displayedPoiRef.current.get(title);
|
||
if (!disp) return;
|
||
const obj = allStructuresRef.current.concat(allPoisRef.current).find(p => p.title === title);
|
||
toAdd.push({
|
||
id, kind: 'poi', title, category: info.category,
|
||
lat: obj?.lat ?? 0, lon: obj?.lon ?? 0, z: 0, dist: null,
|
||
sx: c.offX + disp.x * c.dispW, sy: c.offY + disp.y * c.dispH,
|
||
props: obj?.props, compact: info.compact, auto: true, expanded: false,
|
||
});
|
||
});
|
||
const toRemoveIds = cur.filter(p => p.auto && !vis.has(p.title)).map(p => p.id);
|
||
if (toAdd.length || toRemoveIds.length) {
|
||
setInfoPopups(prev => {
|
||
const kept = prev.filter(p => !toRemoveIds.includes(p.id));
|
||
const ids = new Set(kept.map(p => p.id));
|
||
return [...kept, ...toAdd.filter(p => !ids.has(p.id))];
|
||
});
|
||
}
|
||
};
|
||
const iv = setInterval(tick, 150);
|
||
return () => clearInterval(iv);
|
||
}, []);
|
||
|
||
// ESC → 클릭으로 연/펼친 팝업만 닫기(수동 팝업 제거 + 펼친 것 컴팩트로 접기).
|
||
// 기본 자동 컴팩트 팝업은 유지(닫지 않음).
|
||
useEffect(() => {
|
||
const onKey = (e: KeyboardEvent): void => {
|
||
if (e.key !== 'Escape') return;
|
||
if (infoPopupsRef.current.some(p => !p.auto || p.expanded)) {
|
||
setInfoPopups(prev => prev.filter(p => p.auto).map(p => ({ ...p, expanded: false })));
|
||
}
|
||
};
|
||
window.addEventListener('keydown', onKey);
|
||
return () => window.removeEventListener('keydown', onKey);
|
||
}, []);
|
||
useEffect(() => { hasPopupRef.current = infoPopups.length > 0; }, [infoPopups]);
|
||
useEffect(() => { onTogglePlayRef.current = onTogglePlay; }, [onTogglePlay]);
|
||
useEffect(() => { poiDroneHeightRef.current = poiDroneHeight; }, [poiDroneHeight]);
|
||
useEffect(() => { droneHeightDropRef.current = droneHeightDrop; }, [droneHeightDrop]);
|
||
|
||
const setParam = useCallback(<K extends keyof CameraParams>(key: K, val: CameraParams[K]) =>
|
||
setParams(prev => ({ ...prev, [key]: val })), []);
|
||
|
||
const nearestCL = useCallback((lat: number, lon: number): CenterlinePoint | null => {
|
||
const pts = allCenterlinePointsRef.current;
|
||
if (!pts.length) return null;
|
||
let best = pts[0], bestD = (best.lat - lat) ** 2 + (best.lon - lon) ** 2;
|
||
for (const pt of pts) {
|
||
const d = (pt.lat - lat) ** 2 + (pt.lon - lon) ** 2;
|
||
if (d < bestD) { bestD = d; best = pt; }
|
||
}
|
||
return best;
|
||
}, []);
|
||
|
||
// 클라이언트 지리정보 스토어 구독 (서버 /api/geo/* 대체)
|
||
const storeStations = useGeoStore(s => s.stations);
|
||
const storePois = useGeoStore(s => s.pois);
|
||
const storeCenterline = useGeoStore(s => s.centerline);
|
||
const storeFrames = useGeoStore(s => s.frames);
|
||
const storeOrigin = useGeoStore(s => s.origin);
|
||
const storeStructures = useGeoStore(s => s.structures);
|
||
const routeMeta = useGeoStore(s => s.routeMeta);
|
||
useEffect(() => {
|
||
const d = routeMeta?.routeInfo?.direction ?? '';
|
||
routeDirRef.current = d.includes('하') ? '하' : d.includes('상') ? '상' : null;
|
||
}, [routeMeta]);
|
||
// POI 겹침제외 토글(설정 스토어). false면 겹침 억제 없이 모든 POI 표시.
|
||
const poiOverlapExclude = useSettingsStore(s => s.poiOverlapExclude);
|
||
const overlapExcludeRef = useRef(true);
|
||
useEffect(() => { overlapExcludeRef.current = poiOverlapExclude; }, [poiOverlapExclude]);
|
||
// 좌측 노선 패널(RoutePanel) 표시 토글은 하단 재생바(VideoPlayer)로 이동.
|
||
const storeBaseName = useGeoStore(s => s.baseName);
|
||
const storePoiOverrides = useGeoStore(s => s.poiOverrides);
|
||
const setPoiOverride = useGeoStore(s => s.setPoiOverride);
|
||
const setPoiOverrides = useGeoStore(s => s.setPoiOverrides);
|
||
const clearPoiOverride = useGeoStore(s => s.clearPoiOverride);
|
||
// 경로 표고 슬라이더 상한 — 데이터(중심선) 최대 지면표고 + 여유 (없으면 60 폴백)
|
||
const pathZMax = useMemo(() => {
|
||
let m = 0;
|
||
for (const c of storeCenterline) if (typeof c.z === 'number' && c.z > m) m = c.z;
|
||
return m > 0 ? Math.ceil(m + 10) : 60;
|
||
}, [storeCenterline]);
|
||
useEffect(() => { overridesRef.current = storePoiOverrides; }, [storePoiOverrides]);
|
||
|
||
// ── 보정값 영속화 (baseName별 localStorage) ──────────────────────────────
|
||
// 카메라 각도/내부표정(Yaw±/Pitch±/focal 등) + 표시설정을 데이터셋별로 저장·복원.
|
||
// 한 번 튜닝하면 새로고침/재로드해도 유지 → "처음부터 맞게" 표시.
|
||
const calibLoadedFor = useRef<string | null>(null);
|
||
useEffect(() => {
|
||
if (!storeBaseName || calibLoadedFor.current === storeBaseName) return;
|
||
calibLoadedFor.current = storeBaseName;
|
||
try {
|
||
const raw = localStorage.getItem(`ghivideo:calib:${storeBaseName}`);
|
||
if (!raw) return;
|
||
const c = JSON.parse(raw);
|
||
if (c.params && typeof c.params === 'object') setParams(p => ({ ...p, ...c.params }));
|
||
// maxPoiRange 우선, 없으면 구버전(maxPoiFront) 값으로 마이그레이션.
|
||
if (typeof c.maxPoiRange === 'number') setMaxPoiRange(c.maxPoiRange);
|
||
else if (typeof c.maxPoiFront === 'number') setMaxPoiRange(c.maxPoiFront);
|
||
if (typeof c.smoothHalf === 'number') setSmoothHalf(c.smoothHalf);
|
||
if (typeof c.emaAlpha === 'number') setEmaAlpha(c.emaAlpha);
|
||
if (typeof c.smoothMinAlpha === 'number') setSmoothMinAlpha(c.smoothMinAlpha);
|
||
if (typeof c.smoothSpeedRef === 'number') setSmoothSpeedRef(c.smoothSpeedRef);
|
||
} catch { /* noop */ }
|
||
}, [storeBaseName]);
|
||
useEffect(() => {
|
||
if (!storeBaseName) return;
|
||
const t = setTimeout(() => {
|
||
try {
|
||
localStorage.setItem(`ghivideo:calib:${storeBaseName}`, JSON.stringify({
|
||
params, maxPoiRange, smoothHalf, emaAlpha, smoothMinAlpha, smoothSpeedRef,
|
||
}));
|
||
} catch { /* noop */ }
|
||
}, 800);
|
||
return () => clearTimeout(t);
|
||
}, [storeBaseName, params, maxPoiRange, smoothHalf, emaAlpha, smoothMinAlpha, smoothSpeedRef]);
|
||
|
||
// POI 보정(override: DEM 표고/드래그)도 baseName별 자동 저장·복원 → 새로고침해도 유지.
|
||
const ovLoadedFor = useRef<string | null>(null);
|
||
useEffect(() => {
|
||
if (!storeBaseName || ovLoadedFor.current === storeBaseName) return;
|
||
ovLoadedFor.current = storeBaseName;
|
||
try {
|
||
const raw = localStorage.getItem(`ghivideo:poiov:${storeBaseName}`);
|
||
if (!raw) return;
|
||
const saved = JSON.parse(raw);
|
||
if (saved && typeof saved === 'object') {
|
||
// 폴더 파일 override 위에 localStorage(최근 편집) 병합.
|
||
setPoiOverrides({ ...useGeoStore.getState().poiOverrides, ...saved });
|
||
}
|
||
} catch { /* noop */ }
|
||
}, [storeBaseName, setPoiOverrides]);
|
||
useEffect(() => {
|
||
if (!storeBaseName) return;
|
||
const t = setTimeout(() => {
|
||
try { localStorage.setItem(`ghivideo:poiov:${storeBaseName}`, JSON.stringify(storePoiOverrides)); }
|
||
catch { /* noop */ }
|
||
}, 600);
|
||
return () => clearTimeout(t);
|
||
}, [storeBaseName, storePoiOverrides]);
|
||
|
||
// 측점 (stationOrder 정렬 — 스토어가 이미 정렬해 두지만 방어적으로 동일 순서 보장)
|
||
useEffect(() => {
|
||
allGeoStationsRef.current = [...storeStations]
|
||
.sort((a, b) => stationOrder(a.title) - stationOrder(b.title));
|
||
allPoisRef.current = storePois;
|
||
setGeoDataLoaded(storeStations.length > 0 || storePois.length > 0);
|
||
}, [storeStations, storePois]);
|
||
|
||
// 구조물(교량/터널/구교)을 영상 오버레이에 POI처럼 표시(거리필터 적용).
|
||
// - 영상은 시설등급 무관 모두 표출(신대천교(복) 등 포함). 스테이션바엔 시설등급 필터로 별도 제어.
|
||
// lat/lon 없는 항목(측점기반 배치 등)도 제외.
|
||
useEffect(() => {
|
||
allStructuresRef.current = storeStructures
|
||
.filter((s): s is RouteStructure & { lat: number; lon: number } =>
|
||
s.category != null && OVERLAY_STRUCT_CATEGORIES.has(s.category) &&
|
||
typeof s.lat === 'number' && typeof s.lon === 'number')
|
||
.map(s => ({
|
||
title: s.name,
|
||
category: s.category ?? '구교',
|
||
lat: s.lat, lon: s.lon, z: 0, type: 'poi' as const,
|
||
props: s.props,
|
||
}));
|
||
}, [storeStructures]);
|
||
|
||
// 중심선
|
||
useEffect(() => {
|
||
allCenterlinePointsRef.current = storeCenterline;
|
||
setClDataLoaded(storeCenterline.length > 0);
|
||
}, [storeCenterline]);
|
||
|
||
// 드론 프레임
|
||
useEffect(() => {
|
||
allDroneFramesRef.current = storeFrames;
|
||
setDroneFramesLoaded(storeFrames.length > 0);
|
||
}, [storeFrames]);
|
||
|
||
// ENU 월드 원점 — 스토어 origin 직접 사용 (서버 getWorldOrigin 대체)
|
||
useEffect(() => {
|
||
worldOriginRef.current = storeOrigin
|
||
? { lat: storeOrigin.lat, lon: storeOrigin.lon, alt: storeOrigin.alt }
|
||
: undefined;
|
||
}, [storeOrigin]);
|
||
|
||
// 드론 프레임 이동 평균 (GPS/자세 노이즈 제거)
|
||
// ※ 에지(회전 경계) 보존 적응형 창: 중심 프레임 yaw 와 차이가 임계(YAW_EDGE) 이내인
|
||
// 인접 프레임까지만 좌우로 확장(최대 halfWin). 회전 구간이 직선 평균에 섞이지 않아:
|
||
// - 회전 중엔 창이 좁아 실제 자세를 즉시 추종(지연 없음),
|
||
// - 회전 종료 후엔 회전 프레임을 배제한 채 창이 '한 프레임씩' 부드럽게 재확장(튐 없음).
|
||
// 직선에선 yaw 가 거의 일정 → 풀창(±halfWin)으로 GPS/자세 노이즈 억제.
|
||
// (이전 '변화율 기반 축소'는 회전 종료 시 창이 한꺼번에 재확장돼 회전 프레임이 다시 섞여
|
||
// 한 번 튀는 부작용이 있었음 → 경계 보존 방식으로 교체.)
|
||
const smoothFrame = useCallback((frames: DroneFrameBasic[], i: number, halfWin: number): DroneFrameBasic => {
|
||
let lo = i, hi = i;
|
||
if (frames.length > 1 && halfWin > 0) {
|
||
const YAW_EDGE = 8; // deg — 중심 대비 이 이상 벌어지면 다른(회전) 구간으로 보고 평균 제외
|
||
const yc = frames[i].yaw;
|
||
const near = (idx: number) => Math.abs(((frames[idx].yaw - yc + 540) % 360) - 180) <= YAW_EDGE;
|
||
const loMin = Math.max(0, i - halfWin);
|
||
const hiMax = Math.min(frames.length - 1, i + halfWin);
|
||
while (lo > loMin && near(lo - 1)) lo--;
|
||
while (hi < hiMax && near(hi + 1)) hi++;
|
||
}
|
||
const n = hi - lo + 1;
|
||
let lat = 0, lon = 0, altitude = 0, pitch = 0, roll = 0, sinYaw = 0, cosYaw = 0;
|
||
for (let k = lo; k <= hi; k++) {
|
||
const f = frames[k];
|
||
lat += f.lat; lon += f.lon; altitude += f.altitude;
|
||
pitch += f.pitch; roll += f.roll;
|
||
const yr = f.yaw * Math.PI / 180;
|
||
sinYaw += Math.sin(yr); cosYaw += Math.cos(yr);
|
||
}
|
||
return {
|
||
...frames[i],
|
||
lat: lat / n, lon: lon / n, altitude: altitude / n,
|
||
pitch: pitch / n, roll: roll / n,
|
||
yaw: Math.atan2(sinYaw / n, cosYaw / n) * 180 / Math.PI,
|
||
};
|
||
}, []);
|
||
|
||
// 선/궤적 투영 — RAF에서 매 프레임 보간 포즈로 호출(부드러운 갱신). refs만 읽으므로 stable.
|
||
const buildLines = useCallback((drone: DroneFrameBasic) => {
|
||
const params = paramsRef.current;
|
||
const worldOrigin = worldOriginRef.current;
|
||
const allCL = allCenterlinePointsRef.current;
|
||
const frames = allDroneFramesRef.current;
|
||
const CLIP_Z = 0.1;
|
||
const oc = (px: number, py: number) =>
|
||
(px < -0.1 ? 1 : 0) | (px > 1.1 ? 2 : 0) | (py < -0.1 ? 4 : 0) | (py > 1.1 ? 8 : 0);
|
||
const segFrom = (c1: CameraCoords, c2: CameraCoords): [number, number, number, number] | null => {
|
||
const z1 = c1.Zc, z2 = c2.Zc;
|
||
if (z1 < CLIP_Z && z2 < CLIP_Z) return null;
|
||
let px1: number, py1: number, px2: number, py2: number;
|
||
if (z1 >= CLIP_Z && z2 >= CLIP_Z) {
|
||
const p1 = pixelFromCamera(c1, params), p2 = pixelFromCamera(c2, params);
|
||
px1 = p1.pxRaw; py1 = p1.pyRaw; px2 = p2.pxRaw; py2 = p2.pyRaw;
|
||
} else {
|
||
const t = (CLIP_Z - z1) / (z2 - z1);
|
||
const cClip: CameraCoords = { Xc: c1.Xc + t*(c2.Xc-c1.Xc), Yc: c1.Yc + t*(c2.Yc-c1.Yc), Zc: CLIP_Z };
|
||
if (z1 < CLIP_Z) {
|
||
const p1 = pixelFromCamera(cClip, params), p2 = pixelFromCamera(c2, params);
|
||
px1 = p1.pxRaw; py1 = p1.pyRaw; px2 = p2.pxRaw; py2 = p2.pyRaw;
|
||
} else {
|
||
const p1 = pixelFromCamera(c1, params), p2 = pixelFromCamera(cClip, params);
|
||
px1 = p1.pxRaw; py1 = p1.pyRaw; px2 = p2.pxRaw; py2 = p2.pyRaw;
|
||
}
|
||
}
|
||
if (oc(px1, py1) & oc(px2, py2)) return null;
|
||
return [px1, py1, px2, py2];
|
||
};
|
||
|
||
const centerlineSegs: [number, number, number, number][] = [];
|
||
if (showCenterlineRef.current && allCL.length) {
|
||
let prev: CameraCoords | null = null;
|
||
for (let i = 0; i < allCL.length; i++) {
|
||
const cc = toCameraCoords(drone, allCL[i].lat, allCL[i].lon, allCL[i].z, params, worldOrigin);
|
||
if (prev) { const s = segFrom(prev, cc); if (s) centerlineSegs.push(s); }
|
||
prev = cc;
|
||
}
|
||
}
|
||
|
||
const dronePathPts: [number, number][] = [];
|
||
if (showDronePathRef.current && frames.length) {
|
||
const Z = dronePathZRef.current;
|
||
const BACK = 60, FWD = 1500, STEP = 3, PW = 15, MAX_FWD = 200;
|
||
const cur = currentFrameIdxRef.current;
|
||
const lo = Math.max(0, cur - BACK), hi = Math.min(frames.length - 1, cur + FWD);
|
||
const sm = (i: number): [number, number] => {
|
||
let la = 0, lo2 = 0, n = 0;
|
||
for (let k = Math.max(0, i - PW); k <= Math.min(frames.length - 1, i + PW); k++) { la += frames[k].lat; lo2 += frames[k].lon; n++; }
|
||
return [la / n, lo2 / n];
|
||
};
|
||
for (let i = lo; i <= hi; i += STEP) {
|
||
const [la, lo2] = sm(i);
|
||
const cc = toCameraCoords(drone, la, lo2, Z, params, worldOrigin);
|
||
if ((cc.fwd ?? 0) > MAX_FWD) break;
|
||
if (cc.Zc < CLIP_Z) continue;
|
||
const p = pixelFromCamera(cc, params);
|
||
dronePathPts.push([p.pxRaw, p.pyRaw]);
|
||
}
|
||
}
|
||
return { centerlineSegs, dronePathPts };
|
||
}, []);
|
||
|
||
// 보간 포즈 — estFrame(연속 프레임번호)에서 앞뒤 smoothFrame 선형보간(yaw는 최단각). RAF에서 호출.
|
||
const poseAt = useCallback((estFrame: number): DroneFrameBasic | null => {
|
||
const arr = allDroneFramesRef.current;
|
||
if (!arr.length) return null;
|
||
let lo = 0, hi = arr.length - 1;
|
||
while (lo < hi) { const m = (lo + hi) >> 1; if (arr[m].frame < estFrame) lo = m + 1; else hi = m; }
|
||
const i2 = lo, i1 = Math.max(0, lo - 1);
|
||
const f1 = arr[i1].frame, f2 = arr[i2].frame;
|
||
const frac = f2 > f1 ? Math.min(1, Math.max(0, (estFrame - f1) / (f2 - f1))) : 0;
|
||
const h = smoothHalfRef.current;
|
||
const a = smoothFrame(arr, i1, h), b = smoothFrame(arr, i2, h);
|
||
const L = (x: number, y: number) => x + (y - x) * frac;
|
||
let dy = b.yaw - a.yaw; dy = ((dy + 540) % 360) - 180; // 최단각
|
||
return {
|
||
...a,
|
||
frame: estFrame,
|
||
lat: L(a.lat, b.lat), lon: L(a.lon, b.lon), altitude: L(a.altitude, b.altitude),
|
||
pitch: L(a.pitch, b.pitch), roll: L(a.roll, b.roll),
|
||
yaw: a.yaw + dy * frac,
|
||
};
|
||
}, [smoothFrame]);
|
||
|
||
// 텍스트 사전 계산 — requestIdleCallback으로 백그라운드 실행
|
||
const startLabelPrecompute = useCallback((currentParams: CameraParams, currentSmoothHalf: number, currentMaxRange: number, currentDroneHeight: boolean, currentDroneDrop: number) => {
|
||
const id = ++precomputeIdRef.current;
|
||
// 재계산(기존 맵 보유)이면 옛 맵을 복사해 시작 → 첫 청크(현재 프레임 포함) 후 즉시 노출,
|
||
// 이후 청크가 프레임을 덮어써 전 구간이 새 설정으로 갱신(빈 구간 없음). 초기 로드는 빈 맵.
|
||
const newMap = new Map<number, LabelCache>(labelMapRef.current.size ? labelMapRef.current : undefined);
|
||
const frames = allDroneFramesRef.current;
|
||
const allSt = allGeoStationsRef.current;
|
||
// 모든 POI 표출(철도역 포함) + 영상용 구조물(구교/교량/터널). 철도역은 별도로 스테이션바에도 표출.
|
||
const allPoi = allPoisRef.current.concat(allStructuresRef.current);
|
||
const worldOrigin = worldOriginRef.current;
|
||
const CLIP_Z = 0.1;
|
||
const SMOOTH_HALF = currentSmoothHalf;
|
||
const MAX_RANGE = currentMaxRange; // POI 표시 범위 — 드론 수평 직선거리(m)
|
||
|
||
if (!frames.length) return;
|
||
|
||
const t0 = performance.now();
|
||
const total = frames.length;
|
||
// 현재 재생 프레임부터 순환 처리 → 화면에 보이는 구간을 먼저 계산(재생 즉시 라벨 표시).
|
||
const startIdx = Math.min(total - 1, Math.max(0, currentFrameIdxRef.current));
|
||
// 초기 로드/재계산 모두 첫 청크(현재 프레임 포함) 후 즉시 노출(아래). 재계산은 옛 맵 복사로 시작.
|
||
let processed = 0; // 처리한 프레임 수(0..total)
|
||
let published = false;
|
||
const CHUNK = 200; // 한 번에 처리할 프레임 수
|
||
|
||
const step = () => {
|
||
if (precomputeIdRef.current !== id) return; // 취소됨
|
||
|
||
const end = Math.min(processed + CHUNK, total);
|
||
while (processed < end) {
|
||
const idx = (startIdx + processed) % total; // startIdx→끝→0→… 순환
|
||
processed++;
|
||
const drone = smoothFrame(frames, idx, SMOOTH_HALF);
|
||
|
||
const stationLabels: LabelCache['stationLabels'] = [];
|
||
for (const st of allSt) {
|
||
const snap = nearestCL(st.lat, st.lon);
|
||
const slat = snap?.lat ?? st.lat, slon = snap?.lon ?? st.lon, sz = snap?.z ?? st.z;
|
||
const cc = toCameraCoords(drone, slat, slon, sz, currentParams, worldOrigin);
|
||
if (cc.Zc < CLIP_Z) continue;
|
||
const { pxRaw, pyRaw } = pixelFromCamera(cc, currentParams);
|
||
if (pxRaw < -0.05 || pxRaw > 1.05 || pyRaw < -0.05 || pyRaw > 1.05) continue;
|
||
// 화면좌표 대신 (스냅된)월드좌표 저장 → RAF 가 연속 포즈로 재투영.
|
||
stationLabels.push({ title: st.title, lat: slat, lon: slon, z: sz });
|
||
}
|
||
|
||
// 후보 수집 (거리 dist + 월드좌표 + 지면고도 gz) → 겹침 억제용
|
||
const poiCand: { x: number; y: number; title: string; label: string; category: string; dist: number; lat: number; lon: number; gz: number; compact: { k: string; v: string }[] }[] = [];
|
||
for (const poi of allPoi) {
|
||
// 지면고도 gz: 보정값(DEM/드래그) 또는 최근접 선로표고. RAF 투영 기준값.
|
||
const gz = overridesRef.current[poi.title]?.z ?? nearestCL(poi.lat, poi.lon)?.z ?? poi.z;
|
||
// 가시성 판정용 poiZ: 드론높이 모드면 드론−이격거리, 아니면 gz.
|
||
const poiZ = currentDroneHeight
|
||
? drone.altitude - (currentParams.geoidOffset ?? 0) - currentDroneDrop
|
||
: gz;
|
||
const cc = toCameraCoords(drone, poi.lat, poi.lon, poiZ, currentParams, worldOrigin);
|
||
if (cc.Zc < CLIP_Z) continue;
|
||
// 표시 범위: 드론과의 수평 직선거리(distH)가 범위 안이면 표시, 밖이면 제외.
|
||
if ((cc.distH ?? 0) > MAX_RANGE) continue;
|
||
const { pxRaw, pyRaw } = pixelFromCamera(cc, currentParams);
|
||
if (pxRaw < -0.02 || pxRaw > 1.02 || pyRaw < -0.02 || pyRaw > 1.02) continue;
|
||
poiCand.push({ x: pxRaw, y: pyRaw, title: poi.title, label: guboonOf(poi.props) ?? cleanTitle(poi.title), category: poi.category, dist: cc.distH ?? 0, lat: poi.lat, lon: poi.lon, gz, compact: compactFieldsOf(poi.props) });
|
||
}
|
||
// 겹침 억제: KAKAO_RAIL(철도역/역사) 라벨을 최우선 유지 → 겹치면 일반 POI 가 숨겨진다.
|
||
// 같은 우선순위 내에서는 가까운(dist 작은) 것 우선. 임계 이내면 뒤(낮은우선/먼) 것 숨김.
|
||
const railRank = (cat: string): number => (cat === '철도역' || cat === '역사' ? 0 : 1);
|
||
poiCand.sort((a, b) => railRank(a.category) - railRank(b.category) || a.dist - b.dist);
|
||
// 겹침 시 진행방향 변형 우선: 같은 base 형제(예: 회덕제1가도교 상/하)가 겹치면
|
||
// 하행='(하)' / 상행='(상)' 쪽을 남긴다(드론이 실제 지나는 선로). 무관한 POI엔 영향 없음.
|
||
// 진행방향 변형 태그(닫는 괄호까지) — 하행='(하)', 상행='(상)'. '(하인상)' 등은 제외.
|
||
const dirTag = routeDirRef.current === '상' ? '(상)' : routeDirRef.current === '하' ? '(하)' : '';
|
||
const coordKey = (c: { lat: number; lon: number }): string => `${c.lat.toFixed(6)},${c.lon.toFixed(6)}`;
|
||
const accepted: typeof poiCand = [];
|
||
if (!overlapExcludeRef.current) {
|
||
// 겹침제외 OFF → 겹침 억제 없이 모든 POI 표시(동일좌표는 세로 행으로 쌓임).
|
||
accepted.push(...poiCand);
|
||
} else {
|
||
// 겹침제외 ON → 겹친 것(동일좌표 형제 포함) 1개만. 형제면 진행방향((하)/(상))을 우선.
|
||
for (const c of poiCand) {
|
||
let overlapIdx = -1;
|
||
for (let i = 0; i < accepted.length; i++) {
|
||
const k = accepted[i];
|
||
if (Math.abs(k.x - c.x) < POI_MERGE_X && Math.abs(k.y - c.y) < POI_MERGE_Y) { overlapIdx = i; break; }
|
||
}
|
||
if (overlapIdx < 0) { accepted.push(c); continue; }
|
||
// 형제 + c가 방향 변형, 기존은 아니면 c로 교체.
|
||
const k = accepted[overlapIdx];
|
||
if (dirTag && baseStruct(c.title) === baseStruct(k.title) && c.title.includes(dirTag) && !k.title.includes(dirTag)) {
|
||
accepted[overlapIdx] = c;
|
||
}
|
||
}
|
||
}
|
||
// 동일좌표 그룹에 행 번호 부여(위→아래 순서대로). 4개면 각 라벨이 4행으로 안 겹치게 배치.
|
||
const groupCount = new Map<string, number>();
|
||
for (const a of accepted) groupCount.set(coordKey(a), (groupCount.get(coordKey(a)) ?? 0) + 1);
|
||
const groupSeen = new Map<string, number>();
|
||
// 통과분만 월드좌표로 저장(RAF 가 재투영). labelRow/Count = 동일좌표 라벨 세로 배치.
|
||
const poiMarkers: LabelCache['poiMarkers'] = accepted.map(c => {
|
||
const ck = coordKey(c);
|
||
const row = groupSeen.get(ck) ?? 0;
|
||
groupSeen.set(ck, row + 1);
|
||
return { title: c.title, label: c.label, category: c.category, lat: c.lat, lon: c.lon, gz: c.gz, compact: c.compact, labelRow: row, labelRowCount: groupCount.get(ck) ?? 1 };
|
||
});
|
||
|
||
newMap.set(drone.frame, { stationLabels, poiMarkers });
|
||
}
|
||
|
||
// 초기 로드/재계산 모두: 첫 청크(현재 프레임 포함) 채우면 즉시 노출 → 토글 즉시 반영.
|
||
// 재계산은 옛 맵 복사로 시작했으므로 빈 구간 없이 나머지 청크가 덮어쓰며 갱신된다.
|
||
if (!published) { labelMapRef.current = newMap; published = true; }
|
||
|
||
if (processed < total) {
|
||
requestIdleCallback(step, { timeout: 200 });
|
||
} else {
|
||
console.log(
|
||
`[labelMap] complete ${(performance.now() - t0).toFixed(0)}ms | ${frames.length} frames × ${allSt.length + allPoi.length} items`
|
||
);
|
||
}
|
||
};
|
||
|
||
// 첫 청크(현재 프레임 포함)는 동기 실행 → 토글/재계산이 '정지 중에도' 즉시 반영.
|
||
// (idle 대기 시 정지 상태에서 갱신이 지연/누락되던 문제 해결.) 나머지는 step 내부에서 idle 로 이어감.
|
||
step();
|
||
}, [nearestCL]);
|
||
|
||
// 모든 데이터 로드 완료 시 사전 계산 시작
|
||
useEffect(() => {
|
||
if (!droneFramesLoaded || !geoDataLoaded || !clDataLoaded) return;
|
||
startLabelPrecompute(paramsRef.current, smoothHalf, maxPoiRange, poiDroneHeight, droneHeightDrop);
|
||
}, [droneFramesLoaded, geoDataLoaded, clDataLoaded, startLabelPrecompute, smoothHalf, maxPoiRange, poiDroneHeight, droneHeightDrop, poiOverlapExclude]);
|
||
|
||
// params / smoothHalf / maxPoiDist 변경 시 사전 계산 재시작 (500ms debounce)
|
||
useEffect(() => {
|
||
if (!droneFramesLoaded || !geoDataLoaded || !clDataLoaded) return;
|
||
const timer = setTimeout(() => startLabelPrecompute(params, smoothHalf, maxPoiRange, poiDroneHeight, droneHeightDrop), 500);
|
||
return () => clearTimeout(timer);
|
||
}, [params, smoothHalf, maxPoiRange, poiDroneHeight, droneHeightDrop, storePois, storeStructures, droneFramesLoaded, geoDataLoaded, clDataLoaded, startLabelPrecompute, poiOverlapExclude]);
|
||
|
||
// 현재 재생 시간 → 드론 프레임 ref 갱신
|
||
useEffect(() => {
|
||
// 나침반이 visible 무관 갱신되도록 visible 게이트 제거(프레임 탐색은 가벼움).
|
||
if (!droneFramesLoaded) return;
|
||
const frames = allDroneFramesRef.current;
|
||
if (!frames.length) return;
|
||
let best = frames[0], bestIdx = 0, bestD = Math.abs((best.frame ?? 0) / VIDEO_FPS - currentTime);
|
||
for (let i = 0; i < frames.length; i++) {
|
||
const d = Math.abs(frames[i].frame / VIDEO_FPS - currentTime);
|
||
if (d < bestD) { bestD = d; best = frames[i]; bestIdx = i; }
|
||
if (bestD < 1 / VIDEO_FPS / 2) break;
|
||
}
|
||
currentFrameNumRef.current = best.frame;
|
||
currentFrameIdxRef.current = bestIdx;
|
||
currentTimeSecRef.current = currentTime;
|
||
timeUpdateWallRef.current = performance.now();
|
||
if (currentDroneFrameRef.current?.frame !== best.frame) {
|
||
currentDroneFrameRef.current = best;
|
||
setPanelDroneFrame(best);
|
||
}
|
||
}, [currentTime, droneFramesLoaded]);
|
||
|
||
// 중심선 + 나침반 캐시 빌드 (per-frame, 텍스트 계산 없음)
|
||
useEffect(() => {
|
||
// 나침반(effectiveYaw/hFovRad)은 측점선 visible 과 무관하게 항상 갱신.
|
||
const cur = currentDroneFrameRef.current;
|
||
if (cur) {
|
||
const p = paramsRef.current;
|
||
const fr = allDroneFramesRef.current;
|
||
const d = fr.length
|
||
? smoothFrame(fr, currentFrameIdxRef.current, smoothHalfRef.current)
|
||
: cur;
|
||
compassRef.current = {
|
||
effectiveYaw: d.yaw + p.yawOffset,
|
||
hFovRad: 2 * Math.atan((p.sensorW ?? 36) / (2 * p.focalLen)),
|
||
};
|
||
} else {
|
||
compassRef.current = null;
|
||
}
|
||
|
||
// 선/궤적은 RAF가 매 프레임 직접 투영하므로 여기선 캐시 '준비됨' 표식 + 메타데이터만.
|
||
if (!currentDroneFrameRef.current) { renderCacheRef.current = null; return; }
|
||
const frames = allDroneFramesRef.current;
|
||
const drone = frames.length
|
||
? smoothFrame(frames, currentFrameIdxRef.current, smoothHalfRef.current)
|
||
: currentDroneFrameRef.current;
|
||
const params = paramsRef.current;
|
||
renderCacheRef.current = {
|
||
effectiveYaw: drone.yaw + params.yawOffset,
|
||
hFovRad: 2 * Math.atan((params.sensorW ?? 36) / (2 * params.focalLen)),
|
||
clCount: allCenterlinePointsRef.current.length,
|
||
poiCount: allPoisRef.current.length,
|
||
};
|
||
}, [panelDroneFrame, params]);
|
||
|
||
// ResizeObserver
|
||
useEffect(() => {
|
||
const canvas = canvasRef.current;
|
||
if (!canvas) return;
|
||
const parent = canvas.parentElement;
|
||
if (!parent) return;
|
||
const ro = new ResizeObserver(entries => {
|
||
for (const e of entries) {
|
||
canvas.width = Math.round(e.contentRect.width);
|
||
canvas.height = Math.round(e.contentRect.height);
|
||
canvasSizeRef.current = { w: canvas.width, h: canvas.height };
|
||
}
|
||
});
|
||
ro.observe(parent);
|
||
canvas.width = parent.clientWidth;
|
||
canvas.height = parent.clientHeight;
|
||
canvasSizeRef.current = { w: canvas.width, h: canvas.height };
|
||
return () => ro.disconnect();
|
||
}, []);
|
||
|
||
// RAF 렌더 루프 — 계산 없이 캐시/Map 조회만
|
||
useEffect(() => {
|
||
const canvas = canvasRef.current;
|
||
if (!canvas) return;
|
||
let rafId = 0;
|
||
|
||
const draw = () => {
|
||
rafId = requestAnimationFrame(draw);
|
||
const ctx = canvas.getContext('2d');
|
||
if (!ctx) return;
|
||
const { w: W, h: H } = canvasSizeRef.current;
|
||
ctx.clearRect(0, 0, W, H);
|
||
|
||
// 영상 첫 프레임이 표시되기 전엔 오버레이(라벨/선/나침반)를 그리지 않음 → 라벨이 영상보다 먼저 뜨는 것 방지.
|
||
if (!videoReadyRef.current) return;
|
||
|
||
// object-fit:cover 정렬 — 영상은 컨테이너를 비율유지·크롭하며 채우므로(index.css), 오버레이도
|
||
// 같은 cover 사각형에 매핑해야 영상과 일치한다. 영상 해상도(vW,vH) 없으면 컨테이너 기준 폴백.
|
||
const { w: vW, h: vH } = videoSizeRef.current;
|
||
let dispW = W, dispH = H, offX = 0, offY = 0;
|
||
if (vW > 0 && vH > 0) {
|
||
const s = Math.max(W / vW, H / vH); // cover: 더 큰 배율로 채움(넘침=크롭)
|
||
dispW = vW * s; dispH = vH * s;
|
||
offX = (W - dispW) / 2; offY = (H - dispH) / 2; // 음수 = 화면 밖(크롭)
|
||
}
|
||
coverRef.current = { offX, offY, dispW, dispH, W, H };
|
||
const vx = (nx: number) => offX + nx * dispW; // 정규(0~1, 영상프레임) → 화면 px
|
||
const vy = (ny: number) => offY + ny * dispH;
|
||
|
||
// 이번 프레임 라벨 히트박스(아이콘+글자, 화면 px) 수집 → 끝에서 labelHitRef 갱신.
|
||
const hitBoxes: { kind: 'poi' | 'station'; title: string; x0: number; y0: number; x1: number; y1: number }[] = [];
|
||
|
||
const cache = renderCacheRef.current;
|
||
if (cache) {
|
||
|
||
// 부드러운 시간(timeRef, 60fps 보간) → 연속 프레임번호. 선/궤적·라벨 공통 사용.
|
||
const estTime = timeRef
|
||
? timeRef.current
|
||
: currentTimeSecRef.current + (performance.now() - timeUpdateWallRef.current) / 1000;
|
||
const estFrame = estTime * VIDEO_FPS;
|
||
|
||
// 연속 보간 포즈(라인·라벨 공통) — 매 RAF 프레임 직접 투영해 부드럽게.
|
||
const dronePose = poseAt(estFrame) ?? (allDroneFramesRef.current.length
|
||
? smoothFrame(allDroneFramesRef.current, currentFrameIdxRef.current, smoothHalfRef.current)
|
||
: currentDroneFrameRef.current!);
|
||
const lines = buildLines(dronePose);
|
||
|
||
// 나침반 미니맵(heading-up) 회전 — 영상과 즉시 동기. dronePose.yaw 는 ±smoothHalf(기본 60fr ≈2s)
|
||
// 평활이라 회전 시 지연 → 가벼운 평활(±3fr)의 yaw 를 따로 보간해 사용. 360° 누적 언랩(점프 방지).
|
||
if (minimapRef.current) {
|
||
const arr = allDroneFramesRef.current;
|
||
let rawYaw = dronePose.yaw;
|
||
if (arr.length) {
|
||
let lo = 0, hi = arr.length - 1;
|
||
while (lo < hi) { const m = (lo + hi) >> 1; if (arr[m].frame < estFrame) lo = m + 1; else hi = m; }
|
||
const i2 = lo, i1 = Math.max(0, lo - 1);
|
||
const f1 = arr[i1].frame, f2 = arr[i2].frame;
|
||
const frac = f2 > f1 ? Math.min(1, Math.max(0, (estFrame - f1) / (f2 - f1))) : 0;
|
||
const a = smoothFrame(arr, i1, 3), b = smoothFrame(arr, i2, 3); // ±3fr 가벼운 평활
|
||
let dy = b.yaw - a.yaw; dy = ((dy + 540) % 360) - 180;
|
||
rawYaw = a.yaw + dy * frac;
|
||
}
|
||
const target = -(rawYaw + paramsRef.current.yawOffset);
|
||
const delta = (((target - minimapRotRef.current) % 360) + 540) % 360 - 180;
|
||
minimapRotRef.current += delta;
|
||
minimapRef.current.style.setProperty('--rot', `${minimapRotRef.current}deg`);
|
||
}
|
||
|
||
// 선로 중심선 (선형) — 독립 토글
|
||
if (showCenterlineRef.current && lines.centerlineSegs.length > 0) {
|
||
ctx.strokeStyle = 'rgba(255,50,50,0.85)';
|
||
ctx.lineWidth = 3;
|
||
ctx.setLineDash([]);
|
||
ctx.beginPath();
|
||
for (const [px1, py1, px2, py2] of lines.centerlineSegs) {
|
||
ctx.moveTo(vx(px1), vy(py1));
|
||
ctx.lineTo(vx(px2), vy(py2));
|
||
}
|
||
ctx.stroke();
|
||
}
|
||
|
||
// 드론 경로 — 하나의 연속 폴리라인(둥근 연결)으로 부드럽게 + 작은 점. 독립 토글 + 투명도
|
||
const dpa = dronePathAlphaRef.current;
|
||
const pts = lines.dronePathPts;
|
||
if (pts.length > 1) {
|
||
ctx.lineJoin = 'round';
|
||
ctx.lineCap = 'round';
|
||
ctx.setLineDash([]);
|
||
// 어두운 밑선(대비) → 배경 위에서도 끊김 없이 잘 보임
|
||
ctx.strokeStyle = `rgba(0,0,0,${0.35 * dpa})`;
|
||
ctx.lineWidth = 5;
|
||
ctx.beginPath();
|
||
ctx.moveTo(vx(pts[0][0]), vy(pts[0][1]));
|
||
for (let k = 1; k < pts.length; k++) ctx.lineTo(vx(pts[k][0]), vy(pts[k][1]));
|
||
ctx.stroke();
|
||
// 본선
|
||
ctx.strokeStyle = `rgba(80,200,255,${dpa})`;
|
||
ctx.lineWidth = 3;
|
||
ctx.beginPath();
|
||
ctx.moveTo(vx(pts[0][0]), vy(pts[0][1]));
|
||
for (let k = 1; k < pts.length; k++) ctx.lineTo(vx(pts[k][0]), vy(pts[k][1]));
|
||
ctx.stroke();
|
||
}
|
||
// 측정점 dot — 작고 듬성하게(선 흐름 방해 X)
|
||
if (pts.length > 0) {
|
||
ctx.fillStyle = `rgba(255,230,90,${dpa})`;
|
||
for (let k = 0; k < pts.length; k += 4) {
|
||
ctx.beginPath();
|
||
ctx.arc(vx(pts[k][0]), vy(pts[k][1]), 1.8, 0, Math.PI*2);
|
||
ctx.fill();
|
||
}
|
||
}
|
||
|
||
visStructRef.current.clear(); // 매 프레임 가시 컴팩트-구조물 재수집(라벨 off 시 빈 상태)
|
||
if (visibleRef.current) {
|
||
// 가시집합만 precompute 에서 가져오고(어떤 라벨을 그릴지), 좌표는 RAF 가 연속 포즈로 직접
|
||
// 투영 → 라인처럼 부드럽게 이동(정수프레임 보간/프레임키 의존 제거).
|
||
const baseFrame = Math.floor(estFrame);
|
||
const labelsA = labelMapRef.current.get(baseFrame) ?? labelMapRef.current.get(currentFrameNumRef.current);
|
||
|
||
if (labelsA) {
|
||
const α = emaAlphaRef.current;
|
||
const params = paramsRef.current;
|
||
const worldOrigin = worldOriginRef.current;
|
||
const CLIP_Z = 0.1;
|
||
const seenSt = new Set<string>();
|
||
const seenPoi = new Set<string>();
|
||
|
||
// 측점 라벨 — 연속 포즈로 직접 투영 → 이상치 거부 → EMA.
|
||
// 선형(중심선) 토글과 함께 ON/OFF (빨간 스테이션 라벨 = 선형의 일부).
|
||
if (showCenterlineRef.current) {
|
||
ctx.font = 'bold 18px monospace';
|
||
ctx.textAlign = 'left';
|
||
ctx.textBaseline = 'middle';
|
||
labelsA.stationLabels.forEach((stA) => {
|
||
const cc = toCameraCoords(dronePose, stA.lat, stA.lon, stA.z, params, worldOrigin);
|
||
if (cc.Zc < CLIP_Z) return;
|
||
const { pxRaw, pyRaw } = pixelFromCamera(cc, params);
|
||
const prevDst = displayedStRef.current.get(stA.title);
|
||
const d = smoothStep(prevDst, pxRaw, pyRaw, α, smoothMinAlphaRef.current, smoothSpeedRefRef.current);
|
||
seenSt.add(stA.title);
|
||
// 픽셀 히스테리시스: 0.75px 이상 움직일 때만 정수 위치 갱신 → 저속(1배속) 반올림 깜빡임 제거.
|
||
const sx0 = vx(d.x), sy0 = vy(d.y);
|
||
const x = (prevDst?.rx !== undefined && Math.abs(sx0 - prevDst.rx) <= 0.75) ? prevDst.rx : Math.round(sx0);
|
||
const y = (prevDst?.ry !== undefined && Math.abs(sy0 - prevDst.ry) <= 0.75) ? prevDst.ry : Math.round(sy0);
|
||
d.rx = x; d.ry = y;
|
||
displayedStRef.current.set(stA.title, d);
|
||
// 마커 선
|
||
ctx.strokeStyle = 'rgba(255,100,100,0.95)'; ctx.lineWidth = 2.5;
|
||
ctx.beginPath(); ctx.moveTo(x, y-10); ctx.lineTo(x, y+10); ctx.stroke();
|
||
// 텍스트 테두리
|
||
const lx = Math.max(2, x + 8);
|
||
ctx.strokeStyle = 'rgba(0,0,0,0.85)'; ctx.lineWidth = 4;
|
||
ctx.lineJoin = 'round';
|
||
ctx.strokeText(cleanTitle(stA.title), lx, y);
|
||
// 텍스트 본문
|
||
ctx.fillStyle = 'rgba(255,200,200,1.0)';
|
||
ctx.fillText(cleanTitle(stA.title), lx, y);
|
||
// 히트박스(마커 선 ~ 글자 끝)
|
||
const tw = ctx.measureText(cleanTitle(stA.title)).width;
|
||
hitBoxes.push({ kind: 'station', title: stA.title, x0: x - 8, y0: y - 12, x1: lx + tw + 2, y1: y + 12 });
|
||
});
|
||
}
|
||
|
||
// POI 마커 — 연속 포즈로 직접 투영 → 이상치 거부 → EMA
|
||
ctx.font = 'bold 20px sans-serif';
|
||
labelsA.poiMarkers.forEach((poiA) => {
|
||
// 드론높이 모드면 연속 포즈 고도−이격거리, 아니면 저장된 지면고도 gz.
|
||
const pz = poiDroneHeightRef.current
|
||
? dronePose.altitude - (params.geoidOffset ?? 0) - droneHeightDropRef.current
|
||
: poiA.gz;
|
||
const cc = toCameraCoords(dronePose, poiA.lat, poiA.lon, pz, params, worldOrigin);
|
||
if (cc.Zc < CLIP_Z) return;
|
||
const { pxRaw, pyRaw } = pixelFromCamera(cc, params);
|
||
const prevDpoi = displayedPoiRef.current.get(poiA.title);
|
||
const d = smoothStep(prevDpoi, pxRaw, pyRaw, α, smoothMinAlphaRef.current, smoothSpeedRefRef.current);
|
||
seenPoi.add(poiA.title);
|
||
// 픽셀 히스테리시스: 0.75px 이상 움직일 때만 정수 위치 갱신 → 저속(1배속) 깜빡임 제거.
|
||
const psx0 = vx(d.x), psy0 = vy(d.y);
|
||
const px = (prevDpoi?.rx !== undefined && Math.abs(psx0 - prevDpoi.rx) <= 0.75) ? prevDpoi.rx : Math.round(psx0);
|
||
const py = (prevDpoi?.ry !== undefined && Math.abs(psy0 - prevDpoi.ry) <= 0.75) ? prevDpoi.ry : Math.round(psy0);
|
||
d.rx = px; d.ry = py;
|
||
displayedPoiRef.current.set(poiA.title, d);
|
||
const r = 10;
|
||
// 십자 마커
|
||
ctx.strokeStyle = '#64c8ff'; ctx.lineWidth = 2.5;
|
||
ctx.beginPath();
|
||
ctx.moveTo(px-r, py); ctx.lineTo(px+r, py);
|
||
ctx.moveTo(px, py-r); ctx.lineTo(px, py+r);
|
||
ctx.stroke();
|
||
// 이모지 + 텍스트
|
||
const emoji = CATEGORY_EMOJI[poiA.category] ?? '📌';
|
||
// 라벨: 속성에 '구분'이 있으면 그 값(원문 그대로), 없으면 cleanTitle(title). poiCand에서 확정.
|
||
const label = `${emoji} ${poiA.label}`;
|
||
const lx = Math.max(2, px + 14);
|
||
// 동일좌표 라벨 세로 배치: 행 중앙을 십자에 맞춰 위→아래로 안 겹치게 오프셋.
|
||
const ROW_H = 24;
|
||
const labelY = py + (poiA.labelRow - (poiA.labelRowCount - 1) / 2) * ROW_H;
|
||
ctx.strokeStyle = 'rgba(0,0,0,0.85)'; ctx.lineWidth = 4;
|
||
ctx.lineJoin = 'round';
|
||
ctx.textBaseline = 'middle';
|
||
ctx.strokeText(label, lx, labelY);
|
||
ctx.fillStyle = '#64c8ff';
|
||
ctx.fillText(label, lx, labelY);
|
||
// 히트박스(십자 마커 ~ 글자 끝) — 아이콘·글자 어디를 눌러도 선택되게. (라벨 행 위치 기준)
|
||
const tw = ctx.measureText(label).width;
|
||
hitBoxes.push({ kind: 'poi', title: poiA.title, x0: px - r, y0: labelY - 13, x1: lx + tw + 2, y1: labelY + 13 });
|
||
ctx.textBaseline = 'alphabetic';
|
||
// 컴팩트 팝업 자동표시: 단일 라벨만(동일좌표 다중행은 팝업 겹침 방지 위해 제외 → 클릭 시 개별 표시).
|
||
if (poiA.compact.length && poiA.labelRowCount <= 1) visStructRef.current.set(poiA.title, { category: poiA.category, compact: poiA.compact });
|
||
});
|
||
|
||
// 이번 프레임에 안 보인 라벨의 표시상태 제거 → 재등장 시 새 위치에서 시작
|
||
// (오래된 좌표가 남아 이상치 거부로 늦게 뜨는 것 방지)
|
||
for (const k of displayedStRef.current.keys()) if (!seenSt.has(k)) displayedStRef.current.delete(k);
|
||
for (const k of displayedPoiRef.current.keys()) if (!seenPoi.has(k)) displayedPoiRef.current.delete(k);
|
||
}
|
||
} // end if(visible) — 측점/POI 라벨
|
||
} // end if(cache) — 중심선/드론경로/측점/POI
|
||
|
||
// 편집/FOV 보정 모드: 드래그 중 마커 피드백 (마우스 따라다님)
|
||
if ((editModeRef.current || fovModeRef.current) && dragRef.current && dragPosRef.current) {
|
||
const dx = vx(dragPosRef.current.x), dy = vy(dragPosRef.current.y);
|
||
ctx.strokeStyle = '#ffe14d'; ctx.lineWidth = 2;
|
||
ctx.beginPath(); ctx.arc(dx, dy, 14, 0, Math.PI * 2); ctx.stroke();
|
||
ctx.beginPath();
|
||
ctx.moveTo(dx - 22, dy); ctx.lineTo(dx + 22, dy);
|
||
ctx.moveTo(dx, dy - 22); ctx.lineTo(dx, dy + 22);
|
||
ctx.stroke();
|
||
ctx.fillStyle = '#ffe14d'; ctx.font = 'bold 13px sans-serif';
|
||
ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
|
||
ctx.strokeStyle = 'rgba(0,0,0,0.85)'; ctx.lineWidth = 4; ctx.lineJoin = 'round';
|
||
ctx.strokeText(dragRef.current.title, dx + 26, dy);
|
||
ctx.fillText(dragRef.current.title, dx + 26, dy);
|
||
ctx.textAlign = 'left'; ctx.textBaseline = 'alphabetic';
|
||
}
|
||
|
||
|
||
|
||
// 라벨 속성 팝업 위치 — 해당 라벨의 현재 표시좌표를 따라 매 프레임 갱신(재생 중 이동).
|
||
// 라벨이 화면에서 사라지면(필터/화면밖) 팝업 숨김, 다시 보이면 표시.
|
||
if (popupElsRef.current.size) {
|
||
const toRemove: string[] = [];
|
||
popupElsRef.current.forEach((el, id) => {
|
||
const sep = id.indexOf(':');
|
||
const kind = id.slice(0, sep), title = id.slice(sep + 1);
|
||
const disp = (kind === 'poi' ? displayedPoiRef.current : displayedStRef.current).get(title);
|
||
// 라벨이 없거나(필터됨) 보이는 컨테이너 밖(cover 크롭 포함)이면 '사라짐'으로 간주.
|
||
const sx = disp ? offX + disp.x * dispW : 0, sy = disp ? offY + disp.y * dispH : 0;
|
||
const off = !disp || sx < -16 || sx > W + 16 || sy < -16 || sy > H + 16;
|
||
if (off) {
|
||
const m = (popupMissRef.current.get(id) ?? 0) + 1;
|
||
popupMissRef.current.set(id, m);
|
||
if (m >= 4) toRemove.push(id); // 4프레임(~0.07s) 연속 밖이면 제거(라벨과 함께 사라짐)
|
||
else el.style.visibility = 'hidden';
|
||
return;
|
||
}
|
||
popupMissRef.current.set(id, 0);
|
||
const ph = el.offsetHeight || 120;
|
||
const pw = el.offsetWidth || 240;
|
||
// 기본은 라벨 '아래'. 아래 공간이 부족하면(화면 하단 근접) 라벨 '위'로 플립 →
|
||
// 라벨과 겹치거나 화면 밖으로 잘리는 것을 방지. 위로도 부족하면 화면 안으로 클램프.
|
||
const GAP = 16;
|
||
const tx = Math.min(Math.max(8, sx - 10), Math.max(8, W - pw - 4));
|
||
let ty = sy + GAP; // 아래
|
||
if (ty + ph > H - 2) {
|
||
const aboveTy = sy - GAP - ph; // 위로 플립 (팝업 하단이 라벨 위 ~GAP 지점)
|
||
ty = aboveTy >= 2 ? aboveTy : Math.max(2, H - 2 - ph);
|
||
}
|
||
el.style.visibility = 'visible';
|
||
const pp = popupPosRef.current.get(id);
|
||
const PA = 0.18;
|
||
// flip(위↔아래) 같은 큰 점프는 스냅 → 라벨 위를 천천히 통과하는 잔상 방지.
|
||
const bigJump = !!pp && Math.abs(ty - pp.y) > ph * 0.7;
|
||
const nx = pp ? pp.x + (tx - pp.x) * PA : tx;
|
||
const ny = (pp && !bigJump) ? pp.y + (ty - pp.y) * PA : ty;
|
||
popupPosRef.current.set(id, { x: nx, y: ny });
|
||
el.style.left = `${Math.round(nx)}px`;
|
||
el.style.top = `${Math.round(ny)}px`;
|
||
});
|
||
if (toRemove.length) {
|
||
toRemove.forEach(id => { popupMissRef.current.delete(id); popupPosRef.current.delete(id); });
|
||
setInfoPopups(prev => prev.filter(p => !toRemove.includes(p.id)));
|
||
}
|
||
}
|
||
|
||
labelHitRef.current = hitBoxes; // 이번 프레임 라벨 히트박스 확정(라벨 없으면 빈 배열)
|
||
|
||
// 범례(선로중심선/지장물 개수)는 좌상단 카메라파라미터·스테이션맵과 겹쳐 제거함.
|
||
};
|
||
|
||
rafId = requestAnimationFrame(draw);
|
||
return () => cancelAnimationFrame(rafId);
|
||
}, []);
|
||
|
||
// ── POI 편집(드래그) ───────────────────────────────────────────────────────
|
||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
|
||
// 현재 프레임의 평활 드론 자세 (역투영 기준)
|
||
const getSmoothedDrone = useCallback((): DroneFrameBasic | null => {
|
||
const frames = allDroneFramesRef.current;
|
||
if (frames.length) return smoothFrame(frames, currentFrameIdxRef.current, smoothHalfRef.current);
|
||
return currentDroneFrameRef.current;
|
||
}, [smoothFrame]);
|
||
|
||
// 화면 클릭좌표(client px) → 영상프레임 정규좌표(0~1). object-fit:cover 역변환(coverRef).
|
||
const clientToVideoNorm = useCallback((clientX: number, clientY: number): { x: number; y: number } => {
|
||
const canvas = canvasRef.current;
|
||
if (!canvas) return { x: 0, y: 0 };
|
||
const rect = canvas.getBoundingClientRect();
|
||
const c = coverRef.current;
|
||
const cxpx = ((clientX - rect.left) / rect.width) * (c.W || rect.width);
|
||
const cypx = ((clientY - rect.top) / rect.height) * (c.H || rect.height);
|
||
return {
|
||
x: c.dispW ? (cxpx - c.offX) / c.dispW : (clientX - rect.left) / rect.width,
|
||
y: c.dispH ? (cypx - c.offY) / c.dispH : (clientY - rect.top) / rect.height,
|
||
};
|
||
}, []);
|
||
|
||
// 라벨 히트테스트 — 아이콘+글자 바운딩박스(화면 px) 안인지. 정규좌표(영상프레임) 입력을 px로 변환.
|
||
const hitTestLabel = useCallback((nx: number, ny: number): { title: string; kind: 'poi' | 'station' } | null => {
|
||
const c = coverRef.current;
|
||
const cxpx = c.offX + nx * c.dispW, cypx = c.offY + ny * c.dispH;
|
||
const boxes = labelHitRef.current;
|
||
for (let i = boxes.length - 1; i >= 0; i--) { // 나중에 그린(위) 라벨 우선
|
||
const b = boxes[i];
|
||
if (cxpx >= b.x0 && cxpx <= b.x1 && cypx >= b.y0 && cypx <= b.y1) return { title: b.title, kind: b.kind };
|
||
}
|
||
return null;
|
||
}, []);
|
||
|
||
const onPoiPointerDown = useCallback((e: React.PointerEvent<HTMLCanvasElement>) => {
|
||
const { x: nx, y: ny } = clientToVideoNorm(e.clientX, e.clientY);
|
||
|
||
// 편집/세로화각 보정: POI 선택 + 드래그
|
||
if (editModeRef.current || fovModeRef.current) {
|
||
const canvas = canvasRef.current; if (!canvas) return;
|
||
const hit = hitTestLabel(nx, ny);
|
||
if (!hit) return;
|
||
const poi = allPoisRef.current.concat(allStructuresRef.current).find(p => p.title === hit.title);
|
||
if (!poi) return;
|
||
const baseZ = nearestCL(poi.lat, poi.lon)?.z ?? poi.z;
|
||
const poiZ = overridesRef.current[hit.title] ? poi.z : baseZ;
|
||
setSelPoi(hit.title); setSelZ(poiZ); setSelZBase(baseZ);
|
||
dragRef.current = { title: hit.title, lat: poi.lat, lon: poi.lon, z0: poiZ, sx: nx, sy: ny };
|
||
dragPosRef.current = { x: nx, y: ny };
|
||
setDragTitle(hit.title);
|
||
try { canvas.setPointerCapture(e.pointerId); } catch { /* noop */ }
|
||
return;
|
||
}
|
||
|
||
// 기본: 캔버스가 모든 클릭을 받음 →
|
||
// - 라벨 클릭: 팝업 있으면 전체↔컴팩트 토글, 없으면 전체 팝업 추가.
|
||
// - 빈 곳 클릭: 펼쳐진/수동 팝업 정리(컴팩트로), 없으면 재생/정지 토글.
|
||
const hit = hitTestLabel(nx, ny);
|
||
if (!hit) {
|
||
const cur = infoPopupsRef.current;
|
||
if (cur.some(p => p.expanded || !p.auto)) {
|
||
setInfoPopups(prev => prev.filter(p => p.auto).map(p => ({ ...p, expanded: false })));
|
||
} else {
|
||
onTogglePlayRef.current?.();
|
||
}
|
||
return;
|
||
}
|
||
const id = `${hit.kind}:${hit.title}`;
|
||
// 이미 팝업이 있으면(자동 컴팩트 포함) 클릭으로 전체↔컴팩트 토글.
|
||
if (infoPopupsRef.current.some(p => p.id === id)) {
|
||
setInfoPopups(prev => prev.map(p => p.id === id ? { ...p, expanded: !p.expanded } : p));
|
||
return;
|
||
}
|
||
const obj = hit.kind === 'poi'
|
||
? allPoisRef.current.concat(allStructuresRef.current).find(p => p.title === hit.title)
|
||
: allGeoStationsRef.current.find(s => s.title === hit.title);
|
||
if (!obj) return;
|
||
const groundZ = overridesRef.current[hit.title]?.z ?? nearestCL(obj.lat, obj.lon)?.z ?? obj.z;
|
||
const drone = getSmoothedDrone();
|
||
const dist = drone
|
||
? toCameraCoords(drone, obj.lat, obj.lon, groundZ, paramsRef.current, worldOriginRef.current).distH ?? null
|
||
: null;
|
||
const disp = (hit.kind === 'poi' ? displayedPoiRef.current : displayedStRef.current).get(hit.title)!;
|
||
const c = coverRef.current;
|
||
const compact = compactFieldsOf(obj.props);
|
||
setInfoPopups(prev => [...prev, {
|
||
id, kind: hit.kind, title: hit.title, category: obj.category, lat: obj.lat, lon: obj.lon, z: groundZ, dist,
|
||
sx: c.offX + disp.x * c.dispW, sy: c.offY + disp.y * c.dispH, props: obj.props,
|
||
compact, auto: false, expanded: compact.length === 0, // 컴팩트필드 없으면 바로 전체
|
||
}]);
|
||
}, [nearestCL, clientToVideoNorm, getSmoothedDrone, hitTestLabel]);
|
||
|
||
const onPoiPointerMove = useCallback((e: React.PointerEvent<HTMLCanvasElement>) => {
|
||
if (!dragRef.current) return;
|
||
dragPosRef.current = clientToVideoNorm(e.clientX, e.clientY);
|
||
}, [clientToVideoNorm]);
|
||
|
||
// 캔버스 입력/커서 — Video.js controls:false 라 영상은 클릭을 처리 안 함 → 캔버스가 모든 클릭을
|
||
// 받아 직접 분기(라벨=팝업 / 빈 곳=팝업닫기 or 재생토글). 커서는 라벨 위에서만 손모양.
|
||
useEffect(() => {
|
||
const canvas = canvasRef.current; if (!canvas) return;
|
||
const parent = canvas.parentElement; if (!parent) return;
|
||
// 영상이 준비되기 전(폴더 선택 화면 등)엔 입력을 받지 않음 → 하위 UI(폴더 선택/드롭) 클릭 통과.
|
||
if (!videoReady) { canvas.style.pointerEvents = 'none'; return; }
|
||
canvas.style.pointerEvents = 'auto';
|
||
canvas.style.cursor = (editMode || fovMode) ? 'move' : 'default';
|
||
if (editMode || fovMode) { setInfoPopups([]); return; }
|
||
const onMove = (e: MouseEvent) => {
|
||
const { x: nx, y: ny } = clientToVideoNorm(e.clientX, e.clientY);
|
||
canvas.style.cursor = hitTestLabel(nx, ny) ? 'pointer' : 'default';
|
||
};
|
||
parent.addEventListener('mousemove', onMove);
|
||
return () => { parent.removeEventListener('mousemove', onMove); };
|
||
}, [videoReady, editMode, fovMode, clientToVideoNorm, hitTestLabel]);
|
||
|
||
const onPoiPointerUp = useCallback(() => {
|
||
const drag = dragRef.current, pos = dragPosRef.current;
|
||
dragRef.current = null; dragPosRef.current = null; setDragTitle(null);
|
||
if (!drag || !pos) return;
|
||
// 거의 안 움직였으면 '클릭=선택'으로 보고 변경 안 함.
|
||
if (Math.hypot(pos.x - drag.sx, pos.y - drag.sy) < 0.012) return;
|
||
const drone = getSmoothedDrone(); if (!drone) return;
|
||
const p = paramsRef.current, wo = worldOriginRef.current;
|
||
|
||
// ── 세로 화각(sensorH) 보정 ──
|
||
// 드롭한 화면점의 '세로'(py)만 맞춘다. 가로(f/sW)는 이미 정확하므로 건드리지 않음
|
||
// (f 를 바꾸면 가로까지 틀어져 스테이션선이 좌우로 벗어남).
|
||
// py = 0.5 + cy0 + (Yc/Zc)·(f/sH) → sH = (Yc/Zc)·f / (py* − 0.5 − cy0)
|
||
// sH 만 바꾸므로 세로 배율(상하 벌어짐)만 교정됨. 중심에서 충분히 떨어진 POI 로 보정.
|
||
if (fovModeRef.current) {
|
||
// 렌더와 동일한 poiZ (드론높이 모드면 드론−이격거리, 아니면 선택 z).
|
||
const poiZ = poiDroneHeightRef.current
|
||
? drone.altitude - (p.geoidOffset ?? 0) - droneHeightDropRef.current
|
||
: drag.z0;
|
||
const cc = toCameraCoords(drone, drag.lat, drag.lon, poiZ, p, wo);
|
||
if (cc.Zc < 0.1) return;
|
||
const b = cc.Yc / cc.Zc;
|
||
const v = pos.y - 0.5 - (p.cy0 ?? 0);
|
||
// 화면 세로 중심 근처면(v≈0) 역산이 불안정 → 무시(중심에서 떨어진 POI 사용 안내).
|
||
if (Math.abs(v) < 0.03 || Math.abs(b) < 1e-6) { alert('세로 중심에서 더 떨어진 POI로 보정하세요(위/아래).'); return; }
|
||
const sHNew = Math.max(6, Math.min(36, (b * p.focalLen) / v));
|
||
setParam('sensorH', sHNew);
|
||
return;
|
||
}
|
||
|
||
// ── 위치(lat/lon) 보정 ── 드롭한 화면점을 지면(현재 z)과 교차해 좌표 갱신.
|
||
const ll = groundPointFromPixel(drone, pos.x, pos.y, drag.z0, p, wo);
|
||
if (ll) setPoiOverride(drag.title, { lat: ll.lat, lon: ll.lon, z: drag.z0 });
|
||
}, [getSmoothedDrone, setPoiOverride, setParam]);
|
||
|
||
// 선택된 POI의 표고를 슬라이더로 직접 설정 (lat/lon 고정).
|
||
const setSelectedZ = useCallback((z: number) => {
|
||
setSelZ(z);
|
||
if (!selPoi) return;
|
||
const poi = allPoisRef.current.concat(allStructuresRef.current).find(p => p.title === selPoi);
|
||
if (poi) setPoiOverride(selPoi, { lat: poi.lat, lon: poi.lon, z });
|
||
}, [selPoi, setPoiOverride]);
|
||
|
||
const exportOverrides = useCallback(() => {
|
||
const data = { baseName: storeBaseName, overrides: storePoiOverrides };
|
||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = `${storeBaseName ?? 'poi'}_poi_overrides.json`;
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
}, [storeBaseName, storePoiOverrides]);
|
||
|
||
const importOverridesFile = useCallback((file: File) => {
|
||
file.text().then(t => {
|
||
try {
|
||
const obj = JSON.parse(t);
|
||
const map = (obj && typeof obj === 'object' && obj.overrides) ? obj.overrides : obj;
|
||
setPoiOverrides(map as PoiOverrideMap);
|
||
} catch { alert('보정 파일 파싱 실패'); }
|
||
});
|
||
}, [setPoiOverrides]);
|
||
|
||
// DEM 표고 자동적용 — 현재 POI/구조물 좌표로 지면고도(open-meteo, EGM2008 정표고≈측점)를
|
||
// 일괄 조회해 override z로 설정. 선로표고 가정 대신 실제 지형고도 → 멀리서 밀리는 슬라이드 완화.
|
||
// (90m DEM이라 절벽/구조물 인접은 부정확할 수 있음 → 그런 건 드래그로 미세보정.)
|
||
const applyDemElevations = useCallback(async () => {
|
||
const all = allPoisRef.current.concat(allStructuresRef.current);
|
||
if (!all.length) { alert('POI가 없습니다'); return; }
|
||
setDemBusy(true);
|
||
try {
|
||
const next: PoiOverrideMap = { ...useGeoStore.getState().poiOverrides };
|
||
const CH = 100;
|
||
for (let i = 0; i < all.length; i += CH) {
|
||
const chunk = all.slice(i, i + CH);
|
||
const lat = chunk.map(p => p.lat).join(',');
|
||
const lon = chunk.map(p => p.lon).join(',');
|
||
// 서버 프록시(같은 출처) — COEP/CSP로 외부 직접 호출이 막혀 서버가 중계.
|
||
const r = await fetch(`/api/elevation?lat=${lat}&lon=${lon}`);
|
||
const j = await r.json();
|
||
const elev = j?.elevation;
|
||
if (Array.isArray(elev)) {
|
||
chunk.forEach((p, k) => {
|
||
if (typeof elev[k] !== 'number') return;
|
||
// DEM(SRTM30m)이 그 좌표 지점의 지면고도 → 그대로 사용(구글 지면고도와 일치).
|
||
// (이전엔 선로표고로 상한을 걸었으나, 실제 지형 상승까지 깎아 부정확 → 제거.
|
||
// 드물게 옥상고도로 뜨는 건물은 드래그/표고슬라이더로 개별 보정.)
|
||
next[p.title] = { lat: p.lat, lon: p.lon, z: elev[k] };
|
||
});
|
||
}
|
||
}
|
||
setPoiOverrides(next);
|
||
} catch (e) {
|
||
alert('DEM 표고 조회 실패 (인터넷/CORS 확인): ' + e);
|
||
} finally {
|
||
setDemBusy(false);
|
||
}
|
||
}, [setPoiOverrides]);
|
||
|
||
// 화면표시 옵션 일괄 복귀 — DISPLAY_DEFAULTS 로 리셋(POI 보정/편집모드는 별도 버튼 유지).
|
||
const resetDisplayDefaults = useCallback(() => {
|
||
setShowCenterline(DISPLAY_DEFAULTS.showCenterline);
|
||
setShowDronePath(DISPLAY_DEFAULTS.showDronePath);
|
||
setDronePathZ(DISPLAY_DEFAULTS.dronePathZ);
|
||
setDronePathAlpha(DISPLAY_DEFAULTS.dronePathAlpha);
|
||
setSmoothHalf(DISPLAY_DEFAULTS.smoothHalf);
|
||
setEmaAlpha(DISPLAY_DEFAULTS.emaAlpha);
|
||
setSmoothMinAlpha(DISPLAY_DEFAULTS.smoothMinAlpha);
|
||
setSmoothSpeedRef(DISPLAY_DEFAULTS.smoothSpeedRef);
|
||
setMaxPoiRange(DISPLAY_DEFAULTS.maxPoiRange);
|
||
setPoiDroneHeight(DISPLAY_DEFAULTS.poiDroneHeight);
|
||
setDroneHeightDrop(DISPLAY_DEFAULTS.droneHeightDrop);
|
||
}, []);
|
||
|
||
return (
|
||
<>
|
||
<canvas
|
||
ref={canvasRef}
|
||
className="absolute inset-0 z-20"
|
||
style={{ width: '100%', height: '100%' }}
|
||
onPointerDown={onPoiPointerDown}
|
||
onPointerMove={onPoiPointerMove}
|
||
onPointerUp={onPoiPointerUp}
|
||
onPointerCancel={onPoiPointerUp}
|
||
/>
|
||
|
||
{/* 나침반 미니맵 — 우측 상단(기존 캔버스 나침반 대체). 핀은 RAF 가 드론 방위로 회전. */}
|
||
{geoDataLoaded && <Minimap ref={minimapRef} />}
|
||
|
||
{/* 라벨 속성 팝업(다중) — 위치는 RAF 가 라벨을 따라 갱신. DOM 이라 텍스트 선명.
|
||
컴팩트(기본 5필드) ↔ 전체 토글은 팝업 클릭. 자동(auto) 팝업은 ✕ 없음(가시 구조물 따라 표시). */}
|
||
{infoPopups.map(pp => {
|
||
const showFull = pp.expanded || !pp.compact || pp.compact.length === 0;
|
||
// 전체 보기: 컴팩트 항목을 같은 순서로 맨 위에 + 나머지 속성 → 클릭 전/후 순서 일치.
|
||
const fields = showFull
|
||
? (pp.props && pp.props.length > 0 ? orderedFullFields(pp.compact ?? [], pp.props) : null)
|
||
: pp.compact!;
|
||
return (
|
||
<div
|
||
key={pp.id}
|
||
ref={el => { if (el) popupElsRef.current.set(pp.id, el); else popupElsRef.current.delete(pp.id); }}
|
||
onClick={() => setInfoPopups(prev => prev.map(p => p.id === pp.id ? { ...p, expanded: !p.expanded } : p))}
|
||
title={showFull ? '클릭 → 접기' : '클릭 → 전체 항목'}
|
||
className="absolute z-40 max-w-[260px] bg-black/90 border border-emerald-500/70 rounded-md shadow-xl text-white text-[11px] px-2 py-1.5 pointer-events-auto cursor-pointer"
|
||
style={{ left: Math.min(Math.max(8, pp.sx - 10), (canvasSizeRef.current.w || 9999) - 240), top: Math.min(Math.max(8, pp.sy + 16), (canvasSizeRef.current.h || 9999) - 140) }}
|
||
>
|
||
{!pp.auto && (
|
||
<button onClick={e => { e.stopPropagation(); setInfoPopups(prev => prev.filter(p => p.id !== pp.id)); }} className="absolute top-0.5 right-1 text-gray-400 hover:text-white leading-none text-[12px]">✕</button>
|
||
)}
|
||
<div className="grid grid-cols-[auto_1fr] gap-x-2 gap-y-0.5 font-mono text-[10px] text-gray-300 max-h-[45vh] overflow-y-auto">
|
||
{fields ? (
|
||
fields.map((p, i) => (
|
||
<React.Fragment key={i}>
|
||
<span className="text-gray-500 whitespace-nowrap">{p.k}</span>
|
||
<span className="break-all">{p.v}</span>
|
||
</React.Fragment>
|
||
))
|
||
) : (
|
||
<>
|
||
<span className="text-gray-500">위도</span><span>{pp.lat.toFixed(6)}</span>
|
||
<span className="text-gray-500">경도</span><span>{pp.lon.toFixed(6)}</span>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
|
||
{showPanel && (
|
||
<div className="absolute right-2 z-30 flex flex-col-reverse items-end gap-2" style={{ bottom: (barHeight || 130) + 8 }}>
|
||
<div className="flex gap-2">
|
||
<button onClick={() => setShowDisplay(v => !v)}
|
||
className={`text-xs whitespace-nowrap px-2 py-1 rounded border ${showDisplay ? 'bg-amber-400 border-amber-300 text-black' : 'bg-black/70 hover:bg-black/90 text-gray-300 border-gray-600'}`}>
|
||
{showDisplay ? '▲ 화면표시 옵션' : '▼ 화면표시 옵션'}
|
||
</button>
|
||
<button onClick={() => setShowControls(v => !v)}
|
||
className={`text-xs whitespace-nowrap px-2 py-1 rounded border ${showControls ? 'bg-amber-400 border-amber-300 text-black' : 'bg-black/70 hover:bg-black/90 text-gray-300 border-gray-600'}`}>
|
||
{showControls ? '▲ 카메라 파라미터' : '▼ 카메라 파라미터'}
|
||
</button>
|
||
</div>
|
||
|
||
{/* 두 패널을 가로로 나란히(우→좌) 펼침 — 둘 다 열어도 세로로 안 쌓여 화면 위로 안 넘침. */}
|
||
<div className="flex flex-row-reverse items-end gap-2">
|
||
{/* ── 화면표시 옵션 (카메라 외 모든 표시 설정) ── */}
|
||
{showDisplay && (
|
||
<div className="bg-black/90 border border-sky-700 rounded p-3 text-white w-72 select-none max-h-[80vh] overflow-y-auto shadow-xl">
|
||
<div className="text-[10px] text-gray-500 uppercase tracking-wider mb-1.5 border-b border-gray-700 pb-2 mb-2">표시 토글</div>
|
||
<div className="space-y-2 mb-3">
|
||
{/* 선형/드론궤적/좌측패널 토글은 하단 재생바(VideoPlayer)로 이동. 측점 진단 버튼은 삭제. */}
|
||
{/* 경로표고·투명도는 드론궤적 ON/OFF 무관하게 항상 표시. */}
|
||
<ParamRow label="경로 표고" tip={`GPS 측정점(노란 점)을 표고 ${dronePathZ}m 에 투영·연결`} value={dronePathZ} min={-10} max={pathZMax} step={1} unit="m" decimals={0} onChange={v => setDronePathZ(Math.round(v))} />
|
||
<ParamRow label="투명도" tip={`드론 궤적 선/점 투명도 α=${dronePathAlpha.toFixed(2)}`} value={dronePathAlpha} min={0.1} max={1.0} step={0.05} unit="" decimals={2} onChange={v => setDronePathAlpha(v)} />
|
||
</div>
|
||
<div className="text-[10px] text-gray-500 uppercase tracking-wider mb-1.5 border-t border-gray-700 pt-2">스무딩 <span className="text-gray-600">(재계산 500ms 후)</span></div>
|
||
<div className="mb-3 space-y-2">
|
||
<ParamRow label="smooth" tip={`±${smoothHalf}fr = ±${(smoothHalf * 1000 / (30000/1001)).toFixed(0)}ms · 드론 자세 이동평균 반폭(최대) — 회전 중엔 자동 축소로 즉시 추종`} value={smoothHalf} min={0} max={60} step={1} unit="fr" decimals={0} onChange={v => setSmoothHalf(Math.round(v))} />
|
||
<ParamRow label="EMA α" tip={`α=${emaAlpha.toFixed(2)} → lag≈${(1000/60*(1/emaAlpha - 1)).toFixed(0)}ms · 화면좌표 평활 상한(빠를 때 추종 한계, ↑반응/↓부드러움)`} value={emaAlpha} min={0.01} max={1.0} step={0.01} unit="" decimals={2} onChange={v => setEmaAlpha(v)} />
|
||
<ParamRow label="떨림억제" tip="정지/느릴 때 최소 추종(α min). 작을수록 1배속 떨림이 더 강하게 평활됨(지연 약간↑). 기본 0.12" value={smoothMinAlpha} min={0.02} max={0.6} step={0.01} unit="" decimals={2} onChange={v => setSmoothMinAlpha(v)} />
|
||
<ParamRow label="반응속도" tip="이 화면속도(정규/프레임) 이상이면 즉시 추종. 키우면 더 강하게 평활(느린 이동까지 부드럽게), 줄이면 더 빨리 반응. 기본 0.010" value={smoothSpeedRef} min={0.002} max={0.04} step={0.001} unit="" decimals={3} onChange={v => setSmoothSpeedRef(v)} />
|
||
</div>
|
||
<div className="text-[10px] text-gray-500 uppercase tracking-wider mb-1.5 border-t border-gray-700 pt-2">POI 필터 / 높이</div>
|
||
<div className="mb-3 space-y-2">
|
||
<ParamRow label="POI 범위" tip="드론 위치와의 수평 직선거리 — 이 범위 안의 POI만 표시(밖은 제외)" value={maxPoiRange} min={10} max={1000} step={10} unit="m" decimals={0} onChange={v => setMaxPoiRange(Math.round(v))} />
|
||
<button onClick={() => setPoiDroneHeight(v => !v)}
|
||
className={`w-full text-[11px] px-2 py-1 rounded border transition-colors ${poiDroneHeight ? 'bg-amber-500/80 border-amber-300 text-black font-bold' : 'bg-black/60 border-gray-600 text-gray-200 hover:border-gray-400'}`}>
|
||
{poiDroneHeight ? `■ 드론 높이와 POI 이격거리 ${droneHeightDrop}m` : '▶ 드론 높이와 POI 이격거리'}
|
||
</button>
|
||
{poiDroneHeight && (
|
||
<ParamRow label="이격거리" tip={`POI를 드론보다 ${droneHeightDrop}m 아래에 배치 (값↑ = POI 아래로)`} value={droneHeightDrop} min={0} max={120} step={1} unit="m" decimals={0} onChange={v => setDroneHeightDrop(Math.round(v))} />
|
||
)}
|
||
</div>
|
||
<div className="text-[10px] text-gray-500 uppercase tracking-wider mb-1.5 border-t border-gray-700 pt-2">POI 위치 편집 (마우스)
|
||
<InfoTip text="드래그=수평위치 이동(가까운 프레임에서). 클릭 후 표고 슬라이더=높이. DEM(SRTM 30m)으로 각 POI 지점의 지면고도 적용(구글 지면고도와 일치). 옥상으로 뜨는 건물은 드래그/슬라이더로 보정. 자동저장." />
|
||
</div>
|
||
<div className="space-y-2">
|
||
<button onClick={() => { setEditMode(v => { const nv = !v; if (nv) setFovMode(false); return nv; }); }}
|
||
className={`w-full text-[11px] px-2 py-1 rounded border transition-colors ${editMode ? 'bg-yellow-500/80 border-yellow-300 text-black font-bold' : 'bg-black/60 border-gray-600 text-gray-200 hover:border-gray-400'}`}>
|
||
{editMode ? '■ 편집 종료' : '✎ 편집 모드 (POI 위치/표고)'}
|
||
</button>
|
||
<button onClick={() => { setFovMode(v => { const nv = !v; if (nv) setEditMode(false); return nv; }); }}
|
||
className={`w-full text-[11px] px-2 py-1 rounded border transition-colors ${fovMode ? 'bg-purple-500/80 border-purple-300 text-black font-bold' : 'bg-black/60 border-gray-600 text-gray-200 hover:border-gray-400'}`}>
|
||
{fovMode ? '■ 세로화각 보정 종료' : '🎯 세로화각 보정 — POI를 실제 위치(상하)로 드래그'}
|
||
<InfoTip text="라벨이 멀리(위)/가까이(아래)서 상하로 어긋날 때 사용. POI 하나를 영상 속 실제 위치로 끌면 세로 화각(sensorH)만 역산해 상하 정렬. 가로(초점 f)는 안 건드려 빨간 중심선이 좌우로 안 틀어짐. 세로 중심에서 충분히 떨어진(위/아래) POI로 보정." />
|
||
</button>
|
||
<div className="text-[10px] text-gray-600">ℹ️ 라벨 클릭 → 속성 팝업(여러 개 누적, 재생 따라 이동). 빈 곳 클릭 → 팝업 닫기, 팝업 없으면 재생/정지.</div>
|
||
<div className="text-[10px] text-gray-500">
|
||
{dragTitle ? `드래그 중: ${dragTitle}`
|
||
: fovMode ? `🎯 POI를 실제 위치로 끌면 세로화각 자동보정 (현재 senH=${(params.sensorH ?? 20.25).toFixed(2)}mm)`
|
||
: `보정된 POI ${Object.keys(storePoiOverrides).length}개`}
|
||
</div>
|
||
{editMode && selPoi && (
|
||
<div className="border border-sky-800 rounded p-1.5 bg-sky-950/40 space-y-1">
|
||
<div className="text-[10px] text-sky-300 truncate">선택: {cleanTitle(selPoi)}</div>
|
||
<ParamRow label="표고 z" value={selZ} min={Math.round(selZBase - 30)} max={Math.round(selZBase + 12)} step={0.5} unit="m" decimals={1} onChange={setSelectedZ} />
|
||
<div className="flex items-center justify-between text-[10px] text-gray-500">
|
||
<span>선로표고 {selZBase.toFixed(1)}m</span>
|
||
<button onClick={() => { if (selPoi) { clearPoiOverride(selPoi); setSelZ(selZBase); } }}
|
||
className="text-gray-400 hover:text-red-300 border border-gray-700 rounded px-1">이 POI 해제</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
<div className="flex gap-1">
|
||
<button onClick={exportOverrides}
|
||
className="flex-1 text-[11px] px-2 py-0.5 rounded border border-gray-600 text-gray-200 hover:border-gray-400">내보내기</button>
|
||
<button onClick={() => fileInputRef.current?.click()}
|
||
className="flex-1 text-[11px] px-2 py-0.5 rounded border border-gray-600 text-gray-200 hover:border-gray-400">가져오기</button>
|
||
</div>
|
||
<input ref={fileInputRef} type="file" accept=".json,application/json" className="hidden"
|
||
onChange={e => { const f = e.target.files?.[0]; if (f) importOverridesFile(f); e.target.value = ''; }} />
|
||
<div className="flex gap-1">
|
||
<button onClick={applyDemElevations} disabled={demBusy}
|
||
className="flex-1 text-[11px] px-2 py-1 rounded border border-sky-700 bg-sky-900/40 text-sky-200 hover:border-sky-400 disabled:opacity-50">
|
||
{demBusy ? 'DEM 조회 중…' : '🌐 DEM 표고 자동적용'}
|
||
</button>
|
||
<button onClick={() => { if (confirm('POI 위치/표고 보정을 모두 초기화할까요? (선로표고로 복귀)')) setPoiOverrides({}); }}
|
||
className="text-[11px] px-2 py-1 rounded border border-gray-600 text-gray-300 hover:border-red-400 hover:text-red-300">
|
||
보정 초기화
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div className="border-t border-gray-700 pt-2 mt-3 flex items-center justify-end">
|
||
<button onClick={resetDisplayDefaults}
|
||
title="화면표시 옵션을 기본값으로 되돌립니다 (POI 보정은 별도)"
|
||
className="text-[11px] text-gray-400 hover:text-white border border-gray-600 hover:border-gray-400 px-2 py-0.5 rounded transition-colors">
|
||
기본값
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── 카메라 파라미터 (드론 카메라 관련만) ── */}
|
||
{showControls && (
|
||
<div className="bg-black/90 border border-gray-600 rounded p-3 text-white w-72 select-none max-h-[80vh] overflow-y-auto shadow-xl">
|
||
<div className="text-[10px] text-gray-500 uppercase tracking-wider mb-1.5 border-b border-gray-700 pb-2 mb-2">자세 보정 오프셋<span className="ml-1 text-gray-600">(SRT + offset)</span></div>
|
||
<div className="space-y-2 mb-3">
|
||
<ParamRow label="Yaw ±" value={params.yawOffset} min={-180} max={180} step={0.1} unit="°" decimals={1} onChange={v => setParam('yawOffset', v)} />
|
||
<ParamRow label="Pitch ±" value={params.pitch} min={-45} max={45} step={0.1} unit="°" decimals={1} onChange={v => setParam('pitch', v)} />
|
||
<ParamRow label="Roll ±" value={params.roll} min={-45} max={45} step={0.1} unit="°" decimals={1} onChange={v => setParam('roll', v)} />
|
||
</div>
|
||
<div className="text-[10px] text-gray-500 uppercase tracking-wider mb-1.5 border-t border-gray-700 pt-2">위치 보정 (드론 GPS 오프셋)</div>
|
||
<div className="space-y-2 mb-3">
|
||
<ParamRow label="off X" value={params.offX} min={-500} max={500} step={0.1} unit="m" decimals={1} onChange={v => setParam('offX', v)} />
|
||
<ParamRow label="off Y" value={params.offY} min={-500} max={500} step={0.1} unit="m" decimals={1} onChange={v => setParam('offY', v)} />
|
||
<ParamRow label="off Z" value={params.offZ} min={-200} max={200} step={0.1} unit="m" decimals={1} onChange={v => setParam('offZ', v)} />
|
||
<ParamRow label="지오이드" value={params.geoidOffset} min={0} max={50} step={0.1} unit="m" decimals={1} onChange={v => setParam('geoidOffset', v)} />
|
||
<div className="text-[10px] text-gray-600 text-right">정표고→타원체고 (대전≈25.8)</div>
|
||
</div>
|
||
<div className="text-[10px] text-gray-500 uppercase tracking-wider mb-1.5 border-t border-gray-700 pt-2">내부표정 (초점·주점·센서)</div>
|
||
<div className="space-y-2 mb-3">
|
||
<ParamRow label="f" value={params.focalLen} min={10} max={100} step={0.1} unit="mm" decimals={1} onChange={v => setParam('focalLen', v)} />
|
||
<ParamRow label="cx₀" value={params.cx0} min={-0.5} max={0.5} step={0.005} unit="" decimals={3} onChange={v => setParam('cx0', v)} />
|
||
<ParamRow label="cy₀" value={params.cy0} min={-0.5} max={0.5} step={0.005} unit="" decimals={3} onChange={v => setParam('cy0', v)} />
|
||
<ParamRow label="sen W" value={params.sensorW} min={10} max={50} step={0.05} unit="mm" decimals={2} onChange={v => setParam('sensorW', v)} />
|
||
<ParamRow label="sen H" value={params.sensorH} min={6} max={36} step={0.05} unit="mm" decimals={2} onChange={v => setParam('sensorH', v)} />
|
||
</div>
|
||
{panelDroneFrame && (
|
||
<div className="border-t border-gray-700 pt-2 mb-2 text-[10px] text-gray-400 font-mono space-y-0.5">
|
||
<div>yaw: {((panelDroneFrame.yaw+params.yawOffset+360)%360).toFixed(1)}° pitch: {(panelDroneFrame.pitch+params.pitch).toFixed(1)}° roll: {(panelDroneFrame.roll+params.roll).toFixed(1)}°</div>
|
||
<div>f: {params.focalLen.toFixed(1)}mm hFOV: {(2*Math.atan((params.sensorW??36)/(2*params.focalLen))*180/Math.PI).toFixed(1)}°</div>
|
||
<div>offX: {params.offX.toFixed(1)}m offY: {params.offY.toFixed(1)}m offZ: {params.offZ.toFixed(1)}m</div>
|
||
<div>
|
||
영상 {videoWidth || '?'}×{videoHeight || '?'}
|
||
{videoWidth > 0 && videoHeight > 0 && (
|
||
<span className="text-gray-500"> ({(videoWidth / videoHeight).toFixed(3)} · cover 정렬)</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
<div className="border-t border-gray-700 pt-2 flex items-center justify-end">
|
||
<button onClick={() => setParams({ ...DEFAULT_CAMERA_PARAMS })}
|
||
className="text-[11px] text-gray-400 hover:text-white border border-gray-600 hover:border-gray-400 px-2 py-0.5 rounded transition-colors">
|
||
초기화
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</>
|
||
);
|
||
}
|