/** * 지리정보 오버레이 * 렌더링 최적화: * - 텍스트(측점+POI): 데이터 로드 완료 시 전 프레임 Map 사전 계산 (requestIdleCallback) * params 변경 시 500ms debounce 후 재계산 * - 중심선: 드론 프레임 변경 시 renderCacheRef 갱신 (per-frame, 나중에 최적화) * - RAF 루프: Map 조회 + 캐시 읽기만 (계산 없음 → 60fps) */ import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react'; import { toCameraCoords, pixelFromCamera, groundPointFromPixel, type DroneFrameBasic, type CameraParams, type CameraCoords, DEFAULT_CAMERA_PARAMS, } from '../../utils/geoProjection'; import { useGeoStore } from '../../store/geoStore'; import { useSettingsStore } from '../../store/settingsStore'; import { Minimap } from './Minimap'; import { MapCompass, type CompassPose } from './MapCompass'; import type { GeoPoint, CenterlinePoint, PoiOverrideMap, RouteStructure } from '../../types/geo'; const VIDEO_FPS = 30000 / 1001; // 라벨 좌표 튐 방지 — 1프레임에 이 정규화 거리(화면폭/높이 비율) 이상 점프하면 이상치로 // 보고 갱신 무시(이전 위치 유지). 정상 팬 이동은 60fps에서 프레임당 훨씬 작다. const REJECT_DIST = 0.12; // 단, 시크/재등장 등 '진짜' 큰 이동에 영구히 갇히지 않도록 연속 거부 한계 후 수용. const MAX_REJECT_FRAMES = 8; // POI 라벨 겹침 억제 — 두 마커가 화면상 이 정규거리(가로 X / 세로 Y) 이내면 '겹침'으로 보고 // 드론에 더 가까운 것만 남기고 나머지는 숨긴다. (같은 건물의 여러 업체 등) const POI_MERGE_X = 0.10; const POI_MERGE_Y = 0.035; type DispPos = { x: number; y: number; rej: number; vx: number; vy: number; rx?: number; ry?: number }; // 속도 적응형 평활(One Euro 방식) 상수. // 떨림(노이즈)은 매 프레임 방향이 번갈아 → 평활속도≈0 → 강하게 평활(1배속에서 안정). // 실제 이동은 방향이 일관 → 평활속도 큼 → alpha↑로 즉시 추종(빠른 배속에서 지연 없음). const SMOOTH_VEL_BETA = 0.25; // 속도 추정 평활(저크 제거) /** 이상치 거부 + 속도적응 EMA. * maxAlpha = 빠를 때 추종 상한(패널 EMA α), minAlpha = 정지/떨림 시 최소 추종(작을수록 강한 평활), * speedRef = 이 속도(정규/프레임) 이상에서 alpha 가 maxAlpha 에 도달(클수록 더 강하게 평활). */ function smoothStep(prev: DispPos | undefined, tx: number, ty: number, maxAlpha: number, minAlpha: number, speedRef: number): DispPos { if (!prev) return { x: tx, y: ty, rej: 0, vx: 0, vy: 0 }; const dx = tx - prev.x, dy = ty - prev.y; const d = Math.hypot(dx, dy); if (d > REJECT_DIST && prev.rej < MAX_REJECT_FRAMES) { return { x: prev.x, y: prev.y, rej: prev.rej + 1, vx: prev.vx, vy: prev.vy }; // 이상치: 위치 유지 } // 속도 벡터 평활 → 떨림(왕복)은 상쇄돼 작게, 실제 이동은 유지. const vx = prev.vx + (dx - prev.vx) * SMOOTH_VEL_BETA; const vy = prev.vy + (dy - prev.vy) * SMOOTH_VEL_BETA; const speed = Math.hypot(vx, vy); const a = Math.min(maxAlpha, minAlpha + (maxAlpha - minAlpha) * Math.min(1, speed / Math.max(1e-4, speedRef))); return { x: prev.x + dx * a, y: prev.y + dy * a, rej: 0, vx, vy }; } interface Props { currentFrame: number; currentTime: number; fps: number; visible: boolean; /** 영상 첫 프레임이 표시 가능해진 후 true. false면 오버레이를 그리지 않음(영상보다 먼저 표시 방지). */ videoReady?: boolean; /** 영상 원본 해상도(px). object-fit:cover 크롭 영역 계산 → 오버레이를 영상에 정렬. 0이면 컨테이너 기준. */ videoWidth?: number; videoHeight?: number; /** 영상 빈 곳 클릭(팝업 없을 때) → 재생/정지 토글. Video.js controls:false 라 직접 호출 필요. */ onTogglePlay?: () => void; /** 카메라 파라미터 패널 표시 (영상제어 토글). 기본 true. */ showPanel?: boolean; /** 카메라 파라미터 패널 top(px) — 노선 배너 아래로 배치. 기본 72. */ topPx?: number; /** 하단 스테이션바 높이(px) — 우측 버튼을 좌측 패널과 같은 높이(bottom)에 맞추기 위함. */ barHeight?: number; /** 부드러운 단조보간 재생시간(초) ref. VideoPlayer smoothTimeRef 전달 시 * 라벨이 60fps로 매끄럽게 이동(일시정지·시크·배속 보정 포함). 없으면 prop 기반 추정. */ timeRef?: React.MutableRefObject; } // category → 이모지 const CATEGORY_EMOJI: Record = { '터널': '🚇', '교량': '🌉', '역사': '🚉', '철도역': '🚉', '지장물': '🏢', '지장물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, 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) // 라벨 가시집합(프레임별) — 화면좌표가 아니라 '월드좌표'를 저장한다. 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+)[Kk](\d+)/); if (!m) return 0; return parseInt(m[1]) * 1000 + parseInt(m[2]); } // ── ParamRow ───────────────────────────────────────────────────────────────── // 라벨 옆 작은 ⓘ — 마우스 오버 시 네이티브 툴팁으로 설명 표시(세로 공간 절약). function InfoTip({ text }: { text: string }) { return ( ); } 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 (
{label} { const v = parseFloat(e.target.value); onChange(v); prevRef.current = v; setText(fmt(v)); }} className="flex-1 h-1 accent-yellow-400 cursor-pointer" /> 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" /> {unit}
); } // ── 메인 컴포넌트 ───────────────────────────────────────────────────────────── 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(null); const canvasSizeRef = useRef({ w: 0, h: 0 }); // 데이터 ref const allDroneFramesRef = useRef([]); const allCenterlinePointsRef = useRef([]); const allGeoStationsRef = useRef([]); const allPoisRef = useRef([]); const allStructuresRef = useRef([]); // 교량/터널/구교 → POI처럼 표시 // 현재 상태 ref const currentDroneFrameRef = useRef(null); const currentFrameNumRef = useRef(0); // RAF에서 Map 조회용 const currentFrameIdxRef = useRef(0); // smoothFrame용 배열 인덱스 const currentTimeSecRef = useRef(0); // 마지막으로 알려진 재생 시간 const timeUpdateWallRef = useRef(performance.now()); // currentTime 갱신된 시각 const paramsRef = useRef(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>(new Map()); const precomputeIdRef = useRef(0); // 진행 중 계산 취소용 // 중심선 + 나침반 렌더 캐시 (per-frame) const renderCacheRef = useRef(null); // 나침반 전용(측점선 visible 무관 항상 갱신·표시) const compassRef = useRef<{ effectiveYaw: number; hFovRad: number } | null>(null); // 나침반 미니맵(DOM) root — RAF 에서 --rot(=-yaw) 갱신. minimapRotRef=누적 회전(360° 언랩). const minimapRef = useRef(null); const minimapRotRef = useRef(0); // 지도 나침반용 현재 위치/방위 — RAF 가 매 프레임 갱신, MapCompass 가 읽음. const mapPoseRef = useRef({ lat: 0, lon: 0, yaw: 0 }); // UI state const [params, setParams] = useState(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); // 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([]); const infoPopupsRef = useRef([]); 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>(new Map()); const popupElsRef = useRef>(new Map()); const popupMissRef = useRef>(new Map()); // 라벨이 화면 밖인 연속 프레임 수(제거 유예) const popupPosRef = useRef>(new Map()); // 팝업 위치 EMA(물결 방지) const popupFlipRef = useRef>(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({}); // POI 라벨이 연속으로 화면 안에 그려진 프레임 수(title→streak). 팝업 '추가'에 히스테리시스를 줘 // 경계에서 라벨이 미세하게 깜빡일 때(1~2프레임) 팝업이 재생성돼 번쩍이는 것을 막는다. const poiOnScreenStreakRef = useRef>(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(null); const [demBusy, setDemBusy] = useState(false); // 선택된 POI의 표고 직접 조절 (슬라이더). selZBase=선로표고(슬라이더 범위 기준). const [selPoi, setSelPoi] = useState(null); const [selZ, setSelZ] = useState(0); const [selZBase, setSelZBase] = useState(42); // 표시 위치 EMA 상태 (RAF 내부 유지). rej = 연속 이상치 거부 횟수. const displayedStRef = useRef>(new Map()); const displayedPoiRef = useRef>(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(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((key: K, val: CameraParams[K]) => setParams(prev => ({ ...prev, [key]: val })), []); const nearestCL = useCallback((lat: number, lon: number): CenterlinePoint | null => { const pts = allCenterlinePointsRef.current; if (!pts.length) return null; let best = pts[0], bestD = (best.lat - lat) ** 2 + (best.lon - lon) ** 2; for (const pt of pts) { const d = (pt.lat - lat) ** 2 + (pt.lon - lon) ** 2; if (d < bestD) { bestD = d; best = pt; } } return best; }, []); // 클라이언트 지리정보 스토어 구독 (서버 /api/geo/* 대체) const storeStations = useGeoStore(s => s.stations); const storePois = useGeoStore(s => s.pois); const storeCenterline = useGeoStore(s => s.centerline); const storeFrames = useGeoStore(s => s.frames); const storeOrigin = useGeoStore(s => s.origin); const storeStructures = useGeoStore(s => s.structures); const routeMeta = useGeoStore(s => s.routeMeta); useEffect(() => { const d = routeMeta?.routeInfo?.direction ?? ''; routeDirRef.current = d.includes('하') ? '하' : d.includes('상') ? '상' : null; }, [routeMeta]); // POI 겹침제외 토글(설정 스토어). false면 겹침 억제 없이 모든 POI 표시. const compassType = useSettingsStore(s => s.compassType); const setCompassType = useSettingsStore(s => s.setCompassType); const compassMapStyle = useSettingsStore(s => s.compassMapStyle); const setCompassMapStyle = useSettingsStore(s => s.setCompassMapStyle); const poiOverlapExclude = useSettingsStore(s => s.poiOverlapExclude); const overlapExcludeRef = useRef(true); useEffect(() => { overlapExcludeRef.current = poiOverlapExclude; }, [poiOverlapExclude]); // 좌측 노선 패널(RoutePanel) 표시 토글은 하단 재생바(VideoPlayer)로 이동. const storeBaseName = useGeoStore(s => s.baseName); const storeCameraInfo = useGeoStore(s => s.cameraInfo); const storeCameraJson = useGeoStore(s => s.cameraJson); 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(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(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(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)[k] = v; } return next; }); console.log('[camera] .camera.json 파라미터 적용(최우선):', cam); }, [storeBaseName, storeCameraJson]); // 현재 카메라 파라미터를 사용자 PC 에 <영상 base>.camera.json 으로 다운로드 저장. // Yaw±(자동추정/드래그 역산 결과) 포함 전체 파라미터가 담긴다. // 받은 파일을 영상 폴더에 넣어두면 다음 폴더 선택 때 최우선으로 자동 적용된다. const [camSaveState, setCamSaveState] = useState<'idle' | 'ok'>('idle'); const saveCameraToPc = useCallback(() => { const geo = useGeoStore.getState(); const videoName = geo.videoFiles[geo.videoIndex]?.name ?? (geo.baseName ? `${geo.baseName}.mp4` : 'camera.mp4'); const base = videoName.replace(/\.[^.]+$/, ''); 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 = `${base}.camera.json`; a.click(); setTimeout(() => URL.revokeObjectURL(url), 5000); setCamSaveState('ok'); setTimeout(() => setCamSaveState('idle'), 2500); }, []); // 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(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(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) // 표고 미상(z=0) 라벨의 앵커 평면 — 드론 궤적과 동일한 '경로 표고' 값. // 사용자가 경로표고 슬라이더로 궤적을 도로에 맞추면 측점/POI 라벨도 함께 맞는다. const PATH_Z = dronePathZRef.current; 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; const szRaw = snap?.z ?? st.z; // 표고 미상(0 이하)이면 경로표고 평면에 앵커 — 공중부양/시차 밀림 방지. const sz = szRaw > 0 ? szRaw : PATH_Z; const cc = toCameraCoords(drone, slat, slon, sz, 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: 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 }[]; color?: string }[] = []; for (const poi of allPoi) { // 지면고도 gz: 보정값(DEM/드래그) → 최근접 선로표고(유효값) → POI 자체 z → 경로표고 앵커. // 표고 미상(0)인 데이터셋(제주)은 경로표고 평면에 앵커되어 궤적과 함께 보정된다. const clz = nearestCL(poi.lat, poi.lon)?.z; const gz = overridesRef.current[poi.title]?.z ?? (clz !== undefined && clz > 0 ? clz : undefined) ?? (poi.z > 0 ? poi.z : 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(); for (const a of accepted) groupCount.set(coordKey(a), (groupCount.get(coordKey(a)) ?? 0) + 1); const groupSeen = new Map(); // 통과분만 월드좌표로 저장(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]); // 모든 데이터 로드 완료 시 사전 계산 시작 // 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) 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, storeFrames]); // 현재 재생 시간 → 드론 프레임 ref 갱신 useEffect(() => { // 나침반이 visible 무관 갱신되도록 visible 게이트 제거(프레임 탐색은 가벼움). if (!droneFramesLoaded) return; const frames = allDroneFramesRef.current; if (!frames.length) return; const vfps = fpsRef.current; let best = frames[0], bestIdx = 0, bestD = Math.abs((best.frame ?? 0) / vfps - currentTime); for (let i = 0; i < frames.length; i++) { const d = Math.abs(frames[i].frame / vfps - currentTime); if (d < bestD) { bestD = d; best = frames[i]; bestIdx = i; } if (bestD < 1 / vfps / 2) break; } currentFrameNumRef.current = best.frame; currentFrameIdxRef.current = bestIdx; currentTimeSecRef.current = currentTime; timeUpdateWallRef.current = performance.now(); if (currentDroneFrameRef.current?.frame !== best.frame) { currentDroneFrameRef.current = best; setPanelDroneFrame(best); } }, [currentTime, droneFramesLoaded]); // 중심선 + 나침반 캐시 빌드 (per-frame, 텍스트 계산 없음) useEffect(() => { // 나침반(effectiveYaw/hFovRad)은 측점선 visible 과 무관하게 항상 갱신. const cur = currentDroneFrameRef.current; if (cur) { const p = paramsRef.current; const fr = allDroneFramesRef.current; const d = fr.length ? smoothFrame(fr, currentFrameIdxRef.current, smoothHalfRef.current) : cur; compassRef.current = { effectiveYaw: d.yaw + p.yawOffset, hFovRad: 2 * Math.atan((p.sensorW ?? 36) / (2 * p.focalLen)), }; } else { compassRef.current = null; } // 선/궤적은 RAF가 매 프레임 직접 투영하므로 여기선 캐시 '준비됨' 표식 + 메타데이터만. if (!currentDroneFrameRef.current) { renderCacheRef.current = null; return; } const frames = allDroneFramesRef.current; const drone = frames.length ? smoothFrame(frames, currentFrameIdxRef.current, smoothHalfRef.current) : currentDroneFrameRef.current; const params = paramsRef.current; renderCacheRef.current = { effectiveYaw: drone.yaw + params.yawOffset, hFovRad: 2 * Math.atan((params.sensorW ?? 36) / (2 * params.focalLen)), clCount: allCenterlinePointsRef.current.length, poiCount: allPoisRef.current.length, }; }, [panelDroneFrame, params]); // ResizeObserver useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; const parent = canvas.parentElement; if (!parent) return; const ro = new ResizeObserver(entries => { for (const e of entries) { canvas.width = Math.round(e.contentRect.width); canvas.height = Math.round(e.contentRect.height); canvasSizeRef.current = { w: canvas.width, h: canvas.height }; } }); ro.observe(parent); canvas.width = parent.clientWidth; canvas.height = parent.clientHeight; canvasSizeRef.current = { w: canvas.width, h: canvas.height }; return () => ro.disconnect(); }, []); // RAF 렌더 루프 — 계산 없이 캐시/Map 조회만 useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; let rafId = 0; const draw = () => { rafId = requestAnimationFrame(draw); const ctx = canvas.getContext('2d'); if (!ctx) return; const { w: W, h: H } = canvasSizeRef.current; ctx.clearRect(0, 0, W, H); // 영상 첫 프레임이 표시되기 전엔 오버레이(라벨/선/나침반)를 그리지 않음 → 라벨이 영상보다 먼저 뜨는 것 방지. if (!videoReadyRef.current) return; // object-fit:cover 정렬 — 영상은 컨테이너를 비율유지·크롭하며 채우므로(index.css), 오버레이도 // 같은 cover 사각형에 매핑해야 영상과 일치한다. 영상 해상도(vW,vH) 없으면 컨테이너 기준 폴백. const { w: vW, h: vH } = videoSizeRef.current; let dispW = W, dispH = H, offX = 0, offY = 0; if (vW > 0 && vH > 0) { const s = Math.max(W / vW, H / vH); // cover: 더 큰 배율로 채움(넘침=크롭) dispW = vW * s; dispH = vH * s; offX = (W - dispW) / 2; offY = (H - dispH) / 2; // 음수 = 화면 밖(크롭) } coverRef.current = { offX, offY, dispW, dispH, W, H }; const vx = (nx: number) => offX + nx * dispW; // 정규(0~1, 영상프레임) → 화면 px const vy = (ny: number) => offY + ny * dispH; // 이번 프레임 라벨 히트박스(아이콘+글자, 화면 px) 수집 → 끝에서 labelHitRef 갱신. const hitBoxes: { kind: 'poi' | 'station'; title: string; x0: number; y0: number; x1: number; y1: number }[] = []; // 이번 프레임에 '실제로 화면 안에 그려진' 라벨 id(`kind:title`) 집합. 팝업 표시의 단일 기준 — // 십자마커/텍스트가 뷰포트 안에 있을 때만 채운다. 팝업은 이 집합에 있을 때만 보이게 해 // "라벨은 화면 밖(안 보임)인데 팝업만 clamp 되어 뜨는" 문제를 원천 차단한다. const onScreenLabels = new Set(); // 유효 하단 = 화면 높이 − 스테이션바 높이. 팝업/라벨 가시 기준을 '모니터'가 아니라 // '영상 재생 영역(스테이션바 위쪽)' 으로 잡는다. (라벨이 바 밑으로 내려가면 사라지게) const HB = H - barHeightRef.current; // 히트박스가 '영상 영역(0..W, 0..HB)' 과 겹치는지(라벨이 눈에 보이는지). -2px 안쪽 요구로 경계 애매함 제거. const labelOnScreen = (x0: number, y0: number, x1: number, y1: number): boolean => x1 >= 2 && x0 <= W - 2 && y1 >= 2 && y0 <= HB - 2; const cache = renderCacheRef.current; if (cache) { // 부드러운 시간(timeRef, 60fps 보간) → 연속 프레임번호. 선/궤적·라벨 공통 사용. const estTime = timeRef ? timeRef.current : currentTimeSecRef.current + (performance.now() - timeUpdateWallRef.current) / 1000; const estFrame = estTime * fpsRef.current; // 연속 보간 포즈(라인·라벨 공통) — 매 RAF 프레임 직접 투영해 부드럽게. const dronePose = poseAt(estFrame) ?? (allDroneFramesRef.current.length ? smoothFrame(allDroneFramesRef.current, currentFrameIdxRef.current, smoothHalfRef.current) : currentDroneFrameRef.current!); const lines = buildLines(dronePose); // 나침반 미니맵(heading-up) 회전 — 영상과 즉시 동기. dronePose.yaw 는 ±smoothHalf(기본 60fr ≈2s) // 평활이라 회전 시 지연 → 가벼운 평활(±3fr)의 yaw 를 따로 보간해 사용. 360° 누적 언랩(점프 방지). { const arr = allDroneFramesRef.current; let rawYaw = dronePose.yaw; if (arr.length) { let lo = 0, hi = arr.length - 1; while (lo < hi) { const m = (lo + hi) >> 1; if (arr[m].frame < estFrame) lo = m + 1; else hi = m; } const i2 = lo, i1 = Math.max(0, lo - 1); const f1 = arr[i1].frame, f2 = arr[i2].frame; const frac = f2 > f1 ? Math.min(1, Math.max(0, (estFrame - f1) / (f2 - f1))) : 0; const a = smoothFrame(arr, i1, 3), b = smoothFrame(arr, i2, 3); // ±3fr 가벼운 평활 let dy = b.yaw - a.yaw; dy = ((dy + 540) % 360) - 180; rawYaw = a.yaw + dy * frac; } const headingDeg = rawYaw + paramsRef.current.yawOffset; // 실제 방위(0=N, 시계+) // 지도 나침반(노스업)용 현재 위치/방위 — MapCompass 가 읽음. mapPoseRef.current.lat = dronePose.lat; mapPoseRef.current.lon = dronePose.lon; mapPoseRef.current.yaw = headingDeg; // 아날로그 나침반(heading-up) 회전 — 렌더 중일 때만. 360° 누적 언랩(점프 방지). if (minimapRef.current) { const target = -headingDeg; const delta = (((target - minimapRotRef.current) % 360) + 540) % 360 - 180; minimapRotRef.current += delta; minimapRef.current.style.setProperty('--rot', `${minimapRotRef.current}deg`); } } // 선로 중심선 (선형) — 독립 토글 if (showCenterlineRef.current && lines.centerlineSegs.length > 0) { ctx.strokeStyle = 'rgba(255,50,50,0.85)'; ctx.lineWidth = 3; ctx.setLineDash([]); ctx.beginPath(); for (const [px1, py1, px2, py2] of lines.centerlineSegs) { ctx.moveTo(vx(px1), vy(py1)); ctx.lineTo(vx(px2), vy(py2)); } ctx.stroke(); } // 드론 경로 — 하나의 연속 폴리라인(둥근 연결)으로 부드럽게 + 작은 점. 독립 토글 + 투명도 const dpa = dronePathAlphaRef.current; const pts = lines.dronePathPts; if (pts.length > 1) { ctx.lineJoin = 'round'; ctx.lineCap = 'round'; ctx.setLineDash([]); // 어두운 밑선(대비) → 배경 위에서도 끊김 없이 잘 보임 ctx.strokeStyle = `rgba(0,0,0,${0.35 * dpa})`; ctx.lineWidth = 5; ctx.beginPath(); ctx.moveTo(vx(pts[0][0]), vy(pts[0][1])); for (let k = 1; k < pts.length; k++) ctx.lineTo(vx(pts[k][0]), vy(pts[k][1])); ctx.stroke(); // 본선 ctx.strokeStyle = `rgba(80,200,255,${dpa})`; ctx.lineWidth = 3; ctx.beginPath(); ctx.moveTo(vx(pts[0][0]), vy(pts[0][1])); for (let k = 1; k < pts.length; k++) ctx.lineTo(vx(pts[k][0]), vy(pts[k][1])); ctx.stroke(); } // 측정점 dot — 작고 듬성하게(선 흐름 방해 X) if (pts.length > 0) { ctx.fillStyle = `rgba(255,230,90,${dpa})`; for (let k = 0; k < pts.length; k += 4) { ctx.beginPath(); ctx.arc(vx(pts[k][0]), vy(pts[k][1]), 1.8, 0, Math.PI*2); ctx.fill(); } } visStructRef.current.clear(); // 매 프레임 가시 컴팩트-구조물 재수집(라벨 off 시 빈 상태) if (visibleRef.current) { // 가시집합만 precompute 에서 가져오고(어떤 라벨을 그릴지), 좌표는 RAF 가 연속 포즈로 직접 // 투영 → 라인처럼 부드럽게 이동(정수프레임 보간/프레임키 의존 제거). const baseFrame = Math.floor(estFrame); const labelsA = labelMapRef.current.get(baseFrame) ?? labelMapRef.current.get(currentFrameNumRef.current); if (labelsA) { const α = emaAlphaRef.current; const params = paramsRef.current; const worldOrigin = worldOriginRef.current; const CLIP_Z = 0.1; const seenSt = new Set(); const seenPoi = new Set(); // 측점 라벨 — 연속 포즈로 직접 투영 → 이상치 거부 → EMA. // 선형(중심선) 토글과 함께 ON/OFF (빨간 스테이션 라벨 = 선형의 일부). if (showCenterlineRef.current) { ctx.font = 'bold 18px monospace'; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; labelsA.stationLabels.forEach((stA) => { const cc = toCameraCoords(dronePose, stA.lat, stA.lon, stA.z, params, worldOrigin); if (cc.Zc < CLIP_Z) return; const { pxRaw, pyRaw } = pixelFromCamera(cc, params); const prevDst = displayedStRef.current.get(stA.title); const d = smoothStep(prevDst, pxRaw, pyRaw, α, smoothMinAlphaRef.current, smoothSpeedRefRef.current); seenSt.add(stA.title); // 픽셀 히스테리시스: 0.75px 이상 움직일 때만 정수 위치 갱신 → 저속(1배속) 반올림 깜빡임 제거. const sx0 = vx(d.x), sy0 = vy(d.y); const x = (prevDst?.rx !== undefined && Math.abs(sx0 - prevDst.rx) <= 0.75) ? prevDst.rx : Math.round(sx0); const y = (prevDst?.ry !== undefined && Math.abs(sy0 - prevDst.ry) <= 0.75) ? prevDst.ry : Math.round(sy0); d.rx = x; d.ry = y; displayedStRef.current.set(stA.title, d); // 마커 선 ctx.strokeStyle = 'rgba(255,100,100,0.95)'; ctx.lineWidth = 2.5; ctx.beginPath(); ctx.moveTo(x, y-10); ctx.lineTo(x, y+10); ctx.stroke(); // 텍스트 테두리 const lx = Math.max(2, x + 8); ctx.strokeStyle = 'rgba(0,0,0,0.85)'; ctx.lineWidth = 4; ctx.lineJoin = 'round'; ctx.strokeText(cleanTitle(stA.title), lx, y); // 텍스트 본문 ctx.fillStyle = 'rgba(255,200,200,1.0)'; ctx.fillText(cleanTitle(stA.title), lx, y); // 히트박스(마커 선 ~ 글자 끝) const tw = ctx.measureText(cleanTitle(stA.title)).width; hitBoxes.push({ kind: 'station', title: stA.title, x0: x - 8, y0: y - 12, x1: lx + tw + 2, y1: y + 12 }); if (labelOnScreen(x - 8, y - 12, lx + tw + 2, y + 12)) onScreenLabels.add(`station:${stA.title}`); }); } // POI 마커 — 연속 포즈로 직접 투영 → 이상치 거부 → EMA ctx.font = 'bold 20px sans-serif'; labelsA.poiMarkers.forEach((poiA) => { // 드론높이 모드면 연속 포즈 고도−이격거리, 아니면 저장된 지면고도 gz. const pz = poiDroneHeightRef.current ? dronePose.altitude - (params.geoidOffset ?? 0) - droneHeightDropRef.current : poiA.gz; const cc = toCameraCoords(dronePose, poiA.lat, poiA.lon, pz, params, worldOrigin); if (cc.Zc < CLIP_Z) return; const { pxRaw, pyRaw } = pixelFromCamera(cc, params); const prevDpoi = displayedPoiRef.current.get(poiA.title); const d = smoothStep(prevDpoi, pxRaw, pyRaw, α, smoothMinAlphaRef.current, smoothSpeedRefRef.current); seenPoi.add(poiA.title); // 픽셀 히스테리시스: 0.75px 이상 움직일 때만 정수 위치 갱신 → 저속(1배속) 깜빡임 제거. const psx0 = vx(d.x), psy0 = vy(d.y); const px = (prevDpoi?.rx !== undefined && Math.abs(psx0 - prevDpoi.rx) <= 0.75) ? prevDpoi.rx : Math.round(psx0); const py = (prevDpoi?.ry !== undefined && Math.abs(psy0 - prevDpoi.ry) <= 0.75) ? prevDpoi.ry : Math.round(psy0); d.rx = px; d.ry = py; displayedPoiRef.current.set(poiA.title, d); // 텍스트 박스만 표출(십자 마커/이모지 아이콘 없음): // 배경 = 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(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) => { 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 = 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) => { 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_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 ( <> {/* 나침반 미니맵 — 우측 상단(기존 캔버스 나침반 대체). 핀은 RAF 가 드론 방위로 회전. */} {geoDataLoaded && (compassType === 'map' ? : )} {/* 라벨 속성 팝업(다중) — 위치는 RAF 가 라벨을 따라 갱신. DOM 이라 텍스트 선명. 컴팩트(기본 5필드) ↔ 전체 토글은 팝업 클릭. 자동(auto) 팝업은 ✕ 없음(가시 구조물 따라 표시). ▶ 클립 컨테이너: 높이 = 영상영역(H − barHeight). overflow-hidden 으로 스테이션바 영역을 가려 팝업이 라벨 따라 내려가면 바 뒤로 슬라이드되어 사라짐(flip/걸침 없이). */}
{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 (
{ 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 && ( )}
{fields ? ( fields.map((p, i) => ( {p.k} {p.v} )) ) : ( <> 위도{pp.lat.toFixed(6)} 경도{pp.lon.toFixed(6)} )}
); })}
{showPanel && (
{/* 두 패널을 가로로 나란히(우→좌) 펼침 — 둘 다 열어도 세로로 안 쌓여 화면 위로 안 넘침. */}
{/* ── 화면표시 옵션 (카메라 외 모든 표시 설정) ── */} {showDisplay && (
표시 토글
{/* 나침반 타입 — 눈금(analog) / 지도(OSM, 노스업) 선택 */}
나침반
{/* 지도 나침반 배경 — 일반(OSM) / 위성(Esri). 지도 타입일 때만 노출. */} {compassType === 'map' && (
지도
)}
{/* 선형/드론궤적/좌측패널 토글은 하단 재생바(VideoPlayer)로 이동. 측점 진단 버튼은 삭제. */} {/* 경로표고·투명도는 드론궤적 ON/OFF 무관하게 항상 표시. */} setDronePathZ(Math.round(v))} /> setDronePathAlpha(v)} />
스무딩 (재계산 500ms 후)
setSmoothHalf(Math.round(v))} /> setEmaAlpha(v)} /> setSmoothMinAlpha(v)} /> setSmoothSpeedRef(v)} />
POI 필터 / 높이
setMaxPoiRange(Math.round(v))} /> {poiDroneHeight && ( setDroneHeightDrop(Math.round(v))} /> )}
POI 위치 편집 (마우스)
ℹ️ 라벨 클릭 → 속성 팝업(여러 개 누적, 재생 따라 이동). 빈 곳 클릭 → 팝업 닫기, 팝업 없으면 재생/정지.
{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}개`}
{editMode && selPoi && (
선택: {cleanTitle(selPoi)}
선로표고 {selZBase.toFixed(1)}m
)}
{ const f = e.target.files?.[0]; if (f) importOverridesFile(f); e.target.value = ''; }} />
)} {/* ── 카메라 파라미터 (드론 카메라 관련만) ── */} {showControls && (
자세 보정 오프셋(SRT + offset)
setParam('yawOffset', v)} /> {/* Yaw 자동 추정(2단계 보정의 1단계) — 대략 맞춘 뒤 'Yaw 보정' 드래그 모드로 마무리 */} setParam('pitch', v)} /> setParam('roll', v)} />
위치 보정 (드론 GPS 오프셋)
setParam('offX', v)} /> setParam('offY', v)} /> setParam('offZ', v)} /> setParam('geoidOffset', v)} />
정표고→타원체고 (대전≈25.8)
내부표정 (초점·주점·센서)
{/* 영상(djmd) 자동 감지 정보 + 카메라값 서버 저장('<영상 base>.camera.json' — 재로드 시 자동 적용) */}
📷 {storeCameraInfo ? `${storeCameraInfo.model}${storeCameraInfo.focalLen35 ? ` · ${storeCameraInfo.focalLen35}mm` : ''}${storeCameraInfo.fps ? ` · ${storeCameraInfo.fps}fps` : ''}` : '카메라 미감지'}
{storeCameraInfo?.focalLen35 !== undefined && ( )}
setParam('focalLen', v)} /> setParam('cx0', v)} /> setParam('cy0', v)} /> setParam('sensorW', v)} /> setParam('sensorH', v)} />
{panelDroneFrame && (
yaw: {((panelDroneFrame.yaw+params.yawOffset+360)%360).toFixed(1)}° pitch: {(panelDroneFrame.pitch+params.pitch).toFixed(1)}° roll: {(panelDroneFrame.roll+params.roll).toFixed(1)}°
f: {params.focalLen.toFixed(1)}mm hFOV: {(2*Math.atan((params.sensorW??36)/(2*params.focalLen))*180/Math.PI).toFixed(1)}°
offX: {params.offX.toFixed(1)}m offY: {params.offY.toFixed(1)}m offZ: {params.offZ.toFixed(1)}m
영상 {videoWidth || '?'}×{videoHeight || '?'} {videoWidth > 0 && videoHeight > 0 && ( ({(videoWidth / videoHeight).toFixed(3)} · cover 정렬) )}
)}
)}
)} ); }