From e0ff5dd6d0c5b8ad7ede401c80c7b5c3162f6b7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=95=9C=EC=84=B1=EC=9D=BC?= Date: Thu, 2 Jul 2026 18:03:30 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=A7=80=EB=8F=84=20=EB=82=98=EC=B9=A8?= =?UTF-8?q?=EB=B0=98(OSM/=EC=9C=84=EC=84=B1)=20=EC=B6=94=EA=B0=80=20+=20?= =?UTF-8?q?=EC=B8=A1=EC=A0=90=20=EA=B2=80=EC=83=89=C2=B7=EB=A7=88=EC=BB=A4?= =?UTF-8?q?=20=EC=A0=95=ED=99=95=EB=8F=84=20=EA=B0=9C=EC=84=A0=20+=20POI/?= =?UTF-8?q?=EC=98=81=EC=83=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 지도 기반 나침반(노스업, 현재위치, 시야 역삼각형): 호버 확대·휠 줌·클릭 위성전환, OSM/Esri 타일 서버 프록시(/api/tile) - 스테이션 검색: 실제 측점(chain) 기준 이동, 없으면 '측점 없음' 안내 - 역 마커: 직교 투영 측점 일치 시에만 표시, 미도착 종점은 추가 방식 - POI 팝업 겹침/재등장·라벨 정합 수정 - 영상 fps 데이터 기반 자동 산출 - 기술/발표/쉬운설명 문서 추가 Co-Authored-By: Claude Opus 4.8 (1M context) --- client/src/components/overlay/MapCompass.tsx | 179 ++++ client/src/components/overlay/Minimap.tsx | 2 +- .../src/components/overlay/StationOverlay.tsx | 185 +++- client/src/components/player/VideoPlayer.tsx | 38 +- client/src/hooks/useVideoPlayer.ts | 29 +- client/src/stationbar/StationBar.tsx | 77 +- .../PlaybackControls.module.scss | 24 + .../PlaybackControls/PlaybackControls.tsx | 23 +- client/src/store/geoStore.ts | 6 +- client/src/store/settingsStore.ts | 10 + client/src/types/geo.ts | 8 +- client/src/utils/geoData.ts | 289 +----- docs/DefVideo→GhiVideo_업그레이드_상세.md | 253 ++++++ docs/assets/report-hwp.css | 55 ++ ...론흔들림-글자붙이기_초등학생용_쉬운설명.md | 35 + ...535_지도POI영상매핑_초등학생용_쉬운설명.md | 39 + ...545_DefVideo-GhiVideo-업그레이드-문서화.md | 20 + ...-30_1610_KMZ필수화-CSV폴백제거-누락경고.md | 42 + ...06-30_1615_구현상세-소스코드매칭-문서화.md | 22 + ...-06-30_1640_발표문서-좌표정합과측점기반.md | 20 + ..._1640_재생중-폴더교체-영상로드실패-수정.md | 37 + ...2026-06-30_1823_발표자료-SVG도식화-개편.md | 24 + ...026-07-02_POI라벨사라짐-팝업재등장-수정.md | 103 +++ .../2026-07-02_POI팝업-라벨겹침-수정.md | 23 + .../2026-07-02_역마커-실제측점일치-정정.md | 27 + ...26-07-02_역마커-직교측점일치시에만-표시.md | 26 + .../2026-07-02_영상fps-데이터기반-자동산출.md | 34 + ...26-07-02_종점마커-이동제거-실제위치표시.md | 33 + .../2026-07-02_지도나침반-OSM-노스업-추가.md | 57 ++ ...026-07-02_측점검색-없는측점-오점프-수정.md | 45 + .../2026-07-02_측점없음-안내-표시수정.md | 25 + docs/구현상세_GhiVideo_기술-소스코드매칭.md | 745 +++++++++++++++ ...현상세_드론좌표_영상투영_소스코드매칭.html | 724 +++++++++++++++ ...구현상세_드론좌표_영상투영_소스코드매칭.md | 528 +++++++++++ ...현상세_드론좌표_영상투영_소스코드매칭.pdf | Bin 0 -> 205613 bytes .../발표_GhiVideo_좌표정합과측점기반구현.html | 856 ++++++++++++++++++ docs/발표_GhiVideo_좌표정합과측점기반구현.md | 634 +++++++++++++ docs/발표_GhiVideo_좌표정합과측점기반구현.pdf | Bin 0 -> 181260 bytes ...발표_GhiVideo_좌표투영과_측점기반재생.html | 337 +++++++ docs/발표_GhiVideo_좌표투영과_측점기반재생.md | 234 +++++ .../발표_GhiVideo_좌표투영과_측점기반재생.pdf | Bin 0 -> 104503 bytes ...술_드론좌표를_영상에_맞추기_쉬운설명.html | 434 +++++++++ ...기술_드론좌표를_영상에_맞추기_쉬운설명.md | 396 ++++++++ ...기술_드론좌표를_영상에_맞추기_쉬운설명.pdf | Bin 0 -> 125529 bytes docs/보고서_GhiVideo_개발내용및핵심기술.html | 301 ++++++ docs/보고서_GhiVideo_개발내용및핵심기술.md | 92 ++ docs/보고서_GhiVideo_개발내용및핵심기술.pdf | Bin 0 -> 66384 bytes docs/쉬운설명_GhiVideo_기술이야기.html | 507 +++++++++++ docs/쉬운설명_GhiVideo_기술이야기.md | 432 +++++++++ docs/쉬운설명_GhiVideo_기술이야기.pdf | Bin 0 -> 123717 bytes .../쉬운설명_드론흔들림잡기와_글자붙이기.html | 427 +++++++++ docs/쉬운설명_드론흔들림잡기와_글자붙이기.md | 352 +++++++ docs/쉬운설명_드론흔들림잡기와_글자붙이기.pdf | Bin 0 -> 118540 bytes .../쉬운설명_지도POI를_영상에_붙이는기술.html | 656 ++++++++++++++ docs/쉬운설명_지도POI를_영상에_붙이는기술.md | 515 +++++++++++ docs/쉬운설명_지도POI를_영상에_붙이는기술.pdf | Bin 0 -> 137618 bytes server/src/app.ts | 2 + server/src/routes/tile.ts | 51 ++ 58 files changed, 9635 insertions(+), 378 deletions(-) create mode 100644 client/src/components/overlay/MapCompass.tsx create mode 100644 docs/DefVideo→GhiVideo_업그레이드_상세.md create mode 100644 docs/assets/report-hwp.css create mode 100644 docs/history/2026-06-30_1525_드론흔들림-글자붙이기_초등학생용_쉬운설명.md create mode 100644 docs/history/2026-06-30_1535_지도POI영상매핑_초등학생용_쉬운설명.md create mode 100644 docs/history/2026-06-30_1545_DefVideo-GhiVideo-업그레이드-문서화.md create mode 100644 docs/history/2026-06-30_1610_KMZ필수화-CSV폴백제거-누락경고.md create mode 100644 docs/history/2026-06-30_1615_구현상세-소스코드매칭-문서화.md create mode 100644 docs/history/2026-06-30_1640_발표문서-좌표정합과측점기반.md create mode 100644 docs/history/2026-06-30_1640_재생중-폴더교체-영상로드실패-수정.md create mode 100644 docs/history/2026-06-30_1823_발표자료-SVG도식화-개편.md create mode 100644 docs/history/2026-07-02_POI라벨사라짐-팝업재등장-수정.md create mode 100644 docs/history/2026-07-02_POI팝업-라벨겹침-수정.md create mode 100644 docs/history/2026-07-02_역마커-실제측점일치-정정.md create mode 100644 docs/history/2026-07-02_역마커-직교측점일치시에만-표시.md create mode 100644 docs/history/2026-07-02_영상fps-데이터기반-자동산출.md create mode 100644 docs/history/2026-07-02_종점마커-이동제거-실제위치표시.md create mode 100644 docs/history/2026-07-02_지도나침반-OSM-노스업-추가.md create mode 100644 docs/history/2026-07-02_측점검색-없는측점-오점프-수정.md create mode 100644 docs/history/2026-07-02_측점없음-안내-표시수정.md create mode 100644 docs/구현상세_GhiVideo_기술-소스코드매칭.md create mode 100644 docs/구현상세_드론좌표_영상투영_소스코드매칭.html create mode 100644 docs/구현상세_드론좌표_영상투영_소스코드매칭.md create mode 100644 docs/구현상세_드론좌표_영상투영_소스코드매칭.pdf create mode 100644 docs/발표_GhiVideo_좌표정합과측점기반구현.html create mode 100644 docs/발표_GhiVideo_좌표정합과측점기반구현.md create mode 100644 docs/발표_GhiVideo_좌표정합과측점기반구현.pdf create mode 100644 docs/발표_GhiVideo_좌표투영과_측점기반재생.html create mode 100644 docs/발표_GhiVideo_좌표투영과_측점기반재생.md create mode 100644 docs/발표_GhiVideo_좌표투영과_측점기반재생.pdf create mode 100644 docs/발표_핵심기술_드론좌표를_영상에_맞추기_쉬운설명.html create mode 100644 docs/발표_핵심기술_드론좌표를_영상에_맞추기_쉬운설명.md create mode 100644 docs/발표_핵심기술_드론좌표를_영상에_맞추기_쉬운설명.pdf create mode 100644 docs/보고서_GhiVideo_개발내용및핵심기술.html create mode 100644 docs/보고서_GhiVideo_개발내용및핵심기술.md create mode 100644 docs/보고서_GhiVideo_개발내용및핵심기술.pdf create mode 100644 docs/쉬운설명_GhiVideo_기술이야기.html create mode 100644 docs/쉬운설명_GhiVideo_기술이야기.md create mode 100644 docs/쉬운설명_GhiVideo_기술이야기.pdf create mode 100644 docs/쉬운설명_드론흔들림잡기와_글자붙이기.html create mode 100644 docs/쉬운설명_드론흔들림잡기와_글자붙이기.md create mode 100644 docs/쉬운설명_드론흔들림잡기와_글자붙이기.pdf create mode 100644 docs/쉬운설명_지도POI를_영상에_붙이는기술.html create mode 100644 docs/쉬운설명_지도POI를_영상에_붙이는기술.md create mode 100644 docs/쉬운설명_지도POI를_영상에_붙이는기술.pdf create mode 100644 server/src/routes/tile.ts diff --git a/client/src/components/overlay/MapCompass.tsx b/client/src/components/overlay/MapCompass.tsx new file mode 100644 index 0000000..4e5a95a --- /dev/null +++ b/client/src/components/overlay/MapCompass.tsx @@ -0,0 +1,179 @@ +import { useEffect, useRef, useState } from 'react'; +import type { MutableRefObject } from 'react'; +import { useSettingsStore } from '../../store/settingsStore'; + +/** + * 지도 기반 나침반 (노스업) — OSM 타일 위에 '현재 위치(중심 고정 점)' + '드론 시야(반투명 역삼각형)'. + * - 노스업: 지도는 북쪽 위로 고정. 드론이 움직이면 지도가 팬(현재 위치가 항상 중앙). + * - 시야 역삼각형(▽): 중심(드론)에서 진행/촬영 방향으로 벌어지는 반투명 부채꼴. yaw 로 회전. + * - 마우스 오버: 위젯 자체가 커져(스케일 아님, 타일 재배치) 더 넓은 영역을 선명하게 표시. + * 휠로 줌 인/아웃. 벗어나면 기본 크기·기본 줌으로 복귀. + * - poseRef: 부모 RAF 가 매 프레임 { lat, lon, yaw(도, 0=N 시계+) } 갱신 → 내부 RAF 가 읽어 + * 역삼각형 회전(즉시) + 지도 팬(transform) + 임계 이동/줌·크기 변경 시 타일 재배치. + * - 타일은 서버 프록시(/api/tile) 경유(외부 직접 로드는 CSP img-src/COEP 로 차단). + */ + +export interface CompassPose { lat: number; lon: number; yaw: number } + +const BASE_D = 200; // 기본 지름(px) +const HOVER_D = 400; // 오버 시 지름(px, 2배) — 위젯 확대(스케일 아님 → 더 넓은 영역, 선명) +const TILE = 256; // OSM 타일 크기 +const MARGIN = TILE; // 팬 중 빈틈 방지용 여유 타일 폭 +const RELAYOUT_PX = 96; // 중심이 이만큼 벗어나면 타일 재배치(팬 리셋) +const DEFAULT_ZOOM = 16; +const MIN_ZOOM = 12; +const MAX_ZOOM = 19; + +/** 위경도 → 전역 픽셀(Web Mercator, zoom z). */ +function project(lat: number, lon: number, z: number): { x: number; y: number } { + const n = 2 ** z; + const x = ((lon + 180) / 360) * n * TILE; + const s = Math.min(0.9999, Math.max(-0.9999, Math.sin((lat * Math.PI) / 180))); + const y = (0.5 - Math.log((1 + s) / (1 - s)) / (4 * Math.PI)) * n * TILE; + return { x, y }; +} + +export function MapCompass({ poseRef }: { poseRef: MutableRefObject }) { + const mapStyle = useSettingsStore((s) => s.compassMapStyle); // 'street' | 'sat' + const setMapStyle = useSettingsStore((s) => s.setCompassMapStyle); + const src = mapStyle === 'sat' ? 'sat' : 'osm'; + const [hovered, setHovered] = useState(false); + const [zoom, setZoomState] = useState(DEFAULT_ZOOM); + const zoomRef = useRef(zoom); + useEffect(() => { zoomRef.current = zoom; }, [zoom]); + const setZoom = (z: number): void => setZoomState(Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, Math.round(z)))); + + const D = hovered ? HOVER_D : BASE_D; + const HALF = D / 2; + + // 타일 배치 기준 중심(이 좌표에 맞춰 타일을 깔고, 현재위치와의 차이는 transform 으로 팬). + const [tileCenter, setTileCenter] = useState<{ lat: number; lon: number } | null>(null); + const arrowRef = useRef(null); + const layerRef = useRef(null); + const containerRef = useRef(null); + const tileCenterRef = useRef<{ lat: number; lon: number } | null>(null); + useEffect(() => { tileCenterRef.current = tileCenter; }, [tileCenter]); + + // 줌 변경 시 현재 위치 기준으로 타일 재배치(줌 전환 튐 방지). + useEffect(() => { + const p = poseRef.current; + if (p && isFinite(p.lat) && isFinite(p.lon)) setTileCenter({ lat: p.lat, lon: p.lon }); + }, [zoom, poseRef]); + + // 휠 줌(페이지 스크롤 대신). native 리스너(passive:false)로 preventDefault. + useEffect(() => { + const el = containerRef.current; + if (!el) return; + const onWheel = (e: WheelEvent): void => { + e.preventDefault(); + setZoom(zoomRef.current + (e.deltaY < 0 ? 1 : -1)); // 위로 스크롤 = 확대 + }; + el.addEventListener('wheel', onWheel, { passive: false }); + return () => el.removeEventListener('wheel', onWheel); + }, []); + + useEffect(() => { + let raf = 0; + const tick = (): void => { + raf = requestAnimationFrame(tick); + const p = poseRef.current; + if (!p || !isFinite(p.lat) || !isFinite(p.lon)) return; + const z = zoomRef.current; + if (arrowRef.current) arrowRef.current.style.transform = `rotate(${p.yaw}deg)`; + const tc = tileCenterRef.current; + if (!tc) { setTileCenter({ lat: p.lat, lon: p.lon }); return; } + const cur = project(p.lat, p.lon, z); + const base = project(tc.lat, tc.lon, z); + const dx = cur.x - base.x, dy = cur.y - base.y; + if (layerRef.current) layerRef.current.style.transform = `translate(${-dx}px, ${-dy}px)`; + if (Math.hypot(dx, dy) > RELAYOUT_PX) setTileCenter({ lat: p.lat, lon: p.lon }); + }; + raf = requestAnimationFrame(tick); + return () => cancelAnimationFrame(raf); + }, [poseRef]); + + // 벗어나면 기본값 복귀(기본 크기 + 기본 줌). + const onLeave = (): void => { setHovered(false); setZoomState(DEFAULT_ZOOM); }; + + // 타일 배치 — 기준 중심(tileCenter)을 뷰포트 중앙에 두고, 뷰포트+여유를 덮는 타일 나열. + const tiles: { key: string; url: string; left: number; top: number }[] = []; + if (tileCenter) { + const c = project(tileCenter.lat, tileCenter.lon, zoom); + const n = 2 ** zoom; + const minTx = Math.floor((c.x - HALF - MARGIN) / TILE); + const maxTx = Math.floor((c.x + HALF + MARGIN) / TILE); + const minTy = Math.floor((c.y - HALF - MARGIN) / TILE); + const maxTy = Math.floor((c.y + HALF + MARGIN) / TILE); + for (let tx = minTx; tx <= maxTx; tx++) { + for (let ty = minTy; ty <= maxTy; ty++) { + if (ty < 0 || ty >= n) continue; + const wx = ((tx % n) + n) % n; // 경도 래핑 + tiles.push({ + key: `${src}_${zoom}_${tx}_${ty}`, + url: `/api/tile/${src}/${zoom}/${wx}/${ty}`, + left: tx * TILE - c.x + HALF, + top: ty * TILE - c.y + HALF, + }); + } + } + } + + return ( +
setHovered(true)} + onMouseLeave={onLeave} + onClick={() => setMapStyle(mapStyle === 'sat' ? 'street' : 'sat')} + title="클릭: 위성 ↔ 일반 전환" + style={{ position: 'absolute', top: 14, right: 14, width: D, height: D, zIndex: hovered ? 40 : 30, pointerEvents: 'auto', cursor: 'pointer' }} + > + {/* 원형 클립 지도 */} +
+ {/* 팬 레이어 — RAF 가 transform 으로 이동. 타일은 tileCenter 기준 배치. */} +
+ {tiles.map((t) => ( + + ))} +
+ {!tileCenter && ( +
+ 지도 로딩… +
+ )} +
+ + {/* 드론 시야(역삼각형 ▽) — 중심(드론)에서 촬영방향으로 벌어짐. yaw 로 회전. + 주황 그라데이션: 근거리(드론) 진하게 → 멀수록 투명. 보더 없음. */} +
+ + + + + + + + {/* 꼭짓점이 현재위치 점에 붙음(50). */} + + +
+ + {/* 현재 위치 점(중심) + 상단 N(노스업 고정, 크게·붉은·반투명) — SVG 라 위젯 크기에 맞춰 스케일. */} + + {/* 현재 위치 점 — 빨강 단색, 보더 없음. */} + + N + +
+ ); +} diff --git a/client/src/components/overlay/Minimap.tsx b/client/src/components/overlay/Minimap.tsx index 76e7102..4cdb71b 100644 --- a/client/src/components/overlay/Minimap.tsx +++ b/client/src/components/overlay/Minimap.tsx @@ -6,7 +6,7 @@ import { forwardRef } from 'react'; * - 상단 흰 삼각형 인덱스 + 외곽 링은 고정(위 = 드론 진행방향 = 헤딩). * ref 는 root div — `style.setProperty('--rot', `${-yaw}deg`)`. */ -const D = 150; // 나침반 한 변(px) +const D = 200; // 나침반 한 변(px) — 지도 나침반(BASE_D)과 동일 // 눈금: 6° 간격(60개). 0/90/180/270=장, 30°배수=중, 그 외=단. const TICKS = Array.from({ length: 60 }, (_, i) => { diff --git a/client/src/components/overlay/StationOverlay.tsx b/client/src/components/overlay/StationOverlay.tsx index baeb55c..2686e75 100644 --- a/client/src/components/overlay/StationOverlay.tsx +++ b/client/src/components/overlay/StationOverlay.tsx @@ -20,6 +20,7 @@ import { import { useGeoStore } from '../../store/geoStore'; import { useSettingsStore } from '../../store/settingsStore'; import { Minimap } from './Minimap'; +import { MapCompass, type CompassPose } from './MapCompass'; import type { GeoPoint, CenterlinePoint, PoiOverrideMap, RouteStructure } from '../../types/geo'; const VIDEO_FPS = 30000 / 1001; @@ -253,6 +254,12 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible const visibleRef = useRef(visible); const videoReadyRef = useRef(videoReady); const videoSizeRef = useRef({ w: videoWidth, h: videoHeight }); + const barHeightRef = useRef(barHeight); // 하단 스테이션바 높이(px) — 팝업/라벨 유효 하단 = H − barHeight + useEffect(() => { barHeightRef.current = barHeight; }, [barHeight]); + // 영상 실제 fps — 부모(VideoPlayer)가 데이터(마지막 프레임 번호)+영상길이로 계산해 prop 으로 전달. + // 프레임번호↔시간 변환의 기준(고정 29.97 대신 영상별 자동). 0/미정이면 VIDEO_FPS 폴백. + const fpsRef = useRef(fps > 0 ? fps : VIDEO_FPS); + useEffect(() => { if (fps > 0) fpsRef.current = fps; }, [fps]); // object-fit:cover 변환 (RAF에서 매 프레임 갱신, 포인터 핸들러도 참조). 정규(0~1, 영상프레임) → 화면 px. const coverRef = useRef({ offX: 0, offY: 0, dispW: 0, dispH: 0, W: 0, H: 0 }); const worldOriginRef = useRef<{ lat: number; lon: number; alt: number } | undefined>(undefined); @@ -268,6 +275,8 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible // 나침반 미니맵(DOM) root — RAF 에서 --rot(=-yaw) 갱신. minimapRotRef=누적 회전(360° 언랩). const minimapRef = useRef(null); const minimapRotRef = useRef(0); + // 지도 나침반용 현재 위치/방위 — RAF 가 매 프레임 갱신, MapCompass 가 읽음. + const mapPoseRef = useRef({ lat: 0, lon: 0, yaw: 0 }); // UI state const [params, setParams] = useState(DEFAULT_CAMERA_PARAMS); @@ -324,7 +333,17 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible auto?: boolean }; const [infoPopups, setInfoPopups] = useState([]); const infoPopupsRef = useRef([]); - useEffect(() => { infoPopupsRef.current = infoPopups; }, [infoPopups]); + useEffect(() => { + infoPopupsRef.current = infoPopups; + // 팝업 제거 경로가 여럿(RAF off-4프레임 / sync toRemoveIds / 수동 ✕·ESC·빈곳클릭)인데 + // 보조 맵 정리는 RAF 경로만 했었다 → sync/수동 제거 시 popupPosRef 잔존. 그러면 재추가 때 + // hidden-mount 가드(!popupPosRef.has)가 무력화돼 라벨 없이 팝업만 stale 위치에 번쩍인다. + // → infoPopups 변경 시 사라진 id 의 보조 맵을 일괄 정리(단일 출처). (popupElsRef 는 ref 콜백이 정리.) + const ids = new Set(infoPopups.map(p => p.id)); + for (const id of popupPosRef.current.keys()) if (!ids.has(id)) popupPosRef.current.delete(id); + for (const id of popupMissRef.current.keys()) if (!ids.has(id)) popupMissRef.current.delete(id); + for (const id of popupFlipRef.current.keys()) if (!ids.has(id)) popupFlipRef.current.delete(id); + }, [infoPopups]); // 영상 진행 방향('상'|'하') — 겹치는 상/하 형제 구조물 중 이 방향을 우선 표시. (값 주입은 아래 routeMeta 구독 후) const routeDirRef = useRef<'상' | '하' | null>(null); // 이번 프레임 가시 컴팩트-구조물 (title → {category, compact}). RAF 가 채우고 sync 인터벌이 읽음. @@ -332,6 +351,7 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible const popupElsRef = useRef>(new Map()); const popupMissRef = useRef>(new Map()); // 라벨이 화면 밖인 연속 프레임 수(제거 유예) const popupPosRef = useRef>(new Map()); // 팝업 위치 EMA(물결 방지) + const popupFlipRef = useRef>(new Map()); // 팝업 배치면(위/아래) — 히스테리시스로 경계 왕복(번쩍임) 방지 // 라벨 히트박스(아이콘+글자 영역, 화면 px) — RAF 가 매 프레임 갱신, 클릭/호버 히트테스트에 사용. const labelHitRef = useRef<{ kind: 'poi' | 'station'; title: string; x0: number; y0: number; x1: number; y1: number }[]>([]); const hasPopupRef = useRef(false); @@ -339,6 +359,9 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible const poiDroneHeightRef = useRef(DISPLAY_DEFAULTS.poiDroneHeight); const droneHeightDropRef = useRef(DISPLAY_DEFAULTS.droneHeightDrop); const overridesRef = useRef({}); + // POI 라벨이 연속으로 화면 안에 그려진 프레임 수(title→streak). 팝업 '추가'에 히스테리시스를 줘 + // 경계에서 라벨이 미세하게 깜빡일 때(1~2프레임) 팝업이 재생성돼 번쩍이는 것을 막는다. + const poiOnScreenStreakRef = useRef>(new Map()); // 드래그 상태: 선택 POI + lat/lon(고정) + 시작 z + 시작 화면좌표. 드롭 시 세로위치로 z 역산. const dragRef = useRef<{ title: string; lat: number; lon: number; z0: number; sx: number; sy: number } | null>(null); const dragPosRef = useRef<{ x: number; y: number } | null>(null); @@ -370,7 +393,8 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible useEffect(() => { editModeRef.current = editMode; }, [editMode]); useEffect(() => { fovModeRef.current = fovMode; }, [fovMode]); - // 컴팩트 자동 팝업 동기화 — 가시 구조물(visStructRef)마다 라벨 옆 DOM 팝업을 자동 생성/제거. + // 컴팩트 자동 팝업 동기화 — visStructRef(연속 온스크린 라벨) 마다 라벨 옆 DOM 팝업을 자동 '생성'만. + // 제거는 RAF 가 단일 권한(onScreenLabels + 4프레임 유예)으로 담당 → add/remove 충돌(번쩍임) 제거. // (캔버스 텍스트는 흐려서 DOM 팝업으로 선명하게. 위치는 RAF 가 displayed 좌표로 매 프레임 갱신.) // 60fps 루프에서 setState 남발 방지 → 150ms 인터벌로 가시집합 변화시에만 갱신. useEffect(() => { @@ -392,12 +416,10 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible props: obj?.props, compact: info.compact, auto: true, expanded: false, }); }); - const toRemoveIds = cur.filter(p => p.auto && !vis.has(p.title)).map(p => p.id); - if (toAdd.length || toRemoveIds.length) { + if (toAdd.length) { setInfoPopups(prev => { - const kept = prev.filter(p => !toRemoveIds.includes(p.id)); - const ids = new Set(kept.map(p => p.id)); - return [...kept, ...toAdd.filter(p => !ids.has(p.id))]; + const ids = new Set(prev.map(p => p.id)); + return [...prev, ...toAdd.filter(p => !ids.has(p.id))]; }); } }; @@ -449,6 +471,10 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible routeDirRef.current = d.includes('하') ? '하' : d.includes('상') ? '상' : null; }, [routeMeta]); // POI 겹침제외 토글(설정 스토어). false면 겹침 억제 없이 모든 POI 표시. + const compassType = useSettingsStore(s => s.compassType); + const setCompassType = useSettingsStore(s => s.setCompassType); + const compassMapStyle = useSettingsStore(s => s.compassMapStyle); + const setCompassMapStyle = useSettingsStore(s => s.setCompassMapStyle); const poiOverlapExclude = useSettingsStore(s => s.poiOverlapExclude); const overlapExcludeRef = useRef(true); useEffect(() => { overlapExcludeRef.current = poiOverlapExclude; }, [poiOverlapExclude]); @@ -833,11 +859,12 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible if (!droneFramesLoaded) return; const frames = allDroneFramesRef.current; if (!frames.length) return; - let best = frames[0], bestIdx = 0, bestD = Math.abs((best.frame ?? 0) / VIDEO_FPS - currentTime); + const vfps = fpsRef.current; + let best = frames[0], bestIdx = 0, bestD = Math.abs((best.frame ?? 0) / vfps - currentTime); for (let i = 0; i < frames.length; i++) { - const d = Math.abs(frames[i].frame / VIDEO_FPS - currentTime); + const d = Math.abs(frames[i].frame / vfps - currentTime); if (d < bestD) { bestD = d; best = frames[i]; bestIdx = i; } - if (bestD < 1 / VIDEO_FPS / 2) break; + if (bestD < 1 / vfps / 2) break; } currentFrameNumRef.current = best.frame; currentFrameIdxRef.current = bestIdx; @@ -933,6 +960,16 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible // 이번 프레임 라벨 히트박스(아이콘+글자, 화면 px) 수집 → 끝에서 labelHitRef 갱신. const hitBoxes: { kind: 'poi' | 'station'; title: string; x0: number; y0: number; x1: number; y1: number }[] = []; + // 이번 프레임에 '실제로 화면 안에 그려진' 라벨 id(`kind:title`) 집합. 팝업 표시의 단일 기준 — + // 십자마커/텍스트가 뷰포트 안에 있을 때만 채운다. 팝업은 이 집합에 있을 때만 보이게 해 + // "라벨은 화면 밖(안 보임)인데 팝업만 clamp 되어 뜨는" 문제를 원천 차단한다. + const onScreenLabels = new Set(); + // 유효 하단 = 화면 높이 − 스테이션바 높이. 팝업/라벨 가시 기준을 '모니터'가 아니라 + // '영상 재생 영역(스테이션바 위쪽)' 으로 잡는다. (라벨이 바 밑으로 내려가면 사라지게) + const HB = H - barHeightRef.current; + // 히트박스가 '영상 영역(0..W, 0..HB)' 과 겹치는지(라벨이 눈에 보이는지). -2px 안쪽 요구로 경계 애매함 제거. + const labelOnScreen = (x0: number, y0: number, x1: number, y1: number): boolean => + x1 >= 2 && x0 <= W - 2 && y1 >= 2 && y0 <= HB - 2; const cache = renderCacheRef.current; if (cache) { @@ -941,7 +978,7 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible const estTime = timeRef ? timeRef.current : currentTimeSecRef.current + (performance.now() - timeUpdateWallRef.current) / 1000; - const estFrame = estTime * VIDEO_FPS; + const estFrame = estTime * fpsRef.current; // 연속 보간 포즈(라인·라벨 공통) — 매 RAF 프레임 직접 투영해 부드럽게. const dronePose = poseAt(estFrame) ?? (allDroneFramesRef.current.length @@ -951,7 +988,7 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible // 나침반 미니맵(heading-up) 회전 — 영상과 즉시 동기. dronePose.yaw 는 ±smoothHalf(기본 60fr ≈2s) // 평활이라 회전 시 지연 → 가벼운 평활(±3fr)의 yaw 를 따로 보간해 사용. 360° 누적 언랩(점프 방지). - if (minimapRef.current) { + { const arr = allDroneFramesRef.current; let rawYaw = dronePose.yaw; if (arr.length) { @@ -964,10 +1001,18 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible let dy = b.yaw - a.yaw; dy = ((dy + 540) % 360) - 180; rawYaw = a.yaw + dy * frac; } - const target = -(rawYaw + paramsRef.current.yawOffset); - const delta = (((target - minimapRotRef.current) % 360) + 540) % 360 - 180; - minimapRotRef.current += delta; - minimapRef.current.style.setProperty('--rot', `${minimapRotRef.current}deg`); + const headingDeg = rawYaw + paramsRef.current.yawOffset; // 실제 방위(0=N, 시계+) + // 지도 나침반(노스업)용 현재 위치/방위 — MapCompass 가 읽음. + mapPoseRef.current.lat = dronePose.lat; + mapPoseRef.current.lon = dronePose.lon; + mapPoseRef.current.yaw = headingDeg; + // 아날로그 나침반(heading-up) 회전 — 렌더 중일 때만. 360° 누적 언랩(점프 방지). + if (minimapRef.current) { + const target = -headingDeg; + const delta = (((target - minimapRotRef.current) % 360) + 540) % 360 - 180; + minimapRotRef.current += delta; + minimapRef.current.style.setProperty('--rot', `${minimapRotRef.current}deg`); + } } // 선로 중심선 (선형) — 독립 토글 @@ -1063,6 +1108,7 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible // 히트박스(마커 선 ~ 글자 끝) const tw = ctx.measureText(cleanTitle(stA.title)).width; hitBoxes.push({ kind: 'station', title: stA.title, x0: x - 8, y0: y - 12, x1: lx + tw + 2, y1: y + 12 }); + if (labelOnScreen(x - 8, y - 12, lx + tw + 2, y + 12)) onScreenLabels.add(`station:${stA.title}`); }); } @@ -1109,15 +1155,25 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible // 히트박스(십자 마커 ~ 글자 끝) — 아이콘·글자 어디를 눌러도 선택되게. (라벨 행 위치 기준) const tw = ctx.measureText(label).width; hitBoxes.push({ kind: 'poi', title: poiA.title, x0: px - r, y0: labelY - 13, x1: lx + tw + 2, y1: labelY + 13 }); + const poiVisible = labelOnScreen(px - r, labelY - 13, lx + tw + 2, labelY + 13); + if (poiVisible) onScreenLabels.add(`poi:${poiA.title}`); + // 연속 온스크린 프레임 수 갱신(화면 밖이면 0으로 리셋). + const streak = poiVisible ? (poiOnScreenStreakRef.current.get(poiA.title) ?? 0) + 1 : 0; + poiOnScreenStreakRef.current.set(poiA.title, streak); ctx.textBaseline = 'alphabetic'; - // 컴팩트 팝업 자동표시: 단일 라벨만(동일좌표 다중행은 팝업 겹침 방지 위해 제외 → 클릭 시 개별 표시). - if (poiA.compact.length && poiA.labelRowCount <= 1) visStructRef.current.set(poiA.title, { category: poiA.category, compact: poiA.compact }); + // 컴팩트 팝업 자동생성 후보 등록(sync 인터벌이 읽음). 조건: + // ① 라벨이 '연속' ADD_STREAK 프레임 이상 화면 안(경계 깜빡임에 팝업 재생성 방지), + // ② 컴팩트 필드 있음, ③ 단일 라벨(동일좌표 다중행 제외 → 클릭 시 개별 표시). + const ADD_STREAK = 6; // ~100ms 안정적으로 보여야 팝업 생성 + if (streak >= ADD_STREAK && poiA.compact.length && poiA.labelRowCount <= 1) visStructRef.current.set(poiA.title, { category: poiA.category, compact: poiA.compact }); }); // 이번 프레임에 안 보인 라벨의 표시상태 제거 → 재등장 시 새 위치에서 시작 // (오래된 좌표가 남아 이상치 거부로 늦게 뜨는 것 방지) for (const k of displayedStRef.current.keys()) if (!seenSt.has(k)) displayedStRef.current.delete(k); for (const k of displayedPoiRef.current.keys()) if (!seenPoi.has(k)) displayedPoiRef.current.delete(k); + // poiMarkers 에서 빠진 POI 는 streak 도 정리(재등장 시 0부터 다시 쌓임). + for (const k of poiOnScreenStreakRef.current.keys()) if (!seenPoi.has(k)) poiOnScreenStreakRef.current.delete(k); } } // end if(visible) — 측점/POI 라벨 } // end if(cache) — 중심선/드론경로/측점/POI @@ -1149,28 +1205,34 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible const sep = id.indexOf(':'); const kind = id.slice(0, sep), title = id.slice(sep + 1); const disp = (kind === 'poi' ? displayedPoiRef.current : displayedStRef.current).get(title); - // 라벨이 없거나(필터됨) 보이는 컨테이너 밖(cover 크롭 포함)이면 '사라짐'으로 간주. + // 팝업 표시 = '이 프레임에 라벨이 실제로 화면 안에 그려졌는가'(onScreenLabels)로만 판정. + // 좌표 ±16 슬랙 + clamp 조합은 라벨(특히 텍스트)이 화면 밖인데 팝업만 clamp 되어 뜨는 + // 문제를 만들었음 → 라벨 가시성과 완전히 일치시킨다. const sx = disp ? offX + disp.x * dispW : 0, sy = disp ? offY + disp.y * dispH : 0; - const off = !disp || sx < -16 || sx > W + 16 || sy < -16 || sy > H + 16; + const off = !disp || !onScreenLabels.has(id); if (off) { - const m = (popupMissRef.current.get(id) ?? 0) + 1; - popupMissRef.current.set(id, m); - if (m >= 4) toRemove.push(id); // 4프레임(~0.07s) 연속 밖이면 제거(라벨과 함께 사라짐) - else el.style.visibility = 'hidden'; + // 라벨이 화면에서 벗어나면 '즉시' 제거(유예 없음) → 하단 이탈 즉시 사라짐. + // 유예를 두면 그 사이 흔들림으로 라벨이 경계로 잠깐 되돌아올 때 팝업이 다시 보였다 사라짐. + // 재등장은 sync 의 ADD_STREAK(연속 안정 프레임) 게이트가 막는다(짧은 복귀로는 재생성 안 됨). + el.style.visibility = 'hidden'; + toRemove.push(id); return; } - popupMissRef.current.set(id, 0); const ph = el.offsetHeight || 120; const pw = el.offsetWidth || 240; - // 기본은 라벨 '아래'. 아래 공간이 부족하면(화면 하단 근접) 라벨 '위'로 플립 → - // 라벨과 겹치거나 화면 밖으로 잘리는 것을 방지. 위로도 부족하면 화면 안으로 클램프. - const GAP = 16; + // 라벨(십자+글자)은 sy 를 중심으로 상하 ~LABEL_HALF 만큼 차지한다. GAP 을 sy(중심) 기준으로 + // 잡으면 라벨 높이만큼 먹혀 팝업이 글자에 붙는다 → 라벨 '바깥 가장자리' 기준으로 띄운다. + const LABEL_HALF = 15; // 라벨 반높이(px) — sy 중심 ± 이 값이 글자/아이콘 영역 + const GAP = 12; // 라벨 가장자리 ~ 팝업 사이 실제 여백 const tx = Math.min(Math.max(8, sx - 10), Math.max(8, W - pw - 4)); - let ty = sy + GAP; // 아래 - if (ty + ph > H - 2) { - const aboveTy = sy - GAP - ph; // 위로 플립 (팝업 하단이 라벨 위 ~GAP 지점) - ty = aboveTy >= 2 ? aboveTy : Math.max(2, H - 2 - ph); - } + // 기본은 라벨 '아래'. 아래 공간이 부족하면(화면 하단 근접) 라벨 '위'로 플립. + // ※ 히스테리시스(sticky): 경계에서 sy 가 미세하게 오르내려도 위↔아래로 왕복하지 않도록, + // '위'로 간 뒤엔 아래에 HYST 이상 여유가 생겨야만 '아래'로 복귀. (왕복=팝업 순간이동=번쩍임 방지) + // 팝업은 '항상 라벨 아래'(flip 없음, 하단 클램프 없음). 라벨을 따라 자유롭게 내려가다 + // 스테이션바 영역으로 진입하면 팝업 컨테이너의 overflow-hidden(HB 아래 클립)이 시각적으로 + // 가린다 → 바 뒤로 슬라이드되는 것처럼 보임. 라벨이 바 밑(HB)으로 내려가면 위 off 판정으로 + // 팝업 제거(라벨과 함께 사라짐). (이전 flip=라벨 위로 튐 / clamp=바에 걸침 문제 모두 제거) + let ty = Math.max(2, sy + LABEL_HALF + GAP); el.style.visibility = 'visible'; const pp = popupPosRef.current.get(id); const PA = 0.18; @@ -1183,7 +1245,7 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible el.style.top = `${Math.round(ny)}px`; }); if (toRemove.length) { - toRemove.forEach(id => { popupMissRef.current.delete(id); popupPosRef.current.delete(id); }); + toRemove.forEach(id => { popupMissRef.current.delete(id); popupPosRef.current.delete(id); popupFlipRef.current.delete(id); }); setInfoPopups(prev => prev.filter(p => !toRemove.includes(p.id))); } } @@ -1442,10 +1504,15 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible /> {/* 나침반 미니맵 — 우측 상단(기존 캔버스 나침반 대체). 핀은 RAF 가 드론 방위로 회전. */} - {geoDataLoaded && } + {geoDataLoaded && (compassType === 'map' + ? + : )} {/* 라벨 속성 팝업(다중) — 위치는 RAF 가 라벨을 따라 갱신. DOM 이라 텍스트 선명. - 컴팩트(기본 5필드) ↔ 전체 토글은 팝업 클릭. 자동(auto) 팝업은 ✕ 없음(가시 구조물 따라 표시). */} + 컴팩트(기본 5필드) ↔ 전체 토글은 팝업 클릭. 자동(auto) 팝업은 ✕ 없음(가시 구조물 따라 표시). + ▶ 클립 컨테이너: 높이 = 영상영역(H − barHeight). overflow-hidden 으로 스테이션바 영역을 가려 + 팝업이 라벨 따라 내려가면 바 뒤로 슬라이드되어 사라짐(flip/걸침 없이). */} +
{infoPopups.map(pp => { const showFull = pp.expanded || !pp.compact || pp.compact.length === 0; // 전체 보기: 컴팩트 항목을 같은 순서로 맨 위에 + 나머지 속성 → 클릭 전/후 순서 일치. @@ -1455,11 +1522,20 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible return (
{ if (el) popupElsRef.current.set(pp.id, el); else popupElsRef.current.delete(pp.id); }} + ref={el => { + if (el) { + // RAF 가 위치를 확정(popupPosRef 등록)하기 전인 '신규' 팝업은 숨겨서 mount → + // 화면 밖 라벨의 팝업이 직전(stale) 위치에 한 프레임 번쩍이는 것 방지. + // RAF 가 온스크린 확인 후 visibility:visible + 위치 지정(off 면 계속 숨김→제거). + // visibility 는 RAF 가 명령형으로만 관리(style prop 미포함) → 재렌더에도 유지. + if (!popupPosRef.current.has(pp.id)) el.style.visibility = 'hidden'; + popupElsRef.current.set(pp.id, el); + } else popupElsRef.current.delete(pp.id); + }} onClick={() => setInfoPopups(prev => prev.map(p => p.id === pp.id ? { ...p, expanded: !p.expanded } : p))} title={showFull ? '클릭 → 접기' : '클릭 → 전체 항목'} className="absolute z-40 max-w-[260px] bg-black/90 border border-emerald-500/70 rounded-md shadow-xl text-white text-[11px] px-2 py-1.5 pointer-events-auto cursor-pointer" - style={{ left: Math.min(Math.max(8, pp.sx - 10), (canvasSizeRef.current.w || 9999) - 240), top: Math.min(Math.max(8, pp.sy + 16), (canvasSizeRef.current.h || 9999) - 140) }} + style={{ left: Math.min(Math.max(8, pp.sx - 10), (canvasSizeRef.current.w || 9999) - 240), top: Math.min(Math.max(8, pp.sy + 27), (canvasSizeRef.current.h || 9999) - 140) }} > {!pp.auto && ( @@ -1482,6 +1558,7 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
); })} +
{showPanel && (
@@ -1502,6 +1579,38 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible {showDisplay && (
표시 토글
+ {/* 나침반 타입 — 눈금(analog) / 지도(OSM, 노스업) 선택 */} +
+ 나침반 +
+ + +
+
+ {/* 지도 나침반 배경 — 일반(OSM) / 위성(Esri). 지도 타입일 때만 노출. */} + {compassType === 'map' && ( +
+ 지도 +
+ + +
+
+ )}
{/* 선형/드론궤적/좌측패널 토글은 하단 재생바(VideoPlayer)로 이동. 측점 진단 버튼은 삭제. */} {/* 경로표고·투명도는 드론궤적 ON/OFF 무관하게 항상 표시. */} diff --git a/client/src/components/player/VideoPlayer.tsx b/client/src/components/player/VideoPlayer.tsx index 9d19f53..4950cc1 100644 --- a/client/src/components/player/VideoPlayer.tsx +++ b/client/src/components/player/VideoPlayer.tsx @@ -56,7 +56,7 @@ const VideoPlayer = forwardRef( const { playerRef, loadLocalFile, loadServerStream, switchToHls, getVideoElement } = useVideoPlayer(containerRef); - const { stepForward, stepBackward, fps } = useFrameStep(playerRef); + const { stepForward, stepBackward } = useFrameStep(playerRef); const { currentTime, duration, playing, source, playbackRate, videoReady, videoWidth, videoHeight } = usePlayerStore(); // 커서 매끄러운 이동: @@ -141,6 +141,15 @@ const VideoPlayer = forwardRef( const videoFile = await loadFromFolder(files); if (videoFile) loadLocalFile(videoFile); else console.warn('[geo] 폴더에 영상 파일(mp4/webm)이 없습니다 — 지리정보만 로드'); + // KMZ(POI·구조물 원본) 필수 — 측점/드론은 있는데 KMZ만 빠진 경우 = 데이터 누락. + const { kmzMissing, stations, frames } = useGeoStore.getState(); + if (kmzMissing && (stations.length > 0 || frames.length > 0)) { + alert( + 'KMZ(POI·구조물 원본)가 폴더에 없습니다.\n' + + 'POI·구조물이 표시되지 않습니다. KMZ를 포함해 데이터를 재구축·전달하세요.\n' + + '(측점·드론 정보는 정상 로드되었습니다.)', + ); + } } catch (err) { console.error('[geo] 폴더 로드 실패', err); } @@ -256,10 +265,23 @@ const VideoPlayer = forwardRef( })(); }; - // VIDEO_FPS: 영상 실제 fps (29.97 = 30000/1001). Python SRT FrameCnt 기준과 일치. - // stableFps(VFC 감지)는 31fps 오감지가 있어 프레임 번호 표시에는 사용하지 않음. - const VIDEO_FPS = 30000 / 1001; - const frame = secondsToFrame(currentTime, VIDEO_FPS); + // 영상 실제 fps — 데이터(드론 CSV 마지막 프레임 번호) ÷ 영상 길이(초) 로 자동 산출 후 + // 표준 fps(24/25/29.97/30/50/60 등) 중 가까운 값에 스냅. 영상마다 fps 가 달라도 자동 대응. + // (드론 CSV 엔 시간이 없고 frame_cnt 만 있어, 영상 length 와 결합해야 fps 를 알 수 있다.) + // 데이터/영상길이 미확보 시 29.97 폴백. VFC 자동감지는 31fps 오감지가 있어 사용 안 함. + const effectiveFps = useMemo(() => { + const FALLBACK = 30000 / 1001; + if (!storeFrames.length || !duration || duration <= 0) return FALLBACK; + let maxF = 0; + for (const f of storeFrames) if (f.frame > maxF) maxF = f.frame; + if (maxF <= 0) return FALLBACK; + const raw = maxF / duration; // 마지막 프레임 번호 ÷ 영상 길이(초) + const STD = [23.976, 24, 25, 29.97, 30, 50, 59.94, 60]; + let best = STD[0], bd = Math.abs(raw - STD[0]); + for (const s of STD) { const d = Math.abs(raw - s); if (d < bd) { bd = d; best = s; } } + return bd <= best * 0.1 ? best : raw; // 표준값 ±10% 이내면 스냅, 아니면 원시값 + }, [storeFrames, duration]); + const frame = secondsToFrame(currentTime, effectiveFps); const videoId = source?.kind === 'server' ? source.videoId : null; // 드론 정보(측점진단) — 현재 프레임에 가장 가까운 드론 프레임의 GPS/고도만 표시. @@ -330,7 +352,7 @@ const VideoPlayer = forwardRef(
{/* 영상 프레임 표시 패널 — 프레임 번호를 고정폭(6ch)으로 묶어 자릿수가 늘어도 폭 불변 */} - {secondsToTimecode(currentTime)} | F{frame} | {fps}fps + {secondsToTimecode(currentTime)} | F{frame} | {effectiveFps.toFixed(2)}fps {geoLoaded && ( <> @@ -431,7 +453,7 @@ const VideoPlayer = forwardRef( currentFrame={frame} currentTime={currentTime} timeRef={smoothTimeRef} - fps={fps} + fps={effectiveFps} visible={showStations} videoReady={videoReady} videoWidth={videoWidth} @@ -495,7 +517,7 @@ const VideoPlayer = forwardRef( const input = (e.currentTarget.elements.namedItem('frameInput') as HTMLInputElement); const frameNum = parseInt(input.value, 10); if (!isNaN(frameNum)) { - playerRef.current?.currentTime(frameNum / VIDEO_FPS); + playerRef.current?.currentTime(frameNum / effectiveFps); } input.blur(); }} diff --git a/client/src/hooks/useVideoPlayer.ts b/client/src/hooks/useVideoPlayer.ts index daead22..3f1f8a8 100644 --- a/client/src/hooks/useVideoPlayer.ts +++ b/client/src/hooks/useVideoPlayer.ts @@ -19,6 +19,9 @@ const UNSUPPORTED_CODECS = new Set(['hevc', 'h265', 'hvc1', 'hev1']); export function useVideoPlayer(containerRef: React.RefObject) { const playerRef = useRef(null); const hlsRef = useRef(null); + // 현재 로컬 재생 중인 blob URL. 소스 교체/해제 시점을 결정적으로 관리(이벤트 기반 해제는 교체 시 + // 새 URL까지 조기 revoke 되는 race 가 있어 ref 로 직접 추적한다). + const objectUrlRef = useRef(null); const store = usePlayerStore(); useEffect(() => { @@ -61,6 +64,10 @@ export function useVideoPlayer(containerRef: React.RefObject { hlsRef.current?.destroy(); hlsRef.current = null; + if (objectUrlRef.current) { + URL.revokeObjectURL(objectUrlRef.current); + objectUrlRef.current = null; + } if (playerRef.current && !playerRef.current.isDisposed()) { playerRef.current.dispose(); playerRef.current = null; @@ -77,15 +84,22 @@ export function useVideoPlayer(containerRef: React.RefObject { - URL.revokeObjectURL(objectUrl); - }); + if (prevUrl) URL.revokeObjectURL(prevUrl); + if (wasPlaying) { + // 드롭은 사용자 제스처라 자동재생 정책에 막히지 않음. 준비되면 재생(실패는 무시). + const p = player.play(); + if (p && typeof p.catch === 'function') p.catch(() => {}); + } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -95,6 +109,11 @@ export function useVideoPlayer(containerRef: React.RefObject0) 종점역(최우측 역사)을 - // 트랙 끝(TRACK_END = 미도착 gap 안, 실제 종점 측점 위치)에 두고 미도착 스타일로 표시한다. + // 실제 통과 마커는 '이동/삭제 없이' 직교 통과 위치에 그대로 둔다(드론이 종점 야드에서 162080을 + // 여러 번 지나면 각 통과 자리에 마커 유지 → 검색과 일치). + // 종점(최우측 역사)이 '미도착'(endGapPx>0)이면, 기존 마커를 옮기지 않고 트랙 끝(TRACK_END, + // 실제 종점 측점 위치)에 '미도착' 종점 마커를 하나 '추가'한다. → 드론이 못 간 종점 162080도 + // 끝에 표시되고, 지나간 162080 통과들도 실제 위치에 그대로 남는다. const placeUnreachedTerminal = (marks: StructMark[]): StructMark[] => { if (endGapPx <= 0) return marks; - let hiIdx = -1; let hiPx = -Infinity; - for (let i = 0; i < marks.length; i++) { - if ((marks[i].category === '역사' || marks[i].category === '역') && marks[i].px > hiPx) { - hiPx = marks[i].px; - hiIdx = i; - } + let term: StructMark | null = null; + for (const m of marks) { + if ((m.category === '역사' || m.category === '역') && m.px > hiPx) { hiPx = m.px; term = m; } } - if (hiIdx >= 0) marks[hiIdx] = { ...marks[hiIdx], px: TRACK_END_PX, unreached: true }; + if (term) marks.push({ ...term, px: TRACK_END_PX, unreached: true }); return marks; }; @@ -619,19 +617,12 @@ export function StationBar({ passes = pxPassesAtMileage(projectChainage(s.lat, s.lon, stationLine)); if (!passes.length && match && stationLine) passes = pxPassesAtMileage(projectChainage(match.lat, match.lon, stationLine)); - if (!passes.length && s.lat != null && s.lon != null) passes = pxPassesTo(s.lat, s.lon, off); - if (!passes.length && match) passes = pxPassesTo(match.lat, match.lon, off); - // 역사(역) 통과 보강: 측점선 투영이 불안정한 종점/조차장 구역에서는 측점-기준 탐지가 - // 일부 통과를 놓친다(예: 대전조차장 162k080 재진입은 투영 측점이 ±20m 밖이라 누락). - // 이미 한 통과를 찾으면 위 단락평가로 좌표근접 탐지가 안 돌아 영영 빠지므로, 여기서 - // 좌표근접(pxPassesTo) 통과를 '합집합'으로 합쳐 빠진 재진입까지 마커를 찍는다. - // 같은 통과(거의 같은 px)는 24px 임계로 중복 제거 → 멀리 떨어진 실제 재진입만 추가됨. - if (cat === '역사' && s.lat != null && s.lon != null) { - for (const cp of pxPassesTo(s.lat, s.lon, off)) { - if (!passes.some((p) => Math.abs(p.px - cp.px) < 24)) passes.push(cp); - } - passes.sort((a, b) => a.px - b.px); - } + // 역사(역)은 좌표근접(pxPassesTo) 폴백을 쓰지 않는다 → '드론 실제 측점(chain)이 역 측점과 + // 일치(pxPassesAtMileage)'하는 통과에서만 마커를 찍는다. 그래야: + // ① 좌표만 가까운(측점 다른) 조차장 재진입(드론 162k210인데 대전조차장 162k080)은 안 찍히고, + // ② 진짜 162k080 통과에는 정상적으로 찍힌다. (교량/터널만 좌표근접 폴백 허용.) + if (!passes.length && cat !== '역사' && s.lat != null && s.lon != null) passes = pxPassesTo(s.lat, s.lon, off); + if (!passes.length && cat !== '역사' && match) passes = pxPassesTo(match.lat, match.lon, off); if (!passes.length && s.startMileage != null && s.endMileage != null) { const mid = (s.startMileage + s.endMileage) / 2; const px = pxAtMileage(mid); @@ -643,8 +634,8 @@ export function StationBar({ ? projectChainage(s.lat, s.lon, stationLine) : null; const existTol = routeMeta?.routeInfo?.stationTolerance ?? 20; for (const p of passes) { - // 표시 측점값(stationKmVal)과 그 통과의 실제 측점(p.km)이 허용오차 밖이면(=좌표 반경만으로 - // 잡힌 통과, 그 위치엔 실제로 그 측점값이 없음) 측점값 라벨/검색에서 제외. 마커(동그라미)는 유지. + // 이 통과들은 pxPassesAtMileage(측점 일치) 또는 이정값 기반이라 이미 '실제 측점 일치' 통과. + // (역사는 좌표근접 폴백을 위에서 배제.) kmExists 는 라벨/검색 참고용으로만 유지. const kmExists = stationKmVal == null || Math.abs(p.km - stationKmVal) <= existTol; out.push({ px: p.px, title: s.name, category: cat, km: stationKmVal ?? p.km, kmExists, props: s.props }); } @@ -850,7 +841,7 @@ export function StationBar({ const handleJumpToMileage = useCallback( (km: number) => { const arr = viewedRef.current; - if (!arr.length || duration <= 0) return; + if (!arr.length || duration <= 0) return false; // 커버 측점 범위 산출 (구간 밖 입력은 무시). let lo = Infinity, hi = -Infinity; for (let i = 0; i < arr.length; i++) { @@ -859,18 +850,16 @@ export function StationBar({ if (c > hi) hi = c; } const MARGIN = 20; - if (km < lo - MARGIN || km > hi + MARGIN) return; + if (km < lo - MARGIN || km > hi + MARGIN) return false; - // 1순위: '화면에 보이는 마커(structureMarks)' 중 표시 측점값이 일치하는 것들의 시각. - // → 마커는 좌표근접 탐지까지 포함하므로(조차장 등 chain 이 어긋난 통과 포함) 검색이 모든 - // 동그라미 위치로 이동 가능. px 는 timeAtFrac 로 시각 역변환. 매칭은 표시(10m 반올림) 일치. - const tgt10 = Math.round(km / 10); - let times: number[] = structureMarks - .filter((m) => m.km >= 0 && m.kmExists !== false && Math.round(m.km / 10) === tgt10) - .map((m) => timeAtFrac((m.px - TRACK_START_PX) / timeTrackWidth)); - // 2순위(마커 없음=임의 측점): 연속 체이니지 기준 통과(허용오차 내 연속구간=1통과). - if (!times.length) { - const TOL = routeMeta?.routeInfo?.stationTolerance ?? 20; + // 측점 검색은 '드론의 실제 투영 측점(chain)' 기준으로만 이동한다(마커에 '표시된' 라벨 기준 아님). + // → 입력값과 허용오차(stationTolerance 기본 20m) 안인 프레임들을 '연속구간=1통과'로 묶어, + // 그 통과 지점들로만 순환 이동. 표시 라벨만 162080이고 실제 위치는 다른(예: 162210 조차장 + // 좌표근접) 마커로는 절대 튀지 않는다. + // 통과가 0개면(그 측점을 실제로 안 지남) 이동하지 않고 false → 입력창에 '측점 없음' 안내. + const TOL = routeMeta?.routeInfo?.stationTolerance ?? 20; + const times: number[] = []; + { let i = 0; while (i < arr.length) { if (Math.abs(arr[i].chain - km) < TOL) { @@ -884,15 +873,8 @@ export function StationBar({ i = j; } else i++; } - if (!times.length) { - let best = 0, bd = Infinity; - for (let k = 0; k < arr.length; k++) { - const d = Math.abs(arr[k].chain - km); - if (d < bd) { bd = d; best = k; } - } - times.push(arr[best].time); - } } + if (!times.length) return false; // 시간 오름차순 + 근접 중복 제거(같은 통과가 두 경로/마커로 잡힌 경우). times.sort((a, b) => a - b); const passes: number[] = []; @@ -911,8 +893,9 @@ export function StationBar({ jumpRef.current = { km, time: target }; onSeek(clamp(target, 0, duration)); + return true; }, - [duration, onSeek, routeMeta, currentTime, timeRef, structureMarks, timeAtFrac, timeTrackWidth], + [duration, onSeek, routeMeta, currentTime, timeRef], ); return ( diff --git a/client/src/stationbar/components/PlaybackControls/PlaybackControls.module.scss b/client/src/stationbar/components/PlaybackControls/PlaybackControls.module.scss index e736b1f..d8b9c71 100755 --- a/client/src/stationbar/components/PlaybackControls/PlaybackControls.module.scss +++ b/client/src/stationbar/components/PlaybackControls/PlaybackControls.module.scss @@ -203,6 +203,30 @@ $tool-btn-gap: 1px; // 화면캡처 ↔ 측점선 간격 (47px 버튼이 바 안 } } +/* '측점 없음' 안내 — 입력창 위에 겹쳐 표시(패널 자식이 absolute 라 일반 흐름은 가려짐). */ +.notFound { + position: absolute; + left: $transport-btn-left; + top: 62px; + height: 32.67px; + width: $transport-group-w - $transport-btn-left - $panel-inset; + display: flex; + align-items: center; + justify-content: center; + gap: 4px; + z-index: 6; // mileageInput(z:4) 위 + border-radius: 4px; + background: rgba(20, 0, 0, 0.88); + box-shadow: 0 0 0 2px #000; + color: #ff6b6b; + font-family: 'Noto Sans KR', var(--font-ui); + font-size: 14px; + font-weight: 700; + letter-spacing: 0.02em; + white-space: nowrap; + pointer-events: none; +} + /* 화면캡처·측점선 보기 패널 */ .toolGroup { position: relative; diff --git a/client/src/stationbar/components/PlaybackControls/PlaybackControls.tsx b/client/src/stationbar/components/PlaybackControls/PlaybackControls.tsx index 26fce27..644f2d5 100755 --- a/client/src/stationbar/components/PlaybackControls/PlaybackControls.tsx +++ b/client/src/stationbar/components/PlaybackControls/PlaybackControls.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import type { ChangeEvent, KeyboardEvent } from 'react'; import { parseMileageQuery } from '../../utils/mileage'; import styles from './PlaybackControls.module.scss'; @@ -8,7 +8,8 @@ interface PlaybackControlsProps { onTogglePlay: () => void; onStop: () => void; onCapture: () => void; - onJumpToMileage: (mileage: number) => void; + /** 측점 검색: 이동했으면 true, 이 영상에 없는 측점이라 이동 못 했으면 false. */ + onJumpToMileage: (mileage: number) => boolean; /** 측점선 토글을 외부 상태로 제어할 때 사용(미지정 시 내부 상태). */ lineOn?: boolean; onToggleLine?: () => void; @@ -24,23 +25,34 @@ export function PlaybackControls({ onToggleLine, }: PlaybackControlsProps) { const [query, setQuery] = useState(''); + const [notFound, setNotFound] = useState(false); const [lineOnInternal, setLineOnInternal] = useState(false); const lineOn = lineOnProp ?? lineOnInternal; const toggleLine = onToggleLine ?? (() => setLineOnInternal((v) => !v)); + // '측점 없음' 안내는 잠깐만 표시(1.8초 후 자동 사라짐). + useEffect(() => { + if (!notFound) return; + const t = setTimeout(() => setNotFound(false), 1800); + return () => clearTimeout(t); + }, [notFound]); + const handleQueryChange = (e: ChangeEvent): void => { setQuery(e.target.value); + setNotFound(false); // 다시 입력하면 안내 숨김 }; const handleQueryKeyDown = (e: KeyboardEvent): void => { if (e.key !== 'Enter') return; const mileage = parseMileageQuery(query); // Enter 후에도 입력값 유지 → 같은 측점 재Enter 시 통과방향 다음 위치로 순환 검색. - if (mileage !== null) onJumpToMileage(mileage); + // 이 영상에 없는 측점(이동 실패) 또는 형식 오류면 '측점 없음' 안내를 잠깐 띄운다. + const moved = mileage !== null ? onJumpToMileage(mileage) : false; + setNotFound(!moved); }; // 포커스를 잃으면(다른 곳 클릭 등) 입력값 삭제. - const handleQueryBlur = (): void => setQuery(''); + const handleQueryBlur = (): void => { setQuery(''); setNotFound(false); }; return (
@@ -71,6 +83,9 @@ export function PlaybackControls({ onBlur={handleQueryBlur} placeholder="측점입력" /> + {notFound && ( + ⚠ 측점 없음 + )}