스테이션바 — 측점 검색 재설계(교차 방식·배지 일치)·시점/종점 표기 체계
검색 재설계 (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:
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|||||||
import type { MouseEvent as ReactMouseEvent, RefObject } from 'react';
|
import type { MouseEvent as ReactMouseEvent, RefObject } from 'react';
|
||||||
import type { DroneFrameBasic } from '../utils/geoProjection';
|
import type { DroneFrameBasic } from '../utils/geoProjection';
|
||||||
import { useGeoStore } from '../store/geoStore';
|
import { useGeoStore } from '../store/geoStore';
|
||||||
|
import { DJI_VIRTUAL_FPS } from '../utils/geoData';
|
||||||
import { useSettingsStore, isGradeVisible } from '../store/settingsStore';
|
import { useSettingsStore, isGradeVisible } from '../store/settingsStore';
|
||||||
import {
|
import {
|
||||||
STAGE_WIDTH,
|
STAGE_WIDTH,
|
||||||
@@ -44,6 +45,11 @@ interface ViewedPoint {
|
|||||||
viewChain: number;
|
viewChain: number;
|
||||||
/** 배지 표시 지점(시선/오프셋 지점)의 노선 이격(m) — 오프셋 미설정이면 offM 과 동일. */
|
/** 배지 표시 지점(시선/오프셋 지점)의 노선 이격(m) — 오프셋 미설정이면 offM 과 동일. */
|
||||||
viewOffM: number;
|
viewOffM: number;
|
||||||
|
/** 노선밖일 때 위치 구분: -1=시점 이전(접근), +1=종점 이후(이탈), 0=노선 중간 횡이탈. */
|
||||||
|
offSide: number;
|
||||||
|
/** 종점 초과 거리(m) — 시선값(클램프 전)이 노선 끝을 넘은 양. 0=범위 안.
|
||||||
|
* 배지가 끝값(6+998 등)에 얼어붙는 대신 '노선밖 +NNN' 카운트업 표시에 사용. */
|
||||||
|
beyondM: number;
|
||||||
offM: number;
|
offM: number;
|
||||||
time: number;
|
time: number;
|
||||||
}
|
}
|
||||||
@@ -114,6 +120,8 @@ export interface StructMark {
|
|||||||
unreached?: boolean;
|
unreached?: boolean;
|
||||||
/** 표시 측점값이 그 위치에 실제로 존재하는가. false=좌표 반경만으로 잡힌 통과(값 라벨·검색 제외). */
|
/** 표시 측점값이 그 위치에 실제로 존재하는가. false=좌표 반경만으로 잡힌 통과(값 라벨·검색 제외). */
|
||||||
kmExists?: boolean;
|
kmExists?: boolean;
|
||||||
|
/** 측점값 라벨 텍스트 오버라이드 — 종점 초과 마크('종점+136') 등 mileage 형식이 아닌 표기. */
|
||||||
|
kmText?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface StationBarProps {
|
interface StationBarProps {
|
||||||
@@ -126,6 +134,8 @@ interface StationBarProps {
|
|||||||
onStop: () => void;
|
onStop: () => void;
|
||||||
onCapture: () => void;
|
onCapture: () => void;
|
||||||
onSeek: (time: number) => void;
|
onSeek: (time: number) => void;
|
||||||
|
/** 전 구간 측점 검색 점프 — 다른 세그먼트의 통과 지점으로 전환+시크(세그index, 로컬초). */
|
||||||
|
onSeekSegment?: (segIndex: number, timeSec: number) => void;
|
||||||
showStations: boolean;
|
showStations: boolean;
|
||||||
onToggleStations: () => void;
|
onToggleStations: () => void;
|
||||||
}
|
}
|
||||||
@@ -193,6 +203,7 @@ export function StationBar({
|
|||||||
onStop,
|
onStop,
|
||||||
onCapture,
|
onCapture,
|
||||||
onSeek,
|
onSeek,
|
||||||
|
onSeekSegment,
|
||||||
showStations,
|
showStations,
|
||||||
onToggleStations,
|
onToggleStations,
|
||||||
}: StationBarProps) {
|
}: StationBarProps) {
|
||||||
@@ -209,6 +220,10 @@ export function StationBar({
|
|||||||
[storeStations, storePois],
|
[storeStations, storePois],
|
||||||
);
|
);
|
||||||
const storeFrames = useGeoStore((s) => s.frames);
|
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 routeMeta = useGeoStore((s) => s.routeMeta);
|
||||||
const directionChanges = useGeoStore((s) => s.directionChanges);
|
const directionChanges = useGeoStore((s) => s.directionChanges);
|
||||||
// v2.0: 구조물은 geoStore.structures(CSV 03)교량/04)터널/06)구교 + route.json 보정) 를 우선 사용.
|
// v2.0: 구조물은 geoStore.structures(CSV 03)교량/04)터널/06)구교 + route.json 보정) 를 우선 사용.
|
||||||
@@ -246,6 +261,95 @@ export function StationBar({
|
|||||||
};
|
};
|
||||||
}, [stations]);
|
}, [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회).
|
// 프레임별 precompute (frames·stations 준비되면 1회).
|
||||||
// - km: 드론 GPS 최근접 측점 (배지 표시용, 좌측 RoutePanel 과 동일).
|
// - km: 드론 GPS 최근접 측점 (배지 표시용, 좌측 RoutePanel 과 동일).
|
||||||
// - chain: 측점 폴리라인 투영 연속 체이니지 (구간 방향용, 전환 타이밍 정확).
|
// - chain: 측점 폴리라인 투영 연속 체이니지 (구간 방향용, 전환 타이밍 정확).
|
||||||
@@ -347,6 +451,10 @@ export function StationBar({
|
|||||||
const pj = projectChainageDist(f.lat, f.lon, stationLine);
|
const pj = projectChainageDist(f.lat, f.lon, stationLine);
|
||||||
let viewChain = pj.chain;
|
let viewChain = pj.chain;
|
||||||
let viewOffM = pj.distM;
|
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) {
|
if (lookFixed !== null || lookAuto) {
|
||||||
// 주시거리(m): 고정값 또는 자동(짐벌 피치·AGL)
|
// 주시거리(m): 고정값 또는 자동(짐벌 피치·AGL)
|
||||||
let lookDist: number | null = lookFixed;
|
let lookDist: number | null = lookFixed;
|
||||||
@@ -363,25 +471,39 @@ export function StationBar({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (lookDist !== null && lookDist > 0 && isFinite(f.yaw)) {
|
if (lookDist !== null && lookDist > 0 && isFinite(f.yaw)) {
|
||||||
// 연속 모델: 유효 주시거리 = lookDist − (노선 이격의 초과분).
|
if (offSide === 1) {
|
||||||
// 노선 위(이격 ≤ OFF_ROUTE_M): 초과분 0 → 항상 +lookDist (횡 이동에 안정)
|
// 종점 이후: 투영이 끝점에 클램프 — 이때 pj.distM 은 횡이격이 아니라
|
||||||
// 접근/이탈: 이격이 줄수록 유효 주시거리가 0→lookDist 로 늘어
|
// '종점을 지나온 거리'다. 시선 지점 = 지나온 거리 + 주시거리 →
|
||||||
// 배지가 0+000 에서 멈춤·점프 없이 진입 후 +lookDist 까지 매끄럽게 이어진다.
|
// 단조 증가 카운트업(통과 직전 시선값 chMax+lookDist 와 연속).
|
||||||
const over = Math.max(0, pj.distM - OFF_ROUTE_M);
|
beyondM = pj.distM + lookDist;
|
||||||
const effLook = lookDist - over;
|
viewChain = chMax;
|
||||||
if (effLook >= 0) {
|
viewOffM = 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); // 값 표시 상태(노선밖 아님)
|
|
||||||
} else {
|
} else {
|
||||||
// 아직 시선이 노선에 못 미침 — '노선밖 (남은 거리+θ)m'
|
// 연속 모델: 유효 주시거리 = lookDist − (노선 이격의 초과분).
|
||||||
viewOffM = OFF_ROUTE_M - effLook;
|
// 노선 위(이격 ≤ 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] = {
|
out[i] = {
|
||||||
frameNum: f.frame,
|
frameNum: f.frame,
|
||||||
@@ -389,6 +511,8 @@ export function StationBar({
|
|||||||
chain: pj.chain,
|
chain: pj.chain,
|
||||||
viewChain,
|
viewChain,
|
||||||
viewOffM,
|
viewOffM,
|
||||||
|
offSide,
|
||||||
|
beyondM,
|
||||||
offM: pj.distM,
|
offM: pj.distM,
|
||||||
time: f.frame / videoFps,
|
time: f.frame / videoFps,
|
||||||
};
|
};
|
||||||
@@ -473,6 +597,20 @@ export function StationBar({
|
|||||||
return idx >= 0 ? viewedRef.current[idx].viewOffM : null;
|
return idx >= 0 ? viewedRef.current[idx].viewOffM : null;
|
||||||
}, [currentTime, ready, viewedIdxAtTime]);
|
}, [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) — 영상이 종점역 도착 전 끝날 때, 트랙 우측에 이만큼 비워둔다.
|
// 종점역 '미도착' 폭(px) — 영상이 종점역 도착 전 끝날 때, 트랙 우측에 이만큼 비워둔다.
|
||||||
// 영상 타임라인은 [START … END−endGapPx] 에만 매핑되고, 종점역 마커는 END(미도착)에 둔다.
|
// 영상 타임라인은 [START … END−endGapPx] 에만 매핑되고, 종점역 마커는 END(미도착)에 둔다.
|
||||||
// 폭 = 미도착거리(m) / 노선길이(m) × 트랙폭 (옵션 2: 실제 비례). 안전상 15%로 상한.
|
// 폭 = 미도착거리(m) / 노선길이(m) × 트랙폭 (옵션 2: 실제 비례). 안전상 15%로 상한.
|
||||||
@@ -567,12 +705,25 @@ export function StationBar({
|
|||||||
// 커서 배지 = 폴더 데이터 기반 연속 체이니지(realChain)를 10m 단위로 표시. 데이터 없으면 빈 문자열.
|
// 커서 배지 = 폴더 데이터 기반 연속 체이니지(realChain)를 10m 단위로 표시. 데이터 없으면 빈 문자열.
|
||||||
// 드론이 노선(측점 폴리라인)에서 OFF_ROUTE_M 이상 벗어난 구간(접근/이탈)은 체이니지가
|
// 드론이 노선(측점 폴리라인)에서 OFF_ROUTE_M 이상 벗어난 구간(접근/이탈)은 체이니지가
|
||||||
// 끝점에 클램프되어 무의미 → "노선밖 ○○m" 로 표기해 측점이 안 변하는 이유를 드러낸다.
|
// 끝점에 클램프되어 무의미 → "노선밖 ○○m" 로 표기해 측점이 안 변하는 이유를 드러낸다.
|
||||||
const cursorText =
|
// 구간 밖 표기: 종점 이후 = '종점+NNN' 카운트업, 시점 이전 = '시점-NNN'(남은 거리 감소),
|
||||||
realChain !== null && realChain >= 0
|
// 노선 중간 횡이탈 = '노선밖 NNN'(부호 없음, 3자리 이상이면 m 생략).
|
||||||
? (realOffM !== null && realOffM > OFF_ROUTE_M
|
const cursorText = (() => {
|
||||||
? `노선밖 ${Math.round(realOffM)}m`
|
if (realChain === null || realChain < 0) return '';
|
||||||
: formatMileage1(realChain))
|
// 종점 초과 — 끝값(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) 추이로 구간 색·전환점 산출.
|
// viewedRef(프레임→측점 km) 추이로 구간 색·전환점 산출.
|
||||||
// km 증가 구간 = dir 1(주황), 감소 구간 = dir -1(하늘색). HYST로 최근접 지터 무시.
|
// km 증가 구간 = dir 1(주황), 감소 구간 = dir -1(하늘색). HYST로 최근접 지터 무시.
|
||||||
@@ -771,6 +922,19 @@ export function StationBar({
|
|||||||
return marks;
|
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(체이니지) 이내에 다른 마크가 없으면
|
// 바 양끝 시작/끝 스테이션 — 각 끝 100m(체이니지) 이내에 다른 마크가 없으면
|
||||||
// 영상 시작·끝 지점의 측점값 마크를 추가(양끝이 비어 시작/끝 위치가 모호한 것 방지).
|
// 영상 시작·끝 지점의 측점값 마크를 추가(양끝이 비어 시작/끝 위치가 모호한 것 방지).
|
||||||
// 노선 밖(접근/이탈) 프레임은 체이니지가 클램프되므로 첫/마지막 '노선 위' 프레임 기준.
|
// 노선 밖(접근/이탈) 프레임은 체이니지가 클램프되므로 첫/마지막 '노선 위' 프레임 기준.
|
||||||
@@ -784,11 +948,17 @@ export function StationBar({
|
|||||||
{ v: onRoute[onRoute.length - 1], title: '끝' },
|
{ v: onRoute[onRoute.length - 1], title: '끝' },
|
||||||
];
|
];
|
||||||
for (const e of ends) {
|
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;
|
if (marks.some(m => Math.abs(m.km - ch) <= 100)) continue;
|
||||||
const px = pxAtTime(e.v.time);
|
const px = pxAtTime(e.v.time);
|
||||||
if (px === null) continue;
|
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;
|
return marks;
|
||||||
};
|
};
|
||||||
@@ -806,6 +976,24 @@ export function StationBar({
|
|||||||
marks.push({ px: p.px, title: '', category: '측점', km: m, kmExists: true });
|
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;
|
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 });
|
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 근접, 모든 통과).
|
// 폴백: 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 });
|
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
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [ready, duration, storeFrames, pois, stations, pxAtTime, routeMeta, routeDirCh, storeStructures, pxAtMileage, endGapPx, stationLine, videoFps]);
|
}, [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 jumpRef = useRef<{ km: number; time: number } | null>(null);
|
||||||
const handleJumpToMileage = useCallback(
|
const handleJumpToMileage = useCallback(
|
||||||
(km: number) => {
|
(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;
|
const arr = viewedRef.current;
|
||||||
if (!arr.length || duration <= 0) return false;
|
if (!arr.length || duration <= 0) return false;
|
||||||
// 커버 측점 범위 산출 (구간 밖 입력은 무시).
|
const lookActive = arr.some((v) => Math.abs(v.viewChain - v.chain) > 1);
|
||||||
let lo = Infinity, hi = -Infinity;
|
const okF = (v: ViewedPoint): boolean => !lookActive || v.viewOffM <= OFF_ROUTE_M;
|
||||||
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 TOL = routeMeta?.routeInfo?.stationTolerance ?? 20;
|
const TOL = routeMeta?.routeInfo?.stationTolerance ?? 20;
|
||||||
const times: number[] = [];
|
const times: number[] = [];
|
||||||
{
|
for (let i = 1; i < arr.length; i++) {
|
||||||
let i = 0;
|
const A = arr[i - 1], B = arr[i];
|
||||||
while (i < arr.length) {
|
if (!okF(A) || !okF(B)) continue;
|
||||||
if (Math.abs(arr[i].chain - km) < TOL) {
|
const da = A.viewChain - km, db = B.viewChain - km;
|
||||||
let j = i, bj = i, bd = Math.abs(arr[i].chain - km);
|
if (da === 0) times.push(A.time);
|
||||||
while (j < arr.length && Math.abs(arr[j].chain - km) < TOL) {
|
else if (da * db < 0) {
|
||||||
const d = Math.abs(arr[j].chain - km);
|
times.push(A.time + ((B.time - A.time) * (km - A.viewChain)) / (B.viewChain - A.viewChain));
|
||||||
if (d < bd) { bd = d; bj = j; }
|
|
||||||
j++;
|
|
||||||
}
|
|
||||||
times.push(arr[bj].time);
|
|
||||||
i = j;
|
|
||||||
} else i++;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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);
|
times.sort((a, b) => a - b);
|
||||||
const passes: number[] = [];
|
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 liveT = timeRef?.current ?? currentTime;
|
||||||
const same = !!jumpRef.current
|
// 같은 값 재Enter = 무조건 다음 통과(시크 정착 지연에도 순환 유지), 끝나면 처음으로.
|
||||||
&& Math.abs(jumpRef.current.km - km) < 1e-6
|
const sameKm = !!jumpRef.current && Math.abs(jumpRef.current.km - km) < 1e-6;
|
||||||
&& Math.abs(liveT - jumpRef.current.time) < 0.1;
|
const baseT = sameKm ? Math.max(jumpRef.current!.time, liveT - 0.25) : liveT;
|
||||||
const baseT = same ? jumpRef.current!.time : liveT;
|
const target = passes.find((t) => t > baseT + 1e-3) ?? passes[0];
|
||||||
// 통과방향(시간 증가)으로 baseT 다음 통과. 끝까지 가면 처음으로 순환.
|
|
||||||
const EPS = 1e-3;
|
|
||||||
const target = passes.find((t) => t > baseT + EPS) ?? passes[0];
|
|
||||||
|
|
||||||
jumpRef.current = { km, time: target };
|
jumpRef.current = { km, time: target };
|
||||||
onSeek(clamp(target, 0, duration));
|
onSeek(clamp(target, 0, duration));
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
[duration, onSeek, routeMeta, currentTime, timeRef],
|
[duration, onSeek, onSeekSegment, routeMeta, currentTime, timeRef, globalSearch, videoDurations, videoIndex],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -14,10 +14,13 @@ import { cssVars, px } from '../../utils/cssVars';
|
|||||||
import { MileageMarker } from '../MileageMarker/MileageMarker';
|
import { MileageMarker } from '../MileageMarker/MileageMarker';
|
||||||
import styles from './Timeline.module.scss';
|
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 {
|
function fmtKm(m: number): string {
|
||||||
if (m < 0) return '';
|
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')}`;
|
return `${Math.floor(r / 1000)}+${String(r % 1000).padStart(3, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,7 +132,7 @@ export function Timeline({
|
|||||||
// 측점값 없음(s.km<0) 또는 '그 위치에 실제 측점값이 존재하지 않음'(kmExists=false,
|
// 측점값 없음(s.km<0) 또는 '그 위치에 실제 측점값이 존재하지 않음'(kmExists=false,
|
||||||
// 좌표 반경만으로 잡힌 통과)은 측점값 라벨을 그리지 않는다. (마커 동그라미는 별도로 유지)
|
// 좌표 반경만으로 잡힌 통과)은 측점값 라벨을 그리지 않는다. (마커 동그라미는 별도로 유지)
|
||||||
if (s.km < 0 || s.kmExists === false) continue;
|
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) {
|
if (s.px - lastPx >= (lastW + w) / 2) {
|
||||||
showMileage[i] = true;
|
showMileage[i] = true;
|
||||||
lastPx = s.px;
|
lastPx = s.px;
|
||||||
@@ -295,7 +298,7 @@ export function Timeline({
|
|||||||
s.km >= 0 && showMileage[i] ? (
|
s.km >= 0 && showMileage[i] ? (
|
||||||
<MileageMarker
|
<MileageMarker
|
||||||
key={`stkm-${i}`}
|
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,
|
) : null,
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user