스테이션바 시선 연동(station_lookahead)·팬 지연 보정·1km 측점 마크

- station_lookahead(route.json): 배지 전방 오프셋 — 미설정/0=드론 연직(기존),
  N=진행방향 +N(m), "auto"=짐벌 피치·AGL 기반 자동. 연속 모델(유효 주시거리 =
  N − 노선이격 초과분)로 접근 구간에서 멈춤·점프 없이 측점값 연속 진행
- 노선밖 판정도 시선 기준: 화면이 노선을 비추기 시작하면 배지가 측점값으로
  조기 전환, '노선밖 ○m' 도 시선 지점 기준
- 시점·시설물 마크와 배지 기준 통일(viewChain): 오프셋 활성 시 물리(드론 연직)
  폴백 금지 — 구간 밖 시설물은 해당 세그먼트 바에 미표시(귀덕천교 사례),
  세그먼트별 진단 로그(표본 Δ) 추가
- 스테이션바 1km(1+000) 단위 측점 마크: 측점값 라벨만 표시(이름 라벨·그룹핑 제외),
  기존 마크와 겹침 시 생략 + 양끝 100m 내 시설물 없으면 시작/끝 스테이션 표시
- 팬(회전) 지연 이질감 보정: 프레임 동기 데이터의 POI 라벨 화면 EMA 우회(즉시 투영)
  + 자세 선행(attLeadSec) 파라미터 — 짐벌 로그 기록 지연 상쇄(위치는 유지,
  자세만 앞선 시점 참조), 카메라 패널 슬라이더(-1~+1s)
- 배지 시선 중심 자동 모드: AGL/tan|짐벌피치| 전방 주시거리(상한 350m),
  진행방향 = 카메라 yaw·노선 접선 내적
- 기타: sen H 최대 100mm, F2 개발자 모드 토글(직전 커밋 누락분 아님 — 이번 범위)
- 데이터 작업(코드 외): 경로 1-2/1-3/2 카메라·화면설정 산출 적용(yawOffset 실측,
  경로표고 중앙값) 및 1-3 기존 시각보정 복원 — docs/history 참조
