표고 체계 정비·설정 파일 단순화·선형 클리핑·시연 준비
- 타원체고 기준 자동 변환: route.json ellipsoidal_height 선언 시 드론 고도(CSV 정표고)를 영상 메타(djmd 6.6 타원체고)와의 차(지오이드고 자동 산출)만큼 보정, 세그먼트 전환에도 유지 - KML 표고 파이프라인: 좌표 고도(z) 사용(POI/측점), *_byDem.kml 자동 생성(DEM 조회·높이 속성 포함) + 로더의 _byDem 우선 규칙, 선형중심선(LineString) 로딩·경로표고 앵커 - 설정 파일: camera.json/display.json/route.json 단순 이름 인식, 화면표시 옵션 PC 저장/자동 적용, 초기화 버튼을 환경설정 파일 기준 복원으로 변경, 저장 파일명 단순화 - 선형 표시: 표출된 마지막 측점까지 클립(경계 세그먼트 절단 + 관통 현 처리 — 구간 실종 수정) - 스테이션바: 시점(0k000) 마크(실통과 위치), 노선밖 프레임 통과 오인 방지, 커서 배지 1m 단위, 배너 연장/소요 실측값(측점 체이니지·영상 길이 합) 우선 - 세그먼트 전환 라벨 깜빡임 구조 수정(캐시 초기화·최근접 키 조회·이중 재계산 제거) + 사전계산 프레임 불변값 호이스팅(재생 지연 해소) - UI: 슬라이더/값 입력칸 휠 조절, 패널 버튼 위치 정렬, 저장 버튼 중복 제거, dev_mode(route.json)로 영상제어 UI 표시 제어, 지오이드 최대 200, 이격거리 측점 동반 적용 - 시연: start-demo.bat + docs/DEMO.md - docs/history: 작업 이력 30건 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -26,14 +26,15 @@ function stationKm(title: string): number {
|
||||
/**
|
||||
* 영상 좌상단 노선 정보 배너 — videoplayer 의 RouteInfo 디자인 이식.
|
||||
* 값은 모두 선택한 폴더에서 가져온다(하드코딩 없음).
|
||||
* - 연장(lengthKm): route.json 우선 → 측점 CSV 구간(min~max km) 계산 폴백.
|
||||
* - 소요(durationSec): route.json 우선 → 실제 영상 길이 폴백.
|
||||
* - 연장(lengthKm): 측점 데이터(KML/CSV 전체 체이니지 min~max) 우선 → route.json 폴백.
|
||||
* - 소요(durationSec): 폴더 내 영상 전체 길이 합(분할본 포함) 우선 → route.json → 현재 영상 길이.
|
||||
* - 방향/노선명: CSV에 없는 정보 → route.json(routeInfo).
|
||||
* 표출할 값이 하나도 없으면 렌더하지 않는다.
|
||||
*/
|
||||
export default function RouteInfoOverlay() {
|
||||
const routeInfo = useGeoStore((s) => s.routeMeta?.routeInfo);
|
||||
const stations = useGeoStore((s) => s.stations);
|
||||
const videoDurations = useGeoStore((s) => s.videoDurations);
|
||||
const videoDuration = usePlayerStore((s) => s.duration);
|
||||
|
||||
// 원본처럼 영상 폭/1920 비율로 배너를 스케일 (부모=영상 영역 폭 관측).
|
||||
@@ -53,17 +54,19 @@ export default function RouteInfoOverlay() {
|
||||
const direction = routeInfo?.direction;
|
||||
const name = routeInfo?.name;
|
||||
|
||||
// 연장: route.json 우선 → 측점 구간 계산 폴백
|
||||
let lengthKm = routeInfo?.lengthKm ?? null;
|
||||
if (lengthKm == null && stations.length) {
|
||||
// 연장: 측점 데이터(KML/CSV 전체 체이니지) 우선 — 실측 기반. 없으면 route.json.
|
||||
let lengthKm: number | null = null;
|
||||
if (stations.length) {
|
||||
const kms = stations.map((s) => stationKm(s.title)).filter((k) => k >= 0);
|
||||
if (kms.length >= 2) {
|
||||
lengthKm = Math.round((Math.max(...kms) - Math.min(...kms)) / 10) / 100; // m→km, 소수2
|
||||
}
|
||||
}
|
||||
if (lengthKm == null) lengthKm = routeInfo?.lengthKm ?? null;
|
||||
|
||||
// 소요시간: route.json 우선 → 실제 영상 길이 폴백
|
||||
const dur = formatDuration(routeInfo?.durationSec ?? videoDuration);
|
||||
// 소요시간: 폴더 내 영상 전체 길이 합(분할본 연속재생 총 시간) 우선 → route.json → 현재 영상 길이
|
||||
const totalDurationSec = videoDurations.reduce<number>((acc, d) => acc + (d ?? 0), 0);
|
||||
const dur = formatDuration(totalDurationSec > 0 ? totalDurationSec : (routeInfo?.durationSec ?? videoDuration));
|
||||
|
||||
if (!direction && !name && lengthKm == null && !dur) return null;
|
||||
|
||||
|
||||
@@ -229,16 +229,40 @@ function ParamRow({ label, value, min, max, step, unit, decimals = 1, onChange,
|
||||
onChange(c); setText(fmt(c)); prevRef.current = c;
|
||||
} else setText(fmt(value));
|
||||
};
|
||||
// 마우스 휠: 위 = +step, 아래 = −step (예: 카메라 파라미터 0.1 단위).
|
||||
// 슬라이더와 숫자 값 입력칸 양쪽 위에서 동작. 패널 스크롤을 막아야 하므로
|
||||
// non-passive 네이티브 리스너로 부착(React onWheel 은 passive).
|
||||
const sliderRef = useRef<HTMLInputElement>(null);
|
||||
const numRef = useRef<HTMLInputElement>(null);
|
||||
const wheelStateRef = useRef({ value, min, max, step, decimals, onChange });
|
||||
useEffect(() => { wheelStateRef.current = { value, min, max, step, decimals, onChange }; });
|
||||
useEffect(() => {
|
||||
const els = [sliderRef.current, numRef.current].filter((el): el is HTMLInputElement => !!el);
|
||||
if (!els.length) return;
|
||||
const onWheel = (e: WheelEvent): void => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const s = wheelStateRef.current;
|
||||
const dir = e.deltaY < 0 ? 1 : -1;
|
||||
// 부동소수점 누적 오차 방지 — 표시 자릿수로 반올림
|
||||
const next = parseFloat(
|
||||
Math.max(s.min, Math.min(s.max, s.value + dir * s.step)).toFixed(Math.max(s.decimals, 3)),
|
||||
);
|
||||
if (next !== s.value) s.onChange(next);
|
||||
};
|
||||
els.forEach(el => el.addEventListener('wheel', onWheel, { passive: false }));
|
||||
return () => els.forEach(el => el.removeEventListener('wheel', onWheel));
|
||||
}, []);
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 text-[11px]">
|
||||
<span
|
||||
title={tip}
|
||||
className={`text-gray-400 w-12 shrink-0 text-right ${tip ? 'cursor-help underline decoration-dotted decoration-gray-600 underline-offset-2' : ''}`}
|
||||
>{label}</span>
|
||||
<input type="range" min={min} max={max} step={step} value={value}
|
||||
<input ref={sliderRef} type="range" min={min} max={max} step={step} value={value}
|
||||
onChange={e => { const v = parseFloat(e.target.value); onChange(v); prevRef.current = v; setText(fmt(v)); }}
|
||||
className="flex-1 h-1 accent-yellow-400 cursor-pointer" />
|
||||
<input type="number" min={min} max={max} step={step} value={text}
|
||||
<input ref={numRef} type="number" min={min} max={max} step={step} value={text}
|
||||
onChange={e => setText(e.target.value)}
|
||||
onBlur={e => commit(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') commit((e.target as HTMLInputElement).value); if (e.key === 'Escape') setText(fmt(value)); }}
|
||||
@@ -284,6 +308,10 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
// 텍스트 사전 계산 Map
|
||||
const labelMapRef = useRef<Map<number, LabelCache>>(new Map());
|
||||
const precomputeIdRef = useRef(0); // 진행 중 계산 취소용
|
||||
// 캐시 키(드론 프레임 번호, 오름차순) — RAF 가 최근접 키를 이진탐색으로 조회.
|
||||
const labelKeysRef = useRef<number[]>([]);
|
||||
// 직전 사전계산의 frames 참조 — 바뀌면(세그먼트 전환) 옛 캐시를 복사하지 않고 새로 시작.
|
||||
const lastPrecomputeFramesRef = useRef<DroneFrameBasic[] | null>(null);
|
||||
|
||||
// 중심선 + 나침반 렌더 캐시 (per-frame)
|
||||
const renderCacheRef = useRef<RenderCache | null>(null);
|
||||
@@ -479,6 +507,28 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
return best;
|
||||
}, []);
|
||||
|
||||
/** 중심선 '세그먼트 투영' 최근접점 — 정점 스냅이 아니라 선 위의 정확한 위치.
|
||||
* LineString 중심선(예: 제주 146정점)은 직선 구간 정점이 성겨서, 정점 스냅(nearestCL)을
|
||||
* 측점 라벨에 쓰면 0k200~0k700 같은 직선 구간 측점들이 굽이 정점으로 뭉쳐 버린다. */
|
||||
const projectCL = useCallback((lat: number, lon: number): CenterlinePoint | null => {
|
||||
const pts = allCenterlinePointsRef.current;
|
||||
if (pts.length < 2) return nearestCL(lat, lon);
|
||||
const k = Math.cos((lat * Math.PI) / 180);
|
||||
const px = lon * k, py = lat;
|
||||
let bestD = Infinity, bl = pts[0].lat, bo = pts[0].lon, bz = pts[0].z;
|
||||
for (let i = 0; i < pts.length - 1; i++) {
|
||||
const a = pts[i], b = pts[i + 1];
|
||||
const ax = a.lon * k, ay = a.lat, bx = b.lon * k, by = b.lat;
|
||||
const dx = bx - ax, dy = by - ay;
|
||||
const L2 = dx * dx + dy * dy;
|
||||
const t = L2 === 0 ? 0 : Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / L2));
|
||||
const cx = ax + dx * t, cy = ay + dy * t;
|
||||
const d = (px - cx) ** 2 + (py - cy) ** 2;
|
||||
if (d < bestD) { bestD = d; bl = cy; bo = cx / k; bz = a.z + (b.z - a.z) * t; }
|
||||
}
|
||||
return { lat: bl, lon: bo, z: bz };
|
||||
}, [nearestCL]);
|
||||
|
||||
// 클라이언트 지리정보 스토어 구독 (서버 /api/geo/* 대체)
|
||||
const storeStations = useGeoStore(s => s.stations);
|
||||
const storePois = useGeoStore(s => s.pois);
|
||||
@@ -503,6 +553,7 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
const storeBaseName = useGeoStore(s => s.baseName);
|
||||
const storeCameraInfo = useGeoStore(s => s.cameraInfo);
|
||||
const storeCameraJson = useGeoStore(s => s.cameraJson);
|
||||
const storeDisplayJson = useGeoStore(s => s.displayJson);
|
||||
const storePoiOverrides = useGeoStore(s => s.poiOverrides);
|
||||
const setPoiOverride = useGeoStore(s => s.setPoiOverride);
|
||||
const setPoiOverrides = useGeoStore(s => s.setPoiOverrides);
|
||||
@@ -595,14 +646,12 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
console.log('[camera] <base>.camera.json 파라미터 적용(최우선):', cam);
|
||||
}, [storeBaseName, storeCameraJson]);
|
||||
|
||||
// 현재 카메라 파라미터를 사용자 PC 에 <영상 base>.camera.json 으로 다운로드 저장.
|
||||
// 현재 카메라 파라미터를 사용자 PC 에 `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,
|
||||
@@ -612,13 +661,80 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${base}.camera.json`;
|
||||
a.download = 'camera.json';
|
||||
a.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 5000);
|
||||
setCamSaveState('ok');
|
||||
setTimeout(() => setCamSaveState('idle'), 2500);
|
||||
}, []);
|
||||
|
||||
// 폴더의 <영상 base>.display.json 화면표시 옵션 자동 적용 — 'PC에 저장' 버튼과 왕복.
|
||||
// localStorage 복원(calib effect)보다 뒤에 선언되어 파일 값이 우선 반영된다.
|
||||
const dispJsonAppliedFor = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!storeBaseName || !storeDisplayJson) return;
|
||||
if (dispJsonAppliedFor.current === storeBaseName) return;
|
||||
dispJsonAppliedFor.current = storeBaseName;
|
||||
const d = storeDisplayJson;
|
||||
const num = (k: string): number | undefined =>
|
||||
typeof d[k] === 'number' && isFinite(d[k] as number) ? (d[k] as number) : undefined;
|
||||
const bool = (k: string): boolean | undefined =>
|
||||
typeof d[k] === 'boolean' ? (d[k] as boolean) : undefined;
|
||||
if (num('dronePathZ') !== undefined) setDronePathZ(num('dronePathZ')!);
|
||||
if (num('dronePathAlpha') !== undefined) setDronePathAlpha(num('dronePathAlpha')!);
|
||||
if (num('smoothHalf') !== undefined) setSmoothHalf(num('smoothHalf')!);
|
||||
if (num('emaAlpha') !== undefined) setEmaAlpha(num('emaAlpha')!);
|
||||
if (num('smoothMinAlpha') !== undefined) setSmoothMinAlpha(num('smoothMinAlpha')!);
|
||||
if (num('smoothSpeedRef') !== undefined) setSmoothSpeedRef(num('smoothSpeedRef')!);
|
||||
if (num('maxPoiRange') !== undefined) setMaxPoiRange(num('maxPoiRange')!);
|
||||
if (bool('poiDroneHeight') !== undefined) setPoiDroneHeight(bool('poiDroneHeight')!);
|
||||
if (num('droneHeightDrop') !== undefined) setDroneHeightDrop(num('droneHeightDrop')!);
|
||||
const ss = useSettingsStore.getState();
|
||||
if (bool('showCenterline') !== undefined) ss.setShowCenterline(bool('showCenterline')!);
|
||||
if (bool('showDronePath') !== undefined) ss.setShowDronePath(bool('showDronePath')!);
|
||||
if (bool('showRoutePanel') !== undefined) ss.setShowRoutePanel(bool('showRoutePanel')!);
|
||||
if (bool('poiOverlapExclude') !== undefined) ss.setPoiOverlapExclude(bool('poiOverlapExclude')!);
|
||||
if (d.compassType === 'analog' || d.compassType === 'map') ss.setCompassType(d.compassType);
|
||||
if (d.compassMapStyle === 'street' || d.compassMapStyle === 'sat') ss.setCompassMapStyle(d.compassMapStyle);
|
||||
if (d.gradeFilter && typeof d.gradeFilter === 'object') {
|
||||
for (const [g, v] of Object.entries(d.gradeFilter as Record<string, unknown>)) {
|
||||
if (typeof v === 'boolean') ss.setGradeFilter(g, v);
|
||||
}
|
||||
}
|
||||
console.log('[display] <base>.display.json 화면표시 옵션 적용:', d);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [storeBaseName, storeDisplayJson]);
|
||||
|
||||
// 현재 화면표시 옵션을 사용자 PC 에 `display.json`(단순 이름 규칙)으로 다운로드 저장.
|
||||
// 받은 파일을 영상 폴더에 넣어두면 다음 폴더 선택 때 자동 적용된다.
|
||||
const [dispSaveState, setDispSaveState] = useState<'idle' | 'ok'>('idle');
|
||||
const saveDisplayToPc = useCallback(() => {
|
||||
const ss = useSettingsStore.getState();
|
||||
const payload = {
|
||||
display: {
|
||||
dronePathZ, dronePathAlpha, smoothHalf, emaAlpha, smoothMinAlpha, smoothSpeedRef,
|
||||
maxPoiRange, poiDroneHeight, droneHeightDrop,
|
||||
showCenterline: ss.showCenterline,
|
||||
showDronePath: ss.showDronePath,
|
||||
showRoutePanel: ss.showRoutePanel,
|
||||
poiOverlapExclude: ss.poiOverlapExclude,
|
||||
compassType: ss.compassType,
|
||||
compassMapStyle: ss.compassMapStyle,
|
||||
gradeFilter: ss.gradeFilter,
|
||||
},
|
||||
savedAt: new Date().toISOString(),
|
||||
};
|
||||
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'display.json';
|
||||
a.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 5000);
|
||||
setDispSaveState('ok');
|
||||
setTimeout(() => setDispSaveState('idle'), 2500);
|
||||
}, [dronePathZ, dronePathAlpha, smoothHalf, emaAlpha, smoothMinAlpha, smoothSpeedRef, maxPoiRange, poiDroneHeight, droneHeightDrop]);
|
||||
|
||||
// Yaw 자동 추정 — 전진 비행 구간에서 GPS 진행 방위(정확) vs 짐벌 헤딩(나침반 오차 포함)의
|
||||
// 차이 중앙값을 Yaw± 로 설정. 카메라가 진행방향을 보며 촬영하는 도로 추종 비행 가정(근사치).
|
||||
const autoEstimateYaw = useCallback(() => {
|
||||
@@ -705,6 +821,10 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
// 드론 프레임
|
||||
useEffect(() => {
|
||||
allDroneFramesRef.current = storeFrames;
|
||||
// 세그먼트 전환 시 현재 프레임 추적 초기화 — 스테일 인덱스(이전 세그먼트 끝)로
|
||||
// 사전계산 시작 구간이 엉뚱해지는 것 방지. 다음 currentTime 틱에 정확히 재계산된다.
|
||||
currentFrameIdxRef.current = 0;
|
||||
currentFrameNumRef.current = storeFrames[0]?.frame ?? 0;
|
||||
setDroneFramesLoaded(storeFrames.length > 0);
|
||||
}, [storeFrames]);
|
||||
|
||||
@@ -784,11 +904,66 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
|
||||
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;
|
||||
// 표고 미상(z≤0) 중심선(예: 제주 KML 선형 — 고도 전부 0)은 '경로 표고' 평면에 앵커 —
|
||||
// 측점/POI 라벨과 동일 기준. z=0 그대로 투영하면 해수면 높이로 그려져 크게 어긋난다.
|
||||
const CL_Z = dronePathZRef.current;
|
||||
// 선형은 '표출된 마지막 측점'까지만 — 표시 반경(maxPoiRange) 안에 들어온 측점 중
|
||||
// 가장 먼 것의 거리(+10m)로 클립. 경계를 가로지르는 세그먼트는 경계 지점에서 잘라 그린다
|
||||
// (정점 간격이 긴 직선 구간에서 세그먼트 통째 그리기로 마지막 측점 너머 긴 꼬리가 남던 문제).
|
||||
const MAX_R = maxPoiRangeRef.current;
|
||||
const cosLat = Math.cos((drone.lat * Math.PI) / 180);
|
||||
let clipR = 0;
|
||||
for (const st of allGeoStationsRef.current) {
|
||||
const d = Math.hypot((st.lat - drone.lat) * 111000, (st.lon - drone.lon) * 111000 * cosLat);
|
||||
if (d <= MAX_R && d > clipR) clipR = d;
|
||||
}
|
||||
clipR = clipR > 0 ? Math.min(MAX_R, clipR + 10) : MAX_R;
|
||||
// 드론 기준 동서/남북(m) 좌표 — 세그먼트·클립원 교점 계산용
|
||||
const enOf = (p: { lat: number; lon: number }): [number, number] => [
|
||||
(p.lon - drone.lon) * 111000 * cosLat,
|
||||
(p.lat - drone.lat) * 111000,
|
||||
];
|
||||
// P0→P1 세그먼트가 반지름 clipR 원과 만나는 t(0~1) 목록(오름차순, 최대 2개).
|
||||
const clipTs = (e0: [number, number], e1: [number, number]): number[] => {
|
||||
const dx = e1[0] - e0[0], dy = e1[1] - e0[1];
|
||||
const a = dx * dx + dy * dy;
|
||||
if (a === 0) return [];
|
||||
const b = 2 * (e0[0] * dx + e0[1] * dy);
|
||||
const c = e0[0] * e0[0] + e0[1] * e0[1] - clipR * clipR;
|
||||
const disc = b * b - 4 * a * c;
|
||||
if (disc < 0) return [];
|
||||
const sq = Math.sqrt(disc);
|
||||
return [(-b - sq) / (2 * a), (-b + sq) / (2 * a)].filter((t) => t >= 0 && t <= 1);
|
||||
};
|
||||
const projCL = (lat: number, lon: number, z: number): CameraCoords =>
|
||||
toCameraCoords(drone, lat, lon, z > 0 ? z : CL_Z, params, worldOrigin);
|
||||
for (let i = 1; i < allCL.length; i++) {
|
||||
const p0 = allCL[i - 1], p1 = allCL[i];
|
||||
const e0 = enOf(p0), e1 = enOf(p1);
|
||||
const in0 = Math.hypot(e0[0], e0[1]) <= clipR;
|
||||
const in1 = Math.hypot(e1[0], e1[1]) <= clipR;
|
||||
const lerp = (t: number) => ({
|
||||
lat: p0.lat + (p1.lat - p0.lat) * t,
|
||||
lon: p0.lon + (p1.lon - p0.lon) * t,
|
||||
z: p0.z + (p1.z - p0.z) * t,
|
||||
});
|
||||
let a0 = p0, a1 = p1;
|
||||
if (!in0 && !in1) {
|
||||
// 양끝 다 밖 — 단, 긴 직선 세그먼트(KML 정점 간격 수백 m)가 클립 원을 '관통'하는
|
||||
// 경우가 흔하므로(0k700 부근 선 실종 원인), 두 교점 사이 구간(현)은 그린다.
|
||||
const ts = clipTs(e0, e1);
|
||||
if (ts.length < 2) continue; // 원과 안 만남 → 생략
|
||||
a0 = lerp(ts[0]); a1 = lerp(ts[1]);
|
||||
} else if (in0 !== in1) {
|
||||
// 경계 교차 → 교점까지만
|
||||
const ts = clipTs(e0, e1);
|
||||
if (ts.length) {
|
||||
const cut = lerp(in0 ? ts[ts.length - 1] : ts[0]);
|
||||
if (in0) a1 = cut; else a0 = cut;
|
||||
}
|
||||
}
|
||||
const s = segFrom(projCL(a0.lat, a0.lon, a0.z), projCL(a1.lat, a1.lon, a1.z));
|
||||
if (s) centerlineSegs.push(s);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -840,10 +1015,18 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
// 텍스트 사전 계산 — requestIdleCallback으로 백그라운드 실행
|
||||
const startLabelPrecompute = useCallback((currentParams: CameraParams, currentSmoothHalf: number, currentMaxRange: number, currentDroneHeight: boolean, currentDroneDrop: number) => {
|
||||
const id = ++precomputeIdRef.current;
|
||||
// 재계산(기존 맵 보유)이면 옛 맵을 복사해 시작 → 첫 청크(현재 프레임 포함) 후 즉시 노출,
|
||||
// 이후 청크가 프레임을 덮어써 전 구간이 새 설정으로 갱신(빈 구간 없음). 초기 로드는 빈 맵.
|
||||
const newMap = new Map<number, LabelCache>(labelMapRef.current.size ? labelMapRef.current : undefined);
|
||||
const frames = allDroneFramesRef.current;
|
||||
// 재계산(같은 데이터, 슬라이더 변경 등)이면 옛 맵을 복사해 시작 → 빈 구간 없이 갱신.
|
||||
// 단, frames 가 바뀐 경우(분할영상 세그먼트 전환)는 옛 캐시가 '다른 구간'의 값이므로
|
||||
// 복사하지 않고 비워서 시작 — 신·구 캐시가 섞여 라벨 집합이 프레임마다 뒤바뀌는
|
||||
// 깜빡임(두 번째 영상 재생 시)을 방지한다.
|
||||
const framesChanged = lastPrecomputeFramesRef.current !== frames;
|
||||
lastPrecomputeFramesRef.current = frames;
|
||||
const newMap = new Map<number, LabelCache>(
|
||||
!framesChanged && labelMapRef.current.size ? labelMapRef.current : undefined,
|
||||
);
|
||||
if (framesChanged) labelMapRef.current = newMap; // 스테일 캐시 즉시 차단
|
||||
labelKeysRef.current = frames.map(f => f.frame); // RAF 최근접 키 조회용(오름차순)
|
||||
const allSt = allGeoStationsRef.current;
|
||||
// 모든 POI 표출(철도역 포함) + 영상용 구조물(구교/교량/터널). 철도역은 별도로 스테이션바에도 표출.
|
||||
const allPoi = allPoisRef.current.concat(allStructuresRef.current);
|
||||
@@ -857,6 +1040,29 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
|
||||
if (!frames.length) return;
|
||||
|
||||
// 프레임 불변 값은 루프 밖에서 1회만 계산 — 측점의 중심선 투영(projectCL)과 POI 지면고도(gz)는
|
||||
// 드론 프레임과 무관하다. 프레임 루프 안에서 매번 계산하면(측점71×선분145×프레임 수천)
|
||||
// 재생 중 idle 청크가 메인 스레드를 막아 영상 딜레이·라벨 떨림을 유발한다.
|
||||
const snappedSt = allSt.map(st => {
|
||||
const snap = projectCL(st.lat, st.lon);
|
||||
const szRaw = snap?.z ?? st.z;
|
||||
return {
|
||||
title: st.title,
|
||||
lat: snap?.lat ?? st.lat,
|
||||
lon: snap?.lon ?? st.lon,
|
||||
// 표고 미상(0 이하)이면 경로표고 평면에 앵커 — 공중부양/시차 밀림 방지.
|
||||
z: szRaw > 0 ? szRaw : PATH_Z,
|
||||
};
|
||||
});
|
||||
const gzOf = new Map<string, number>();
|
||||
for (const poi of allPoi) {
|
||||
// 지면고도 gz: 보정값(DEM/드래그) → 최근접 선로표고(유효값) → POI 자체 z → 경로표고 앵커.
|
||||
const clz = nearestCL(poi.lat, poi.lon)?.z;
|
||||
gzOf.set(poi.title, overridesRef.current[poi.title]?.z
|
||||
?? (clz !== undefined && clz > 0 ? clz : undefined)
|
||||
?? (poi.z > 0 ? poi.z : PATH_Z));
|
||||
}
|
||||
|
||||
const t0 = performance.now();
|
||||
const total = frames.length;
|
||||
// 현재 재생 프레임부터 순환 처리 → 화면에 보이는 구간을 먼저 계산(재생 즉시 라벨 표시).
|
||||
@@ -876,13 +1082,13 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
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);
|
||||
for (const sp of snappedSt) {
|
||||
const slat = sp.lat, slon = sp.lon, sz = sp.z;
|
||||
// 가시성 판정도 드로우와 동일 높이 규칙 — 드론높이 모드면 드론고도 − 이격거리.
|
||||
const stZ = currentDroneHeight
|
||||
? drone.altitude - (currentParams.geoidOffset ?? 0) - currentDroneDrop
|
||||
: sz;
|
||||
const cc = toCameraCoords(drone, slat, slon, stZ, currentParams, worldOrigin);
|
||||
if (cc.Zc < CLIP_Z) continue;
|
||||
// 측점도 POI 와 동일하게 표시 범위 제한 — 원거리(수 km) 측점이 수평선에 뭉치는 것 방지.
|
||||
if ((cc.distH ?? 0) > MAX_RANGE) continue;
|
||||
@@ -891,18 +1097,14 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
// 캐시 프레임마다 들락거리며 깜빡이는 것 방지. 실제 화면 밖 컬링은 드로우가 담당.
|
||||
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 });
|
||||
stationLabels.push({ title: sp.title, lat: slat, lon: slon, z: sz });
|
||||
}
|
||||
|
||||
// 후보 수집 (거리 dist + 월드좌표 + 지면고도 gz) → 겹침 억제용
|
||||
const poiCand: { x: number; y: number; title: string; label: string; category: string; dist: number; lat: number; lon: number; gz: number; compact: { k: string; v: string }[]; color?: string }[] = [];
|
||||
for (const poi of allPoi) {
|
||||
// 지면고도 gz: 보정값(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);
|
||||
// 지면고도 gz — 프레임 불변이므로 루프 밖에서 1회 계산(gzOf).
|
||||
const gz = gzOf.get(poi.title) ?? PATH_Z;
|
||||
// 가시성 판정용 poiZ: 드론높이 모드면 드론−이격거리, 아니면 gz.
|
||||
const poiZ = currentDroneHeight
|
||||
? drone.altitude - (currentParams.geoidOffset ?? 0) - currentDroneDrop
|
||||
@@ -980,7 +1182,7 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
// 첫 청크(현재 프레임 포함)는 동기 실행 → 토글/재계산이 '정지 중에도' 즉시 반영.
|
||||
// (idle 대기 시 정지 상태에서 갱신이 지연/누락되던 문제 해결.) 나머지는 step 내부에서 idle 로 이어감.
|
||||
step();
|
||||
}, [nearestCL]);
|
||||
}, [nearestCL, projectCL]);
|
||||
|
||||
// 모든 데이터 로드 완료 시 사전 계산 시작
|
||||
// storeFrames 의존 필수: 분할 영상 세그먼트 전환(loadSegment)으로 frames 가 교체되면
|
||||
@@ -992,11 +1194,12 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
}, [droneFramesLoaded, geoDataLoaded, clDataLoaded, startLabelPrecompute, smoothHalf, maxPoiRange, poiDroneHeight, droneHeightDrop, poiOverlapExclude, dronePathZ, storeFrames]);
|
||||
|
||||
// params / smoothHalf / maxPoiDist / 경로표고(표고 미상 라벨 앵커) 변경 시 사전 계산 재시작 (500ms debounce)
|
||||
// ※ storeFrames(세그먼트 전환)는 위의 즉시 effect 가 담당 — 여기 넣으면 전환마다 이중 재계산.
|
||||
useEffect(() => {
|
||||
if (!droneFramesLoaded || !geoDataLoaded || !clDataLoaded) return;
|
||||
const timer = setTimeout(() => startLabelPrecompute(params, smoothHalf, maxPoiRange, poiDroneHeight, droneHeightDrop), 500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [params, smoothHalf, maxPoiRange, poiDroneHeight, droneHeightDrop, storePois, storeStructures, droneFramesLoaded, geoDataLoaded, clDataLoaded, startLabelPrecompute, poiOverlapExclude, dronePathZ, storeFrames]);
|
||||
}, [params, smoothHalf, maxPoiRange, poiDroneHeight, droneHeightDrop, storePois, storeStructures, droneFramesLoaded, geoDataLoaded, clDataLoaded, startLabelPrecompute, poiOverlapExclude, dronePathZ]);
|
||||
|
||||
// 현재 재생 시간 → 드론 프레임 ref 갱신
|
||||
useEffect(() => {
|
||||
@@ -1209,8 +1412,22 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
if (visibleRef.current) {
|
||||
// 가시집합만 precompute 에서 가져오고(어떤 라벨을 그릴지), 좌표는 RAF 가 연속 포즈로 직접
|
||||
// 투영 → 라인처럼 부드럽게 이동(정수프레임 보간/프레임키 의존 제거).
|
||||
const baseFrame = Math.floor(estFrame);
|
||||
const labelsA = labelMapRef.current.get(baseFrame) ?? labelMapRef.current.get(currentFrameNumRef.current);
|
||||
// 조회는 '최근접 캐시 키' 이진탐색으로 고정 — 종전의 floor 정확일치 + 폴백(4Hz 갱신 스테일 키)
|
||||
// 조합은 프레임마다 서로 다른 캐시 항목을 교대로 그려 라벨 집합이 뒤바뀌는 깜빡임을 유발했음.
|
||||
let labelsA: LabelCache | undefined;
|
||||
{
|
||||
const keys = labelKeysRef.current;
|
||||
if (keys.length) {
|
||||
let lo = 0, hi = keys.length - 1;
|
||||
while (hi - lo > 1) { const m = (lo + hi) >> 1; if (keys[m] <= estFrame) lo = m; else hi = m; }
|
||||
const ki = Math.abs(keys[hi] - estFrame) < Math.abs(estFrame - keys[lo]) ? hi : lo;
|
||||
// 채워지는 중(청크 진행)이면 인접 키로 소폭 폴백
|
||||
for (let d = 0; d <= 3 && !labelsA; d++) {
|
||||
labelsA = labelMapRef.current.get(keys[ki + d] ?? NaN) ?? labelMapRef.current.get(keys[ki - d] ?? NaN);
|
||||
}
|
||||
}
|
||||
if (!labelsA) labelsA = labelMapRef.current.get(Math.floor(estFrame)) ?? labelMapRef.current.get(currentFrameNumRef.current);
|
||||
}
|
||||
|
||||
if (labelsA) {
|
||||
const α = emaAlphaRef.current;
|
||||
@@ -1227,7 +1444,12 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'middle';
|
||||
labelsA.stationLabels.forEach((stA) => {
|
||||
const cc = toCameraCoords(dronePose, stA.lat, stA.lon, stA.z, params, worldOrigin);
|
||||
// 측점도 POI 와 동일한 높이 규칙 — 드론높이 모드면 '드론고도 − 이격거리' 평면
|
||||
// (이격거리 슬라이더가 측점 라벨에도 반영), 지면고도 모드면 측점 표고(stA.z — 경로표고 앵커 포함).
|
||||
const stZ = poiDroneHeightRef.current
|
||||
? dronePose.altitude - (params.geoidOffset ?? 0) - droneHeightDropRef.current
|
||||
: stA.z;
|
||||
const cc = toCameraCoords(dronePose, stA.lat, stA.lon, stZ, params, worldOrigin);
|
||||
if (cc.Zc < CLIP_Z) return;
|
||||
const { pxRaw, pyRaw } = pixelFromCamera(cc, params);
|
||||
const prevDst = displayedStRef.current.get(stA.title);
|
||||
@@ -1646,19 +1868,40 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
}
|
||||
}, [setPoiOverrides]);
|
||||
|
||||
// 화면표시 옵션 일괄 복귀 — DISPLAY_DEFAULTS 로 리셋(POI 보정/편집모드는 별도 버튼 유지).
|
||||
// 화면표시 옵션 일괄 복귀 — 환경설정 파일(*.display.json)이 폴더에 있으면 **그 파일 값**으로,
|
||||
// 없으면 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);
|
||||
const d = (useGeoStore.getState().displayJson ?? {}) as Record<string, unknown>;
|
||||
const num = (k: string, def: number): number =>
|
||||
typeof d[k] === 'number' && isFinite(d[k] as number) ? (d[k] as number) : def;
|
||||
const bool = (k: string, def: boolean): boolean =>
|
||||
typeof d[k] === 'boolean' ? (d[k] as boolean) : def;
|
||||
setShowCenterline(bool('showCenterline', DISPLAY_DEFAULTS.showCenterline));
|
||||
setShowDronePath(bool('showDronePath', DISPLAY_DEFAULTS.showDronePath));
|
||||
setDronePathZ(num('dronePathZ', DISPLAY_DEFAULTS.dronePathZ));
|
||||
setDronePathAlpha(num('dronePathAlpha', DISPLAY_DEFAULTS.dronePathAlpha));
|
||||
setSmoothHalf(num('smoothHalf', DISPLAY_DEFAULTS.smoothHalf));
|
||||
setEmaAlpha(num('emaAlpha', DISPLAY_DEFAULTS.emaAlpha));
|
||||
setSmoothMinAlpha(num('smoothMinAlpha', DISPLAY_DEFAULTS.smoothMinAlpha));
|
||||
setSmoothSpeedRef(num('smoothSpeedRef', DISPLAY_DEFAULTS.smoothSpeedRef));
|
||||
setMaxPoiRange(num('maxPoiRange', DISPLAY_DEFAULTS.maxPoiRange));
|
||||
setPoiDroneHeight(bool('poiDroneHeight', DISPLAY_DEFAULTS.poiDroneHeight));
|
||||
setDroneHeightDrop(num('droneHeightDrop', DISPLAY_DEFAULTS.droneHeightDrop));
|
||||
}, []);
|
||||
|
||||
// 카메라 파라미터 초기화 — 환경설정 파일(*.camera.json)이 폴더에 있으면 **그 파일 값**으로,
|
||||
// 없으면 DEFAULT_CAMERA_PARAMS 로 복원 (파일에 없는 항목도 기본값).
|
||||
const resetCameraParams = useCallback(() => {
|
||||
const cam = useGeoStore.getState().cameraJson;
|
||||
const next = { ...DEFAULT_CAMERA_PARAMS };
|
||||
if (cam) {
|
||||
for (const [k, v] of Object.entries(cam)) {
|
||||
if (k in next && typeof v === 'number' && isFinite(v)) {
|
||||
(next as unknown as Record<string, number>)[k] = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
setParams(next);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
@@ -1732,15 +1975,16 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
|
||||
{showPanel && (
|
||||
<div className="absolute right-2 z-30 flex flex-col-reverse items-end gap-2" style={{ bottom: (barHeight || 130) + 8 }}>
|
||||
{/* 버튼 순서 = 패널 위치와 일치: 카메라(왼쪽 패널) → 화면표시(오른쪽 패널) */}
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => setShowDisplay(v => !v)}
|
||||
className={`text-xs whitespace-nowrap px-2 py-1 rounded border ${showDisplay ? 'bg-amber-400 border-amber-300 text-black' : 'bg-black/70 hover:bg-black/90 text-gray-300 border-gray-600'}`}>
|
||||
{showDisplay ? '▲ 화면표시 옵션' : '▼ 화면표시 옵션'}
|
||||
</button>
|
||||
<button onClick={() => setShowControls(v => !v)}
|
||||
className={`text-xs whitespace-nowrap px-2 py-1 rounded border ${showControls ? 'bg-amber-400 border-amber-300 text-black' : 'bg-black/70 hover:bg-black/90 text-gray-300 border-gray-600'}`}>
|
||||
{showControls ? '▲ 카메라 파라미터' : '▼ 카메라 파라미터'}
|
||||
</button>
|
||||
<button onClick={() => setShowDisplay(v => !v)}
|
||||
className={`text-xs whitespace-nowrap px-2 py-1 rounded border ${showDisplay ? 'bg-amber-400 border-amber-300 text-black' : 'bg-black/70 hover:bg-black/90 text-gray-300 border-gray-600'}`}>
|
||||
{showDisplay ? '▲ 화면표시 옵션' : '▼ 화면표시 옵션'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 두 패널을 가로로 나란히(우→좌) 펼침 — 둘 다 열어도 세로로 안 쌓여 화면 위로 안 넘침. */}
|
||||
@@ -1748,7 +1992,16 @@ 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>
|
||||
<div className="flex items-center justify-between border-b border-gray-700 pb-2 mb-2">
|
||||
<span className="text-[10px] text-gray-500 uppercase tracking-wider">표시 토글</span>
|
||||
{/* 화면표시 옵션 전체를 display.json 으로 PC 저장 — 폴더에 넣어두면 자동 적용 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={saveDisplayToPc}
|
||||
className={`text-[10px] px-1.5 py-0.5 rounded border ${dispSaveState === 'ok' ? 'border-emerald-400 bg-emerald-700/60 text-emerald-100' : 'border-gray-600 bg-gray-700 hover:bg-gray-600 text-gray-200'}`}
|
||||
title="현재 화면표시 옵션 전체(토글/슬라이더/시설등급 등)를 display.json 으로 PC에 다운로드 — 영상 폴더에 넣어두면 폴더 선택 시 자동 적용"
|
||||
>{dispSaveState === 'ok' ? '저장됨 ✓' : 'PC에 저장'}</button>
|
||||
</div>
|
||||
{/* 나침반 타입 — 눈금(analog) / 지도(OSM, 노스업) 선택 */}
|
||||
<div className="flex items-center gap-1.5 text-[11px] mb-3">
|
||||
<span className="text-gray-400 w-12 shrink-0 text-right">나침반</span>
|
||||
@@ -1862,7 +2115,7 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
</div>
|
||||
<div className="border-t border-gray-700 pt-2 mt-3 flex items-center justify-end">
|
||||
<button onClick={resetDisplayDefaults}
|
||||
title="화면표시 옵션을 기본값으로 되돌립니다 (POI 보정은 별도)"
|
||||
title="환경설정 파일(*.display.json)이 폴더에 있으면 그 값으로, 없으면 기본값으로 복원 (POI 보정은 별도)"
|
||||
className="text-[11px] text-gray-400 hover:text-white border border-gray-600 hover:border-gray-400 px-2 py-0.5 rounded transition-colors">
|
||||
기본값
|
||||
</button>
|
||||
@@ -1873,7 +2126,16 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
{/* ── 카메라 파라미터 (드론 카메라 관련만) ── */}
|
||||
{showControls && (
|
||||
<div className="bg-black/90 border border-gray-600 rounded p-3 text-white w-72 select-none max-h-[80vh] overflow-y-auto shadow-xl">
|
||||
<div className="text-[10px] text-gray-500 uppercase tracking-wider mb-1.5 border-b border-gray-700 pb-2 mb-2">자세 보정 오프셋<span className="ml-1 text-gray-600">(SRT + offset)</span></div>
|
||||
{/* 헤더 + 카메라 파라미터 PC 저장 — 화면표시 옵션 패널과 동일 위치(우상단)에 배치 */}
|
||||
<div className="flex items-center justify-between border-b border-gray-700 pb-2 mb-2">
|
||||
<span className="text-[10px] text-gray-500 uppercase tracking-wider">자세 보정 오프셋<span className="ml-1 text-gray-600">(SRT + offset)</span></span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={saveCameraToPc}
|
||||
className={`text-[10px] px-1.5 py-0.5 rounded border ${camSaveState === 'ok' ? 'border-emerald-400 bg-emerald-700/60 text-emerald-100' : 'border-gray-600 bg-gray-700 hover:bg-gray-600 text-gray-200'}`}
|
||||
title="현재 카메라 파라미터 전체(Yaw±/f/센서 등)를 camera.json 으로 PC에 다운로드 — 영상 폴더에 넣어두면 폴더 선택 시 자동 적용"
|
||||
>{camSaveState === 'ok' ? '저장됨 ✓' : 'PC에 저장'}</button>
|
||||
</div>
|
||||
<div className="space-y-2 mb-3">
|
||||
<ParamRow label="Yaw ±" value={params.yawOffset} min={-180} max={180} step={0.1} unit="°" decimals={1} onChange={v => setParam('yawOffset', v)} />
|
||||
{/* Yaw 자동 추정(2단계 보정의 1단계) — 대략 맞춘 뒤 'Yaw 보정' 드래그 모드로 마무리 */}
|
||||
@@ -1891,33 +2153,25 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
<ParamRow label="off X" value={params.offX} min={-500} max={500} step={0.1} unit="m" decimals={1} onChange={v => setParam('offX', v)} />
|
||||
<ParamRow label="off Y" value={params.offY} min={-500} max={500} step={0.1} unit="m" decimals={1} onChange={v => setParam('offY', v)} />
|
||||
<ParamRow label="off Z" value={params.offZ} min={-200} max={200} step={0.1} unit="m" decimals={1} onChange={v => setParam('offZ', v)} />
|
||||
<ParamRow label="지오이드" value={params.geoidOffset} min={0} max={50} step={0.1} unit="m" decimals={1} onChange={v => setParam('geoidOffset', v)} />
|
||||
<ParamRow label="지오이드" value={params.geoidOffset} min={0} max={200} step={0.1} unit="m" decimals={1} onChange={v => setParam('geoidOffset', v)} />
|
||||
<div className="text-[10px] text-gray-600 text-right">정표고→타원체고 (대전≈25.8)</div>
|
||||
</div>
|
||||
<div className="text-[10px] text-gray-500 uppercase tracking-wider mb-1.5 border-t border-gray-700 pt-2">내부표정 (초점·주점·센서)</div>
|
||||
{/* 영상(djmd) 자동 감지 정보 + 카메라값 서버 저장('<영상 base>.camera.json' — 재로드 시 자동 적용) */}
|
||||
{/* 영상(djmd) 자동 감지 정보 — PC 저장은 패널 헤더 버튼 하나로 통일(중복 제거) */}
|
||||
<div className="flex items-center justify-between gap-1 mb-1.5 text-[10px] text-gray-400">
|
||||
<span className="truncate" title="영상 메타데이터(djmd)에서 감지된 카메라 정보">
|
||||
📷 {storeCameraInfo
|
||||
? `${storeCameraInfo.model}${storeCameraInfo.focalLen35 ? ` · ${storeCameraInfo.focalLen35}mm` : ''}${storeCameraInfo.fps ? ` · ${storeCameraInfo.fps}fps` : ''}`
|
||||
: '카메라 미감지'}
|
||||
</span>
|
||||
<div className="flex gap-1 shrink-0">
|
||||
{storeCameraInfo?.focalLen35 !== undefined && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setParams(p => ({ ...p, focalLen: storeCameraInfo!.focalLen35! }))}
|
||||
className="px-1.5 py-0.5 rounded border border-gray-600 bg-gray-700 hover:bg-gray-600 text-gray-200"
|
||||
title={`감지된 초점거리 ${storeCameraInfo!.focalLen35}mm 를 f 에 적용`}
|
||||
>적용</button>
|
||||
)}
|
||||
{storeCameraInfo?.focalLen35 !== undefined && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={saveCameraToPc}
|
||||
className={`px-1.5 py-0.5 rounded border ${camSaveState === 'ok' ? 'border-emerald-400 bg-emerald-700/60 text-emerald-100' : 'border-gray-600 bg-gray-700 hover:bg-gray-600 text-gray-200'}`}
|
||||
title="현재 카메라 파라미터(Yaw± 보정 포함 전체)를 <영상이름>.camera.json 으로 PC에 다운로드 — 영상 폴더에 넣어두면 폴더 선택 시 자동 적용"
|
||||
>{camSaveState === 'ok' ? '저장됨 ✓' : 'PC에 저장'}</button>
|
||||
</div>
|
||||
onClick={() => setParams(p => ({ ...p, focalLen: storeCameraInfo!.focalLen35! }))}
|
||||
className="shrink-0 px-1.5 py-0.5 rounded border border-gray-600 bg-gray-700 hover:bg-gray-600 text-gray-200"
|
||||
title={`감지된 초점거리 ${storeCameraInfo!.focalLen35}mm 를 f 에 적용`}
|
||||
>적용</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2 mb-3">
|
||||
<ParamRow label="f" value={params.focalLen} min={10} max={100} step={0.1} unit="mm" decimals={1} onChange={v => setParam('focalLen', v)} />
|
||||
@@ -1940,7 +2194,8 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
</div>
|
||||
)}
|
||||
<div className="border-t border-gray-700 pt-2 flex items-center justify-end">
|
||||
<button onClick={() => setParams({ ...DEFAULT_CAMERA_PARAMS })}
|
||||
<button onClick={resetCameraParams}
|
||||
title="환경설정 파일(*.camera.json)이 폴더에 있으면 그 값으로, 없으면 앱 기본값으로 복원"
|
||||
className="text-[11px] text-gray-400 hover:text-white border border-gray-600 hover:border-gray-400 px-2 py-0.5 rounded transition-colors">
|
||||
초기화
|
||||
</button>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { usePlayerStore } from '../../store/playerStore';
|
||||
import { useGeoStore } from '../../store/geoStore';
|
||||
import { useSettingsStore, KNOWN_GRADES } from '../../store/settingsStore';
|
||||
import { captureFrame, downloadDataUrl } from '../../utils/frameCapture';
|
||||
import { generateByDemKmls } from '../../utils/demKml';
|
||||
import { secondsToTimecode, secondsToFrame } from '../../utils/timecode';
|
||||
import { useCaptureStore } from '../../store/captureStore';
|
||||
import HlsConversionStatus from './HlsConversionStatus';
|
||||
@@ -125,6 +126,14 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
|
||||
const geoLoaded = useGeoStore((s) => s.loaded);
|
||||
// 드론 정보 — 하단바에 GPS/고도 항상 표시.
|
||||
const storeFrames = useGeoStore((s) => s.frames);
|
||||
// dev_mode (route.json) — 0 이면 '영상제어' 버튼과 제어 UI 전체를 숨김(뷰어 모드).
|
||||
// routeInfo.dev_mode 우선, 최상위 dev_mode 도 허용. 없으면 1(표시) — 기존 동작 유지.
|
||||
const storeRouteMeta = useGeoStore((s) => s.routeMeta);
|
||||
const devMode = (() => {
|
||||
const ri = storeRouteMeta?.routeInfo as Record<string, unknown> | undefined;
|
||||
const v = ri?.dev_mode ?? (storeRouteMeta as unknown as Record<string, unknown> | null)?.dev_mode;
|
||||
return v === 0 || v === '0' ? 0 : 1;
|
||||
})();
|
||||
// 분할 영상 재생목록 — 폴더 로드 시 이름 오름차순. 목록 클릭으로 세그먼트 직접 선택 재생.
|
||||
const videoFiles = useGeoStore((s) => s.videoFiles);
|
||||
const videoDurations = useGeoStore((s) => s.videoDurations);
|
||||
@@ -162,6 +171,18 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
|
||||
'(측점·드론 정보는 정상 로드되었습니다.)',
|
||||
);
|
||||
}
|
||||
// 표고(z) 없는 KML → DEM(SRTM 30m)으로 채운 <이름>_byDem.kml 자동 생성·다운로드.
|
||||
// 폴더에 _byDem 이 이미 있으면 건너뛰고, 로더는 _byDem 을 원본보다 우선 사용한다.
|
||||
void generateByDemKmls(files)
|
||||
.then((made) => {
|
||||
if (made.length) {
|
||||
alert(
|
||||
`표고 없는 KML을 발견해 DEM 표고를 채운 파일을 생성했습니다:\n${made.join(', ')}\n\n` +
|
||||
'다운로드된 파일을 영상 폴더에 넣어두면 다음 폴더 선택부터 자동으로 사용됩니다.',
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((e) => console.warn('[DEM] _byDem.kml 생성 실패(오프라인이면 정상):', e));
|
||||
} catch (err) {
|
||||
console.error('[geo] 폴더 로드 실패', err);
|
||||
}
|
||||
@@ -373,7 +394,8 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
|
||||
{/* 노선 정보 배너 — 영상 좌상단 (route.json routeInfo) */}
|
||||
<RouteInfoOverlay />
|
||||
{/* 좌하단 그룹(재생바 위) — 한 줄: 영상제어 → 배속 → 프레임정보 → 겹침제외 → 시설등급 */}
|
||||
{source && (
|
||||
{/* dev_mode=0(route.json) 이면 그룹 전체 숨김 — 뷰어 모드 */}
|
||||
{source && devMode !== 0 && (
|
||||
<div
|
||||
className="absolute left-2 z-30 flex items-center gap-2 flex-wrap pointer-events-auto"
|
||||
style={{ bottom: (barHeight || 130) + 8 }}
|
||||
@@ -561,7 +583,7 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
|
||||
videoWidth={videoWidth}
|
||||
videoHeight={videoHeight}
|
||||
onTogglePlay={handleTogglePlay}
|
||||
showPanel={showVideoControls && !!source}
|
||||
showPanel={devMode !== 0 && showVideoControls && !!source}
|
||||
topPx={paramTop}
|
||||
barHeight={barHeight}
|
||||
/>
|
||||
|
||||
@@ -158,6 +158,14 @@ function formatMileage10(m: number): string {
|
||||
return `${km}k${String(mm).padStart(3, '0')}`;
|
||||
}
|
||||
|
||||
/** 미터값 → "158k163" (1m 단위) — 커서 배지용. 노선밖 표기(1m)와 갱신 밀도를 맞춘다. */
|
||||
function formatMileage1(m: number): string {
|
||||
const r = Math.round(m);
|
||||
const km = Math.floor(r / 1000);
|
||||
const mm = r % 1000;
|
||||
return `${km}k${String(mm).padStart(3, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* abcVideo 실제 영상 데이터 기반 측점 바.
|
||||
* - 트랙 x축 = 프레임(시간) 진행. 구간 색 = 측점 km 증가(주황)/감소(하늘색).
|
||||
@@ -457,7 +465,7 @@ export function StationBar({
|
||||
realChain !== null && realChain >= 0
|
||||
? (realOffM !== null && realOffM > OFF_ROUTE_M
|
||||
? `노선밖 ${Math.round(realOffM)}m`
|
||||
: formatMileage10(realChain))
|
||||
: formatMileage1(realChain))
|
||||
: '';
|
||||
|
||||
// viewedRef(프레임→측점 km) 추이로 구간 색·전환점 산출.
|
||||
@@ -620,9 +628,12 @@ export function StationBar({
|
||||
const out: { px: number; km: number }[] = [];
|
||||
let i = 0;
|
||||
while (i < arr.length) {
|
||||
// 노선 밖(접근/이탈) 프레임 제외 — 폴리라인 밖에선 체이니지가 끝점(0 등)에 클램프되어
|
||||
// 접근 구간 전체가 '시점(0k000) 통과'로 오인돼 마크가 바 맨 앞에 찍히는 문제 방지.
|
||||
if (arr[i].offM > OFF_ROUTE_M) { i++; continue; }
|
||||
if (Math.abs(arr[i].chain - targetM) < TH) {
|
||||
let j = i, bj = i, bd = Math.abs(arr[i].chain - targetM);
|
||||
while (j < arr.length && Math.abs(arr[j].chain - targetM) < TH) {
|
||||
while (j < arr.length && arr[j].offM <= OFF_ROUTE_M && Math.abs(arr[j].chain - targetM) < TH) {
|
||||
const d = Math.abs(arr[j].chain - targetM);
|
||||
if (d < bd) { bd = d; bj = j; }
|
||||
j++;
|
||||
@@ -635,6 +646,19 @@ export function StationBar({
|
||||
return out;
|
||||
};
|
||||
|
||||
// 시점(첫 측점, 예: 0k000) 마크 — 드론이 노선 시작점을 통과하는 위치에 측점값 라벨 표시.
|
||||
const appendStartMark = (marks: StructMark[]): StructMark[] => {
|
||||
const valid = stations.filter(st => stationKm(st.title) >= 0);
|
||||
if (!valid.length || !stationLine) return marks;
|
||||
const first = valid.reduce((a, b) => (stationKm(a.title) <= stationKm(b.title) ? a : b));
|
||||
const startM = stationKm(first.title);
|
||||
for (const p of pxPassesAtMileage(startM)) {
|
||||
// 측점값 라벨(위)이 "0k000" 을 이미 표시하므로 제목(아래)은 "시점" 만 — 중복 방지.
|
||||
marks.push({ px: p.px, title: '시점', category: '측점', km: startM, kmExists: true });
|
||||
}
|
||||
return marks;
|
||||
};
|
||||
|
||||
// 구조물 후보 POI(교량/터널/역사) base 이름 → 실좌표.
|
||||
const cats = new Set(['교량', '터널', '역사']);
|
||||
const poiByName = new Map<string, { lat: number; lon: number; category: string }>();
|
||||
@@ -717,7 +741,7 @@ export function StationBar({
|
||||
out.push({ px: p.px, title: s.name, category: cat, km: stationKmVal ?? p.km, kmExists, props: s.props });
|
||||
}
|
||||
}
|
||||
return placeUnreachedTerminal(out);
|
||||
return placeUnreachedTerminal(appendStartMark(out));
|
||||
}
|
||||
|
||||
// 폴백: POI category 기반 (실좌표 GPS 근접, 모든 통과).
|
||||
@@ -736,7 +760,7 @@ export function StationBar({
|
||||
out.push({ px: p.px, title: base, category: info.category, km: stationKmVal ?? p.km, kmExists });
|
||||
}
|
||||
}
|
||||
return placeUnreachedTerminal(out);
|
||||
return placeUnreachedTerminal(appendStartMark(out));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ready, duration, storeFrames, pois, stations, pxAtTime, routeMeta, routeDirCh, storeStructures, pxAtMileage, endGapPx, stationLine, videoFps]);
|
||||
|
||||
|
||||
@@ -56,6 +56,10 @@ interface GeoStore {
|
||||
cameraInfo: DetectedCameraInfo | null;
|
||||
/** 폴더의 `<영상 base>.camera.json` 카메라 파라미터 — 감지값/저장값보다 최우선 적용. */
|
||||
cameraJson: Record<string, number> | null;
|
||||
/** 폴더의 `<영상 base>.display.json` 화면표시 옵션 — 로드 시 자동 적용. */
|
||||
displayJson: Record<string, unknown> | null;
|
||||
/** 드론 고도 datum 보정(m) — 타원체고 데이터셋의 정표고→타원체고 변환량. 세그먼트 전환에도 적용. */
|
||||
altitudeOffsetM: number;
|
||||
|
||||
/**
|
||||
* 폴더 선택 파일에서 지리정보를 파싱해 스토어에 적재한다.
|
||||
@@ -97,6 +101,8 @@ const EMPTY = {
|
||||
folderFiles: [] as File[],
|
||||
cameraInfo: null as DetectedCameraInfo | null,
|
||||
cameraJson: null as Record<string, number> | null,
|
||||
displayJson: null as Record<string, unknown> | null,
|
||||
altitudeOffsetM: 0,
|
||||
};
|
||||
|
||||
export const useGeoStore = create<GeoStore>((set, get) => ({
|
||||
@@ -124,6 +130,8 @@ export const useGeoStore = create<GeoStore>((set, get) => ({
|
||||
folderFiles: data.folderFiles,
|
||||
cameraInfo: data.cameraInfo,
|
||||
cameraJson: data.cameraJson,
|
||||
displayJson: data.displayJson,
|
||||
altitudeOffsetM: data.altitudeOffsetM,
|
||||
});
|
||||
return data.videoFile;
|
||||
},
|
||||
@@ -152,6 +160,10 @@ export const useGeoStore = create<GeoStore>((set, get) => ({
|
||||
offsetSec,
|
||||
durationSec: s.videoDurations[index],
|
||||
});
|
||||
// datum 보정(타원체고 데이터셋) 유지 — 폴더 로드 때 산출한 오프셋을 재파싱 프레임에도 적용.
|
||||
if (s.altitudeOffsetM) {
|
||||
for (const f of frames) f.altitude += s.altitudeOffsetM;
|
||||
}
|
||||
console.log(
|
||||
`[geo] 세그먼트 ${index + 1}/${s.videoFiles.length} 전환 — 로그 ${offsetSec.toFixed(1)}s~ 정렬, ${frames.length}행`,
|
||||
);
|
||||
|
||||
@@ -138,6 +138,10 @@ export interface RouteInfo {
|
||||
durationSec?: number;
|
||||
startStationName?: string;
|
||||
endStationName?: string;
|
||||
/** 노선 기준 타원체고(m) — 타원체고 기준 데이터셋 참고값(파일에서 통과, 현재 투영엔 미사용). */
|
||||
ellipsoidal_height?: number;
|
||||
/** 개발 모드: 1(기본)=영상제어 버튼·제어 UI 표시, 0=숨김(뷰어 모드). */
|
||||
dev_mode?: number;
|
||||
/** 측점 인정 범위(m): station 위치를 이 거리 이내로 지날 때 마커 표시. 기본 40. 폴더별 조절. */
|
||||
stationTolerance?: number;
|
||||
/**
|
||||
@@ -163,6 +167,8 @@ export interface DetectedCameraInfo {
|
||||
width?: number;
|
||||
height?: number;
|
||||
fps?: number;
|
||||
/** 영상 시작 시점 드론 타원체고(m) — djmd 필드 6.6(mm) 유래. 타원체고 기준 데이터셋 변환용. */
|
||||
ellipsoidalAltM?: number;
|
||||
}
|
||||
|
||||
/** loadFolderGeoData 파싱 결과 (v2.0 데이터 형상) */
|
||||
@@ -193,4 +199,9 @@ export interface FolderGeoData {
|
||||
cameraInfo: DetectedCameraInfo | null;
|
||||
/** 폴더의 `<영상 base>.camera.json` 카메라 파라미터 (없으면 null). 최우선 적용. */
|
||||
cameraJson: Record<string, number> | null;
|
||||
/** 폴더의 `<영상 base>.display.json` 화면표시 옵션 (없으면 null). 자동 적용. */
|
||||
displayJson: Record<string, unknown> | null;
|
||||
/** 드론 고도 datum 보정(m) — 타원체고 기준 데이터셋에서 CSV 정표고→타원체고 변환량(지오이드고).
|
||||
* 세그먼트 전환 재파싱 시에도 동일 적용. 정표고 데이터셋은 0. */
|
||||
altitudeOffsetM: number;
|
||||
}
|
||||
|
||||
@@ -101,6 +101,11 @@ export async function detectCameraInfo(file: File): Promise<DetectedCameraInfo |
|
||||
if (w && w > 100) info.width = w;
|
||||
if (h && h > 100) info.height = h;
|
||||
if (typeof fps === 'number' && fps > 1 && fps < 1000) info.fps = Math.round(fps * 100) / 100;
|
||||
// 6.6 = 드론 타원체고(mm) — 영상 시작 시점. CSV 정표고와의 차 = 지역 지오이드고(datum 변환용).
|
||||
const altMm = out.get('6.6');
|
||||
if (typeof altMm === 'number' && altMm > 0 && altMm < 10_000_000) {
|
||||
info.ellipsoidalAltM = Math.round(altMm) / 1000;
|
||||
}
|
||||
return info;
|
||||
} catch {
|
||||
return null;
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* KML 표고(DEM) 파생 파일 생성 — `<이름>_byDem.kml`
|
||||
*
|
||||
* 폴더 로드 시 측점/지장물 KML 의 좌표 고도 성분이 없거나 0 이면, DEM(서버 /api/elevation
|
||||
* 프록시 → SRTM 30m, 폴백 90m)으로 지면 표고를 조회해 채운 `<이름>_byDem.kml` 을 만들어
|
||||
* PC 로 다운로드한다. 사용자가 받은 파일을 영상 폴더에 넣으면, 로더(parseKmz)가
|
||||
* 원본 대신 _byDem 버전을 읽는다(중복 파싱 방지 규칙).
|
||||
*
|
||||
* - 이미 표고가 있는 좌표(>0)는 그대로 두고 없는 것만 채운다
|
||||
* - 폴더에 `<이름>_byDem.kml` 이 이미 있으면 해당 원본은 건너뜀
|
||||
* - DEM 조회는 100좌표/청크, 업스트림 rate limit(1req/s) 고려 1.2s 간격
|
||||
*/
|
||||
|
||||
/** File 의 폴더 내 파일명 (webkitRelativePath 마지막 세그먼트). */
|
||||
function baseNameOf(f: File): string {
|
||||
const p = f.webkitRelativePath || f.name;
|
||||
return p.split('/').pop() ?? f.name;
|
||||
}
|
||||
|
||||
function isInBuilding(f: File): boolean {
|
||||
const parts = (f.webkitRelativePath || f.name).split('/');
|
||||
return parts.length >= 2 && parts[parts.length - 2].toLowerCase() === 'building';
|
||||
}
|
||||
|
||||
/** 표고가 필요한(없거나 0) 좌표 목록 수집. */
|
||||
function collectMissing(text: string): { key: string; lat: number; lon: number }[] {
|
||||
const need: { key: string; lat: number; lon: number }[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const block of text.matchAll(/<coordinates>([\s\S]*?)<\/coordinates>/g)) {
|
||||
for (const tok of block[1].trim().split(/\s+/)) {
|
||||
const p = tok.split(',');
|
||||
if (p.length < 2) continue;
|
||||
const lon = parseFloat(p[0]), lat = parseFloat(p[1]);
|
||||
if (!isFinite(lon) || !isFinite(lat)) continue;
|
||||
const alt = p.length >= 3 ? parseFloat(p[2]) : NaN;
|
||||
if (isFinite(alt) && alt > 0) continue; // 이미 표고 보유
|
||||
const key = `${p[0]},${p[1]}`;
|
||||
if (!seen.has(key)) { seen.add(key); need.push({ key, lat, lon }); }
|
||||
}
|
||||
}
|
||||
return need;
|
||||
}
|
||||
|
||||
/**
|
||||
* 폴더 파일들에서 표고 없는 KML 을 찾아 `<이름>_byDem.kml` 을 생성·다운로드한다.
|
||||
* 생성한 파일명 목록을 반환(생성할 것이 없으면 빈 배열).
|
||||
*/
|
||||
export async function generateByDemKmls(input: FileList | File[]): Promise<string[]> {
|
||||
const files = Array.from(input);
|
||||
const kmls = files.filter((f) => !isInBuilding(f) && /\.kml$/i.test(baseNameOf(f)));
|
||||
const lowerNames = new Set(kmls.map((f) => baseNameOf(f).toLowerCase()));
|
||||
const made: string[] = [];
|
||||
|
||||
for (const f of kmls) {
|
||||
const base = baseNameOf(f).replace(/\.kml$/i, '');
|
||||
if (/_bydem$/i.test(base)) continue; // 자신이 DEM 버전
|
||||
if (lowerNames.has(`${base.toLowerCase()}_bydem.kml`)) continue; // DEM 버전이 이미 폴더에 있음
|
||||
|
||||
const text = await f.text();
|
||||
const need = collectMissing(text);
|
||||
if (!need.length) continue; // 전 좌표 표고 보유 → 불필요
|
||||
|
||||
// DEM 조회 (서버 프록시)
|
||||
const elev = new Map<string, number>();
|
||||
for (let i = 0; i < need.length; i += 100) {
|
||||
const chunk = need.slice(i, i + 100);
|
||||
const r = await fetch(
|
||||
`/api/elevation?lat=${chunk.map((c) => c.lat).join(',')}&lon=${chunk.map((c) => c.lon).join(',')}`,
|
||||
);
|
||||
if (!r.ok) throw new Error(`DEM 조회 실패 (HTTP ${r.status})`);
|
||||
const j: { elevation?: (number | null)[] } = await r.json();
|
||||
chunk.forEach((c, k) => {
|
||||
const e = j.elevation?.[k];
|
||||
if (typeof e === 'number' && isFinite(e)) elev.set(c.key, e);
|
||||
});
|
||||
if (i + 100 < need.length) await new Promise((res) => setTimeout(res, 1200));
|
||||
}
|
||||
if (!elev.size) continue;
|
||||
|
||||
// 고도 성분 치환(없거나 0 인 것만) — 그 외 텍스트/구조는 원본 그대로 보존
|
||||
let next = text.replace(/<coordinates>([\s\S]*?)<\/coordinates>/g, (_m, body: string) =>
|
||||
`<coordinates>${body.replace(/(\S+)/g, (tok: string) => {
|
||||
const p = tok.split(',');
|
||||
if (p.length < 2) return tok;
|
||||
const alt = p.length >= 3 ? parseFloat(p[2]) : NaN;
|
||||
if (isFinite(alt) && alt > 0) return tok;
|
||||
const e = elev.get(`${p[0]},${p[1]}`);
|
||||
return e === undefined ? tok : `${p[0]},${p[1]},${e.toFixed(1)}`;
|
||||
})}</coordinates>`);
|
||||
|
||||
// '높이' 속성(지장물 스키마)도 0/빈값이면 같은 DEM 표고로 채운다 — Placemark 단위로
|
||||
// 자기 좌표의 표고를 대응. 실제 값이 있는 높이는 보존.
|
||||
next = next.replace(/<Placemark[^>]*>[\s\S]*?<\/Placemark>/g, (pm) => {
|
||||
const cm = pm.match(/<coordinates>\s*([-\d.]+),([-\d.]+)/);
|
||||
if (!cm) return pm;
|
||||
const e = elev.get(`${cm[1]},${cm[2]}`);
|
||||
if (e === undefined) return pm;
|
||||
return pm.replace(
|
||||
/(<SimpleData name="높이">)\s*(?:0(?:\.0+)?)?\s*(<\/SimpleData>)/,
|
||||
`$1${e.toFixed(1)}$2`,
|
||||
);
|
||||
});
|
||||
|
||||
const outName = `${base}_byDem.kml`;
|
||||
const url = URL.createObjectURL(new Blob([next], { type: 'application/vnd.google-earth.kml+xml' }));
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = outName;
|
||||
a.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 5000);
|
||||
made.push(outName);
|
||||
console.log(`[DEM] ${outName} 생성 — 좌표 ${need.length}개 중 ${elev.size}개 표고 기록`);
|
||||
}
|
||||
return made;
|
||||
}
|
||||
+143
-22
@@ -423,11 +423,25 @@ function inlineIconColor(pm: Element): string | undefined {
|
||||
* 등 측점 파이프라인(스테이션바·노선패널·검색)과 그대로 호환된다. */
|
||||
export async function parseKmz(
|
||||
files: File[],
|
||||
): Promise<{ pois: GeoPoint[]; structures: RouteStructure[]; stations: GeoPoint[] } | null> {
|
||||
): Promise<{ pois: GeoPoint[]; structures: RouteStructure[]; stations: GeoPoint[]; centerline: CenterlinePoint[] } | null> {
|
||||
// 폴더 내 모든 KML/KMZ 를 파싱해 병합한다 (예: 제주 — 측점 KML + 지장물 KML 별도 파일).
|
||||
const kmlFiles = files.filter(
|
||||
// 단, `<이름>_byDem.kml`(DEM 표고 채운 파생본)이 있으면 그 원본 `<이름>.kml` 은 제외 —
|
||||
// 같은 지물이 두 번 파싱되는 것 방지 + 표고 보유 버전 우선.
|
||||
let kmlFiles = files.filter(
|
||||
(f) => !isInBuilding(f) && /\.kml$/i.test(baseNameOf(relPath(f))),
|
||||
);
|
||||
const byDemBases = new Set(
|
||||
kmlFiles
|
||||
.map((f) => baseNameOf(relPath(f)).toLowerCase().match(/^(.+)_bydem\.kml$/)?.[1])
|
||||
.filter((b): b is string => !!b),
|
||||
);
|
||||
if (byDemBases.size) {
|
||||
kmlFiles = kmlFiles.filter((f) => {
|
||||
const name = baseNameOf(relPath(f)).toLowerCase();
|
||||
const m = name.match(/^(.+)\.kml$/);
|
||||
return !(m && !m[1].endsWith('_bydem') && byDemBases.has(m[1]));
|
||||
});
|
||||
}
|
||||
const kmzFiles = files.filter(
|
||||
(f) => !isInBuilding(f) && /\.kmz$/i.test(baseNameOf(relPath(f))),
|
||||
);
|
||||
@@ -458,6 +472,8 @@ export async function parseKmz(
|
||||
const pois: GeoPoint[] = [];
|
||||
const structures: RouteStructure[] = [];
|
||||
const stations: GeoPoint[] = [];
|
||||
// 선형중심선 (LineString) — 도로 중심선 폴리라인. 여러 개면 가장 긴 것 사용.
|
||||
let centerline: CenterlinePoint[] = [];
|
||||
let nStruct = 0;
|
||||
|
||||
const handlePlacemark = (pm: Element, folder: string): void => {
|
||||
@@ -472,12 +488,38 @@ export async function parseKmz(
|
||||
}
|
||||
const pget = (k: string): string | undefined => props.find((p) => p.k === k)?.v;
|
||||
|
||||
// 선형중심선 (LineString) — 도로 중심선 폴리라인으로 수집(POI 아님).
|
||||
// CAD 내보내기 KML(예: 제주_중산간도로.kml Folder 선형중심선)의 실제 도로 곡선.
|
||||
const lineEl = pm.getElementsByTagName('LineString')[0];
|
||||
if (lineEl) {
|
||||
const coordText = lineEl.getElementsByTagName('coordinates')[0]?.textContent ?? '';
|
||||
const pts: CenterlinePoint[] = [];
|
||||
let prevKey = '';
|
||||
for (const tok of coordText.trim().split(/\s+/)) {
|
||||
const p = tok.split(',').map(Number);
|
||||
if (p.length < 2 || !isFinite(p[0]) || !isFinite(p[1])) continue;
|
||||
if (p[1] < 33 || p[1] > 39 || p[0] < 124 || p[0] > 132) continue;
|
||||
const key = `${p[0].toFixed(8)},${p[1].toFixed(8)}`;
|
||||
if (key === prevKey) continue; // 연속 중복 정점 제거
|
||||
prevKey = key;
|
||||
pts.push({ lat: p[1], lon: p[0], z: p.length >= 3 && isFinite(p[2]) ? p[2] : 0 });
|
||||
}
|
||||
if (pts.length >= 2 && pts.length > centerline.length) centerline = pts;
|
||||
return;
|
||||
}
|
||||
|
||||
// 좌표: <coordinates> lon,lat[,alt] 우선, 없으면 속성 lat/lon.
|
||||
// alt(3번째 성분)는 DEM 일괄 기록(docs/history 2026-07-13 참조) 등으로 채워진 지면 표고 —
|
||||
// 있으면(>0) POI/측점의 z 로 사용해 지형 기복을 정확히 따른다.
|
||||
let lat = NaN, lon = NaN;
|
||||
let coordAlt: number | undefined;
|
||||
const coordEl = pm.getElementsByTagName('coordinates')[0];
|
||||
if (coordEl?.textContent) {
|
||||
const p = coordEl.textContent.trim().split(/[\s,]+/).map(Number);
|
||||
if (p.length >= 2 && isFinite(p[0]) && isFinite(p[1])) { lon = p[0]; lat = p[1]; }
|
||||
if (p.length >= 2 && isFinite(p[0]) && isFinite(p[1])) {
|
||||
lon = p[0]; lat = p[1];
|
||||
if (p.length >= 3 && isFinite(p[2]) && p[2] > 0) coordAlt = p[2];
|
||||
}
|
||||
}
|
||||
if (isNaN(lat) || isNaN(lon)) {
|
||||
lat = parseFloat(pget('lat') ?? pget('latitude') ?? '');
|
||||
@@ -495,10 +537,11 @@ export async function parseKmz(
|
||||
const lenM = parseFloat(pget('연장') ?? '');
|
||||
// 라벨 색: 텍스트박스_색상(한국어명) 우선, 없으면 인라인 IconStyle 색.
|
||||
const labelColor = koreanColorToCss(pget('텍스트박스_색상')) ?? inlineIconColor(pm);
|
||||
// z: '높이'는 표고가 아니라 구조물 높이라 사용하지 않음(팝업 props 로만 노출).
|
||||
// 지면표고 미상(0) → 오버레이 기본(드론고도−이격) 모드 및 중심선 z 폴백으로 표시.
|
||||
// z: 좌표 고도(DEM 기록 지면 표고) 있으면 사용 — 지형 기복을 따라 정확히 배치.
|
||||
// ('높이' 속성은 표고가 아니라 구조물 높이라 사용하지 않음 — 팝업 props 로만 노출.)
|
||||
// 미상(0)이면 오버레이가 지면고도 모드에서 경로표고 앵커로 폴백.
|
||||
pois.push({
|
||||
title, category, lat, lon, z: 0, type: 'poi',
|
||||
title, category, lat, lon, z: coordAlt ?? 0, type: 'poi',
|
||||
...(labelColor ? { labelColor } : {}),
|
||||
props,
|
||||
});
|
||||
@@ -533,8 +576,8 @@ export async function parseKmz(
|
||||
: staMatch ? parseInt(staMatch[1], 10) * 1000 + parseInt(staMatch[2], 10) : NaN;
|
||||
if (!isFinite(meters)) return;
|
||||
const title = `${Math.floor(meters / 1000)}k${String(meters % 1000).padStart(3, '0')}`;
|
||||
// z 미상(KML 고도 0) — 소비측(loadFolderGeoData)에서 드론 고도 기반으로 보정.
|
||||
stations.push({ title, category: '측점', lat, lon, z: 0, type: 'station', props });
|
||||
// z: 좌표 고도(DEM 기록값) 있으면 사용 — 없으면 0(미상, 오버레이가 경로표고 앵커).
|
||||
stations.push({ title, category: '측점', lat, lon, z: coordAlt ?? 0, type: 'station', props });
|
||||
return;
|
||||
}
|
||||
// 02)지장물 등 = 지오코딩 POI. 모든 POI 는 영상에 표출(철도역 포함).
|
||||
@@ -572,30 +615,37 @@ export async function parseKmz(
|
||||
if (doc.documentElement) walk(doc.documentElement, '');
|
||||
}
|
||||
|
||||
return { pois, structures, stations };
|
||||
return { pois, structures, stations, centerline };
|
||||
}
|
||||
|
||||
// ── 카메라 파라미터 파일 (<base>.camera.json) ─────────────────────────
|
||||
|
||||
/**
|
||||
* 폴더에서 카메라 파라미터 파일을 읽는다. 서버 '카메라값 저장'(PUT /api/camera/:videoId)이
|
||||
* 영상 옆에 만든 `<영상 base>.camera.json`(또는 `<영상 base>.json`) 형식:
|
||||
* 폴더에서 카메라 파라미터 파일을 읽는다. 'PC에 저장' 버튼이 만든 형식:
|
||||
* { "camera": { focalLen, sensorW, sensorH, yawOffset, pitch, roll, offX, offY, offZ, geoidOffset, cx0, cy0 }, ... }
|
||||
* 폴더 내 어떤 영상의 base 와도 매칭 허용(분할본 공용). 숫자 필드만 통과시킨다.
|
||||
* 폴더 공용 설정 — 파일명 규칙(우선순위):
|
||||
* ① `camera.json` (단순 이름 — 권장)
|
||||
* ② `<영상 base>.camera.json` / `<영상 base>.json` (어느 분할본 base 든)
|
||||
* ③ 임의 이름의 `*.camera.json` (예: "제주 중산간도로 경로 1-1.camera.json")
|
||||
* 같은 폴더의 모든 영상(분할본 전체)에 동일하게 적용된다. 숫자 필드만 통과시킨다.
|
||||
*/
|
||||
export async function parseCameraJson(
|
||||
files: File[],
|
||||
videoFiles: File[],
|
||||
): Promise<Record<string, number> | null> {
|
||||
const bases = videoFiles.map((v) => baseNameOf(relPath(v)).replace(VIDEO_EXT, '').toLowerCase());
|
||||
const file = files.find((f) => {
|
||||
const rootJson = files.filter((f) => {
|
||||
if (isInBuilding(f)) return false;
|
||||
const name = baseNameOf(relPath(f)).toLowerCase();
|
||||
const m = name.match(/^(.+?)(\.camera)?\.json$/);
|
||||
if (!m) return false;
|
||||
if (/\.route\.json$|_poi_overrides\.json$/.test(name)) return false;
|
||||
return bases.includes(m[1]);
|
||||
return /\.json$/.test(name) && !/^route\.json$|\.route\.json$|^display\.json$|\.display\.json$|_poi_overrides\.json$/.test(name);
|
||||
});
|
||||
const file =
|
||||
rootJson.find((f) => baseNameOf(relPath(f)).toLowerCase() === 'camera.json') ??
|
||||
rootJson.find((f) => {
|
||||
const m = baseNameOf(relPath(f)).toLowerCase().match(/^(.+?)(\.camera)?\.json$/);
|
||||
return !!m && bases.includes(m[1]);
|
||||
}) ??
|
||||
rootJson.find((f) => /\.camera\.json$/i.test(baseNameOf(relPath(f))));
|
||||
if (!file) return null;
|
||||
try {
|
||||
const obj = JSON.parse(await file.text());
|
||||
@@ -611,6 +661,40 @@ export async function parseCameraJson(
|
||||
}
|
||||
}
|
||||
|
||||
// ── 화면표시 옵션 파일 (<base>.display.json) ──────────────────────────
|
||||
|
||||
/**
|
||||
* 폴더에서 화면표시 옵션 파일을 읽는다. '화면표시 옵션 PC 저장' 버튼이 만든
|
||||
* `{ display: {...}, savedAt }` 형식. 폴더 공용 설정 — 파일명 규칙(우선순위):
|
||||
* ① `display.json` (단순 이름 — 권장)
|
||||
* ② `<영상 base>.display.json`
|
||||
* ③ 임의 이름의 `*.display.json` (예: "제주 중산간도로 경로 1-1.display.json")
|
||||
* 폴더 내 모든 영상에 동일 적용.
|
||||
*/
|
||||
export async function parseDisplayJson(
|
||||
files: File[],
|
||||
videoFiles: File[],
|
||||
): Promise<Record<string, unknown> | null> {
|
||||
const bases = videoFiles.map((v) => baseNameOf(relPath(v)).replace(VIDEO_EXT, '').toLowerCase());
|
||||
const rootDisplay = files.filter(
|
||||
(f) => !isInBuilding(f) && /(^|\.)display\.json$/i.test(baseNameOf(relPath(f)).toLowerCase()),
|
||||
);
|
||||
const file =
|
||||
rootDisplay.find((f) => baseNameOf(relPath(f)).toLowerCase() === 'display.json') ??
|
||||
rootDisplay.find((f) => {
|
||||
const m = baseNameOf(relPath(f)).toLowerCase().match(/^(.+?)\.display\.json$/);
|
||||
return !!m && bases.includes(m[1]);
|
||||
}) ?? rootDisplay[0];
|
||||
if (!file) return null;
|
||||
try {
|
||||
const obj = JSON.parse(await file.text());
|
||||
const d = obj && typeof obj === 'object' ? (obj.display ?? obj) : null;
|
||||
return d && typeof d === 'object' ? (d as Record<string, unknown>) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── route.json 보정 ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -777,7 +861,7 @@ export async function loadFolderGeoData(
|
||||
);
|
||||
}
|
||||
|
||||
const [frames, stationResult, routeMeta, poiOverrides, kmz, cameraInfo, cameraJson] = await Promise.all([
|
||||
const [frames, stationResult, routeMeta, poiOverrides, kmz, cameraInfo, cameraJson, displayJson] = await Promise.all([
|
||||
parseDroneFrames(files, baseName, segmentWindow),
|
||||
parseStations(files),
|
||||
parseRouteMeta(files, baseName),
|
||||
@@ -785,12 +869,37 @@ export async function loadFolderGeoData(
|
||||
parseKmz(files),
|
||||
videoFile ? detectCameraInfo(videoFile) : Promise.resolve(null),
|
||||
parseCameraJson(files, videoFiles),
|
||||
parseDisplayJson(files, videoFiles),
|
||||
]);
|
||||
|
||||
if (displayJson) {
|
||||
console.log('[display] <base>.display.json 발견 — 저장된 화면표시 옵션 적용 예정');
|
||||
}
|
||||
|
||||
if (cameraJson) {
|
||||
console.log('[camera] <base>.camera.json 발견 — 저장된 카메라 파라미터 적용:', cameraJson);
|
||||
}
|
||||
|
||||
// 타원체고 기준 데이터셋 자동 변환 — route.json 에 ellipsoidal_height 가 선언된 폴더는
|
||||
// 지물(측점/지장물) z 가 타원체고이므로, 드론 고도(CSV 정표고)를 영상 메타(djmd)의
|
||||
// 타원체고와의 차이(= 그 지점 지오이드고)만큼 올려 기준을 통일한다. 지오이드/offZ 수동 보정 불필요.
|
||||
let altitudeOffsetM = 0;
|
||||
if (
|
||||
routeMeta?.routeInfo?.ellipsoidal_height != null &&
|
||||
cameraInfo?.ellipsoidalAltM != null &&
|
||||
frames.length
|
||||
) {
|
||||
const geoidN = cameraInfo.ellipsoidalAltM - frames[0].altitude;
|
||||
if (isFinite(geoidN) && Math.abs(geoidN) < 60) {
|
||||
altitudeOffsetM = Math.round(geoidN * 100) / 100;
|
||||
for (const f of frames) f.altitude += altitudeOffsetM;
|
||||
console.log(
|
||||
`[geo] 타원체고 기준 데이터셋 — 드론 고도를 타원체고로 변환 (+${altitudeOffsetM.toFixed(2)}m = ` +
|
||||
`djmd ${cameraInfo.ellipsoidalAltM.toFixed(1)} − CSV ${(frames[0].altitude - altitudeOffsetM).toFixed(1)})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 감지된 초점거리를 드론 프레임 focalLen 에도 반영 (검색/투영 FOV 판정 일관성).
|
||||
if (cameraInfo?.focalLen35) {
|
||||
for (const f of frames) f.focalLen = cameraInfo.focalLen35;
|
||||
@@ -815,7 +924,7 @@ export async function loadFolderGeoData(
|
||||
} else if (kmz!.pois.length === 0 && kmz!.structures.length === 0 && kmz!.stations.length === 0) {
|
||||
console.warn('[KMZ] POI·구조물·측점 0건 — 이 데이터셋엔 표출할 지물이 없습니다(드론 궤적은 무관).');
|
||||
} else {
|
||||
console.log(`[KMZ] POI ${kmz!.pois.length} · 구조물 ${kmz!.structures.length} · 측점 ${kmz!.stations.length} 로드(원본)`);
|
||||
console.log(`[KMZ] POI ${kmz!.pois.length} · 구조물 ${kmz!.structures.length} · 측점 ${kmz!.stations.length} · 중심선 ${kmz!.centerline.length}점 로드(원본)`);
|
||||
}
|
||||
const pois = kmz?.pois ?? [];
|
||||
const baseStructures = kmz?.structures ?? [];
|
||||
@@ -827,7 +936,11 @@ export async function loadFolderGeoData(
|
||||
let rawStations = stationResult.stations;
|
||||
if (!rawStations.length && kmz?.stations.length) {
|
||||
rawStations = kmz.stations;
|
||||
console.log(`[KMZ] 체이니지 측점 ${rawStations.length}개 로드 (표고 미상 → 오버레이 '경로 표고' 평면 앵커)`);
|
||||
const withZ = rawStations.filter((s) => s.z > 0).length;
|
||||
console.log(
|
||||
`[KMZ] 체이니지 측점 ${rawStations.length}개 로드` +
|
||||
(withZ ? ` (DEM 표고 보유 ${withZ}개)` : ' (표고 미상 → 오버레이 경로표고 앵커)'),
|
||||
);
|
||||
}
|
||||
|
||||
const stations = rawStations.sort(
|
||||
@@ -839,8 +952,14 @@ export async function loadFolderGeoData(
|
||||
// 철도역(역사)은 KMZ의 KAKAO_RAIL placemark에서 구조물로 생성됨(스테이션바 표출).
|
||||
const structures = mergeStructures(baseStructures, routeMeta);
|
||||
|
||||
// v2.0엔 center.csv 가 없다 → 측점 폴리라인으로 중심선 생성.
|
||||
const centerline = buildCenterlineFromStations(stations);
|
||||
// 중심선 소스 우선순위: KML 선형중심선(LineString — 실제 도로 곡선, 예: 제주 146정점)
|
||||
// → 없으면 측점 폴리라인(100m 간격 직선 연결). v2.0엔 center.csv 가 없다.
|
||||
const centerline = (kmz?.centerline.length ?? 0) >= 2
|
||||
? kmz!.centerline
|
||||
: buildCenterlineFromStations(stations);
|
||||
if ((kmz?.centerline.length ?? 0) >= 2) {
|
||||
console.log(`[geo] 중심선: KML 선형중심선 ${kmz!.centerline.length}점 사용`);
|
||||
}
|
||||
|
||||
let origin: GeoOrigin | null = null;
|
||||
if (stations.length || frames.length) {
|
||||
@@ -867,5 +986,7 @@ export async function loadFolderGeoData(
|
||||
folderFiles: files,
|
||||
cameraInfo,
|
||||
cameraJson,
|
||||
displayJson,
|
||||
altitudeOffsetM,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user