Compare commits
6
Commits
ca591ce58c
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fcf686f203 | ||
|
|
67cf646d70 | ||
|
|
0a7044d79b | ||
|
|
87b66d9ed4 | ||
|
|
76a2ab4984 | ||
|
|
f6a9b62dc3 |
@@ -34,3 +34,4 @@ npm-debug.log*
|
|||||||
# OS/에디터
|
# OS/에디터
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
dist-portable/
|
||||||
|
|||||||
@@ -23,13 +23,14 @@
|
|||||||
|
|
||||||
```markdown
|
```markdown
|
||||||
**소요 시간**: X분
|
**소요 시간**: X분
|
||||||
**Context 사용량**: input Xk / output Xk tokens
|
**Context 사용량**: 작업분 input Xk / output Xk tokens · 누적 input Xk / output Xk tokens
|
||||||
```
|
```
|
||||||
|
|
||||||
- 날짜는 오늘 날짜(YYYY-MM-DD), 시간은 작업 완료 시각 24시간제(HHMM), 작업명은 작업 내용을 한글 또는 영문으로 간략히
|
- 날짜는 오늘 날짜(YYYY-MM-DD), 시간은 작업 완료 시각 24시간제(HHMM), 작업명은 작업 내용을 한글 또는 영문으로 간략히
|
||||||
- 같은 날 여러 작업이면 각각 별도 파일 (예: `2026-04-01_1430_StationOverlay-분석.md`)
|
- 같은 날 여러 작업이면 각각 별도 파일 (예: `2026-04-01_1430_StationOverlay-분석.md`)
|
||||||
- 소요 시간: 대화 시작부터 작업 완료까지의 실제 경과 시간
|
- 소요 시간: 해당 작업(요청 접수 → 완료)의 실제 경과 시간
|
||||||
- Context 사용량: 이 대화의 토큰 사용량 (모를 경우 추정값 기재)
|
- Context 사용량: **작업분(그 작업을 처리하며 사용된 토큰)과 누적(대화 전체) 둘 다 기록**
|
||||||
|
(정확한 값을 모르면 추정값 기재)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@
|
|||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700;800&display=swap"
|
href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700;800&display=swap"
|
||||||
/>
|
/>
|
||||||
<title>abcvideo — 대용량 동영상 플레이어</title>
|
<title>StationPlayer — 스테이션 기반 동영상 플레이어</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
+2
-2
@@ -83,8 +83,8 @@ export default function App() {
|
|||||||
{SHOW_LEFT_PANEL && (
|
{SHOW_LEFT_PANEL && (
|
||||||
<div className="w-56 flex-shrink-0 bg-gray-900 border-r border-gray-800 flex flex-col">
|
<div className="w-56 flex-shrink-0 bg-gray-900 border-r border-gray-800 flex flex-col">
|
||||||
<div className="px-4 py-3 border-b border-gray-800">
|
<div className="px-4 py-3 border-b border-gray-800">
|
||||||
<h1 className="text-sm font-bold text-white">abcvideo</h1>
|
<h1 className="text-sm font-bold text-white">StationPlayer</h1>
|
||||||
<p className="text-xs text-gray-500">대용량 영상 플레이어</p>
|
<p className="text-xs text-gray-500">스테이션 기반 동영상 플레이어</p>
|
||||||
<p className="text-xs text-gray-600 mt-0.5">v1.0.0 · {new Date(__BUILD_TIME__).toLocaleString('ko-KR', { month:'2-digit', day:'2-digit', hour:'2-digit', minute:'2-digit' })}</p>
|
<p className="text-xs text-gray-600 mt-0.5">v1.0.0 · {new Date(__BUILD_TIME__).toLocaleString('ko-KR', { month:'2-digit', day:'2-digit', hour:'2-digit', minute:'2-digit' })}</p>
|
||||||
</div>
|
</div>
|
||||||
{/* 상단: 서버 영상 목록 */}
|
{/* 상단: 서버 영상 목록 */}
|
||||||
|
|||||||
@@ -38,9 +38,11 @@ const POI_MERGE_Y = 0.035;
|
|||||||
|
|
||||||
type DispPos = { x: number; y: number; rej: number; vx: number; vy: number; rx?: number; ry?: number };
|
type DispPos = { x: number; y: number; rej: number; vx: number; vy: number; rx?: number; ry?: number };
|
||||||
|
|
||||||
// djmd 프레임 동기 데이터일 때 포즈 평활 상한(±프레임). 위치·자세가 이미 매끄러워
|
// djmd 프레임 동기 데이터일 때 포즈 평활 상한.
|
||||||
// 긴 평활(기본 60 ≈ ±1s)은 회전 시 포즈 지연 → 오버레이 전체가 드론에 '딸려오는' 원인.
|
// 주의: smoothFrame 의 halfWin 단위는 '프레임 배열 행'(CSV 10Hz 행)이라 ±5 = ±0.5초 —
|
||||||
const POSE_SMOOTH_SYNCED_MAX = 5;
|
// 팬(회전) 시작·종료 때 라벨이 늦게 출발하고 늦게 정렬되는 원인이었음.
|
||||||
|
// 프레임 동기 데이터는 위치(djmd)·자세(10Hz 선형보간) 모두 이미 매끄러워 평활 불필요 → 0(끔).
|
||||||
|
const POSE_SMOOTH_SYNCED_MAX = 0;
|
||||||
|
|
||||||
// 속도 적응형 평활(One Euro 방식) 상수.
|
// 속도 적응형 평활(One Euro 방식) 상수.
|
||||||
// 떨림(노이즈)은 매 프레임 방향이 번갈아 → 평활속도≈0 → 강하게 평활(1배속에서 안정).
|
// 떨림(노이즈)은 매 프레임 방향이 번갈아 → 평활속도≈0 → 강하게 평활(1배속에서 안정).
|
||||||
@@ -126,6 +128,10 @@ const DISPLAY_DEFAULTS = {
|
|||||||
showDronePath: true,
|
showDronePath: true,
|
||||||
dronePathZ: 58,
|
dronePathZ: 58,
|
||||||
dronePathAlpha: 0.75,
|
dronePathAlpha: 0.75,
|
||||||
|
clFx: true, // 선형 표시 형태 — true=입체형(반투명 원통+유동 대시), false=기본형(단순 선)
|
||||||
|
clTubeD: 1.2, // 선형 튜브 지름(m) — 입체형에서 원통의 실제 지름
|
||||||
|
clDotDiv: 10, // 측점 사이 점 분할 수(N등분 — 10이면 10m 단위). 1=점 없음
|
||||||
|
|
||||||
smoothHalf: 60,
|
smoothHalf: 60,
|
||||||
emaAlpha: 1.0,
|
emaAlpha: 1.0,
|
||||||
smoothMinAlpha: 0.12, // 정지/떨림 시 최소 추종(작을수록 강한 평활)
|
smoothMinAlpha: 0.12, // 정지/떨림 시 최소 추종(작을수록 강한 평활)
|
||||||
@@ -285,6 +291,9 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
|||||||
// 데이터 ref
|
// 데이터 ref
|
||||||
const allDroneFramesRef = useRef<DroneFrameBasic[]>([]);
|
const allDroneFramesRef = useRef<DroneFrameBasic[]>([]);
|
||||||
const allCenterlinePointsRef = useRef<CenterlinePoint[]>([]);
|
const allCenterlinePointsRef = useRef<CenterlinePoint[]>([]);
|
||||||
|
const clCumRef = useRef<number[]>([]); // 중심선 정점 누적거리(m) — 선형 점 배치용
|
||||||
|
const clStaAnchorsRef = useRef<{ cum: number; km: number }[]>([]); // 측점 체이니지 ↔ 누적거리 앵커
|
||||||
|
const tubeCanvasRef = useRef<HTMLCanvasElement | null>(null); // 원통 셸 오프스크린(불투명→일괄 알파 합성)
|
||||||
const allGeoStationsRef = useRef<GeoPoint[]>([]);
|
const allGeoStationsRef = useRef<GeoPoint[]>([]);
|
||||||
const allPoisRef = useRef<GeoPoint[]>([]);
|
const allPoisRef = useRef<GeoPoint[]>([]);
|
||||||
const allStructuresRef = useRef<GeoPoint[]>([]); // 교량/터널/구교 → POI처럼 표시
|
const allStructuresRef = useRef<GeoPoint[]>([]); // 교량/터널/구교 → POI처럼 표시
|
||||||
@@ -352,10 +361,19 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
|||||||
const showDronePath = useSettingsStore(s => s.showDronePath);
|
const showDronePath = useSettingsStore(s => s.showDronePath);
|
||||||
const setShowDronePath = useSettingsStore(s => s.setShowDronePath);
|
const setShowDronePath = useSettingsStore(s => s.setShowDronePath);
|
||||||
const [dronePathZ, setDronePathZ] = useState(DISPLAY_DEFAULTS.dronePathZ); // 기본 경로 표고
|
const [dronePathZ, setDronePathZ] = useState(DISPLAY_DEFAULTS.dronePathZ); // 기본 경로 표고
|
||||||
|
const [clFx, setClFx] = useState(DISPLAY_DEFAULTS.clFx); // 선형 입체 효과 on/off
|
||||||
|
const clFxRef = useRef(DISPLAY_DEFAULTS.clFx);
|
||||||
|
const [clTubeD, setClTubeD] = useState(DISPLAY_DEFAULTS.clTubeD); // 선형 튜브 지름(m)
|
||||||
|
const clTubeDRef = useRef(DISPLAY_DEFAULTS.clTubeD);
|
||||||
|
const [clDotDiv, setClDotDiv] = useState(DISPLAY_DEFAULTS.clDotDiv); // 측점 분할 수
|
||||||
|
const clDotDivRef = useRef(DISPLAY_DEFAULTS.clDotDiv);
|
||||||
const showDronePathRef = useRef(true);
|
const showDronePathRef = useRef(true);
|
||||||
const dronePathZRef = useRef(78);
|
const dronePathZRef = useRef(78);
|
||||||
useEffect(() => { showDronePathRef.current = showDronePath; }, [showDronePath]);
|
useEffect(() => { showDronePathRef.current = showDronePath; }, [showDronePath]);
|
||||||
useEffect(() => { dronePathZRef.current = dronePathZ; }, [dronePathZ]);
|
useEffect(() => { dronePathZRef.current = dronePathZ; }, [dronePathZ]);
|
||||||
|
useEffect(() => { clFxRef.current = clFx; }, [clFx]);
|
||||||
|
useEffect(() => { clTubeDRef.current = clTubeD; }, [clTubeD]);
|
||||||
|
useEffect(() => { clDotDivRef.current = clDotDiv; }, [clDotDiv]);
|
||||||
// 선형(중심선) 독립 토글 + 드론경로 투명도(0~1) — 토글은 설정 스토어 공유(바 버튼 제어).
|
// 선형(중심선) 독립 토글 + 드론경로 투명도(0~1) — 토글은 설정 스토어 공유(바 버튼 제어).
|
||||||
const showCenterline = useSettingsStore(s => s.showCenterline);
|
const showCenterline = useSettingsStore(s => s.showCenterline);
|
||||||
const setShowCenterline = useSettingsStore(s => s.setShowCenterline);
|
const setShowCenterline = useSettingsStore(s => s.setShowCenterline);
|
||||||
@@ -661,6 +679,17 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
|||||||
// Yaw±(자동추정/드래그 역산 결과) 포함 전체 파라미터가 담긴다.
|
// Yaw±(자동추정/드래그 역산 결과) 포함 전체 파라미터가 담긴다.
|
||||||
// 받은 파일을 영상 폴더에 넣어두면 다음 폴더 선택 때 최우선으로 자동 적용된다.
|
// 받은 파일을 영상 폴더에 넣어두면 다음 폴더 선택 때 최우선으로 자동 적용된다.
|
||||||
const [camSaveState, setCamSaveState] = useState<'idle' | 'ok'>('idle');
|
const [camSaveState, setCamSaveState] = useState<'idle' | 'ok'>('idle');
|
||||||
|
// 환경설정 파일 이름 — 폴더의 CSV(비행로그) 이름 → route.json 노선명 → 영상 base 순.
|
||||||
|
// 예: "제주 중산간도로 경로 1-1.camera.json" (로더의 *.camera.json 규칙으로 자동 인식)
|
||||||
|
const settingsBaseName = useCallback((): string => {
|
||||||
|
const geo = useGeoStore.getState();
|
||||||
|
const csv = geo.folderFiles.find((f) => /\.csv$/i.test(f.name));
|
||||||
|
if (csv) return csv.name.replace(/\.csv$/i, '');
|
||||||
|
const rn = geo.routeMeta?.routeInfo?.name;
|
||||||
|
if (rn && rn.trim()) return rn.trim();
|
||||||
|
return geo.baseName ?? 'settings';
|
||||||
|
}, []);
|
||||||
|
|
||||||
const saveCameraToPc = useCallback(() => {
|
const saveCameraToPc = useCallback(() => {
|
||||||
const geo = useGeoStore.getState();
|
const geo = useGeoStore.getState();
|
||||||
const payload = {
|
const payload = {
|
||||||
@@ -672,12 +701,12 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
|||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
a.href = url;
|
||||||
a.download = 'camera.json';
|
a.download = `${settingsBaseName()}.camera.json`;
|
||||||
a.click();
|
a.click();
|
||||||
setTimeout(() => URL.revokeObjectURL(url), 5000);
|
setTimeout(() => URL.revokeObjectURL(url), 5000);
|
||||||
setCamSaveState('ok');
|
setCamSaveState('ok');
|
||||||
setTimeout(() => setCamSaveState('idle'), 2500);
|
setTimeout(() => setCamSaveState('idle'), 2500);
|
||||||
}, []);
|
}, [settingsBaseName]);
|
||||||
|
|
||||||
// 폴더의 <영상 base>.display.json 화면표시 옵션 자동 적용 — 'PC에 저장' 버튼과 왕복.
|
// 폴더의 <영상 base>.display.json 화면표시 옵션 자동 적용 — 'PC에 저장' 버튼과 왕복.
|
||||||
// localStorage 복원(calib effect)보다 뒤에 선언되어 파일 값이 우선 반영된다.
|
// localStorage 복원(calib effect)보다 뒤에 선언되어 파일 값이 우선 반영된다.
|
||||||
@@ -692,6 +721,9 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
|||||||
const bool = (k: string): boolean | undefined =>
|
const bool = (k: string): boolean | undefined =>
|
||||||
typeof d[k] === 'boolean' ? (d[k] as boolean) : undefined;
|
typeof d[k] === 'boolean' ? (d[k] as boolean) : undefined;
|
||||||
if (num('dronePathZ') !== undefined) setDronePathZ(num('dronePathZ')!);
|
if (num('dronePathZ') !== undefined) setDronePathZ(num('dronePathZ')!);
|
||||||
|
if (bool('clFx') !== undefined) setClFx(bool('clFx')!);
|
||||||
|
if (num('clTubeD') !== undefined) setClTubeD(num('clTubeD')!);
|
||||||
|
if (num('clDotDiv') !== undefined) setClDotDiv(num('clDotDiv')!);
|
||||||
if (num('dronePathAlpha') !== undefined) setDronePathAlpha(num('dronePathAlpha')!);
|
if (num('dronePathAlpha') !== undefined) setDronePathAlpha(num('dronePathAlpha')!);
|
||||||
if (num('smoothHalf') !== undefined) setSmoothHalf(num('smoothHalf')!);
|
if (num('smoothHalf') !== undefined) setSmoothHalf(num('smoothHalf')!);
|
||||||
if (num('emaAlpha') !== undefined) setEmaAlpha(num('emaAlpha')!);
|
if (num('emaAlpha') !== undefined) setEmaAlpha(num('emaAlpha')!);
|
||||||
@@ -723,7 +755,7 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
|||||||
const ss = useSettingsStore.getState();
|
const ss = useSettingsStore.getState();
|
||||||
const payload = {
|
const payload = {
|
||||||
display: {
|
display: {
|
||||||
dronePathZ, dronePathAlpha, smoothHalf, emaAlpha, smoothMinAlpha, smoothSpeedRef,
|
dronePathZ, dronePathAlpha, clFx, clTubeD, clDotDiv, smoothHalf, emaAlpha, smoothMinAlpha, smoothSpeedRef,
|
||||||
maxPoiRange, poiDroneHeight, droneHeightDrop,
|
maxPoiRange, poiDroneHeight, droneHeightDrop,
|
||||||
showCenterline: ss.showCenterline,
|
showCenterline: ss.showCenterline,
|
||||||
showDronePath: ss.showDronePath,
|
showDronePath: ss.showDronePath,
|
||||||
@@ -739,12 +771,12 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
|||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
a.href = url;
|
||||||
a.download = 'display.json';
|
a.download = `${settingsBaseName()}.display.json`;
|
||||||
a.click();
|
a.click();
|
||||||
setTimeout(() => URL.revokeObjectURL(url), 5000);
|
setTimeout(() => URL.revokeObjectURL(url), 5000);
|
||||||
setDispSaveState('ok');
|
setDispSaveState('ok');
|
||||||
setTimeout(() => setDispSaveState('idle'), 2500);
|
setTimeout(() => setDispSaveState('idle'), 2500);
|
||||||
}, [dronePathZ, dronePathAlpha, smoothHalf, emaAlpha, smoothMinAlpha, smoothSpeedRef, maxPoiRange, poiDroneHeight, droneHeightDrop]);
|
}, [dronePathZ, dronePathAlpha, clFx, clTubeD, clDotDiv, smoothHalf, emaAlpha, smoothMinAlpha, smoothSpeedRef, maxPoiRange, poiDroneHeight, droneHeightDrop, settingsBaseName]);
|
||||||
|
|
||||||
// Yaw 자동 추정 — 전진 비행 구간에서 GPS 진행 방위(정확) vs 짐벌 헤딩(나침반 오차 포함)의
|
// Yaw 자동 추정 — 전진 비행 구간에서 GPS 진행 방위(정확) vs 짐벌 헤딩(나침반 오차 포함)의
|
||||||
// 차이 중앙값을 Yaw± 로 설정. 카메라가 진행방향을 보며 촬영하는 도로 추종 비행 가정(근사치).
|
// 차이 중앙값을 Yaw± 로 설정. 카메라가 진행방향을 보며 촬영하는 도로 추종 비행 가정(근사치).
|
||||||
@@ -828,11 +860,40 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
|||||||
}));
|
}));
|
||||||
}, [storeStructures, storePois]);
|
}, [storeStructures, storePois]);
|
||||||
|
|
||||||
// 중심선
|
// 중심선 (+ 누적거리, 측점 체이니지 앵커 — 10m 단위 선형 점 배치용)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
allCenterlinePointsRef.current = storeCenterline;
|
allCenterlinePointsRef.current = storeCenterline;
|
||||||
|
const cum: number[] = [0];
|
||||||
|
if (storeCenterline.length > 1) {
|
||||||
|
const k = Math.cos((storeCenterline[0].lat * Math.PI) / 180) * 111000;
|
||||||
|
for (let i = 1; i < storeCenterline.length; i++) {
|
||||||
|
const a = storeCenterline[i - 1], b = storeCenterline[i];
|
||||||
|
cum.push(cum[i - 1] + Math.hypot((b.lon - a.lon) * k, (b.lat - a.lat) * 111000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
clCumRef.current = cum;
|
||||||
|
// 측점(체이니지 값) ↔ 폴리라인 누적거리 앵커 — 측점 정점이 선형에 삽입되어 있어
|
||||||
|
// 최근접 정점(5m 이내)이 곧 그 측점 위치. 점을 '측점 기준 10m 단위'로 정렬하는 근거.
|
||||||
|
const anchors: { cum: number; km: number }[] = [];
|
||||||
|
if (storeCenterline.length > 1 && storeStations.length) {
|
||||||
|
const k2 = Math.cos((storeCenterline[0].lat * Math.PI) / 180) * 111000;
|
||||||
|
for (const st of storeStations) {
|
||||||
|
const m = st.title.match(/(\d+)\s*[Kk+]\s*(\d+)/);
|
||||||
|
if (!m) continue;
|
||||||
|
const km = parseInt(m[1], 10) * 1000 + parseInt(m[2], 10);
|
||||||
|
let bi = -1, bd = Infinity;
|
||||||
|
for (let i = 0; i < storeCenterline.length; i++) {
|
||||||
|
const p = storeCenterline[i];
|
||||||
|
const d = Math.hypot((p.lon - st.lon) * k2, (p.lat - st.lat) * 111000);
|
||||||
|
if (d < bd) { bd = d; bi = i; }
|
||||||
|
}
|
||||||
|
if (bi >= 0 && bd < 5) anchors.push({ cum: cum[bi], km });
|
||||||
|
}
|
||||||
|
anchors.sort((a, b) => a.cum - b.cum);
|
||||||
|
}
|
||||||
|
clStaAnchorsRef.current = anchors;
|
||||||
setClDataLoaded(storeCenterline.length > 0);
|
setClDataLoaded(storeCenterline.length > 0);
|
||||||
}, [storeCenterline]);
|
}, [storeCenterline, storeStations]);
|
||||||
|
|
||||||
// 드론 프레임
|
// 드론 프레임
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -919,6 +980,12 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
|||||||
};
|
};
|
||||||
|
|
||||||
const centerlineSegs: [number, number, number, number][] = [];
|
const centerlineSegs: [number, number, number, number][] = [];
|
||||||
|
// 튜브 레일(입체형) — 세그먼트를 ~15m 분할, 분할점 사이 [x1,y1,x2,y2,평균거리m].
|
||||||
|
// 폭은 그리기에서 지름/거리 원근 환산.
|
||||||
|
const railTubes: number[][] = [];
|
||||||
|
// 유동 펄스 — 체이니지(월드)에 고정된 점 [x,y,거리m]. 화면 대시는 근거리에서
|
||||||
|
// 지면 대비 역행처럼 보이므로, 월드 좌표로 실속도 이동시켜 항상 '앞으로' 흐르게.
|
||||||
|
const railPulses: number[][] = [];
|
||||||
if (showCenterlineRef.current && allCL.length) {
|
if (showCenterlineRef.current && allCL.length) {
|
||||||
// 표고 미상(z≤0) 중심선(예: 제주 KML 선형 — 고도 전부 0)은 '경로 표고' 평면에 앵커 —
|
// 표고 미상(z≤0) 중심선(예: 제주 KML 선형 — 고도 전부 0)은 '경로 표고' 평면에 앵커 —
|
||||||
// 측점/POI 라벨과 동일 기준. z=0 그대로 투영하면 해수면 높이로 그려져 크게 어긋난다.
|
// 측점/POI 라벨과 동일 기준. z=0 그대로 투영하면 해수면 높이로 그려져 크게 어긋난다.
|
||||||
@@ -959,6 +1026,19 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
|||||||
: null;
|
: null;
|
||||||
const projCL = (lat: number, lon: number, z: number): CameraCoords =>
|
const projCL = (lat: number, lon: number, z: number): CameraCoords =>
|
||||||
toCameraCoords(drone, lat, lon, planeZ ?? (z > 0 ? z : CL_Z), params, worldOrigin);
|
toCameraCoords(drone, lat, lon, planeZ ?? (z > 0 ? z : CL_Z), params, worldOrigin);
|
||||||
|
// 진행방향 벡터(동/북) — 유동 대시가 '가는 방향'으로 흐르도록 세그먼트 정렬 기준.
|
||||||
|
// 실제 이동(±3행 GPS 변위) 우선, 이동이 미미(호버)하면 카메라 yaw 폴백.
|
||||||
|
let travelE = Math.sin((drone.yaw * Math.PI) / 180);
|
||||||
|
let travelN = Math.cos((drone.yaw * Math.PI) / 180);
|
||||||
|
{
|
||||||
|
const fr = allDroneFramesRef.current;
|
||||||
|
const ci = currentFrameIdxRef.current;
|
||||||
|
if (fr.length > 6) {
|
||||||
|
const A = fr[Math.max(0, ci - 3)], B = fr[Math.min(fr.length - 1, ci + 3)];
|
||||||
|
const de = (B.lon - A.lon) * cosLat, dn = B.lat - A.lat;
|
||||||
|
if (Math.hypot(de, dn) * 111000 > 1) { travelE = de; travelN = dn; }
|
||||||
|
}
|
||||||
|
}
|
||||||
for (let i = 1; i < allCL.length; i++) {
|
for (let i = 1; i < allCL.length; i++) {
|
||||||
const p0 = allCL[i - 1], p1 = allCL[i];
|
const p0 = allCL[i - 1], p1 = allCL[i];
|
||||||
const e0 = enOf(p0), e1 = enOf(p1);
|
const e0 = enOf(p0), e1 = enOf(p1);
|
||||||
@@ -984,8 +1064,85 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
|||||||
if (in0) a1 = cut; else a0 = cut;
|
if (in0) a1 = cut; else a0 = cut;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const s = segFrom(projCL(a0.lat, a0.lon, a0.z), projCL(a1.lat, a1.lon, a1.z));
|
// 세그먼트를 진행방향(뒤→앞)으로 정렬 — 대시 흐름이 항상 '가는 방향'이 되게.
|
||||||
if (s) centerlineSegs.push(s);
|
if ((e1[0] - e0[0]) * travelE + (e1[1] - e0[1]) * travelN < 0) {
|
||||||
|
const tmp = a0; a0 = a1; a1 = tmp;
|
||||||
|
}
|
||||||
|
const cT0 = projCL(a0.lat, a0.lon, a0.z);
|
||||||
|
const cT1 = projCL(a1.lat, a1.lon, a1.z);
|
||||||
|
const s = segFrom(cT0, cT1);
|
||||||
|
if (s) {
|
||||||
|
centerlineSegs.push(s);
|
||||||
|
// 커튼: 세그먼트를 ~15m 단위로 분할, 각 경계에서 윗변(z)·아랫변(z−H)을 투영.
|
||||||
|
// 분할 경계마다 수직살을 세워 원근 수렴(멀수록 촘촘·짧아짐)으로 입체감을 만든다.
|
||||||
|
if (clFxRef.current) {
|
||||||
|
const e0c = enOf(a0), e1c = enOf(a1);
|
||||||
|
const segLen = Math.hypot(e1c[0] - e0c[0], e1c[1] - e0c[1]);
|
||||||
|
const N = Math.max(1, Math.min(40, Math.ceil(segLen / 15)));
|
||||||
|
const zt0 = planeZ ?? (a0.z > 0 ? a0.z : CL_Z);
|
||||||
|
const zt1 = planeZ ?? (a1.z > 0 ? a1.z : CL_Z);
|
||||||
|
let prev: { xt: number; yt: number; d: number } | null = null;
|
||||||
|
for (let k = 0; k <= N; k++) {
|
||||||
|
const t = k / N;
|
||||||
|
const wla = a0.lat + (a1.lat - a0.lat) * t;
|
||||||
|
const wlo = a0.lon + (a1.lon - a0.lon) * t;
|
||||||
|
const wzt = zt0 + (zt1 - zt0) * t;
|
||||||
|
const cT = toCameraCoords(drone, wla, wlo, wzt, params, worldOrigin);
|
||||||
|
if (cT.Zc < CLIP_Z) { prev = null; continue; }
|
||||||
|
const pT = pixelFromCamera(cT, params);
|
||||||
|
const cur = { xt: pT.pxRaw, yt: pT.pyRaw, d: cT.distH ?? 0 };
|
||||||
|
if (prev && !(oc(prev.xt, prev.yt) & oc(cur.xt, cur.yt))) {
|
||||||
|
railTubes.push([prev.xt, prev.yt, cur.xt, cur.yt, (prev.d + cur.d) / 2]);
|
||||||
|
}
|
||||||
|
prev = cur;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 선형 점 — 측점(100m) 사이를 십등분한 '10m 단위' 체이니지 고정점(궤적 측정점 방식).
|
||||||
|
// 사용자가 10m 간격으로 검토 가능. 50m 지점은 약간 크게(중간 확인). 100m 지점은
|
||||||
|
// 측점 눈금이 이미 있어 생략. 측점 앵커 없으면 20m 등간격 폴백.
|
||||||
|
if (clFxRef.current) {
|
||||||
|
const cum = clCumRef.current;
|
||||||
|
if (cum.length === allCL.length && cum.length > 1) {
|
||||||
|
const anchors = clStaAnchorsRef.current;
|
||||||
|
const targets: { s: number; big: boolean }[] = [];
|
||||||
|
const div = Math.max(1, Math.round(clDotDivRef.current));
|
||||||
|
if (anchors.length >= 2 && div > 1) {
|
||||||
|
// 인접 측점 구간을 div 등분 — 등분점(측점 자체 제외)에 점, 중간(½)은 크게.
|
||||||
|
for (let ai = 0; ai < anchors.length - 1; ai++) {
|
||||||
|
const A = anchors[ai], B = anchors[ai + 1];
|
||||||
|
if (A.km === B.km) continue;
|
||||||
|
for (let j = 1; j < div; j++) {
|
||||||
|
const tt = j / div;
|
||||||
|
targets.push({ s: A.cum + tt * (B.cum - A.cum), big: div % 2 === 0 && j === div / 2 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (anchors.length >= 2) {
|
||||||
|
// div=1 — 점 없음
|
||||||
|
} else {
|
||||||
|
const total = cum[cum.length - 1];
|
||||||
|
for (let sb = 0; sb <= total; sb += 20) targets.push({ s: sb, big: false });
|
||||||
|
}
|
||||||
|
const total = cum[cum.length - 1];
|
||||||
|
for (const tg of targets) {
|
||||||
|
const sPos = tg.s;
|
||||||
|
if (sPos < 0 || sPos > total) continue;
|
||||||
|
let lo = 0, hi = cum.length - 1;
|
||||||
|
while (hi - lo > 1) { const m = (lo + hi) >> 1; if (cum[m] <= sPos) lo = m; else hi = m; }
|
||||||
|
const tt = cum[hi] > cum[lo] ? (sPos - cum[lo]) / (cum[hi] - cum[lo]) : 0;
|
||||||
|
const la = allCL[lo].lat + (allCL[hi].lat - allCL[lo].lat) * tt;
|
||||||
|
const ln = allCL[lo].lon + (allCL[hi].lon - allCL[lo].lon) * tt;
|
||||||
|
const zz = allCL[lo].z + (allCL[hi].z - allCL[lo].z) * tt;
|
||||||
|
const e = enOf({ lat: la, lon: ln });
|
||||||
|
if (Math.hypot(e[0], e[1]) > clipR) continue;
|
||||||
|
const cc = toCameraCoords(drone, la, ln, planeZ ?? (zz > 0 ? zz : CL_Z), params, worldOrigin);
|
||||||
|
if (cc.Zc < CLIP_Z) continue;
|
||||||
|
const p = pixelFromCamera(cc, params);
|
||||||
|
if (oc(p.pxRaw, p.pyRaw)) continue;
|
||||||
|
railPulses.push([p.pxRaw, p.pyRaw, cc.distH ?? 0, tg.big ? 1 : 0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1009,7 +1166,7 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
|||||||
dronePathPts.push([p.pxRaw, p.pyRaw]);
|
dronePathPts.push([p.pxRaw, p.pyRaw]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { centerlineSegs, dronePathPts };
|
return { centerlineSegs, railTubes, railPulses, dronePathPts };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 보간 포즈 — estFrame(연속 프레임번호)에서 앞뒤 smoothFrame 선형보간(yaw는 최단각). RAF에서 호출.
|
// 보간 포즈 — estFrame(연속 프레임번호)에서 앞뒤 smoothFrame 선형보간(yaw는 최단각). RAF에서 호출.
|
||||||
@@ -1042,6 +1199,55 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
|||||||
};
|
};
|
||||||
}, [smoothFrame, poseHalf]);
|
}, [smoothFrame, poseHalf]);
|
||||||
|
|
||||||
|
// ── 자세 속도적응 필터(One Euro) — frameSynced 전용, RAF 에서 dronePose 에 적용 ──
|
||||||
|
// 평활 제거(POSE_SMOOTH_SYNCED_MAX=0) 후 남는 자세 미세 잡음(짐벌 피치 정수 양자화 등)이
|
||||||
|
// 직진 시 원거리 라벨 떨림으로 보임 → 변화 느릴 때만 강하게 평활(MIN_CUT), 팬처럼 빠르면
|
||||||
|
// 컷오프가 속도에 비례해 올라가 즉시 추종(지연 없음). 위치(djmd)는 잡음 없어 미적용.
|
||||||
|
// 필터 강도 — route.json routeInfo 로 폴더별 조정:
|
||||||
|
// att_min_cut(Hz, 기본 0.8): 저속 변화 평활 강도 — 작을수록 떨림 억제 강함(0=필터 끔 아님, 0.1~3 권장)
|
||||||
|
// att_beta(기본 0.5): 각속도(°/s)당 컷오프 증가 — 클수록 회전 추종 민감
|
||||||
|
const attMinCutRef = useRef(0.8);
|
||||||
|
const attBetaRef = useRef(0.5);
|
||||||
|
useEffect(() => {
|
||||||
|
const ri = routeMeta?.routeInfo as Record<string, unknown> | undefined;
|
||||||
|
const num = (v: unknown): number | null =>
|
||||||
|
typeof v === 'number' && isFinite(v) ? v
|
||||||
|
: typeof v === 'string' && v.trim() !== '' && isFinite(Number(v)) ? Number(v) : null;
|
||||||
|
attMinCutRef.current = num(ri?.att_min_cut) ?? 0.8;
|
||||||
|
attBetaRef.current = num(ri?.att_beta) ?? 0.5;
|
||||||
|
if (ri?.att_min_cut !== undefined || ri?.att_beta !== undefined) {
|
||||||
|
console.log(`[오버레이] 자세 필터: min_cut ${attMinCutRef.current}Hz, beta ${attBetaRef.current} (route.json)`);
|
||||||
|
}
|
||||||
|
}, [routeMeta]);
|
||||||
|
const attFilterRef = useRef<{ yaw: number; pitch: number; roll: number; dyaw: number; dpitch: number; droll: number; ts: number } | null>(null);
|
||||||
|
const filterAttitude = useCallback((p: DroneFrameBasic): DroneFrameBasic => {
|
||||||
|
if (!frameSyncedRef.current) { attFilterRef.current = null; return p; }
|
||||||
|
const now = performance.now() / 1000;
|
||||||
|
const st = attFilterRef.current;
|
||||||
|
const angDiff = (a: number, b: number): number => ((a - b + 540) % 360) - 180;
|
||||||
|
// 초기화/시크·세그먼트 전환(큰 점프)/장시간 정지 후에는 필터 리셋
|
||||||
|
if (!st || now - st.ts > 0.5 || Math.abs(angDiff(p.yaw, st.yaw)) > 30) {
|
||||||
|
attFilterRef.current = { yaw: p.yaw, pitch: p.pitch, roll: p.roll, dyaw: 0, dpitch: 0, droll: 0, ts: now };
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
const dt = Math.max(1e-3, Math.min(0.1, now - st.ts));
|
||||||
|
const alphaOf = (fc: number): number => { const r = 2 * Math.PI * fc * dt; return r / (r + 1); };
|
||||||
|
const MIN_CUT = attMinCutRef.current; // Hz — 저속 변화 평활 강도(작을수록 강한 평활)
|
||||||
|
const BETA = attBetaRef.current; // 속도(°/s)당 컷오프 증가 — 팬 시 즉시 추종
|
||||||
|
const D_CUT = 1.0; // 속도 추정 평활
|
||||||
|
const one = (val: number, prev: number, dprev: number, ang: boolean): [number, number] => {
|
||||||
|
const diff = ang ? angDiff(val, prev) : val - prev;
|
||||||
|
const d = dprev + alphaOf(D_CUT) * (diff / dt - dprev);
|
||||||
|
const a = alphaOf(MIN_CUT + BETA * Math.abs(d));
|
||||||
|
return [prev + a * diff, d];
|
||||||
|
};
|
||||||
|
const [yaw, dyaw] = one(p.yaw, st.yaw, st.dyaw, true);
|
||||||
|
const [pitch, dpitch] = one(p.pitch, st.pitch, st.dpitch, false);
|
||||||
|
const [roll, droll] = one(p.roll, st.roll, st.droll, false);
|
||||||
|
attFilterRef.current = { yaw, pitch, roll, dyaw, dpitch, droll, ts: now };
|
||||||
|
return { ...p, yaw, pitch, roll };
|
||||||
|
}, []);
|
||||||
|
|
||||||
// 텍스트 사전 계산 — requestIdleCallback으로 백그라운드 실행
|
// 텍스트 사전 계산 — requestIdleCallback으로 백그라운드 실행
|
||||||
const startLabelPrecompute = useCallback((currentParams: CameraParams, currentSmoothHalf: number, currentMaxRange: number, currentDroneHeight: boolean, currentDroneDrop: number) => {
|
const startLabelPrecompute = useCallback((currentParams: CameraParams, currentSmoothHalf: number, currentMaxRange: number, currentDroneHeight: boolean, currentDroneDrop: number) => {
|
||||||
const id = ++precomputeIdRef.current;
|
const id = ++precomputeIdRef.current;
|
||||||
@@ -1366,9 +1572,9 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
|||||||
const estFrame = estTime * fpsRef.current;
|
const estFrame = estTime * fpsRef.current;
|
||||||
|
|
||||||
// 연속 보간 포즈(라인·라벨 공통) — 매 RAF 프레임 직접 투영해 부드럽게.
|
// 연속 보간 포즈(라인·라벨 공통) — 매 RAF 프레임 직접 투영해 부드럽게.
|
||||||
const dronePose = poseAt(estFrame) ?? (allDroneFramesRef.current.length
|
const dronePose = filterAttitude(poseAt(estFrame) ?? (allDroneFramesRef.current.length
|
||||||
? smoothFrame(allDroneFramesRef.current, currentFrameIdxRef.current, poseHalf())
|
? smoothFrame(allDroneFramesRef.current, currentFrameIdxRef.current, poseHalf())
|
||||||
: currentDroneFrameRef.current!);
|
: currentDroneFrameRef.current!));
|
||||||
const lines = buildLines(dronePose);
|
const lines = buildLines(dronePose);
|
||||||
|
|
||||||
// 나침반 미니맵(heading-up) 회전 — 영상과 즉시 동기. dronePose.yaw 는 ±smoothHalf(기본 60fr ≈2s)
|
// 나침반 미니맵(heading-up) 회전 — 영상과 즉시 동기. dronePose.yaw 는 ±smoothHalf(기본 60fr ≈2s)
|
||||||
@@ -1400,8 +1606,11 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 선로 중심선 (선형) — 독립 토글
|
// 선로 중심선 (선형) — 독립 토글. '홀로그램 펜스' 입체 표현:
|
||||||
if (showCenterlineRef.current && lines.centerlineSegs.length > 0) {
|
// ① 그라데이션 커튼(위 진함→아래 소멸) ② 수직살(원근 수렴 + 원거리 페이드)
|
||||||
|
// ③ 상단 레일 글로우 ④ 진행방향 유동 대시(흐르는 빛)
|
||||||
|
if (showCenterlineRef.current && lines.centerlineSegs.length > 0 && !clFxRef.current) {
|
||||||
|
// 입체 효과 꺼짐 — 종전처럼 단순 선만
|
||||||
ctx.strokeStyle = 'rgba(255,50,50,0.85)';
|
ctx.strokeStyle = 'rgba(255,50,50,0.85)';
|
||||||
ctx.lineWidth = 3;
|
ctx.lineWidth = 3;
|
||||||
ctx.setLineDash([]);
|
ctx.setLineDash([]);
|
||||||
@@ -1411,6 +1620,90 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
|||||||
ctx.lineTo(vx(px2), vy(py2));
|
ctx.lineTo(vx(px2), vy(py2));
|
||||||
}
|
}
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
} else if (showCenterlineRef.current && lines.centerlineSegs.length > 0) {
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
// 상단 레일 — 튜브(지름 있는 원형 단면): 각크기(지름/거리)를 픽셀 폭으로 환산해
|
||||||
|
// 가까울수록 굵고 멀수록 가늘게 + 3중 스트로크(외곽 어둡게/본체/하이라이트)로 원통 음영.
|
||||||
|
const railPath = (): void => {
|
||||||
|
ctx.beginPath();
|
||||||
|
for (const [px1, py1, px2, py2] of lines.centerlineSegs) {
|
||||||
|
ctx.moveTo(vx(px1), vy(py1));
|
||||||
|
ctx.lineTo(vx(px2), vy(py2));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// 반투명 원통 셸 — 중심의 진한 실선을 감싸는 유리관 느낌(다층 저알파 스트로크).
|
||||||
|
// 폭은 지름/거리 원근 환산, 안쪽으로 갈수록 은은히 밝아져 원통 볼륨감.
|
||||||
|
if (lines.railTubes.length > 0) {
|
||||||
|
const pp = paramsRef.current;
|
||||||
|
const hfov = 2 * Math.atan((pp.sensorW ?? 36) / (2 * pp.focalLen));
|
||||||
|
const pxPerRad = (coverRef.current?.dispW || canvasSizeRef.current.w || 1920) / hfov;
|
||||||
|
const D = clTubeDRef.current;
|
||||||
|
// 이음매 중첩 제거: 반투명 조각을 직접 겹치면 이음/폭 단차마다 알파가 중첩돼
|
||||||
|
// 잘려 보인다 → 오프스크린에 '불투명'으로 그린 뒤 전체를 한 번에 알파 합성.
|
||||||
|
const cv = ctx.canvas;
|
||||||
|
let oc2 = tubeCanvasRef.current;
|
||||||
|
if (!oc2 || oc2.width !== cv.width || oc2.height !== cv.height) {
|
||||||
|
oc2 = document.createElement('canvas');
|
||||||
|
oc2.width = cv.width;
|
||||||
|
oc2.height = cv.height;
|
||||||
|
tubeCanvasRef.current = oc2;
|
||||||
|
}
|
||||||
|
const otx = oc2.getContext('2d');
|
||||||
|
if (otx) {
|
||||||
|
otx.clearRect(0, 0, oc2.width, oc2.height);
|
||||||
|
otx.lineCap = 'round';
|
||||||
|
const passes: Array<[number, string]> = [
|
||||||
|
[1.0, 'rgb(150,30,30)'],
|
||||||
|
[0.66, 'rgb(225,60,58)'],
|
||||||
|
[0.34, 'rgb(255,140,125)'],
|
||||||
|
];
|
||||||
|
// 폭 버킷(0.5px)별로 묶어 스트로크 횟수 절감(불투명이라 겹침 무해)
|
||||||
|
for (const [fw, col] of passes) {
|
||||||
|
otx.strokeStyle = col;
|
||||||
|
const buckets = new Map<number, number[][]>();
|
||||||
|
for (const tb of lines.railTubes) {
|
||||||
|
const w = Math.min(44, Math.max(2, (D / Math.max(5, tb[4])) * pxPerRad)) * fw;
|
||||||
|
const key = Math.round(w * 2);
|
||||||
|
let arr = buckets.get(key);
|
||||||
|
if (!arr) { arr = []; buckets.set(key, arr); }
|
||||||
|
arr.push(tb);
|
||||||
|
}
|
||||||
|
for (const [key, arr] of buckets) {
|
||||||
|
otx.lineWidth = key / 2;
|
||||||
|
otx.beginPath();
|
||||||
|
for (const tb of arr) {
|
||||||
|
otx.moveTo(vx(tb[0]), vy(tb[1]));
|
||||||
|
otx.lineTo(vx(tb[2]), vy(tb[3]));
|
||||||
|
}
|
||||||
|
otx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalAlpha = 0.32;
|
||||||
|
ctx.drawImage(oc2, 0, 0);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ctx.strokeStyle = 'rgba(255,50,50,0.20)';
|
||||||
|
ctx.lineWidth = 8;
|
||||||
|
railPath();
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
// 중심선(코어) — 항상 진한 실선. 반투명 원통이 이 선을 감싼다.
|
||||||
|
ctx.strokeStyle = 'rgba(205,25,25,0.95)';
|
||||||
|
ctx.lineWidth = 2.5;
|
||||||
|
railPath();
|
||||||
|
ctx.stroke();
|
||||||
|
// 선형 점 — 측점 체이니지 10m 단위 고정점(궤적 측정점과 동일 질감).
|
||||||
|
// 50m 지점은 약간 크게 — 측점 사이 중간 위치를 한눈에 확인.
|
||||||
|
if (lines.railPulses.length > 0) {
|
||||||
|
ctx.fillStyle = 'rgba(255,230,90,0.9)';
|
||||||
|
for (const q of lines.railPulses) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(vx(q[0]), vy(q[1]), q[3] ? 2.8 : 1.8, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 드론 경로 — 하나의 연속 폴리라인(둥근 연결)으로 부드럽게 + 작은 점. 독립 토글 + 투명도
|
// 드론 경로 — 하나의 연속 폴리라인(둥근 연결)으로 부드럽게 + 작은 점. 독립 토글 + 투명도
|
||||||
@@ -1924,6 +2217,9 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
|||||||
setShowCenterline(bool('showCenterline', DISPLAY_DEFAULTS.showCenterline));
|
setShowCenterline(bool('showCenterline', DISPLAY_DEFAULTS.showCenterline));
|
||||||
setShowDronePath(bool('showDronePath', DISPLAY_DEFAULTS.showDronePath));
|
setShowDronePath(bool('showDronePath', DISPLAY_DEFAULTS.showDronePath));
|
||||||
setDronePathZ(num('dronePathZ', DISPLAY_DEFAULTS.dronePathZ));
|
setDronePathZ(num('dronePathZ', DISPLAY_DEFAULTS.dronePathZ));
|
||||||
|
setClFx(bool('clFx', DISPLAY_DEFAULTS.clFx));
|
||||||
|
setClTubeD(num('clTubeD', DISPLAY_DEFAULTS.clTubeD));
|
||||||
|
setClDotDiv(num('clDotDiv', DISPLAY_DEFAULTS.clDotDiv));
|
||||||
setDronePathAlpha(num('dronePathAlpha', DISPLAY_DEFAULTS.dronePathAlpha));
|
setDronePathAlpha(num('dronePathAlpha', DISPLAY_DEFAULTS.dronePathAlpha));
|
||||||
setSmoothHalf(num('smoothHalf', DISPLAY_DEFAULTS.smoothHalf));
|
setSmoothHalf(num('smoothHalf', DISPLAY_DEFAULTS.smoothHalf));
|
||||||
setEmaAlpha(num('emaAlpha', DISPLAY_DEFAULTS.emaAlpha));
|
setEmaAlpha(num('emaAlpha', DISPLAY_DEFAULTS.emaAlpha));
|
||||||
@@ -2091,6 +2387,16 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
|||||||
{/* 선형/드론궤적/좌측패널 토글은 하단 재생바(VideoPlayer)로 이동. 측점 진단 버튼은 삭제. */}
|
{/* 선형/드론궤적/좌측패널 토글은 하단 재생바(VideoPlayer)로 이동. 측점 진단 버튼은 삭제. */}
|
||||||
{/* 경로표고·투명도는 드론궤적 ON/OFF 무관하게 항상 표시. */}
|
{/* 경로표고·투명도는 드론궤적 ON/OFF 무관하게 항상 표시. */}
|
||||||
<ParamRow label="경로 표고" tip={`GPS 측정점(노란 점)을 표고 ${dronePathZ}m 에 투영·연결`} value={dronePathZ} min={-10} max={pathZMax} step={1} unit="m" decimals={0} onChange={v => setDronePathZ(Math.round(v))} />
|
<ParamRow label="경로 표고" tip={`GPS 측정점(노란 점)을 표고 ${dronePathZ}m 에 투영·연결`} value={dronePathZ} min={-10} max={pathZMax} step={1} unit="m" decimals={0} onChange={v => setDronePathZ(Math.round(v))} />
|
||||||
|
<button onClick={() => setClFx(v => !v)}
|
||||||
|
className={`w-full text-[11px] px-2 py-1 rounded border transition-colors ${clFx ? 'bg-red-500/70 border-red-300 text-white font-bold' : 'bg-black/60 border-gray-600 text-gray-200 hover:border-gray-400'}`}>
|
||||||
|
{clFx ? '■ 선형 입체형 (원통 + 유동 대시)' : '▶ 선형 기본형 (단순 선)'}
|
||||||
|
</button>
|
||||||
|
{clFx && (
|
||||||
|
<>
|
||||||
|
<ParamRow label="선형 지름" tip="입체형 원통의 실제 지름(m) — 가까울수록 굵고 멀수록 가늘게 원근 표현. display.json(clTubeD) 저장" value={clTubeD} min={0.2} max={5} step={0.1} unit="m" decimals={1} onChange={v => setClTubeD(v)} />
|
||||||
|
<ParamRow label="측점 분할" tip="측점(100m) 사이를 N등분한 위치에 노란 점 표시 — 10이면 10m 단위. 1 = 점 없음. 중간(½) 지점은 크게. display.json(clDotDiv) 저장" value={clDotDiv} min={1} max={20} step={1} unit="등분" decimals={0} onChange={v => setClDotDiv(Math.round(v))} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<ParamRow label="투명도" tip={`드론 궤적 선/점 투명도 α=${dronePathAlpha.toFixed(2)}`} value={dronePathAlpha} min={0.1} max={1.0} step={0.05} unit="" decimals={2} onChange={v => setDronePathAlpha(v)} />
|
<ParamRow label="투명도" tip={`드론 궤적 선/점 투명도 α=${dronePathAlpha.toFixed(2)}`} value={dronePathAlpha} min={0.1} max={1.0} step={0.05} unit="" decimals={2} onChange={v => setDronePathAlpha(v)} />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[10px] text-gray-500 uppercase tracking-wider mb-1.5 border-t border-gray-700 pt-2">스무딩 <span className="text-gray-600">(재계산 500ms 후)</span></div>
|
<div className="text-[10px] text-gray-500 uppercase tracking-wider mb-1.5 border-t border-gray-700 pt-2">스무딩 <span className="text-gray-600">(재계산 500ms 후)</span></div>
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ function collectDropEntry(entry: FileSystemEntry, out: File[]): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface VideoPlayerHandle {
|
export interface VideoPlayerHandle {
|
||||||
loadLocalFile: (file: File) => void;
|
loadLocalFile: (file: File, autoPlay?: boolean, startAtSec?: number) => void;
|
||||||
loadServerStream: (videoId: string, filename: string) => void | Promise<void>;
|
loadServerStream: (videoId: string, filename: string) => void | Promise<void>;
|
||||||
seekTo: (time: number) => void;
|
seekTo: (time: number) => void;
|
||||||
getVideoElement: () => HTMLVideoElement | null;
|
getVideoElement: () => HTMLVideoElement | null;
|
||||||
@@ -232,6 +232,21 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 측점 검색 전 구간 점프 — 다른 세그먼트의 통과 지점으로 전환+시크.
|
||||||
|
// 같은 세그먼트면 시크만, 다르면 loadSegment 후 해당 시각부터(재생 상태 유지).
|
||||||
|
const handleSeekSegment = async (i: number, timeSec: number): Promise<void> => {
|
||||||
|
const geo = useGeoStore.getState();
|
||||||
|
if (usePlayerStore.getState().source?.kind !== 'local') return;
|
||||||
|
if (i === geo.videoIndex) {
|
||||||
|
playerRef.current?.currentTime(timeSec);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const wasPlaying = usePlayerStore.getState().playing;
|
||||||
|
const file = await geo.loadSegment(i);
|
||||||
|
if (!file) return;
|
||||||
|
loadLocalFile(file, wasPlaying, timeSec);
|
||||||
|
};
|
||||||
|
|
||||||
// 분할 영상 연속재생 — 영상이 끝나면 같은 폴더의 다음 영상(이름 오름차순)을 자동 재생.
|
// 분할 영상 연속재생 — 영상이 끝나면 같은 폴더의 다음 영상(이름 오름차순)을 자동 재생.
|
||||||
// 드론 로그(시간 기반)는 loadSegment 가 누적 오프셋으로 재정렬해 궤적 정합을 유지한다.
|
// 드론 로그(시간 기반)는 loadSegment 가 누적 오프셋으로 재정렬해 궤적 정합을 유지한다.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -625,6 +640,7 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
|
|||||||
onStop={handleStop}
|
onStop={handleStop}
|
||||||
onCapture={handleCaptureFrame}
|
onCapture={handleCaptureFrame}
|
||||||
onSeek={handleSeek}
|
onSeek={handleSeek}
|
||||||
|
onSeekSegment={(i, t) => { void handleSeekSegment(i, t); }}
|
||||||
showStations={showStations}
|
showStations={showStations}
|
||||||
onToggleStations={() => setShowStations((v) => !v)}
|
onToggleStations={() => setShowStations((v) => !v)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ export function useVideoPlayer(containerRef: React.RefObject<HTMLDivElement | nu
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadLocalFile = useCallback((file: File, autoPlay = false) => {
|
const loadLocalFile = useCallback((file: File, autoPlay = false, startAtSec = 0) => {
|
||||||
const player = playerRef.current;
|
const player = playerRef.current;
|
||||||
if (!player) return;
|
if (!player) return;
|
||||||
|
|
||||||
@@ -96,7 +96,7 @@ export function useVideoPlayer(containerRef: React.RefObject<HTMLDivElement | nu
|
|||||||
// - autoPlay=true(분할영상 연속재생·재생목록 선택): 0초부터 즉시 재생
|
// - autoPlay=true(분할영상 연속재생·재생목록 선택): 0초부터 즉시 재생
|
||||||
player.one('loadedmetadata', () => {
|
player.one('loadedmetadata', () => {
|
||||||
if (player.isDisposed() || objectUrlRef.current !== objectUrl) return;
|
if (player.isDisposed() || objectUrlRef.current !== objectUrl) return;
|
||||||
player.currentTime(0);
|
player.currentTime(startAtSec > 0 ? startAtSec : 0);
|
||||||
if (autoPlay) {
|
if (autoPlay) {
|
||||||
const p = player.play();
|
const p = player.play();
|
||||||
if (p && typeof p.catch === 'function') p.catch(() => {});
|
if (p && typeof p.catch === 'function') p.catch(() => {});
|
||||||
@@ -110,7 +110,7 @@ export function useVideoPlayer(containerRef: React.RefObject<HTMLDivElement | nu
|
|||||||
// 새 소스 메타데이터 로드 전까지 남아 보이는 문제 방지. (src 교체는 pause 이벤트를
|
// 새 소스 메타데이터 로드 전까지 남아 보이는 문제 방지. (src 교체는 pause 이벤트를
|
||||||
// 보장하지 않아 store.playing 이 true 로 남을 수 있음)
|
// 보장하지 않아 store.playing 이 true 로 남을 수 있음)
|
||||||
store.setPlaying(false);
|
store.setPlaying(false);
|
||||||
store.setCurrentTime(0);
|
store.setCurrentTime(startAtSec > 0 ? startAtSec : 0);
|
||||||
store.setDuration(0);
|
store.setDuration(0);
|
||||||
if (prevUrl) URL.revokeObjectURL(prevUrl);
|
if (prevUrl) URL.revokeObjectURL(prevUrl);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
|||||||
@@ -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,6 +471,14 @@ export function StationBar({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (lookDist !== null && lookDist > 0 && isFinite(f.yaw)) {
|
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 − (노선 이격의 초과분).
|
// 연속 모델: 유효 주시거리 = lookDist − (노선 이격의 초과분).
|
||||||
// 노선 위(이격 ≤ OFF_ROUTE_M): 초과분 0 → 항상 +lookDist (횡 이동에 안정)
|
// 노선 위(이격 ≤ OFF_ROUTE_M): 초과분 0 → 항상 +lookDist (횡 이동에 안정)
|
||||||
// 접근/이탈: 이격이 줄수록 유효 주시거리가 0→lookDist 로 늘어
|
// 접근/이탈: 이격이 줄수록 유효 주시거리가 0→lookDist 로 늘어
|
||||||
@@ -374,7 +490,9 @@ export function StationBar({
|
|||||||
if (t) {
|
if (t) {
|
||||||
const yawRad = (f.yaw * Math.PI) / 180;
|
const yawRad = (f.yaw * Math.PI) / 180;
|
||||||
const dot = Math.sin(yawRad) * t.x + Math.cos(yawRad) * t.y;
|
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); // 값 표시 상태(노선밖 아님)
|
viewOffM = Math.min(pj.distM, OFF_ROUTE_M); // 값 표시 상태(노선밖 아님)
|
||||||
} else {
|
} else {
|
||||||
@@ -383,12 +501,18 @@ export function StationBar({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if (offSide === 1) {
|
||||||
|
// 오프셋 미설정: 물리 투영이 종점에 클램프 — 끝점까지의 직선거리로 카운트업
|
||||||
|
beyondM = pj.distM;
|
||||||
|
}
|
||||||
out[i] = {
|
out[i] = {
|
||||||
frameNum: f.frame,
|
frameNum: f.frame,
|
||||||
km: nearestStationKm(f, stations),
|
km: nearestStationKm(f, stations),
|
||||||
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,
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -64,6 +64,10 @@ interface GeoStore {
|
|||||||
/** frames 가 djmd(영상 내장 텔레메트리) 프레임 동기 위치로 정밀화됐는지.
|
/** frames 가 djmd(영상 내장 텔레메트리) 프레임 동기 위치로 정밀화됐는지.
|
||||||
* true 면 입력이 매끄러워 오버레이 포즈 평활(smoothHalf)을 최소로 제한한다. */
|
* true 면 입력이 매끄러워 오버레이 포즈 평활(smoothHalf)을 최소로 제한한다. */
|
||||||
frameSynced: boolean;
|
frameSynced: boolean;
|
||||||
|
/** 전체 비행(모든 세그먼트) 프레임 — 측점 '전 구간' 검색용. */
|
||||||
|
fullFrames: DroneFrame[];
|
||||||
|
/** djmd 교차상관 시간 오프셋(초) — 영상시간 = CSV시간 − 이 값. 검색 시각 변환용. */
|
||||||
|
djmdDtSec: number;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 폴더 선택 파일에서 지리정보를 파싱해 스토어에 적재한다.
|
* 폴더 선택 파일에서 지리정보를 파싱해 스토어에 적재한다.
|
||||||
@@ -88,6 +92,9 @@ interface GeoStore {
|
|||||||
const EMPTY = {
|
const EMPTY = {
|
||||||
loaded: false,
|
loaded: false,
|
||||||
frames: [] as DroneFrame[],
|
frames: [] as DroneFrame[],
|
||||||
|
/** 전체 비행(모든 세그먼트) 프레임 — 측점 '전 구간' 검색용. 표시용 frames 와 별개. */
|
||||||
|
fullFrames: [] as DroneFrame[],
|
||||||
|
djmdDtSec: 0,
|
||||||
frameSynced: false,
|
frameSynced: false,
|
||||||
pois: [] as GeoPoint[],
|
pois: [] as GeoPoint[],
|
||||||
basePois: [] as GeoPoint[],
|
basePois: [] as GeoPoint[],
|
||||||
@@ -128,8 +135,8 @@ async function refineWithDjmd(
|
|||||||
const s = get();
|
const s = get();
|
||||||
// 파싱 중 세그먼트/폴더가 바뀌었으면 폐기
|
// 파싱 중 세그먼트/폴더가 바뀌었으면 폐기
|
||||||
if (s.videoIndex !== forIndex || s.videoFiles[forIndex] !== videoFile || s.frames !== s0.frames) return;
|
if (s.videoIndex !== forIndex || s.videoFiles[forIndex] !== videoFile || s.frames !== s0.frames) return;
|
||||||
set({ frames: refined, frameSynced: true });
|
set({ frames: refined.frames, frameSynced: true, djmdDtSec: refined.dtSec });
|
||||||
console.log(`[djmd] 영상 내장 텔레메트리로 위치/고도 정밀화 적용 (${refined.length}행, 프레임 동기)`);
|
console.log(`[djmd] 영상 내장 텔레메트리로 위치/고도 정밀화 적용 (${refined.frames.length}행, 프레임 동기)`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[djmd] 정밀 위치 적용 실패(CSV 기반 유지):', e);
|
console.warn('[djmd] 정밀 위치 적용 실패(CSV 기반 유지):', e);
|
||||||
}
|
}
|
||||||
@@ -143,6 +150,7 @@ export const useGeoStore = create<GeoStore>((set, get) => ({
|
|||||||
set({
|
set({
|
||||||
loaded: true,
|
loaded: true,
|
||||||
frames: data.frames,
|
frames: data.frames,
|
||||||
|
fullFrames: data.fullFrames,
|
||||||
frameSynced: false,
|
frameSynced: false,
|
||||||
basePois: data.pois,
|
basePois: data.pois,
|
||||||
poiOverrides: data.poiOverrides,
|
poiOverrides: data.poiOverrides,
|
||||||
|
|||||||
@@ -146,6 +146,10 @@ export interface RouteInfo {
|
|||||||
* N=진행방향 +N(m) 고정, "auto"=자동(짐벌 피치·AGL 기반 카메라 시선 중심).
|
* N=진행방향 +N(m) 고정, "auto"=자동(짐벌 피치·AGL 기반 카메라 시선 중심).
|
||||||
* 활성 시 노선 밖(접근/이탈)에서는 시선 지점을 노선에 투영해 측점값 전환이 앞당겨진다. */
|
* 활성 시 노선 밖(접근/이탈)에서는 시선 지점을 노선에 투영해 측점값 전환이 앞당겨진다. */
|
||||||
station_lookahead?: number | string;
|
station_lookahead?: number | string;
|
||||||
|
/** 오버레이 자세 필터 저속 컷오프(Hz, 기본 0.8) — 작을수록 직진 시 떨림 억제가 강함. */
|
||||||
|
att_min_cut?: number;
|
||||||
|
/** 오버레이 자세 필터 속도 계수(기본 0.5) — 클수록 팬(회전) 추종이 민감. */
|
||||||
|
att_beta?: number;
|
||||||
/** 측점 인정 범위(m): station 위치를 이 거리 이내로 지날 때 마커 표시. 기본 40. 폴더별 조절. */
|
/** 측점 인정 범위(m): station 위치를 이 거리 이내로 지날 때 마커 표시. 기본 40. 폴더별 조절. */
|
||||||
stationTolerance?: number;
|
stationTolerance?: number;
|
||||||
/**
|
/**
|
||||||
@@ -180,6 +184,8 @@ export interface FolderGeoData {
|
|||||||
videoFile: File | null;
|
videoFile: File | null;
|
||||||
baseName: string | null;
|
baseName: string | null;
|
||||||
frames: DroneFrame[];
|
frames: DroneFrame[];
|
||||||
|
/** 전체 비행(모든 세그먼트) 프레임 — 측점 '전 구간' 검색용. 표시용 frames 와 별개. */
|
||||||
|
fullFrames: DroneFrame[];
|
||||||
pois: GeoPoint[]; // type==='poi' (KMZ 원본: 지장물·출입문 등)
|
pois: GeoPoint[]; // type==='poi' (KMZ 원본: 지장물·출입문 등)
|
||||||
stations: GeoPoint[]; // type==='station' (측점.csv, stationOrder 정렬)
|
stations: GeoPoint[]; // type==='station' (측점.csv, stationOrder 정렬)
|
||||||
centerline: CenterlinePoint[]; // 측점을 mileage 순으로 이은 폴리라인 (v2.0엔 center.csv 없음)
|
centerline: CenterlinePoint[]; // 측점을 mileage 순으로 이은 폴리라인 (v2.0엔 center.csv 없음)
|
||||||
|
|||||||
@@ -234,7 +234,7 @@ export async function refineFramesWithDjmd(
|
|||||||
videoFile: File,
|
videoFile: File,
|
||||||
frames: DroneFrame[],
|
frames: DroneFrame[],
|
||||||
useEllipsoidal: boolean,
|
useEllipsoidal: boolean,
|
||||||
): Promise<DroneFrame[] | null> {
|
): Promise<{ frames: DroneFrame[]; dtSec: number } | null> {
|
||||||
if (!frames.length) return null;
|
if (!frames.length) return null;
|
||||||
const samples = await parseDjmdPositions(videoFile);
|
const samples = await parseDjmdPositions(videoFile);
|
||||||
if (!samples) return null;
|
if (!samples) return null;
|
||||||
@@ -306,7 +306,7 @@ export async function refineFramesWithDjmd(
|
|||||||
console.log(`[djmd] CSV↔영상 시간 오프셋 ${bestDt.toFixed(2)}s 추정 (잔차 ${bestC.toFixed(1)}m) — 자세 재정렬 적용`);
|
console.log(`[djmd] CSV↔영상 시간 오프셋 ${bestDt.toFixed(2)}s 추정 (잔차 ${bestC.toFixed(1)}m) — 자세 재정렬 적용`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return frames.map((f) => {
|
const refined = frames.map((f) => {
|
||||||
const p = at(f.frame); // 위치/고도: djmd (프레임 동기)
|
const p = at(f.frame); // 위치/고도: djmd (프레임 동기)
|
||||||
const a = csvAt(f.frame / FPS + bestDt); // 자세: CSV 를 추정 오프셋으로 재시각화
|
const a = csvAt(f.frame / FPS + bestDt); // 자세: CSV 를 추정 오프셋으로 재시각화
|
||||||
return {
|
return {
|
||||||
@@ -315,4 +315,7 @@ export async function refineFramesWithDjmd(
|
|||||||
yaw: a.yaw, pitch: a.pitch, roll: a.roll,
|
yaw: a.yaw, pitch: a.pitch, roll: a.roll,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
// dtSec: CSV↔영상 시간 오프셋 — 영상시간 = CSV시간 − dtSec.
|
||||||
|
// 측점 검색(CSV 축 인덱스)이 영상 시각으로 변환할 때 사용(0.33s ≈ 2m 오차 제거).
|
||||||
|
return { frames: refined, dtSec: bestDt };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ export function deriveBaseName(videoFile: File | null): string | null {
|
|||||||
* 같은 값을 역산해 시간으로 되돌리므로 상수 자체는 임의여도 정합이 유지된다.
|
* 같은 값을 역산해 시간으로 되돌리므로 상수 자체는 임의여도 정합이 유지된다.
|
||||||
* 59.94 는 이 데이터셋(DJI 4K60) 실제 fps 와 일치시켜 HUD 표기도 자연스럽게 한 것.
|
* 59.94 는 이 데이터셋(DJI 4K60) 실제 fps 와 일치시켜 HUD 표기도 자연스럽게 한 것.
|
||||||
*/
|
*/
|
||||||
const DJI_VIRTUAL_FPS = 59.94;
|
export const DJI_VIRTUAL_FPS = 59.94;
|
||||||
|
|
||||||
/** 영상 세그먼트 창 — DJI 시간 기반 로그를 특정 영상 구간에 정렬할 때 사용. */
|
/** 영상 세그먼트 창 — DJI 시간 기반 로그를 특정 영상 구간에 정렬할 때 사용. */
|
||||||
export interface SegmentWindow {
|
export interface SegmentWindow {
|
||||||
@@ -690,9 +690,63 @@ export async function parseKmz(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 곡선 평활 — 측점 정점 삽입/정비가 끝난 최종 선형에 적용(원 정점 전부 보존).
|
||||||
|
if (centerline.length >= 4) {
|
||||||
|
const before = centerline.length;
|
||||||
|
centerline = smoothCenterlineCR(centerline);
|
||||||
|
if (centerline.length !== before) {
|
||||||
|
console.log(`[KML] 선형 곡선 평활(Catmull-Rom): ${before} → ${centerline.length}점`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return { pois, structures, stations, centerline };
|
return { pois, structures, stations, centerline };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 선형 곡선 평활 — Centripetal Catmull-Rom 으로 정점 사이를 세분(모든 원 정점 통과).
|
||||||
|
* 곡선부(정점 ~20m 간격)의 직선 연결이 화면에서 각져 보이는 것을 해소한다.
|
||||||
|
* 측점 삽입 정점도 그대로 통과하므로 선형-측점 정합 유지. 60m 이상 장대 세그먼트
|
||||||
|
* (직선 구간)와 양끝 세그먼트는 세분하지 않는다(불필요·경계 불안정 방지).
|
||||||
|
*/
|
||||||
|
function smoothCenterlineCR(pts: CenterlinePoint[]): CenterlinePoint[] {
|
||||||
|
if (pts.length < 4) return pts;
|
||||||
|
const k = Math.cos((pts[0].lat * Math.PI) / 180) * 111320;
|
||||||
|
const dist = (a: CenterlinePoint, b: CenterlinePoint): number =>
|
||||||
|
Math.hypot((b.lon - a.lon) * k, (b.lat - a.lat) * 111320);
|
||||||
|
const out: CenterlinePoint[] = [];
|
||||||
|
for (let i = 0; i < pts.length - 1; i++) {
|
||||||
|
const p1 = pts[i], p2 = pts[i + 1];
|
||||||
|
out.push(p1);
|
||||||
|
if (i === 0 || i === pts.length - 2) continue; // 양끝 세그먼트는 직선 유지
|
||||||
|
const len = dist(p1, p2);
|
||||||
|
if (len < 2 || len >= 60) continue; // 중복/장대 세그먼트 제외
|
||||||
|
const p0 = pts[i - 1], p3 = pts[i + 2];
|
||||||
|
const sub = Math.min(6, Math.max(2, Math.ceil(len / 6)));
|
||||||
|
const t0 = 0;
|
||||||
|
const t1 = t0 + Math.max(0.7, Math.sqrt(dist(p0, p1)));
|
||||||
|
const t2 = t1 + Math.max(0.7, Math.sqrt(len));
|
||||||
|
const t3 = t2 + Math.max(0.7, Math.sqrt(dist(p2, p3)));
|
||||||
|
const bg = (c0: number, c1: number, c2: number, c3: number, t: number): number => {
|
||||||
|
const A1 = ((t1 - t) * c0 + (t - t0) * c1) / (t1 - t0);
|
||||||
|
const A2 = ((t2 - t) * c1 + (t - t1) * c2) / (t2 - t1);
|
||||||
|
const A3 = ((t3 - t) * c2 + (t - t2) * c3) / (t3 - t2);
|
||||||
|
const B1 = ((t2 - t) * A1 + (t - t0) * A2) / (t2 - t0);
|
||||||
|
const B2 = ((t3 - t) * A2 + (t - t1) * A3) / (t3 - t1);
|
||||||
|
return ((t2 - t) * B1 + (t - t1) * B2) / (t2 - t1);
|
||||||
|
};
|
||||||
|
for (let j = 1; j < sub; j++) {
|
||||||
|
const t = t1 + ((t2 - t1) * j) / sub;
|
||||||
|
out.push({
|
||||||
|
lat: bg(p0.lat, p1.lat, p2.lat, p3.lat, t),
|
||||||
|
lon: bg(p0.lon, p1.lon, p2.lon, p3.lon, t),
|
||||||
|
z: bg(p0.z, p1.z, p2.z, p3.z, t),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push(pts[pts.length - 1]);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
// ── 카메라 파라미터 파일 (<base>.camera.json) ─────────────────────────
|
// ── 카메라 파라미터 파일 (<base>.camera.json) ─────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -936,8 +990,10 @@ export async function loadFolderGeoData(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [frames, stationResult, routeMeta, poiOverrides, kmz, cameraInfo, cameraJson, displayJson] = await Promise.all([
|
const [frames, fullFrames, stationResult, routeMeta, poiOverrides, kmz, cameraInfo, cameraJson, displayJson] = await Promise.all([
|
||||||
parseDroneFrames(files, baseName, segmentWindow),
|
parseDroneFrames(files, baseName, segmentWindow),
|
||||||
|
// 전체 비행(모든 세그먼트) 프레임 — 측점 '전 구간' 검색용(표시용 frames 와 별개)
|
||||||
|
parseDroneFrames(files, baseName, null),
|
||||||
parseStations(files),
|
parseStations(files),
|
||||||
parseRouteMeta(files, baseName),
|
parseRouteMeta(files, baseName),
|
||||||
parsePoiOverrides(files, baseName),
|
parsePoiOverrides(files, baseName),
|
||||||
@@ -968,6 +1024,7 @@ export async function loadFolderGeoData(
|
|||||||
if (isFinite(geoidN) && Math.abs(geoidN) < 60) {
|
if (isFinite(geoidN) && Math.abs(geoidN) < 60) {
|
||||||
altitudeOffsetM = Math.round(geoidN * 100) / 100;
|
altitudeOffsetM = Math.round(geoidN * 100) / 100;
|
||||||
for (const f of frames) f.altitude += altitudeOffsetM;
|
for (const f of frames) f.altitude += altitudeOffsetM;
|
||||||
|
for (const f of fullFrames) f.altitude += altitudeOffsetM;
|
||||||
console.log(
|
console.log(
|
||||||
`[geo] 타원체고 기준 데이터셋 — 드론 고도를 타원체고로 변환 (+${altitudeOffsetM.toFixed(2)}m = ` +
|
`[geo] 타원체고 기준 데이터셋 — 드론 고도를 타원체고로 변환 (+${altitudeOffsetM.toFixed(2)}m = ` +
|
||||||
`djmd ${cameraInfo.ellipsoidalAltM.toFixed(1)} − CSV ${(frames[0].altitude - altitudeOffsetM).toFixed(1)})`,
|
`djmd ${cameraInfo.ellipsoidalAltM.toFixed(1)} − CSV ${(frames[0].altitude - altitudeOffsetM).toFixed(1)})`,
|
||||||
@@ -978,6 +1035,7 @@ export async function loadFolderGeoData(
|
|||||||
// 감지된 초점거리를 드론 프레임 focalLen 에도 반영 (검색/투영 FOV 판정 일관성).
|
// 감지된 초점거리를 드론 프레임 focalLen 에도 반영 (검색/투영 FOV 판정 일관성).
|
||||||
if (cameraInfo?.focalLen35) {
|
if (cameraInfo?.focalLen35) {
|
||||||
for (const f of frames) f.focalLen = cameraInfo.focalLen35;
|
for (const f of frames) f.focalLen = cameraInfo.focalLen35;
|
||||||
|
for (const f of fullFrames) f.focalLen = cameraInfo.focalLen35;
|
||||||
console.log(
|
console.log(
|
||||||
`[camera] ${cameraInfo.model} 감지 — focal ${cameraInfo.focalLen35}mm` +
|
`[camera] ${cameraInfo.model} 감지 — focal ${cameraInfo.focalLen35}mm` +
|
||||||
(cameraInfo.width ? `, ${cameraInfo.width}x${cameraInfo.height}@${cameraInfo.fps ?? '?'}fps` : ''),
|
(cameraInfo.width ? `, ${cameraInfo.width}x${cameraInfo.height}@${cameraInfo.fps ?? '?'}fps` : ''),
|
||||||
@@ -1047,6 +1105,7 @@ export async function loadFolderGeoData(
|
|||||||
videoFile,
|
videoFile,
|
||||||
baseName,
|
baseName,
|
||||||
frames,
|
frames,
|
||||||
|
fullFrames,
|
||||||
pois,
|
pois,
|
||||||
stations,
|
stations,
|
||||||
centerline,
|
centerline,
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# 2026-07-14 전체 커밋 (4차) — 스테이션바 시선 연동·팬 지연 보정
|
||||||
|
|
||||||
|
**소요 시간**: 2분
|
||||||
|
**Context 사용량**: input 13600k / output 1k tokens
|
||||||
|
|
||||||
|
## 커밋
|
||||||
|
|
||||||
|
- **ca591ce** `스테이션바 시선 연동(station_lookahead)·팬 지연 보정·1km 측점 마크`
|
||||||
|
- 20개 파일, +639 / −43 (코드 5개 + docs/history 15건)
|
||||||
|
|
||||||
|
## 주요 내용
|
||||||
|
|
||||||
|
station_lookahead 옵션(연직/고정N/auto, 연속 모델), 노선밖 시선 기준 전환,
|
||||||
|
시점·시설물 마크 배지 기준 통일(구간 밖 미표시), 1km 단위 측점 마크,
|
||||||
|
양끝 시작/끝 스테이션, 팬 지연 보정(POI EMA 우회 + 자세 선행 attLeadSec),
|
||||||
|
sen H 최대 100. 데이터 작업(경로별 camera/display.json 산출·복원)은 이력 문서로 기록.
|
||||||
|
|
||||||
|
## 다음
|
||||||
|
|
||||||
|
- push 는 사용자 요청 시
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# 2026-07-15 포터블(로컬 단독 실행) 패키지 — 웹서버 없이 더블클릭 실행
|
||||||
|
|
||||||
|
**소요 시간**: 15분
|
||||||
|
**Context 사용량**: input 14400k / output 4k tokens
|
||||||
|
|
||||||
|
## 요청
|
||||||
|
|
||||||
|
웹서버로 돌던 버전을 로컬에서 바로 실행할 수 있게.
|
||||||
|
|
||||||
|
## 구현
|
||||||
|
|
||||||
|
- **server/src/demo.ts** (신규): 경량 서버 엔트리 — 정적 클라이언트 + SPA 폴백 +
|
||||||
|
클라이언트 필수 API 2종(/api/elevation DEM 프록시, /api/tile 지도 타일 프록시)만.
|
||||||
|
영상은 브라우저 폴더 선택(로컬 파일)이라 업로드/HLS/DB(네이티브 모듈) 불필요
|
||||||
|
- **scripts/build-portable.sh** (신규): 클라이언트 빌드 → esbuild 단일 번들(server.cjs)
|
||||||
|
→ client/ 복사 → 호스트 node.exe 복사 → GhiVideo.bat/README.txt(CRLF) 생성
|
||||||
|
- 산출물 `dist-portable/GhiVideo-Portable/` (약 103MB, gitignore 등록):
|
||||||
|
node.exe + server.cjs(1.2MB) + client/ + GhiVideo.bat + README.txt
|
||||||
|
- **D:\GhiVideo-Portable** 로 복사 완료 — USB/다른 PC 에 폴더째 복사해 사용
|
||||||
|
|
||||||
|
## 사용법
|
||||||
|
|
||||||
|
1. 폴더째 복사 (설치 불필요 — Node 런타임 포함)
|
||||||
|
2. `GhiVideo.bat` 더블클릭 → 브라우저 자동 오픈(http://localhost:54000)
|
||||||
|
3. 폴더 선택으로 영상 데이터 로드. 종료 = 콘솔 창 닫기
|
||||||
|
|
||||||
|
## 검증
|
||||||
|
|
||||||
|
- server.cjs 단독 실행 ✓, /api/health ✓, DEM 프록시(33.41,126.29→52m) ✓,
|
||||||
|
index.html 서빙 ✓, 타일 프록시 200 image/png ✓
|
||||||
|
- 재빌드: `nvm use 20 && bash scripts/build-portable.sh`
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# 2026-07-15 포터블 고객 배포 — 소스 비공개 확인·강화
|
||||||
|
|
||||||
|
**소요 시간**: 4분
|
||||||
|
**Context 사용량**: input 14800k / output 2k tokens
|
||||||
|
|
||||||
|
## 요청
|
||||||
|
|
||||||
|
고객 배포용 — 소스가 오픈되지 않게.
|
||||||
|
|
||||||
|
## 점검·조치
|
||||||
|
|
||||||
|
- 클라이언트(client/): Vite 프로덕션 빌드 — 압축(minify) + **소스맵 0개** 확인 ✓
|
||||||
|
(원본 TS/TSX/주석 미포함)
|
||||||
|
- 서버(server.cjs): 미압축 상태였음 → **esbuild --minify** 추가(식별자·공백 압축),
|
||||||
|
빌드 스크립트 반영
|
||||||
|
- 원본 소스는 패키지에 전혀 포함되지 않음(빌드 산출물만)
|
||||||
|
- D:\GhiVideo-Portable 에 server.cjs·client 교체 반영
|
||||||
|
(node.exe 는 실행 중 잠김 — 동일 파일이라 교체 불필요)
|
||||||
|
|
||||||
|
## 참고 (한계 고지)
|
||||||
|
|
||||||
|
- JS 특성상 압축 해제(beautify)로 코드 '구조'까지 숨길 수는 없음 —
|
||||||
|
변수명·주석·원본 구성은 제거된 일반적 상용 배포 수준.
|
||||||
|
더 강한 보호(난독화 도구, 바이너리화)는 필요 시 별도 검토.
|
||||||
|
|
||||||
|
## 검증
|
||||||
|
|
||||||
|
- 압축 번들 단독 실행 + /api/health 정상 응답 확인
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# 2026-07-15 측점 검색에 station_lookahead 적용 — 검색 오차 제거
|
||||||
|
|
||||||
|
**소요 시간**: 5분
|
||||||
|
**Context 사용량**: input 15200k / output 2k tokens
|
||||||
|
|
||||||
|
## 제보
|
||||||
|
|
||||||
|
측점 검색 시 station_lookahead 값만큼 오차 — 검색은 드론 연직(chain) 기준이라
|
||||||
|
이동 후 배지가 '검색값 + 오프셋'을 표시.
|
||||||
|
|
||||||
|
## 수정 — StationBar.tsx handleJumpToMileage
|
||||||
|
|
||||||
|
- 오프셋 활성(lookActive: viewChain≠chain 감지) 시 검색 매칭·범위 판정을
|
||||||
|
**viewChain(배지와 동일 기준)** 으로 전환 — 검색한 측점이 '화면이 비추는 지점'이
|
||||||
|
되는 시각으로 이동, 이동 직후 배지 = 검색값
|
||||||
|
- 노선밖(viewOffM>60) 프레임은 매칭 제외(접근/이탈 구간 오검색 방지)
|
||||||
|
- 미설정 데이터셋은 기존 동작 그대로(chain 기준, 필터 없음)
|
||||||
|
- 순환 이동(같은 값 연속 Enter)·허용오차(stationTolerance) 로직은 유지
|
||||||
|
|
||||||
|
## 검증
|
||||||
|
|
||||||
|
- tsc+vite 빌드 통과, 새 번들(index-DSBYNc0I.js, 빌드 07.15 10:43) 서빙
|
||||||
|
- 적용: F5 새로고침(빌드 시각 10:43) → 폴더 재선택 → 측점 검색 후 배지 = 검색값 확인
|
||||||
|
- 포터블 배포본에 반영하려면 `bash scripts/build-portable.sh` 재실행 필요
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# 2026-07-15 타이틀 StationPlayer 변경 — 재빌드·포터블·zip 갱신
|
||||||
|
|
||||||
|
**소요 시간**: 5분
|
||||||
|
**Context 사용량**: input 15800k / output 2k tokens
|
||||||
|
|
||||||
|
## 작업
|
||||||
|
|
||||||
|
- 사용자: App.tsx 좌측 패널(숨김) 제목을 StationPlayer / 스테이션 기반 동영상
|
||||||
|
플레이어로 변경
|
||||||
|
- 보완: 실제 브라우저 탭 제목인 **client/index.html `<title>`** 도 동일하게 변경
|
||||||
|
(App.tsx 쪽은 SHOW_LEFT_PANEL=false 라 화면에 안 보임)
|
||||||
|
- 웹 서버 빌드 + 포터블 재빌드 + D:\GhiVideo-Portable(client·server.cjs) 갱신 +
|
||||||
|
GhiVideo-Portable.zip 재생성(48.9MB)
|
||||||
|
|
||||||
|
## 검증
|
||||||
|
|
||||||
|
- 서빙 확인: `<title>StationPlayer — 스테이션 기반 동영상 플레이어` ✓
|
||||||
|
- 적용: F5 새로고침으로 탭 제목 확인
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# 2026-07-15 팬(회전) 시 라벨 지연·늦은 정렬 — 포즈 평활 완전 제거
|
||||||
|
|
||||||
|
**소요 시간**: 8분
|
||||||
|
**Context 사용량**: input 16400k / output 3k tokens
|
||||||
|
|
||||||
|
## 제보 (재발)
|
||||||
|
|
||||||
|
좌우 회전 시 측점·라벨이 여전히 뒤늦게 따라오고 뒤늦게 정렬됨.
|
||||||
|
|
||||||
|
## 원인 (단위 착오 발견)
|
||||||
|
|
||||||
|
- 포즈 평활 상한 POSE_SMOOTH_SYNCED_MAX=5 를 '±5 영상프레임(83ms)'로 잡았으나
|
||||||
|
smoothFrame 의 halfWin 단위는 **프레임 배열 행(CSV 10Hz 행)** → 실제 **±0.5초 이동평균**
|
||||||
|
- 대칭 창이라 등속 회전 중에는 지연이 없지만, **팬 시작·종료(각가속) 구간**에서
|
||||||
|
평균이 실제 자세를 못 따라가 "늦게 출발·늦게 정렬" 발생
|
||||||
|
|
||||||
|
## 수정 — StationOverlay.tsx
|
||||||
|
|
||||||
|
- POSE_SMOOTH_SYNCED_MAX **5 → 0** (프레임 동기 데이터는 평활 완전 끔)
|
||||||
|
- 위치 = djmd(프레임 동기, 잡음 없음), 자세 = 10Hz 선형보간 — 둘 다 이미 매끄러움
|
||||||
|
- smoothFrame(h=0) = 해당 행 그대로 + poseAt 의 행간 선형보간만 수행
|
||||||
|
- 비동기(CSV 지터) 데이터셋은 기존 smoothHalf 그대로
|
||||||
|
|
||||||
|
## 잔여 지연 시
|
||||||
|
|
||||||
|
- 남는 '일정한' 지연(등속 회전 중에도 밀림) = 짐벌 로그 기록 지연 →
|
||||||
|
카메라 파라미터 '자세 선행' +0.1~0.3s 튜닝 후 PC 저장 (어제 추가한 기능)
|
||||||
|
|
||||||
|
## 반영
|
||||||
|
|
||||||
|
- 웹(index-vsJQCQXN.js, 07.15 11:09) + 포터블(D:\GhiVideo-Portable) + zip 갱신 완료
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# 2026-07-15 작업 기록 형식 규칙화 — 작업분·누적 토큰 병기 (영구 적용)
|
||||||
|
|
||||||
|
**소요 시간**: 5분
|
||||||
|
**Context 사용량**: 작업분 input 90k / output 2k tokens · 누적 input 16600k / output 220k tokens
|
||||||
|
|
||||||
|
## 요청
|
||||||
|
|
||||||
|
작업 기록 md: 파일명 앞 날짜·시간 + 내용에 처리 토큰량 기록.
|
||||||
|
사용된 토큰량(작업분)과 누적 토큰량 **모두** 기록. 앞으로 모든 작업에 영구 적용.
|
||||||
|
|
||||||
|
## 조치
|
||||||
|
|
||||||
|
- **CLAUDE.md** 히스토리 규칙 갱신 — Context 사용량 형식을
|
||||||
|
`작업분 input Xk / output Xk · 누적 input Xk / output Xk` 로 확정
|
||||||
|
(기존 파일명 규칙 YYYY-MM-DD_HHMM_작업명.md 은 그대로)
|
||||||
|
- **개인 메모리** 등록(work-history-md-format) — 다른 프로젝트/새 세션에도 적용
|
||||||
|
- 이 파일부터 새 형식 적용 (작업분 = 해당 요청 처리에 사용된 추정 토큰)
|
||||||
|
|
||||||
|
## 참고
|
||||||
|
|
||||||
|
- 토큰값은 추정치 기준(정확한 계측값이 없을 때) — CLAUDE.md 에 명시
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# 2026-07-15 직진 시 원거리 라벨 떨림 — 자세 속도적응 필터(One Euro)
|
||||||
|
|
||||||
|
**소요 시간**: 7분
|
||||||
|
**Context 사용량**: 작업분 input 120k / output 3k tokens · 누적 input 16800k / output 226k tokens
|
||||||
|
|
||||||
|
## 제보
|
||||||
|
|
||||||
|
회전 추종은 좋아졌는데, 직진 시 전방 측점·라벨이 흔들려 보임.
|
||||||
|
|
||||||
|
## 원인
|
||||||
|
|
||||||
|
포즈 평활 완전 제거(11:09) 후 비행로그 자세의 미세 잡음이 그대로 노출 —
|
||||||
|
특히 짐벌 피치가 **정수 단위 양자화**로 기록되어 행 사이 ±1° 계단이 생기고,
|
||||||
|
원거리 라벨은 각도 잡음이 증폭(0.5°≈수십 px)되어 떨림으로 보임.
|
||||||
|
|
||||||
|
## 수정 — StationOverlay.tsx
|
||||||
|
|
||||||
|
- **자세 속도적응 필터(One Euro)** 를 RAF 의 dronePose 에 적용 (frameSynced 전용):
|
||||||
|
- 컷오프 = MIN_CUT(0.8Hz) + BETA(0.5)×|각속도| — 변화가 느리면(직진 미세 잡음)
|
||||||
|
강하게 평활, 팬처럼 빠르면 컷오프 상승 → **지연 없이 떨림만 제거**
|
||||||
|
- yaw(최단각)/pitch/roll 에 적용, 위치·고도(djmd, 잡음 없음)는 미적용
|
||||||
|
- 시크·세그먼트 전환(급점프)·0.5s 이상 정지 시 필터 리셋
|
||||||
|
- 선형·측점·POI 라벨이 같은 dronePose 를 쓰므로 한 몸으로 움직임(상호 분리 없음)
|
||||||
|
|
||||||
|
## 반영
|
||||||
|
|
||||||
|
- 웹(index-3vQazFsG.js, 07.15 11:16) + 포터블(D:\GhiVideo-Portable) + zip 갱신
|
||||||
|
- 확인: F5 → 직진 구간 떨림 감소 + 회전 추종 유지. 떨림이 남으면 MIN_CUT 하향(0.5),
|
||||||
|
회전이 다시 늦으면 BETA 상향(0.8) 여지 있음
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# 2026-07-15 자세 필터 강도 route.json 옵션화 — att_min_cut / att_beta
|
||||||
|
|
||||||
|
**소요 시간**: 6분
|
||||||
|
**Context 사용량**: 작업분 input 100k / output 2k tokens · 누적 input 16900k / output 228k tokens
|
||||||
|
|
||||||
|
## 요청
|
||||||
|
|
||||||
|
자세 필터(One Euro) 값을 route.json 옵션으로 사용자 조정 가능하게.
|
||||||
|
|
||||||
|
## 구현 — StationOverlay.tsx, types/geo.ts
|
||||||
|
|
||||||
|
route.json `routeInfo` 에 추가(폴더별, 숫자·숫자문자열 허용):
|
||||||
|
|
||||||
|
| 키 | 기본 | 의미 |
|
||||||
|
|----|------|------|
|
||||||
|
| `att_min_cut` | 0.8 | 저속 컷오프(Hz) — **작을수록 직진 떨림 억제 강함** (0.1~3 권장) |
|
||||||
|
| `att_beta` | 0.5 | 각속도당 컷오프 증가 — **클수록 팬(회전) 추종 민감** |
|
||||||
|
|
||||||
|
- routeMeta 변경 시 자동 반영, 설정 존재하면 콘솔 로그
|
||||||
|
`[오버레이] 자세 필터: min_cut …Hz, beta … (route.json)` 로 확인 가능
|
||||||
|
- RouteMeta 타입 문서화
|
||||||
|
|
||||||
|
## 튜닝 가이드
|
||||||
|
|
||||||
|
- 직진 떨림 남음 → att_min_cut 0.8 → 0.5 → 0.3 낮추기
|
||||||
|
- 회전 추종 둔함 → att_beta 0.5 → 0.8 → 1.0 올리기
|
||||||
|
|
||||||
|
## 반영
|
||||||
|
|
||||||
|
- 웹(index-DZfP1Yr_.js, 07.15 11:24) + 포터블 + zip 갱신 완료
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# 2026-07-15 StationPlayer-Portable — 지정 경로에 로컬 버전 생성·개명
|
||||||
|
|
||||||
|
**소요 시간**: 6분
|
||||||
|
**Context 사용량**: 작업분 input 80k / output 2k tokens · 누적 input 17100k / output 233k tokens
|
||||||
|
|
||||||
|
## 요청
|
||||||
|
|
||||||
|
D:\GhiVideo-Portable 대신 **D:\_project\20260612_abcvideo\StationPlayer-Portable** 에 로컬 버전.
|
||||||
|
|
||||||
|
## 작업
|
||||||
|
|
||||||
|
- 최신 포터블(자세 필터 옵션 포함, 11:24 빌드)을 지정 경로에 배치
|
||||||
|
- 대상 폴더가 빈 폴더로 이미 존재 → 중첩 복사를 평탄화로 정리
|
||||||
|
- 제품명 변경 반영: **GhiVideo.bat → StationPlayer.bat**, README 제목/안내문 StationPlayer 로
|
||||||
|
- scripts/build-portable.sh 도 동일 이름 체계로 갱신
|
||||||
|
(산출물 dist-portable/StationPlayer-Portable, StationPlayer.bat)
|
||||||
|
- 기존 D:\GhiVideo-Portable / GhiVideo-Portable.zip 은 그대로 둠(구버전 이름 배포본)
|
||||||
|
|
||||||
|
## 구성 (D:\_project\20260612_abcvideo\StationPlayer-Portable)
|
||||||
|
|
||||||
|
StationPlayer.bat / README.txt / client\ / node.exe / server.cjs — 실행은 bat 더블클릭
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# 2026-07-15 선형 입체 표시 — 아래로 내려 그리는 커튼 면
|
||||||
|
|
||||||
|
**소요 시간**: 12분
|
||||||
|
**Context 사용량**: 작업분 input 150k / output 4k tokens · 누적 input 17400k / output 240k tokens
|
||||||
|
|
||||||
|
## 요청
|
||||||
|
|
||||||
|
선형을 선만이 아니라, 아래쪽으로 일정 두께의 면(커튼)이 있는 입체 형태로.
|
||||||
|
|
||||||
|
## 구현 — StationOverlay.tsx
|
||||||
|
|
||||||
|
- buildLines: 각 선형 세그먼트에서 윗변(기존 선 z)과 아랫변(z − 커튼높이)을 각각
|
||||||
|
투영해 화면 사각형(curtainQuads) 생성 — 원 클립(표시 측점까지)·드론높이 모드 평면
|
||||||
|
규칙 동일 적용, 근평면 뒤 꼭짓점 있으면 면 생략(선은 유지)
|
||||||
|
- RAF: 커튼 면을 반투명 빨강(0.22)으로 채우고 하단 윤곽선(0.35) 추가 → 그 위에
|
||||||
|
기존 선(0.85) — 벽처럼 서 있는 입체감
|
||||||
|
- **화면표시 옵션 '커튼 높이'** 슬라이더(0~20m, 0.5 스텝, 기본 3m, 0=선만):
|
||||||
|
display.json `clCurtainM` 로 저장/로드/초기화 연동 (PC 저장 payload 포함)
|
||||||
|
|
||||||
|
## 반영
|
||||||
|
|
||||||
|
- 웹(index-CIAoq9c5.js, 07.15 12:50) + 포터블(D:\_project\...\StationPlayer-Portable) 갱신
|
||||||
|
- 확인: F5 → 폴더 재선택 → 화면표시 옵션에서 '커튼 높이' 조절
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# 2026-07-15 선형 입체 표현 '홀로그램 펜스' + 표시 토글 옵션
|
||||||
|
|
||||||
|
**소요 시간**: 14분
|
||||||
|
**Context 사용량**: 작업분 input 180k / output 6k tokens · 누적 input 17700k / output 248k tokens
|
||||||
|
|
||||||
|
## 요청
|
||||||
|
|
||||||
|
선형을 참신하게 입체적으로 + 보일지 말지 사용자 옵션.
|
||||||
|
|
||||||
|
## 구현 — '홀로그램 펜스' (StationOverlay.tsx)
|
||||||
|
|
||||||
|
1. **그라데이션 커튼**: 세그먼트를 ~15m 분할, 위(0.38)→아래(0.02) 소멸 그라데이션 면
|
||||||
|
2. **수직살(스트럿)**: 분할 경계마다 세로선 — 원근 수렴(멀수록 촘촘·짧음)이 핵심 입체 큐,
|
||||||
|
거리 페이드(450m 기준)로 깊이감
|
||||||
|
3. **상단 레일 글로우**: 넓고 옅은 글로우(8px, 0.22) 위에 본선(2.5px, 0.9) — 네온 레일
|
||||||
|
4. **유동 대시**: 밝은 점(5/27 대시)이 레일을 따라 흐르는 애니메이션 — 방향/생동감
|
||||||
|
|
||||||
|
## 옵션 (display.json)
|
||||||
|
|
||||||
|
- **clFx**(기본 true): 화면표시 패널 '선형 입체 효과' 토글 버튼 — 끄면 종전 단순 선
|
||||||
|
- **clCurtainM**(기본 3m): 커튼 높이 (입체 효과 켠 상태에서만 노출, 0=면 없이 레일만)
|
||||||
|
- PC 저장/자동 적용/초기화(파일 복원) 모두 연동
|
||||||
|
|
||||||
|
## 반영
|
||||||
|
|
||||||
|
- 웹(index-DqFz4LF6.js, 07.15 12:56) + 포터블(StationPlayer-Portable) 갱신
|
||||||
|
- 확인: F5 → 폴더 재선택 → 화면표시 옵션 '선형 입체 효과' 토글
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# 2026-07-15 유동 대시 진행방향 정렬 + 선형 튜브(지름 있는 원형 단면) 레일
|
||||||
|
|
||||||
|
**소요 시간**: 13분
|
||||||
|
**Context 사용량**: 작업분 input 200k / output 7k tokens · 누적 input 18000k / output 255k tokens
|
||||||
|
|
||||||
|
## 요청
|
||||||
|
|
||||||
|
1. 선형 애니메이션(유동 대시)이 '가는 방향'으로 흐르게
|
||||||
|
2. 선형을 지름 있는 입체 원형(튜브)으로
|
||||||
|
|
||||||
|
## 구현 — StationOverlay.tsx
|
||||||
|
|
||||||
|
### ① 대시 진행방향 정렬
|
||||||
|
|
||||||
|
- 선형 배열이 체이니지 감소 순(7k→0k)이라 대시가 역방향으로 흐르던 문제
|
||||||
|
- 드론 **실제 이동 벡터**(±3행 GPS 변위, 호버 시 yaw 폴백)와 세그먼트 방향의
|
||||||
|
내적으로 각 세그먼트를 '뒤→앞'으로 정렬 → 역방향 비행에서도 항상 진행방향 흐름
|
||||||
|
|
||||||
|
### ② 튜브 레일 (원형 단면)
|
||||||
|
|
||||||
|
- 분할점(~15m) 사이 구간마다 화면 폭 = **지름(m) ÷ 거리 × 픽셀/라디안** —
|
||||||
|
가까울수록 굵고 멀수록 가는 원근 지름 표현 (하한 1.5px, 상한 40px)
|
||||||
|
- **3중 스트로크 원통 음영**: 외곽(어두운 적, 1.0w) → 본체(적, 0.6w) →
|
||||||
|
하이라이트(밝은 살구, 0.26w) — 둥근 파이프 질감. 라운드 캡으로 이음새 없음
|
||||||
|
- 옵션 **'선형 지름'**(display.json clTubeD, 기본 1.2m, 0.2~5m) — 입체 효과 켠
|
||||||
|
상태에서 커튼 높이 아래 슬라이더. 입체 효과 끄면 종전 단순 선
|
||||||
|
|
||||||
|
## 반영
|
||||||
|
|
||||||
|
- 웹(index-L2DHv7Ep.js, 07.15 13:02) + 포터블(StationPlayer-Portable) 갱신
|
||||||
|
- 확인: F5 → 대시가 진행방향으로 흐르는지, 튜브가 가까울수록 굵어지는지
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# 2026-07-15 선형 렌더링 — 진한 중심선 + 반투명 원통 셸
|
||||||
|
|
||||||
|
**소요 시간**: 4분
|
||||||
|
**Context 사용량**: 작업분 input 60k / output 2k tokens · 누적 input 18100k / output 258k tokens
|
||||||
|
|
||||||
|
## 요청
|
||||||
|
|
||||||
|
선형의 선은 진하게, 그 선을 중심으로 감싸는 원통은 입체감 + 투명도.
|
||||||
|
|
||||||
|
## 수정 — StationOverlay.tsx (RAF 렌더)
|
||||||
|
|
||||||
|
- **중심선(코어)**: 항상 진한 실선 rgba(205,25,25,0.95) / 2.5px — 튜브 유무와 무관
|
||||||
|
- **원통 셸**: 불투명 3중 파이프 → **반투명 다층 셸**(알파 0.10~0.13, 3층)로 교체
|
||||||
|
- 폭 = 지름/거리 원근 환산(상한 44px), 안쪽 층일수록 은은히 밝음 — 유리관 볼륨감
|
||||||
|
- 셸이 코어를 감싸는 구조(셸 먼저 → 코어 → 유동 대시)
|
||||||
|
- 유동 대시·커튼·수직살·'선형 지름' 옵션은 그대로
|
||||||
|
|
||||||
|
## 반영
|
||||||
|
|
||||||
|
- 웹(index-CgJSMixD.js, 07.15 13:07) + 포터블(StationPlayer-Portable) 갱신
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# 2026-07-15 커튼 제거 + 선형 기본형/입체형 사용자 선택
|
||||||
|
|
||||||
|
**소요 시간**: 4분
|
||||||
|
**Context 사용량**: 작업분 input 70k / output 3k tokens · 누적 input 18200k / output 262k tokens
|
||||||
|
|
||||||
|
## 요청
|
||||||
|
|
||||||
|
커튼 표현 제거. 선형을 기본형/입체형으로 사용자 옵션 선택.
|
||||||
|
|
||||||
|
## 수정 — StationOverlay.tsx
|
||||||
|
|
||||||
|
- **커튼 완전 제거**: 그라데이션 면·수직살 렌더링, 생성 로직, clCurtainM
|
||||||
|
옵션(상태/저장/로드/초기화/슬라이더) 모두 삭제
|
||||||
|
- **표시 형태 토글**(display.json `clFx`):
|
||||||
|
- **입체형**(기본): 반투명 원통 셸 + 진한 중심선 + 유동 대시 + 원근 지름
|
||||||
|
- **기본형**: 종전 단순 빨간 선
|
||||||
|
- 버튼 문구: '■ 선형 입체형 (원통 + 유동 대시)' ↔ '▶ 선형 기본형 (단순 선)'
|
||||||
|
- '선형 지름'(clTubeD) 슬라이더는 입체형에서만 노출
|
||||||
|
- 기존 display.json 에 남은 clCurtainM 키는 무시됨(무해)
|
||||||
|
|
||||||
|
## 반영
|
||||||
|
|
||||||
|
- 웹(index-Kjiwq3q1.js, 07.15 13:09) + 포터블(StationPlayer-Portable) 갱신
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# 2026-07-15 유동 점 근거리 역행 — 월드(체이니지) 고정 펄스로 전환
|
||||||
|
|
||||||
|
**소요 시간**: 8분
|
||||||
|
**Context 사용량**: 작업분 input 130k / output 5k tokens · 누적 input 18400k / output 268k tokens
|
||||||
|
|
||||||
|
## 제보
|
||||||
|
|
||||||
|
유동 점이 화면 위쪽(원거리)은 앞으로 흐르는데, 드론에 가까울수록 뒤(드론 쪽)로
|
||||||
|
흐르는 것처럼 보임 — 모두 앞으로 흐르게.
|
||||||
|
|
||||||
|
## 원인
|
||||||
|
|
||||||
|
대시가 **화면(픽셀) 좌표 기준**으로 이동 — 근거리에서는 지면이 화면 아래로
|
||||||
|
흘러내리는 속도(수백 px/s)가 대시 상승 속도(36px/s)보다 훨씬 빨라,
|
||||||
|
지면 기준으로는 뒤로 가는 것처럼 보이는 상대운동 착시.
|
||||||
|
|
||||||
|
## 수정 — StationOverlay.tsx
|
||||||
|
|
||||||
|
- 화면 대시 제거 → **월드 고정 펄스**: 체이니지에 고정된 점(30m 간격)이
|
||||||
|
노선을 따라 **실속도 12m/s** 로 전진 — 원근 투영을 매 프레임 다시 하므로
|
||||||
|
어느 거리에서든 지면 기준 항상 진행방향으로 흐름
|
||||||
|
- 진행방향: 드론 이동 벡터와 중심선 배열 방향의 투표(dot) 부호로 결정
|
||||||
|
(역방향 비행도 정상), 중심선 누적거리(clCumRef) 사전 계산
|
||||||
|
- 펄스 크기도 원근(지름 연동 rWorld/거리) — 가까울수록 큼 (1.6~9px)
|
||||||
|
|
||||||
|
## 반영
|
||||||
|
|
||||||
|
- 웹(index-oyd_KyNG.js, 07.15 13:12) + 포터블(StationPlayer-Portable) 갱신
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# 2026-07-15 측점 검색 전 구간(모든 세그먼트) 순환 + 선형 점 궤적 방식 전환
|
||||||
|
|
||||||
|
**소요 시간**: 25분
|
||||||
|
**Context 사용량**: 작업분 input 350k / output 12k tokens · 누적 input 18900k / output 282k tokens
|
||||||
|
|
||||||
|
## 요청
|
||||||
|
|
||||||
|
1. 측점 검색(예: 1355)이 한 군데만 찾음 — 여러 통과가 있으면 현재 위치에서
|
||||||
|
진행방향으로 찾고, 끝까지 가면 처음부터 현재까지 순환해야 함
|
||||||
|
2. 선형 애니메이션을 드론 궤적 표현과 동일한 방식으로
|
||||||
|
|
||||||
|
## 구현
|
||||||
|
|
||||||
|
### ① 측점 검색 — 전 구간(모든 세그먼트) 순환
|
||||||
|
|
||||||
|
- 종전: 검색 범위가 **현재 영상(세그먼트) 프레임**뿐 → 다른 영상에 있는 통과는
|
||||||
|
못 찾고, 현재 영상에 1곳이면 1곳만 순환
|
||||||
|
- geoData/geoStore: **fullFrames**(전체 비행 프레임, datum·초점 보정 동일 적용) 추가
|
||||||
|
- StationBar: 전 구간 검색 인덱스(globalSearch — 배지와 동일한 viewChain 규칙,
|
||||||
|
0.2s 표본) 생성 → 통과 그룹핑 → **전역 시간축에서 현재 위치 기준 진행방향 순환**
|
||||||
|
(같은 값 연속 Enter = 다음 통과, 마지막 넘으면 처음으로 랩어라운드)
|
||||||
|
- 다른 세그먼트의 통과로 점프: useVideoPlayer.loadLocalFile 에 **startAtSec** 추가,
|
||||||
|
VideoPlayer.handleSeekSegment(세그먼트 전환 + 해당 시각 시크, 재생 상태 유지),
|
||||||
|
StationBar 에 onSeekSegment prop 배선
|
||||||
|
- 세그먼트 길이 미측정(null) 등 전역 인덱스 불가 시 기존 세그먼트 내 검색 폴백
|
||||||
|
|
||||||
|
### ② 선형 점 — 궤적(측정점)과 동일 방식
|
||||||
|
|
||||||
|
- 이동 애니메이션(12m/s 위상) 제거 → **체이니지 20m 간격 월드 고정점**
|
||||||
|
- 스타일도 궤적 측정점과 동일(노란 점 1.8px 고정 크기) — 화면 이동만으로
|
||||||
|
자연스럽게 흐르는 궤적과 같은 질감
|
||||||
|
|
||||||
|
## 반영
|
||||||
|
|
||||||
|
- 웹(index-BCV4opt2.js, 07.15 13:21) + 포터블(StationPlayer-Portable) 갱신
|
||||||
|
- 확인: 측점 입력 후 Enter 반복 — 전 구간 통과를 순환하며 세그먼트가 자동 전환되는지
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# 2026-07-15 선형 점을 측점 사이 등분점으로 + 분할 수 옵션
|
||||||
|
|
||||||
|
**소요 시간**: 8분
|
||||||
|
**Context 사용량**: 작업분 input 130k / output 6k tokens · 누적 input 19200k / output 290k tokens
|
||||||
|
|
||||||
|
## 요청
|
||||||
|
|
||||||
|
1. 노란 점을 측점(100m) 사이 십등분 위치에 — 10m 단위 검토용
|
||||||
|
2. 몇 등분할지 사용자 옵션으로
|
||||||
|
|
||||||
|
## 구현 — StationOverlay.tsx
|
||||||
|
|
||||||
|
- 중심선 로드 시 **측점 체이니지 ↔ 폴리라인 누적거리 앵커** 구축
|
||||||
|
(측점 정점이 선형에 삽입되어 있어 최근접 5m 이내 = 그 측점 위치)
|
||||||
|
- 선형 점을 임의 등간격(20m) → **인접 측점 구간의 N등분점**으로 변경:
|
||||||
|
측점 자체 위치(눈금 존재)는 제외, **중간(½) 지점은 크게(2.8px)** 표시
|
||||||
|
- **'측점 분할' 옵션**(display.json `clDotDiv`, 기본 10 = 10m 단위, 1~20, 1=점 없음):
|
||||||
|
입체형 상태의 화면표시 패널 슬라이더, PC 저장/자동 적용/초기화 연동
|
||||||
|
- 측점 앵커가 없는 데이터셋은 20m 등간격 폴백
|
||||||
|
|
||||||
|
## 반영
|
||||||
|
|
||||||
|
- 웹(index-DX-VwDm-.js, 07.15 13:27) + 포터블(StationPlayer-Portable) 갱신
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# 2026-07-15 선형 각짐 — Catmull-Rom 곡선 평활 + 튜브 렌더 배치 최적화
|
||||||
|
|
||||||
|
**소요 시간**: 7분
|
||||||
|
**Context 사용량**: 작업분 input 120k / output 5k tokens · 누적 input 19400k / output 296k tokens
|
||||||
|
|
||||||
|
## 제보 (스크린샷: 2+000~2+100 곡선)
|
||||||
|
|
||||||
|
선형(튜브)이 곡선부에서 각져 보임 — 정점(~20m 간격) 사이 직선 연결이 원인.
|
||||||
|
|
||||||
|
## 수정
|
||||||
|
|
||||||
|
### geoData.ts — 곡선 평활 (로드 시 1회)
|
||||||
|
|
||||||
|
- **Centripetal Catmull-Rom** 보간으로 정점 사이를 세분(세그먼트당 2~6분할, ~6m 간격)
|
||||||
|
- **모든 원 정점을 정확히 통과** → 측점 삽입 정점 유지 = 선형-측점 정합 그대로,
|
||||||
|
z 도 곡선 보간(수직 각짐도 완화)
|
||||||
|
- 60m 이상 장대 세그먼트(직선)·양끝 세그먼트·중복 정점은 세분 제외(불필요·경계 안정)
|
||||||
|
- 측점 z 재정렬·정점 삽입이 끝난 최종 선형에 적용, 콘솔 로그로 정점 수 확인 가능
|
||||||
|
|
||||||
|
### StationOverlay.tsx — 튜브 스트로크 배치
|
||||||
|
|
||||||
|
- 세그먼트 수 증가(213 → 수백)에 대비해 폭 버킷(0.5px) 단위로 묶어
|
||||||
|
스트로크 횟수를 세그먼트 수 → 버킷 수(~20)로 절감
|
||||||
|
|
||||||
|
## 반영
|
||||||
|
|
||||||
|
- 웹(index-BnZPSKzv.js, 07.15 13:30) + 포터블(StationPlayer-Portable) 갱신
|
||||||
|
- 확인: 곡선 구간(예: 2+000~2+100)에서 튜브가 매끄러운 호로 보이는지
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# 2026-07-15 원통 셸 이음매 잘림/중첩 — 오프스크린 불투명 합성으로 해소
|
||||||
|
|
||||||
|
**소요 시간**: 5분
|
||||||
|
**Context 사용량**: 작업분 input 90k / output 3k tokens · 누적 input 19600k / output 300k tokens
|
||||||
|
|
||||||
|
## 제보 (스크린샷)
|
||||||
|
|
||||||
|
중심선을 감싸는 반투명 원통이 중간중간 잘려 보이고 중첩 부분이 진하게 보임 — 정확한 관찰.
|
||||||
|
|
||||||
|
## 원인
|
||||||
|
|
||||||
|
원통 셸을 짧은 세그먼트 '반투명' 스트로크로 이어 그려서:
|
||||||
|
- 세그먼트 이음매(라운드 캡 겹침)마다 알파가 중첩 → 진한 띠
|
||||||
|
- 폭 버킷이 바뀌는 지점은 다른 패스로 그려져 단차(잘림)처럼 보임
|
||||||
|
|
||||||
|
## 수정 — StationOverlay.tsx
|
||||||
|
|
||||||
|
- **오프스크린 캔버스에 '불투명'으로 셸 3층을 그린 뒤, 메인 캔버스에 전체를
|
||||||
|
한 번(globalAlpha 0.32)에 합성** — 불투명 겹침은 흔적이 없으므로 이음매·단차가
|
||||||
|
원천 제거되고, 투명도는 화면 합성 단계에서 균일하게 적용됨
|
||||||
|
- 폭 버킷 배치 최적화 유지(불투명이라 겹침 무해), 캔버스 크기 변경 시 오프스크린 재생성
|
||||||
|
|
||||||
|
## 반영
|
||||||
|
|
||||||
|
- 웹(index-D3J3-0ig.js, 07.15 13:34) + 포터블(StationPlayer-Portable) 갱신
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# 2026-07-15 측점 검색 오차(2000→1+991) — 시간축·표본 보정
|
||||||
|
|
||||||
|
**소요 시간**: 10분
|
||||||
|
**Context 사용량**: 작업분 input 160k / output 5k tokens · 누적 input 19800k / output 305k tokens
|
||||||
|
|
||||||
|
## 제보 (스크린샷)
|
||||||
|
|
||||||
|
2000 검색 시 1+991 로 이동 — 해당 측점으로 정확히 안 감.
|
||||||
|
|
||||||
|
## 원인 (실측)
|
||||||
|
|
||||||
|
1. **전역 시간축 스케일 오차**: 전 구간 검색의 fps 를 '마지막 프레임 ÷ 영상 길이 합'으로
|
||||||
|
환산 — CSV 로그가 영상보다 길어(착륙 꼬리, 경로 1-1 +0.9s/0.14%) 시간축이 압축되어
|
||||||
|
검색 지점이 최대 ~7m 어긋남 (ffprobe 실측: 1-1 671.7s vs CSV 672.6s)
|
||||||
|
2. **표본 이산화**: 검색 인덱스가 0.2s 표본이라 ±0.8m 추가 오차
|
||||||
|
|
||||||
|
## 수정 — StationBar.tsx (+ geoData DJI_VIRTUAL_FPS export)
|
||||||
|
|
||||||
|
- 시간기반(DJI) 로그는 frame = 경과초×59.94 가 정확한 환산 → **가상 fps 상수 사용**
|
||||||
|
(프레임 간격 >1 로 자동 감지, 프레임 기반 데이터는 기존 방식 유지)
|
||||||
|
- 통과 시각을 이웃 표본과 **선형보간**해 vc == 검색값 지점으로 정밀화
|
||||||
|
|
||||||
|
## 잔여 한계 (참고)
|
||||||
|
|
||||||
|
- 마지막 세그먼트 끝부분은 CSV 꼬리 때문에 세그먼트 내부 배지 시간축이 미세 압축 —
|
||||||
|
끝 수 초 구간에서 수 m 오차 가능(통상 검토 구간 밖)
|
||||||
|
|
||||||
|
## 반영
|
||||||
|
|
||||||
|
- 웹(index-BcIFwOAf.js, 07.15 13:39) + 포터블(StationPlayer-Portable) 갱신
|
||||||
|
- 확인: 2000 검색 → 배지 2+000(±2m 이내) 도착
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# 2026-07-15 측점 검색 불일치(1370→1+360) — '도달 통과' 우선 선택
|
||||||
|
|
||||||
|
**소요 시간**: 12분
|
||||||
|
**Context 사용량**: 작업분 input 180k / output 6k tokens · 누적 input 20100k / output 311k tokens
|
||||||
|
|
||||||
|
## 제보 (스크린샷)
|
||||||
|
|
||||||
|
1370 검색 시 배지 1+360 지점으로 이동 — 검색값과 불일치.
|
||||||
|
|
||||||
|
## 원인 (실데이터 재현)
|
||||||
|
|
||||||
|
- 경로 1-1, lookahead 240. t≈252s 구간에서 **드론이 약 2분 호버**하며 배지값(vc)이
|
||||||
|
1360 에 정체 + 호버 중 카메라 회전으로 vc 가 검색 창을 들락거림
|
||||||
|
- 그 결과 "1360 까지만 접근하는" **약한 통과 창**(최소 오차 9.9m)이 생기고,
|
||||||
|
검색이 이 창을 첫 통과로 채택 → 실제 1370 도달 지점(t=336.9s)을 지나침
|
||||||
|
|
||||||
|
## 수정 — StationBar.tsx
|
||||||
|
|
||||||
|
- 통과 창을 **강한 통과**(창 안에서 vc 가 검색값을 실제로 가로지름: min≤km≤max,
|
||||||
|
또는 최소 오차 ≤5m)와 **약한 통과**(근접만)로 분류
|
||||||
|
- 순환 이동은 강한 통과들로만 — 강한 통과가 하나도 없을 때만 약한 통과 폴백
|
||||||
|
|
||||||
|
## 검증 (경로 1-1, km=1370)
|
||||||
|
|
||||||
|
- 강한 통과: t=336.9s, vc=**1370.0** (오차 0) ✓
|
||||||
|
- 약한 통과(후순위): t=252.3s, vc=1360.1 — 종전에 잘못 착지하던 곳
|
||||||
|
|
||||||
|
## 반영
|
||||||
|
|
||||||
|
- 웹(index-CngDBzBa.js, 07.15 13:55) + 포터블(StationPlayer-Portable) 갱신
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# 2026-07-15 측점 2개 이상 시 Enter 순환 — 시크 지연에도 다음 통과로
|
||||||
|
|
||||||
|
**소요 시간**: 6분
|
||||||
|
**Context 사용량**: 작업분 input 90k / output 3k tokens · 누적 input 20300k / output 315k tokens
|
||||||
|
|
||||||
|
## 요청
|
||||||
|
|
||||||
|
측점이 2개 이상 존재하면 Enter 를 칠 때마다 다음 검색 측점으로 이동.
|
||||||
|
|
||||||
|
## 원인 (기존 순환이 멈추던 조건)
|
||||||
|
|
||||||
|
- 순환 로직은 있었으나 '같은 검색' 판정이 **현재 시각 ≈ 직전 목표(0.1s)** 일 때만 성립 —
|
||||||
|
4K 시크·세그먼트 전환은 정착에 수백 ms~수 초 걸려, 그 전에 Enter 를 다시 치면
|
||||||
|
현재 시각이 직전 목표에 못 미쳐 '새 검색'으로 오인 → **같은 통과를 다시 잡음**
|
||||||
|
|
||||||
|
## 수정 — StationBar.tsx (전 구간·세그먼트 폴백 양쪽)
|
||||||
|
|
||||||
|
- 같은 값 재Enter 는 시각 근접 여부와 무관하게 **무조건 다음 통과**:
|
||||||
|
기준시각 = max(직전 목표, 현재 시각 − 0.25s)
|
||||||
|
- 시크 미정착(현재 < 직전 목표) → 직전 목표 기준으로 다음 통과 ✓
|
||||||
|
- 재생으로 이미 지나감 → 현재 시각 기준 다음 통과 ✓
|
||||||
|
- 마지막 통과 이후 → 처음으로 랩어라운드(기존 유지)
|
||||||
|
|
||||||
|
## 반영
|
||||||
|
|
||||||
|
- 웹(index-C_8WNL38.js, 07.15 13:59) + 포터블(StationPlayer-Portable) 갱신
|
||||||
|
- 확인: 같은 값 Enter 연타 — 시크가 끝나기 전에 쳐도 통과 지점들을 차례로 순환
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# 2026-07-15 측점 검색 재설계 — 부호 교차(zero-crossing) 방식
|
||||||
|
|
||||||
|
**소요 시간**: 15분
|
||||||
|
**Context 사용량**: 작업분 input 250k / output 8k tokens · 누적 input 20600k / output 324k tokens
|
||||||
|
|
||||||
|
## 제보
|
||||||
|
|
||||||
|
1355 검색이 계속 오차 발생 + 두 번째 1355 지점으로 순환이 안 됨.
|
||||||
|
|
||||||
|
## 원인 (실데이터 재현으로 확정)
|
||||||
|
|
||||||
|
±20m '허용 창' 그룹핑 방식의 구조적 한계:
|
||||||
|
- 호버·저속 구간에서 창이 128초까지 늘어나며 **서로 다른 통과들이 한 창으로 병합**
|
||||||
|
(1355 실제 통과 3곳 — 201.0s/327.2s/334.5s — 이 1곳으로 뭉개짐 → 순환 불가)
|
||||||
|
- 창 안 '최적점' 선택이 흔들리며 착지 오차 유발
|
||||||
|
|
||||||
|
## 수정 — StationBar.tsx (전 구간 검색)
|
||||||
|
|
||||||
|
- **부호 교차 방식**: 배지값(vc)이 검색값을 가로지르는 모든 순간을 각각의 통과로
|
||||||
|
(교차 시각 = 선형보간 — 표본 오차 제거)
|
||||||
|
- 노선밖(off>60)·표본 불연속 구간 제외, 3초 이내 다중 교차(채터)는 병합
|
||||||
|
- 교차가 전혀 없는 값(도달만 못 함)은 최근접 지점 폴백(TOL 이내)
|
||||||
|
- 진단 로그: `[검색] 1355m: 통과 3곳 — 201.0s, 327.2s, 334.5s`
|
||||||
|
|
||||||
|
## 검증 (경로 1-1, lookahead 240)
|
||||||
|
|
||||||
|
| 검색 | 통과 |
|
||||||
|
|------|------|
|
||||||
|
| 1355 | **3곳** (201.0/327.2/334.5s) — Enter 순환으로 모두 방문 가능 |
|
||||||
|
| 1370 | 1곳 (336.9s) — 호버 1360 정체는 통과로 오인 안 함 |
|
||||||
|
| 2000 | 1곳 (441.6s) |
|
||||||
|
|
||||||
|
## 잔여 오차 안내
|
||||||
|
|
||||||
|
- 착지 후 배지 ±1~2m 는 로그 해상도(10Hz=0.8m)·표시 반올림(1m)에 따른
|
||||||
|
표시 한계 — 데이터 정밀도상 최선
|
||||||
|
|
||||||
|
## 반영
|
||||||
|
|
||||||
|
- 웹(index-D3EiCnLq.js, 07.15 14:04) + 포터블(StationPlayer-Portable) 갱신
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# 2026-07-15 측점 검색 최종 — 스테이션바(배지) 데이터에서 직접 탐지
|
||||||
|
|
||||||
|
**소요 시간**: 12분
|
||||||
|
**Context 사용량**: 작업분 input 200k / output 7k tokens · 누적 input 20900k / output 331k tokens
|
||||||
|
|
||||||
|
## 제보/제안
|
||||||
|
|
||||||
|
±2m 잔여 오차 지속 → 사용자 제안: "스테이션바에서 해당 값을 찾는 방식으로 하자".
|
||||||
|
|
||||||
|
## 잔여 2m 의 원인
|
||||||
|
|
||||||
|
배지는 djmd 프레임 동기(영상 시간축) 위치를 쓰는데, 검색은 CSV 시간축 교차 시각으로
|
||||||
|
시크 — 교차상관 오프셋(dt≈0.33s)×비행속도 ≈ 2m 가 구조적으로 남음.
|
||||||
|
|
||||||
|
## 수정 (2단계)
|
||||||
|
|
||||||
|
1. **djmd dt 전파**: refineFramesWithDjmd 가 dtSec 반환 → geoStore.djmdDtSec →
|
||||||
|
검색의 CSV↔영상 시간 변환에 반영
|
||||||
|
2. **하이브리드 탐지** (사용자 제안 채택):
|
||||||
|
- **현재 세그먼트**: 배지가 쓰는 바로 그 데이터(viewedRef.viewChain)에서
|
||||||
|
부호 교차 탐지 — 착지 시 배지값 == 검색값, **정의상 오차 0**
|
||||||
|
- **다른 세그먼트**: 전 구간 CSV 인덱스(dt 보정) 교차 — 현 세그 범위는 ①이 대체
|
||||||
|
- 순환·랩어라운드·3s 채터 병합·최근접 폴백 유지
|
||||||
|
|
||||||
|
## 반영
|
||||||
|
|
||||||
|
- 웹(index-BFtgZCK8.js, 07.15 14:13) + 포터블(StationPlayer-Portable) 갱신
|
||||||
|
- 확인: 1355 검색 → 배지 1+355 정확 착지, Enter 연타 시 통과 지점 순환
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# 2026-07-15 레거시(±20m 창) 검색 방식 제거 — 교차 방식으로 완전 통일
|
||||||
|
|
||||||
|
**소요 시간**: 5분
|
||||||
|
**Context 사용량**: 작업분 input 80k / output 3k tokens · 누적 input 21100k / output 337k tokens
|
||||||
|
|
||||||
|
## 확인/요청
|
||||||
|
|
||||||
|
배지 데이터 검색 정상 동작 확인. 오차 있던 이전 방식 삭제 여부 질문 →
|
||||||
|
폴백 경로에 ±20m '창' 방식이 남아 있었음.
|
||||||
|
|
||||||
|
## 조치 — StationBar.tsx
|
||||||
|
|
||||||
|
- 폴백 경로(전 구간 인덱스를 못 만드는 경우: 영상 길이 미측정 등)도
|
||||||
|
**배지 데이터(viewedRef.viewChain) 부호 교차 방식**으로 교체 —
|
||||||
|
창 그룹핑·범위(MARGIN) 검사·창 내 최적점 선택 코드 전부 삭제
|
||||||
|
- 이제 검색 경로는 두 가지뿐, 모두 교차 방식:
|
||||||
|
① 전 구간(현 세그 배지 데이터 + 타 세그 CSV 인덱스(dt 보정))
|
||||||
|
② 폴백(현 세그 배지 데이터만)
|
||||||
|
- 교차 없으면 최근접(TOL 이내) → 그것도 없으면 '측점 없음' 안내(기존 UX 유지)
|
||||||
|
|
||||||
|
## 반영
|
||||||
|
|
||||||
|
- 웹(index-BTt0XCMH.js, 07.15 14:16) + 포터블(StationPlayer-Portable) 갱신
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# 2026-07-15 카메라·디스플레이 PC 저장 — 환경설정 이름으로 파일명 생성
|
||||||
|
|
||||||
|
**소요 시간**: 6분
|
||||||
|
**Context 사용량**: 작업분 input 90k / output 3k tokens · 누적 input 21300k / output 341k tokens
|
||||||
|
|
||||||
|
## 요청
|
||||||
|
|
||||||
|
카메라/디스플레이 옵션 저장 시 해당 환경설정 이름을 만들어 저장.
|
||||||
|
|
||||||
|
## 수정 — StationOverlay.tsx
|
||||||
|
|
||||||
|
- 종전: 무조건 `camera.json` / `display.json` 으로 다운로드
|
||||||
|
- 변경: **`<환경설정 이름>.camera.json` / `<환경설정 이름>.display.json`**
|
||||||
|
- 이름 우선순위: 폴더의 **CSV(비행로그) 파일명**(예: "제주 중산간도로 경로 1-1")
|
||||||
|
→ route.json 노선명 → 영상 base → 'settings'
|
||||||
|
- 예: `제주 중산간도로 경로 1-1.camera.json` — 기존 폴더 파일들과 동일 규칙
|
||||||
|
- 로더는 `*.camera.json` / `*.display.json` 패턴을 이미 인식 → 폴더에 넣으면 자동 적용
|
||||||
|
|
||||||
|
## 반영
|
||||||
|
|
||||||
|
- 웹(index-DIeltQJQ.js, 07.15 14:51) + 포터블(StationPlayer-Portable) 갱신
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# 2026-07-15 7000 검색 → 6+998 착지 — 데이터 확인(정상 동작)
|
||||||
|
|
||||||
|
**소요 시간**: 3분
|
||||||
|
**Context 사용량**: 작업분 input 40k / output 1k tokens · 누적 input 21400k / output 343k tokens
|
||||||
|
|
||||||
|
## 제보
|
||||||
|
|
||||||
|
7000 검색 시 6+998 로 이동.
|
||||||
|
|
||||||
|
## 진단 (측점.kml 확인)
|
||||||
|
|
||||||
|
- 노선 마지막 측점 = **6+998** (…6+800, 6+900, 6+998 — 마지막 구간 98m, 7+000 없음)
|
||||||
|
- 7000 은 교차 지점이 없어 **최근접 폴백(±20m)** 이 6+998 로 이동시킨 것 — 정상
|
||||||
|
- 6+998 검색 시 정확히 해당 지점, ±20m 밖 값은 '측점 없음' 안내
|
||||||
|
|
||||||
|
## 조치
|
||||||
|
|
||||||
|
- 코드 변경 없음. 필요 시 '최근접 N 으로 이동' 안내 표시 추가 가능(사용자 판단 대기)
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# 2026-07-15 노선밖 배지 부호 표기 — 시점 이전 −, 종점 이후 +
|
||||||
|
|
||||||
|
**소요 시간**: 8분
|
||||||
|
**Context 사용량**: 작업분 input 110k / output 4k tokens · 누적 input 21600k / output 348k tokens
|
||||||
|
|
||||||
|
## 요청
|
||||||
|
|
||||||
|
1. 노선 구간 이후 = "노선밖 +값"(끝점 기준 증가), 시작 이전 = "노선밖 -값"(남은 거리 감소)
|
||||||
|
2. 배지 폭이 부족하면 m 단위 생략
|
||||||
|
|
||||||
|
## 구현 — StationBar.tsx
|
||||||
|
|
||||||
|
- ViewedPoint 에 **offSide** 추가: 투영 체이니지가 시점(chMin+2 이내)에 클램프 = **−1**,
|
||||||
|
종점(chMax−2 이상) = **+1**, 노선 중간 횡이탈 = 0
|
||||||
|
- 배지 표기:
|
||||||
|
- 시점 이전: `노선밖 -294` (접근할수록 감소 — 남은 거리)
|
||||||
|
- 종점 이후: `노선밖 +120` (멀어질수록 증가 — 지나간 거리)
|
||||||
|
- 중간 횡이탈: `노선밖 390` (부호 없음, 기존)
|
||||||
|
- **값 100 이상(3자리)이면 'm' 생략** — 배지 폭 확보 (99 이하는 `노선밖 -42m`)
|
||||||
|
|
||||||
|
## 반영
|
||||||
|
|
||||||
|
- 웹(index-DFjO-Oq9.js, 07.15 15:32) + 포터블(StationPlayer-Portable) 갱신
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# 2026-07-15 종점(6+998) 이후 배지 고정 — '노선밖 +000' 카운트업으로 교체
|
||||||
|
|
||||||
|
**소요 시간**: 6분
|
||||||
|
**Context 사용량**: 작업분 input 100k / output 4k tokens · 누적 input 21800k / output 352k tokens
|
||||||
|
|
||||||
|
## 제보
|
||||||
|
|
||||||
|
6+998 이후 배지 값 변화 없음(고정). 이때 '노선밖 +000' 형식으로 표시 원함
|
||||||
|
(7+000 같은 측점값 표기가 아니라).
|
||||||
|
|
||||||
|
## 원인
|
||||||
|
|
||||||
|
시선값(viewChain)이 노선 끝(chMax=6998)에 **클램프** — lookahead 240 이면
|
||||||
|
드론이 끝을 300m 지날 때까지 배지가 6+998 에 얼어붙음.
|
||||||
|
|
||||||
|
## 수정 — StationBar.tsx
|
||||||
|
|
||||||
|
- ViewedPoint 에 **beyondM**(종점 초과량) 추가:
|
||||||
|
- lookahead 활성: 클램프 전 시선값 − chMax (시선이 끝을 넘는 순간부터 0 → 증가)
|
||||||
|
- 미설정: 물리 투영이 종점 클램프 시 끝점까지 직선거리
|
||||||
|
- 배지 우선순위: **beyondM > 0.5 → `노선밖 +NNN`**(3자리 0패딩, 단위 없음, 카운트업)
|
||||||
|
→ 그 외 기존 표기(값/노선밖 ±)
|
||||||
|
- 측점값 표기는 6+998 이 마지막 — 7+000 등 범위 밖 값은 표기되지 않음
|
||||||
|
|
||||||
|
## 반영
|
||||||
|
|
||||||
|
- 웹(index-BmnWJtb5.js, 07.15 15:36) + 포터블(StationPlayer-Portable) 갱신
|
||||||
|
- 확인: 종점 접근 → 6+99x → **노선밖 +000 → +001 → …** 증가
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# 2026-07-15 '끝' 마크 7+000 오표기 — 단수 측점(6+998) 그대로 표시
|
||||||
|
|
||||||
|
**소요 시간**: 4분
|
||||||
|
**Context 사용량**: 작업분 input 60k / output 2k tokens · 누적 input 22000k / output 354k tokens
|
||||||
|
|
||||||
|
## 제보 (스크린샷)
|
||||||
|
|
||||||
|
'끝' 마크의 측점값 라벨이 7+000 — 실제 마지막 측점은 6+998 (7+000 은 존재하지 않음).
|
||||||
|
|
||||||
|
## 원인
|
||||||
|
|
||||||
|
Timeline 의 측점값 포맷터(fmtKm)가 **10m 단위 반올림** — 6998 → 7000 으로 왜곡.
|
||||||
|
|
||||||
|
## 수정 — Timeline.tsx fmtKm
|
||||||
|
|
||||||
|
- 10m 배수 ±2m 이내는 10m 스냅 유지(GPS 잡음 정리), **그 외는 1m 그대로**
|
||||||
|
→ 끝 측점 6+998 이 그대로 표시, 기존 10m 단위 마크(2+200 등)는 변화 없음
|
||||||
|
|
||||||
|
## 반영
|
||||||
|
|
||||||
|
- 웹(index-Di_zA62r.js, 07.15 15:40) + 포터블(StationPlayer-Portable) 갱신
|
||||||
|
- 확인: 종점부 '끝' 마크 라벨 = 6+998
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# 2026-07-15 구간 밖 배지 — '시점-NNN' / '종점+NNN' 표기
|
||||||
|
|
||||||
|
**소요 시간**: 4분
|
||||||
|
**Context 사용량**: 작업분 input 50k / output 2k tokens · 누적 input 22100k / output 356k tokens
|
||||||
|
|
||||||
|
## 요청
|
||||||
|
|
||||||
|
'노선밖 ±값' 대신 기준점을 명시: 종점 이후 = **종점+xxx**, 시점 이전 = **시점-xxx**.
|
||||||
|
|
||||||
|
## 수정 — StationBar.tsx cursorText
|
||||||
|
|
||||||
|
| 상태 | 표기 | 동작 |
|
||||||
|
|------|------|------|
|
||||||
|
| 시점 이전(접근) | `시점-294` → `시점-095` | 남은 거리 감소 |
|
||||||
|
| 종점 이후 | `종점+000` → `종점+078` | 지나간 거리 증가(카운트업) |
|
||||||
|
| 노선 중간 횡이탈 | `노선밖 390` | 기존 유지 |
|
||||||
|
|
||||||
|
- 값은 3자리 0패딩, 단위 생략(배지 폭)
|
||||||
|
|
||||||
|
## 반영
|
||||||
|
|
||||||
|
- 웹(index-CGOwiWul.js, 07.15 15:44) + 포터블(StationPlayer-Portable) 갱신
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# 2026-07-15 시점-062 → 0+000 건너뜀 — 카운트다운 연속화(-001 까지)
|
||||||
|
|
||||||
|
**소요 시간**: 5분
|
||||||
|
**Context 사용량**: 작업분 input 60k / output 2k tokens · 누적 input 22200k / output 358k tokens
|
||||||
|
|
||||||
|
## 제보
|
||||||
|
|
||||||
|
배지가 시점-062 에서 곧장 0+000 으로 전환 — -001 까지 내려가야 하지 않나.
|
||||||
|
|
||||||
|
## 원인
|
||||||
|
|
||||||
|
값 표시 전환은 이격 ≤ (주시거리+60m) 시점에 일어나는데, 시점- 표시값에
|
||||||
|
**진입 판정 여유(OFF_ROUTE_M=60m)** 가 포함되어 전환 직전 값이 늘 ~62 —
|
||||||
|
0 까지 못 내려가고 건너뛰는 것처럼 보임.
|
||||||
|
|
||||||
|
## 수정 — StationBar.tsx cursorText
|
||||||
|
|
||||||
|
- 시점-/종점+ 카운트 표시값에서 60m 여유를 차감 —
|
||||||
|
**전환점에서 정확히 0** 이 되도록: `시점-234 → … → -002 → -001 → 0+000`
|
||||||
|
- 노선 중간 횡이탈('노선밖 NNN')은 실제 이격 그대로(변화 없음)
|
||||||
|
|
||||||
|
## 반영
|
||||||
|
|
||||||
|
- 웹(index-D02b3J1p.js, 07.15 15:49) + 포터블(StationPlayer-Portable) 갱신
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# 2026-07-15 종점 이후 세그먼트 — 바 시작/끝 마크를 '종점+NNN' 표기로
|
||||||
|
|
||||||
|
**소요 시간**: 6분
|
||||||
|
**Context 사용량**: 작업분 input 90k / output 3k tokens · 누적 input 22400k / output 362k tokens
|
||||||
|
|
||||||
|
## 제보 (스크린샷)
|
||||||
|
|
||||||
|
종점 이후 구간 세그먼트에서 바 시작 마크가 6+998(클램프 값)로 표시,
|
||||||
|
끝 마크는 아예 없음. '종점+136' 식으로 표시되어야 하지 않나.
|
||||||
|
|
||||||
|
## 원인
|
||||||
|
|
||||||
|
- 시작 마크: viewChain 이 노선 끝(6998)에 클램프된 값을 그대로 라벨링
|
||||||
|
- 끝 마크: 시작과 같은 값(6998)이라 **100m 중복 제거 규칙에 걸려 생략**
|
||||||
|
|
||||||
|
## 수정
|
||||||
|
|
||||||
|
- StructMark 에 **kmText**(측점값 라벨 오버라이드) 추가, Timeline 이 우선 사용
|
||||||
|
- appendEndpointStations: 해당 지점의 종점 초과량(beyondM)이 있으면
|
||||||
|
- 라벨 = **`종점+NNN`** (배지와 동일 표기)
|
||||||
|
- km = 끝값+초과량 — 시작/끝 마크가 서로 중복 제거에 안 걸림 → 둘 다 표시
|
||||||
|
- 일반(노선 안) 세그먼트는 기존 표기 그대로
|
||||||
|
|
||||||
|
## 반영
|
||||||
|
|
||||||
|
- 웹(index-B886RUzX.js, 07.15 15:54) + 포터블(StationPlayer-Portable) 갱신
|
||||||
|
- 확인: 종점 이후 세그먼트 바 — 시작 '종점+084', 끝 '종점+300' 식으로 양끝 표시
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# 2026-07-15 종점 이후 값 감소·끝 마크 6+998 — 단조 증가 카운트업으로 재정의
|
||||||
|
|
||||||
|
**소요 시간**: 6분
|
||||||
|
**Context 사용량**: 작업분 input 100k / output 4k tokens · 누적 input 22600k / output 366k tokens
|
||||||
|
|
||||||
|
## 제보 (스크린샷: 시작 '종점+137', 중간 '끝 6+998', 배지 '종점+099')
|
||||||
|
|
||||||
|
종점을 지나왔으니 스테이션은 계속 증가해야 하는데 값이 뒤섞임 —
|
||||||
|
우측 끝에 '끝'과 '종점+xxx' 가 있어야 함.
|
||||||
|
|
||||||
|
## 원인
|
||||||
|
|
||||||
|
종점 이후 beyondM 을 시선 여유(effLook) 기반으로 계산 — 드론이 멀어질수록
|
||||||
|
여유가 줄어 **+137 → 0 으로 감소**, 0 이 되는 지점이 '끝' 마크(6+998)로 찍히고
|
||||||
|
그 뒤는 노선밖 분기로 넘어가는 뒤죽박죽 상태.
|
||||||
|
|
||||||
|
## 수정 — StationBar.tsx (종점 이후 재정의)
|
||||||
|
|
||||||
|
- 투영이 끝점에 클램프(offSide=+1)되면 pj.distM 은 횡이격이 아니라
|
||||||
|
**'종점을 지나온 거리'** → beyondM = 지나온 거리 + 주시거리
|
||||||
|
- **단조 증가** ✓, 통과 직전 시선값(chMax+주시거리)과 **연속** ✓
|
||||||
|
- viewOffM=0 으로 고정해 항상 '종점+' 표기(노선밖 분기 차단)
|
||||||
|
- 결과: 배지 종점+240 → +307 → … 증가, 바 좌측 '시작 종점+240', 우측 '끝 종점+xxx(최종)'
|
||||||
|
|
||||||
|
## 반영
|
||||||
|
|
||||||
|
- 웹(index-uaQSa9Kc.js, 07.15 15:59) + 포터블(StationPlayer-Portable) 갱신
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# 2026-07-15 스테이션바 '종점' 마크 추가 — 노선 끝 통과 위치 표시
|
||||||
|
|
||||||
|
**소요 시간**: 4분
|
||||||
|
**Context 사용량**: 작업분 input 60k / output 2k tokens · 누적 input 22800k / output 369k tokens
|
||||||
|
|
||||||
|
## 요청
|
||||||
|
|
||||||
|
스테이션바에 시점/종점 위치 표시 필요.
|
||||||
|
|
||||||
|
## 구현 — StationBar.tsx
|
||||||
|
|
||||||
|
- 기존 '시점' 마크(0+000 통과 위치)와 대칭으로 **'종점' 마크** 추가:
|
||||||
|
배지가 노선 끝 값(마지막 측점, 예: 6+998)에 도달하는 통과 위치에
|
||||||
|
위 줄 = 6+998, 아래 줄 = '종점' 으로 표시
|
||||||
|
- 이후 구간의 '종점+NNN' 카운트업이 어느 지점을 기준으로 하는지 바에서 바로 확인 가능
|
||||||
|
- 노선 끝까지 가지 않는 세그먼트에는 표시되지 않음(통과 창 없음)
|
||||||
|
|
||||||
|
## 반영
|
||||||
|
|
||||||
|
- 웹(index-BAbkBP_n.js, 07.15 16:01) + 포터블(StationPlayer-Portable) 갱신
|
||||||
|
- 확인: 종점 통과 세그먼트 바 — [ …6+900 · 6+998(종점) · 종점+NNN(끝) ] 구조
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# 2026-07-15 종점 이후 1000m 단위 마크 — 종점+1000 표시
|
||||||
|
|
||||||
|
**소요 시간**: 4분
|
||||||
|
**Context 사용량**: 작업분 input 60k / output 2k tokens · 누적 input 22900k / output 371k tokens
|
||||||
|
|
||||||
|
## 요청
|
||||||
|
|
||||||
|
종점+xxx 카운트업에서 1000 단위 지점이 표시되게(종점+1000).
|
||||||
|
|
||||||
|
## 구현 — StationBar.tsx appendKmStations
|
||||||
|
|
||||||
|
- 종점 이후 구간에서 **beyondM 이 1000, 2000… 을 가로지르는 프레임**(선형보간)에
|
||||||
|
측점값 라벨 마크(`종점+1000` 등, kmText) 추가 — 1km 단위 측점 마크의 연장선
|
||||||
|
- 기존 마크와 24px 이내 겹침 시 생략(기존 규칙 동일)
|
||||||
|
- 배지·끝 마크의 종점+ 값은 1000 이상도 원래 그대로 표시됨(잘림 없음) — 확인
|
||||||
|
|
||||||
|
## 반영
|
||||||
|
|
||||||
|
- 웹(index-Cl9_J0Ro.js, 07.15 16:05) + 포터블(StationPlayer-Portable) 갱신
|
||||||
|
- 확인: 초과 1km 이상 세그먼트(경로 1-3 끝) 바에 '종점+1000' 마크
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# 2026-07-15 단계별 커밋 6건 — 7/15 작업 전체
|
||||||
|
|
||||||
|
**소요 시간**: 5분
|
||||||
|
**Context 사용량**: 작업분 input 80k / output 3k tokens · 누적 input 23100k / output 377k tokens
|
||||||
|
|
||||||
|
## 커밋 (논리 단위 6개, ca591ce 이후)
|
||||||
|
|
||||||
|
| 커밋 | 내용 |
|
||||||
|
|------|------|
|
||||||
|
| f6a9b62 | StationPlayer 포터블 패키지(demo.ts·build-portable.sh·gitignore) |
|
||||||
|
| 76a2ab4 | 브랜딩 — 타이틀 StationPlayer |
|
||||||
|
| 87b66d9 | 데이터 파이프라인 — fullFrames·djmd dt·세그먼트 시크·곡선 평활 |
|
||||||
|
| 0a7044d | 스테이션바 — 검색 재설계(교차·배지 일치)·시점/종점 표기 체계 |
|
||||||
|
| 67cf646 | 오버레이 — 선형 입체형·자세 필터·설정 파일명 |
|
||||||
|
| 6ebad54 | 문서 — 작업 기록 규칙 + 이력 37건 |
|
||||||
|
|
||||||
|
- 작업 트리 클린(미커밋 0건). push 는 사용자 요청 시.
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# StationPlayer 포터블(로컬 단독 실행) 패키지 빌드
|
||||||
|
# 산출물: dist-portable/StationPlayer-Portable/
|
||||||
|
# node.exe — Windows 포터블 런타임 (호스트 설치본 복사)
|
||||||
|
# server.cjs — 경량 서버 단일 번들 (demo.ts)
|
||||||
|
# client/ — 클라이언트 정적 빌드
|
||||||
|
# StationPlayer.bat — 더블클릭 실행 (서버 시작 + 브라우저 오픈)
|
||||||
|
# README.txt
|
||||||
|
# 사용: source ~/.nvm/nvm.sh && nvm use 20 && bash scripts/build-portable.sh
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
OUT="dist-portable/StationPlayer-Portable"
|
||||||
|
NODE_EXE="/mnt/c/Program Files/nodejs/node.exe"
|
||||||
|
|
||||||
|
echo "[1/5] 클라이언트 빌드"
|
||||||
|
npm run build -w client
|
||||||
|
|
||||||
|
echo "[2/5] 경량 서버 번들 (esbuild)"
|
||||||
|
rm -rf "$OUT"
|
||||||
|
mkdir -p "$OUT"
|
||||||
|
# --minify: 고객 배포용 — 식별자·공백 압축(소스 비공개 수준을 클라이언트와 동일하게)
|
||||||
|
npx --yes esbuild server/src/demo.ts \
|
||||||
|
--bundle --minify --platform=node --target=node18 --format=cjs \
|
||||||
|
--outfile="$OUT/server.cjs" --log-level=warning
|
||||||
|
|
||||||
|
echo "[3/5] 클라이언트 복사"
|
||||||
|
cp -r client/dist "$OUT/client"
|
||||||
|
|
||||||
|
echo "[4/5] node.exe 복사"
|
||||||
|
if [ -f "$NODE_EXE" ]; then
|
||||||
|
cp "$NODE_EXE" "$OUT/node.exe"
|
||||||
|
else
|
||||||
|
echo " 경고: $NODE_EXE 없음 — node.exe 를 수동으로 넣어주세요 (nodejs.org 'Windows Binary')"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[5/5] 실행 스크립트/README 생성"
|
||||||
|
python3 - "$OUT" <<'EOF'
|
||||||
|
import sys, os
|
||||||
|
out = sys.argv[1]
|
||||||
|
bat = "\r\n".join([
|
||||||
|
"@echo off",
|
||||||
|
"chcp 65001 > nul",
|
||||||
|
"cd /d %~dp0",
|
||||||
|
"echo StationPlayer 서버 시작 - http://localhost:54000 (이 창을 닫으면 종료)",
|
||||||
|
'start "" http://localhost:54000',
|
||||||
|
"node.exe server.cjs",
|
||||||
|
"pause",
|
||||||
|
"",
|
||||||
|
])
|
||||||
|
open(os.path.join(out, "StationPlayer.bat"), "w", encoding="utf-8").write(bat)
|
||||||
|
readme = "\r\n".join([
|
||||||
|
"StationPlayer 포터블 — 스테이션 기반 동영상 플레이어 (로컬 단독 실행)",
|
||||||
|
"",
|
||||||
|
"실행 방법:",
|
||||||
|
" 1) 이 폴더 전체를 아무 위치(USB/바탕화면 등)에 복사",
|
||||||
|
" 2) StationPlayer.bat 더블클릭",
|
||||||
|
" 3) 브라우저가 열리면 [폴더 선택]으로 영상 데이터 폴더 지정",
|
||||||
|
"",
|
||||||
|
"요구사항: Windows 10/11 + 크롬/엣지 브라우저 (설치 불필요, Node.js 포함됨)",
|
||||||
|
"포트: 54000 (사용 중이면 StationPlayer.bat 의 PORT 수정)",
|
||||||
|
"종료: 검은 콘솔 창 닫기",
|
||||||
|
"",
|
||||||
|
])
|
||||||
|
open(os.path.join(out, "README.txt"), "w", encoding="utf-8-sig").write(readme)
|
||||||
|
print(" StationPlayer.bat / README.txt 생성")
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "완료: $OUT"
|
||||||
|
du -sh "$OUT" 2>/dev/null | awk '{print "총 크기: " $1}'
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* 포터블(로컬 단독 실행) 경량 서버 — Node 만으로 실행되는 단일 번들용 엔트리.
|
||||||
|
*
|
||||||
|
* 용도: 드론 도로영상 플레이어를 웹서버 설치 없이 아무 PC 에서 더블클릭으로 실행.
|
||||||
|
* - 정적 클라이언트(client/) 서빙 + SPA 폴백
|
||||||
|
* - /api/elevation (DEM 프록시), /api/tile (지도 타일 프록시) — 클라이언트 필수 API
|
||||||
|
* - 영상은 브라우저 폴더 선택(로컬 파일)로 재생하므로 업로드/HLS/DB(네이티브 모듈) 불필요
|
||||||
|
*
|
||||||
|
* 빌드: scripts/build-portable.sh → dist-portable/GhiVideo-Portable/
|
||||||
|
* (esbuild 단일 번들 server.cjs + client/ + node.exe + GhiVideo.bat)
|
||||||
|
*/
|
||||||
|
import express from 'express';
|
||||||
|
import cors from 'cors';
|
||||||
|
import path from 'path';
|
||||||
|
import elevationRouter from './routes/elevation';
|
||||||
|
import tileRouter from './routes/tile';
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use(cors({ origin: true }));
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
app.use('/api/elevation', elevationRouter);
|
||||||
|
app.use('/api/tile', tileRouter);
|
||||||
|
app.get('/api/health', (_req, res) => {
|
||||||
|
res.json({ status: 'ok', mode: 'portable', timestamp: new Date().toISOString() });
|
||||||
|
});
|
||||||
|
|
||||||
|
// 정적 클라이언트 — 번들(server.cjs) 옆의 client/ 폴더.
|
||||||
|
const clientDistPath = path.join(__dirname, 'client');
|
||||||
|
const cspHeader =
|
||||||
|
"default-src 'self'; " +
|
||||||
|
"script-src 'self'; " +
|
||||||
|
"style-src 'self' 'unsafe-inline'; " +
|
||||||
|
"font-src 'self' data:; " +
|
||||||
|
"img-src 'self' blob: data:; " +
|
||||||
|
"media-src 'self' blob:; " +
|
||||||
|
"connect-src 'self'; " +
|
||||||
|
"worker-src 'self' blob:;";
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
if (!req.path.startsWith('/api')) res.setHeader('Content-Security-Policy', cspHeader);
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
app.use(express.static(clientDistPath));
|
||||||
|
app.get('*', (req, res, next) => {
|
||||||
|
if (req.path.startsWith('/api')) return next();
|
||||||
|
res.sendFile(path.join(clientDistPath, 'index.html'));
|
||||||
|
});
|
||||||
|
|
||||||
|
const port = Number(process.env.PORT || 54000);
|
||||||
|
app.listen(port, () => {
|
||||||
|
console.log(`GhiVideo 포터블 서버 실행 중 — http://localhost:${port}`);
|
||||||
|
console.log('이 창을 닫으면 서버가 종료됩니다.');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user