From 7d51ba9945dd4e8c19ce4013387ab8cdd4924932 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=95=9C=EC=84=B1=EC=9D=BC?= Date: Mon, 13 Jul 2026 20:37:46 +0900 Subject: [PATCH] =?UTF-8?q?=ED=91=9C=EA=B3=A0=20=EC=B2=B4=EA=B3=84=20?= =?UTF-8?q?=EC=A0=95=EB=B9=84=C2=B7=EC=84=A4=EC=A0=95=20=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=20=EB=8B=A8=EC=88=9C=ED=99=94=C2=B7=EC=84=A0=ED=98=95=20?= =?UTF-8?q?=ED=81=B4=EB=A6=AC=ED=95=91=C2=B7=EC=8B=9C=EC=97=B0=20=EC=A4=80?= =?UTF-8?q?=EB=B9=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 타원체고 기준 자동 변환: 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 --- .../components/overlay/RouteInfoOverlay.tsx | 17 +- .../src/components/overlay/StationOverlay.tsx | 393 +++++++++++++++--- client/src/components/player/VideoPlayer.tsx | 26 +- client/src/stationbar/StationBar.tsx | 32 +- client/src/store/geoStore.ts | 12 + client/src/types/geo.ts | 11 + client/src/utils/cameraMeta.ts | 5 + client/src/utils/demKml.ts | 115 +++++ client/src/utils/geoData.ts | 165 +++++++- docs/DEMO.md | 39 ++ docs/history/2026-07-09_2053_전체-커밋.md | 19 + docs/history/2026-07-09_2056_push-인증차단.md | 21 + ...026-07-09_2102_KML-데이터-로드범위-확인.md | 18 + .../2026-07-13_0959_KML-선형중심선-로딩.md | 36 ++ ...07-13_1010_화면표시옵션-PC저장-자동적용.md | 30 ++ .../2026-07-13_1016_측점투영수정-시점마크.md | 29 ++ ...026-07-13_1021_시점마크-실통과위치-수정.md | 27 ++ .../2026-07-13_1024_폴더공용json-시점라벨.md | 25 ++ ...6-07-13_1028_화면표시옵션-저장기능-안내.md | 14 + ...026-07-13_1037_사전계산-최적화-버튼교환.md | 30 ++ .../2026-07-13_1045_이격거리-측점-동일적용.md | 25 ++ .../2026-07-13_1052_슬라이더-휠-조절.md | 22 + .../2026-07-13_1102_측점-이격거리-제외.md | 23 + ...2026-07-13_1236_지오이드200-휠-값입력칸.md | 15 + ...3_1358_세그먼트전환-라벨깜빡임-구조수정.md | 36 ++ .../2026-07-13_1414_선형클리핑-배너-실측값.md | 23 + .../2026-07-13_1419_선형-마지막측점-클립.md | 20 + ...026-07-13_1424_초기화-환경설정파일-복원.md | 26 ++ .../2026-07-13_1446_커서배지-1m단위.md | 18 + .../2026-07-13_1448_선형-경계세그먼트-절단.md | 25 ++ .../2026-07-13_1452_노트북-시연-가이드.md | 26 ++ .../2026-07-13_1500_POI-z값-부재-확인.md | 22 + .../2026-07-13_1506_KML-DEM표고-일괄기록.md | 36 ++ .../2026-07-13_1513_byDem-kml-자동생성.md | 42 ++ .../2026-07-13_1551_byDem-높이속성-포함.md | 25 ++ .../2026-07-13_1657_설정파일-단순이름-지원.md | 37 ++ .../2026-07-13_1712_경로1-3-폴더검증.md | 20 + ...026-07-13_1725_카메라-저장버튼-중복제거.md | 17 + .../2026-07-13_1759_54000-외부접속-개방.md | 18 + .../2026-07-13_1842_이격거리-측점-재적용.md | 19 + .../2026-07-13_1848_지오이드-설정-설명.md | 14 + .../2026-07-13_1852_지오이드-이격거리-차이.md | 12 + ...026-07-13_1858_시작타원체고-활용성-답변.md | 17 + .../2026-07-13_1904_맵핑-기준값-권장안.md | 20 + .../2026-07-13_1912_타원체고-기준-자동변환.md | 36 ++ .../2026-07-13_1920_측점-datum-불일치-발견.md | 24 ++ ...2026-07-13_1927_측점-타원체고-교정-실행.md | 17 + ...2026-07-13_1935_지오이드0-타원체고-여부.md | 11 + .../2026-07-13_1940_이격거리-권장값.md | 11 + .../2026-07-13_1943_드론높이-off-확인.md | 10 + ...26-07-13_2028_devmode-영상제어-표시제어.md | 28 ++ .../2026-07-13_2034_선형-관통세그먼트-수정.md | 26 ++ start-demo.bat | 27 ++ 53 files changed, 1708 insertions(+), 104 deletions(-) create mode 100644 client/src/utils/demKml.ts create mode 100644 docs/DEMO.md create mode 100644 docs/history/2026-07-09_2053_전체-커밋.md create mode 100644 docs/history/2026-07-09_2056_push-인증차단.md create mode 100644 docs/history/2026-07-09_2102_KML-데이터-로드범위-확인.md create mode 100644 docs/history/2026-07-13_0959_KML-선형중심선-로딩.md create mode 100644 docs/history/2026-07-13_1010_화면표시옵션-PC저장-자동적용.md create mode 100644 docs/history/2026-07-13_1016_측점투영수정-시점마크.md create mode 100644 docs/history/2026-07-13_1021_시점마크-실통과위치-수정.md create mode 100644 docs/history/2026-07-13_1024_폴더공용json-시점라벨.md create mode 100644 docs/history/2026-07-13_1028_화면표시옵션-저장기능-안내.md create mode 100644 docs/history/2026-07-13_1037_사전계산-최적화-버튼교환.md create mode 100644 docs/history/2026-07-13_1045_이격거리-측점-동일적용.md create mode 100644 docs/history/2026-07-13_1052_슬라이더-휠-조절.md create mode 100644 docs/history/2026-07-13_1102_측점-이격거리-제외.md create mode 100644 docs/history/2026-07-13_1236_지오이드200-휠-값입력칸.md create mode 100644 docs/history/2026-07-13_1358_세그먼트전환-라벨깜빡임-구조수정.md create mode 100644 docs/history/2026-07-13_1414_선형클리핑-배너-실측값.md create mode 100644 docs/history/2026-07-13_1419_선형-마지막측점-클립.md create mode 100644 docs/history/2026-07-13_1424_초기화-환경설정파일-복원.md create mode 100644 docs/history/2026-07-13_1446_커서배지-1m단위.md create mode 100644 docs/history/2026-07-13_1448_선형-경계세그먼트-절단.md create mode 100644 docs/history/2026-07-13_1452_노트북-시연-가이드.md create mode 100644 docs/history/2026-07-13_1500_POI-z값-부재-확인.md create mode 100644 docs/history/2026-07-13_1506_KML-DEM표고-일괄기록.md create mode 100644 docs/history/2026-07-13_1513_byDem-kml-자동생성.md create mode 100644 docs/history/2026-07-13_1551_byDem-높이속성-포함.md create mode 100644 docs/history/2026-07-13_1657_설정파일-단순이름-지원.md create mode 100644 docs/history/2026-07-13_1712_경로1-3-폴더검증.md create mode 100644 docs/history/2026-07-13_1725_카메라-저장버튼-중복제거.md create mode 100644 docs/history/2026-07-13_1759_54000-외부접속-개방.md create mode 100644 docs/history/2026-07-13_1842_이격거리-측점-재적용.md create mode 100644 docs/history/2026-07-13_1848_지오이드-설정-설명.md create mode 100644 docs/history/2026-07-13_1852_지오이드-이격거리-차이.md create mode 100644 docs/history/2026-07-13_1858_시작타원체고-활용성-답변.md create mode 100644 docs/history/2026-07-13_1904_맵핑-기준값-권장안.md create mode 100644 docs/history/2026-07-13_1912_타원체고-기준-자동변환.md create mode 100644 docs/history/2026-07-13_1920_측점-datum-불일치-발견.md create mode 100644 docs/history/2026-07-13_1927_측점-타원체고-교정-실행.md create mode 100644 docs/history/2026-07-13_1935_지오이드0-타원체고-여부.md create mode 100644 docs/history/2026-07-13_1940_이격거리-권장값.md create mode 100644 docs/history/2026-07-13_1943_드론높이-off-확인.md create mode 100644 docs/history/2026-07-13_2028_devmode-영상제어-표시제어.md create mode 100644 docs/history/2026-07-13_2034_선형-관통세그먼트-수정.md create mode 100644 start-demo.bat diff --git a/client/src/components/overlay/RouteInfoOverlay.tsx b/client/src/components/overlay/RouteInfoOverlay.tsx index 7cd382f..ddbd7d2 100644 --- a/client/src/components/overlay/RouteInfoOverlay.tsx +++ b/client/src/components/overlay/RouteInfoOverlay.tsx @@ -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((acc, d) => acc + (d ?? 0), 0); + const dur = formatDuration(totalDurationSec > 0 ? totalDurationSec : (routeInfo?.durationSec ?? videoDuration)); if (!direction && !name && lengthKm == null && !dur) return null; diff --git a/client/src/components/overlay/StationOverlay.tsx b/client/src/components/overlay/StationOverlay.tsx index a8b1399..f28992d 100644 --- a/client/src/components/overlay/StationOverlay.tsx +++ b/client/src/components/overlay/StationOverlay.tsx @@ -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(null); + const numRef = useRef(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 (
{label} - { const v = parseFloat(e.target.value); onChange(v); prevRef.current = v; setText(fmt(v)); }} className="flex-1 h-1 accent-yellow-400 cursor-pointer" /> - setText(e.target.value)} onBlur={e => commit(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') commit((e.target as HTMLInputElement).value); if (e.key === 'Escape') setText(fmt(value)); }} @@ -284,6 +308,10 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible // 텍스트 사전 계산 Map const labelMapRef = useRef>(new Map()); const precomputeIdRef = useRef(0); // 진행 중 계산 취소용 + // 캐시 키(드론 프레임 번호, 오름차순) — RAF 가 최근접 키를 이진탐색으로 조회. + const labelKeysRef = useRef([]); + // 직전 사전계산의 frames 참조 — 바뀌면(세그먼트 전환) 옛 캐시를 복사하지 않고 새로 시작. + const lastPrecomputeFramesRef = useRef(null); // 중심선 + 나침반 렌더 캐시 (per-frame) const renderCacheRef = useRef(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] .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(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)) { + if (typeof v === 'boolean') ss.setGradeFilter(g, v); + } + } + console.log('[display] .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(labelMapRef.current.size ? labelMapRef.current : undefined); const frames = allDroneFramesRef.current; + // 재계산(같은 데이터, 슬라이더 변경 등)이면 옛 맵을 복사해 시작 → 빈 구간 없이 갱신. + // 단, frames 가 바뀐 경우(분할영상 세그먼트 전환)는 옛 캐시가 '다른 구간'의 값이므로 + // 복사하지 않고 비워서 시작 — 신·구 캐시가 섞여 라벨 집합이 프레임마다 뒤바뀌는 + // 깜빡임(두 번째 영상 재생 시)을 방지한다. + const framesChanged = lastPrecomputeFramesRef.current !== frames; + lastPrecomputeFramesRef.current = frames; + const newMap = new Map( + !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(); + 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; + 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)[k] = v; + } + } + } + setParams(next); }, []); return ( @@ -1732,15 +1975,16 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible {showPanel && (
+ {/* 버튼 순서 = 패널 위치와 일치: 카메라(왼쪽 패널) → 화면표시(오른쪽 패널) */}
- +
{/* 두 패널을 가로로 나란히(우→좌) 펼침 — 둘 다 열어도 세로로 안 쌓여 화면 위로 안 넘침. */} @@ -1748,7 +1992,16 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible {/* ── 화면표시 옵션 (카메라 외 모든 표시 설정) ── */} {showDisplay && (
-
표시 토글
+
+ 표시 토글 + {/* 화면표시 옵션 전체를 display.json 으로 PC 저장 — 폴더에 넣어두면 자동 적용 */} + +
{/* 나침반 타입 — 눈금(analog) / 지도(OSM, 노스업) 선택 */}
나침반 @@ -1862,7 +2115,7 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
@@ -1873,7 +2126,16 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible {/* ── 카메라 파라미터 (드론 카메라 관련만) ── */} {showControls && (
-
자세 보정 오프셋(SRT + offset)
+ {/* 헤더 + 카메라 파라미터 PC 저장 — 화면표시 옵션 패널과 동일 위치(우상단)에 배치 */} +
+ 자세 보정 오프셋(SRT + offset) + +
setParam('yawOffset', v)} /> {/* Yaw 자동 추정(2단계 보정의 1단계) — 대략 맞춘 뒤 'Yaw 보정' 드래그 모드로 마무리 */} @@ -1891,33 +2153,25 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible setParam('offX', v)} /> setParam('offY', v)} /> setParam('offZ', v)} /> - setParam('geoidOffset', v)} /> + setParam('geoidOffset', v)} />
정표고→타원체고 (대전≈25.8)
내부표정 (초점·주점·센서)
- {/* 영상(djmd) 자동 감지 정보 + 카메라값 서버 저장('<영상 base>.camera.json' — 재로드 시 자동 적용) */} + {/* 영상(djmd) 자동 감지 정보 — PC 저장은 패널 헤더 버튼 하나로 통일(중복 제거) */}
📷 {storeCameraInfo ? `${storeCameraInfo.model}${storeCameraInfo.focalLen35 ? ` · ${storeCameraInfo.focalLen35}mm` : ''}${storeCameraInfo.fps ? ` · ${storeCameraInfo.fps}fps` : ''}` : '카메라 미감지'} -
- {storeCameraInfo?.focalLen35 !== undefined && ( - - )} + {storeCameraInfo?.focalLen35 !== undefined && ( -
+ 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 에 적용`} + >적용 + )}
setParam('focalLen', v)} /> @@ -1940,7 +2194,8 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
)}
- diff --git a/client/src/components/player/VideoPlayer.tsx b/client/src/components/player/VideoPlayer.tsx index 6e2328a..f399086 100644 --- a/client/src/components/player/VideoPlayer.tsx +++ b/client/src/components/player/VideoPlayer.tsx @@ -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( 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 | undefined; + const v = ri?.dev_mode ?? (storeRouteMeta as unknown as Record | 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( '(측점·드론 정보는 정상 로드되었습니다.)', ); } + // 표고(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( {/* 노선 정보 배너 — 영상 좌상단 (route.json routeInfo) */} {/* 좌하단 그룹(재생바 위) — 한 줄: 영상제어 → 배속 → 프레임정보 → 겹침제외 → 시설등급 */} - {source && ( + {/* dev_mode=0(route.json) 이면 그룹 전체 숨김 — 뷰어 모드 */} + {source && devMode !== 0 && (
( videoWidth={videoWidth} videoHeight={videoHeight} onTogglePlay={handleTogglePlay} - showPanel={showVideoControls && !!source} + showPanel={devMode !== 0 && showVideoControls && !!source} topPx={paramTop} barHeight={barHeight} /> diff --git a/client/src/stationbar/StationBar.tsx b/client/src/stationbar/StationBar.tsx index ce6fc52..10a2470 100755 --- a/client/src/stationbar/StationBar.tsx +++ b/client/src/stationbar/StationBar.tsx @@ -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(); @@ -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]); diff --git a/client/src/store/geoStore.ts b/client/src/store/geoStore.ts index 26027d5..feb6a97 100644 --- a/client/src/store/geoStore.ts +++ b/client/src/store/geoStore.ts @@ -56,6 +56,10 @@ interface GeoStore { cameraInfo: DetectedCameraInfo | null; /** 폴더의 `<영상 base>.camera.json` 카메라 파라미터 — 감지값/저장값보다 최우선 적용. */ cameraJson: Record | null; + /** 폴더의 `<영상 base>.display.json` 화면표시 옵션 — 로드 시 자동 적용. */ + displayJson: Record | null; + /** 드론 고도 datum 보정(m) — 타원체고 데이터셋의 정표고→타원체고 변환량. 세그먼트 전환에도 적용. */ + altitudeOffsetM: number; /** * 폴더 선택 파일에서 지리정보를 파싱해 스토어에 적재한다. @@ -97,6 +101,8 @@ const EMPTY = { folderFiles: [] as File[], cameraInfo: null as DetectedCameraInfo | null, cameraJson: null as Record | null, + displayJson: null as Record | null, + altitudeOffsetM: 0, }; export const useGeoStore = create((set, get) => ({ @@ -124,6 +130,8 @@ export const useGeoStore = create((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((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}행`, ); diff --git a/client/src/types/geo.ts b/client/src/types/geo.ts index 9014fad..49b6718 100644 --- a/client/src/types/geo.ts +++ b/client/src/types/geo.ts @@ -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 | null; + /** 폴더의 `<영상 base>.display.json` 화면표시 옵션 (없으면 null). 자동 적용. */ + displayJson: Record | null; + /** 드론 고도 datum 보정(m) — 타원체고 기준 데이터셋에서 CSV 정표고→타원체고 변환량(지오이드고). + * 세그먼트 전환 재파싱 시에도 동일 적용. 정표고 데이터셋은 0. */ + altitudeOffsetM: number; } diff --git a/client/src/utils/cameraMeta.ts b/client/src/utils/cameraMeta.ts index dccfc5b..4607755 100644 --- a/client/src/utils/cameraMeta.ts +++ b/client/src/utils/cameraMeta.ts @@ -101,6 +101,11 @@ export async function detectCameraInfo(file: File): Promise 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; diff --git a/client/src/utils/demKml.ts b/client/src/utils/demKml.ts new file mode 100644 index 0000000..b5292b1 --- /dev/null +++ b/client/src/utils/demKml.ts @@ -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(); + for (const block of text.matchAll(/([\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 { + 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(); + 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(/([\s\S]*?)<\/coordinates>/g, (_m, body: string) => + `${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)}`; + })}`); + + // '높이' 속성(지장물 스키마)도 0/빈값이면 같은 DEM 표고로 채운다 — Placemark 단위로 + // 자기 좌표의 표고를 대응. 실제 값이 있는 높이는 보존. + next = next.replace(/]*>[\s\S]*?<\/Placemark>/g, (pm) => { + const cm = pm.match(/\s*([-\d.]+),([-\d.]+)/); + if (!cm) return pm; + const e = elev.get(`${cm[1]},${cm[2]}`); + if (e === undefined) return pm; + return pm.replace( + /()\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; +} diff --git a/client/src/utils/geoData.ts b/client/src/utils/geoData.ts index 9404ea0..f20922b 100644 --- a/client/src/utils/geoData.ts +++ b/client/src/utils/geoData.ts @@ -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; + } + // 좌표: 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 }; } // ── 카메라 파라미터 파일 (.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 | 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( } } +// ── 화면표시 옵션 파일 (.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 | 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) : 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] .display.json 발견 — 저장된 화면표시 옵션 적용 예정'); + } + if (cameraJson) { console.log('[camera] .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, }; } diff --git a/docs/DEMO.md b/docs/DEMO.md new file mode 100644 index 0000000..8ba1f21 --- /dev/null +++ b/docs/DEMO.md @@ -0,0 +1,39 @@ +# 노트북 시연 가이드 + +폴더 선택 기반 재생(드론 영상 + 측점/지장물 오버레이) 시연은 **Node.js만 있으면** 동작한다. +WSL/PM2/FFmpeg 불필요 (FFmpeg 은 HLS 변환·서버 프레임추출용 — 시연 흐름에서 안 씀). + +## 1. 사전 준비 (사무실, 인터넷 필요) + +1. 노트북에 **Node.js 20 LTS** 설치 — https://nodejs.org (Windows 인스톨러) +2. 소스 받기 (둘 중 하나) + - `git clone https://gitea.hmac.kr/b23042/GhiVideo_v4.git` + - 또는 현재 PC 의 저장소 폴더를 통째로 복사 (node_modules 제외) +3. 저장소 루트에서: + ```bat + npm install + npm run build + ``` +4. **시연 데이터 폴더 복사** — `경로 1-1` 폴더 전체(약 12GB)를 노트북 아무 곳에나: + - 영상 4개(DJI_*.MP4), 비행로그 CSV, 측점/지장물 KML, route.json + - **`*.camera.json` / `*.display.json`(환경설정 파일) 포함** — 캘리브레이션 자동 적용됨 + +## 2. 시연 실행 + +- 저장소 루트의 **`start-demo.bat` 더블클릭** → 브라우저가 http://localhost:54000 자동 오픈 +- 화면 가운데 **[폴더 선택]** → 복사해 둔 `경로 1-1` 폴더 선택 +- 자동으로: 첫 영상 재생 + 측점/지장물/궤적/선형 오버레이 + 카메라 보정 적용 + 이름순 연속재생 + +## 3. 주의사항 + +- **사전 리허설 필수**: 시연 전 노트북에서 위 흐름 1회 확인 +- **오프라인 시연**: 나침반 '지도(street/sat)' 스타일은 외부 타일 프록시라 인터넷 필요 → + 오프라인이면 나침반을 '눈금(analog)'으로 (display.json 기본이 analog 라 문제 없음) +- 브라우저는 Chrome/Edge 권장 (폴더 선택 webkitdirectory) +- 영상이 고사양(4K60, 150Mbps)이라 저사양 노트북에서는 1x 재생 위주로 시연 권장 +- 서버 종료: bat 창에서 Ctrl+C (또는 창 닫기) + +## 4. (선택) FFmpeg + +서버 업로드·HLS 변환·정밀 프레임 추출까지 시연하려면 FFmpeg 설치 후 PATH 등록: +https://www.gyan.dev/ffmpeg/builds/ → 미설치 시 해당 기능만 비활성(경고 로그), 나머지 정상. diff --git a/docs/history/2026-07-09_2053_전체-커밋.md b/docs/history/2026-07-09_2053_전체-커밋.md new file mode 100644 index 0000000..aa285ae --- /dev/null +++ b/docs/history/2026-07-09_2053_전체-커밋.md @@ -0,0 +1,19 @@ +# 2026-07-09 오늘 개발분 전체 커밋 + +**소요 시간**: 2분 +**Context 사용량**: input 880k / output 1k tokens + +## 작업 내용 + +오늘 세션 전체 개발분을 main 에 커밋 (84f4921, 32파일 +1834/−111): + +- 제주 DJI 비행로그 어댑터, 분할영상 연속재생 + 영상목록 콤보 +- KML 다중 병합(측점 71 + 지장물 신포맷 + 라벨 색상), 라벨 박스 스타일 +- 카메라 자동감지(djmd) + camera.json PC 저장/자동 적용, Yaw 자동추정·드래그 역산 +- 스테이션바 개선(이동거리 진행도, 노선밖 표기, 원거리 구조물 제외, 배지 한 줄) +- 서버 /api/camera 라우트, ecosystem(54000, 제주 경로), 히스토리 문서 21건 + +## 참고 + +- 원격(origin: gitea.hmac.kr/b23042/GhiVideo_v4)으로 push 는 하지 않음 — 요청 시 수행 +- 이 히스토리 파일은 커밋 이후 생성분 (다음 커밋에 포함) diff --git a/docs/history/2026-07-09_2056_push-인증차단.md b/docs/history/2026-07-09_2056_push-인증차단.md new file mode 100644 index 0000000..4a4a20f --- /dev/null +++ b/docs/history/2026-07-09_2056_push-인증차단.md @@ -0,0 +1,21 @@ +# 2026-07-09 push 시도 — 인증 차단 (사용자 조치 대기) + +**소요 시간**: 2분 +**Context 사용량**: input 900k / output 1k tokens + +## 상황 + +`git push origin main` 실패: `could not read Username for 'https://gitea.hmac.kr'` +— WSL 환경에 credential helper / SSH 키 / .netrc 없음 (비대화형이라 암호 입력 불가). + +## 사용자 안내 + +- 방법 A: 터미널에서 직접 `git push origin main` (대화형 인증) +- 방법 B: gitea 액세스 토큰(repository write) 발급 → 전달 시 push + credential store 설정 + +커밋 84f4921 은 로컬에 보존됨. + +## 결과 (20:58 확인) + +사용자가 직접 push 완료 — `main...origin/main` 동기화 확인(origin/main = 84f4921). +커밋 이후 생성된 히스토리 문서 2건은 untracked 상태로 다음 커밋에 포함 예정. diff --git a/docs/history/2026-07-09_2102_KML-데이터-로드범위-확인.md b/docs/history/2026-07-09_2102_KML-데이터-로드범위-확인.md new file mode 100644 index 0000000..d3518cf --- /dev/null +++ b/docs/history/2026-07-09_2102_KML-데이터-로드범위-확인.md @@ -0,0 +1,18 @@ +# 2026-07-09 두 KML 데이터 로드 범위 확인 (Q&A) + +**소요 시간**: 2분 +**Context 사용량**: input 920k / output 1k tokens + +## 질문 + +제주_중산간도로.kml 과 제주_중산간도로_지장물.kml 데이터를 모두 읽어오는지. + +## 답변 (코드 변경 없음) + +- 두 파일 모두 병합 파싱됨 (다중 KML 병합은 지장물 KML 대응 때 구현) +- 중산간도로.kml: STA 측점 71개 사용 ✅ / 선형중심선 LineString 은 미사용(고도 0 — + 측점 폴리라인으로 중심선 대체) / 5186_x/y 는 props 보관만 +- 지장물.kml: 33개 전부 사용 ✅ (라벨+색상+좌표), 교량 3건 구조물 중복 등록, + 타입상세·높이 등은 팝업 props +- 표시는 POI 표시범위(기본 1km) 이내만 — 뒷구간(3k~6k998) 측점이 재생 중 안 보이는 건 정상 +- 확인 방법: 콘솔 `[KMZ] POI 33 · 구조물 3 · 측점 71 로드(원본)` 로그 diff --git a/docs/history/2026-07-13_0959_KML-선형중심선-로딩.md b/docs/history/2026-07-13_0959_KML-선형중심선-로딩.md new file mode 100644 index 0000000..cbb6e64 --- /dev/null +++ b/docs/history/2026-07-13_0959_KML-선형중심선-로딩.md @@ -0,0 +1,36 @@ +# 2026-07-13 KML 선형중심선(LineString) 로딩 + +**소요 시간**: 8분 +**Context 사용량**: input 950k / output 4k tokens + +## 요청 + +제주_중산간도로.kml 로딩 — 측점(STA 71)·지장물은 이미 로딩 중이므로, +유일하게 미사용이던 **선형중심선(LineString)** 을 로딩해 사용. + +## 수정 내용 + +### client/src/utils/geoData.ts +- `parseKmz`: Placemark 의 LineString 지오메트리를 중심선 폴리라인으로 수집 + (연속 중복 정점 제거, 한국 범위 좌표만, 여러 개면 가장 긴 것). 반환에 `centerline` 추가 +- `loadFolderGeoData`: 중심선 소스 우선순위 — **KML 선형중심선(146정점, 실제 도로 곡선)** + → 없으면 기존 측점 폴리라인(100m 간격 직선 연결) + +### client/src/components/overlay/StationOverlay.tsx +- 선형(중심선) 오버레이 투영 시 **표고 미상(z≤0) 정점을 '경로 표고' 평면에 앵커** — + 기존엔 z=0(해수면) 그대로 투영되어 실제 도로와 크게 어긋났음(측점/POI 라벨과 동일 기준으로 통일) + +## 효과 + +- 선형 ON 시 곡선 구간에서 각지지 않는 실제 도로 선형 표시(146정점 vs 측점 71개 직선 연결) +- 측점 라벨 스냅(nearestCL)도 더 정밀한 선형 기준으로 동작 +- 콘솔: `[KMZ] POI 33 · 구조물 3 · 측점 71 · 중심선 146점 로드(원본)` + `[geo] 중심선: KML 선형중심선 146점 사용` + +## 검증 + +- tsc+vite 빌드 통과, 새 번들(index-Cc-unyK5.js) 서빙 +- 참고: 폴더에 사용자가 저장한 DJI_20260629121835_0002.camera.json 확인됨 — 폴더 선택 시 자동 적용됨 + +## 다음 단계 참고 + +- 스테이션바의 체이니지 투영(stationLine)은 여전히 측점 기반(km 값 필요) — 변경 없음 diff --git a/docs/history/2026-07-13_1010_화면표시옵션-PC저장-자동적용.md b/docs/history/2026-07-13_1010_화면표시옵션-PC저장-자동적용.md new file mode 100644 index 0000000..e0e4625 --- /dev/null +++ b/docs/history/2026-07-13_1010_화면표시옵션-PC저장-자동적용.md @@ -0,0 +1,30 @@ +# 2026-07-13 화면표시 옵션 PC 저장 + 폴더 자동 적용 + +**소요 시간**: 10분 +**Context 사용량**: input 990k / output 5k tokens + +## 작업 내용 + +camera.json 과 동일한 패턴으로 화면표시 옵션 저장/적용 왕복 구현. + +### 저장 — StationOverlay 화면표시 옵션 패널 헤더 [PC에 저장] 버튼 +`<영상 base>.display.json` 다운로드. 포함 항목: +- 슬라이더: dronePathZ(경로표고), dronePathAlpha(투명도), smoothHalf, emaAlpha, + smoothMinAlpha(떨림억제), smoothSpeedRef(반응속도), maxPoiRange(POI 범위), droneHeightDrop(이격거리) +- 토글: poiDroneHeight(드론높이 모드), showCenterline(선형), showDronePath(드론궤적), + showRoutePanel(좌측 패널), poiOverlapExclude(겹침제외) +- 기타: compassType/compassMapStyle(나침반), gradeFilter(시설등급 체크) + +### 로드 — 폴더 선택 시 자동 적용 +- `parseDisplayJson`(geoData): `<영상 base>.display.json` 인식(분할본 base 아무거나 매칭) +- geoStore `displayJson` 적재 → StationOverlay effect 가 타입 검증 후 각 상태/설정스토어에 반영 + (localStorage 복원 effect 뒤에 선언 — 파일 값이 우선) + +## 검증 + +- tsc+vite 빌드 통과, 새 번들(index-D6YhUPvg.js) 서빙 + +## 다음 단계 참고 + +- camera.json(카메라 내부표정)과 display.json(화면표시)은 별개 파일 — 둘 다 폴더에 두면 각각 적용 +- 저장 파일은 다운로드 폴더에 생성 → 영상 폴더로 이동 필요(브라우저 보안 제약) diff --git a/docs/history/2026-07-13_1016_측점투영수정-시점마크.md b/docs/history/2026-07-13_1016_측점투영수정-시점마크.md new file mode 100644 index 0000000..f396d35 --- /dev/null +++ b/docs/history/2026-07-13_1016_측점투영수정-시점마크.md @@ -0,0 +1,29 @@ +# 2026-07-13 측점 라벨 투영 수정 + 스테이션바 시점 마크 + +**소요 시간**: 10분 +**Context 사용량**: input 1030k / output 5k tokens + +## 문제 1 (스크린샷 제보) — 0k000·0k100 다음에 바로 0k800 이 보임 + +### 원인 +직전 작업(KML 선형중심선 로딩)의 부작용. 측점 라벨은 중심선의 **최근접 '정점'에 스냅**되는데 +(nearestCL), 기존 중심선(측점 71개 자신)에선 자기 위치였지만, LineString 중심선은 CAD 유래라 +**직선 구간 정점이 성김** → 직선 구간 측점들이 굽이 정점으로 뭉쳐 스냅됨. +실측 오차: 0k200=23m, 0k400=223m, **0k600=422m**. + +### 수정 — StationOverlay.tsx +- `projectCL` 신설: 중심선 **세그먼트 투영**(선 위의 정확한 최근접점, z 는 선형보간) +- 측점 라벨 스냅을 nearestCL(정점) → projectCL(투영) 로 교체. 투영 오차 0.02m 로 개선 +- POI 지면고도(gz) 조회는 기존 nearestCL(z 참조용) 유지 + +## 문제 2 (요청) — 스테이션바에 시점(0k000) 값·위치 표시 + +### 수정 — StationBar.tsx +- structureMarks 에 `appendStartMark` 추가: 첫 측점(최소 stationKm, 예: 0k000)의 + 통과 지점(pxPassesAtMileage)에 카테고리 '측점' 마크 push → Timeline 이 측점값 라벨 + ("0k000") 을 해당 위치에 표시. 구조물 경로/폴백 경로 양쪽 반영 +- 노선 진입 전 구간에선 통과가 없으므로 표시 안 됨(정상), 진입 시점 위치에 표시됨 + +## 검증 + +- 정점 스냅 vs 투영 오차 실측(위 수치), tsc+vite 빌드 통과, 새 번들(index-B6hy7ai4.js) 서빙 diff --git a/docs/history/2026-07-13_1021_시점마크-실통과위치-수정.md b/docs/history/2026-07-13_1021_시점마크-실통과위치-수정.md new file mode 100644 index 0000000..a8b1b1c --- /dev/null +++ b/docs/history/2026-07-13_1021_시점마크-실통과위치-수정.md @@ -0,0 +1,27 @@ +# 2026-07-13 시점(0k000) 마크 실제 통과 위치로 수정 + +**소요 시간**: 5분 +**Context 사용량**: input 1080k / output 3k tokens + +## 문제 (스크린샷 제보) + +시점(0k000) 마크가 스테이션바 맨 왼쪽(t=0)에 표시됨 — 실제 0k000 통과 위치(약 84초 지점)에 +표시되어야 함. + +## 원인 + +접근 구간(첫 87초)은 노선 밖이라 체이니지가 시작점 0 으로 클램프됨 → +`pxPassesAtMileage(0)` 이 접근 구간 전체를 "0k000 통과"로 오인하고, 최초 프레임(t=0)을 +최근접으로 선택해 바 맨 앞에 마크가 찍힘. + +## 수정 — client/src/stationbar/StationBar.tsx + +- `pxPassesAtMileage` 에서 **노선 밖 프레임(offM > OFF_ROUTE_M=60m) 을 통과 후보에서 제외** + (탐색 시작·연속 구간 판정 모두). 직전 작업에서 추가한 ViewedPoint.offM 활용 +- 부수 효과: 측점값 기반 다른 마크(구조물 이정 폴백 등)도 접근/이탈 구간 오인 방지 + +## 검증 + +- 실데이터 시뮬레이션: 수정 후 0k000 마크 = t=83.8s 지점 (기존 t=0s) + — 드론이 실제로 0k000 에 도달하는 시점과 일치 +- tsc+vite 빌드 통과, 새 번들(index-DmRp0Sle.js) 서빙 diff --git a/docs/history/2026-07-13_1024_폴더공용json-시점라벨.md b/docs/history/2026-07-13_1024_폴더공용json-시점라벨.md new file mode 100644 index 0000000..108926c --- /dev/null +++ b/docs/history/2026-07-13_1024_폴더공용json-시점라벨.md @@ -0,0 +1,25 @@ +# 2026-07-13 속성 JSON 폴더 공용 인식 + 시점 라벨 간소화 + +**소요 시간**: 6분 +**Context 사용량**: input 1130k / output 3k tokens + +## 요청 1 — 속성 json 을 같은 폴더의 모든 영상에 동일 적용 + +사용자가 json 을 영상 이름이 아닌 폴더 공용 이름("제주 중산간도로 경로 1-1.camera.json" / +".display.json")으로 관리 — 기존 로더는 영상 base 매칭만 인식해 무시됨. + +### 수정 — client/src/utils/geoData.ts +- `parseCameraJson`: ① `<영상 base>.camera.json`/`<영상 base>.json` 우선 → ② **임의 이름의 + `*.camera.json` 폴백**. rootJson 필터에서 .display.json 제외 추가 +- `parseDisplayJson`: ① `<영상 base>.display.json` 우선 → ② **임의 이름의 `*.display.json` 폴백** +- 두 파일 모두 폴더 공용 — 분할본 4개 전체에 동일 적용(원래 적용 자체가 폴더 단위) +- 실파일명 목록으로 매칭 검증 완료 + +## 요청 2 — 시점 마크 하단 텍스트 중복 제거 + +- 시점 마크의 측점값 라벨(위)이 이미 "0k000" 표시 → 제목(아래)을 "0k000 (시점)" 에서 + **"시점"** 으로 변경 (StationBar appendStartMark) + +## 검증 + +- tsc+vite 빌드 통과, 새 번들(index-D4SFb-vJ.js) 서빙 diff --git a/docs/history/2026-07-13_1028_화면표시옵션-저장기능-안내.md b/docs/history/2026-07-13_1028_화면표시옵션-저장기능-안내.md new file mode 100644 index 0000000..d99c880 --- /dev/null +++ b/docs/history/2026-07-13_1028_화면표시옵션-저장기능-안내.md @@ -0,0 +1,14 @@ +# 2026-07-13 화면표시 옵션 저장 기능 확인·안내 (Q&A) + +**소요 시간**: 2분 +**Context 사용량**: input 1180k / output 1k tokens + +## 내용 (코드 변경 없음) + +화면표시 옵션 저장 요청 — 이미 구현됨(2026-07-13_1010 작업): +- 저장: 화면표시 옵션 패널 헤더 [PC에 저장] → `*.display.json` +- 적용: 폴더 선택 시 자동 (임의 이름 `*.display.json` 인식은 1024 작업에서 확장) + +폴더 실파일 확인: "제주 중산간도로 경로 1-1.display.json" (사용자가 오늘 10:20 저장) 형식 정상 — +경로표고 58/선형 ON/궤적 ON/등급 1·2종 등. camera.json(Yaw −16.3, f 29.9)도 정상. +사용 방법 안내로 마무리. diff --git a/docs/history/2026-07-13_1037_사전계산-최적화-버튼교환.md b/docs/history/2026-07-13_1037_사전계산-최적화-버튼교환.md new file mode 100644 index 0000000..df2ccd5 --- /dev/null +++ b/docs/history/2026-07-13_1037_사전계산-최적화-버튼교환.md @@ -0,0 +1,30 @@ +# 2026-07-13 라벨 사전계산 최적화 + 패널 버튼 위치 교환 + +**소요 시간**: 12분 +**Context 사용량**: input 1230k / output 6k tokens + +## 문제 1 — 재생 느려짐·딜레이·POI 라벨 깜빡임 (제보) + +### 원인 +오늘 추가한 측점 세그먼트 투영(projectCL)이 라벨 사전계산의 **프레임 루프 안**에서 실행 — +측점 71 × 선분 145 × 프레임 1947 ≈ 2천만 반복. POI 지면고도(gz: nearestCL 146정점 × POI 33)도 +프레임마다 반복. 재생 중 requestIdleCallback 청크가 timeout(200ms)으로 강제 실행될 때마다 +메인 스레드를 수십 ms 점유 → 영상 버벅임·딜레이·라벨 위치 갱신 밀림(깜빡임처럼 보임). + +### 수정 — StationOverlay.tsx startLabelPrecompute +- 측점 투영(snappedSt)과 POI 지면고도(gzOf)는 **프레임 불변** → 사전계산 시작 시 1회만 계산, + 프레임 루프에서는 조회만. 프레임당 지오메트리 연산 ~15,000회 → 0회 + +## 문제 2 — 패널 토글 버튼 위치가 창과 반대 (요청) + +- 패널은 flex-row-reverse 라 화면표시=오른쪽, 카메라=왼쪽인데 버튼은 반대 순서였음 +- 버튼 순서를 [카메라 파라미터][화면표시 옵션] 으로 교환 — 버튼과 패널 위치 일치 + +## 문제 3 — 카메라 파라미터 저장 (요청) + +- 이미 존재(내부표정 📷 줄 [PC에 저장])하나 찾기 어려움 → **카메라 패널 헤더(자세 보정 오프셋 행) + 우측에도 [PC에 저장] 배치** — 화면표시 옵션 패널과 동일한 위치·스타일 + +## 검증 + +- tsc+vite 빌드 통과, 새 번들(index-CYjWswA1.js) 서빙 diff --git a/docs/history/2026-07-13_1045_이격거리-측점-동일적용.md b/docs/history/2026-07-13_1045_이격거리-측점-동일적용.md new file mode 100644 index 0000000..e974809 --- /dev/null +++ b/docs/history/2026-07-13_1045_이격거리-측점-동일적용.md @@ -0,0 +1,25 @@ +# 2026-07-13 이격거리 조절 시 측점 라벨 동일 적용 + +**소요 시간**: 5분 +**Context 사용량**: input 1290k / output 2k tokens + +## 문제 (요청) + +'드론 높이와 POI 이격거리' 조절이 POI 라벨에만 반영되고 측점(스테이션) 라벨은 고정 표고로 +투영되어 함께 움직이지 않음. + +## 수정 — client/src/components/overlay/StationOverlay.tsx + +- RAF 측점 라벨 드로우: 드론높이 모드(poiDroneHeight=true)면 POI 와 동일하게 + `드론고도 − 지오이드 − 이격거리` 평면에 투영. 지면고도 모드면 기존 저장 표고(stA.z) 유지 +- 사전계산의 측점 가시성 판정도 같은 높이 규칙으로 통일 (이격거리/모드 변경 시 + 재계산은 기존 deps 로 이미 트리거됨) + +## 동작 + +- 드론높이 모드 ON + 이격거리 슬라이더 조절 → POI·측점 라벨이 함께 위아래로 이동 +- 지면고도 모드(기본) → 기존대로 경로표고 앵커(측점)·gz(POI) 사용, 이격거리 무관 + +## 검증 + +- tsc+vite 빌드 통과, 새 번들(index-DOYzU1-r.js) 서빙 diff --git a/docs/history/2026-07-13_1052_슬라이더-휠-조절.md b/docs/history/2026-07-13_1052_슬라이더-휠-조절.md new file mode 100644 index 0000000..83e431d --- /dev/null +++ b/docs/history/2026-07-13_1052_슬라이더-휠-조절.md @@ -0,0 +1,22 @@ +# 2026-07-13 슬라이더 마우스 휠 조절 + +**소요 시간**: 4분 +**Context 사용량**: input 1340k / output 2k tokens + +## 요청 + +슬라이더 바 위에서 휠 위 = 증가, 휠 아래 = 감소 (0.1 단위). + +## 수정 — StationOverlay.tsx ParamRow + +- range 슬라이더에 휠 핸들러: 위 = +step, 아래 = −step (min/max 클램프, + 표시 자릿수 반올림으로 부동소수점 누적 오차 방지) +- 각 슬라이더의 step 단위 적용 — 카메라 파라미터(Yaw±/Pitch±/f 등)는 0.1, + 정수 슬라이더(경로표고/이격거리 등)는 1, 정밀 슬라이더(반응속도)는 0.001 +- 패널 스크롤과 충돌 방지: non-passive 네이티브 wheel 리스너로 preventDefault + (React onWheel 은 passive 라 스크롤 차단 불가) +- 모든 ParamRow(카메라 파라미터·화면표시 옵션·표고 z) 공통 적용 + +## 검증 + +- tsc+vite 빌드 통과, 새 번들(index-LzJdmqNA.js) 서빙 diff --git a/docs/history/2026-07-13_1102_측점-이격거리-제외.md b/docs/history/2026-07-13_1102_측점-이격거리-제외.md new file mode 100644 index 0000000..8883429 --- /dev/null +++ b/docs/history/2026-07-13_1102_측점-이격거리-제외.md @@ -0,0 +1,23 @@ +# 2026-07-13 측점을 드론높이/이격거리 적용에서 제외 + +**소요 시간**: 3분 +**Context 사용량**: input 1390k / output 1k tokens + +## 요청 + +측점(KML)은 '경로 표고' 슬라이더로 이미 높이가 제어되므로, '드론 높이와 POI 이격거리' +옵션 적용 대상에서 제외 (직전 1045 작업의 측점 적용을 되돌림). + +## 수정 — StationOverlay.tsx + +- RAF 측점 드로우: 드론높이 모드 분기 제거 → 항상 stA.z(경로표고 앵커) 사용 +- 사전계산 측점 가시성 판정: 동일하게 sz(경로표고 앵커)로 복원 + +## 결과 — 높이 제어 체계 + +- **측점 라벨·드론 궤적·선형**: '경로 표고' 슬라이더 +- **POI(지장물) 라벨**: 지면고도 모드 = 경로표고 앵커(gz) / 드론높이 모드 = 드론고도 − 이격거리 + +## 검증 + +- tsc+vite 빌드 통과, 새 번들(index-DsT-6KME.js) 서빙 diff --git a/docs/history/2026-07-13_1236_지오이드200-휠-값입력칸.md b/docs/history/2026-07-13_1236_지오이드200-휠-값입력칸.md new file mode 100644 index 0000000..e20a134 --- /dev/null +++ b/docs/history/2026-07-13_1236_지오이드200-휠-값입력칸.md @@ -0,0 +1,15 @@ +# 2026-07-13 지오이드 최대 200 + 휠 조절 값 입력칸 확장 + +**소요 시간**: 3분 +**Context 사용량**: input 1440k / output 1k tokens + +## 요청 2건 — StationOverlay.tsx + +1. **지오이드 슬라이더 최대값 50 → 200** (ParamRow max) +2. **휠 조절을 숫자 값 입력칸 위에서도 동작**: ParamRow 의 non-passive wheel 리스너를 + range 슬라이더 + number 입력칸 양쪽에 부착 (위=+step, 아래=−step, min/max 클램프, + 브라우저 기본 number 휠 동작/패널 스크롤 차단) + +## 검증 + +- tsc+vite 빌드 통과, 새 번들(index-DTzX7VJi.js) 서빙 diff --git a/docs/history/2026-07-13_1358_세그먼트전환-라벨깜빡임-구조수정.md b/docs/history/2026-07-13_1358_세그먼트전환-라벨깜빡임-구조수정.md new file mode 100644 index 0000000..60692a7 --- /dev/null +++ b/docs/history/2026-07-13_1358_세그먼트전환-라벨깜빡임-구조수정.md @@ -0,0 +1,36 @@ +# 2026-07-13 두 번째 영상 POI 깜빡임 — 구조적 원인 3건 제거 + +**소요 시간**: 15분 +**Context 사용량**: input 1500k / output 6k tokens + +## 문제 (제보) + +두 번째 영상(세그먼트 2) 재생 시 POI 라벨 깜빡임. 부하/불필요 동작 점검 요청. + +## 원인 3건 + 수정 — StationOverlay.tsx + +### 1. 세그먼트 전환 시 이전 세그먼트 캐시 혼재 (핵심) +- 라벨 사전계산이 "옛 맵 복사 후 덮어쓰기" 방식 — 세그먼트 전환 시 **이전 세그먼트의 캐시** + (같은 프레임 번호, 다른 위치)가 남은 채 청크가 도착할 때까지 신·구 항목이 섞여 + 라벨 집합이 프레임마다 뒤바뀜(깜빡임) +- 수정: frames 참조가 바뀌면(전환) 복사 없이 **빈 맵으로 시작** + labelMapRef 즉시 교체. + 같은 데이터의 재계산(슬라이더 변경)만 기존처럼 복사 + +### 2. 캐시 조회의 정확일치/스테일 폴백 교대 +- 조회가 `floor(estFrame) 정확일치 ?? currentFrameNumRef(4Hz 갱신 스테일 키)` — 60fps 중 + ~1/6 프레임은 정확일치, 나머지는 최대 250ms 뒤처진 폴백 키 → **서로 다른 캐시 항목을 + 교대로 그려** 이동 중 경계 POI 가 깜빡임 +- 수정: 캐시 키 배열(labelKeysRef, 오름차순)을 두고 **최근접 키 이진탐색**으로 조회 고정 + (채워지는 중엔 인접 ±3 키 폴백) + +### 3. 전환 시 사전계산 이중 실행 (불필요 부하) +- storeFrames 가 즉시 effect·디바운스 effect 양쪽 deps 에 있어 전환마다 풀 재계산 2회 +- 수정: 디바운스 effect 에서 storeFrames 제거(즉시 effect 전담) + +### 보조 +- 프레임 ingest 시 currentFrameIdx/NumRef 초기화 — 전환 직후 사전계산 시작 구간이 + 이전 세그먼트 끝 인덱스(스테일)로 잡히는 것 방지 + +## 검증 + +- tsc+vite 빌드 통과, 새 번들(index-1zg4kXAW.js) 서빙 diff --git a/docs/history/2026-07-13_1414_선형클리핑-배너-실측값.md b/docs/history/2026-07-13_1414_선형클리핑-배너-실측값.md new file mode 100644 index 0000000..60dba50 --- /dev/null +++ b/docs/history/2026-07-13_1414_선형클리핑-배너-실측값.md @@ -0,0 +1,23 @@ +# 2026-07-13 선형 측점 범위 클리핑 + 배너 연장/소요 실측값 + +**소요 시간**: 8분 +**Context 사용량**: input 1560k / output 4k tokens + +## 요청 1 — 측점 연장선(빨간 선형)을 표출된 측점 POI 까지만 + +- StationOverlay buildLines: 중심선 세그먼트를 **측점 라벨 표시 반경(maxPoiRange)과 동일하게 + 클리핑** — 양 끝점 모두 반경 밖이면 그리지 않음(한쪽이라도 안이면 그려 경계에서 자연스럽게 끊김) +- 라벨 없는 원거리 구간이 수평선까지 길게 그려지던 것 해소. 'POI 범위' 슬라이더로 함께 조절됨 + +## 요청 2 — 좌상단 배너 연장/소요를 데이터 실측값으로 + +- RouteInfoOverlay 우선순위 변경: + - **연장**: 측점 데이터(KML STA 전체 체이니지 min~max) 우선 → route.json 폴백 + (제주: 0~6998m → 7.0km. 기존 route.json 4.25 대신 실측) + - **소요**: 폴더 내 영상 전체 길이 합(geoStore.videoDurations, 분할본 4개 합 671.7s ≈ 11분 12초) + 우선 → route.json → 현재 영상 길이 +- route.json 에 값이 있어도 데이터 실측값이 우선 표시됨(데이터 없을 때만 route.json 사용) + +## 검증 + +- tsc+vite 빌드 통과, 새 번들(index-BQeIVPjw.js) 서빙 diff --git a/docs/history/2026-07-13_1419_선형-마지막측점-클립.md b/docs/history/2026-07-13_1419_선형-마지막측점-클립.md new file mode 100644 index 0000000..8985363 --- /dev/null +++ b/docs/history/2026-07-13_1419_선형-마지막측점-클립.md @@ -0,0 +1,20 @@ +# 2026-07-13 선형을 마지막 표출 측점까지로 클립 + +**소요 시간**: 4분 +**Context 사용량**: input 1620k / output 2k tokens + +## 문제 (스크린샷 제보) + +라벨은 0k800 까지인데 빨간 선형이 그 너머(표시 반경 1km 끝)까지 이어짐 — +직전 수정이 '반경' 기준이라 마지막 라벨~1km 사이의 라벨 없는 꼬리가 남았음. + +## 수정 — StationOverlay.tsx buildLines + +- 클립 기준을 반경(maxPoiRange) → **표시 반경 안에 들어온 측점 중 최원거리 + 20m** 로 변경 + (매 프레임 측점 71개 수평거리 스캔 — 비용 무시 가능) +- 측점이 하나도 반경 안에 없으면 기존 반경 클립 폴백 + +## 검증 + +- tsc+vite 빌드 통과, 새 번들(index-DshfapxL.js) 서빙 +- 기하 검증: 스크린샷 상황(드론 ~0k000 부근, 0k900 이 1km 밖) → clipR ≈ 820m → 0k800 직후 선 종료 diff --git a/docs/history/2026-07-13_1424_초기화-환경설정파일-복원.md b/docs/history/2026-07-13_1424_초기화-환경설정파일-복원.md new file mode 100644 index 0000000..90cc6b6 --- /dev/null +++ b/docs/history/2026-07-13_1424_초기화-환경설정파일-복원.md @@ -0,0 +1,26 @@ +# 2026-07-13 초기화 버튼 — 환경설정 파일 기준 복원 + +**소요 시간**: 4분 +**Context 사용량**: input 1680k / output 2k tokens + +## 요청 + +초기화 버튼이 앱 기본값이 아니라 환경설정 파일(폴더의 *.camera.json / *.display.json) +값으로 복원되도록. + +## 수정 — StationOverlay.tsx + +- **카메라 파라미터 [초기화]**: `resetCameraParams` 신설 — 폴더에 *.camera.json 있으면 + 기본값 위에 파일 값을 덮어 복원, 없으면 DEFAULT_CAMERA_PARAMS (파일에 없는 키는 기본값) +- **화면표시 옵션 [기본값]**: `resetDisplayDefaults` 를 동일 패턴으로 변경 — + *.display.json 있으면 파일 값(타입 검증), 없으면 DISPLAY_DEFAULTS +- 두 버튼 title 에 동작 설명 추가 + +## 동작 정리 + +- 슬라이더를 만지작거리다 [초기화]/[기본값] → 저장해 둔 환경설정 파일 상태로 복귀 +- 파일이 없는 폴더 → 기존처럼 앱 기본값 복귀 + +## 검증 + +- tsc+vite 빌드 통과, 새 번들(index-Cx5R9huP.js) 서빙 diff --git a/docs/history/2026-07-13_1446_커서배지-1m단위.md b/docs/history/2026-07-13_1446_커서배지-1m단위.md new file mode 100644 index 0000000..5a9dfbe --- /dev/null +++ b/docs/history/2026-07-13_1446_커서배지-1m단위.md @@ -0,0 +1,18 @@ +# 2026-07-13 커서 배지 측점값 1m 단위 갱신 + +**소요 시간**: 3분 +**Context 사용량**: input 1730k / output 1k tokens + +## 요청 + +노선밖 구간 배지는 1m 단위("노선밖 346m")인데 노선 진입 후 측점값은 10m 단위(0k110)로 +갱신 — 진입 후에도 1m 단위로. + +## 수정 — StationBar.tsx + +- `formatMileage1`(1m 단위, "0k113") 신설, 커서 배지(cursorText)에 적용 +- 구간 전환점·시설물 라벨 등 기존 10m/100m 양자화 표기는 유지(formatMileage10 그대로) + +## 검증 + +- tsc+vite 빌드 통과, 새 번들(index-82dmYVKO.js) 서빙 diff --git a/docs/history/2026-07-13_1448_선형-경계세그먼트-절단.md b/docs/history/2026-07-13_1448_선형-경계세그먼트-절단.md new file mode 100644 index 0000000..3c3dc95 --- /dev/null +++ b/docs/history/2026-07-13_1448_선형-경계세그먼트-절단.md @@ -0,0 +1,25 @@ +# 2026-07-13 선형 경계 세그먼트 절단 (측점 POI 이후 선 제거) + +**소요 시간**: 5분 +**Context 사용량**: input 1780k / output 3k tokens + +## 문제 (제보) + +직전 클립(마지막 표출 측점 +20m 반경) 후에도 마지막 측점 너머로 선형 꼬리가 남음. + +## 원인 + +클립 판정이 "세그먼트 한쪽 끝이라도 반경 안이면 통째로 그림" — KML LineString 은 +직선 구간 정점 간격이 수백 m 라, 경계에 걸친 세그먼트 하나가 마지막 측점 너머로 +길게 그려짐. + +## 수정 — StationOverlay.tsx buildLines + +- 경계를 가로지르는 세그먼트는 **클립 원과의 교점(t)을 이차방정식으로 구해 그 지점까지만** 그림 +- 양끝 다 밖 → 생략, 양끝 다 안 → 전체, 교차 → 교점 절단. 여유 20m → 10m 축소 +- 드론 기준 EN(m) 좌표로 교점 계산 후 위경도 보간 → 재투영 + +## 검증 + +- tsc+vite 빌드 통과, 새 번들(index-JQsYskUj.js) 서빙 +- 기하: 어느 방향이든 선 끝 = 마지막 표출 측점 +10m 이내 diff --git a/docs/history/2026-07-13_1452_노트북-시연-가이드.md b/docs/history/2026-07-13_1452_노트북-시연-가이드.md new file mode 100644 index 0000000..9edbfc0 --- /dev/null +++ b/docs/history/2026-07-13_1452_노트북-시연-가이드.md @@ -0,0 +1,26 @@ +# 2026-07-13 노트북 로컬 시연 준비 (Q&A + 스크립트) + +**소요 시간**: 5분 +**Context 사용량**: input 1830k / output 3k tokens + +## 질문 + +노트북에 로컬 설치해서 시연 가능한지. + +## 답변 — 가능. Node.js 20만 있으면 됨 + +- 시연 흐름(폴더 선택 재생 + 오버레이)은 전부 클라이언트 + 정적 서빙 — WSL/PM2 불필요 +- FFmpeg 미설치 시 경고만 뜨고 기동(app.ts 확인) — HLS/서버 프레임추출만 비활성 +- better-sqlite3 은 win-x64 Node20 프리빌트 바이너리로 npm install 시 자동 설치 + +## 산출물 + +- **start-demo.bat** (저장소 루트): PORT=54000 + storage 경로 env 설정 후 서버 실행, + 브라우저 자동 오픈. 빌드 산출물 없으면 안내 후 종료 +- **docs/DEMO.md**: 설치(노드/소스/빌드) → 데이터 복사(환경설정 json 포함) → 실행 → + 주의사항(오프라인 타일, 리허설, 4K60 사양) 가이드 + +## 참고 + +- 시연 데이터: 경로 1-1 폴더(12GB) 통째 복사 — camera.json/display.json 포함해야 + 캘리브레이션 자동 적용 diff --git a/docs/history/2026-07-13_1500_POI-z값-부재-확인.md b/docs/history/2026-07-13_1500_POI-z값-부재-확인.md new file mode 100644 index 0000000..eeae76d --- /dev/null +++ b/docs/history/2026-07-13_1500_POI-z값-부재-확인.md @@ -0,0 +1,22 @@ +# 2026-07-13 POI z 값 부재 확인 (Q&A) + +**소요 시간**: 3분 +**Context 사용량**: input 1880k / output 1k tokens + +## 질문 + +POI 데이터에 z 값이 없는지 확인 요청. + +## 답변 (코드 변경 없음) — 맞음 + +실파일 확인: +- 지장물.kml: Point 33개 고도 성분 전부 0, '높이' 속성 전부 0 (높이=구조물 높이 필드) +- 측점.kml: 좌표 217개(선형+측점) 고도 전부 0 + +앱 처리: z=0 → 표고 미상 → 지면고도 모드는 경로표고 앵커 / 드론높이 모드는 드론고도−이격거리 / +POI 편집 드래그·표고 슬라이더 보정값 최우선. + +## 참고 + +- 사용자가 KML 파일명을 지장물.kml / 측점.kml 로 일반화 (경로 1-2, 1-3, 2 폴더 신설 확인) + → 로더는 이름 무관(*.kml 전체 파싱)이라 그대로 동작 diff --git a/docs/history/2026-07-13_1506_KML-DEM표고-일괄기록.md b/docs/history/2026-07-13_1506_KML-DEM표고-일괄기록.md new file mode 100644 index 0000000..a527c3f --- /dev/null +++ b/docs/history/2026-07-13_1506_KML-DEM표고-일괄기록.md @@ -0,0 +1,36 @@ +# 2026-07-13 KML 표고 DEM 일괄 기록 + 파서 z 사용 + +**소요 시간**: 12분 +**Context 사용량**: input 1940k / output 6k tokens + +## 요청 + +높이값 없는 KML(지장물/측점)에 DEM 연동으로 표고를 채워 파일 업데이트. + +## 수행 1 — 데이터 일괄 업데이트 (스크립트, SRTM 30m) + +- open-topodata SRTM 30m(서버 /api/elevation 과 동일 소스) 배치 조회(100좌표/청크, 1.2s 간격) +- **경로 1-1 / 1-2 / 1-3 / 2 의 지장물.kml·측점.kml 8개 파일** 모든 고도 성분 기록 + - 고유 좌표 237개 조회 → 지장물 33/33, 측점·선형 217/217 기록 + - 표고 범위: 도로(측점/선형) 53~89m, 지장물 52~118m — 기존 경로표고 추정(58m)과 부합 +- 원본은 `<파일>.kml.bak` 백업 (로더는 *.kml 만 읽어 무영향) +- ※ 현재 4개 폴더 KML 내용이 동일(경로 1-1 복제본) — 경로별 실데이터로 교체돼도 + 좌표 고도가 0이면 스크립트 재실행으로 다시 채울 수 있음(스크래치패드 dem-update-kml.mjs) + +## 수행 2 — 파서가 좌표 고도(z) 사용 (geoData.ts) + +- handlePlacemark: coordinates 3번째 성분(>0)을 coordAlt 로 캡처 +- 지장물 POI z ← coordAlt (지형 기복 따라 정확 배치), STA 측점 z ← coordAlt +- 선형(LineString)은 기존부터 z 사용 → DEM 표고로 지형 따라 그려짐 +- 측점 로드 로그에 DEM 표고 보유 수 표기 + +## 효과 + +- 지면고도 모드(기본)에서 측점·선형·POI 가 실제 지면 표고 사용 — 경로표고 슬라이더 + 수동 보정 불필요(궤적 점 표시만 슬라이더 사용). 시차 정합 향상 +- 파일명 변경(지장물.kml/측점.kml)과 무관하게 동작 (로더는 전체 *.kml 병합) + +## 검증 + +- 업데이트 파일 재파싱 검증(33/33, 217/217, 범위 정상), tsc+vite 빌드 통과, + 새 번들(index-DJYuQ_W_.js) 서빙 diff --git a/docs/history/2026-07-13_1513_byDem-kml-자동생성.md b/docs/history/2026-07-13_1513_byDem-kml-자동생성.md new file mode 100644 index 0000000..d175aa4 --- /dev/null +++ b/docs/history/2026-07-13_1513_byDem-kml-자동생성.md @@ -0,0 +1,42 @@ +# 2026-07-13 표고 없는 KML → _byDem.kml 자동 생성 기능 + +**소요 시간**: 12분 +**Context 사용량**: input 2000k / output 6k tokens + +## 요청 + +폴더 로드 시 측점/지장물 KML 의 좌표 z(높이)가 없거나 0 이면, DEM 연동으로 채운 +`지장물_byDem.kml` / `측점_byDem.kml` 을 생성·저장하는 기능. (원본 비파괴) + +## 데이터 정리 (선행) + +직전 턴에서 원본에 직접 기록했던 것을 새 규칙에 맞게 재정리(4개 폴더): +- 현재(DEM 기록) 파일 → `<이름>_byDem.kml` 로 이동, `.bak` → 원본 복원 +- 결과: 원본(z=0) + _byDem(z=DEM) 공존 + +## 구현 + +### client/src/utils/demKml.ts (신규) +- `generateByDemKmls(files)`: 폴더의 각 KML 에서 고도 없는(≤0) 좌표만 수집 → + 서버 `/api/elevation`(SRTM 30m 프록시) 100좌표/청크·1.2s 간격 조회 → + 고도 성분만 치환한 `<이름>_byDem.kml` 을 Blob 다운로드. 기존 표고(>0)는 보존 +- 건너뜀: 자신이 _byDem / 폴더에 _byDem 존재 / 전 좌표 표고 보유 + +### client/src/utils/geoData.ts — parseKmz +- `<이름>_byDem.kml` 존재 시 원본 `<이름>.kml` 파싱 제외 (중복 지물 방지, 표고 버전 우선) + +### client/src/components/player/VideoPlayer.tsx +- 폴더 선택 시 generateByDemKmls 자동 실행 — 생성되면 alert 로 안내 + ("다운로드 파일을 영상 폴더에 넣으면 다음 로드부터 자동 사용"). 오프라인 실패는 콘솔 경고만 + +## 검증 + +- 파일 선택 규칙 시뮬레이션: [지장물, 지장물_byDem, 측점, 측점_byDem, 기타] → + 파싱 대상 [지장물_byDem, 측점_byDem, 기타] ✓ +- 서버 DEM 프록시 응답 확인: {"elevation":[70],"source":"opentopodata-srtm30m"} ✓ +- tsc+vite 빌드 통과, 새 번들(index-BY1J77b5.js) 서빙 + +## 다음 단계 참고 + +- 브라우저 다중 다운로드 허용 팝업이 뜰 수 있음(측점+지장물 2파일) — 허용 1회 +- DEM 조회는 인터넷 필요(서버가 외부 API 프록시) — 오프라인 시연 중엔 생성 건너뜀(무해) diff --git a/docs/history/2026-07-13_1551_byDem-높이속성-포함.md b/docs/history/2026-07-13_1551_byDem-높이속성-포함.md new file mode 100644 index 0000000..f890241 --- /dev/null +++ b/docs/history/2026-07-13_1551_byDem-높이속성-포함.md @@ -0,0 +1,25 @@ +# 2026-07-13 _byDem 생성 시 '높이' 속성도 DEM 표고로 채움 + +**소요 시간**: 8분 +**Context 사용량**: input 2080k / output 4k tokens + +## 요청 + +`0` 에도 좌표와 동일한 DEM 표고를 기록. + +## 수정 + +### client/src/utils/demKml.ts (생성 기능) +- 좌표 고도 치환 후 **Placemark 단위 2차 패스**: 해당 Placemark 좌표의 DEM 표고를 + `높이` 속성(0/빈값인 경우만)에 기록. 실값이 있는 높이는 보존 + +### 기존 데이터 패치 (스크립트) +- 경로 1-2 / 1-3 / 2 의 지장물_byDem.kml — 좌표 고도값으로 높이 33개씩 기록 + (검증: 52~118m, 0 잔여 없음, Placemark 33/33 무결) +- 측점_byDem 은 스키마에 높이 필드 없음 → 해당 없음 +- **경로 1-1 은 사용자가 _byDem 삭제** → 새 자동 생성 기능 테스트 상태로 유지 + (폴더 선택 시 높이 포함 버전이 자동 생성됨) + +## 검증 + +- tsc+vite 빌드 통과, 새 번들(index-DPUNE-4y.js) 서빙 diff --git a/docs/history/2026-07-13_1657_설정파일-단순이름-지원.md b/docs/history/2026-07-13_1657_설정파일-단순이름-지원.md new file mode 100644 index 0000000..3d2ba16 --- /dev/null +++ b/docs/history/2026-07-13_1657_설정파일-단순이름-지원.md @@ -0,0 +1,37 @@ +# 2026-07-13 설정 파일 단순 이름(camera/display/route.json) 지원 + +**소요 시간**: 8분 +**Context 사용량**: input 2160k / output 4k tokens + +## 배경 + +사용자가 설정 파일명을 `route.json` / `camera.json` / `display.json` 으로 단순화. +route.json 은 기존 인식, **camera.json·display.json 순수 이름은 접미사 규칙만 있어 미인식** 상태였음. +새 폴더 `경로 1-1_타원체고기준데이터`(타원체고 z 의 측점.kml) + route.json 에 +`ellipsoidal_height: 100.823` 필드 추가됨. + +## 수정 + +### client/src/utils/geoData.ts +- parseCameraJson 우선순위: ① `camera.json` → ② `<영상 base>(.camera).json` → ③ `*.camera.json` + (rootJson 필터에서 route.json/display.json 단순 이름도 제외 처리) +- parseDisplayJson 우선순위: ① `display.json` → ② `<영상 base>.display.json` → ③ `*.display.json` + +### client/src/components/overlay/StationOverlay.tsx +- [PC에 저장] 다운로드 파일명: `<영상 base>.camera.json`/`<...>.display.json` → **camera.json / display.json** +- 버튼 툴팁 표기 갱신 + +### client/src/types/geo.ts +- RouteInfo 에 `ellipsoidal_height?: number` 추가 (파일 통과용 — 현재 투영 로직엔 미사용) + +## 검증 + +- 인식 규칙 시뮬레이션: camera.json / display.json / route.json 모두 인식 ✓ +- tsc+vite 빌드 통과, 새 번들(index-D6PNHGXU.js) 서빙 + +## 다음 단계 참고 + +- **ellipsoidal_height(100.823) 활용은 미구현** — 타원체고 기준 측점 z 와 드론 정표고 간 + datum 변환에 쓸지, 경로표고 기본값으로 쓸지 등 의미 확정 필요 (현재 사용자는 + camera.json 의 geoidOffset(20)/offZ(21.8) 수동 보정으로 대응 중) +- 타원체고기준 폴더의 측점.kml 은 z 보유(77~114m) → _byDem 생성 대상 아님(로더가 z 그대로 사용) diff --git a/docs/history/2026-07-13_1712_경로1-3-폴더검증.md b/docs/history/2026-07-13_1712_경로1-3-폴더검증.md new file mode 100644 index 0000000..503179b --- /dev/null +++ b/docs/history/2026-07-13_1712_경로1-3-폴더검증.md @@ -0,0 +1,20 @@ +# 2026-07-13 경로 1-3 폴더 형상 사전 검증 (Q&A) + +**소요 시간**: 6분 +**Context 사용량**: input 2230k / output 3k tokens + +## 질문 + +새 형상 폴더(영상2 + 경로별 이름 camera/display/route.json + 지장물/측점.kml) 로딩 가능 여부. + +## 검증 (코드 변경 없음) — 로딩 가능 + +- 영상 _0001/_0002 총 281.6s vs 로그 폭 282.4s → 정렬 성립 +- JSON 3종 유효·인식(임의 접두 *.camera/display/route.json 규칙), KML z 보유 → byDem 불필요 + +## 발견 — z 기준 혼재 (주의) + +- 측점(Point) 53~88m = 정표고(DEM) / 선형(LineString) 78~115m·지장물 77~144m = 타원체고 +- 드론 고도(CSV)는 정표고 → 이대로면 선형·지장물이 측점 대비 ~25m 떠 보임 +- 권장: 정표고로 통일. 대안 제시: ① 타원체고 파일을 정표고로 변환해 제공, + ② route.json ellipsoidal_height 로 앱 자동 변환 구현 — 사용자 선택 대기 diff --git a/docs/history/2026-07-13_1725_카메라-저장버튼-중복제거.md b/docs/history/2026-07-13_1725_카메라-저장버튼-중복제거.md new file mode 100644 index 0000000..9e4c86a --- /dev/null +++ b/docs/history/2026-07-13_1725_카메라-저장버튼-중복제거.md @@ -0,0 +1,17 @@ +# 2026-07-13 카메라 패널 PC 저장 버튼 중복 제거 + +**소요 시간**: 3분 +**Context 사용량**: input 2280k / output 1k tokens + +## 문제 (스크린샷 제보) + +카메라 파라미터 패널에 [PC에 저장] 버튼이 2개 (헤더 + 📷 내부표정 줄) — 동일 기능 중복. + +## 수정 — StationOverlay.tsx + +- 📷 줄(중간)의 저장 버튼 제거 — [적용](감지 초점거리)만 유지 +- 저장은 패널 헤더 버튼 하나로 통일 (화면표시 옵션 패널과 동일 위치) + +## 검증 + +- 빌드 통과, 패널당 저장 버튼 1개 확인, 새 번들(index-tnrOfrVu.js) 서빙 diff --git a/docs/history/2026-07-13_1759_54000-외부접속-개방.md b/docs/history/2026-07-13_1759_54000-외부접속-개방.md new file mode 100644 index 0000000..326db64 --- /dev/null +++ b/docs/history/2026-07-13_1759_54000-외부접속-개방.md @@ -0,0 +1,18 @@ +# 2026-07-13 54000 포트 외부 접속 개방 완료 + +**소요 시간**: 6분 +**Context 사용량**: input 2330k / output 2k tokens + +## 작업 내용 (사용자 UAC 승인 2회로 완료) + +- **portproxy**: `0.0.0.0:54000 → 127.0.0.1:54000` 추가 (WSL localhost forwarding 경유 — + 재부팅으로 WSL IP 가 바뀌어도 규칙이 깨지지 않는 방식. 기존 55000 은 IP 하드코딩 방식) +- **방화벽**: 인바운드 TCP 54000 허용 규칙 `WSL_ghiVideo_54000` 생성 + (첫 시도는 공백 포함 이름의 중첩 따옴표 문제로 실패 → 언더스코어 이름으로 재시도 성공) +- 검증: portproxy 규칙 등록, 방화벽 Enabled/Allow, WSL 서비스 200 응답 + +## 접속 방법 + +- 같은 네트워크의 다른 PC 에서 `http://172.16.40.179:54000` (Windows 유선 IP) +- 재부팅 후에도 규칙은 유지되나 **WSL 내 서비스(pm2)는 수동 시작 필요** + (2026-07-09_2056 참고 — pm2 resurrect 자동화 미적용 상태) diff --git a/docs/history/2026-07-13_1842_이격거리-측점-재적용.md b/docs/history/2026-07-13_1842_이격거리-측점-재적용.md new file mode 100644 index 0000000..e47e652 --- /dev/null +++ b/docs/history/2026-07-13_1842_이격거리-측점-재적용.md @@ -0,0 +1,19 @@ +# 2026-07-13 이격거리 측점 적용 복원 + +**소요 시간**: 3분 +**Context 사용량**: input 2380k / output 1k tokens + +## 요청 + +이격거리(드론높이 모드) 적용 시 측점 라벨도 함께 적용. +(1045 에 구현 → 1102 에 사용자 요청으로 제외했던 것을 재적용 — 현재 사용자 워크플로가 +드론높이 모드(display.json poiDroneHeight=true, drop 40) 기반으로 바뀜) + +## 수정 — StationOverlay.tsx + +- RAF 측점 드로우 + 사전계산 가시성: 드론높이 모드면 `드론고도 − 지오이드 − 이격거리` 평면, + 지면고도 모드면 측점 표고(stA.z — DEM/타원체고 실값 또는 경로표고 앵커) + +## 검증 + +- 빌드 통과, 새 번들(index-CXVVMHe3.js) 서빙 diff --git a/docs/history/2026-07-13_1848_지오이드-설정-설명.md b/docs/history/2026-07-13_1848_지오이드-설정-설명.md new file mode 100644 index 0000000..3e35d80 --- /dev/null +++ b/docs/history/2026-07-13_1848_지오이드-설정-설명.md @@ -0,0 +1,14 @@ +# 2026-07-13 지오이드 설정 설명 (Q&A) + +**소요 시간**: 2분 +**Context 사용량**: input 2420k / output 1k tokens + +## 내용 (코드 변경 없음) + +지오이드(geoidOffset) = 타원체고(GPS)와 정표고(해발) 간 차이 보정값. 드론높이 모드에서만 +사용: 라벨 높이 = 드론고도 − 지오이드 − 이격거리 (두 항은 사실상 합산). + +- 기본 25.8 = 대전(회덕) 기준 잔재. 제주 지오이드고 ~21-25m +- 제주 CSV 드론고도는 정표고 확인(타원체고 174.4 − 26 = 148.4) → DEM 지물과 동일 기준이라 + 원칙상 0 권장 (현재 사용자의 지오이드20+이격40 = 0+60 과 결과 동일) +- 지물 z 가 타원체고인 데이터셋에서는 지역 지오이드고를 넣어 기준 변환 diff --git a/docs/history/2026-07-13_1852_지오이드-이격거리-차이.md b/docs/history/2026-07-13_1852_지오이드-이격거리-차이.md new file mode 100644 index 0000000..9cd86ec --- /dev/null +++ b/docs/history/2026-07-13_1852_지오이드-이격거리-차이.md @@ -0,0 +1,12 @@ +# 2026-07-13 지오이드 vs 이격거리 차이 설명 (Q&A) + +**소요 시간**: 2분 +**Context 사용량**: input 2450k / output 1k tokens + +## 내용 (코드 변경 없음) + +- 현재 수식(라벨높이 = 드론고도 − (지오이드 + 이격거리))에서는 둘이 합산되어 동일하게 동작 +- 역할 구분: 지오이드 = 높이 기준 변환값(camera.json, 지역 물리량) / + 이격거리 = 표시 옵션(display.json, 눈대중 조절) +- 권장: 현 데이터(양쪽 정표고)에선 지오이드 0 고정 + 이격거리 단일 조절 +- 제안(미실행): 지오이드 UI 고급 옵션화 — 사용자 요청 시 diff --git a/docs/history/2026-07-13_1858_시작타원체고-활용성-답변.md b/docs/history/2026-07-13_1858_시작타원체고-활용성-답변.md new file mode 100644 index 0000000..2efeaff --- /dev/null +++ b/docs/history/2026-07-13_1858_시작타원체고-활용성-답변.md @@ -0,0 +1,17 @@ +# 2026-07-13 드론 시작 타원체고 활용성 (Q&A) + +**소요 시간**: 3분 +**Context 사용량**: input 2480k / output 1k tokens + +## 질문 + +드론 시작 타원체고(route.json ellipsoidal_height=100.823)가 화면·지장물 맵핑에 도움이 되는지. + +## 답변 (코드 변경 없음) + +- 직접 개선 아님 — 수직 기준은 이미 정합(드론 CSV 정표고 148.4 ↔ DEM 지물 정표고). + 남은 오차의 주원인은 수평 요소(Yaw/초점/GPS) +- 유용한 경우: ① 타원체고 기준 지물 데이터의 지오이드 변환 상수 산출(핵심), + ② 상대고도 로그 데이터셋의 절대고도 복원 기준, ③ 교차검증 +- 제안(미구현): 타원체고 지물 폴더에서 지오이드고 = djmd 타원체고 − CSV 정표고(26.0m) 자동 + 산출·변환, ellipsoidal_height 는 검증값 — 사용자 선택 대기 diff --git a/docs/history/2026-07-13_1904_맵핑-기준값-권장안.md b/docs/history/2026-07-13_1904_맵핑-기준값-권장안.md new file mode 100644 index 0000000..79b82d2 --- /dev/null +++ b/docs/history/2026-07-13_1904_맵핑-기준값-권장안.md @@ -0,0 +1,20 @@ +# 2026-07-13 측점·지장물 맵핑 기준값 권장안 (Q&A) + +**소요 시간**: 3분 +**Context 사용량**: input 2520k / output 2k tokens + +## 질문 + +측점·지장물의 화면 맵핑 기준을 무엇으로 삼을지. + +## 답변 (코드 변경 없음) — 실측값 고정 + Yaw 단일 튜닝 + +- 높이: 정표고 통일(드론 CSV + DEM 지물 z), 지오이드 0/offZ 0, **지면고도 모드**(지형 추종) +- f=29.9(감지값)·센서 36×20.25(물리값) 고정 — sensorH 16.95 튜닝값은 재검토 권장 +- 튜닝은 Yaw± 만(자동추정→드래그 역산), 잔차에만 Pitch±/cy₀ +- 보정 순서 5단계 안내, 저장(camera/display.json)으로 재현 + +## 남는 한계 및 제안 + +- GPS ±1~3m, 10Hz 보간, 로그-영상 시간 동기 ±0.5s(이동 중 ~5m) +- 제안(미구현): **시간 오프셋(ms) 보정 슬라이더** — 남은 오차원 중 효과 최대. 사용자 선택 대기 diff --git a/docs/history/2026-07-13_1912_타원체고-기준-자동변환.md b/docs/history/2026-07-13_1912_타원체고-기준-자동변환.md new file mode 100644 index 0000000..2c3869f --- /dev/null +++ b/docs/history/2026-07-13_1912_타원체고-기준-자동변환.md @@ -0,0 +1,36 @@ +# 2026-07-13 타원체고 기준 자동 변환 (datum 통일) + +**소요 시간**: 12분 +**Context 사용량**: input 2570k / output 6k tokens + +## 요청 + +지물(측점/지장물) 데이터가 타원체고 기준일 때, 그 기준으로 맵핑을 맞출 수 있는지 → 구현. + +## 원리 + +- 드론 **타원체고**가 영상 메타(djmd 필드 6.6, mm)에 프레임별 기록됨: 174.42m +- CSV 고도는 **정표고**: 148.36m → 차이 26.06m = **그 지점의 지오이드고** (자동 산출 가능) +- 타원체고 데이터셋이면 드론 고도에 이 값을 더해 지물 z(타원체고)와 기준 통일 + → 지오이드/offZ 수동 보정 불필요 + +## 구현 + +- **cameraMeta.ts**: djmd 6.6 → `DetectedCameraInfo.ellipsoidalAltM` 추출 (0~10km 범위 검증) +- **geoData.ts (loadFolderGeoData)**: route.json `routeInfo.ellipsoidal_height` 가 선언된 폴더 = + 타원체고 기준 데이터셋 신호 → `altitudeOffsetM = djmd 타원체고 − CSV 정표고` (|값|<60m 검증) + 를 모든 드론 프레임 altitude 에 가산. FolderGeoData/geoStore 에 오프셋 보존 +- **geoStore.loadSegment**: 세그먼트 전환 재파싱 프레임에도 동일 오프셋 적용 +- 정표고(DEM) 데이터셋(ellipsoidal_height 없음)은 오프셋 0 — 기존 동작 무변화 + +## 검증 + +- 실파일: djmd 174.42 − CSV 148.36 = 26.06m 자동 산출 ✓ (제주 지오이드고와 부합) +- 경로 1-3 route.json 에 ellipsoidal_height(100.823) 존재 확인 → 해당 폴더 자동 발동 +- tsc+vite 빌드 통과, 새 번들(index-D4bVLsb9.js) 서빙 + +## 사용 안내 + +- 타원체고 기준 폴더: route.json 에 `ellipsoidal_height` 만 있으면 자동 변환 — + 지오이드 0/offZ 0 으로 두고 지면고도 모드 사용 권장 +- 콘솔 로그: `[geo] 타원체고 기준 데이터셋 — 드론 고도를 타원체고로 변환 (+26.06m …)` diff --git a/docs/history/2026-07-13_1920_측점-datum-불일치-발견.md b/docs/history/2026-07-13_1920_측점-datum-불일치-발견.md new file mode 100644 index 0000000..a90c363 --- /dev/null +++ b/docs/history/2026-07-13_1920_측점-datum-불일치-발견.md @@ -0,0 +1,24 @@ +# 2026-07-13 측점.kml datum 불일치 발견 (사용자 승인 대기) + +**소요 시간**: 6분 +**Context 사용량**: input 2620k / output 3k tokens + +## 사용자 확인 + +측점·지장물 KML 이 타원체고 기준이라고 확인 → 실파일 검증 수행. + +## 검증 결과 (경로 1-1·1-3 동일) + +- 지장물(77~144m)·선형(78~115m) = 타원체고 ✓ +- **측점 Point(53~88m) = 정표고(DEM)** — 불일치. 이대로면 타원체고 자동 변환 후 + 측점 라벨만 ~25m 낮게 표시됨 + +## 준비된 교정 (미실행 — 원본 덮어쓰기 보호로 차단) + +- 측점 Point z ← 같은 파일의 타원체고 선형에 세그먼트 투영·z 보간 (지오이드 가정 불필요) +- 원본은 측점.kml.bak 백업 +- 실행 조건: 사용자가 "측점.kml 덮어써서 교정" 명시 승인 시 + +## 대안 + +- 사용자가 GIS 도구에서 측점 z 를 타원체고로 재출력해 파일 교체 diff --git a/docs/history/2026-07-13_1927_측점-타원체고-교정-실행.md b/docs/history/2026-07-13_1927_측점-타원체고-교정-실행.md new file mode 100644 index 0000000..7f30ddc --- /dev/null +++ b/docs/history/2026-07-13_1927_측점-타원체고-교정-실행.md @@ -0,0 +1,17 @@ +# 2026-07-13 측점 Point z 타원체고 교정 실행 (사용자 승인) + +**소요 시간**: 4분 +**Context 사용량**: input 2720k / output 2k tokens + +## 실행 내용 (사용자 "응 해줘" 승인) + +- 경로 1-1·1-3 의 측점.kml: 측점 Point 71개 z 를 **같은 파일의 실측 타원체고 선형에 + 세그먼트 투영·보간한 값**으로 교정 (원본 → 측점.kml.bak 백업) +- 보정량 +17.5~+39.7m (지오이드 ~25m + SRTM 국소 오차 제거분) +- 교정 후 측점 z 77.9~113.5m — 선형과 최대 편차 **0.05m** (교정 전 ~25m) + +## 결과 — 데이터 기준 통일 완료 + +- 지장물(타원체고) + 선형(타원체고) + 측점(타원체고) + 드론 고도(자동 변환 +26.06m) + → 전부 타원체고 기준. 지오이드 0/offZ 0 + 지면고도 모드에서 정합 +- SRTM 근사가 실측 선형 기반으로 대체되어 수직 정확도도 개선 diff --git a/docs/history/2026-07-13_1935_지오이드0-타원체고-여부.md b/docs/history/2026-07-13_1935_지오이드0-타원체고-여부.md new file mode 100644 index 0000000..6fcbf61 --- /dev/null +++ b/docs/history/2026-07-13_1935_지오이드0-타원체고-여부.md @@ -0,0 +1,11 @@ +# 2026-07-13 지오이드 0 = 타원체고? (Q&A) + +**소요 시간**: 2분 +**Context 사용량**: input 2680k / output 1k tokens + +## 답변 (코드 변경 없음) — 아니요 + +- 지오이드 슬라이더는 datum 스위치가 아님 — 드론높이 모드의 라벨평면 뺄셈 항일 뿐 +- 기준 결정 요소: 드론 고도 값(정표고 / ellipsoidal_height 선언 시 자동 +26.06 타원체고)과 지물 z +- 타원체고 기준 스위치 = route.json `ellipsoidal_height` (자동 변환), 그때 지오이드는 0 유지(중복 방지) +- 지면고도 모드는 지오이드 미사용 → 측점 Point(정표고 잔재) 교정 필요성 재안내 diff --git a/docs/history/2026-07-13_1940_이격거리-권장값.md b/docs/history/2026-07-13_1940_이격거리-권장값.md new file mode 100644 index 0000000..4821bec --- /dev/null +++ b/docs/history/2026-07-13_1940_이격거리-권장값.md @@ -0,0 +1,11 @@ +# 2026-07-13 이격거리 권장값 (Q&A) + +**소요 시간**: 2분 +**Context 사용량**: input 2760k / output 1k tokens + +## 답변 (코드 변경 없음) + +- 1순위: **지면고도 모드에서는 이격거리 미사용** — 실측 타원체고 z 에 직접 배치(권장) +- 드론높이 모드 사용 시: 드론 174.4m − 비행구간 도로 95.8~113.5m = **61~79m, 평균 ~72m** + → 이격거리 70~75 권장(지오이드 0 기준), 단일값 한계(도로 기복 18m) 안내 +- 구 display.json(drop 40+geoid 20=60)은 datum 통일 전 값 — 갱신 필요 diff --git a/docs/history/2026-07-13_1943_드론높이-off-확인.md b/docs/history/2026-07-13_1943_드론높이-off-확인.md new file mode 100644 index 0000000..1c49c46 --- /dev/null +++ b/docs/history/2026-07-13_1943_드론높이-off-확인.md @@ -0,0 +1,10 @@ +# 2026-07-13 드론높이 기능 OFF 권장 확인 (Q&A) + +**소요 시간**: 1분 +**Context 사용량**: input 2790k / output 1k tokens + +## 답변 (코드 변경 없음) — 네, OFF 권장 + +- OFF(지면고도) = 실측 타원체고 z 직접 배치 → 지형 추종(비행구간 기복 18m 대응) +- ON(드론높이) = 평면 근사 → 기복 구간 어긋남. z 없던 시절의 대안이었음 +- 최종 체크리스트: 드론높이 OFF → 지오이드 0 → offZ 0 → Yaw 확인 → camera/display.json 저장 diff --git a/docs/history/2026-07-13_2028_devmode-영상제어-표시제어.md b/docs/history/2026-07-13_2028_devmode-영상제어-표시제어.md new file mode 100644 index 0000000..085dc41 --- /dev/null +++ b/docs/history/2026-07-13_2028_devmode-영상제어-표시제어.md @@ -0,0 +1,28 @@ +# 2026-07-13 dev_mode 로 영상제어 UI 표시 제어 + +**소요 시간**: 5분 +**Context 사용량**: input 2830k / output 2k tokens + +## 요청 + +route.json `dev_mode`: 1 이면 영상제어 ON 버튼 표시, 0 이면 숨김. + +## 수정 + +### client/src/types/geo.ts +- RouteInfo 에 `dev_mode?: number` 추가 + +### client/src/components/player/VideoPlayer.tsx +- routeMeta 구독 → devMode 산출 (routeInfo.dev_mode 우선, 최상위 dev_mode 허용, + **없으면 1 — 기존 동작 유지**) +- dev_mode=0 이면: + - 좌하단 그룹(영상제어 버튼 + 배속/프레임/좌측패널/선형/드론궤적/GPS/시설등급) 전체 숨김 + - StationOverlay showPanel=false → 우측 카메라 파라미터/화면표시 옵션 버튼도 숨김(뷰어 모드) + +## 사용법 + +route.json 의 routeInfo 안에 `"dev_mode": 0` (숨김) 또는 `"dev_mode": 1` (표시) 추가. + +## 검증 + +- tsc+vite 빌드 통과, 새 번들(index-m3xt1ima.js) 서빙 diff --git a/docs/history/2026-07-13_2034_선형-관통세그먼트-수정.md b/docs/history/2026-07-13_2034_선형-관통세그먼트-수정.md new file mode 100644 index 0000000..79e79c6 --- /dev/null +++ b/docs/history/2026-07-13_2034_선형-관통세그먼트-수정.md @@ -0,0 +1,26 @@ +# 2026-07-13 선형 실종(0k700 구간) — 관통 세그먼트 수정 + +**소요 시간**: 6분 +**Context 사용량**: input 2880k / output 3k tokens + +## 문제 (스크린샷 제보) + +특정 구간(0k700~0k900 부근)에서만 선형(빨간 선)이 통째로 사라짐. 측점 라벨·궤적은 정상. + +## 원인 + +선형 클립(마지막 표출 측점 반경)에서 "세그먼트 양끝이 모두 원 밖 → 생략" 처리 — +KML 선형은 직선 구간 정점 간격이 수백 m 라, **양끝은 밖이지만 세그먼트가 클립 원을 +관통(현)하는 경우**가 흔함. 그 구간에선 화면에 보여야 할 선이 전부 생략됨. +실측 재현: 드론 0k700 상공(clipR 300m)에서 기존 코드 세그먼트 0개. + +## 수정 — StationOverlay.tsx buildLines + +- 양끝 밖 세그먼트도 원과의 교점을 계산해 **두 교점(t1 nul +REM ──────────────────────────────────────────────────────────── +REM GhiVideo 시연 실행 (Windows) +REM 필요: Node.js 20+ 설치, 사전 빌드(npm install && npm run build) +REM 사용: 이 파일 더블클릭 → 브라우저에서 http://localhost:54000 접속 +REM ──────────────────────────────────────────────────────────── +cd /d %~dp0 + +set PORT=54000 +REM 저장 경로(저장소 루트 기준 상대) — 없으면 서버가 자동 생성 +set VIDEOS_DIR=storage\videos +set HLS_DIR=storage\hls +set FRAMES_DIR=storage\frames +set THUMBNAILS_DIR=storage\thumbnails +set DB_PATH=storage\annotations.db + +if not exist server\dist\server\src\app.js ( + echo [오류] 빌드 산출물이 없습니다. 먼저 실행하세요: npm install ^&^& npm run build + pause + exit /b 1 +) + +echo GhiVideo 서버 시작 — http://localhost:54000 (종료: Ctrl+C) +start "" http://localhost:54000 +node server\dist\server\src\app.js +pause