From 0a7044d79b59da115f9d34bbef4e5d5bbf1e9163 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=95=9C=EC=84=B1=EC=9D=BC?= Date: Wed, 15 Jul 2026 16:27:30 +0900 Subject: [PATCH] =?UTF-8?q?=EC=8A=A4=ED=85=8C=EC=9D=B4=EC=85=98=EB=B0=94?= =?UTF-8?q?=20=E2=80=94=20=EC=B8=A1=EC=A0=90=20=EA=B2=80=EC=83=89=20?= =?UTF-8?q?=EC=9E=AC=EC=84=A4=EA=B3=84(=EA=B5=90=EC=B0=A8=20=EB=B0=A9?= =?UTF-8?q?=EC=8B=9D=C2=B7=EB=B0=B0=EC=A7=80=20=EC=9D=BC=EC=B9=98)=C2=B7?= =?UTF-8?q?=EC=8B=9C=EC=A0=90/=EC=A2=85=EC=A0=90=20=ED=91=9C=EA=B8=B0=20?= =?UTF-8?q?=EC=B2=B4=EA=B3=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 검색 재설계 (5단계 진화): - 전 구간(모든 세그먼트) 검색: 현재 세그먼트는 배지가 쓰는 데이터(viewedRef)에서 부호 교차 탐지 → 착지 배지값 == 검색값(오차 0). 타 세그먼트는 CSV 인덱스 (가상 fps·djmd dt 보정) → 세그먼트 자동 전환+시크 - 통과 = 값을 '가로지르는' 모든 순간(선형보간) — 종전 ±20m 창 방식은 호버 구간에서 통과 병합·착지 흔들림을 유발해 제거. 3s 채터 병합, 최근접 폴백(TOL 이내) - 같은 값 재Enter = 무조건 다음 통과(시크 정착 지연에도 순환), 랩어라운드 시점/종점 표기 체계: - 배지: 시점 이전 '시점-NNN'(전환점 0 연속 카운트다운), 종점 이후 '종점+NNN' (지나온 거리+주시거리 — 단조 증가, 통과 순간과 연속), 횡이탈 '노선밖 NNN' - 마크: '종점'(노선 끝 통과 위치), 종점 이후 시작/끝 마크 '종점+NNN' 표기(kmText), 종점 이후 1000m 단위 마크(종점+1000 …), fmtKm 단수 측점 보존(6+998 → 7+000 왜곡 제거) - ViewedPoint: viewOffM·offSide·beyondM 필드 추가 Co-Authored-By: Claude Fable 5 --- client/src/stationbar/StationBar.tsx | 386 +++++++++++++++--- .../components/Timeline/Timeline.tsx | 11 +- 2 files changed, 326 insertions(+), 71 deletions(-) diff --git a/client/src/stationbar/StationBar.tsx b/client/src/stationbar/StationBar.tsx index e81b2f5..759339a 100755 --- a/client/src/stationbar/StationBar.tsx +++ b/client/src/stationbar/StationBar.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { MouseEvent as ReactMouseEvent, RefObject } from 'react'; import type { DroneFrameBasic } from '../utils/geoProjection'; import { useGeoStore } from '../store/geoStore'; +import { DJI_VIRTUAL_FPS } from '../utils/geoData'; import { useSettingsStore, isGradeVisible } from '../store/settingsStore'; import { STAGE_WIDTH, @@ -44,6 +45,11 @@ interface ViewedPoint { viewChain: number; /** 배지 표시 지점(시선/오프셋 지점)의 노선 이격(m) — 오프셋 미설정이면 offM 과 동일. */ viewOffM: number; + /** 노선밖일 때 위치 구분: -1=시점 이전(접근), +1=종점 이후(이탈), 0=노선 중간 횡이탈. */ + offSide: number; + /** 종점 초과 거리(m) — 시선값(클램프 전)이 노선 끝을 넘은 양. 0=범위 안. + * 배지가 끝값(6+998 등)에 얼어붙는 대신 '노선밖 +NNN' 카운트업 표시에 사용. */ + beyondM: number; offM: number; time: number; } @@ -114,6 +120,8 @@ export interface StructMark { unreached?: boolean; /** 표시 측점값이 그 위치에 실제로 존재하는가. false=좌표 반경만으로 잡힌 통과(값 라벨·검색 제외). */ kmExists?: boolean; + /** 측점값 라벨 텍스트 오버라이드 — 종점 초과 마크('종점+136') 등 mileage 형식이 아닌 표기. */ + kmText?: string; } interface StationBarProps { @@ -126,6 +134,8 @@ interface StationBarProps { onStop: () => void; onCapture: () => void; onSeek: (time: number) => void; + /** 전 구간 측점 검색 점프 — 다른 세그먼트의 통과 지점으로 전환+시크(세그index, 로컬초). */ + onSeekSegment?: (segIndex: number, timeSec: number) => void; showStations: boolean; onToggleStations: () => void; } @@ -193,6 +203,7 @@ export function StationBar({ onStop, onCapture, onSeek, + onSeekSegment, showStations, onToggleStations, }: StationBarProps) { @@ -209,6 +220,10 @@ export function StationBar({ [storeStations, storePois], ); const storeFrames = useGeoStore((s) => s.frames); + const fullFrames = useGeoStore((s) => s.fullFrames); + const videoDurations = useGeoStore((s) => s.videoDurations); + const videoIndex = useGeoStore((s) => s.videoIndex); + const djmdDtSec = useGeoStore((s) => s.djmdDtSec); const routeMeta = useGeoStore((s) => s.routeMeta); const directionChanges = useGeoStore((s) => s.directionChanges); // v2.0: 구조물은 geoStore.structures(CSV 03)교량/04)터널/06)구교 + route.json 보정) 를 우선 사용. @@ -246,6 +261,95 @@ export function StationBar({ }; }, [stations]); + // ── 전 구간(모든 세그먼트) 측점 검색 인덱스 ────────────────────────── + // 표시용 precompute(현재 세그먼트)와 별개로, 전체 비행 프레임에서 + // [전역시각 t, 배지값 vc(viewChain 규칙), 이격 off] 을 만들어 검색이 + // 세그먼트 경계를 넘어 진행방향으로 순환하게 한다. (0.2s 표본) + const globalSearch = useMemo<{ t: number; vc: number; off: number }[] | null>(() => { + if (!fullFrames.length || !stationLine || !stations.length) return null; + if (!videoDurations.length || videoDurations.some((d) => d == null)) return null; + const totalDur = (videoDurations as number[]).reduce((a, b) => a + b, 0); + const lastFrame = fullFrames[fullFrames.length - 1].frame; + // 시간기반(DJI) 로그는 frame = 경과초×59.94 가 정확한 환산 — 영상 길이 합으로 나누면 + // CSV 가 영상보다 긴 만큼(착륙 꼬리 등) 시간축이 압축되어 검색이 수 m 어긋난다. + // 프레임 간격이 1 초과(가상 fps 방식)면 상수 사용, 아니면(프레임 기반) 길이 비로 환산. + const stepF = fullFrames.length > 4 ? (fullFrames[4].frame - fullFrames[0].frame) / 4 : 1; + const fps = stepF > 1.5 ? DJI_VIRTUAL_FPS : lastFrame > 0 && totalDur > 0 ? lastFrame / totalDur : VIDEO_FPS; + const raw = (routeMeta?.routeInfo as Record | undefined)?.station_lookahead + ?? (routeMeta as unknown as Record | null)?.station_lookahead; + const lookFixed: number | null = + typeof raw === 'number' && isFinite(raw) && raw > 0 ? raw + : typeof raw === 'string' && raw.trim() !== '' && isFinite(Number(raw)) && Number(raw) > 0 ? Number(raw) + : null; + const lookAuto = typeof raw === 'string' && raw.trim().toLowerCase() === 'auto'; + const zProf = stations + .map((st) => ({ km: stationKm(st.title), z: st.z })) + .filter((p) => p.km >= 0 && p.z > 0) + .sort((a, b) => a.km - b.km); + const zAt = (km: number): number | null => { + if (zProf.length < 2) return null; + if (km <= zProf[0].km) return zProf[0].z; + if (km >= zProf[zProf.length - 1].km) return zProf[zProf.length - 1].z; + let lo = 0, hi = zProf.length - 1; + while (hi - lo > 1) { const m = (lo + hi) >> 1; if (zProf[m].km <= km) lo = m; else hi = m; } + const a = zProf[lo], b = zProf[hi]; + const r = b.km > a.km ? (km - a.km) / (b.km - a.km) : 0; + return a.z + (b.z - a.z) * r; + }; + const MAX_LOOK = 350; + const tangentAt = (km: number): { x: number; y: number } | null => { + const pts = stationLine.pts; + for (let i = 0; i < pts.length - 1; i++) { + const a = pts[i], b = pts[i + 1]; + if ((km - a.km) * (km - b.km) <= 0 && a.km !== b.km) { + const sg = b.km > a.km ? 1 : -1; + const dx = (b.x - a.x) * sg, dy = (b.y - a.y) * sg; + const L = Math.hypot(dx, dy); + return L > 0 ? { x: dx / L, y: dy / L } : null; + } + } + return null; + }; + let chMin = Infinity, chMax = -Infinity; + for (const p of stationLine.pts) { if (p.km < chMin) chMin = p.km; if (p.km > chMax) chMax = p.km; } + const out: { t: number; vc: number; off: number }[] = []; + for (let i = 0; i < fullFrames.length; i += 2) { + const f = fullFrames[i]; + const pj = projectChainageDist(f.lat, f.lon, stationLine); + let vc = pj.chain, off = pj.distM; + if (lookFixed !== null || lookAuto) { + let lookDist: number | null = lookFixed; + if (lookAuto) { + const gz = zAt(pj.chain); + if (gz !== null && isFinite(f.altitude)) { + const agl = f.altitude - gz; + if (agl > 5 && agl < 500) { + const pd = isFinite(f.pitch) ? f.pitch : -15; + lookDist = pd <= -3 ? Math.min(MAX_LOOK, agl / Math.tan((-pd * Math.PI) / 180)) : MAX_LOOK; + } + } + } + if (lookDist !== null && lookDist > 0 && isFinite(f.yaw)) { + const over = Math.max(0, pj.distM - OFF_ROUTE_M); + const eff = lookDist - over; + if (eff >= 0) { + const tg = tangentAt(pj.chain); + if (tg) { + const yr = (f.yaw * Math.PI) / 180; + const dot = Math.sin(yr) * tg.x + Math.cos(yr) * tg.y; + vc = clamp(pj.chain + (dot >= 0 ? 1 : -1) * eff, chMin, chMax); + } + off = Math.min(pj.distM, OFF_ROUTE_M); + } else { + off = OFF_ROUTE_M - eff; + } + } + } + out.push({ t: f.frame / fps, vc, off }); + } + return out; + }, [fullFrames, stationLine, stations, routeMeta, videoDurations]); + // 프레임별 precompute (frames·stations 준비되면 1회). // - km: 드론 GPS 최근접 측점 (배지 표시용, 좌측 RoutePanel 과 동일). // - chain: 측점 폴리라인 투영 연속 체이니지 (구간 방향용, 전환 타이밍 정확). @@ -347,6 +451,10 @@ export function StationBar({ const pj = projectChainageDist(f.lat, f.lon, stationLine); let viewChain = pj.chain; let viewOffM = pj.distM; + // 노선밖 위치 구분 — 투영이 시점/종점에 클램프되면 구간 밖(이전/이후), 아니면 횡이탈. + const offSide = pj.chain <= chMin + 2 ? -1 : pj.chain >= chMax - 2 ? 1 : 0; + // 종점 초과량 — 시선값이 끝에 클램프되어 배지가 얼어붙는 것을 '+NNN' 카운트업으로 대체. + let beyondM = 0; if (lookFixed !== null || lookAuto) { // 주시거리(m): 고정값 또는 자동(짐벌 피치·AGL) let lookDist: number | null = lookFixed; @@ -363,25 +471,39 @@ export function StationBar({ } } if (lookDist !== null && lookDist > 0 && isFinite(f.yaw)) { - // 연속 모델: 유효 주시거리 = lookDist − (노선 이격의 초과분). - // 노선 위(이격 ≤ OFF_ROUTE_M): 초과분 0 → 항상 +lookDist (횡 이동에 안정) - // 접근/이탈: 이격이 줄수록 유효 주시거리가 0→lookDist 로 늘어 - // 배지가 0+000 에서 멈춤·점프 없이 진입 후 +lookDist 까지 매끄럽게 이어진다. - const over = Math.max(0, pj.distM - OFF_ROUTE_M); - const effLook = lookDist - over; - if (effLook >= 0) { - const t = tangentAt(pj.chain); - if (t) { - const yawRad = (f.yaw * Math.PI) / 180; - const dot = Math.sin(yawRad) * t.x + Math.cos(yawRad) * t.y; - viewChain = clamp(pj.chain + (dot >= 0 ? 1 : -1) * effLook, chMin, chMax); - } - viewOffM = Math.min(pj.distM, OFF_ROUTE_M); // 값 표시 상태(노선밖 아님) + if (offSide === 1) { + // 종점 이후: 투영이 끝점에 클램프 — 이때 pj.distM 은 횡이격이 아니라 + // '종점을 지나온 거리'다. 시선 지점 = 지나온 거리 + 주시거리 → + // 단조 증가 카운트업(통과 직전 시선값 chMax+lookDist 와 연속). + beyondM = pj.distM + lookDist; + viewChain = chMax; + viewOffM = 0; // 항상 '종점+' 표기 (노선밖 분기 방지) } else { - // 아직 시선이 노선에 못 미침 — '노선밖 (남은 거리+θ)m' - viewOffM = OFF_ROUTE_M - effLook; + // 연속 모델: 유효 주시거리 = lookDist − (노선 이격의 초과분). + // 노선 위(이격 ≤ OFF_ROUTE_M): 초과분 0 → 항상 +lookDist (횡 이동에 안정) + // 접근/이탈: 이격이 줄수록 유효 주시거리가 0→lookDist 로 늘어 + // 배지가 0+000 에서 멈춤·점프 없이 진입 후 +lookDist 까지 매끄럽게 이어진다. + const over = Math.max(0, pj.distM - OFF_ROUTE_M); + const effLook = lookDist - over; + if (effLook >= 0) { + const t = tangentAt(pj.chain); + if (t) { + const yawRad = (f.yaw * Math.PI) / 180; + const dot = Math.sin(yawRad) * t.x + Math.cos(yawRad) * t.y; + const rawVC = pj.chain + (dot >= 0 ? 1 : -1) * effLook; + beyondM = Math.max(0, rawVC - chMax); + viewChain = clamp(rawVC, chMin, chMax); + } + viewOffM = Math.min(pj.distM, OFF_ROUTE_M); // 값 표시 상태(노선밖 아님) + } else { + // 아직 시선이 노선에 못 미침 — '노선밖 (남은 거리+θ)m' + viewOffM = OFF_ROUTE_M - effLook; + } } } + } else if (offSide === 1) { + // 오프셋 미설정: 물리 투영이 종점에 클램프 — 끝점까지의 직선거리로 카운트업 + beyondM = pj.distM; } out[i] = { frameNum: f.frame, @@ -389,6 +511,8 @@ export function StationBar({ chain: pj.chain, viewChain, viewOffM, + offSide, + beyondM, offM: pj.distM, time: f.frame / videoFps, }; @@ -473,6 +597,20 @@ export function StationBar({ return idx >= 0 ? viewedRef.current[idx].viewOffM : null; }, [currentTime, ready, viewedIdxAtTime]); + // 노선밖 위치 구분(-1 시점 이전 / +1 종점 이후 / 0 횡이탈) — 배지 부호 표기용. + const realOffSide = useMemo(() => { + if (!ready) return 0; + const idx = viewedIdxAtTime(currentTime); + return idx >= 0 ? viewedRef.current[idx].offSide : 0; + }, [currentTime, ready, viewedIdxAtTime]); + + // 종점 초과 거리(m) — 배지 '노선밖 +NNN' 카운트업. + const realBeyondM = useMemo(() => { + if (!ready) return 0; + const idx = viewedIdxAtTime(currentTime); + return idx >= 0 ? viewedRef.current[idx].beyondM : 0; + }, [currentTime, ready, viewedIdxAtTime]); + // 종점역 '미도착' 폭(px) — 영상이 종점역 도착 전 끝날 때, 트랙 우측에 이만큼 비워둔다. // 영상 타임라인은 [START … END−endGapPx] 에만 매핑되고, 종점역 마커는 END(미도착)에 둔다. // 폭 = 미도착거리(m) / 노선길이(m) × 트랙폭 (옵션 2: 실제 비례). 안전상 15%로 상한. @@ -567,12 +705,25 @@ export function StationBar({ // 커서 배지 = 폴더 데이터 기반 연속 체이니지(realChain)를 10m 단위로 표시. 데이터 없으면 빈 문자열. // 드론이 노선(측점 폴리라인)에서 OFF_ROUTE_M 이상 벗어난 구간(접근/이탈)은 체이니지가 // 끝점에 클램프되어 무의미 → "노선밖 ○○m" 로 표기해 측점이 안 변하는 이유를 드러낸다. - const cursorText = - realChain !== null && realChain >= 0 - ? (realOffM !== null && realOffM > OFF_ROUTE_M - ? `노선밖 ${Math.round(realOffM)}m` - : formatMileage1(realChain)) - : ''; + // 구간 밖 표기: 종점 이후 = '종점+NNN' 카운트업, 시점 이전 = '시점-NNN'(남은 거리 감소), + // 노선 중간 횡이탈 = '노선밖 NNN'(부호 없음, 3자리 이상이면 m 생략). + const cursorText = (() => { + if (realChain === null || realChain < 0) return ''; + // 종점 초과 — 끝값(6+998 등) 고정 대신 종점 기준 증가 표시 + if (realBeyondM > 0.5) { + return `종점+${String(Math.round(realBeyondM)).padStart(3, '0')}`; + } + if (realOffM !== null && realOffM > OFF_ROUTE_M) { + const off = Math.round(realOffM); + // 시점-/종점+ 카운트는 '값 표시 전환점'에서 0 이 되도록 진입 여유(OFF_ROUTE_M=60m)를 + // 빼고 표시 — 종전엔 -062 에서 곧장 0+000 으로 건너뛰어 보였음(-001 까지 내려간 뒤 전환). + const rem = Math.max(1, off - OFF_ROUTE_M); + if (realOffSide < 0) return `시점-${String(rem).padStart(3, '0')}`; + if (realOffSide > 0) return `종점+${String(rem).padStart(3, '0')}`; + return `노선밖 ${off}${off < 100 ? 'm' : ''}`; + } + return formatMileage1(realChain); + })(); // viewedRef(프레임→측점 km) 추이로 구간 색·전환점 산출. // km 증가 구간 = dir 1(주황), 감소 구간 = dir -1(하늘색). HYST로 최근접 지터 무시. @@ -771,6 +922,19 @@ export function StationBar({ return marks; }; + // 종점(마지막 측점, 예: 6+998) 마크 — 배지가 노선 끝 값에 도달하는 위치에 표시. + // 이후 구간은 '종점+NNN' 카운트업이므로, 기준점이 되는 통과 위치를 바에 남긴다. + const appendEndMark = (marks: StructMark[]): StructMark[] => { + const valid = stations.filter(st => stationKm(st.title) >= 0); + if (!valid.length || !stationLine) return marks; + const last = valid.reduce((a, b) => (stationKm(a.title) >= stationKm(b.title) ? a : b)); + const endM = stationKm(last.title); + for (const p of pxPassesAtMileage(endM)) { + marks.push({ px: p.px, title: '종점', category: '측점', km: endM, kmExists: true }); + } + return marks; + }; + // 바 양끝 시작/끝 스테이션 — 각 끝 100m(체이니지) 이내에 다른 마크가 없으면 // 영상 시작·끝 지점의 측점값 마크를 추가(양끝이 비어 시작/끝 위치가 모호한 것 방지). // 노선 밖(접근/이탈) 프레임은 체이니지가 클램프되므로 첫/마지막 '노선 위' 프레임 기준. @@ -784,11 +948,17 @@ export function StationBar({ { v: onRoute[onRoute.length - 1], title: '끝' }, ]; for (const e of ends) { - const ch = e.v.viewChain; + // 종점 초과 지점이면 클램프된 끝값(6+998) 대신 '종점+NNN' 로 표기 — + // km 도 초과량을 더해 시작/끝 마크가 서로 100m 중복 제거에 걸리지 않게 한다. + const beyond = e.v.beyondM > 0.5 ? Math.round(e.v.beyondM) : 0; + const ch = Math.round(e.v.viewChain + beyond); if (marks.some(m => Math.abs(m.km - ch) <= 100)) continue; const px = pxAtTime(e.v.time); if (px === null) continue; - marks.push({ px, title: e.title, category: '측점', km: Math.round(ch), kmExists: true }); + marks.push({ + px, title: e.title, category: '측점', km: ch, kmExists: true, + ...(beyond ? { kmText: `종점+${String(beyond).padStart(3, '0')}` } : {}), + }); } return marks; }; @@ -806,6 +976,24 @@ export function StationBar({ marks.push({ px: p.px, title: '', category: '측점', km: m, kmExists: true }); } } + // 종점 이후 구간 — 1000m 단위 초과 지점 마크(종점+1000, +2000 …). + // beyondM 이 해당 값을 가로지르는 프레임(선형보간)에 측점값 라벨만 표시. + const va = viewedRef.current; + let maxBeyond = 0; + for (const v of va) if (v.beyondM > maxBeyond) maxBeyond = v.beyondM; + for (let bk = 1000; bk <= maxBeyond; bk += 1000) { + for (let i = 1; i < va.length; i++) { + const A = va[i - 1], B = va[i]; + if (A.beyondM < bk && B.beyondM >= bk && B.beyondM > A.beyondM) { + const r = (bk - A.beyondM) / (B.beyondM - A.beyondM); + const px = pxAtTime(A.time + (B.time - A.time) * r); + if (px !== null && !marks.some((x) => Math.abs(x.px - px) < 24)) { + marks.push({ px, title: '', category: '측점', km: hi + bk, kmExists: true, kmText: `종점+${bk}` }); + } + break; + } + } + } return marks; }; @@ -901,7 +1089,7 @@ export function StationBar({ out.push({ px: p.px, title: s.name, category: cat, km: stationKmVal ?? p.km, kmExists, props: s.props }); } } - return appendKmStations(appendEndpointStations(placeUnreachedTerminal(appendStartMark(out)))); + return appendKmStations(appendEndpointStations(placeUnreachedTerminal(appendEndMark(appendStartMark(out))))); } // 폴백: POI category 기반 (실좌표 GPS 근접, 모든 통과). @@ -920,7 +1108,7 @@ export function StationBar({ out.push({ px: p.px, title: base, category: info.category, km: stationKmVal ?? p.km, kmExists }); } } - return appendKmStations(appendEndpointStations(placeUnreachedTerminal(appendStartMark(out)))); + return appendKmStations(appendEndpointStations(placeUnreachedTerminal(appendEndMark(appendStartMark(out))))); // eslint-disable-next-line react-hooks/exhaustive-deps }, [ready, duration, storeFrames, pois, stations, pxAtTime, routeMeta, routeDirCh, storeStructures, pxAtMileage, endGapPx, stationLine, videoFps]); @@ -1101,62 +1289,126 @@ export function StationBar({ const jumpRef = useRef<{ km: number; time: number } | null>(null); const handleJumpToMileage = useCallback( (km: number) => { + // ── 전 구간 검색 — 현재 위치에서 진행방향(시간 증가)으로 순환. + // 통과 = 값을 '가로지르는' 순간(부호 교차, 선형보간). 같은 값 연속 Enter = 다음 통과. + // ① 현재 세그먼트: 스테이션바(배지)가 쓰는 바로 그 데이터(viewedRef)에서 탐지 — + // 착지 시 배지값 == 검색값 (정의상 오차 0). + // ② 다른 세그먼트: 전 구간 CSV 인덱스(djmd dt 보정) — 현 세그 범위는 ①이 대체. + if (globalSearch && onSeekSegment) { + const TOLg = routeMeta?.routeInfo?.stationTolerance ?? 20; + const g = globalSearch; + const offs: number[] = [0]; + for (const d of videoDurations) offs.push(offs[offs.length - 1] + (d ?? 0)); + const dtC = djmdDtSec; // 영상시간 = CSV시간 − dtC + const segBase = offs[videoIndex] ?? 0; + const segEnd = segBase + (videoDurations[videoIndex] ?? 0); + const passes: number[] = []; // 영상 전역 시각(초) + // ① 현재 세그먼트 — 배지 데이터(viewedRef) 교차 + const arrV = viewedRef.current; + for (let i2 = 1; i2 < arrV.length; i2++) { + const A = arrV[i2 - 1], B = arrV[i2]; + if (A.viewOffM > OFF_ROUTE_M || B.viewOffM > OFF_ROUTE_M) continue; + const da = A.viewChain - km, db = B.viewChain - km; + if (da === 0) passes.push(segBase + A.time); + else if (da * db < 0) { + passes.push(segBase + A.time + ((B.time - A.time) * (km - A.viewChain)) / (B.viewChain - A.viewChain)); + } + } + // ② 다른 세그먼트 — CSV 인덱스 교차(dt 보정), 현 세그 범위 제외 + for (let gi = 1; gi < g.length; gi++) { + const A = g[gi - 1], B = g[gi]; + if (A.off > OFF_ROUTE_M || B.off > OFF_ROUTE_M) continue; + if (B.t - A.t > 1) continue; + const da = A.vc - km, db = B.vc - km; + let tc: number | null = null; + if (da === 0) tc = A.t; + else if (da * db < 0) tc = A.t + ((B.t - A.t) * (km - A.vc)) / (B.vc - A.vc); + if (tc === null) continue; + const tv = tc - dtC; + if (tv >= segBase - 0.5 && tv <= segEnd + 0.5) continue; + passes.push(tv); + } + // 교차가 전혀 없으면 최근접 폴백(현 세그 우선, TOL 이내) + if (!passes.length) { + let bt = -1, bd = TOLg; + for (let i2 = 0; i2 < arrV.length; i2++) { + if (arrV[i2].viewOffM > OFF_ROUTE_M) continue; + const d = Math.abs(arrV[i2].viewChain - km); + if (d < bd) { bd = d; bt = segBase + arrV[i2].time; } + } + if (bt < 0) { + for (let gi = 0; gi < g.length; gi++) { + if (g[gi].off > OFF_ROUTE_M) continue; + const d = Math.abs(g[gi].vc - km); + if (d < bd) { bd = d; bt = g[gi].t - dtC; } + } + } + if (bt < 0) return false; + passes.push(bt); + } + passes.sort((a, b) => a - b); + // 교차 채터(정지·GPS 미세 요동) 병합 — 3초 이내는 한 통과 + const merged: number[] = []; + for (const t of passes) if (!merged.length || t - merged[merged.length - 1] > 3) merged.push(t); + console.log(`[검색] ${km}m: 통과 ${merged.length}곳 — ${merged.map((t) => t.toFixed(1) + 's').join(', ')}`); + const liveGlobal = segBase + (timeRef?.current ?? currentTime); + // 같은 값 재Enter = 무조건 '다음 통과'(시크 정착 지연에도 순환 유지) + const sameKm = !!jumpRef.current && Math.abs(jumpRef.current.km - km) < 1e-6; + const baseT = sameKm ? Math.max(jumpRef.current!.time, liveGlobal - 0.25) : liveGlobal; + const target = merged.find((t) => t > baseT + 1e-3) ?? merged[0]; + let seg = 0; + while (seg + 1 < videoDurations.length && offs[seg + 1] <= target) seg++; + const segDur = videoDurations[seg]; + const local = Math.max(0, Math.min(target - offs[seg], (segDur ?? Infinity) - 0.05)); + jumpRef.current = { km, time: target }; + if (seg === videoIndex) onSeek(local); + else onSeekSegment(seg, local); + return true; + } + + // ── 폴백(전 구간 인덱스 불가: 영상 길이 미측정 등) — 현 세그먼트 배지 데이터에서 + // 부호 교차 탐지(위 ① 과 동일 방식). 종전 ±20m '창' 방식은 오차·병합 문제로 제거됨. const arr = viewedRef.current; if (!arr.length || duration <= 0) return false; - // 커버 측점 범위 산출 (구간 밖 입력은 무시). - let lo = Infinity, hi = -Infinity; - for (let i = 0; i < arr.length; i++) { - const c = arr[i].chain; - if (c < lo) lo = c; - if (c > hi) hi = c; - } - const MARGIN = 20; - if (km < lo - MARGIN || km > hi + MARGIN) return false; - - // 측점 검색은 '드론의 실제 투영 측점(chain)' 기준으로만 이동한다(마커에 '표시된' 라벨 기준 아님). - // → 입력값과 허용오차(stationTolerance 기본 20m) 안인 프레임들을 '연속구간=1통과'로 묶어, - // 그 통과 지점들로만 순환 이동. 표시 라벨만 162080이고 실제 위치는 다른(예: 162210 조차장 - // 좌표근접) 마커로는 절대 튀지 않는다. - // 통과가 0개면(그 측점을 실제로 안 지남) 이동하지 않고 false → 입력창에 '측점 없음' 안내. + const lookActive = arr.some((v) => Math.abs(v.viewChain - v.chain) > 1); + const okF = (v: ViewedPoint): boolean => !lookActive || v.viewOffM <= OFF_ROUTE_M; const TOL = routeMeta?.routeInfo?.stationTolerance ?? 20; const times: number[] = []; - { - let i = 0; - while (i < arr.length) { - if (Math.abs(arr[i].chain - km) < TOL) { - let j = i, bj = i, bd = Math.abs(arr[i].chain - km); - while (j < arr.length && Math.abs(arr[j].chain - km) < TOL) { - const d = Math.abs(arr[j].chain - km); - if (d < bd) { bd = d; bj = j; } - j++; - } - times.push(arr[bj].time); - i = j; - } else i++; + for (let i = 1; i < arr.length; i++) { + const A = arr[i - 1], B = arr[i]; + if (!okF(A) || !okF(B)) continue; + const da = A.viewChain - km, db = B.viewChain - km; + if (da === 0) times.push(A.time); + else if (da * db < 0) { + times.push(A.time + ((B.time - A.time) * (km - A.viewChain)) / (B.viewChain - A.viewChain)); } } - if (!times.length) return false; - // 시간 오름차순 + 근접 중복 제거(같은 통과가 두 경로/마커로 잡힌 경우). + // 교차 없음 → 최근접 폴백(TOL 이내), 그것도 없으면 '측점 없음' + if (!times.length) { + let bt = -1, bd = TOL; + for (const v of arr) { + if (!okF(v)) continue; + const d = Math.abs(v.viewChain - km); + if (d < bd) { bd = d; bt = v.time; } + } + if (bt < 0) return false; + times.push(bt); + } times.sort((a, b) => a - b); const passes: number[] = []; - for (const t of times) if (!passes.length || t - passes[passes.length - 1] > 0.3) passes.push(t); + for (const t of times) if (!passes.length || t - passes[passes.length - 1] > 3) passes.push(t); - // 기준 시각: 같은 측점을 '직전 점프 위치 그대로'에서 다시 Enter 하면 연속(다음 통과), - // 재생/클릭으로 커서가 움직였거나 다른 측점이면 현재 커서(라이브) 시각에서 새로 시작. const liveT = timeRef?.current ?? currentTime; - const same = !!jumpRef.current - && Math.abs(jumpRef.current.km - km) < 1e-6 - && Math.abs(liveT - jumpRef.current.time) < 0.1; - const baseT = same ? jumpRef.current!.time : liveT; - // 통과방향(시간 증가)으로 baseT 다음 통과. 끝까지 가면 처음으로 순환. - const EPS = 1e-3; - const target = passes.find((t) => t > baseT + EPS) ?? passes[0]; + // 같은 값 재Enter = 무조건 다음 통과(시크 정착 지연에도 순환 유지), 끝나면 처음으로. + const sameKm = !!jumpRef.current && Math.abs(jumpRef.current.km - km) < 1e-6; + const baseT = sameKm ? Math.max(jumpRef.current!.time, liveT - 0.25) : liveT; + const target = passes.find((t) => t > baseT + 1e-3) ?? passes[0]; jumpRef.current = { km, time: target }; onSeek(clamp(target, 0, duration)); return true; }, - [duration, onSeek, routeMeta, currentTime, timeRef], + [duration, onSeek, onSeekSegment, routeMeta, currentTime, timeRef, globalSearch, videoDurations, videoIndex], ); return ( diff --git a/client/src/stationbar/components/Timeline/Timeline.tsx b/client/src/stationbar/components/Timeline/Timeline.tsx index 451dd99..0478ded 100755 --- a/client/src/stationbar/components/Timeline/Timeline.tsx +++ b/client/src/stationbar/components/Timeline/Timeline.tsx @@ -14,10 +14,13 @@ import { cssVars, px } from '../../utils/cssVars'; import { MileageMarker } from '../MileageMarker/MileageMarker'; import styles from './Timeline.module.scss'; -/** 미터값 → "158+160" (10m 단위). 음수면 빈 문자열. */ +/** 미터값 → "158+160". 10m 배수 근처(±2m)는 10m 로 스냅(GPS 잡음 정리), + * 아니면 1m 그대로 — 끝 측점이 6+998 처럼 단수일 때 7+000 으로 왜곡되지 않게. */ function fmtKm(m: number): string { if (m < 0) return ''; - const r = Math.round(m / 10) * 10; + const r1 = Math.round(m); + const r10 = Math.round(r1 / 10) * 10; + const r = Math.abs(r1 - r10) < 2 ? r10 : r1; return `${Math.floor(r / 1000)}+${String(r % 1000).padStart(3, '0')}`; } @@ -129,7 +132,7 @@ export function Timeline({ // 측점값 없음(s.km<0) 또는 '그 위치에 실제 측점값이 존재하지 않음'(kmExists=false, // 좌표 반경만으로 잡힌 통과)은 측점값 라벨을 그리지 않는다. (마커 동그라미는 별도로 유지) if (s.km < 0 || s.kmExists === false) continue; - const w = labelW(fmtKm(s.km)); + const w = labelW(s.kmText ?? fmtKm(s.km)); if (s.px - lastPx >= (lastW + w) / 2) { showMileage[i] = true; lastPx = s.px; @@ -295,7 +298,7 @@ export function Timeline({ s.km >= 0 && showMileage[i] ? ( ) : null, )}