- docs/history: 작업 이력 15건

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 20:43:00 +09:00
co-authored by Claude Fable 5
parent c64c57b58e
commit ca591ce58c
20 changed files with 639 additions and 43 deletions
@@ -1013,24 +1013,32 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
}, []);
// 보간 포즈 — estFrame(연속 프레임번호)에서 앞뒤 smoothFrame 선형보간(yaw는 최단각). RAF에서 호출.
// 자세(yaw/pitch/roll)는 attLeadSec(자세 선행) 만큼 앞선 시점에서 별도 샘플 —
// 짐벌 로그의 기록 지연으로 팬(회전) 시 오버레이가 늦게 따라오는 이질감 보정.
const poseAt = useCallback((estFrame: number): DroneFrameBasic | null => {
const arr = allDroneFramesRef.current;
if (!arr.length) return null;
let lo = 0, hi = arr.length - 1;
while (lo < hi) { const m = (lo + hi) >> 1; if (arr[m].frame < estFrame) lo = m + 1; else hi = m; }
const i2 = lo, i1 = Math.max(0, lo - 1);
const f1 = arr[i1].frame, f2 = arr[i2].frame;
const frac = f2 > f1 ? Math.min(1, Math.max(0, (estFrame - f1) / (f2 - f1))) : 0;
const h = poseHalf();
const a = smoothFrame(arr, i1, h), b = smoothFrame(arr, i2, h);
const L = (x: number, y: number) => x + (y - x) * frac;
let dy = b.yaw - a.yaw; dy = ((dy + 540) % 360) - 180; // 최단각
const sample = (ef: number): { a: DroneFrameBasic; b: DroneFrameBasic; frac: number } => {
let lo = 0, hi = arr.length - 1;
while (lo < hi) { const m = (lo + hi) >> 1; if (arr[m].frame < ef) lo = m + 1; else hi = m; }
const i2 = lo, i1 = Math.max(0, lo - 1);
const f1 = arr[i1].frame, f2 = arr[i2].frame;
const frac = f2 > f1 ? Math.min(1, Math.max(0, (ef - f1) / (f2 - f1))) : 0;
return { a: smoothFrame(arr, i1, h), b: smoothFrame(arr, i2, h), frac };
};
const p = sample(estFrame);
const leadSec = paramsRef.current.attLeadSec ?? 0;
const q = leadSec !== 0 ? sample(estFrame + leadSec * fpsRef.current) : p;
const L = (x: number, y: number) => x + (y - x) * p.frac;
const A = (x: number, y: number) => x + (y - x) * q.frac;
let dy = q.b.yaw - q.a.yaw; dy = ((dy + 540) % 360) - 180; // 최단각
return {
...a,
...p.a,
frame: estFrame,
lat: L(a.lat, b.lat), lon: L(a.lon, b.lon), altitude: L(a.altitude, b.altitude),
pitch: L(a.pitch, b.pitch), roll: L(a.roll, b.roll),
yaw: a.yaw + dy * frac,
lat: L(p.a.lat, p.b.lat), lon: L(p.a.lon, p.b.lon), altitude: L(p.a.altitude, p.b.altitude),
pitch: A(q.a.pitch, q.b.pitch), roll: A(q.a.roll, q.b.roll),
yaw: q.a.yaw + dy * q.frac,
};
}, [smoothFrame, poseHalf]);
@@ -1522,7 +1530,12 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
if (cc.Zc < CLIP_Z) return;
const { pxRaw, pyRaw } = pixelFromCamera(cc, params);
const prevDpoi = displayedPoiRef.current.get(poiA.title);
const d = smoothStep(prevDpoi, pxRaw, pyRaw, α, smoothMinAlphaRef.current, smoothSpeedRefRef.current);
// 프레임 동기(djmd) 데이터는 입력이 매끄러워 화면 EMA 불필요 — 즉시 투영으로
// 팬(회전) 시 라벨이 늦게 따라오는 이질감 제거(선형·측점과 동일 타이밍).
// 비동기 데이터셋(CSV 지터)만 기존 속도적응 EMA 유지.
const d = frameSyncedRef.current
? { x: pxRaw, y: pyRaw, rej: 0, vx: 0, vy: 0 }
: smoothStep(prevDpoi, pxRaw, pyRaw, α, smoothMinAlphaRef.current, smoothSpeedRefRef.current);
seenPoi.add(poiA.title);
// 픽셀 히스테리시스: 0.75px 이상 움직일 때만 정수 위치 갱신 → 저속(1배속) 깜빡임 제거.
const psx0 = vx(d.x), psy0 = vy(d.y);
@@ -2178,6 +2191,7 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
</div>
<div className="space-y-2 mb-3">
<ParamRow label="시간동기" tip="영상↔비행로그 시각 오프셋(초). POI 전체가 진행방향으로 밀리면 조정 (+: 라벨을 진행방향 앞으로 / −: 뒤로). '풀 붙임' 정합의 핵심 — camera.json 에 저장됨" value={params.timeOffsetSec ?? 0} min={-2} max={2} step={0.05} unit="s" decimals={2} onChange={v => setParam('timeOffsetSec', v)} />
<ParamRow label="자세 선행" tip="짐벌 자세 로그의 기록 지연 보정(초). 영상이 좌우로 팬(회전)할 때 라벨·선형이 늦게 따라오면 +, 앞서가면 −. 위치는 그대로 두고 자세만 앞선 시점을 참조 — camera.json 에 저장됨" value={params.attLeadSec ?? 0} min={-1} max={1} step={0.01} unit="s" decimals={2} onChange={v => setParam('attLeadSec', v)} />
<ParamRow label="Yaw ±" value={params.yawOffset} min={-180} max={180} step={0.1} unit="°" decimals={1} onChange={v => setParam('yawOffset', v)} />
{/* Yaw 자동 추정(2단계 보정의 1단계) — 대략 맞춘 뒤 'Yaw 보정' 드래그 모드로 마무리 */}
<button
@@ -2219,7 +2233,7 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
<ParamRow label="cx₀" value={params.cx0} min={-0.5} max={0.5} step={0.005} unit="" decimals={3} onChange={v => setParam('cx0', v)} />
<ParamRow label="cy₀" value={params.cy0} min={-0.5} max={0.5} step={0.005} unit="" decimals={3} onChange={v => setParam('cy0', v)} />
<ParamRow label="sen W" value={params.sensorW} min={10} max={50} step={0.05} unit="mm" decimals={2} onChange={v => setParam('sensorW', v)} />
<ParamRow label="sen H" value={params.sensorH} min={6} max={36} step={0.05} unit="mm" decimals={2} onChange={v => setParam('sensorH', v)} />
<ParamRow label="sen H" value={params.sensorH} min={6} max={100} step={0.05} unit="mm" decimals={2} onChange={v => setParam('sensorH', v)} />
</div>
{panelDroneFrame && (
<div className="border-t border-gray-700 pt-2 mb-2 text-[10px] text-gray-400 font-mono space-y-0.5">
+166 -28
View File
@@ -34,11 +34,16 @@ interface GeoPoint {
}
/** 프레임별 precompute 결과. km=배지용 최근접측점, chain=구간방향용 연속 체이니지,
* offM=측점 폴리라인까지 수평거리(m) — 노선 밖(접근/이탈) 판별용. */
* offM=측점 폴리라인까지 수평거리(m) — 노선 밖(접근/이탈) 판별용.
* viewChain=카메라 시선 중심이 닿는 지면 교점의 체이니지(커서 배지용) —
* 드론 연직 체이니지(chain)보다 전방 주시거리(AGL/tan|pitch|)만큼 앞. */
interface ViewedPoint {
frameNum: number;
km: number;
chain: number;
viewChain: number;
/** 배지 표시 지점(시선/오프셋 지점)의 노선 이격(m) — 오프셋 미설정이면 offM 과 동일. */
viewOffM: number;
offM: number;
time: number;
}
@@ -49,12 +54,11 @@ interface StationLine {
k: number; // 경도→m 환산 (cos(lat0)*111000)
}
/** 드론 lat/lon 을 측점 폴리라인에 투영 → 연속 체이니지(m) + 폴리라인까지 수평거리(m).
* 100m 양자화된 최근접측점과 달리 전진/후진 전환 시점이 실제 이동과 일치.
* distM 은 노선 밖(접근/이탈) 판별에 사용 — 밖에서는 chain 이 끝점에 클램프됨. */
function projectChainageDist(lat: number, lon: number, line: StationLine): { chain: number; distM: number } {
const px = lon * line.k;
const py = lat * 111000;
/** 노선 이탈 판정 거리(m) — 이 이상 벗어나면 접근/이탈 구간으로 취급. */
const OFF_ROUTE_M = 60;
/** 평면 투영 좌표(px,py)를 측점 폴리라인에 투영 → 연속 체이니지(m) + 수평거리(m). */
function projectXYChainage(px: number, py: number, line: StationLine): { chain: number; distM: number } {
const pts = line.pts;
let bestD = Infinity;
let bestKm = pts.length ? pts[0].km : -1;
@@ -76,6 +80,13 @@ function projectChainageDist(lat: number, lon: number, line: StationLine): { cha
return { chain: bestKm, distM: isFinite(bestD) ? Math.sqrt(bestD) : Infinity };
}
/** 드론 lat/lon 을 측점 폴리라인에 투영 → 연속 체이니지(m) + 폴리라인까지 수평거리(m).
* 100m 양자화된 최근접측점과 달리 전진/후진 전환 시점이 실제 이동과 일치.
* distM 은 노선 밖(접근/이탈) 판별에 사용 — 밖에서는 chain 이 끝점에 클램프됨. */
function projectChainageDist(lat: number, lon: number, line: StationLine): { chain: number; distM: number } {
return projectXYChainage(lon * line.k, lat * 111000, line);
}
function projectChainage(lat: number, lon: number, line: StationLine): number {
return projectChainageDist(lat, lon, line).chain;
}
@@ -283,17 +294,110 @@ export function StationBar({
}
const n = frames.length;
const out: ViewedPoint[] = new Array(n);
// ── 시선 중심 체이니지(viewChain) 준비 ─────────────────────────────
// route.json routeInfo.station_lookahead(m) 설정:
// 미설정/0 → 기존 그대로: 드론 연직(바로 아래) 체이니지 (기본)
// N(숫자) → 드론 체이니지 + 진행방향으로 N(m) 고정 오프셋
// "auto" → 자동: 주시거리 ≈ AGL / tan(|짐벌 피치|)
// (AGL = 드론고도 − 지면고도(측점 z 체이니지 보간, 동일 datum))
// 진행방향 = 카메라 yaw 벡터 · 노선 접선 내적 부호.
// 오프셋 활성 시 노선 밖(접근/이탈)에서는 '시선이 닿는 지점'을 노선에 투영해
// 그 지점이 노선 위(OFF_ROUTE_M 이내)면 배지가 측점값으로 전환된다.
const lookCfgRaw = (routeMeta?.routeInfo as Record<string, unknown> | undefined)?.station_lookahead
?? (routeMeta as unknown as Record<string, unknown> | null)?.station_lookahead;
const lookFixed: number | null =
typeof lookCfgRaw === 'number' && isFinite(lookCfgRaw) && lookCfgRaw > 0 ? lookCfgRaw
: typeof lookCfgRaw === 'string' && lookCfgRaw.trim() !== '' && isFinite(Number(lookCfgRaw)) && Number(lookCfgRaw) > 0 ? Number(lookCfgRaw)
: null;
const lookAuto = typeof lookCfgRaw === 'string' && lookCfgRaw.trim().toLowerCase() === 'auto';
const lookModeText = lookAuto ? '자동(카메라 시선 중심)' : lookFixed !== null ? `고정 +${lookFixed}m (진행방향)` : '미설정 (드론 연직 — 기존 동작)';
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; // 전방 주시거리 상한(m) — POI 표시범위(기본 400)와 유사 수준
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 s = b.km > a.km ? 1 : -1; // +km(체이니지 증가) 방향 접선
const dx = (b.x - a.x) * s, dy = (b.y - a.y) * s;
const L = Math.hypot(dx, dy);
return L > 0 ? { x: dx / L, y: dy / L } : null;
}
}
return null;
};
// 노선 체이니지 범위(클램프용) — 측점 폴리라인 km 범위(z 프로파일 유무와 무관).
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; }
for (let i = 0; i < n; i++) {
const f = frames[i];
const pj = projectChainageDist(f.lat, f.lon, stationLine);
let viewChain = pj.chain;
let viewOffM = pj.distM;
if (lookFixed !== null || lookAuto) {
// 주시거리(m): 고정값 또는 자동(짐벌 피치·AGL)
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 pitchDeg = isFinite(f.pitch) ? f.pitch : -15;
lookDist = pitchDeg <= -3
? Math.min(MAX_LOOK, agl / Math.tan((-pitchDeg * Math.PI) / 180))
: MAX_LOOK; // 수평에 가까우면 상한 사용
}
}
}
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); // 값 표시 상태(노선밖 아님)
} else {
// 아직 시선이 노선에 못 미침 — '노선밖 (남은 거리+θ)m'
viewOffM = OFF_ROUTE_M - effLook;
}
}
}
out[i] = {
frameNum: f.frame,
km: nearestStationKm(f, stations),
chain: pj.chain,
viewChain,
viewOffM,
offM: pj.distM,
time: f.frame / videoFps,
};
}
// 진단: 세그먼트별 적용 확인용 — 중간 프레임 표본의 연직 대비 오프셋(Δ).
const midVp = out[Math.floor(out.length / 2)];
console.log(
`[스테이션바] 배지 오프셋: ${lookModeText} | 표본 Δ${Math.round(midVp.viewChain - midVp.chain) >= 0 ? '+' : ''}${Math.round(midVp.viewChain - midVp.chain)}m (${out.length}행)`,
);
// 이동거리 축(frac): 체이니지 |Δ| 가 아니라 GPS 실이동거리 누적(±W 이동평균 평활).
// 체이니지 투영은 노선 밖(접근/이탈 구간)에서 폴리라인 끝점(0k000 등)에 클램프되어
// 드론이 움직여도 커서가 멈추는 문제가 있었음(제주 — 비행 시작점이 노선 밖 700m).
@@ -333,7 +437,7 @@ export function StationBar({
chainRef.current = { time, frac, depChain: dep, arrChain: arr };
viewedRef.current = out;
setReady(true);
}, [stations, stationLine, storeFrames, videoFps]);
}, [stations, stationLine, storeFrames, videoFps, routeMeta]);
/** 현재 시간에 가장 가까운 precompute 인덱스. */
const viewedIdxAtTime = useCallback((t: number): number => {
@@ -352,18 +456,21 @@ export function StationBar({
return best;
}, [videoFps]);
// 현재 보는 측점값 — 연속 체이니지(chain)로 10m 해상도 (km 필드는 100m 양자화).
// 현재 보는 측점값 — station_lookahead 미설정이면 드론 연직 체이니지(기존),
// 설정 시 진행방향 오프셋/시선 중심 지점의 체이니지(viewChain).
const realChain = useMemo<number | null>(() => {
if (!ready) return null;
const idx = viewedIdxAtTime(currentTime);
return idx >= 0 ? viewedRef.current[idx].chain : null;
return idx >= 0 ? viewedRef.current[idx].viewChain : null;
}, [currentTime, ready, viewedIdxAtTime]);
// 현재 드론의 노선(측점 폴리라인) 이탈 거리(m) — 접근/이탈 구간 배지 표기용.
// 배지 표시 지점의 노선 이격(m) — 접근/이탈 구간 '노선밖' 표기용.
// 오프셋 활성 시 '시선이 닿는 지점' 기준이라, 화면이 노선을 비추기 시작하면
// 드론이 아직 노선 밖이어도 배지가 측점값으로 먼저 전환된다.
const realOffM = useMemo<number | null>(() => {
if (!ready) return null;
const idx = viewedIdxAtTime(currentTime);
return idx >= 0 ? viewedRef.current[idx].offM : null;
return idx >= 0 ? viewedRef.current[idx].viewOffM : null;
}, [currentTime, ready, viewedIdxAtTime]);
// 종점역 '미도착' 폭(px) — 영상이 종점역 도착 전 끝날 때, 트랙 우측에 이만큼 비워둔다.
@@ -460,7 +567,6 @@ export function StationBar({
// 커서 배지 = 폴더 데이터 기반 연속 체이니지(realChain)를 10m 단위로 표시. 데이터 없으면 빈 문자열.
// 드론이 노선(측점 폴리라인)에서 OFF_ROUTE_M 이상 벗어난 구간(접근/이탈)은 체이니지가
// 끝점에 클램프되어 무의미 → "노선밖 ○○m" 로 표기해 측점이 안 변하는 이유를 드러낸다.
const OFF_ROUTE_M = 60;
const cursorText =
realChain !== null && realChain >= 0
? (realOffM !== null && realOffM > OFF_ROUTE_M
@@ -561,6 +667,9 @@ export function StationBar({
const arr = viewedRef.current;
const frames = storeFrames;
if (!ready || !arr.length || duration <= 0 || !frames.length) return [];
// station_lookahead(시선/오프셋) 활성 여부 — viewChain 이 연직 체이니지와 다르면 활성.
// 활성 시 시설물 마크는 viewChain 기준으로만 배치(물리 폴백 금지).
const lookActive = arr.some((v) => Math.abs(v.viewChain - v.chain) > 1);
// 실좌표(lat/lon)에 드론이 가까워지는 '모든 통과 구간'의 px 목록.
// (드론이 같은 구조물을 2번 이상 지날 때 각 통과마다 마커를 찍기 위함)
@@ -619,6 +728,9 @@ export function StationBar({
};
// 측점값(미터)에 해당하는 모든 통과 지점 px (연속 체이니지 기준).
// 배지와 동일한 viewChain(오프셋/시선 기준) 사용 — station_lookahead 설정 시
// 마크(시점·측점 기반 구조물)가 배지 값과 같은 타이밍에 커서와 만난다.
// (미설정이면 viewChain=연직 체이니지 — 기존 동작 그대로)
const pxPassesAtMileage = (targetM: number): { px: number; km: number }[] => {
const arr = viewedRef.current;
if (!arr.length) return [];
@@ -628,13 +740,13 @@ export function StationBar({
const out: { px: number; km: number }[] = [];
let i = 0;
while (i < arr.length) {
// 노선 밖(접근/이탈) 프레임 제외 — 폴리라인 밖에선 체이니지가 끝점(0 등)에 클램프되어
// 접근 구간 전체가 '시점(0k000) 통과'로 오인돼 마크가 바 맨 앞에 찍히는 문제 방지.
if (arr[i].offM > OFF_ROUTE_M) { i++; continue; }
if (Math.abs(arr[i].chain - targetM) < TH) {
let j = i, bj = i, bd = Math.abs(arr[i].chain - targetM);
while (j < arr.length && arr[j].offM <= OFF_ROUTE_M && Math.abs(arr[j].chain - targetM) < TH) {
const d = Math.abs(arr[j].chain - targetM);
// 배지 표시 지점이 노선 밖인 프레임 제외 — 접근/이탈 구간에서 체이니지가
// 끝점(0 등)에 클램프되어 '시점 통과'로 오인돼 마크가 앞에 찍히는 문제 방지.
if (arr[i].viewOffM > OFF_ROUTE_M) { i++; continue; }
if (Math.abs(arr[i].viewChain - targetM) < TH) {
let j = i, bj = i, bd = Math.abs(arr[i].viewChain - targetM);
while (j < arr.length && arr[j].viewOffM <= OFF_ROUTE_M && Math.abs(arr[j].viewChain - targetM) < TH) {
const d = Math.abs(arr[j].viewChain - targetM);
if (d < bd) { bd = d; bj = j; }
j++;
}
@@ -665,14 +777,14 @@ export function StationBar({
const appendEndpointStations = (marks: StructMark[]): StructMark[] => {
const va = viewedRef.current;
if (!va.length) return marks;
const onRoute = va.filter(v => v.offM <= OFF_ROUTE_M);
const onRoute = va.filter(v => v.viewOffM <= OFF_ROUTE_M);
if (!onRoute.length) return marks;
const ends = [
{ v: onRoute[0], title: '시작' },
{ v: onRoute[onRoute.length - 1], title: '끝' },
];
for (const e of ends) {
const ch = e.v.chain;
const ch = e.v.viewChain;
if (marks.some(m => Math.abs(m.km - ch) <= 100)) continue;
const px = pxAtTime(e.v.time);
if (px === null) continue;
@@ -681,6 +793,22 @@ export function StationBar({
return marks;
};
// 1km(1+000) 단위 측점 마크 — 매 1000m 측점의 통과 위치에 측점값 라벨(위)만 표시.
// title 은 빈 문자열 → 하단 이름 라벨/그룹핑 제외(Timeline 에서 스킵).
// 기존 마크(시점·시설물 등)와 24px 이내로 겹치면 생략(라벨 충돌 방지).
const appendKmStations = (marks: StructMark[]): StructMark[] => {
if (!stationLine) return marks;
let lo = Infinity, hi = -Infinity;
for (const p of stationLine.pts) { if (p.km < lo) lo = p.km; if (p.km > hi) hi = p.km; }
for (let m = Math.ceil(lo / 1000) * 1000; m <= hi; m += 1000) {
for (const p of pxPassesAtMileage(m)) {
if (marks.some((x) => Math.abs(x.px - p.px) < 24)) continue;
marks.push({ px: p.px, title: '', category: '측점', km: m, kmExists: true });
}
}
return marks;
};
// 구조물 후보 POI(교량/터널/역사) base 이름 → 실좌표.
const cats = new Set(['교량', '터널', '역사']);
const poiByName = new Map<string, { lat: number; lon: number; category: string }>();
@@ -744,12 +872,22 @@ export function StationBar({
// 일치(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);
// ※ station_lookahead(시선/오프셋) 활성 시 물리(드론 연직) 폴백 금지 —
// 구간 밖 시설물(예: 이전 세그먼트에서 이미 바라본 교량)이 물리 통과 위치로
// 끌려 들어와 배지 기준(viewChain 범위)에 없는 값이 바에 찍히는 것을 방지.
// 해당 시설물은 자기 viewChain 범위를 가진 세그먼트의 바에서 정상 표시된다.
if (!lookActive) {
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);
if (px !== null) passes = [{ px, km: mid }];
if (lookActive) {
passes = pxPassesAtMileage(mid); // 시선 기준 통과 — 범위 밖이면 미표시
} else {
const px = pxAtMileage(mid);
if (px !== null) passes = [{ px, km: mid }];
}
}
// 역사 km 라벨 = '역 좌표 자체'의 측점값(드론 최근접 프레임 chain 아님).
// 역은 바 양끝에 스냅되므로, 시작/끝 커서값(= 역 위치 측점)과 일치시키기 위함.
@@ -763,7 +901,7 @@ export function StationBar({
out.push({ px: p.px, title: s.name, category: cat, km: stationKmVal ?? p.km, kmExists, props: s.props });
}
}
return appendEndpointStations(placeUnreachedTerminal(appendStartMark(out)));
return appendKmStations(appendEndpointStations(placeUnreachedTerminal(appendStartMark(out))));
}
// 폴백: POI category 기반 (실좌표 GPS 근접, 모든 통과).
@@ -782,7 +920,7 @@ export function StationBar({
out.push({ px: p.px, title: base, category: info.category, km: stationKmVal ?? p.km, kmExists });
}
}
return appendEndpointStations(placeUnreachedTerminal(appendStartMark(out)));
return appendKmStations(appendEndpointStations(placeUnreachedTerminal(appendStartMark(out))));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ready, duration, storeFrames, pois, stations, pxAtTime, routeMeta, routeDirCh, storeStructures, pxAtMileage, endGapPx, stationLine, videoFps]);
@@ -105,11 +105,13 @@ export function Timeline({
// 같은 구조물의 상/하/인상 변형(같은 base = 같은 좌표)은 '겹침제외 옵션과 무관하게' 진행방향 1개만.
// (dir-우선 정렬돼 있어 방향 변형이 먼저 = 채택. 예: 법동가도교(하)/(인상) → (하) 1개)
const byBase = new Map<string, StructMark>();
const untitled: StructMark[] = []; // 이름 없는 마크(1km 단위 측점) — base dedupe 비대상
for (const it of ordered) {
if (!it.title) { untitled.push(it); continue; }
const b = baseStruct(it.title);
if (!byBase.has(b)) byBase.set(b, it);
}
const keptOthers = [...byBase.values()];
const keptOthers = [...byBase.values(), ...untitled];
// 라벨은 긴 이름 2줄(아래 splitStructLabel)로 폭을 줄여 표시. 그래도 가까워 겹치면 그대로 둠(엇갈림 없음).
void overlapExclude;
const structs = [...structures.filter(isStation), ...keptOthers].sort((a, b) => a.px - b.px);
@@ -151,6 +153,7 @@ export function Timeline({
{
const byTitle = new Map<string, StructMark[]>();
for (const s of structs) {
if (!s.title) continue; // 이름 없는 마크(1km 단위 측점 등) — 측점값 라벨만 표시
const a = byTitle.get(s.title);
if (a) a.push(s);
else byTitle.set(s.title, [s]);
+4
View File
@@ -142,6 +142,10 @@ export interface RouteInfo {
ellipsoidal_height?: number;
/** 개발 모드: 1(기본)=영상제어 버튼·제어 UI 표시, 0=숨김(뷰어 모드). */
dev_mode?: number;
/** 스테이션바 배지 전방 오프셋(m). 미설정/0=드론 연직 체이니지(기본, 기존 동작),
* N=진행방향 +N(m) 고정, "auto"=자동(짐벌 피치·AGL 기반 카메라 시선 중심).
* 활성 시 노선 밖(접근/이탈)에서는 시선 지점을 노선에 투영해 측점값 전환이 앞당겨진다. */
station_lookahead?: number | string;
/** 측점 인정 범위(m): station 위치를 이 거리 이내로 지날 때 마커 표시. 기본 40. 폴더별 조절. */
stationTolerance?: number;
/**
+3
View File
@@ -35,6 +35,8 @@ export interface DroneFrameBasic {
export interface CameraParams {
timeOffsetSec?: number; // 영상↔비행로그 시간동기 보정(초). + = 로그를 뒤로 밀어 참조.
// POI 가 진행방향으로 일괄 밀릴 때 조정 — "풀 붙임" 정합의 핵심 값.
attLeadSec?: number; // 자세(yaw/pitch/roll)만의 추가 시간 보정(초) — 짐벌 로그 기록 지연 보정.
// 영상이 좌우 팬(회전)할 때 라벨·선형이 늦게 따라오면 +, 앞서가면 −.
yawOffset: number; // yaw 오프셋 (degrees, per-frame SRT yaw에 더함)
pitch: number; // pitch 오프셋 (degrees, per-frame SRT pitch에 더함, 기본 0)
roll: number; // roll 오프셋 (degrees, per-frame SRT roll에 더함, 기본 0)
@@ -54,6 +56,7 @@ export interface CameraParams {
/** Python advanced_tuner_v2.py 기본값 */
export const DEFAULT_CAMERA_PARAMS: CameraParams = {
timeOffsetSec: 0,
attLeadSec: 0,
yawOffset: 0,
pitch: 0, // offset, Python spn_pitch 기본값 0
roll: 0, // offset, Python spn_roll 기본값 0