diff --git a/client/src/components/overlay/StationOverlay.tsx b/client/src/components/overlay/StationOverlay.tsx
index 05c867e..6821428 100644
--- a/client/src/components/overlay/StationOverlay.tsx
+++ b/client/src/components/overlay/StationOverlay.tsx
@@ -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
setParam('timeOffsetSec', v)} />
+ setParam('attLeadSec', v)} />
setParam('yawOffset', v)} />
{/* Yaw 자동 추정(2단계 보정의 1단계) — 대략 맞춘 뒤 'Yaw 보정' 드래그 모드로 마무리 */}
{panelDroneFrame && (
diff --git a/client/src/stationbar/StationBar.tsx b/client/src/stationbar/StationBar.tsx
index d563c79..e81b2f5 100755
--- a/client/src/stationbar/StationBar.tsx
+++ b/client/src/stationbar/StationBar.tsx
@@ -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 | undefined)?.station_lookahead
+ ?? (routeMeta as unknown as Record | 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(() => {
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(() => {
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();
@@ -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]);
diff --git a/client/src/stationbar/components/Timeline/Timeline.tsx b/client/src/stationbar/components/Timeline/Timeline.tsx
index 19b6326..451dd99 100755
--- a/client/src/stationbar/components/Timeline/Timeline.tsx
+++ b/client/src/stationbar/components/Timeline/Timeline.tsx
@@ -105,11 +105,13 @@ export function Timeline({
// 같은 구조물의 상/하/인상 변형(같은 base = 같은 좌표)은 '겹침제외 옵션과 무관하게' 진행방향 1개만.
// (dir-우선 정렬돼 있어 방향 변형이 먼저 = 채택. 예: 법동가도교(하)/(인상) → (하) 1개)
const byBase = new Map();
+ 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();
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]);
diff --git a/client/src/types/geo.ts b/client/src/types/geo.ts
index 49b6718..572340c 100644
--- a/client/src/types/geo.ts
+++ b/client/src/types/geo.ts
@@ -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;
/**
diff --git a/client/src/utils/geoProjection.ts b/client/src/utils/geoProjection.ts
index a5f1de3..ef69564 100644
--- a/client/src/utils/geoProjection.ts
+++ b/client/src/utils/geoProjection.ts
@@ -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
diff --git a/docs/history/2026-07-14_1759_전체-커밋-3차.md b/docs/history/2026-07-14_1759_전체-커밋-3차.md
new file mode 100644
index 0000000..732a01e
--- /dev/null
+++ b/docs/history/2026-07-14_1759_전체-커밋-3차.md
@@ -0,0 +1,22 @@
+# 2026-07-14 전체 커밋 (3차) — djmd 프레임 동기·오버레이 지면고정
+
+**소요 시간**: 3분
+**Context 사용량**: input 7300k / output 2k tokens
+
+## 커밋
+
+- **c64c57b** `djmd 프레임 동기·오버레이 지면고정·선형-측점 정합·UI 단축키`
+- 36개 파일, +1248 / −56
+- 신규: client/src/utils/djmdTrack.ts (MP4 djmd 트랙 파서 + 자세 재정렬)
+- docs/history 23건 포함 (7/13 저녁 ~ 7/14)
+
+## 주요 내용
+
+djmd 프레임 동기 위치/고도, 자세 시간축 재정렬(-0.33s), 포즈 평활 제한(±5fr),
+선형-측점 정점 삽입 정합, 측점 라벨 즉시 투영, 드론높이 모드 선형 통일,
+offZ 잔재 교정, 로드 UX(정지 대기·UI 초기화), 측점 팝업 표고, 이중 라벨 제거,
+측점 표기 0+000 전환, 양끝 시작/끝 스테이션, F2/A 단축키, 54000 포트 재부팅 자동화.
+
+## 다음
+
+- push 는 사용자 요청 시 (GCM helper 연동됨 — `git push`)
diff --git a/docs/history/2026-07-14_1835_배지-시선중심-체이니지-연동.md b/docs/history/2026-07-14_1835_배지-시선중심-체이니지-연동.md
new file mode 100644
index 0000000..7987d01
--- /dev/null
+++ b/docs/history/2026-07-14_1835_배지-시선중심-체이니지-연동.md
@@ -0,0 +1,29 @@
+# 2026-07-14 스테이션바 현재값 — 드론 연직 → 카메라 시선 중심 체이니지 연동
+
+**소요 시간**: 10분
+**Context 사용량**: input 7600k / output 3k tokens
+
+## 배경 (사용자 제보)
+
+배지(현재 스테이션)는 드론의 연직 투영 체이니지인데, 카메라는 피치 −15°로 전방
+(AGL 70m 기준 약 260m 앞)을 비추므로 화면 속 측점 라벨과 배지 값이 달라 혼동 유발.
+
+## 구현 — StationBar.tsx
+
+- ViewedPoint 에 **viewChain**(시선 중심 지면 교점의 체이니지) 추가:
+ - 전방 주시거리 = AGL / tan(|짐벌 피치|), 상한 350m (수평에 가까우면 상한 사용)
+ - AGL = 드론고도 − 지면고도(측점 z 의 체이니지 보간 — 동일 datum(타원체고))
+ - 진행방향 = 카메라 yaw 벡터 · 노선 접선(+체이니지 방향) 내적 부호 → ±주시거리
+ - 노선 양끝은 측점 범위로 클램프
+- 커서 배지(realChain)가 chain → **viewChain** 을 사용
+- 폴백: 측점 z 없는 데이터셋(z 프로파일 <2점)·고도/yaw 미상 → 기존 연직 체이니지 유지
+- 구간색·마크·이동거리축·노선밖 표기는 기존(물리 통과 기준) 그대로
+
+## 효과
+
+배지 값이 화면 중앙이 실제로 비추는 지점의 측점값과 일치 — 영상 속 측점 라벨과 동기화.
+
+## 검증
+
+- tsc+vite 빌드 통과, 새 번들(index-DGLDr81A.js, 빌드 07.14 18:35) 서빙
+- 적용: F5 새로고침(빌드 시각 18:35 확인) → 폴더 재선택
diff --git a/docs/history/2026-07-14_1842_station-lookahead-설정.md b/docs/history/2026-07-14_1842_station-lookahead-설정.md
new file mode 100644
index 0000000..e57e36c
--- /dev/null
+++ b/docs/history/2026-07-14_1842_station-lookahead-설정.md
@@ -0,0 +1,27 @@
+# 2026-07-14 배지 전방 오프셋 route.json 설정화 — station_lookahead
+
+**소요 시간**: 6분
+**Context 사용량**: input 7900k / output 2k tokens
+
+## 요청
+
+배지 시선 연동을 route.json 환경설정으로 — 0 이면 드론 연직, 100 이면 진행방향 +100m.
+
+## 구현 — StationBar.tsx, types/geo.ts
+
+route.json `routeInfo.station_lookahead` (m):
+
+| 값 | 동작 |
+|----|------|
+| `0` | 드론 바로 아래(연직 체이니지) — 종전 방식 |
+| `N` (예: `100`) | 드론 체이니지 + 진행방향으로 N m 고정 오프셋 |
+| 미설정 / `"auto"` | 자동 — 짐벌 피치·AGL 기반 카메라 시선 중심(직전 구현) |
+
+- 진행방향 = 카메라 yaw · 노선 접선 내적 부호 (후진 시 −N)
+- 노선 체이니지 범위로 클램프(양끝 밖으로 안 나감), 숫자 문자열("100")도 허용
+- RouteMeta 타입에 station_lookahead 필드 문서화
+
+## 검증
+
+- tsc+vite 빌드 통과, 새 번들(index-BtY-C6rz.js, 빌드 07.14 18:42) 서빙
+- 적용: route.json 에 `"station_lookahead": 100` 추가 → F5 새로고침 → 폴더 재선택
diff --git a/docs/history/2026-07-14_1850_lookahead-미반영-진단-로그추가.md b/docs/history/2026-07-14_1850_lookahead-미반영-진단-로그추가.md
new file mode 100644
index 0000000..b19f8fe
--- /dev/null
+++ b/docs/history/2026-07-14_1850_lookahead-미반영-진단-로그추가.md
@@ -0,0 +1,28 @@
+# 2026-07-14 station_lookahead=100 미반영 제보 — 알고리즘 검증 + 모드 로그 추가
+
+**소요 시간**: 7분
+**Context 사용량**: input 8200k / output 3k tokens
+
+## 제보
+
+route.json 에 station_lookahead: 100 을 넣어도 배지가 0(드론 연직)처럼 보임.
+
+## 검증
+
+- 사용자 route.json 확인: `"station_lookahead": 100` routeInfo 안에 정상 기재 ✓
+- parseRouteMeta 는 JSON 전체 보존(키 화이트리스트 없음) — 설정 전달 경로 정상 ✓
+- **알고리즘 실데이터 재현**: 경로 1-1 CSV 4개 시점(60/150/300/450s) 전부
+ viewChain = chain **정확히 +100m** (진행방향 dot +0.94~0.97) — 코드 정상
+- 결론: 브라우저가 **이전 번들 실행 중**으로 판단(폴더 재선택은 데이터만 갱신, JS 는 F5 필요)
+
+## 조치
+
+- 프리컴퓨트에 모드 확인용 콘솔 로그 추가:
+ `[스테이션바] 배지 오프셋: 고정 +100m (진행방향) | 0 (드론 연직) | 자동(카메라 시선 중심)`
+- station_lookahead 를 routeInfo 밖(최상위)에 둔 경우도 인식하도록 허용
+
+## 확인 절차 (사용자 안내)
+
+1. F5 새로고침 → 좌상단 빌드 시각 **07.14 18:50** 확인
+2. 폴더 재선택 → 콘솔(F12)에 `[스테이션바] 배지 오프셋: 고정 +100m` 로그 확인
+3. 배지가 드론 위치보다 100m 앞 측점값으로 표시되는지 확인
diff --git a/docs/history/2026-07-14_1902_노선밖-시선기준-전환.md b/docs/history/2026-07-14_1902_노선밖-시선기준-전환.md
new file mode 100644
index 0000000..714f48e
--- /dev/null
+++ b/docs/history/2026-07-14_1902_노선밖-시선기준-전환.md
@@ -0,0 +1,36 @@
+# 2026-07-14 접근 구간 '노선밖' — 시선 기준 전환 + 미설정 시 기존(연직) 기본값
+
+**소요 시간**: 12분
+**Context 사용량**: input 8600k / output 4k tokens
+
+## 제보/요청
+
+1. 접근 구간에서 화면은 이미 0+000~0+100 을 비추는데 배지는 "노선밖 229m" —
+ 바라보는 곳 기준이면 측점값이 나와야 하지 않나
+2. station_lookahead 는 **정의된 경우에만** 적용, 미정의면 **기존(드론 연직)** 동작
+
+## 구현 — StationBar.tsx
+
+- **기본값 변경**: 미설정/0 → 드론 연직 체이니지(기존 동작 그대로).
+ 숫자 N → 진행방향 +N(m), `"auto"` → 짐벌 피치·AGL 기반 자동 (명시 시에만)
+- **노선밖 판정도 시선 기준으로**(오프셋 활성 시):
+ - 노선 위: 노선을 따라 +N (곡선 안정, 기존 로직)
+ - 노선 밖(접근/이탈): 시선이 닿는 지점(직선 전방 N m)을 노선에 투영 —
+ 시선 지점 이격 ≤ 150m(광각 화면에 노선이 보이기 시작)면 측점값으로 전환,
+ 아니면 '노선밖 (시선 지점 이격)m'
+- projectXYChainage 분리(평면좌표 투영 재사용), OFF_ROUTE_M 모듈 상수화,
+ ViewedPoint 에 viewOffM 추가(배지 전용 — 마크/구간색은 종전 드론 기준 유지)
+
+## 검증 (경로 1-1 실데이터, 고정 100m)
+
+| 드론 이격 | 배지 |
+|-----------|------|
+| 394→245m | 노선밖 299→152m (시선 지점 기준으로 기존보다 ~100m 작게) |
+| 195→96m | **0+000** (화면에 노선 보임 → 조기 전환) |
+| 노선 진입 후 | 0+104 → 0+308 → 0+707 (+100 유지) |
+
+- tsc+vite 빌드 통과, 새 번들(index-YV1lA2Bm.js, 빌드 07.14 19:02) 서빙
+
+## 안내
+
+- 스크린샷 시점(이격 229m)에 바로 측점값을 원하면 값을 200~300 또는 "auto" 권장
diff --git a/docs/history/2026-07-14_1907_시점마크-배지기준-통일.md b/docs/history/2026-07-14_1907_시점마크-배지기준-통일.md
new file mode 100644
index 0000000..0d05b75
--- /dev/null
+++ b/docs/history/2026-07-14_1907_시점마크-배지기준-통일.md
@@ -0,0 +1,27 @@
+# 2026-07-14 배지 0+000 인데 시점 마크가 다른 위치 — 마크도 배지 기준(viewChain) 통일
+
+**소요 시간**: 5분
+**Context 사용량**: input 9000k / output 2k tokens
+
+## 제보
+
+배지는 0+000 인데 스테이션바의 시점(0+000) 마크는 더 오른쪽(다른 위치)에 표시.
+
+## 원인
+
+배지는 시선/오프셋 기준(viewChain)으로 전환됐는데, 시점 마크·측점 기반 구조물 마크는
+여전히 **드론 물리 통과 시각**(chain) 기준 → 오프셋(예: 100m)만큼 시간차 발생.
+
+## 수정 — StationBar.tsx
+
+- pxPassesAtMileage(시점 마크·측점 기반 구조물 마크 공용)의 통과 탐지를
+ chain/offM → **viewChain/viewOffM** 으로 전환 — 배지가 그 측점값을 표시하는
+ 순간에 커서가 마크와 만난다
+- appendEndpointStations(양끝 시작/끝 스테이션)도 동일 기준으로 통일
+- station_lookahead 미설정이면 viewChain=연직 체이니지 → **기존 동작 그대로**
+
+## 검증
+
+- tsc+vite 빌드 통과, 새 번들(index-qhnyNh_5.js, 빌드 07.14 19:07) 서빙
+- 적용: F5 새로고침(빌드 시각 19:07 확인) → 폴더 재선택 →
+ 배지가 0+000 이 되는 순간 커서가 시점 마크 위에 있는지 확인
diff --git a/docs/history/2026-07-14_1913_배지-연속모델-멈춤제거.md b/docs/history/2026-07-14_1913_배지-연속모델-멈춤제거.md
new file mode 100644
index 0000000..5ed909b
--- /dev/null
+++ b/docs/history/2026-07-14_1913_배지-연속모델-멈춤제거.md
@@ -0,0 +1,30 @@
+# 2026-07-14 시점 이후 스테이션 이동량 0 — 배지 연속 모델로 교체
+
+**소요 시간**: 6분
+**Context 사용량**: input 9400k / output 3k tokens
+
+## 제보
+
+시점(0+000) 마크부터 커서 위치까지 배지가 0+000 에 고정(이동량 0), 이후 +100 점프.
+
+## 원인
+
+접근 구간에서 '시선 지점의 노선 투영'이 노선 시점(0+000)에 **클램프** —
+시선이 노선 시작에 닿은 뒤 드론이 실제 진입할 때까지(~100m 비행) 배지가 0 에 정체,
+진입 순간 노선따라 +100 으로 전환되며 점프.
+
+## 수정 — StationBar.tsx (연속 모델)
+
+- **유효 주시거리 = lookDist − max(0, 노선이격 − OFF_ROUTE_M)**
+ - 노선 위(이격 ≤ 60m): 초과분 0 → 항상 +lookDist (횡 이동 흔들림에도 안정)
+ - 접근/이탈: 이격이 줄수록 유효 주시거리 0 → lookDist 로 선형 증가
+ - 유효 주시거리 < 0 이면 '노선밖 (남은거리+60)m'
+- 배지가 0+000 을 지나는 순간부터 진입(+100)까지 **멈춤·점프 없이 연속**
+- 시점 마크(pxPassesAtMileage, viewChain 기준)도 그 교차 순간에 찍혀 배지와 일치
+
+## 검증 (경로 1-1, 고정 100m)
+
+노선밖 294→194→95m → 0+063 → 0+104 → 0+207 → 0+308 → … (단조 연속, 플랫 구간 없음)
+
+- tsc+vite 빌드 통과, 새 번들(index-ChxJN0eC.js, 빌드 07.14 19:13) 서빙
+- 적용: F5 새로고침(빌드 시각 19:13 확인) → 폴더 재선택
diff --git a/docs/history/2026-07-14_1929_시설물마크-옵션기준-통일.md b/docs/history/2026-07-14_1929_시설물마크-옵션기준-통일.md
new file mode 100644
index 0000000..ed70b22
--- /dev/null
+++ b/docs/history/2026-07-14_1929_시설물마크-옵션기준-통일.md
@@ -0,0 +1,32 @@
+# 2026-07-14 스테이션바 시설물 마크 — station_lookahead 기준으로 완전 통일
+
+**소요 시간**: 9분
+**Context 사용량**: input 9800k / output 3k tokens
+
+## 제보
+
+배지(스테이션값)는 설정대로 나오는데, 스테이션바의 시설물(지장물) 마크 위치는
+드론 연직(물리 통과) 기준으로 보임 — 특히 두 번째 영상에서.
+
+## 원인
+
+시설물 마크 배치는 다단 폴백 구조:
+① 측점값 통과 창(pxPassesAtMileage — viewChain 기준으로 이미 전환) →
+② **좌표 근접(pxPassesTo — 드론 GPS 물리 통과, 미전환)**.
+세그먼트 시작 100m 안쪽 시설물은 viewChain(=연직+100)이 그 측점값을 커버하지
+않아 ①이 빈 결과 → ②(물리) 폴백으로 떨어져 배지와 기준이 섞임.
+
+## 수정 — StationBar.tsx pxPassesAtMileage
+
+- 오프셋 활성(viewChain≠chain 데이터 감지) 시 통과 창을 못 찾으면
+ **viewChain 이 가장 가까워지는 프레임에 배치**(이격 150m 이내) —
+ 물리 폴백으로 빠지지 않음. 세그먼트 경계 시설물은 바 시작 부근에 클램프
+- 오프셋 미설정 데이터셋(회덕 등)은 기존 동작 그대로(감지 조건 불충족)
+- 진단 로그 확장: `[스테이션바] 배지 오프셋: … | 표본 Δ+100m (N행)` —
+ 세그먼트마다 실제 적용량 확인 가능
+
+## 검증
+
+- tsc+vite 빌드 통과, 새 번들(index-mXKdnFF6.js, 빌드 07.14 19:29) 서빙
+- 적용: F5 새로고침(빌드 시각 19:29) → 폴더 재선택 → 두 번째 영상에서
+ 커서가 시설물 마크에 닿을 때 화면 중앙(전방 100m)에 해당 시설물이 보여야 정상
diff --git a/docs/history/2026-07-14_1934_구간밖-시설물-마크-제거.md b/docs/history/2026-07-14_1934_구간밖-시설물-마크-제거.md
new file mode 100644
index 0000000..937b399
--- /dev/null
+++ b/docs/history/2026-07-14_1934_구간밖-시설물-마크-제거.md
@@ -0,0 +1,34 @@
+# 2026-07-14 귀덕천교 1+110 이 세그2 바에 표시 — 구간 밖 시설물 마크 제거
+
+**소요 시간**: 5분
+**Context 사용량**: input 10200k / output 3k tokens
+
+## 제보 (스크린샷)
+
+세그먼트 2 바에 귀덕천교(1+110)가 표시 — 현재 바의 스테이션 구간에 없는 값.
+
+## 데이터 확인
+
+- 귀덕천교 물리 체이니지 1+102, 세그먼트 2 의 시선(viewChain) 범위 **1+177~1+608**
+- 시선 기준으로 귀덕천교는 **세그먼트 1 에서 이미 바라본** 시설물 —
+ 세그2 바에는 없어야 정상 (세그1 바에서 viewChain 1+102 통과 시 정상 표시)
+
+## 원인
+
+직전 수정의 '최근접 배치' 완화 + 물리(좌표 근접) 폴백이 구간 밖 시설물을
+현재 바로 끌어들임 (물리적으로는 드론이 세그2 초반에 귀덕천교 위를 지나므로).
+
+## 수정 — StationBar.tsx
+
+- '최근접 배치' 완화 제거 (viewChain 은 연속이라 범위 안 시설물은 항상 창에 잡힘 —
+ 완화가 구제하는 건 범위 밖뿐이므로 불필요·유해)
+- **lookActive(오프셋 활성) 시 물리 폴백(pxPassesTo/pxAtMileage) 금지** —
+ 시설물 마크는 viewChain 기준으로만 배치, 범위 밖이면 미표시
+ (이정값 전용 구조물은 pxPassesAtMileage 로 대체 시도)
+- 오프셋 미설정 데이터셋(회덕 등)은 기존 폴백 체계 그대로
+
+## 검증
+
+- tsc+vite 빌드 통과, 새 번들(index-_1oNGZpr.js, 빌드 07.14 19:34) 서빙
+- 적용: F5 새로고침(빌드 시각 19:34) → 폴더 재선택 →
+ 세그2 바에서 귀덕천교 마크 사라지고, 세그1 바 끝부분(1+102 바라보는 시점)에 표시
diff --git a/docs/history/2026-07-14_1941_1km단위-측점-마크.md b/docs/history/2026-07-14_1941_1km단위-측점-마크.md
new file mode 100644
index 0000000..48f16da
--- /dev/null
+++ b/docs/history/2026-07-14_1941_1km단위-측점-마크.md
@@ -0,0 +1,23 @@
+# 2026-07-14 스테이션바 1km(1+000) 단위 측점 마크 표시
+
+**소요 시간**: 7분
+**Context 사용량**: input 10600k / output 3k tokens
+
+## 요청
+
+1+000 단위 스테이션마다 스테이션바에 표시.
+
+## 구현
+
+- StationBar: appendKmStations — 측점 범위 내 매 1000m 값을 pxPassesAtMileage
+ (배지와 동일한 viewChain 기준)로 통과 위치에 배치. 측점값 라벨(위)만 표시
+ (title 빈 문자열), 기존 마크와 24px 이내 겹치면 생략
+- Timeline: 빈 title 마크는 하단 이름 라벨/그룹핑·base dedupe 에서 제외
+ (측점값 라벨과 눈금만 표시), 기존 겹침 숨김 로직은 그대로 적용
+- 세그먼트별로 해당 구간에 포함된 1km 측점만 표시 (viewChain 범위 기준)
+
+## 검증
+
+- tsc+vite 빌드 통과, 새 번들(index-qkCcaENF.js, 빌드 07.14 19:41) 서빙
+- 적용: F5 새로고침(빌드 시각 19:41) → 폴더 재선택 →
+ 바에 1+000, 2+000 … 값이 통과 위치마다 표시되는지 확인
diff --git a/docs/history/2026-07-14_1958_경로2-노선밖149m-진단.md b/docs/history/2026-07-14_1958_경로2-노선밖149m-진단.md
new file mode 100644
index 0000000..b25470f
--- /dev/null
+++ b/docs/history/2026-07-14_1958_경로2-노선밖149m-진단.md
@@ -0,0 +1,29 @@
+# 2026-07-14 경로 2 '노선밖 149m' 진단 — 실제 대이탈 구간(최대 390m)
+
+**소요 시간**: 9분
+**Context 사용량**: input 11200k / output 3k tokens
+
+## 질문 (스크린샷: 바 4+000~5+510 '끝', 배지 '노선밖 149m')
+
+스테이션 구간인데 왜 노선밖 149m 인가?
+
+## 진단 (데이터 재현)
+
+- 해당 화면은 **경로 2** (비행 chain 3+046~6+334, lookahead 240 설정)
+- 경로 2 비행경로는 **chain 4+15x ~ 5+52x 구간에서 측점 선형을 크게 벗어남**
+ (t=116s 이탈 시작 → 최대 이격 390m(t=164s) → t=289s 복귀)
+- 배지 계산 재현: t=164s 이격 390m → 유효 주시거리 240−(390−60)=−90 →
+ '노선밖 60+90=**150m**' — 스크린샷의 149m 와 일치 ✓
+- 즉 오작동이 아니라 **드론(기존 도로)이 설계 선형에서 실제로 390m 떨어진 구간** —
+ 체이니지 범위(스테이션 구간) 안이라도 노선밖 판정은 '선형과의 횡거리' 기준
+- 설계 선형이 기존 도로와 다른 신설 구간(선형이 밭/온실을 가로지르는 것과 동일 맥락)
+
+## 관찰 (참고)
+
+- 이탈 구간 양끝에서 배지가 4+374 → (노선밖) → 5+433 으로 빠르게 건너뜀 —
+ 우회 도로를 나는 동안 선형 투영이 굴곡부를 가로질러 이동하기 때문(데이터 특성)
+
+## 조치
+
+- 코드 변경 없음 (정상 동작). 원하면 이탈 구간에서도 최근접 측점값을
+ 회색 등 다른 스타일로 계속 표시하는 옵션 구현 가능 — 사용자 판단 대기
diff --git a/docs/history/2026-07-14_2004_팬-회전-지연-이질감-보정.md b/docs/history/2026-07-14_2004_팬-회전-지연-이질감-보정.md
new file mode 100644
index 0000000..d81b9c0
--- /dev/null
+++ b/docs/history/2026-07-14_2004_팬-회전-지연-이질감-보정.md
@@ -0,0 +1,34 @@
+# 2026-07-14 좌우 팬(회전) 시 오버레이 지연 이질감 — POI EMA 우회 + 자세 선행 보정
+
+**소요 시간**: 12분
+**Context 사용량**: input 11800k / output 4k tokens
+
+## 제보
+
+드론이 좌우로 회전(팬)할 때 노선·지장물이 뒤늦게 따라와 이질감.
+
+## 원인 (2중)
+
+1. **지장물(POI) 라벨의 화면 EMA 잔존** — 측점 라벨은 즉시 투영으로 바꿨지만(13:24)
+ POI 라벨은 속도적응 EMA(smoothStep)를 계속 사용 → 팬 시 수 프레임 지연
+2. **짐벌 자세 로그의 기록 지연** — 위치 교차상관 dt(−0.33s)로도 짐벌 yaw 고유의
+ 내부 지연은 보정 안 됨 → 선형(EMA 없음)까지 함께 늦는 공통 원인
+
+## 수정
+
+- StationOverlay: **frameSynced(djmd) 데이터면 POI 라벨도 즉시 투영** (EMA 우회,
+ 0.75px 히스테리시스 유지) — 선형·측점·POI 모두 동일 타이밍
+- poseAt: **자세 선행(attLeadSec)** — 위치는 estFrame 그대로, 자세(yaw/pitch/roll)만
+ attLeadSec 초 앞선 시점에서 샘플(짐벌 로그 지연 상쇄)
+- CameraParams.attLeadSec 추가(기본 0) — camera.json 자동 저장/로드(숫자 통과),
+ 카메라 파라미터 패널에 '자세 선행' 슬라이더(−1~+1s, 0.01 스텝, 시간동기 아래)
+
+## 튜닝 방법 (사용자 안내)
+
+- 좌우로 회전하는 구간 재생 → '자세 선행'을 +0.10~0.30 범위에서 조금씩 올리며
+ 라벨·선형이 영상과 같이 붙어 도는 값을 찾기 (앞서가면 −로)
+- 찾은 값은 'PC 저장'으로 camera.json 에 보존
+
+## 검증
+
+- tsc+vite 빌드 통과, 새 번들(index-BvoVCM-N.js, 빌드 07.14 20:04) 서빙
diff --git a/docs/history/2026-07-14_2013_senH-최대값-100.md b/docs/history/2026-07-14_2013_senH-최대값-100.md
new file mode 100644
index 0000000..abfbf95
--- /dev/null
+++ b/docs/history/2026-07-14_2013_senH-최대값-100.md
@@ -0,0 +1,17 @@
+# 2026-07-14 sen H(센서 높이) 슬라이더 최대값 100mm 확장
+
+**소요 시간**: 2분
+**Context 사용량**: input 12200k / output 1k tokens
+
+## 요청
+
+카메라 파라미터 'sen H' 최대값을 100 까지.
+
+## 수정 — StationOverlay.tsx
+
+- sen H ParamRow: max 36 → **100** (min 6, step 0.05 유지)
+
+## 검증
+
+- tsc+vite 빌드 통과, 새 번들(index-D27TXlz-.js, 빌드 07.14 20:13) 서빙
+- 적용: F5 새로고침(빌드 시각 20:13) 후 카메라 파라미터에서 확인
diff --git a/docs/history/2026-07-14_2022_폴더별-카메라-화면설정-산출.md b/docs/history/2026-07-14_2022_폴더별-카메라-화면설정-산출.md
new file mode 100644
index 0000000..6ec9a1f
--- /dev/null
+++ b/docs/history/2026-07-14_2022_폴더별-카메라-화면설정-산출.md
@@ -0,0 +1,35 @@
+# 2026-07-14 지도 기준 맵핑 — 경로 1-2/1-3/2 카메라·화면표시 설정값 데이터 산출
+
+**소요 시간**: 10분
+**Context 사용량**: input 12600k / output 4k tokens
+
+## 요청
+
+지도(측점 5+800~6+100, 상가교차로)처럼 영상에 지장물이 맵핑되도록
+카메라·화면표시 옵션 설정값을 찾아 적용.
+
+## 방법 — 데이터 기반 산출
+
+- **yawOffset**: 비행 트랙 방위 − 짐벌 방위의 원형 평균(이동 중 표본만).
+ 경로 1-1 검증: 추정 −14.0° vs 사용자 보정값 −13.6° → **0.4° 이내 일치**(방법 검증).
+ 짐벌 방위 편차는 비행마다 다름(자기 간섭·IMU 초기화) → 폴더별 산출 필요
+- **경로표고(dronePathZ)**: 각 폴더 비행 구간 측점 z 의 중앙값
+- 내부 파라미터(초점 29.9 / 센서 27.45×20.85 / 각종 오프셋 0)는
+ 같은 카메라(ZenmuseP1)라 1-1 검증값 그대로
+
+## 적용 (백업 .bak2)
+
+| 폴더 | yawOffset | 경로표고 | 비고 |
+|------|-----------|---------|------|
+| 경로 1-2 | −15.6° | 64m | 기존 offZ 0.6 등 잔재 제거 |
+| 경로 1-3 | **+3.3°** | 101m | 기존 offZ 21.8 잔재 제거 — 부호 반대 편차였음 |
+| 경로 2 | −9.8° | 70m | 기존 offZ 21.8 잔재 제거 |
+
+- camera.json/display.json 모두 새 값으로 교체(각 .bak2 백업), route.json 은 유지
+- display: 드론높이 OFF·표시거리 400m·선형/궤적 ON 등 1-1 구성 복제
+
+## 확인/미세조정 안내
+
+- F5 새로고침 → 해당 폴더 재선택 → 지도의 5+800~6+100·상가교차로 위치와 대조
+- 남는 미세 오차: 좌우 일괄 → yawOffset ±1° / 상하 원거리 → Pitch ±0.5° /
+ 회전 시 지연 → 자세 선행 +0.1~0.3 / 확정 후 'PC 저장'
diff --git a/docs/history/2026-07-14_2031_경로1-3-카메라-기존복원.md b/docs/history/2026-07-14_2031_경로1-3-카메라-기존복원.md
new file mode 100644
index 0000000..efdbb34
--- /dev/null
+++ b/docs/history/2026-07-14_2031_경로1-3-카메라-기존복원.md
@@ -0,0 +1,31 @@
+# 2026-07-14 경로 1-3 산출 설정이 더 안 맞음 — 기존 시각 보정값 복원
+
+**소요 시간**: 9분
+**Context 사용량**: input 13200k / output 3k tokens
+
+## 제보 (스크린샷: 5+500~5+700)
+
+산출 설정(yaw +3.3, 표준 센서) 적용 후 기존보다 더 안 맞음.
+
+## 분석
+
+- 노선 위 구간 한정 재산출도 +3.1° — 이탈 구간 오염이 아니라
+ **1-3 은 카메라가 트랙과 나란하지 않은(도로가 화면 우측) 비행**이라
+ '트랙=카메라' 가정 자체가 성립하지 않음 (1-1/1-2 는 −13.9/−14.1 로 일치)
+- 사용자가 '백업' 폴더로 옮겨둔 기존 설정 확인: **yaw +4.3, pitch −12.9,
+ 센서 46.9×36, cy0 −0.01** — 시각 튜닝으로 찾은 조합(광화각+피치 보정이 한 몸)
+- 결론: 데이터 산출은 카메라·짐벌이 '표준 자세'일 때만 유효 — 1-3 같은
+ 변칙 촬영은 시각 튜닝 조합이 우선
+
+## 조치
+
+- 경로 1-3 camera.json 을 기존 값으로 복원 (+ timeOffsetSec/attLeadSec 0 추가,
+ offZ 는 0 유지 — 기존에도 0)
+- display.json 은 산출본 유지(경로표고 101m 등) — 표고 데이터 기준이라 무해
+- 경로 1-2(−15.6)/경로 2(−9.8) 산출본은 유지 — 확인 후 안 맞으면 각각
+ .bak2(1-2: 폴더 내 / 2: 폴더 내)로 복원 가능
+
+## 확인
+
+- F5 새로고침 → 경로 1-3 재선택 → 5+500~5+700 구간과 도로 정합 확인
+- 미세조정: 좌우 → yawOffset, 원거리 상하 → pitch, 회전 지연 → 자세 선행