feat: 지도 나침반(OSM/위성) 추가 + 측점 검색·마커 정확도 개선 + POI/영상 수정
- 지도 기반 나침반(노스업, 현재위치, 시야 역삼각형): 호버 확대·휠 줌·클릭 위성전환, OSM/Esri 타일 서버 프록시(/api/tile) - 스테이션 검색: 실제 측점(chain) 기준 이동, 없으면 '측점 없음' 안내 - 역 마커: 직교 투영 측점 일치 시에만 표시, 미도착 종점은 추가 방식 - POI 팝업 겹침/재등장·라벨 정합 수정 - 영상 fps 데이터 기반 자동 산출 - 기술/발표/쉬운설명 문서 추가 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { MutableRefObject } from 'react';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
|
||||
/**
|
||||
* 지도 기반 나침반 (노스업) — OSM 타일 위에 '현재 위치(중심 고정 점)' + '드론 시야(반투명 역삼각형)'.
|
||||
* - 노스업: 지도는 북쪽 위로 고정. 드론이 움직이면 지도가 팬(현재 위치가 항상 중앙).
|
||||
* - 시야 역삼각형(▽): 중심(드론)에서 진행/촬영 방향으로 벌어지는 반투명 부채꼴. yaw 로 회전.
|
||||
* - 마우스 오버: 위젯 자체가 커져(스케일 아님, 타일 재배치) 더 넓은 영역을 선명하게 표시.
|
||||
* 휠로 줌 인/아웃. 벗어나면 기본 크기·기본 줌으로 복귀.
|
||||
* - poseRef: 부모 RAF 가 매 프레임 { lat, lon, yaw(도, 0=N 시계+) } 갱신 → 내부 RAF 가 읽어
|
||||
* 역삼각형 회전(즉시) + 지도 팬(transform) + 임계 이동/줌·크기 변경 시 타일 재배치.
|
||||
* - 타일은 서버 프록시(/api/tile) 경유(외부 직접 로드는 CSP img-src/COEP 로 차단).
|
||||
*/
|
||||
|
||||
export interface CompassPose { lat: number; lon: number; yaw: number }
|
||||
|
||||
const BASE_D = 200; // 기본 지름(px)
|
||||
const HOVER_D = 400; // 오버 시 지름(px, 2배) — 위젯 확대(스케일 아님 → 더 넓은 영역, 선명)
|
||||
const TILE = 256; // OSM 타일 크기
|
||||
const MARGIN = TILE; // 팬 중 빈틈 방지용 여유 타일 폭
|
||||
const RELAYOUT_PX = 96; // 중심이 이만큼 벗어나면 타일 재배치(팬 리셋)
|
||||
const DEFAULT_ZOOM = 16;
|
||||
const MIN_ZOOM = 12;
|
||||
const MAX_ZOOM = 19;
|
||||
|
||||
/** 위경도 → 전역 픽셀(Web Mercator, zoom z). */
|
||||
function project(lat: number, lon: number, z: number): { x: number; y: number } {
|
||||
const n = 2 ** z;
|
||||
const x = ((lon + 180) / 360) * n * TILE;
|
||||
const s = Math.min(0.9999, Math.max(-0.9999, Math.sin((lat * Math.PI) / 180)));
|
||||
const y = (0.5 - Math.log((1 + s) / (1 - s)) / (4 * Math.PI)) * n * TILE;
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
export function MapCompass({ poseRef }: { poseRef: MutableRefObject<CompassPose> }) {
|
||||
const mapStyle = useSettingsStore((s) => s.compassMapStyle); // 'street' | 'sat'
|
||||
const setMapStyle = useSettingsStore((s) => s.setCompassMapStyle);
|
||||
const src = mapStyle === 'sat' ? 'sat' : 'osm';
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const [zoom, setZoomState] = useState(DEFAULT_ZOOM);
|
||||
const zoomRef = useRef(zoom);
|
||||
useEffect(() => { zoomRef.current = zoom; }, [zoom]);
|
||||
const setZoom = (z: number): void => setZoomState(Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, Math.round(z))));
|
||||
|
||||
const D = hovered ? HOVER_D : BASE_D;
|
||||
const HALF = D / 2;
|
||||
|
||||
// 타일 배치 기준 중심(이 좌표에 맞춰 타일을 깔고, 현재위치와의 차이는 transform 으로 팬).
|
||||
const [tileCenter, setTileCenter] = useState<{ lat: number; lon: number } | null>(null);
|
||||
const arrowRef = useRef<HTMLDivElement>(null);
|
||||
const layerRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const tileCenterRef = useRef<{ lat: number; lon: number } | null>(null);
|
||||
useEffect(() => { tileCenterRef.current = tileCenter; }, [tileCenter]);
|
||||
|
||||
// 줌 변경 시 현재 위치 기준으로 타일 재배치(줌 전환 튐 방지).
|
||||
useEffect(() => {
|
||||
const p = poseRef.current;
|
||||
if (p && isFinite(p.lat) && isFinite(p.lon)) setTileCenter({ lat: p.lat, lon: p.lon });
|
||||
}, [zoom, poseRef]);
|
||||
|
||||
// 휠 줌(페이지 스크롤 대신). native 리스너(passive:false)로 preventDefault.
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const onWheel = (e: WheelEvent): void => {
|
||||
e.preventDefault();
|
||||
setZoom(zoomRef.current + (e.deltaY < 0 ? 1 : -1)); // 위로 스크롤 = 확대
|
||||
};
|
||||
el.addEventListener('wheel', onWheel, { passive: false });
|
||||
return () => el.removeEventListener('wheel', onWheel);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let raf = 0;
|
||||
const tick = (): void => {
|
||||
raf = requestAnimationFrame(tick);
|
||||
const p = poseRef.current;
|
||||
if (!p || !isFinite(p.lat) || !isFinite(p.lon)) return;
|
||||
const z = zoomRef.current;
|
||||
if (arrowRef.current) arrowRef.current.style.transform = `rotate(${p.yaw}deg)`;
|
||||
const tc = tileCenterRef.current;
|
||||
if (!tc) { setTileCenter({ lat: p.lat, lon: p.lon }); return; }
|
||||
const cur = project(p.lat, p.lon, z);
|
||||
const base = project(tc.lat, tc.lon, z);
|
||||
const dx = cur.x - base.x, dy = cur.y - base.y;
|
||||
if (layerRef.current) layerRef.current.style.transform = `translate(${-dx}px, ${-dy}px)`;
|
||||
if (Math.hypot(dx, dy) > RELAYOUT_PX) setTileCenter({ lat: p.lat, lon: p.lon });
|
||||
};
|
||||
raf = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [poseRef]);
|
||||
|
||||
// 벗어나면 기본값 복귀(기본 크기 + 기본 줌).
|
||||
const onLeave = (): void => { setHovered(false); setZoomState(DEFAULT_ZOOM); };
|
||||
|
||||
// 타일 배치 — 기준 중심(tileCenter)을 뷰포트 중앙에 두고, 뷰포트+여유를 덮는 타일 나열.
|
||||
const tiles: { key: string; url: string; left: number; top: number }[] = [];
|
||||
if (tileCenter) {
|
||||
const c = project(tileCenter.lat, tileCenter.lon, zoom);
|
||||
const n = 2 ** zoom;
|
||||
const minTx = Math.floor((c.x - HALF - MARGIN) / TILE);
|
||||
const maxTx = Math.floor((c.x + HALF + MARGIN) / TILE);
|
||||
const minTy = Math.floor((c.y - HALF - MARGIN) / TILE);
|
||||
const maxTy = Math.floor((c.y + HALF + MARGIN) / TILE);
|
||||
for (let tx = minTx; tx <= maxTx; tx++) {
|
||||
for (let ty = minTy; ty <= maxTy; ty++) {
|
||||
if (ty < 0 || ty >= n) continue;
|
||||
const wx = ((tx % n) + n) % n; // 경도 래핑
|
||||
tiles.push({
|
||||
key: `${src}_${zoom}_${tx}_${ty}`,
|
||||
url: `/api/tile/${src}/${zoom}/${wx}/${ty}`,
|
||||
left: tx * TILE - c.x + HALF,
|
||||
top: ty * TILE - c.y + HALF,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={onLeave}
|
||||
onClick={() => setMapStyle(mapStyle === 'sat' ? 'street' : 'sat')}
|
||||
title="클릭: 위성 ↔ 일반 전환"
|
||||
style={{ position: 'absolute', top: 14, right: 14, width: D, height: D, zIndex: hovered ? 40 : 30, pointerEvents: 'auto', cursor: 'pointer' }}
|
||||
>
|
||||
{/* 원형 클립 지도 */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute', inset: 0, borderRadius: '50%', overflow: 'hidden',
|
||||
background: 'rgba(16,22,32,0.55)', border: '3px solid rgba(255,255,255,0.92)', boxSizing: 'border-box',
|
||||
}}
|
||||
>
|
||||
{/* 팬 레이어 — RAF 가 transform 으로 이동. 타일은 tileCenter 기준 배치. */}
|
||||
<div ref={layerRef} style={{ position: 'absolute', inset: 0, willChange: 'transform' }}>
|
||||
{tiles.map((t) => (
|
||||
<img
|
||||
key={t.key} src={t.url} width={TILE} height={TILE} alt="" draggable={false}
|
||||
style={{ position: 'absolute', left: t.left, top: t.top, maxWidth: 'none' }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{!tileCenter && (
|
||||
<div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'rgba(255,255,255,0.7)', fontSize: 11 }}>
|
||||
지도 로딩…
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 드론 시야(역삼각형 ▽) — 중심(드론)에서 촬영방향으로 벌어짐. yaw 로 회전.
|
||||
주황 그라데이션: 근거리(드론) 진하게 → 멀수록 투명. 보더 없음. */}
|
||||
<div ref={arrowRef} style={{ position: 'absolute', inset: 0, transformOrigin: '50% 50%' }}>
|
||||
<svg viewBox="0 0 100 100" width={D} height={D} style={{ position: 'absolute', inset: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="fovGrad" gradientUnits="userSpaceOnUse" x1="50" y1="50" x2="50" y2="13">
|
||||
<stop offset="0%" stopColor="#ff8800" stopOpacity="0.85" />
|
||||
<stop offset="100%" stopColor="#ff8800" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{/* 꼭짓점이 현재위치 점에 붙음(50). */}
|
||||
<polygon points="50,50 27,13 73,13" fill="url(#fovGrad)" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* 현재 위치 점(중심) + 상단 N(노스업 고정, 크게·붉은·반투명) — SVG 라 위젯 크기에 맞춰 스케일. */}
|
||||
<svg viewBox="0 0 100 100" width={D} height={D} style={{ position: 'absolute', inset: 0 }}>
|
||||
{/* 현재 위치 점 — 빨강 단색, 보더 없음. */}
|
||||
<circle cx="50" cy="50" r="3.6" fill="rgba(226,35,26,0.8)" />
|
||||
<text
|
||||
x="50" y="13.5" textAnchor="middle" fontSize="15" fontWeight="800"
|
||||
fill="#e22319" textLength="9" lengthAdjust="spacingAndGlyphs"
|
||||
>N</text>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { forwardRef } from 'react';
|
||||
* - 상단 흰 삼각형 인덱스 + 외곽 링은 고정(위 = 드론 진행방향 = 헤딩).
|
||||
* ref 는 root div — `style.setProperty('--rot', `${-yaw}deg`)`.
|
||||
*/
|
||||
const D = 150; // 나침반 한 변(px)
|
||||
const D = 200; // 나침반 한 변(px) — 지도 나침반(BASE_D)과 동일
|
||||
|
||||
// 눈금: 6° 간격(60개). 0/90/180/270=장, 30°배수=중, 그 외=단.
|
||||
const TICKS = Array.from({ length: 60 }, (_, i) => {
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
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;
|
||||
@@ -253,6 +254,12 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
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);
|
||||
@@ -268,6 +275,8 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
// 나침반 미니맵(DOM) root — RAF 에서 --rot(=-yaw) 갱신. minimapRotRef=누적 회전(360° 언랩).
|
||||
const minimapRef = useRef<HTMLDivElement>(null);
|
||||
const minimapRotRef = useRef(0);
|
||||
// 지도 나침반용 현재 위치/방위 — RAF 가 매 프레임 갱신, MapCompass 가 읽음.
|
||||
const mapPoseRef = useRef<CompassPose>({ lat: 0, lon: 0, yaw: 0 });
|
||||
|
||||
// UI state
|
||||
const [params, setParams] = useState<CameraParams>(DEFAULT_CAMERA_PARAMS);
|
||||
@@ -324,7 +333,17 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
auto?: boolean };
|
||||
const [infoPopups, setInfoPopups] = useState<InfoPopup[]>([]);
|
||||
const infoPopupsRef = useRef<InfoPopup[]>([]);
|
||||
useEffect(() => { infoPopupsRef.current = infoPopups; }, [infoPopups]);
|
||||
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 인터벌이 읽음.
|
||||
@@ -332,6 +351,7 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
const popupElsRef = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
const popupMissRef = useRef<Map<string, number>>(new Map()); // 라벨이 화면 밖인 연속 프레임 수(제거 유예)
|
||||
const popupPosRef = useRef<Map<string, { x: number; y: number }>>(new Map()); // 팝업 위치 EMA(물결 방지)
|
||||
const popupFlipRef = useRef<Map<string, 'above' | 'below'>>(new Map()); // 팝업 배치면(위/아래) — 히스테리시스로 경계 왕복(번쩍임) 방지
|
||||
// 라벨 히트박스(아이콘+글자 영역, 화면 px) — RAF 가 매 프레임 갱신, 클릭/호버 히트테스트에 사용.
|
||||
const labelHitRef = useRef<{ kind: 'poi' | 'station'; title: string; x0: number; y0: number; x1: number; y1: number }[]>([]);
|
||||
const hasPopupRef = useRef(false);
|
||||
@@ -339,6 +359,9 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
const poiDroneHeightRef = useRef(DISPLAY_DEFAULTS.poiDroneHeight);
|
||||
const droneHeightDropRef = useRef(DISPLAY_DEFAULTS.droneHeightDrop);
|
||||
const overridesRef = useRef<PoiOverrideMap>({});
|
||||
// POI 라벨이 연속으로 화면 안에 그려진 프레임 수(title→streak). 팝업 '추가'에 히스테리시스를 줘
|
||||
// 경계에서 라벨이 미세하게 깜빡일 때(1~2프레임) 팝업이 재생성돼 번쩍이는 것을 막는다.
|
||||
const poiOnScreenStreakRef = useRef<Map<string, number>>(new Map());
|
||||
// 드래그 상태: 선택 POI + lat/lon(고정) + 시작 z + 시작 화면좌표. 드롭 시 세로위치로 z 역산.
|
||||
const dragRef = useRef<{ title: string; lat: number; lon: number; z0: number; sx: number; sy: number } | null>(null);
|
||||
const dragPosRef = useRef<{ x: number; y: number } | null>(null);
|
||||
@@ -370,7 +393,8 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
useEffect(() => { editModeRef.current = editMode; }, [editMode]);
|
||||
useEffect(() => { fovModeRef.current = fovMode; }, [fovMode]);
|
||||
|
||||
// 컴팩트 자동 팝업 동기화 — 가시 구조물(visStructRef)마다 라벨 옆 DOM 팝업을 자동 생성/제거.
|
||||
// 컴팩트 자동 팝업 동기화 — visStructRef(연속 온스크린 라벨) 마다 라벨 옆 DOM 팝업을 자동 '생성'만.
|
||||
// 제거는 RAF 가 단일 권한(onScreenLabels + 4프레임 유예)으로 담당 → add/remove 충돌(번쩍임) 제거.
|
||||
// (캔버스 텍스트는 흐려서 DOM 팝업으로 선명하게. 위치는 RAF 가 displayed 좌표로 매 프레임 갱신.)
|
||||
// 60fps 루프에서 setState 남발 방지 → 150ms 인터벌로 가시집합 변화시에만 갱신.
|
||||
useEffect(() => {
|
||||
@@ -392,12 +416,10 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
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) {
|
||||
if (toAdd.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 ids = new Set(prev.map(p => p.id));
|
||||
return [...prev, ...toAdd.filter(p => !ids.has(p.id))];
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -449,6 +471,10 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
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]);
|
||||
@@ -833,11 +859,12 @@ export default function StationOverlay({ currentFrame, currentTime, fps, 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);
|
||||
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 / VIDEO_FPS - currentTime);
|
||||
const d = Math.abs(frames[i].frame / vfps - currentTime);
|
||||
if (d < bestD) { bestD = d; best = frames[i]; bestIdx = i; }
|
||||
if (bestD < 1 / VIDEO_FPS / 2) break;
|
||||
if (bestD < 1 / vfps / 2) break;
|
||||
}
|
||||
currentFrameNumRef.current = best.frame;
|
||||
currentFrameIdxRef.current = bestIdx;
|
||||
@@ -933,6 +960,16 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
|
||||
// 이번 프레임 라벨 히트박스(아이콘+글자, 화면 px) 수집 → 끝에서 labelHitRef 갱신.
|
||||
const hitBoxes: { kind: 'poi' | 'station'; title: string; x0: number; y0: number; x1: number; y1: number }[] = [];
|
||||
// 이번 프레임에 '실제로 화면 안에 그려진' 라벨 id(`kind:title`) 집합. 팝업 표시의 단일 기준 —
|
||||
// 십자마커/텍스트가 뷰포트 안에 있을 때만 채운다. 팝업은 이 집합에 있을 때만 보이게 해
|
||||
// "라벨은 화면 밖(안 보임)인데 팝업만 clamp 되어 뜨는" 문제를 원천 차단한다.
|
||||
const onScreenLabels = new Set<string>();
|
||||
// 유효 하단 = 화면 높이 − 스테이션바 높이. 팝업/라벨 가시 기준을 '모니터'가 아니라
|
||||
// '영상 재생 영역(스테이션바 위쪽)' 으로 잡는다. (라벨이 바 밑으로 내려가면 사라지게)
|
||||
const HB = H - barHeightRef.current;
|
||||
// 히트박스가 '영상 영역(0..W, 0..HB)' 과 겹치는지(라벨이 눈에 보이는지). -2px 안쪽 요구로 경계 애매함 제거.
|
||||
const labelOnScreen = (x0: number, y0: number, x1: number, y1: number): boolean =>
|
||||
x1 >= 2 && x0 <= W - 2 && y1 >= 2 && y0 <= HB - 2;
|
||||
|
||||
const cache = renderCacheRef.current;
|
||||
if (cache) {
|
||||
@@ -941,7 +978,7 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
const estTime = timeRef
|
||||
? timeRef.current
|
||||
: currentTimeSecRef.current + (performance.now() - timeUpdateWallRef.current) / 1000;
|
||||
const estFrame = estTime * VIDEO_FPS;
|
||||
const estFrame = estTime * fpsRef.current;
|
||||
|
||||
// 연속 보간 포즈(라인·라벨 공통) — 매 RAF 프레임 직접 투영해 부드럽게.
|
||||
const dronePose = poseAt(estFrame) ?? (allDroneFramesRef.current.length
|
||||
@@ -951,7 +988,7 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
|
||||
// 나침반 미니맵(heading-up) 회전 — 영상과 즉시 동기. dronePose.yaw 는 ±smoothHalf(기본 60fr ≈2s)
|
||||
// 평활이라 회전 시 지연 → 가벼운 평활(±3fr)의 yaw 를 따로 보간해 사용. 360° 누적 언랩(점프 방지).
|
||||
if (minimapRef.current) {
|
||||
{
|
||||
const arr = allDroneFramesRef.current;
|
||||
let rawYaw = dronePose.yaw;
|
||||
if (arr.length) {
|
||||
@@ -964,10 +1001,18 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
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`);
|
||||
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`);
|
||||
}
|
||||
}
|
||||
|
||||
// 선로 중심선 (선형) — 독립 토글
|
||||
@@ -1063,6 +1108,7 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
// 히트박스(마커 선 ~ 글자 끝)
|
||||
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}`);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1109,15 +1155,25 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
// 히트박스(십자 마커 ~ 글자 끝) — 아이콘·글자 어디를 눌러도 선택되게. (라벨 행 위치 기준)
|
||||
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 });
|
||||
const poiVisible = labelOnScreen(px - r, labelY - 13, lx + tw + 2, labelY + 13);
|
||||
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';
|
||||
// 컴팩트 팝업 자동표시: 단일 라벨만(동일좌표 다중행은 팝업 겹침 방지 위해 제외 → 클릭 시 개별 표시).
|
||||
if (poiA.compact.length && poiA.labelRowCount <= 1) visStructRef.current.set(poiA.title, { category: poiA.category, compact: poiA.compact });
|
||||
// 컴팩트 팝업 자동생성 후보 등록(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
|
||||
@@ -1149,28 +1205,34 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
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 크롭 포함)이면 '사라짐'으로 간주.
|
||||
// 팝업 표시 = '이 프레임에 라벨이 실제로 화면 안에 그려졌는가'(onScreenLabels)로만 판정.
|
||||
// 좌표 ±16 슬랙 + clamp 조합은 라벨(특히 텍스트)이 화면 밖인데 팝업만 clamp 되어 뜨는
|
||||
// 문제를 만들었음 → 라벨 가시성과 완전히 일치시킨다.
|
||||
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;
|
||||
const off = !disp || !onScreenLabels.has(id);
|
||||
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';
|
||||
// 라벨이 화면에서 벗어나면 '즉시' 제거(유예 없음) → 하단 이탈 즉시 사라짐.
|
||||
// 유예를 두면 그 사이 흔들림으로 라벨이 경계로 잠깐 되돌아올 때 팝업이 다시 보였다 사라짐.
|
||||
// 재등장은 sync 의 ADD_STREAK(연속 안정 프레임) 게이트가 막는다(짧은 복귀로는 재생성 안 됨).
|
||||
el.style.visibility = 'hidden';
|
||||
toRemove.push(id);
|
||||
return;
|
||||
}
|
||||
popupMissRef.current.set(id, 0);
|
||||
const ph = el.offsetHeight || 120;
|
||||
const pw = el.offsetWidth || 240;
|
||||
// 기본은 라벨 '아래'. 아래 공간이 부족하면(화면 하단 근접) 라벨 '위'로 플립 →
|
||||
// 라벨과 겹치거나 화면 밖으로 잘리는 것을 방지. 위로도 부족하면 화면 안으로 클램프.
|
||||
const GAP = 16;
|
||||
// 라벨(십자+글자)은 sy 를 중심으로 상하 ~LABEL_HALF 만큼 차지한다. GAP 을 sy(중심) 기준으로
|
||||
// 잡으면 라벨 높이만큼 먹혀 팝업이 글자에 붙는다 → 라벨 '바깥 가장자리' 기준으로 띄운다.
|
||||
const LABEL_HALF = 15; // 라벨 반높이(px) — sy 중심 ± 이 값이 글자/아이콘 영역
|
||||
const GAP = 12; // 라벨 가장자리 ~ 팝업 사이 실제 여백
|
||||
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);
|
||||
}
|
||||
// 기본은 라벨 '아래'. 아래 공간이 부족하면(화면 하단 근접) 라벨 '위'로 플립.
|
||||
// ※ 히스테리시스(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;
|
||||
@@ -1183,7 +1245,7 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
el.style.top = `${Math.round(ny)}px`;
|
||||
});
|
||||
if (toRemove.length) {
|
||||
toRemove.forEach(id => { popupMissRef.current.delete(id); popupPosRef.current.delete(id); });
|
||||
toRemove.forEach(id => { popupMissRef.current.delete(id); popupPosRef.current.delete(id); popupFlipRef.current.delete(id); });
|
||||
setInfoPopups(prev => prev.filter(p => !toRemove.includes(p.id)));
|
||||
}
|
||||
}
|
||||
@@ -1442,10 +1504,15 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
/>
|
||||
|
||||
{/* 나침반 미니맵 — 우측 상단(기존 캔버스 나침반 대체). 핀은 RAF 가 드론 방위로 회전. */}
|
||||
{geoDataLoaded && <Minimap ref={minimapRef} />}
|
||||
{geoDataLoaded && (compassType === 'map'
|
||||
? <MapCompass poseRef={mapPoseRef} />
|
||||
: <Minimap ref={minimapRef} />)}
|
||||
|
||||
{/* 라벨 속성 팝업(다중) — 위치는 RAF 가 라벨을 따라 갱신. DOM 이라 텍스트 선명.
|
||||
컴팩트(기본 5필드) ↔ 전체 토글은 팝업 클릭. 자동(auto) 팝업은 ✕ 없음(가시 구조물 따라 표시). */}
|
||||
컴팩트(기본 5필드) ↔ 전체 토글은 팝업 클릭. 자동(auto) 팝업은 ✕ 없음(가시 구조물 따라 표시).
|
||||
▶ 클립 컨테이너: 높이 = 영상영역(H − barHeight). overflow-hidden 으로 스테이션바 영역을 가려
|
||||
팝업이 라벨 따라 내려가면 바 뒤로 슬라이드되어 사라짐(flip/걸침 없이). */}
|
||||
<div className="absolute left-0 right-0 top-0 overflow-hidden pointer-events-none z-40" style={{ bottom: barHeight }}>
|
||||
{infoPopups.map(pp => {
|
||||
const showFull = pp.expanded || !pp.compact || pp.compact.length === 0;
|
||||
// 전체 보기: 컴팩트 항목을 같은 순서로 맨 위에 + 나머지 속성 → 클릭 전/후 순서 일치.
|
||||
@@ -1455,11 +1522,20 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
return (
|
||||
<div
|
||||
key={pp.id}
|
||||
ref={el => { if (el) popupElsRef.current.set(pp.id, el); else popupElsRef.current.delete(pp.id); }}
|
||||
ref={el => {
|
||||
if (el) {
|
||||
// RAF 가 위치를 확정(popupPosRef 등록)하기 전인 '신규' 팝업은 숨겨서 mount →
|
||||
// 화면 밖 라벨의 팝업이 직전(stale) 위치에 한 프레임 번쩍이는 것 방지.
|
||||
// RAF 가 온스크린 확인 후 visibility:visible + 위치 지정(off 면 계속 숨김→제거).
|
||||
// visibility 는 RAF 가 명령형으로만 관리(style prop 미포함) → 재렌더에도 유지.
|
||||
if (!popupPosRef.current.has(pp.id)) el.style.visibility = 'hidden';
|
||||
popupElsRef.current.set(pp.id, el);
|
||||
} else popupElsRef.current.delete(pp.id);
|
||||
}}
|
||||
onClick={() => setInfoPopups(prev => prev.map(p => p.id === pp.id ? { ...p, expanded: !p.expanded } : p))}
|
||||
title={showFull ? '클릭 → 접기' : '클릭 → 전체 항목'}
|
||||
className="absolute z-40 max-w-[260px] bg-black/90 border border-emerald-500/70 rounded-md shadow-xl text-white text-[11px] px-2 py-1.5 pointer-events-auto cursor-pointer"
|
||||
style={{ left: Math.min(Math.max(8, pp.sx - 10), (canvasSizeRef.current.w || 9999) - 240), top: Math.min(Math.max(8, pp.sy + 16), (canvasSizeRef.current.h || 9999) - 140) }}
|
||||
style={{ left: Math.min(Math.max(8, pp.sx - 10), (canvasSizeRef.current.w || 9999) - 240), top: Math.min(Math.max(8, pp.sy + 27), (canvasSizeRef.current.h || 9999) - 140) }}
|
||||
>
|
||||
{!pp.auto && (
|
||||
<button onClick={e => { e.stopPropagation(); setInfoPopups(prev => prev.filter(p => p.id !== pp.id)); }} className="absolute top-0.5 right-1 text-gray-400 hover:text-white leading-none text-[12px]">✕</button>
|
||||
@@ -1482,6 +1558,7 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{showPanel && (
|
||||
<div className="absolute right-2 z-30 flex flex-col-reverse items-end gap-2" style={{ bottom: (barHeight || 130) + 8 }}>
|
||||
@@ -1502,6 +1579,38 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
{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>
|
||||
{/* 나침반 타입 — 눈금(analog) / 지도(OSM, 노스업) 선택 */}
|
||||
<div className="flex items-center gap-1.5 text-[11px] mb-3">
|
||||
<span className="text-gray-400 w-12 shrink-0 text-right">나침반</span>
|
||||
<div className="flex-1 flex gap-1">
|
||||
<button
|
||||
onClick={() => setCompassType('analog')}
|
||||
className={`flex-1 px-2 py-1 rounded border text-[11px] ${compassType === 'analog' ? 'bg-amber-400 border-amber-300 text-black' : 'bg-black/60 border-gray-700 text-gray-300 hover:bg-black/80'}`}
|
||||
>눈금</button>
|
||||
<button
|
||||
onClick={() => setCompassType('map')}
|
||||
className={`flex-1 px-2 py-1 rounded border text-[11px] ${compassType === 'map' ? 'bg-amber-400 border-amber-300 text-black' : 'bg-black/60 border-gray-700 text-gray-300 hover:bg-black/80'}`}
|
||||
title="지도 위 현재 위치(노스업). 인터넷 필요."
|
||||
>지도</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* 지도 나침반 배경 — 일반(OSM) / 위성(Esri). 지도 타입일 때만 노출. */}
|
||||
{compassType === 'map' && (
|
||||
<div className="flex items-center gap-1.5 text-[11px] mb-3">
|
||||
<span className="text-gray-400 w-12 shrink-0 text-right">지도</span>
|
||||
<div className="flex-1 flex gap-1">
|
||||
<button
|
||||
onClick={() => setCompassMapStyle('street')}
|
||||
className={`flex-1 px-2 py-1 rounded border text-[11px] ${compassMapStyle === 'street' ? 'bg-amber-400 border-amber-300 text-black' : 'bg-black/60 border-gray-700 text-gray-300 hover:bg-black/80'}`}
|
||||
>일반</button>
|
||||
<button
|
||||
onClick={() => setCompassMapStyle('sat')}
|
||||
className={`flex-1 px-2 py-1 rounded border text-[11px] ${compassMapStyle === 'sat' ? 'bg-amber-400 border-amber-300 text-black' : 'bg-black/60 border-gray-700 text-gray-300 hover:bg-black/80'}`}
|
||||
title="Esri World Imagery 위성 영상"
|
||||
>위성</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2 mb-3">
|
||||
{/* 선형/드론궤적/좌측패널 토글은 하단 재생바(VideoPlayer)로 이동. 측점 진단 버튼은 삭제. */}
|
||||
{/* 경로표고·투명도는 드론궤적 ON/OFF 무관하게 항상 표시. */}
|
||||
|
||||
@@ -56,7 +56,7 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
|
||||
const { playerRef, loadLocalFile, loadServerStream, switchToHls, getVideoElement } =
|
||||
useVideoPlayer(containerRef);
|
||||
|
||||
const { stepForward, stepBackward, fps } = useFrameStep(playerRef);
|
||||
const { stepForward, stepBackward } = useFrameStep(playerRef);
|
||||
const { currentTime, duration, playing, source, playbackRate, videoReady, videoWidth, videoHeight } = usePlayerStore();
|
||||
|
||||
// 커서 매끄러운 이동:
|
||||
@@ -141,6 +141,15 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
|
||||
const videoFile = await loadFromFolder(files);
|
||||
if (videoFile) loadLocalFile(videoFile);
|
||||
else console.warn('[geo] 폴더에 영상 파일(mp4/webm)이 없습니다 — 지리정보만 로드');
|
||||
// KMZ(POI·구조물 원본) 필수 — 측점/드론은 있는데 KMZ만 빠진 경우 = 데이터 누락.
|
||||
const { kmzMissing, stations, frames } = useGeoStore.getState();
|
||||
if (kmzMissing && (stations.length > 0 || frames.length > 0)) {
|
||||
alert(
|
||||
'KMZ(POI·구조물 원본)가 폴더에 없습니다.\n' +
|
||||
'POI·구조물이 표시되지 않습니다. KMZ를 포함해 데이터를 재구축·전달하세요.\n' +
|
||||
'(측점·드론 정보는 정상 로드되었습니다.)',
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[geo] 폴더 로드 실패', err);
|
||||
}
|
||||
@@ -256,10 +265,23 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
|
||||
})();
|
||||
};
|
||||
|
||||
// VIDEO_FPS: 영상 실제 fps (29.97 = 30000/1001). Python SRT FrameCnt 기준과 일치.
|
||||
// stableFps(VFC 감지)는 31fps 오감지가 있어 프레임 번호 표시에는 사용하지 않음.
|
||||
const VIDEO_FPS = 30000 / 1001;
|
||||
const frame = secondsToFrame(currentTime, VIDEO_FPS);
|
||||
// 영상 실제 fps — 데이터(드론 CSV 마지막 프레임 번호) ÷ 영상 길이(초) 로 자동 산출 후
|
||||
// 표준 fps(24/25/29.97/30/50/60 등) 중 가까운 값에 스냅. 영상마다 fps 가 달라도 자동 대응.
|
||||
// (드론 CSV 엔 시간이 없고 frame_cnt 만 있어, 영상 length 와 결합해야 fps 를 알 수 있다.)
|
||||
// 데이터/영상길이 미확보 시 29.97 폴백. VFC 자동감지는 31fps 오감지가 있어 사용 안 함.
|
||||
const effectiveFps = useMemo(() => {
|
||||
const FALLBACK = 30000 / 1001;
|
||||
if (!storeFrames.length || !duration || duration <= 0) return FALLBACK;
|
||||
let maxF = 0;
|
||||
for (const f of storeFrames) if (f.frame > maxF) maxF = f.frame;
|
||||
if (maxF <= 0) return FALLBACK;
|
||||
const raw = maxF / duration; // 마지막 프레임 번호 ÷ 영상 길이(초)
|
||||
const STD = [23.976, 24, 25, 29.97, 30, 50, 59.94, 60];
|
||||
let best = STD[0], bd = Math.abs(raw - STD[0]);
|
||||
for (const s of STD) { const d = Math.abs(raw - s); if (d < bd) { bd = d; best = s; } }
|
||||
return bd <= best * 0.1 ? best : raw; // 표준값 ±10% 이내면 스냅, 아니면 원시값
|
||||
}, [storeFrames, duration]);
|
||||
const frame = secondsToFrame(currentTime, effectiveFps);
|
||||
const videoId = source?.kind === 'server' ? source.videoId : null;
|
||||
|
||||
// 드론 정보(측점진단) — 현재 프레임에 가장 가까운 드론 프레임의 GPS/고도만 표시.
|
||||
@@ -330,7 +352,7 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
|
||||
</div>
|
||||
{/* 영상 프레임 표시 패널 — 프레임 번호를 고정폭(6ch)으로 묶어 자릿수가 늘어도 폭 불변 */}
|
||||
<span className="bg-black/70 text-gray-200 text-xs px-2 py-1 rounded font-mono whitespace-nowrap">
|
||||
{secondsToTimecode(currentTime)} | F<span className="inline-block text-left" style={{ minWidth: '6ch' }}>{frame}</span> | {fps}fps
|
||||
{secondsToTimecode(currentTime)} | F<span className="inline-block text-left" style={{ minWidth: '6ch' }}>{frame}</span> | {effectiveFps.toFixed(2)}fps
|
||||
</span>
|
||||
{geoLoaded && (
|
||||
<>
|
||||
@@ -431,7 +453,7 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
|
||||
currentFrame={frame}
|
||||
currentTime={currentTime}
|
||||
timeRef={smoothTimeRef}
|
||||
fps={fps}
|
||||
fps={effectiveFps}
|
||||
visible={showStations}
|
||||
videoReady={videoReady}
|
||||
videoWidth={videoWidth}
|
||||
@@ -495,7 +517,7 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
|
||||
const input = (e.currentTarget.elements.namedItem('frameInput') as HTMLInputElement);
|
||||
const frameNum = parseInt(input.value, 10);
|
||||
if (!isNaN(frameNum)) {
|
||||
playerRef.current?.currentTime(frameNum / VIDEO_FPS);
|
||||
playerRef.current?.currentTime(frameNum / effectiveFps);
|
||||
}
|
||||
input.blur();
|
||||
}}
|
||||
|
||||
@@ -19,6 +19,9 @@ const UNSUPPORTED_CODECS = new Set(['hevc', 'h265', 'hvc1', 'hev1']);
|
||||
export function useVideoPlayer(containerRef: React.RefObject<HTMLDivElement | null>) {
|
||||
const playerRef = useRef<Player | null>(null);
|
||||
const hlsRef = useRef<Hls | null>(null);
|
||||
// 현재 로컬 재생 중인 blob URL. 소스 교체/해제 시점을 결정적으로 관리(이벤트 기반 해제는 교체 시
|
||||
// 새 URL까지 조기 revoke 되는 race 가 있어 ref 로 직접 추적한다).
|
||||
const objectUrlRef = useRef<string | null>(null);
|
||||
const store = usePlayerStore();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -61,6 +64,10 @@ export function useVideoPlayer(containerRef: React.RefObject<HTMLDivElement | nu
|
||||
return () => {
|
||||
hlsRef.current?.destroy();
|
||||
hlsRef.current = null;
|
||||
if (objectUrlRef.current) {
|
||||
URL.revokeObjectURL(objectUrlRef.current);
|
||||
objectUrlRef.current = null;
|
||||
}
|
||||
if (playerRef.current && !playerRef.current.isDisposed()) {
|
||||
playerRef.current.dispose();
|
||||
playerRef.current = null;
|
||||
@@ -77,15 +84,22 @@ export function useVideoPlayer(containerRef: React.RefObject<HTMLDivElement | nu
|
||||
hlsRef.current?.destroy();
|
||||
hlsRef.current = null;
|
||||
|
||||
// 재생 중 폴더 교체 지원: 새 src 설정 후 이전 blob URL 을 해제한다.
|
||||
// (player.src() 가 새 소스를 채택한 뒤이므로 prevUrl 은 더 이상 참조되지 않아 안전)
|
||||
const prevUrl = objectUrlRef.current;
|
||||
// 교체 직전 재생 중이었으면(=드롭 교체) 새 영상도 이어서 재생. 최초 로드는 prevUrl 없음 → 자동재생 안 함.
|
||||
const wasPlaying = prevUrl != null && !player.paused();
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
objectUrlRef.current = objectUrl;
|
||||
player.src({ src: objectUrl, type: file.type || 'video/mp4' });
|
||||
store.setSource({ kind: 'local', file, objectUrl });
|
||||
store.setHlsReady(false);
|
||||
|
||||
// Revoke objectUrl when video element is reset
|
||||
player.one('emptied', () => {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
});
|
||||
if (prevUrl) URL.revokeObjectURL(prevUrl);
|
||||
if (wasPlaying) {
|
||||
// 드롭은 사용자 제스처라 자동재생 정책에 막히지 않음. 준비되면 재생(실패는 무시).
|
||||
const p = player.play();
|
||||
if (p && typeof p.catch === 'function') p.catch(() => {});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
@@ -95,6 +109,11 @@ export function useVideoPlayer(containerRef: React.RefObject<HTMLDivElement | nu
|
||||
|
||||
hlsRef.current?.destroy();
|
||||
hlsRef.current = null;
|
||||
// 로컬 → 서버 전환 시 이전 blob URL 해제.
|
||||
if (objectUrlRef.current) {
|
||||
URL.revokeObjectURL(objectUrlRef.current);
|
||||
objectUrlRef.current = null;
|
||||
}
|
||||
|
||||
store.setSource({ kind: 'server', videoId, filename });
|
||||
store.setHlsReady(false);
|
||||
|
||||
@@ -567,21 +567,19 @@ export function StationBar({
|
||||
if (!poiByName.has(base)) poiByName.set(base, { lat: p.lat, lon: p.lon, category: p.category });
|
||||
}
|
||||
|
||||
// 양끝 스냅 제거: 각 역사/구조물은 '실제 통과 위치(시간축 px)' 그대로 둔다
|
||||
// → 마커 위치가 그 지점 커서 측점값과 일치(스냅으로 인한 측점 어긋남 해소).
|
||||
// 종점역 '미도착'만 별도 처리: 영상이 종점 측점에 못 미치면(endGapPx>0) 종점역(최우측 역사)을
|
||||
// 트랙 끝(TRACK_END = 미도착 gap 안, 실제 종점 측점 위치)에 두고 미도착 스타일로 표시한다.
|
||||
// 실제 통과 마커는 '이동/삭제 없이' 직교 통과 위치에 그대로 둔다(드론이 종점 야드에서 162080을
|
||||
// 여러 번 지나면 각 통과 자리에 마커 유지 → 검색과 일치).
|
||||
// 종점(최우측 역사)이 '미도착'(endGapPx>0)이면, 기존 마커를 옮기지 않고 트랙 끝(TRACK_END,
|
||||
// 실제 종점 측점 위치)에 '미도착' 종점 마커를 하나 '추가'한다. → 드론이 못 간 종점 162080도
|
||||
// 끝에 표시되고, 지나간 162080 통과들도 실제 위치에 그대로 남는다.
|
||||
const placeUnreachedTerminal = (marks: StructMark[]): StructMark[] => {
|
||||
if (endGapPx <= 0) return marks;
|
||||
let hiIdx = -1;
|
||||
let hiPx = -Infinity;
|
||||
for (let i = 0; i < marks.length; i++) {
|
||||
if ((marks[i].category === '역사' || marks[i].category === '역') && marks[i].px > hiPx) {
|
||||
hiPx = marks[i].px;
|
||||
hiIdx = i;
|
||||
}
|
||||
let term: StructMark | null = null;
|
||||
for (const m of marks) {
|
||||
if ((m.category === '역사' || m.category === '역') && m.px > hiPx) { hiPx = m.px; term = m; }
|
||||
}
|
||||
if (hiIdx >= 0) marks[hiIdx] = { ...marks[hiIdx], px: TRACK_END_PX, unreached: true };
|
||||
if (term) marks.push({ ...term, px: TRACK_END_PX, unreached: true });
|
||||
return marks;
|
||||
};
|
||||
|
||||
@@ -619,19 +617,12 @@ export function StationBar({
|
||||
passes = pxPassesAtMileage(projectChainage(s.lat, s.lon, stationLine));
|
||||
if (!passes.length && match && stationLine)
|
||||
passes = pxPassesAtMileage(projectChainage(match.lat, match.lon, stationLine));
|
||||
if (!passes.length && s.lat != null && s.lon != null) passes = pxPassesTo(s.lat, s.lon, off);
|
||||
if (!passes.length && match) passes = pxPassesTo(match.lat, match.lon, off);
|
||||
// 역사(역) 통과 보강: 측점선 투영이 불안정한 종점/조차장 구역에서는 측점-기준 탐지가
|
||||
// 일부 통과를 놓친다(예: 대전조차장 162k080 재진입은 투영 측점이 ±20m 밖이라 누락).
|
||||
// 이미 한 통과를 찾으면 위 단락평가로 좌표근접 탐지가 안 돌아 영영 빠지므로, 여기서
|
||||
// 좌표근접(pxPassesTo) 통과를 '합집합'으로 합쳐 빠진 재진입까지 마커를 찍는다.
|
||||
// 같은 통과(거의 같은 px)는 24px 임계로 중복 제거 → 멀리 떨어진 실제 재진입만 추가됨.
|
||||
if (cat === '역사' && s.lat != null && s.lon != null) {
|
||||
for (const cp of pxPassesTo(s.lat, s.lon, off)) {
|
||||
if (!passes.some((p) => Math.abs(p.px - cp.px) < 24)) passes.push(cp);
|
||||
}
|
||||
passes.sort((a, b) => a.px - b.px);
|
||||
}
|
||||
// 역사(역)은 좌표근접(pxPassesTo) 폴백을 쓰지 않는다 → '드론 실제 측점(chain)이 역 측점과
|
||||
// 일치(pxPassesAtMileage)'하는 통과에서만 마커를 찍는다. 그래야:
|
||||
// ① 좌표만 가까운(측점 다른) 조차장 재진입(드론 162k210인데 대전조차장 162k080)은 안 찍히고,
|
||||
// ② 진짜 162k080 통과에는 정상적으로 찍힌다. (교량/터널만 좌표근접 폴백 허용.)
|
||||
if (!passes.length && cat !== '역사' && s.lat != null && s.lon != null) passes = pxPassesTo(s.lat, s.lon, off);
|
||||
if (!passes.length && cat !== '역사' && match) passes = pxPassesTo(match.lat, match.lon, off);
|
||||
if (!passes.length && s.startMileage != null && s.endMileage != null) {
|
||||
const mid = (s.startMileage + s.endMileage) / 2;
|
||||
const px = pxAtMileage(mid);
|
||||
@@ -643,8 +634,8 @@ export function StationBar({
|
||||
? projectChainage(s.lat, s.lon, stationLine) : null;
|
||||
const existTol = routeMeta?.routeInfo?.stationTolerance ?? 20;
|
||||
for (const p of passes) {
|
||||
// 표시 측점값(stationKmVal)과 그 통과의 실제 측점(p.km)이 허용오차 밖이면(=좌표 반경만으로
|
||||
// 잡힌 통과, 그 위치엔 실제로 그 측점값이 없음) 측점값 라벨/검색에서 제외. 마커(동그라미)는 유지.
|
||||
// 이 통과들은 pxPassesAtMileage(측점 일치) 또는 이정값 기반이라 이미 '실제 측점 일치' 통과.
|
||||
// (역사는 좌표근접 폴백을 위에서 배제.) kmExists 는 라벨/검색 참고용으로만 유지.
|
||||
const kmExists = stationKmVal == null || Math.abs(p.km - stationKmVal) <= existTol;
|
||||
out.push({ px: p.px, title: s.name, category: cat, km: stationKmVal ?? p.km, kmExists, props: s.props });
|
||||
}
|
||||
@@ -850,7 +841,7 @@ export function StationBar({
|
||||
const handleJumpToMileage = useCallback(
|
||||
(km: number) => {
|
||||
const arr = viewedRef.current;
|
||||
if (!arr.length || duration <= 0) return;
|
||||
if (!arr.length || duration <= 0) return false;
|
||||
// 커버 측점 범위 산출 (구간 밖 입력은 무시).
|
||||
let lo = Infinity, hi = -Infinity;
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
@@ -859,18 +850,16 @@ export function StationBar({
|
||||
if (c > hi) hi = c;
|
||||
}
|
||||
const MARGIN = 20;
|
||||
if (km < lo - MARGIN || km > hi + MARGIN) return;
|
||||
if (km < lo - MARGIN || km > hi + MARGIN) return false;
|
||||
|
||||
// 1순위: '화면에 보이는 마커(structureMarks)' 중 표시 측점값이 일치하는 것들의 시각.
|
||||
// → 마커는 좌표근접 탐지까지 포함하므로(조차장 등 chain 이 어긋난 통과 포함) 검색이 모든
|
||||
// 동그라미 위치로 이동 가능. px 는 timeAtFrac 로 시각 역변환. 매칭은 표시(10m 반올림) 일치.
|
||||
const tgt10 = Math.round(km / 10);
|
||||
let times: number[] = structureMarks
|
||||
.filter((m) => m.km >= 0 && m.kmExists !== false && Math.round(m.km / 10) === tgt10)
|
||||
.map((m) => timeAtFrac((m.px - TRACK_START_PX) / timeTrackWidth));
|
||||
// 2순위(마커 없음=임의 측점): 연속 체이니지 기준 통과(허용오차 내 연속구간=1통과).
|
||||
if (!times.length) {
|
||||
const TOL = routeMeta?.routeInfo?.stationTolerance ?? 20;
|
||||
// 측점 검색은 '드론의 실제 투영 측점(chain)' 기준으로만 이동한다(마커에 '표시된' 라벨 기준 아님).
|
||||
// → 입력값과 허용오차(stationTolerance 기본 20m) 안인 프레임들을 '연속구간=1통과'로 묶어,
|
||||
// 그 통과 지점들로만 순환 이동. 표시 라벨만 162080이고 실제 위치는 다른(예: 162210 조차장
|
||||
// 좌표근접) 마커로는 절대 튀지 않는다.
|
||||
// 통과가 0개면(그 측점을 실제로 안 지남) 이동하지 않고 false → 입력창에 '측점 없음' 안내.
|
||||
const TOL = routeMeta?.routeInfo?.stationTolerance ?? 20;
|
||||
const times: number[] = [];
|
||||
{
|
||||
let i = 0;
|
||||
while (i < arr.length) {
|
||||
if (Math.abs(arr[i].chain - km) < TOL) {
|
||||
@@ -884,15 +873,8 @@ export function StationBar({
|
||||
i = j;
|
||||
} else i++;
|
||||
}
|
||||
if (!times.length) {
|
||||
let best = 0, bd = Infinity;
|
||||
for (let k = 0; k < arr.length; k++) {
|
||||
const d = Math.abs(arr[k].chain - km);
|
||||
if (d < bd) { bd = d; best = k; }
|
||||
}
|
||||
times.push(arr[best].time);
|
||||
}
|
||||
}
|
||||
if (!times.length) return false;
|
||||
// 시간 오름차순 + 근접 중복 제거(같은 통과가 두 경로/마커로 잡힌 경우).
|
||||
times.sort((a, b) => a - b);
|
||||
const passes: number[] = [];
|
||||
@@ -911,8 +893,9 @@ export function StationBar({
|
||||
|
||||
jumpRef.current = { km, time: target };
|
||||
onSeek(clamp(target, 0, duration));
|
||||
return true;
|
||||
},
|
||||
[duration, onSeek, routeMeta, currentTime, timeRef, structureMarks, timeAtFrac, timeTrackWidth],
|
||||
[duration, onSeek, routeMeta, currentTime, timeRef],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -203,6 +203,30 @@ $tool-btn-gap: 1px; // 화면캡처 ↔ 측점선 간격 (47px 버튼이 바 안
|
||||
}
|
||||
}
|
||||
|
||||
/* '측점 없음' 안내 — 입력창 위에 겹쳐 표시(패널 자식이 absolute 라 일반 흐름은 가려짐). */
|
||||
.notFound {
|
||||
position: absolute;
|
||||
left: $transport-btn-left;
|
||||
top: 62px;
|
||||
height: 32.67px;
|
||||
width: $transport-group-w - $transport-btn-left - $panel-inset;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
z-index: 6; // mileageInput(z:4) 위
|
||||
border-radius: 4px;
|
||||
background: rgba(20, 0, 0, 0.88);
|
||||
box-shadow: 0 0 0 2px #000;
|
||||
color: #ff6b6b;
|
||||
font-family: 'Noto Sans KR', var(--font-ui);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 화면캡처·측점선 보기 패널 */
|
||||
.toolGroup {
|
||||
position: relative;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { ChangeEvent, KeyboardEvent } from 'react';
|
||||
import { parseMileageQuery } from '../../utils/mileage';
|
||||
import styles from './PlaybackControls.module.scss';
|
||||
@@ -8,7 +8,8 @@ interface PlaybackControlsProps {
|
||||
onTogglePlay: () => void;
|
||||
onStop: () => void;
|
||||
onCapture: () => void;
|
||||
onJumpToMileage: (mileage: number) => void;
|
||||
/** 측점 검색: 이동했으면 true, 이 영상에 없는 측점이라 이동 못 했으면 false. */
|
||||
onJumpToMileage: (mileage: number) => boolean;
|
||||
/** 측점선 토글을 외부 상태로 제어할 때 사용(미지정 시 내부 상태). */
|
||||
lineOn?: boolean;
|
||||
onToggleLine?: () => void;
|
||||
@@ -24,23 +25,34 @@ export function PlaybackControls({
|
||||
onToggleLine,
|
||||
}: PlaybackControlsProps) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [notFound, setNotFound] = useState(false);
|
||||
const [lineOnInternal, setLineOnInternal] = useState(false);
|
||||
const lineOn = lineOnProp ?? lineOnInternal;
|
||||
const toggleLine = onToggleLine ?? (() => setLineOnInternal((v) => !v));
|
||||
|
||||
// '측점 없음' 안내는 잠깐만 표시(1.8초 후 자동 사라짐).
|
||||
useEffect(() => {
|
||||
if (!notFound) return;
|
||||
const t = setTimeout(() => setNotFound(false), 1800);
|
||||
return () => clearTimeout(t);
|
||||
}, [notFound]);
|
||||
|
||||
const handleQueryChange = (e: ChangeEvent<HTMLInputElement>): void => {
|
||||
setQuery(e.target.value);
|
||||
setNotFound(false); // 다시 입력하면 안내 숨김
|
||||
};
|
||||
|
||||
const handleQueryKeyDown = (e: KeyboardEvent<HTMLInputElement>): void => {
|
||||
if (e.key !== 'Enter') return;
|
||||
const mileage = parseMileageQuery(query);
|
||||
// Enter 후에도 입력값 유지 → 같은 측점 재Enter 시 통과방향 다음 위치로 순환 검색.
|
||||
if (mileage !== null) onJumpToMileage(mileage);
|
||||
// 이 영상에 없는 측점(이동 실패) 또는 형식 오류면 '측점 없음' 안내를 잠깐 띄운다.
|
||||
const moved = mileage !== null ? onJumpToMileage(mileage) : false;
|
||||
setNotFound(!moved);
|
||||
};
|
||||
|
||||
// 포커스를 잃으면(다른 곳 클릭 등) 입력값 삭제.
|
||||
const handleQueryBlur = (): void => setQuery('');
|
||||
const handleQueryBlur = (): void => { setQuery(''); setNotFound(false); };
|
||||
|
||||
return (
|
||||
<div className={styles.controlsRow}>
|
||||
@@ -71,6 +83,9 @@ export function PlaybackControls({
|
||||
onBlur={handleQueryBlur}
|
||||
placeholder="측점입력"
|
||||
/>
|
||||
{notFound && (
|
||||
<span role="alert" className={styles.notFound}>⚠ 측점 없음</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.toolGroup}>
|
||||
<button
|
||||
|
||||
@@ -37,10 +37,12 @@ interface GeoStore {
|
||||
origin: GeoOrigin | null;
|
||||
baseName: string | null;
|
||||
routeMeta: RouteMeta | null;
|
||||
/** v2.0 CSV(03)교량/04)터널/06)구교) 유래 구조물 + route.json 보정. */
|
||||
/** KMZ 원본 구조물(교량/터널/구교/역사) + route.json 보정. */
|
||||
structures: RouteStructure[];
|
||||
/** 측점 비고에서 추출한 방향전환점 목록. */
|
||||
directionChanges: DirectionChange[];
|
||||
/** 마지막 폴더 로드에서 KMZ(POI·구조물 원본)가 누락됐는지. true면 재구축 필요. */
|
||||
kmzMissing: boolean;
|
||||
|
||||
/**
|
||||
* 폴더 선택 파일에서 지리정보를 파싱해 스토어에 적재한다.
|
||||
@@ -69,6 +71,7 @@ const EMPTY = {
|
||||
routeMeta: null as RouteMeta | null,
|
||||
structures: [] as RouteStructure[],
|
||||
directionChanges: [] as DirectionChange[],
|
||||
kmzMissing: false,
|
||||
};
|
||||
|
||||
export const useGeoStore = create<GeoStore>((set) => ({
|
||||
@@ -89,6 +92,7 @@ export const useGeoStore = create<GeoStore>((set) => ({
|
||||
routeMeta: data.routeMeta,
|
||||
structures: data.structures,
|
||||
directionChanges: data.directionChanges,
|
||||
kmzMissing: data.kmzMissing,
|
||||
});
|
||||
return data.videoFile;
|
||||
},
|
||||
|
||||
@@ -34,6 +34,12 @@ interface SettingsStore {
|
||||
/** 드론 궤적 영상 오버레이 표시 여부. 기본 표시. (VideoPlayer 바 토글로 제어) */
|
||||
showDronePath: boolean;
|
||||
setShowDronePath: (v: boolean) => void;
|
||||
/** 우측 상단 나침반 타입. 'analog'=눈금 나침반, 'map'=지도(노스업, 현재위치). 기본 map. */
|
||||
compassType: 'analog' | 'map';
|
||||
setCompassType: (v: 'analog' | 'map') => void;
|
||||
/** 지도 나침반 배경. 'street'=OSM 일반, 'sat'=위성(Esri). 기본 street. */
|
||||
compassMapStyle: 'street' | 'sat';
|
||||
setCompassMapStyle: (v: 'street' | 'sat') => void;
|
||||
}
|
||||
|
||||
const DEFAULT_GRADE_FILTER: Record<string, boolean> = {
|
||||
@@ -59,6 +65,10 @@ export const useSettingsStore = create<SettingsStore>()(
|
||||
setShowCenterline: (v) => set({ showCenterline: v }),
|
||||
showDronePath: true,
|
||||
setShowDronePath: (v) => set({ showDronePath: v }),
|
||||
compassType: 'map',
|
||||
setCompassType: (v) => set({ compassType: v }),
|
||||
compassMapStyle: 'street',
|
||||
setCompassMapStyle: (v) => set({ compassMapStyle: v }),
|
||||
}),
|
||||
{
|
||||
name: 'ghivideo.settings',
|
||||
|
||||
@@ -157,15 +157,17 @@ export interface FolderGeoData {
|
||||
videoFile: File | null;
|
||||
baseName: string | null;
|
||||
frames: DroneFrame[];
|
||||
pois: GeoPoint[]; // type==='poi' (<base>_POI.csv + building/02)지장물.csv)
|
||||
stations: GeoPoint[]; // type==='station' (building/01)측점.csv, stationOrder 정렬)
|
||||
pois: GeoPoint[]; // type==='poi' (KMZ 원본: 지장물·출입문 등)
|
||||
stations: GeoPoint[]; // type==='station' (측점.csv, stationOrder 정렬)
|
||||
centerline: CenterlinePoint[]; // 측점을 mileage 순으로 이은 폴리라인 (v2.0엔 center.csv 없음)
|
||||
origin: GeoOrigin | null;
|
||||
routeMeta: RouteMeta | null;
|
||||
/** v2.0 CSV(03)교량/04)터널/06)구교) 유래 구조물 + route.json 보정. */
|
||||
/** KMZ 원본 구조물(교량/터널/구교/역사) + route.json 보정. */
|
||||
structures: RouteStructure[];
|
||||
/** 측점 비고에서 추출한 방향전환점 목록. */
|
||||
directionChanges: DirectionChange[];
|
||||
/** 폴더의 `<base>_poi_overrides.json` 에서 읽은 POI 위치 보정값 (없으면 {}). */
|
||||
poiOverrides: PoiOverrideMap;
|
||||
/** KMZ(POI·구조물 원본)가 없거나 비어 있음 → 데이터 누락(재구축 필요). 측점·드론은 별개. */
|
||||
kmzMissing: boolean;
|
||||
}
|
||||
|
||||
+18
-271
@@ -94,16 +94,6 @@ function cell(row: string[], i: number): string {
|
||||
return i >= 0 && i < row.length ? row[i] : '';
|
||||
}
|
||||
|
||||
/** 헤더행 정규화(트림+BOM 제거). props/팝업 표시용. */
|
||||
function cleanHeader(header: string[]): string[] {
|
||||
return header.map((h) => h.trim().replace(/^/, ''));
|
||||
}
|
||||
|
||||
/** (정규화된)헤더 + 데이터행 → 비어있지 않은 속성 배열(순서 보존). 라벨 클릭 팝업 표시용. */
|
||||
function rowProps(header: string[], row: string[]): { k: string; v: string }[] {
|
||||
return header.map((k, i) => ({ k, v: cell(row, i) })).filter((p) => p.k && p.v !== '');
|
||||
}
|
||||
|
||||
// ── 파일 식별 헬퍼 ────────────────────────────────────────────────────
|
||||
|
||||
/** File 의 폴더 내 상대경로 (webkitRelativePath 우선, 없으면 name). */
|
||||
@@ -263,254 +253,6 @@ export async function parseStations(files: File[]): Promise<{
|
||||
return { stations, directionChanges };
|
||||
}
|
||||
|
||||
// ── 파서: POI (2개 소스 병합) ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* POI 파싱 — 2개 소스를 병합한다(둘 다 type='poi').
|
||||
* a. 루트 <base>_POI.csv (UTF-8): title←title, category←category_clean, lat/lon, z=0
|
||||
* b. building/02)지장물.csv (EUC-KR): title←명칭, category='지장물', lat/lon, z←Z좌표
|
||||
*/
|
||||
export async function parsePois(files: File[], baseName: string | null): Promise<GeoPoint[]> {
|
||||
const result: GeoPoint[] = [];
|
||||
|
||||
// a. <base>_POI.csv (없으면 루트의 *_POI.csv 폴백)
|
||||
const poiFile =
|
||||
(baseName &&
|
||||
files.find(
|
||||
(f) => !isInBuilding(f) && baseNameOf(relPath(f)) === `${baseName}_POI.csv`,
|
||||
)) ||
|
||||
files.find((f) => !isInBuilding(f) && /_POI\.csv$/i.test(baseNameOf(relPath(f)))) ||
|
||||
null;
|
||||
|
||||
if (poiFile) {
|
||||
const rows = await readCsv(poiFile);
|
||||
if (rows.length >= 2) {
|
||||
const header = cleanHeader(rows[0]);
|
||||
const fi = makeFieldIndexer(rows[0]);
|
||||
const iTitle = fi('title', 2);
|
||||
const iCat = fi('category_clean', 3);
|
||||
const iLat = fi('lat', 7);
|
||||
const iLon = fi('lon', 8);
|
||||
for (const r of rows.slice(1)) {
|
||||
const lat = parseFloat(cell(r, iLat));
|
||||
const lon = parseFloat(cell(r, iLon));
|
||||
if (isNaN(lat) || isNaN(lon)) continue;
|
||||
result.push({
|
||||
title: cell(r, iTitle),
|
||||
category: cell(r, iCat) || '건물',
|
||||
lat,
|
||||
lon,
|
||||
z: 0,
|
||||
type: 'poi',
|
||||
props: rowProps(header, r),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// b. building/02)지장물.csv (단, '02)지장물_역사.csv'는 역사 전용 → 제외)
|
||||
const isObstacle = (f: File) => {
|
||||
const n = baseNameOf(relPath(f));
|
||||
return isInBuilding(f) && n.includes('지장물') && !n.includes('역사');
|
||||
};
|
||||
const obFile = files.find((f) => isObstacle(f) && baseNameOf(relPath(f)).includes('02)')) ??
|
||||
files.find(isObstacle) ?? null;
|
||||
if (obFile) {
|
||||
const rows = await readCsv(obFile);
|
||||
if (rows.length >= 2) {
|
||||
const header = cleanHeader(rows[0]);
|
||||
const fi = makeFieldIndexer(rows[0]);
|
||||
const iTitle = fi('명칭', 0);
|
||||
const iZ = fi('Z좌표', 5);
|
||||
const iLat = fi('lat', 6);
|
||||
const iLon = fi('lon', 7);
|
||||
for (const r of rows.slice(1)) {
|
||||
const lat = parseFloat(cell(r, iLat));
|
||||
const lon = parseFloat(cell(r, iLon));
|
||||
if (isNaN(lat) || isNaN(lon)) continue;
|
||||
result.push({
|
||||
title: cell(r, iTitle),
|
||||
category: '지장물',
|
||||
lat,
|
||||
lon,
|
||||
z: parseFloat(cell(r, iZ)) || 0,
|
||||
type: 'poi',
|
||||
props: rowProps(header, r),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── 파서: 구조물 (교량/터널/구교) ─────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 구조물 파싱 — 3개 CSV 를 RouteStructure[] 로 병합한다.
|
||||
* a. building/03)교량.csv: name←구분, type='bridge', lengthM←연장(m), grade←시설종별, category='교량'
|
||||
* b. building/04)터널.csv: name←구분, type='tunnel', lengthM←연장(m), grade←시설종별, category='터널'
|
||||
* c. building/06)구교.csv: name←시설물명, type='bridge'(구교 아이콘=교량), lengthM←연장(m), category='구교'(시설종별 컬럼 없음→grade undefined)
|
||||
* lat/lon 이 유효하지 않은 행은 건너뛴다(깨진 좌표 방어).
|
||||
*/
|
||||
export async function parseStructures(files: File[]): Promise<RouteStructure[]> {
|
||||
const out: RouteStructure[] = [];
|
||||
|
||||
const pushFrom = async (
|
||||
file: File | null,
|
||||
spec: {
|
||||
type: 'bridge' | 'tunnel';
|
||||
category: string;
|
||||
nameKey: string;
|
||||
nameFallback: number;
|
||||
lengthKey: string;
|
||||
lengthFallback: number;
|
||||
latFallback: number;
|
||||
lonFallback: number;
|
||||
/** 시설종별 컬럼 인덱스 폴백. 컬럼이 없는 파일은 생략(undefined). */
|
||||
gradeFallback?: number;
|
||||
},
|
||||
): Promise<void> => {
|
||||
if (!file) return;
|
||||
const rows = await readCsv(file);
|
||||
if (rows.length < 2) return;
|
||||
const header = cleanHeader(rows[0]);
|
||||
const fi = makeFieldIndexer(rows[0]);
|
||||
const iName = fi(spec.nameKey, spec.nameFallback);
|
||||
const iLen = fi(spec.lengthKey, spec.lengthFallback);
|
||||
const iLat = fi('lat', spec.latFallback);
|
||||
const iLon = fi('lon', spec.lonFallback);
|
||||
// 시설종별: 헤더에 있으면 그 인덱스, 없으면 폴백(지정 시) 사용. 둘 다 없으면 -1.
|
||||
const iGrade = spec.gradeFallback != null ? fi('시설종별', spec.gradeFallback) : fi('시설종별');
|
||||
let n = 0;
|
||||
for (const r of rows.slice(1)) {
|
||||
const lat = parseFloat(cell(r, iLat));
|
||||
const lon = parseFloat(cell(r, iLon));
|
||||
// 위경도 유효성: 대한민국 범위(대략) 밖이면 깨진 행으로 간주.
|
||||
if (isNaN(lat) || isNaN(lon) || lat < 33 || lat > 39 || lon < 124 || lon > 132) continue;
|
||||
const name = cell(r, iName);
|
||||
if (!name) continue;
|
||||
const lengthM = parseFloat(cell(r, iLen));
|
||||
// 시설종별 원문(trim). 빈 문자열이면 undefined.
|
||||
const grade = cell(r, iGrade).trim();
|
||||
out.push({
|
||||
id: `${spec.category}-${n++}`,
|
||||
type: spec.type,
|
||||
name,
|
||||
category: spec.category,
|
||||
lat,
|
||||
lon,
|
||||
...(isNaN(lengthM) ? {} : { lengthM }),
|
||||
...(grade ? { grade } : {}),
|
||||
props: rowProps(header, r),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// a. 03)교량: 구분,시설종별,...,연장(m),...,X좌표,Y좌표,Z좌표,lat,lon,category_clean
|
||||
await pushFrom(findBuildingFile(files, '03)교량') ?? findBuildingFile(files, '교량'), {
|
||||
type: 'bridge',
|
||||
category: '교량',
|
||||
nameKey: '구분',
|
||||
nameFallback: 0,
|
||||
lengthKey: '연장(m)',
|
||||
lengthFallback: 5,
|
||||
latFallback: 10,
|
||||
lonFallback: 11,
|
||||
gradeFallback: 1,
|
||||
});
|
||||
|
||||
// b. 04)터널: 구분,...,연장(m),시설종별,X좌표,Y좌표,Z좌표,lat,lon,category_clean
|
||||
await pushFrom(findBuildingFile(files, '04)터널') ?? findBuildingFile(files, '터널'), {
|
||||
type: 'tunnel',
|
||||
category: '터널',
|
||||
nameKey: '구분',
|
||||
nameFallback: 0,
|
||||
lengthKey: '연장(m)',
|
||||
lengthFallback: 4,
|
||||
latFallback: 9,
|
||||
lonFallback: 10,
|
||||
gradeFallback: 5,
|
||||
});
|
||||
|
||||
// c. 06)구교: 역구간,시설물명,...,연장(m),lat,lon,z,...
|
||||
await pushFrom(findBuildingFile(files, '06)구교') ?? findBuildingFile(files, '구교'), {
|
||||
type: 'bridge',
|
||||
category: '구교',
|
||||
nameKey: '시설물명',
|
||||
nameFallback: 1,
|
||||
lengthKey: '연장(m)',
|
||||
lengthFallback: 5,
|
||||
latFallback: 6,
|
||||
lonFallback: 7,
|
||||
});
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 역사(철도역) 파싱 — building/02)지장물_역사.csv (EUC-KR).
|
||||
* 헤더: 명칭,주소(도로명),주소(지번),X좌표,Y좌표,Z좌표,lat,lon
|
||||
* → RouteStructure(type='station', category='역사')로 변환. 하단 스테이션바에 원형 마커로 표출.
|
||||
* (영상 오버레이는 category '구교'만 표시하므로 역사는 영상에 안 뜸.)
|
||||
*/
|
||||
export async function parseHistoricStations(files: File[]): Promise<RouteStructure[]> {
|
||||
// 역사 파일 — building/ 또는 root 어디든(building 폴더 삭제 대비).
|
||||
const file = files.find((f) => baseNameOf(relPath(f)).includes('지장물_역사'));
|
||||
if (!file) return [];
|
||||
const rows = await readCsv(file);
|
||||
if (rows.length < 2) return [];
|
||||
|
||||
const fi = makeFieldIndexer(rows[0]);
|
||||
const iName = fi('명칭', 0);
|
||||
const iLat = fi('lat', 6);
|
||||
const iLon = fi('lon', 7);
|
||||
|
||||
const out: RouteStructure[] = [];
|
||||
let n = 0;
|
||||
for (const r of rows.slice(1)) {
|
||||
const lat = parseFloat(cell(r, iLat));
|
||||
const lon = parseFloat(cell(r, iLon));
|
||||
if (isNaN(lat) || isNaN(lon) || lat < 33 || lat > 39 || lon < 124 || lon > 132) continue;
|
||||
const name = cell(r, iName);
|
||||
if (!name) continue;
|
||||
out.push({ id: `역사-${n++}`, type: 'station', name, category: '역사', lat, lon });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 출입문 파싱 — building/05)출입문번호.csv (UTF-8 BOM).
|
||||
* 헤더: 본부,선별,역구간,latitude,longitude,시설팀,출입문번호,...,z,측점
|
||||
* → GeoPoint{ title=출입문번호, category='출입문', lat/lon, z, type='poi' } 로 영상에 POI 처럼 표출.
|
||||
* 위치 인덱스 폴백: latitude=3, longitude=4, 출입문번호=6, z=12.
|
||||
*/
|
||||
export async function parseAccessDoors(files: File[]): Promise<GeoPoint[]> {
|
||||
const file = findBuildingFile(files, '05)출입문') ?? findBuildingFile(files, '출입문');
|
||||
if (!file) return [];
|
||||
const rows = await readCsv(file);
|
||||
if (rows.length < 2) return [];
|
||||
|
||||
const header = cleanHeader(rows[0]);
|
||||
const fi = makeFieldIndexer(rows[0]);
|
||||
const iTitle = fi('출입문번호', 6);
|
||||
const iLat = fi('latitude', 3);
|
||||
const iLon = fi('longitude', 4);
|
||||
const iZ = fi('z', 12);
|
||||
|
||||
const out: GeoPoint[] = [];
|
||||
for (const r of rows.slice(1)) {
|
||||
const lat = parseFloat(cell(r, iLat));
|
||||
const lon = parseFloat(cell(r, iLon));
|
||||
if (isNaN(lat) || isNaN(lon) || lat < 33 || lat > 39 || lon < 124 || lon > 132) continue;
|
||||
const title = cell(r, iTitle);
|
||||
if (!title) continue;
|
||||
// 원본 CSV 속성 전부(비어있지 않은 것) → 팝업에 표시(KMZ 정보표와 동일 항목).
|
||||
out.push({ title, category: '출입문', lat, lon, z: parseFloat(cell(r, iZ)) || 0, type: 'poi', props: rowProps(header, r) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── 파서: KMZ (원본) ─────────────────────────────────────────────────
|
||||
//
|
||||
// KMZ(=doc.kml zip)는 building CSV의 원본이다. 폴더가 02)지장물·03)교량·04)터널·05)출입문번호·
|
||||
@@ -794,24 +536,28 @@ export async function loadFolderGeoData(
|
||||
const videoFile = findVideoFile(files);
|
||||
const baseName = deriveBaseName(videoFile);
|
||||
|
||||
const [frames, stationResult, poisRaw, csvStructures, routeMeta, poiOverrides, historicStations, accessDoors, kmz] = await Promise.all([
|
||||
const [frames, stationResult, routeMeta, poiOverrides, kmz] = await Promise.all([
|
||||
parseDroneFrames(files, baseName),
|
||||
parseStations(files),
|
||||
parsePois(files, baseName),
|
||||
parseStructures(files),
|
||||
parseRouteMeta(files, baseName),
|
||||
parsePoiOverrides(files, baseName),
|
||||
parseHistoricStations(files),
|
||||
parseAccessDoors(files),
|
||||
parseKmz(files),
|
||||
]);
|
||||
|
||||
// POI/구조물 출처: KMZ(원본)가 있으면 그걸 사용(지장물·출입문·교량/터널/구교) → CSV 추출 중복 제거.
|
||||
// KMZ 없거나 파싱 실패 시 CSV 폴백(지오코딩 _POI + 지장물 + 출입문 / 구조물). 측점은 항상 CSV.
|
||||
const useKmz = !!kmz && (kmz.pois.length > 0 || kmz.structures.length > 0);
|
||||
const pois = useKmz ? kmz!.pois : poisRaw.concat(accessDoors);
|
||||
const baseStructures = useKmz ? kmz!.structures : csvStructures;
|
||||
if (useKmz) console.log(`[KMZ] POI ${kmz!.pois.length} · 구조물 ${kmz!.structures.length} 로드(원본) → CSV 미사용`);
|
||||
// POI/구조물 출처: KMZ(원본)가 유일한 소스다(지장물·출입문·교량/터널/구교·철도역).
|
||||
// KMZ 정책상 필수 — 없거나 비어 있으면 "데이터 누락"으로 보고 경고만 남기고 빈 상태로 둔다
|
||||
// (CSV 폴백 없음. building/ POI·구조물 CSV는 사용하지 않음). 측점·중심선은 항상 CSV(측점)에서 온다.
|
||||
const kmzMissing = !kmz || (kmz.pois.length === 0 && kmz.structures.length === 0);
|
||||
if (kmzMissing) {
|
||||
console.warn(
|
||||
'[KMZ] 누락/비어있음 — POI·구조물이 표시되지 않습니다. ' +
|
||||
'KMZ(원본)를 포함해 데이터를 재구축·전달하세요. (측점·드론 정보는 정상 로드)',
|
||||
);
|
||||
} else {
|
||||
console.log(`[KMZ] POI ${kmz!.pois.length} · 구조물 ${kmz!.structures.length} 로드(원본)`);
|
||||
}
|
||||
const pois = kmz?.pois ?? [];
|
||||
const baseStructures = kmz?.structures ?? [];
|
||||
|
||||
const stations = stationResult.stations.sort(
|
||||
(a, b) => stationOrder(a.title) - stationOrder(b.title),
|
||||
@@ -819,8 +565,8 @@ export async function loadFolderGeoData(
|
||||
const directionChanges = stationResult.directionChanges;
|
||||
|
||||
// route.json 은 선택적 보정 레이어 — 있으면 구조물에 override/augment.
|
||||
// 역사(02)지장물_역사)는 구조물로 합류 → 스테이션바에 표출(영상엔 미표시).
|
||||
const structures = mergeStructures(baseStructures, routeMeta).concat(historicStations);
|
||||
// 철도역(역사)은 KMZ의 KAKAO_RAIL placemark에서 구조물로 생성됨(스테이션바 표출).
|
||||
const structures = mergeStructures(baseStructures, routeMeta);
|
||||
|
||||
// v2.0엔 center.csv 가 없다 → 측점 폴리라인으로 중심선 생성.
|
||||
const centerline = buildCenterlineFromStations(stations);
|
||||
@@ -844,5 +590,6 @@ export async function loadFolderGeoData(
|
||||
structures,
|
||||
directionChanges,
|
||||
poiOverrides,
|
||||
kmzMissing,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user