스테이션바 — 측점 검색 재설계(교차 방식·배지 일치)·시점/종점 표기 체계

검색 재설계 (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 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 16:27:30 +09:00
co-authored by Claude Fable 5
parent 87b66d9ed4
commit 0a7044d79b
2 changed files with 326 additions and 71 deletions
+304 -52
View File
@@ -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<string, unknown> | undefined)?.station_lookahead
?? (routeMeta as unknown as Record<string, unknown> | 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,6 +471,14 @@ export function StationBar({
}
}
if (lookDist !== null && lookDist > 0 && isFinite(f.yaw)) {
if (offSide === 1) {
// 종점 이후: 투영이 끝점에 클램프 — 이때 pj.distM 은 횡이격이 아니라
// '종점을 지나온 거리'다. 시선 지점 = 지나온 거리 + 주시거리 →
// 단조 증가 카운트업(통과 직전 시선값 chMax+lookDist 와 연속).
beyondM = pj.distM + lookDist;
viewChain = chMax;
viewOffM = 0; // 항상 '종점+' 표기 (노선밖 분기 방지)
} else {
// 연속 모델: 유효 주시거리 = lookDist − (노선 이격의 초과분).
// 노선 위(이격 ≤ OFF_ROUTE_M): 초과분 0 → 항상 +lookDist (횡 이동에 안정)
// 접근/이탈: 이격이 줄수록 유효 주시거리가 0→lookDist 로 늘어
@@ -374,7 +490,9 @@ export function StationBar({
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);
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 {
@@ -383,12 +501,18 @@ export function StationBar({
}
}
}
} else if (offSide === 1) {
// 오프셋 미설정: 물리 투영이 종점에 클램프 — 끝점까지의 직선거리로 카운트업
beyondM = pj.distM;
}
out[i] = {
frameNum: f.frame,
km: nearestStationKm(f, stations),
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<number>(() => {
if (!ready) return 0;
const idx = viewedIdxAtTime(currentTime);
return idx >= 0 ? viewedRef.current[idx].offSide : 0;
}, [currentTime, ready, viewedIdxAtTime]);
// 종점 초과 거리(m) — 배지 '노선밖 +NNN' 카운트업.
const realBeyondM = useMemo<number>(() => {
if (!ready) return 0;
const idx = viewedIdxAtTime(currentTime);
return idx >= 0 ? viewedRef.current[idx].beyondM : 0;
}, [currentTime, ready, viewedIdxAtTime]);
// 종점역 '미도착' 폭(px) — 영상이 종점역 도착 전 끝날 때, 트랙 우측에 이만큼 비워둔다.
// 영상 타임라인은 [START … ENDendGapPx] 에만 매핑되고, 종점역 마커는 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 (
@@ -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] ? (
<MileageMarker
key={`stkm-${i}`}
marker={{ id: `stkm-${i}`, value: fmtKm(s.km), left: s.px, mileage: 0 }}
marker={{ id: `stkm-${i}`, value: s.kmText ?? fmtKm(s.km), left: s.px, mileage: 0 }}
/>
) : null,
)}