feat: 스테이션바 측점 정확도 개선 + 영상 컨트롤 UI 통합 + 종합 기술문서
스테이션바/측점(체이니지) - 역 재진입 마커 누락 수정: 측점기준 탐지에 좌표근접(pxPassesTo) 합집합 보강(중복 24px 제거) - kmExists 판정: 좌표 반경만으로 잡힌 통과는 측점값 라벨/검색에서 제외(마커 동그라미는 유지) - 측점값 라벨 겹침 숨김(좌측 우선), 동일항목 브래킷 클러스터 - 측점 검색 순환(통과방향 다음→끝에서 처음), 마커 기준 후보 산출, 입력값 유지/blur 삭제 - 연속 체이니지 수직투영 유틸 분리(client/src/utils/chainage.ts), Minimap 컴포넌트 추가 영상 컨트롤 UI - 선형/드론궤적/좌측패널 토글을 재생바로 통합(settingsStore 공유·persist) - 드론 GPS·고도 HUD 상시 표시, 경로표고·투명도 항상 노출, 겹침제외 버튼 숨김 - 재생바 요소 재배치(좌측패널→선형→드론궤적→드론위치정보→시설등급), 활성색 amber 통일 문서/도구 - 종합 기술명세서(인계+특허 기초) md/html/pdf 3종, UI개선 보고서 3종 - md→html→pdf 변환 스크립트(scripts/md2docs.sh) + 스타일(docs/assets/report.css) - 작업 히스토리 다수 추가(docs/history/) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
/**
|
||||
* 나침반 (heading-up, 아날로그 눈금형) — 눈금 링 + N + 빨간 바늘이 드론 방위(--rot = -yaw)로 회전.
|
||||
* - 회전 카드(눈금/N/빨간 바늘/빨간 눈금): 부모 RAF 가 `--rot`(누적 언랩) 갱신 → 부드럽게 회전.
|
||||
* - 상단 흰 삼각형 인덱스 + 외곽 링은 고정(위 = 드론 진행방향 = 헤딩).
|
||||
* ref 는 root div — `style.setProperty('--rot', `${-yaw}deg`)`.
|
||||
*/
|
||||
const D = 150; // 나침반 한 변(px)
|
||||
|
||||
// 눈금: 6° 간격(60개). 0/90/180/270=장, 30°배수=중, 그 외=단.
|
||||
const TICKS = Array.from({ length: 60 }, (_, i) => {
|
||||
const deg = i * 6;
|
||||
const long = deg % 90 === 0;
|
||||
const mid = deg % 30 === 0;
|
||||
const a = (deg * Math.PI) / 180;
|
||||
const r1 = long ? 37 : mid ? 40 : 42; // 안쪽 끝
|
||||
const sin = Math.sin(a), cos = Math.cos(a);
|
||||
return {
|
||||
deg,
|
||||
x1: 50 + r1 * sin, y1: 50 - r1 * cos,
|
||||
x2: 50 + 46 * sin, y2: 50 - 46 * cos,
|
||||
w: long ? 1.7 : mid ? 1.2 : 0.8,
|
||||
color: long ? '#fff' : 'rgba(255,255,255,0.6)',
|
||||
};
|
||||
});
|
||||
|
||||
export const Minimap = forwardRef<HTMLDivElement, { className?: string }>(function Minimap(_props, ref) {
|
||||
return (
|
||||
<div ref={ref} style={{ position: 'absolute', top: 14, right: 14, width: D, height: D, zIndex: 30, pointerEvents: 'none' }}>
|
||||
{/* 고정 — 배경 원 + 외곽 링(두껍게). 프레임만 고정, 그 외 전부 회전. */}
|
||||
<svg viewBox="0 0 100 100" width={D} height={D} style={{ position: 'absolute', inset: 0 }}>
|
||||
<circle cx="50" cy="50" r="46.5" fill="rgba(16,22,32,0.45)" stroke="rgba(255,255,255,0.92)" strokeWidth="3" />
|
||||
</svg>
|
||||
|
||||
{/* 회전 카드 — 눈금 + N + 빨간 바늘(N) + 흰 삼각형(S) + 빨간 눈금. 전체가 함께 회전. */}
|
||||
<svg
|
||||
viewBox="0 0 100 100"
|
||||
width={D}
|
||||
height={D}
|
||||
style={{ position: 'absolute', inset: 0, transform: 'rotate(var(--rot, 0deg))', transformOrigin: '50% 50%' }}
|
||||
>
|
||||
{TICKS.map((t) => (
|
||||
<line key={t.deg} x1={t.x1} y1={t.y1} x2={t.x2} y2={t.y2} stroke={t.color} strokeWidth={t.w} strokeLinecap="round" />
|
||||
))}
|
||||
{/* 빨간 화살표 — N(정북). 끝(tip)이 테두리에 맞닿고, 기단은 중심 쪽. */}
|
||||
<polygon points="50,7 43,32 57,32" fill="#e2231a" />
|
||||
{/* N 문자 — 빨간 화살표 '안쪽'(중심 방향). */}
|
||||
<text
|
||||
x="50"
|
||||
y="45"
|
||||
textAnchor="middle"
|
||||
fontSize="12"
|
||||
fontWeight="700"
|
||||
fill="#fff"
|
||||
stroke="rgba(0,0,0,0.55)"
|
||||
strokeWidth="0.7"
|
||||
style={{ paintOrder: 'stroke' }}
|
||||
>
|
||||
N
|
||||
</text>
|
||||
{/* 흰 삼각형 — S방향, 빨간 화살표와 '정반대'(아래로). 끝이 하단 테두리에 맞닿음. */}
|
||||
<polygon points="50,93 43,68 57,68" fill="rgba(255,255,255,0.95)" />
|
||||
{/* 중심 점 */}
|
||||
<circle cx="50" cy="50" r="2.3" fill="#fff" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -66,6 +66,7 @@ export default function RoutePanel({ currentTime, visible, onSeek, topPx = 90, b
|
||||
const pois = useGeoStore(s => s.pois);
|
||||
const structures = useGeoStore(s => s.structures);
|
||||
const gradeFilter = useSettingsStore(s => s.gradeFilter);
|
||||
const poiOverlapExclude = useSettingsStore(s => s.poiOverlapExclude);
|
||||
const droneFrames = useGeoStore(s => s.frames);
|
||||
const routeMeta = useGeoStore(s => s.routeMeta);
|
||||
const [currentKm, setCurrentKm] = useState(0);
|
||||
@@ -250,9 +251,21 @@ export default function RoutePanel({ currentTime, visible, onSeek, topPx = 90, b
|
||||
const kmToY = (km: number) => (1 - (km - minKm) / (maxKm - minKm)) * 100;
|
||||
|
||||
// 교량/터널만 표시 + 시설종별 필터(설정 스토어, 재생바와 동일 규칙).
|
||||
// \uACB9\uCE68\uC81C\uC678 ON \uC2DC \uAC19\uC740 \uC704\uCE58 \uD615\uC81C \uC911 \uC9C4\uD589\uBC29\uD5A5((\uD558)/(\uC0C1))\uC744 \uC6B0\uC120 \uB0A8\uAE30\uB3C4\uB85D dir-\uC6B0\uC120 \uC815\uB82C.
|
||||
const routeDir = routeMeta?.routeInfo?.direction?.includes('\uD558') ? '\uD558'
|
||||
: routeMeta?.routeInfo?.direction?.includes('\uC0C1') ? '\uC0C1' : null;
|
||||
const dirTag = routeDir === '\uC0C1' ? '(\uC0C1)' : routeDir === '\uD558' ? '(\uD558)' : '';
|
||||
// \uACBD\uB85C\uC0C1 \uC2DC\uC124\uBB3C\uB9CC: \uBC29\uD5A5\uD45C\uAE30((\uC0C1\u2026/(\uD558\u2026)\uAC00 \uC788\uC73C\uBA74 \uC601\uC0C1 \uBC29\uD5A5 \uC77C\uCE58\uD558\uB294 \uAC83\uB9CC(\uBC18\uB300 \uBC29\uD5A5=\uB2E4\uB978 \uC120\uB85C \uC81C\uC678).
|
||||
const oppCh = routeDir === '\uD558' ? '\uC0C1' : routeDir === '\uC0C1' ? '\uD558' : '';
|
||||
const isOppositeDir = (name: string): boolean => !!oppCh && new RegExp(`[(\uFF08]${oppCh}`).test(name);
|
||||
const baseStruct = (t: string): string => t.replace(/\s*[(\uFF08].*$/, '').trim();
|
||||
// \uAC19\uC740 \uC88C\uD45C(\uAC19\uC740 base \uBCC0\uD615)\uB294 \uACB9\uCE68\uC81C\uC678 \uC635\uC158\uACFC \uBB34\uAD00\uD558\uAC8C \uC9C4\uD589\uBC29\uD5A5 1\uAC1C\uB9CC(dir-\uC6B0\uC120 \uC815\uB82C \uD6C4 base\uBCC4 \uCCAB\uC9F8).
|
||||
const seenBase = new Set<string>();
|
||||
const filteredPois = structures
|
||||
.filter(s => (s.type === 'bridge' || s.type === 'tunnel') && typeof s.lat === 'number' && typeof s.lon === 'number' && isGradeVisible(s.grade, gradeFilter))
|
||||
.map(s => ({ title: s.name, category: s.type === 'tunnel' ? '\uD130\uB110' : '\uAD50\uB7C9', lat: s.lat as number, lon: s.lon as number, z: 0, type: 'poi' as const }));
|
||||
.filter(s => (s.type === 'bridge' || s.type === 'tunnel') && typeof s.lat === 'number' && typeof s.lon === 'number' && isGradeVisible(s.grade, gradeFilter) && !isOppositeDir(s.name))
|
||||
.map(s => ({ title: s.name, category: s.type === 'tunnel' ? '\uD130\uB110' : '\uAD50\uB7C9', lat: s.lat as number, lon: s.lon as number, z: 0, type: 'poi' as const }))
|
||||
.sort((a, b) => (dirTag && a.title.includes(dirTag) ? 0 : 1) - (dirTag && b.title.includes(dirTag) ? 0 : 1))
|
||||
.filter(p => { const b = baseStruct(p.title); if (seenBase.has(b)) return false; seenBase.add(b); return true; });
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -287,8 +300,12 @@ export default function RoutePanel({ currentTime, visible, onSeek, topPx = 90, b
|
||||
if (km < 0) return null;
|
||||
const y = kmToY(km);
|
||||
if (y < 5 || y > 95) return null;
|
||||
if (placed.some(py => Math.abs(py - y) < MIN_GAP)) return null;
|
||||
placed.push(y);
|
||||
// 겹침제외 ON 일 때만 겹친 것(같은 위치 형제 포함) 제외 → 방향 우선(위 dir-정렬)으로 1개만.
|
||||
// OFF 면 모두 표시(겹쳐도). 영상 오버레이 토글과 동일 규칙.
|
||||
if (poiOverlapExclude) {
|
||||
if (placed.some(py => Math.abs(py - y) < MIN_GAP)) return null;
|
||||
placed.push(y);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={`poi-${i}`}
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from '../../utils/geoProjection';
|
||||
import { useGeoStore } from '../../store/geoStore';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { Minimap } from './Minimap';
|
||||
import type { GeoPoint, CenterlinePoint, PoiOverrideMap, RouteStructure } from '../../types/geo';
|
||||
|
||||
const VIDEO_FPS = 30000 / 1001;
|
||||
@@ -75,6 +76,8 @@ interface Props {
|
||||
showPanel?: boolean;
|
||||
/** 카메라 파라미터 패널 top(px) — 노선 배너 아래로 배치. 기본 72. */
|
||||
topPx?: number;
|
||||
/** 하단 스테이션바 높이(px) — 우측 버튼을 좌측 패널과 같은 높이(bottom)에 맞추기 위함. */
|
||||
barHeight?: number;
|
||||
/** 부드러운 단조보간 재생시간(초) ref. VideoPlayer smoothTimeRef 전달 시
|
||||
* 라벨이 60fps로 매끄럽게 이동(일시정지·시크·배속 보정 포함). 없으면 prop 기반 추정. */
|
||||
timeRef?: React.MutableRefObject<number>;
|
||||
@@ -107,10 +110,10 @@ const DISPLAY_DEFAULTS = {
|
||||
emaAlpha: 1.0,
|
||||
smoothMinAlpha: 0.12, // 정지/떨림 시 최소 추종(작을수록 강한 평활)
|
||||
smoothSpeedRef: 0.010, // 즉시추종 기준 속도(클수록 더 강하게 평활)
|
||||
maxPoiRange: 300,
|
||||
maxPoiRange: 1000,
|
||||
// false = 지면고도 기준(POI를 지면고도 z에 고정 → gap=드론고도−지면고도 자동변동, 드론 오르내려도 지물에 붙음).
|
||||
// true = 드론−이격거리 고정(gap 상수). 드론 고도 변동 시 부정확 → 기본 off.
|
||||
poiDroneHeight: false,
|
||||
// true = 드론−이격거리 고정(gap 상수). 드론 고도 변동 시 부정확.
|
||||
poiDroneHeight: true,
|
||||
droneHeightDrop: 24,
|
||||
};
|
||||
|
||||
@@ -229,7 +232,7 @@ function ParamRow({ label, value, min, max, step, unit, decimals = 1, onChange,
|
||||
|
||||
// ── 메인 컴포넌트 ─────────────────────────────────────────────────────────────
|
||||
|
||||
export default function StationOverlay({ currentFrame, currentTime, fps, visible, videoReady = true, videoWidth = 0, videoHeight = 0, onTogglePlay, showPanel = true, topPx = 72, timeRef }: Props) {
|
||||
export default function StationOverlay({ currentFrame, currentTime, fps, visible, videoReady = true, videoWidth = 0, videoHeight = 0, onTogglePlay, showPanel = true, topPx = 72, barHeight = 0, timeRef }: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const canvasSizeRef = useRef({ w: 0, h: 0 });
|
||||
|
||||
@@ -262,6 +265,9 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
const renderCacheRef = useRef<RenderCache | null>(null);
|
||||
// 나침반 전용(측점선 visible 무관 항상 갱신·표시)
|
||||
const compassRef = useRef<{ effectiveYaw: number; hFovRad: number } | null>(null);
|
||||
// 나침반 미니맵(DOM) root — RAF 에서 --rot(=-yaw) 갱신. minimapRotRef=누적 회전(360° 언랩).
|
||||
const minimapRef = useRef<HTMLDivElement>(null);
|
||||
const minimapRotRef = useRef(0);
|
||||
|
||||
// UI state
|
||||
const [params, setParams] = useState<CameraParams>(DEFAULT_CAMERA_PARAMS);
|
||||
@@ -279,24 +285,28 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
// POI 표시 범위(m) — 드론 위치와의 수평 직선거리(distH, 고도차 무시). 이 범위 안이면 표시,
|
||||
// 밖이면 제외. (이전: 진행방향 앞/옆 비등방 → 단일 반경으로 통합.) 패널에서 실시간 조절.
|
||||
const [maxPoiRange, setMaxPoiRange] = useState(DISPLAY_DEFAULTS.maxPoiRange);
|
||||
const maxPoiRangeRef = useRef(300);
|
||||
const maxPoiRangeRef = useRef(DISPLAY_DEFAULTS.maxPoiRange);
|
||||
// 비교 테스트: POI 높이를 선로표고(false) 대신 '드론 고도 − N미터'(true)로. N=droneHeightDrop.
|
||||
const [poiDroneHeight, setPoiDroneHeight] = useState(DISPLAY_DEFAULTS.poiDroneHeight);
|
||||
const [droneHeightDrop, setDroneHeightDrop] = useState(DISPLAY_DEFAULTS.droneHeightDrop);
|
||||
// 드론 경로 가시화 — 드론의 GPS 경로(lat/lon)를 지정 표고 z 에 투영해 선으로 표시.
|
||||
const [showDronePath, setShowDronePath] = useState(DISPLAY_DEFAULTS.showDronePath);
|
||||
// 선형/드론궤적 토글은 설정 스토어로 공유 → VideoPlayer 하단 바 버튼에서도 제어.
|
||||
const showDronePath = useSettingsStore(s => s.showDronePath);
|
||||
const setShowDronePath = useSettingsStore(s => s.setShowDronePath);
|
||||
const [dronePathZ, setDronePathZ] = useState(DISPLAY_DEFAULTS.dronePathZ); // 기본 경로 표고
|
||||
const showDronePathRef = useRef(true);
|
||||
const dronePathZRef = useRef(78);
|
||||
useEffect(() => { showDronePathRef.current = showDronePath; }, [showDronePath]);
|
||||
useEffect(() => { dronePathZRef.current = dronePathZ; }, [dronePathZ]);
|
||||
// 선형(중심선) 독립 토글 + 드론경로 투명도(0~1)
|
||||
const [showCenterline, setShowCenterline] = useState(DISPLAY_DEFAULTS.showCenterline);
|
||||
// 선형(중심선) 독립 토글 + 드론경로 투명도(0~1) — 토글은 설정 스토어 공유(바 버튼 제어).
|
||||
const showCenterline = useSettingsStore(s => s.showCenterline);
|
||||
const setShowCenterline = useSettingsStore(s => s.setShowCenterline);
|
||||
const showCenterlineRef = useRef(true);
|
||||
useEffect(() => { showCenterlineRef.current = showCenterline; }, [showCenterline]);
|
||||
const [dronePathAlpha, setDronePathAlpha] = useState(DISPLAY_DEFAULTS.dronePathAlpha);
|
||||
const dronePathAlphaRef = useRef(0.75);
|
||||
useEffect(() => { dronePathAlphaRef.current = dronePathAlpha; }, [dronePathAlpha]);
|
||||
// 측점 진단 토글 버튼은 삭제됨. (HUD 표시는 VideoPlayer 에서 showStationDiag 구독)
|
||||
// POI 위치 편집(드래그) 모드
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const editModeRef = useRef(false);
|
||||
@@ -442,6 +452,7 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
const poiOverlapExclude = useSettingsStore(s => s.poiOverlapExclude);
|
||||
const overlapExcludeRef = useRef(true);
|
||||
useEffect(() => { overlapExcludeRef.current = poiOverlapExclude; }, [poiOverlapExclude]);
|
||||
// 좌측 노선 패널(RoutePanel) 표시 토글은 하단 재생바(VideoPlayer)로 이동.
|
||||
const storeBaseName = useGeoStore(s => s.baseName);
|
||||
const storePoiOverrides = useGeoStore(s => s.poiOverrides);
|
||||
const setPoiOverride = useGeoStore(s => s.setPoiOverride);
|
||||
@@ -798,7 +809,9 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
}
|
||||
};
|
||||
|
||||
requestIdleCallback(step, { timeout: 200 });
|
||||
// 첫 청크(현재 프레임 포함)는 동기 실행 → 토글/재계산이 '정지 중에도' 즉시 반영.
|
||||
// (idle 대기 시 정지 상태에서 갱신이 지연/누락되던 문제 해결.) 나머지는 step 내부에서 idle 로 이어감.
|
||||
step();
|
||||
}, [nearestCL]);
|
||||
|
||||
// 모든 데이터 로드 완료 시 사전 계산 시작
|
||||
@@ -936,6 +949,27 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
: currentDroneFrameRef.current!);
|
||||
const lines = buildLines(dronePose);
|
||||
|
||||
// 나침반 미니맵(heading-up) 회전 — 영상과 즉시 동기. dronePose.yaw 는 ±smoothHalf(기본 60fr ≈2s)
|
||||
// 평활이라 회전 시 지연 → 가벼운 평활(±3fr)의 yaw 를 따로 보간해 사용. 360° 누적 언랩(점프 방지).
|
||||
if (minimapRef.current) {
|
||||
const arr = allDroneFramesRef.current;
|
||||
let rawYaw = dronePose.yaw;
|
||||
if (arr.length) {
|
||||
let lo = 0, hi = arr.length - 1;
|
||||
while (lo < hi) { const m = (lo + hi) >> 1; if (arr[m].frame < estFrame) lo = m + 1; else hi = m; }
|
||||
const i2 = lo, i1 = Math.max(0, lo - 1);
|
||||
const f1 = arr[i1].frame, f2 = arr[i2].frame;
|
||||
const frac = f2 > f1 ? Math.min(1, Math.max(0, (estFrame - f1) / (f2 - f1))) : 0;
|
||||
const a = smoothFrame(arr, i1, 3), b = smoothFrame(arr, i2, 3); // ±3fr 가벼운 평활
|
||||
let dy = b.yaw - a.yaw; dy = ((dy + 540) % 360) - 180;
|
||||
rawYaw = a.yaw + dy * frac;
|
||||
}
|
||||
const target = -(rawYaw + paramsRef.current.yawOffset);
|
||||
const delta = (((target - minimapRotRef.current) % 360) + 540) % 360 - 180;
|
||||
minimapRotRef.current += delta;
|
||||
minimapRef.current.style.setProperty('--rot', `${minimapRotRef.current}deg`);
|
||||
}
|
||||
|
||||
// 선로 중심선 (선형) — 독립 토글
|
||||
if (showCenterlineRef.current && lines.centerlineSegs.length > 0) {
|
||||
ctx.strokeStyle = 'rgba(255,50,50,0.85)';
|
||||
@@ -996,7 +1030,9 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
const seenSt = new Set<string>();
|
||||
const seenPoi = new Set<string>();
|
||||
|
||||
// 측점 라벨 — 연속 포즈로 직접 투영 → 이상치 거부 → EMA
|
||||
// 측점 라벨 — 연속 포즈로 직접 투영 → 이상치 거부 → EMA.
|
||||
// 선형(중심선) 토글과 함께 ON/OFF (빨간 스테이션 라벨 = 선형의 일부).
|
||||
if (showCenterlineRef.current) {
|
||||
ctx.font = 'bold 18px monospace';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'middle';
|
||||
@@ -1028,6 +1064,7 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
const tw = ctx.measureText(cleanTitle(stA.title)).width;
|
||||
hitBoxes.push({ kind: 'station', title: stA.title, x0: x - 8, y0: y - 12, x1: lx + tw + 2, y1: y + 12 });
|
||||
});
|
||||
}
|
||||
|
||||
// POI 마커 — 연속 포즈로 직접 투영 → 이상치 거부 → EMA
|
||||
ctx.font = 'bold 20px sans-serif';
|
||||
@@ -1102,37 +1139,7 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
ctx.textAlign = 'left'; ctx.textBaseline = 'alphabetic';
|
||||
}
|
||||
|
||||
// 나침반 HUD — 우측 상단, 측점선 토글과 무관하게 항상 표시
|
||||
const compass = compassRef.current;
|
||||
if (compass) {
|
||||
const r = 52;
|
||||
const cx = W - (r + 14), cy = 16 + r;
|
||||
ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI*2);
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.55)'; ctx.fill();
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.25)'; ctx.lineWidth = 1; ctx.stroke();
|
||||
ctx.font = 'bold 9px sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
||||
for (const [label, deg] of [['N',0],['E',90],['S',180],['W',270]] as const) {
|
||||
const rad = (deg-90)*Math.PI/180;
|
||||
ctx.fillStyle = label==='N' ? '#ff6060' : 'rgba(255,255,255,0.5)';
|
||||
ctx.fillText(label, cx+Math.cos(rad)*(r-9), cy+Math.sin(rad)*(r-9));
|
||||
}
|
||||
const yawRad = (compass.effectiveYaw-90)*Math.PI/180;
|
||||
const tx = cx+Math.cos(yawRad)*(r-14), ty = cy+Math.sin(yawRad)*(r-14);
|
||||
ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(tx, ty);
|
||||
ctx.strokeStyle = '#ffd700'; ctx.lineWidth = 2.5; ctx.stroke();
|
||||
const ha = 0.42, hl = 9;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(tx, ty); ctx.lineTo(tx-hl*Math.cos(yawRad-ha), ty-hl*Math.sin(yawRad-ha));
|
||||
ctx.moveTo(tx, ty); ctx.lineTo(tx-hl*Math.cos(yawRad+ha), ty-hl*Math.sin(yawRad+ha));
|
||||
ctx.strokeStyle = '#ffd700'; ctx.lineWidth = 2; ctx.stroke();
|
||||
ctx.beginPath(); ctx.moveTo(cx, cy);
|
||||
ctx.arc(cx, cy, r-2, yawRad-compass.hFovRad/2, yawRad+compass.hFovRad/2); ctx.closePath();
|
||||
ctx.fillStyle = 'rgba(255,215,0,0.12)'; ctx.fill();
|
||||
ctx.strokeStyle = 'rgba(255,215,0,0.35)'; ctx.lineWidth = 1; ctx.stroke();
|
||||
ctx.font = '9px monospace'; ctx.textBaseline = 'top'; ctx.fillStyle = '#ffd700';
|
||||
ctx.fillText(`${((compass.effectiveYaw+360)%360).toFixed(1)}°`, cx, cy+r+2);
|
||||
ctx.textAlign = 'left'; ctx.textBaseline = 'alphabetic';
|
||||
}
|
||||
|
||||
|
||||
// 라벨 속성 팝업 위치 — 해당 라벨의 현재 표시좌표를 따라 매 프레임 갱신(재생 중 이동).
|
||||
// 라벨이 화면에서 사라지면(필터/화면밖) 팝업 숨김, 다시 보이면 표시.
|
||||
@@ -1155,15 +1162,14 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
popupMissRef.current.set(id, 0);
|
||||
const ph = el.offsetHeight || 120;
|
||||
const pw = el.offsetWidth || 240;
|
||||
// 팝업은 항상 라벨 '아래'. 라벨 위로는 띄우지 않는다.
|
||||
// 화면 하단을 넘칠 만큼 라벨이 내려가면(곧 사라지는 중) → 위로 올리지 않고 팝업을 숨김.
|
||||
const GAP = 26;
|
||||
// 기본은 라벨 '아래'. 아래 공간이 부족하면(화면 하단 근접) 라벨 '위'로 플립 →
|
||||
// 라벨과 겹치거나 화면 밖으로 잘리는 것을 방지. 위로도 부족하면 화면 안으로 클램프.
|
||||
const GAP = 16;
|
||||
const tx = Math.min(Math.max(8, sx - 10), Math.max(8, W - pw - 4));
|
||||
const ty = sy + GAP;
|
||||
let ty = sy + GAP; // 아래
|
||||
if (ty + ph > H - 2) {
|
||||
// 라벨이 화면 하단에 근접 → 아래로 못 펼침. 라벨 위로 올리지 않고 숨김(라벨과 함께 사라짐).
|
||||
el.style.visibility = 'hidden';
|
||||
return;
|
||||
const aboveTy = sy - GAP - ph; // 위로 플립 (팝업 하단이 라벨 위 ~GAP 지점)
|
||||
ty = aboveTy >= 2 ? aboveTy : Math.max(2, H - 2 - ph);
|
||||
}
|
||||
el.style.visibility = 'visible';
|
||||
const pp = popupPosRef.current.get(id);
|
||||
@@ -1435,6 +1441,9 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
onPointerCancel={onPoiPointerUp}
|
||||
/>
|
||||
|
||||
{/* 나침반 미니맵 — 우측 상단(기존 캔버스 나침반 대체). 핀은 RAF 가 드론 방위로 회전. */}
|
||||
{geoDataLoaded && <Minimap ref={minimapRef} />}
|
||||
|
||||
{/* 라벨 속성 팝업(다중) — 위치는 RAF 가 라벨을 따라 갱신. DOM 이라 텍스트 선명.
|
||||
컴팩트(기본 5필드) ↔ 전체 토글은 팝업 클릭. 자동(auto) 팝업은 ✕ 없음(가시 구조물 따라 표시). */}
|
||||
{infoPopups.map(pp => {
|
||||
@@ -1475,37 +1484,29 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
})}
|
||||
|
||||
{showPanel && (
|
||||
<div className="absolute right-2 z-30 flex flex-col items-end gap-1" style={{ top: Math.max(topPx, 150) }}>
|
||||
<div className="flex gap-1">
|
||||
<div className="absolute right-2 z-30 flex flex-col-reverse items-end gap-2" style={{ bottom: (barHeight || 130) + 8 }}>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => setShowDisplay(v => !v)}
|
||||
className={`text-[10px] whitespace-nowrap px-2 py-1 rounded border shadow ${showDisplay ? 'bg-sky-700/90 border-sky-400 text-white' : 'bg-black/70 hover:bg-black/90 text-gray-200 border-gray-500'}`}>
|
||||
className={`text-xs whitespace-nowrap px-2 py-1 rounded border ${showDisplay ? 'bg-amber-400 border-amber-300 text-black' : 'bg-black/70 hover:bg-black/90 text-gray-300 border-gray-600'}`}>
|
||||
{showDisplay ? '▲ 화면표시 옵션' : '▼ 화면표시 옵션'}
|
||||
</button>
|
||||
<button onClick={() => setShowControls(v => !v)}
|
||||
className={`text-[10px] whitespace-nowrap px-2 py-1 rounded border shadow ${showControls ? 'bg-gray-700/90 border-gray-400 text-white' : 'bg-black/70 hover:bg-black/90 text-gray-200 border-gray-500'}`}>
|
||||
className={`text-xs whitespace-nowrap px-2 py-1 rounded border ${showControls ? 'bg-amber-400 border-amber-300 text-black' : 'bg-black/70 hover:bg-black/90 text-gray-300 border-gray-600'}`}>
|
||||
{showControls ? '▲ 카메라 파라미터' : '▼ 카메라 파라미터'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 두 패널을 가로로 나란히(우→좌) 펼침 — 둘 다 열어도 세로로 안 쌓여 화면 위로 안 넘침. */}
|
||||
<div className="flex flex-row-reverse items-end gap-2">
|
||||
{/* ── 화면표시 옵션 (카메라 외 모든 표시 설정) ── */}
|
||||
{showDisplay && (
|
||||
<div className="bg-black/90 border border-sky-700 rounded p-3 text-white w-72 select-none max-h-[80vh] overflow-y-auto shadow-xl">
|
||||
<div className="text-[10px] text-gray-500 uppercase tracking-wider mb-1.5 border-b border-gray-700 pb-2 mb-2">표시 토글</div>
|
||||
<div className="space-y-2 mb-3">
|
||||
<button onClick={() => setShowCenterline(v => !v)}
|
||||
className={`w-full text-[11px] px-2 py-1 rounded border transition-colors ${showCenterline ? 'bg-red-500/80 border-red-300 text-black font-bold' : 'bg-black/60 border-gray-600 text-gray-200 hover:border-gray-400'}`}>
|
||||
{showCenterline ? '■ 선형(중심선) 표시중' : '▶ 선형(중심선) 표시'}
|
||||
</button>
|
||||
<button onClick={() => setShowDronePath(v => !v)}
|
||||
className={`w-full text-[11px] px-2 py-1 rounded border transition-colors ${showDronePath ? 'bg-sky-500/80 border-sky-300 text-black font-bold' : 'bg-black/60 border-gray-600 text-gray-200 hover:border-gray-400'}`}>
|
||||
{showDronePath ? '■ 드론 궤적 표시중' : '▶ 드론 궤적 표시'}
|
||||
</button>
|
||||
{showDronePath && (
|
||||
<>
|
||||
<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={`드론 궤적 선/점 투명도 α=${dronePathAlpha.toFixed(2)}`} value={dronePathAlpha} min={0.1} max={1.0} step={0.05} unit="" decimals={2} onChange={v => setDronePathAlpha(v)} />
|
||||
</>
|
||||
)}
|
||||
{/* 선형/드론궤적/좌측패널 토글은 하단 재생바(VideoPlayer)로 이동. 측점 진단 버튼은 삭제. */}
|
||||
{/* 경로표고·투명도는 드론궤적 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={`드론 궤적 선/점 투명도 α=${dronePathAlpha.toFixed(2)}`} value={dronePathAlpha} min={0.1} max={1.0} step={0.05} unit="" decimals={2} onChange={v => setDronePathAlpha(v)} />
|
||||
</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="mb-3 space-y-2">
|
||||
@@ -1630,6 +1631,7 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useRef, useImperativeHandle, forwardRef, useState, useEffect } from 'react';
|
||||
import React, { useRef, useImperativeHandle, forwardRef, useState, useEffect, useMemo } from 'react';
|
||||
import StationOverlay from '../overlay/StationOverlay';
|
||||
import RoutePanel from '../overlay/RoutePanel';
|
||||
import RouteInfoOverlay from '../overlay/RouteInfoOverlay';
|
||||
@@ -15,6 +15,27 @@ import { useCaptureStore } from '../../store/captureStore';
|
||||
import HlsConversionStatus from './HlsConversionStatus';
|
||||
import { StationBar } from '../../stationbar/StationBar';
|
||||
|
||||
/** 드롭된 디렉토리/파일 엔트리에서 모든 파일을 재귀 수집 (폴더 드래그&드롭 지원). */
|
||||
function collectDropEntry(entry: FileSystemEntry, out: File[]): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (entry.isFile) {
|
||||
(entry as FileSystemFileEntry).file((f) => { out.push(f); resolve(); }, () => resolve());
|
||||
} else if (entry.isDirectory) {
|
||||
const reader = (entry as FileSystemDirectoryEntry).createReader();
|
||||
const readBatch = () => {
|
||||
reader.readEntries(async (ents) => {
|
||||
if (!ents.length) { resolve(); return; } // 모든 배치 소진
|
||||
await Promise.all(ents.map((en) => collectDropEntry(en, out)));
|
||||
readBatch();
|
||||
}, () => resolve());
|
||||
};
|
||||
readBatch();
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export interface VideoPlayerHandle {
|
||||
loadLocalFile: (file: File) => void;
|
||||
loadServerStream: (videoId: string, filename: string) => void | Promise<void>;
|
||||
@@ -96,11 +117,20 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
|
||||
}, [playerRef, source]);
|
||||
const loadFromFolder = useGeoStore((s) => s.loadFromFolder);
|
||||
const geoLoaded = useGeoStore((s) => s.loaded);
|
||||
// 드론 정보 — 하단바에 GPS/고도 항상 표시.
|
||||
const storeFrames = useGeoStore((s) => s.frames);
|
||||
// 시설등급(시설종별) 표시 필터 — 재생바/패널이 구독하는 설정 스토어.
|
||||
const gradeFilter = useSettingsStore((s) => s.gradeFilter);
|
||||
const setGradeFilter = useSettingsStore((s) => s.setGradeFilter);
|
||||
const poiOverlapExclude = useSettingsStore((s) => s.poiOverlapExclude);
|
||||
const setPoiOverlapExclude = useSettingsStore((s) => s.setPoiOverlapExclude);
|
||||
// 선형(중심선)·드론 궤적 영상 오버레이 토글 — StationOverlay 와 공유(설정 스토어).
|
||||
const showCenterline = useSettingsStore((s) => s.showCenterline);
|
||||
const setShowCenterline = useSettingsStore((s) => s.setShowCenterline);
|
||||
const showDronePath = useSettingsStore((s) => s.showDronePath);
|
||||
const setShowDronePath = useSettingsStore((s) => s.setShowDronePath);
|
||||
const showRoutePanel = useSettingsStore((s) => s.showRoutePanel);
|
||||
const setShowRoutePanel = useSettingsStore((s) => s.setShowRoutePanel);
|
||||
// 하단 도구 패널: UI에서 숨김(코드는 보존). 다시 표시하려면 true 로.
|
||||
const SHOW_TOOLBAR = false;
|
||||
|
||||
@@ -200,11 +230,30 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
|
||||
containerRef: wrapperRef,
|
||||
});
|
||||
|
||||
// Drag and drop local video file
|
||||
// 드래그&드롭 — 폴더(영상+측점/POI) 또는 단일 영상 파일.
|
||||
// 폴더는 dataTransfer.files 가 비어 있으므로 webkitGetAsEntry 로 디렉토리를 재귀 순회한다.
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file?.type.startsWith('video/')) loadLocalFile(file);
|
||||
// await 후엔 dataTransfer 가 무효화될 수 있어 동기적으로 먼저 캡처.
|
||||
const entries: FileSystemEntry[] = [];
|
||||
const dt = e.dataTransfer;
|
||||
if (dt.items) {
|
||||
for (let i = 0; i < dt.items.length; i++) {
|
||||
const en = dt.items[i].webkitGetAsEntry?.();
|
||||
if (en) entries.push(en);
|
||||
}
|
||||
}
|
||||
const flatFiles = Array.from(dt.files);
|
||||
void (async () => {
|
||||
if (entries.length) {
|
||||
const out: File[] = [];
|
||||
await Promise.all(entries.map((en) => collectDropEntry(en, out)));
|
||||
if (out.length) { handleSelectFolder(out); return; }
|
||||
}
|
||||
if (flatFiles.length > 1) { handleSelectFolder(flatFiles); return; }
|
||||
const file = flatFiles[0];
|
||||
if (file?.type.startsWith('video/')) loadLocalFile(file);
|
||||
})();
|
||||
};
|
||||
|
||||
// VIDEO_FPS: 영상 실제 fps (29.97 = 30000/1001). Python SRT FrameCnt 기준과 일치.
|
||||
@@ -213,6 +262,18 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
|
||||
const frame = secondsToFrame(currentTime, VIDEO_FPS);
|
||||
const videoId = source?.kind === 'server' ? source.videoId : null;
|
||||
|
||||
// 드론 정보(측점진단) — 현재 프레임에 가장 가까운 드론 프레임의 GPS/고도만 표시.
|
||||
// 드론 GPS·고도 HUD 는 항상 표시 → 토글 없이 프레임만 있으면 계산.
|
||||
const stationDiag = useMemo(() => {
|
||||
if (!storeFrames.length) return null;
|
||||
let best = storeFrames[0], bd = Math.abs(storeFrames[0].frame - frame);
|
||||
for (const df of storeFrames) {
|
||||
const d = Math.abs(df.frame - frame);
|
||||
if (d < bd) { bd = d; best = df; }
|
||||
}
|
||||
return { f: best };
|
||||
}, [storeFrames, frame]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
@@ -235,86 +296,107 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
|
||||
/>
|
||||
{/* 노선 정보 배너 — 영상 좌상단 (route.json routeInfo) */}
|
||||
<RouteInfoOverlay />
|
||||
{/* 좌하단 그룹(재생바 위): 위→아래 = 배속 → 토글(측점선/영상제어) + 프레임정보 */}
|
||||
{/* 좌하단 그룹(재생바 위) — 한 줄: 영상제어 → 배속 → 프레임정보 → 겹침제외 → 시설등급 */}
|
||||
{source && (
|
||||
<div
|
||||
className="absolute left-2 z-30 flex flex-col items-start gap-2"
|
||||
className="absolute left-2 z-30 flex items-center gap-2 flex-wrap pointer-events-auto"
|
||||
style={{ bottom: (barHeight || 130) + 8 }}
|
||||
>
|
||||
{/* 재생 배속 (영상제어 ON) */}
|
||||
{/* 영상제어 토글 — 버튼만, 고정폭(가장 긴 'OFF' 기준이라 ON/OFF로 폭이 안 변함) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowVideoControls((v) => !v)}
|
||||
className={`text-xs px-2 py-1 rounded border font-semibold text-center min-w-[96px] ${showVideoControls ? 'bg-amber-400 border-amber-300 text-black' : 'bg-black/70 border-gray-600 text-gray-300'}`}
|
||||
>영상제어 {showVideoControls ? 'ON' : 'OFF'}</button>
|
||||
{showVideoControls && (
|
||||
<div className="flex items-center gap-1 bg-black/70 px-2 py-1 rounded pointer-events-auto">
|
||||
<span className="text-gray-400 text-xs">배속</span>
|
||||
{[0.5, 1, 1.5, 2, 3, 4].map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
type="button"
|
||||
onClick={() => playerRef.current?.playbackRate(r)}
|
||||
className={`text-xs px-1.5 py-0.5 rounded font-semibold ${
|
||||
Math.abs(playbackRate - r) < 0.01
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-700 text-gray-200 hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
{r}x
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<>
|
||||
{/* 배속 패널 (영상제어 우측) */}
|
||||
<div className="flex items-center gap-1 bg-black/70 px-2 py-1 rounded">
|
||||
<span className="text-gray-400 text-xs">배속</span>
|
||||
{[0.5, 1, 1.5, 2, 3, 4].map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
type="button"
|
||||
onClick={() => playerRef.current?.playbackRate(r)}
|
||||
className={`text-xs px-1.5 py-0.5 rounded ${
|
||||
Math.abs(playbackRate - r) < 0.01
|
||||
? 'bg-amber-400 text-black'
|
||||
: 'bg-gray-700 text-gray-200 hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
{r}x
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* 영상 프레임 표시 패널 — 프레임 번호를 고정폭(6ch)으로 묶어 자릿수가 늘어도 폭 불변 */}
|
||||
<span className="bg-black/70 text-gray-200 text-xs px-2 py-1 rounded font-mono whitespace-nowrap">
|
||||
{secondsToTimecode(currentTime)} | F<span className="inline-block text-left" style={{ minWidth: '6ch' }}>{frame}</span> | {fps}fps
|
||||
</span>
|
||||
{geoLoaded && (
|
||||
<>
|
||||
{/* 좌측 패널 토글 — 선형 왼편, 동일 폭(min-w-[96px]) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowRoutePanel(!showRoutePanel)}
|
||||
title="좌측 노선 패널 표시/숨김"
|
||||
className={`text-xs px-2 py-1 rounded border text-center min-w-[96px] ${showRoutePanel ? 'bg-amber-400 border-amber-300 text-black' : 'bg-black/70 border-gray-600 text-gray-300'}`}
|
||||
>좌측 패널 {showRoutePanel ? 'ON' : 'OFF'}</button>
|
||||
{/* 선형(중심선) 토글 — 겹침제외와 동일 폭(min-w-[96px]) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCenterline(!showCenterline)}
|
||||
title="선형(중심선) 표시/숨김"
|
||||
className={`text-xs px-2 py-1 rounded border text-center min-w-[96px] ${showCenterline ? 'bg-amber-400 border-amber-300 text-black' : 'bg-black/70 border-gray-600 text-gray-300'}`}
|
||||
>선형 {showCenterline ? 'ON' : 'OFF'}</button>
|
||||
{/* 드론 궤적 토글 — 겹침제외와 동일 폭(min-w-[96px]) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowDronePath(!showDronePath)}
|
||||
title="드론 궤적 표시/숨김"
|
||||
className={`text-xs px-2 py-1 rounded border text-center min-w-[96px] ${showDronePath ? 'bg-amber-400 border-amber-300 text-black' : 'bg-black/70 border-gray-600 text-gray-300'}`}
|
||||
>드론궤적 {showDronePath ? 'ON' : 'OFF'}</button>
|
||||
{/* 드론 위치정보 — 드론궤적 버튼 바로 우측에 붙임. GPS·고도 항상 표시. */}
|
||||
{stationDiag && (
|
||||
<div className="bg-black/70 px-2 py-1 rounded font-mono text-xs text-gray-200 whitespace-nowrap">
|
||||
GPS {stationDiag.f.lat.toFixed(6)}, {stationDiag.f.lon.toFixed(6)}
|
||||
<span className="text-gray-400"> · 절대고도 {stationDiag.f.altitude.toFixed(1)}m</span>
|
||||
</div>
|
||||
)}
|
||||
{/* 겹침제외 토글 — UI 숨김(기능/상태는 보존, 기본 ON). 다시 보이려면 주석 해제. */}
|
||||
{/* <button
|
||||
type="button"
|
||||
onClick={() => setPoiOverlapExclude(!poiOverlapExclude)}
|
||||
title="ON=겹친 POI 숨김 / OFF=모든 POI 표시"
|
||||
className={`text-xs px-2 py-1 rounded border text-center min-w-[96px] ${poiOverlapExclude ? 'bg-blue-600/80 border-blue-400 text-white' : 'bg-black/70 border-gray-600 text-gray-300'}`}
|
||||
>겹침제외 {poiOverlapExclude ? 'ON' : 'OFF'}</button> */}
|
||||
{/* 시설등급 패널 */}
|
||||
<div className="flex items-center gap-2 bg-black/70 px-2 py-1 rounded">
|
||||
<span className="text-gray-400 text-xs">시설등급</span>
|
||||
{KNOWN_GRADES.map((g) => (
|
||||
<label
|
||||
key={g}
|
||||
className="flex items-center gap-1 text-xs text-gray-200 cursor-pointer select-none"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-amber-400"
|
||||
checked={gradeFilter[g] ?? false}
|
||||
onChange={(e) => setGradeFilter(g, e.target.checked)}
|
||||
/>
|
||||
{g}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{/* 한 줄(좌→우): 영상제어 토글 → 프레임표시 패널 → POI옵션 패널 → 시설등급 패널 */}
|
||||
<div className="flex items-center gap-2 pointer-events-auto flex-wrap">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowVideoControls((v) => !v)}
|
||||
className={`text-xs px-2 py-1 rounded border font-semibold ${showVideoControls ? 'bg-blue-600/80 border-blue-400 text-white' : 'bg-black/70 border-gray-600 text-gray-300'}`}
|
||||
>영상제어 {showVideoControls ? 'ON' : 'OFF'}</button>
|
||||
{showVideoControls && (
|
||||
<>
|
||||
{/* 영상 프레임 표시 패널 */}
|
||||
<span className="bg-black/70 text-white text-xs px-2 py-1 rounded font-mono">
|
||||
{secondsToTimecode(currentTime)} | F{frame} | {fps}fps
|
||||
</span>
|
||||
{geoLoaded && (
|
||||
<>
|
||||
{/* POI 옵션 패널 (시설등급 왼쪽) — 겹침제외 토글 */}
|
||||
<div className="flex items-center gap-2 bg-black/70 px-2 py-1 rounded">
|
||||
<span className="text-gray-400 text-xs">POI 옵션</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPoiOverlapExclude(!poiOverlapExclude)}
|
||||
title="ON=겹친 POI 숨김 / OFF=모든 POI 표시"
|
||||
className={`text-xs px-2 py-0.5 rounded border font-semibold ${poiOverlapExclude ? 'bg-blue-600/80 border-blue-400 text-white' : 'bg-black/70 border-gray-600 text-gray-300'}`}
|
||||
>겹침제외 {poiOverlapExclude ? 'ON' : 'OFF'}</button>
|
||||
</div>
|
||||
{/* 시설등급 패널 (프레임표시 우측) */}
|
||||
<div className="flex items-center gap-2 bg-black/70 px-2 py-1 rounded">
|
||||
<span className="text-gray-400 text-xs">시설등급</span>
|
||||
{KNOWN_GRADES.map((g) => (
|
||||
<label
|
||||
key={g}
|
||||
className="flex items-center gap-1 text-xs text-gray-200 cursor-pointer select-none"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-blue-500"
|
||||
checked={gradeFilter[g] ?? false}
|
||||
onChange={(e) => setGradeFilter(g, e.target.checked)}
|
||||
/>
|
||||
{g}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* 루트 패널 미니맵 — 위(배너+카메라파라미터)·아래(배속/토글/재생바) 침범 방지 */}
|
||||
<RoutePanel
|
||||
currentTime={currentTime}
|
||||
visible={showStations}
|
||||
visible={showStations && showRoutePanel}
|
||||
onSeek={(time) => playerRef.current?.currentTime(time)}
|
||||
topPx={paramTop + 40}
|
||||
bottomPx={(barHeight || 130) + 90}
|
||||
@@ -357,6 +439,7 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
|
||||
onTogglePlay={handleTogglePlay}
|
||||
showPanel={showVideoControls && !!source}
|
||||
topPx={paramTop}
|
||||
barHeight={barHeight}
|
||||
/>
|
||||
|
||||
{/* 측점 기반 재생 바 — 영상 하단에 오버레이로 앵커 (폴더 로드 후) */}
|
||||
|
||||
@@ -20,8 +20,10 @@
|
||||
bottom: 0;
|
||||
width: 1920px;
|
||||
height: 1080px;
|
||||
transform: scale(var(--bar-scale));
|
||||
transform-origin: bottom left;
|
||||
/* transform: scale() 는 텍스트를 비트맵으로 축소해 흐려진다. zoom 은 축소된 크기로 '다시 렌더'
|
||||
→ 글자가 네이티브 해상도로 또렷. 내부 px 좌표/커서/seek 계산은 그대로 동작.
|
||||
(지원: Chrome·Edge·Safari·Firefox 126+) */
|
||||
zoom: var(--bar-scale);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -94,6 +94,8 @@ export interface StructMark {
|
||||
props?: { k: string; v: string }[];
|
||||
/** 종점역인데 영상이 도착하지 못함 → 끝에 '미도착' 스타일(속 빈 링)로 표시. */
|
||||
unreached?: boolean;
|
||||
/** 표시 측점값이 그 위치에 실제로 존재하는가. false=좌표 반경만으로 잡힌 통과(값 라벨·검색 제외). */
|
||||
kmExists?: boolean;
|
||||
}
|
||||
|
||||
interface StationBarProps {
|
||||
@@ -182,15 +184,23 @@ export function StationBar({
|
||||
);
|
||||
const storeFrames = useGeoStore((s) => s.frames);
|
||||
const routeMeta = useGeoStore((s) => s.routeMeta);
|
||||
const directionChanges = useGeoStore((s) => s.directionChanges);
|
||||
// v2.0: 구조물은 geoStore.structures(CSV 03)교량/04)터널/06)구교 + route.json 보정) 를 우선 사용.
|
||||
const storeStructuresRaw = useGeoStore((s) => s.structures);
|
||||
// 시설종별 필터(설정 스토어) — 체크 변경 시 즉시 반영(구독).
|
||||
const gradeFilter = useSettingsStore((s) => s.gradeFilter);
|
||||
const poiOverlapExclude = useSettingsStore((s) => s.poiOverlapExclude);
|
||||
const storeStructures = useMemo(
|
||||
() => storeStructuresRaw.filter((s) => isGradeVisible(s.grade, gradeFilter)),
|
||||
[storeStructuresRaw, gradeFilter],
|
||||
);
|
||||
const viewedRef = useRef<ViewedPoint[]>([]);
|
||||
// 이동거리축: time[i] ↔ frac[i](누적 이동거리 비율 0~1, 단조 비감소).
|
||||
// 드론의 '실제 이동거리'를 옆으로 펴서 배치 → 같은 측점이라도 이동량만큼 떨어져 보인다.
|
||||
// 호버(드론 이동 無) 구간은 frac 정체 → 커서 정지. 방향(전진/후진)은 barSegments 색 리본으로 구분.
|
||||
// 이동거리(누적) 축: time[i]↔frac[i](누적 이동거리 비율 0~1, 단조 비감소) → 커서 항상 우측 이동.
|
||||
// depChain=출발측점, arrChain=도착측점 → 방향색 기준(도착방향=주황/반대=파랑).
|
||||
const chainRef = useRef<{ time: Float64Array; frac: Float64Array; depChain: number; arrChain: number } | null>(null);
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
const stations = useMemo(
|
||||
@@ -223,8 +233,9 @@ export function StationBar({
|
||||
useEffect(() => {
|
||||
const frames = storeFrames;
|
||||
if (!frames.length || !stations.length || !stationLine) { setReady(false); return; }
|
||||
const out: ViewedPoint[] = new Array(frames.length);
|
||||
for (let i = 0; i < frames.length; i++) {
|
||||
const n = frames.length;
|
||||
const out: ViewedPoint[] = new Array(n);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const f = frames[i];
|
||||
out[i] = {
|
||||
frameNum: f.frame,
|
||||
@@ -233,6 +244,31 @@ export function StationBar({
|
||||
time: f.frame / videoFps,
|
||||
};
|
||||
}
|
||||
// 이동거리 축: 평활 측점값(±W 이동평균)의 프레임간 |Δ| 누적 = 실제 이동량 → 전체로 정규화(frac).
|
||||
// 호버(이동 無)면 frac 정체 → 커서 정지. 이동하면(전진/후진 무관) frac 증가 → 커서 항상 우측.
|
||||
// depChain/arrChain(앞·뒤 5% 평균)은 방향색 기준.
|
||||
const W = 8; // 평활 반폭(프레임)
|
||||
const pre = new Float64Array(n + 1);
|
||||
for (let i = 0; i < n; i++) pre[i + 1] = pre[i] + out[i].chain;
|
||||
const time = new Float64Array(n);
|
||||
const sm = new Float64Array(n);
|
||||
const frac = new Float64Array(n);
|
||||
let cum = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const lo = Math.max(0, i - W), hi = Math.min(n, i + W + 1);
|
||||
sm[i] = (pre[hi] - pre[lo]) / (hi - lo);
|
||||
if (i > 0) cum += Math.abs(sm[i] - sm[i - 1]);
|
||||
time[i] = out[i].time;
|
||||
frac[i] = cum;
|
||||
}
|
||||
const total = cum > 0 ? cum : 1;
|
||||
for (let i = 0; i < n; i++) frac[i] /= total;
|
||||
const seg = Math.max(1, Math.floor(n * 0.05));
|
||||
let dep = 0, arr = 0;
|
||||
for (let i = 0; i < seg; i++) { dep += sm[i]; arr += sm[n - 1 - i]; }
|
||||
dep /= seg; arr /= seg;
|
||||
if (Math.abs(arr - dep) < 1) arr = dep + 1; // 퇴화 방지
|
||||
chainRef.current = { time, frac, depChain: dep, arrChain: arr };
|
||||
viewedRef.current = out;
|
||||
setReady(true);
|
||||
}, [stations, stationLine, storeFrames, videoFps]);
|
||||
@@ -273,35 +309,87 @@ export function StationBar({
|
||||
// 시간→px 변환에 쓰는 유효 트랙폭 (미도착폭 제외).
|
||||
const timeTrackWidth = TRACK_WIDTH_PX - endGapPx;
|
||||
|
||||
// 프레임(시간) 진행률 px — 커서의 단조 이동 기준.
|
||||
// 영상은 노선을 시간순으로 한 번 통과(leg 01→10)하므로, 시간 진행률이 곧
|
||||
// 트랙을 좌→우로 지나는 경로 진행률이다.
|
||||
const progressPx =
|
||||
duration > 0
|
||||
? TRACK_START_PX + clamp(currentTime / duration, 0, 1) * timeTrackWidth
|
||||
: TRACK_START_PX;
|
||||
// 노선 진행방향 '상'|'하' — 동명 시설물의 (상)/(하) 변형 중 어느 쪽을 바에 표출할지 결정.
|
||||
// 1순위 route.json direction, 없으면 directionChanges(상행↔하행 전환점)에서 가장 오래 지속된 방향.
|
||||
const routeDirCh = useMemo<'상' | '하' | null>(() => {
|
||||
const d = routeMeta?.routeInfo?.direction ?? '';
|
||||
const up = d.includes('상'), down = d.includes('하');
|
||||
if (up && !down) return '상';
|
||||
if (down && !up) return '하';
|
||||
const dc = directionChanges;
|
||||
if (dc && dc.length && duration > 0) {
|
||||
const acc: Record<string, number> = {};
|
||||
let prevT = 0;
|
||||
let cur = dc[0].from;
|
||||
for (const c of dc) {
|
||||
acc[cur] = (acc[cur] ?? 0) + Math.max(0, c.atSeconds - prevT);
|
||||
prevT = c.atSeconds;
|
||||
cur = c.to;
|
||||
}
|
||||
acc[cur] = (acc[cur] ?? 0) + Math.max(0, duration - prevT);
|
||||
let best = '', bv = -1;
|
||||
for (const k in acc) if (acc[k] > bv) { bv = acc[k]; best = k; }
|
||||
if (best.includes('상') && !best.includes('하')) return '상';
|
||||
if (best.includes('하') && !best.includes('상')) return '하';
|
||||
}
|
||||
return null;
|
||||
}, [routeMeta, directionChanges, duration]);
|
||||
|
||||
// 커서 위치 = 프레임 진행률(progressPx)로만 단조 이동.
|
||||
// 측점 km은 노선에서 여러 번 반복(증가↔감소)되어, km으로 px를 역산하면
|
||||
// 같은 km의 여러 후보 중 하나로 스냅되며 구간 점프가 생긴다(사용자 보고).
|
||||
// → 진행은 유니크한 프레임 기준, 표시(배지)만 현재 보는 측점(realKm)으로 한다.
|
||||
const cursorPx = progressPx;
|
||||
|
||||
// 커서 배지 = 폴더 데이터 기반 연속 체이니지(realChain)를 10m 단위로 표시.
|
||||
// mock ROUTE_LEGS(mileageAtPx) 의존 제거. 데이터 없으면 빈 문자열.
|
||||
const cursorText =
|
||||
realChain !== null && realChain >= 0 ? formatMileage10(realChain) : '';
|
||||
// 진행도(이동거리축): 시간 t → 누적 이동거리 비율(frac, 0~1). chainRef 미준비 시 시간선형 폴백.
|
||||
const cumFracAtTime = useCallback(
|
||||
(t: number): number => {
|
||||
const c = chainRef.current;
|
||||
if (!c || !ready || duration <= 0)
|
||||
return duration > 0 ? clamp(t / duration, 0, 1) : 0;
|
||||
const { time, frac } = c;
|
||||
const n = time.length;
|
||||
if (n === 0) return 0;
|
||||
if (t <= time[0]) return frac[0];
|
||||
if (t >= time[n - 1]) return frac[n - 1];
|
||||
let lo = 0, hi = n - 1;
|
||||
while (hi - lo > 1) { const m = (lo + hi) >> 1; if (time[m] <= t) lo = m; else hi = m; }
|
||||
const span = time[hi] - time[lo];
|
||||
const r = span > 0 ? (t - time[lo]) / span : 0;
|
||||
return frac[lo] + (frac[hi] - frac[lo]) * r;
|
||||
},
|
||||
[ready, duration],
|
||||
);
|
||||
// 역변환(이동거리 비율 → 시간): 바 클릭 seek 용. frac 단조 비감소 → 이진 탐색.
|
||||
const timeAtFrac = useCallback(
|
||||
(f: number): number => {
|
||||
const c = chainRef.current;
|
||||
if (!c || !ready || duration <= 0) return clamp(f, 0, 1) * duration;
|
||||
const { time, frac } = c;
|
||||
const n = frac.length;
|
||||
if (n === 0) return 0;
|
||||
const ff = clamp(f, 0, 1);
|
||||
if (ff <= frac[0]) return time[0];
|
||||
if (ff >= frac[n - 1]) return time[n - 1];
|
||||
let lo = 0, hi = n - 1;
|
||||
while (hi - lo > 1) { const m = (lo + hi) >> 1; if (frac[m] <= ff) lo = m; else hi = m; }
|
||||
const span = frac[hi] - frac[lo];
|
||||
const r = span > 0 ? (ff - frac[lo]) / span : 0;
|
||||
return time[lo] + (time[hi] - time[lo]) * r;
|
||||
},
|
||||
[ready, duration],
|
||||
);
|
||||
|
||||
// ── 데이터 기반 측점 바 ──────────────────────────────────────────
|
||||
// 시간(프레임) 진행을 트랙 px로 선형 변환.
|
||||
// 시간(프레임) → 트랙 px (이동거리축). 커서·구간색·구조물 마커가 이 매핑을 공유한다.
|
||||
const pxAtTime = useCallback(
|
||||
(t: number): number =>
|
||||
duration > 0
|
||||
? TRACK_START_PX + clamp(t / duration, 0, 1) * timeTrackWidth
|
||||
: TRACK_START_PX,
|
||||
[duration, timeTrackWidth],
|
||||
TRACK_START_PX + clamp(cumFracAtTime(t), 0, 1) * timeTrackWidth,
|
||||
[cumFracAtTime, timeTrackWidth],
|
||||
);
|
||||
|
||||
// 커서 위치 = 이동거리축 진행도 → 전진/후진 무관하게 항상 우측으로 이동. 색만 방향에 따라 바뀜.
|
||||
const progressPx = pxAtTime(currentTime);
|
||||
const cursorPx = progressPx;
|
||||
|
||||
// 커서 배지 = 폴더 데이터 기반 연속 체이니지(realChain)를 10m 단위로 표시. 데이터 없으면 빈 문자열.
|
||||
const cursorText =
|
||||
realChain !== null && realChain >= 0 ? formatMileage10(realChain) : '';
|
||||
|
||||
// viewedRef(프레임→측점 km) 추이로 구간 색·전환점 산출.
|
||||
// km 증가 구간 = dir 1(주황), 감소 구간 = dir -1(하늘색). HYST로 최근접 지터 무시.
|
||||
const { barSegments, kmLabels } = useMemo<{
|
||||
@@ -309,25 +397,35 @@ export function StationBar({
|
||||
kmLabels: KmLabel[];
|
||||
}>(() => {
|
||||
const arr = viewedRef.current;
|
||||
if (!ready || !arr.length || duration <= 0)
|
||||
const n = arr.length;
|
||||
if (!ready || !n || duration <= 0)
|
||||
return { barSegments: [], kmLabels: [] };
|
||||
// 방향은 연속 체이니지(chain)로 판정 — 100m 양자화 km은 전환을 ~13s 빨리 잡아 영상과 어긋남.
|
||||
const HYST = 100; // m — 이 이상 반대로 움직여야 방향 전환으로 인정 (GPS 노이즈 무시)
|
||||
// 측점값(chain)을 ±W 이동평균으로 평활 → GPS 지터 제거. 평활했으므로 HYST를 작게 잡아
|
||||
// '목적지에 가까워짐/멀어짐'의 작은 추세 전환(20m+)도 구간으로 잡는다. (커서 배지 = 그 구간 색)
|
||||
const W = 8;
|
||||
const pre = new Float64Array(n + 1);
|
||||
for (let i = 0; i < n; i++) pre[i + 1] = pre[i] + arr[i].chain;
|
||||
const sm = new Float64Array(n);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const lo = Math.max(0, i - W), hi = Math.min(n, i + W + 1);
|
||||
sm[i] = (pre[hi] - pre[lo]) / (hi - lo);
|
||||
}
|
||||
const HYST = 10; // m — 평활 후 기준. 이 이상 반대로 움직이면 방향(가까워짐↔멀어짐) 전환.
|
||||
const segs: BarSegment[] = [];
|
||||
const bounds: { chain: number; time: number; turn: boolean }[] = [
|
||||
{ chain: arr[0].chain, time: arr[0].time, turn: false },
|
||||
{ chain: sm[0], time: arr[0].time, turn: false },
|
||||
];
|
||||
// 시작 방향을 실제 데이터(첫 유의미 이동)로 판정. 기본 증가 가정이 틀리면 첫 구간 색이 반대로 나옴.
|
||||
// 시작 방향을 실제 데이터(첫 유의미 이동)로 판정.
|
||||
let dir: 1 | -1 = 1;
|
||||
for (let i = 1; i < arr.length; i++) {
|
||||
const d = arr[i].chain - arr[0].chain;
|
||||
for (let i = 1; i < n; i++) {
|
||||
const d = sm[i] - sm[0];
|
||||
if (Math.abs(d) >= HYST) { dir = d > 0 ? 1 : -1; break; }
|
||||
}
|
||||
let extCh = arr[0].chain;
|
||||
let extCh = sm[0];
|
||||
let extIdx = 0;
|
||||
let startIdx = 0;
|
||||
for (let i = 1; i < arr.length; i++) {
|
||||
const c = arr[i].chain;
|
||||
for (let i = 1; i < n; i++) {
|
||||
const c = sm[i];
|
||||
if (dir > 0 ? c > extCh : c < extCh) {
|
||||
extCh = c;
|
||||
extIdx = i;
|
||||
@@ -346,10 +444,10 @@ export function StationBar({
|
||||
}
|
||||
segs.push({
|
||||
startPx: pxAtTime(arr[startIdx].time),
|
||||
endPx: pxAtTime(arr[arr.length - 1].time),
|
||||
endPx: pxAtTime(arr[n - 1].time),
|
||||
dir,
|
||||
});
|
||||
bounds.push({ chain: arr[arr.length - 1].chain, time: arr[arr.length - 1].time, turn: false });
|
||||
bounds.push({ chain: sm[n - 1], time: arr[n - 1].time, turn: false });
|
||||
// 전환(턴) 지점은 실제 위치를 10m 단위로, 시·종점 등 기본 라벨은 100m 단위로 표시.
|
||||
const labels: KmLabel[] = bounds.map((b) => ({
|
||||
px: pxAtTime(b.time),
|
||||
@@ -469,50 +567,71 @@ export function StationBar({
|
||||
if (!poiByName.has(base)) poiByName.set(base, { lat: p.lat, lon: p.lon, category: p.category });
|
||||
}
|
||||
|
||||
// 첫·마지막 스테이션(역사)을 트랙 양끝에 고정 표출.
|
||||
// 역사 마커 중 최좌측 → 트랙 시작(TRACK_START_PX), 최우측 → 트랙 끝(TRACK_END_PX)으로 스냅.
|
||||
// (드론 항로가 역사에서 떨어져 지나가도 첫/끝 역이 바 양끝에 앵커되도록.)
|
||||
const snapStationEnds = (marks: StructMark[]): StructMark[] => {
|
||||
const st = marks
|
||||
.map((m, i) => ({ i, px: m.px }))
|
||||
.filter((_, i) => marks[i].category === '역사' || marks[i].category === '역');
|
||||
if (st.length < 2) return marks;
|
||||
let lo = st[0], hi = st[0];
|
||||
for (const x of st) {
|
||||
if (x.px < lo.px) lo = x;
|
||||
if (x.px > hi.px) hi = x;
|
||||
}
|
||||
if (lo.i !== hi.i) {
|
||||
marks[lo.i] = { ...marks[lo.i], px: TRACK_START_PX };
|
||||
// 종점역: 끝(END)에 두되, 미도착폭이 설정돼 있으면 '미도착'으로 표시
|
||||
// (영상 진행바·커서는 END−endGapPx 까지만 도달 → 끝의 회색 구간 = 미도착).
|
||||
marks[hi.i] = { ...marks[hi.i], px: TRACK_END_PX, unreached: endGapPx > 0 };
|
||||
// 양끝 스냅 제거: 각 역사/구조물은 '실제 통과 위치(시간축 px)' 그대로 둔다
|
||||
// → 마커 위치가 그 지점 커서 측점값과 일치(스냅으로 인한 측점 어긋남 해소).
|
||||
// 종점역 '미도착'만 별도 처리: 영상이 종점 측점에 못 미치면(endGapPx>0) 종점역(최우측 역사)을
|
||||
// 트랙 끝(TRACK_END = 미도착 gap 안, 실제 종점 측점 위치)에 두고 미도착 스타일로 표시한다.
|
||||
const placeUnreachedTerminal = (marks: StructMark[]): StructMark[] => {
|
||||
if (endGapPx <= 0) return marks;
|
||||
let hiIdx = -1;
|
||||
let hiPx = -Infinity;
|
||||
for (let i = 0; i < marks.length; i++) {
|
||||
if ((marks[i].category === '역사' || marks[i].category === '역') && marks[i].px > hiPx) {
|
||||
hiPx = marks[i].px;
|
||||
hiIdx = i;
|
||||
}
|
||||
}
|
||||
if (hiIdx >= 0) marks[hiIdx] = { ...marks[hiIdx], px: TRACK_END_PX, unreached: true };
|
||||
return marks;
|
||||
};
|
||||
|
||||
// 경로상 시설물만: 시설물명에 방향표기((상…/(하…)가 있으면 영상 진행방향과 일치하는 것만.
|
||||
// 하행이면 '(상…' 제외, 상행이면 '(하…' 제외. (예: 회덕천교(상)/(상인상)은 하행 영상에서 숨김)
|
||||
// 진행방향(routeDirCh)의 반대 변형은 바에서 제외 → 상행이면 (상), 하행이면 (하) 만 남는다.
|
||||
const oppDirCh = routeDirCh === '하' ? '상' : routeDirCh === '상' ? '하' : '';
|
||||
const isOppositeDir = (name: string): boolean =>
|
||||
!!oppDirCh && new RegExp(`[((]${oppDirCh}`).test(name);
|
||||
|
||||
// v2.0: CSV 유래 구조물(storeStructures) 우선. route.json 보정은 geoData 에서 이미 병합됨.
|
||||
if (storeStructures && storeStructures.length) {
|
||||
const out: StructMark[] = [];
|
||||
for (const s of storeStructures) {
|
||||
// 구교(06)는 영상 오버레이에 표출 → 스테이션바에서는 제외(교량/터널만).
|
||||
if (s.category === '구교') continue;
|
||||
if (isOppositeDir(s.name)) continue; // 반대 방향(다른 선로) 시설물 제외
|
||||
const cat = s.type === 'tunnel' ? '터널' : s.type === 'bridge' ? '교량' : '역사';
|
||||
const sBase = s.name.replace(/\s*[((].*$/, '').trim();
|
||||
// 이름 매칭 POI 실좌표 우선 → 없으면 route.json 이정값 폴백.
|
||||
const match =
|
||||
poiByName.get(sBase) ??
|
||||
[...poiByName.entries()].find(([k]) => k.includes(sBase) || sBase.includes(k))?.[1];
|
||||
// 우선순위: station(측점값) → 직접 좌표 → POI 이름매칭 → 이정값 폴백.
|
||||
// 우선순위: station(측점값) → 좌표를 측점선에 투영한 '측점 기준' 탐지 → 좌표 근접(최후 폴백) → 이정값.
|
||||
// 각 통과 지점마다 마커(드론이 2번 지나면 2개). 동명 시설물은 각자 station 으로 구분.
|
||||
// ※ 좌표 근접(pxPassesTo)은 출발점 부근 등에서 엉뚱한 '조기 통과'를 잡아 마커가 앞쪽(좌측)으로
|
||||
// 잘못 배치될 수 있다 → 좌표를 측점값으로 환산해 station-기준으로 탐지하면 실제 통과순서와 일치.
|
||||
let passes: { px: number; km: number }[] = [];
|
||||
const off = s.offset ?? 0;
|
||||
if (s.station != null) {
|
||||
const sM = mileageToMeters(s.station);
|
||||
if (sM >= 0) passes = pxPassesAtMileage(sM);
|
||||
}
|
||||
if (!passes.length && s.lat != null && s.lon != null && stationLine)
|
||||
passes = pxPassesAtMileage(projectChainage(s.lat, s.lon, stationLine));
|
||||
if (!passes.length && match && stationLine)
|
||||
passes = pxPassesAtMileage(projectChainage(match.lat, match.lon, stationLine));
|
||||
if (!passes.length && s.lat != null && s.lon != null) passes = pxPassesTo(s.lat, s.lon, off);
|
||||
if (!passes.length && match) passes = pxPassesTo(match.lat, match.lon, off);
|
||||
// 역사(역) 통과 보강: 측점선 투영이 불안정한 종점/조차장 구역에서는 측점-기준 탐지가
|
||||
// 일부 통과를 놓친다(예: 대전조차장 162k080 재진입은 투영 측점이 ±20m 밖이라 누락).
|
||||
// 이미 한 통과를 찾으면 위 단락평가로 좌표근접 탐지가 안 돌아 영영 빠지므로, 여기서
|
||||
// 좌표근접(pxPassesTo) 통과를 '합집합'으로 합쳐 빠진 재진입까지 마커를 찍는다.
|
||||
// 같은 통과(거의 같은 px)는 24px 임계로 중복 제거 → 멀리 떨어진 실제 재진입만 추가됨.
|
||||
if (cat === '역사' && s.lat != null && s.lon != null) {
|
||||
for (const cp of pxPassesTo(s.lat, s.lon, off)) {
|
||||
if (!passes.some((p) => Math.abs(p.px - cp.px) < 24)) passes.push(cp);
|
||||
}
|
||||
passes.sort((a, b) => a.px - b.px);
|
||||
}
|
||||
if (!passes.length && s.startMileage != null && s.endMileage != null) {
|
||||
const mid = (s.startMileage + s.endMileage) / 2;
|
||||
const px = pxAtMileage(mid);
|
||||
@@ -522,33 +641,54 @@ export function StationBar({
|
||||
// 역은 바 양끝에 스냅되므로, 시작/끝 커서값(= 역 위치 측점)과 일치시키기 위함.
|
||||
const stationKmVal = cat === '역사' && s.lat != null && s.lon != null && stationLine
|
||||
? projectChainage(s.lat, s.lon, stationLine) : null;
|
||||
for (const p of passes) out.push({ px: p.px, title: s.name, category: cat, km: stationKmVal ?? p.km, props: s.props });
|
||||
const existTol = routeMeta?.routeInfo?.stationTolerance ?? 20;
|
||||
for (const p of passes) {
|
||||
// 표시 측점값(stationKmVal)과 그 통과의 실제 측점(p.km)이 허용오차 밖이면(=좌표 반경만으로
|
||||
// 잡힌 통과, 그 위치엔 실제로 그 측점값이 없음) 측점값 라벨/검색에서 제외. 마커(동그라미)는 유지.
|
||||
const kmExists = stationKmVal == null || Math.abs(p.km - stationKmVal) <= existTol;
|
||||
out.push({ px: p.px, title: s.name, category: cat, km: stationKmVal ?? p.km, kmExists, props: s.props });
|
||||
}
|
||||
}
|
||||
return snapStationEnds(out);
|
||||
return placeUnreachedTerminal(out);
|
||||
}
|
||||
|
||||
// 폴백: POI category 기반 (실좌표 GPS 근접, 모든 통과).
|
||||
const out: StructMark[] = [];
|
||||
for (const [base, info] of poiByName) {
|
||||
if (isOppositeDir(base)) continue; // 반대 방향(다른 선로) 시설물 제외
|
||||
const stationKmVal = info.category === '역사' && stationLine
|
||||
? projectChainage(info.lat, info.lon, stationLine) : null;
|
||||
for (const p of pxPassesTo(info.lat, info.lon)) {
|
||||
out.push({ px: p.px, title: base, category: info.category, km: stationKmVal ?? p.km });
|
||||
const existTol = routeMeta?.routeInfo?.stationTolerance ?? 20;
|
||||
// 측점 기준 탐지 우선(통과순서 정확), 측점선 없으면 좌표 근접 폴백.
|
||||
const ps = stationLine
|
||||
? pxPassesAtMileage(projectChainage(info.lat, info.lon, stationLine))
|
||||
: pxPassesTo(info.lat, info.lon);
|
||||
for (const p of ps) {
|
||||
const kmExists = stationKmVal == null || Math.abs(p.km - stationKmVal) <= existTol;
|
||||
out.push({ px: p.px, title: base, category: info.category, km: stationKmVal ?? p.km, kmExists });
|
||||
}
|
||||
}
|
||||
return snapStationEnds(out);
|
||||
return placeUnreachedTerminal(out);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ready, duration, storeFrames, pois, stations, pxAtTime, routeMeta, storeStructures, pxAtMileage, endGapPx, stationLine, videoFps]);
|
||||
}, [ready, duration, storeFrames, pois, stations, pxAtTime, routeMeta, routeDirCh, storeStructures, pxAtMileage, endGapPx, stationLine, videoFps]);
|
||||
|
||||
// 방향 색 트랙 gradient 빌더(전진/후진 색을 받아 구성). 구간 경계는 부드럽게 섞는다.
|
||||
// 여정 정방향(목적지=도착 방향): 측점값이 출발(depChain)→도착(arrChain) 으로 변하는 방향.
|
||||
// 그 방향으로 움직이는 구간=정방향(황색), 반대=역방향(청색).
|
||||
const forwardDir = useMemo<1 | -1>(() => {
|
||||
const c = chainRef.current;
|
||||
if (!ready || !c) return 1;
|
||||
return c.arrChain >= c.depChain ? 1 : -1;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ready, storeFrames]);
|
||||
|
||||
// 방향 색 트랙 gradient — 이동거리축은 구간 px가 좌→우 단조라 gradient 1장으로 충분(경량).
|
||||
// 정방향(도착방향)=FWD, 역방향=BWD. 구간 경계는 하드 스톱.
|
||||
const buildGradient = useCallback(
|
||||
(FWD: string, BWD: string): string => {
|
||||
const segs = barSegments;
|
||||
if (!segs.length) return '';
|
||||
const col = (d: 1 | -1) => (d > 0 ? FWD : BWD);
|
||||
const pct = (px: number) =>
|
||||
clamp(((px - TRACK_START_PX) / TRACK_WIDTH_PX) * 100, 0, 100);
|
||||
// 전환점에서 색이 '한번에' 바뀌도록 하드 스톱(같은 위치에 두 색).
|
||||
const col = (d: 1 | -1) => (d === forwardDir ? FWD : BWD);
|
||||
const pct = (px: number) => clamp(((px - TRACK_START_PX) / TRACK_WIDTH_PX) * 100, 0, 100);
|
||||
const stops: string[] = [`${col(segs[0].dir)} 0%`];
|
||||
for (let i = 1; i < segs.length; i++) {
|
||||
const bp = pct(segs[i].startPx).toFixed(2);
|
||||
@@ -558,31 +698,27 @@ export function StationBar({
|
||||
stops.push(`${col(segs[segs.length - 1].dir)} 100%`);
|
||||
return `linear-gradient(to right, ${stops.join(', ')})`;
|
||||
},
|
||||
[barSegments, duration],
|
||||
[barSegments, forwardDir],
|
||||
);
|
||||
// 통과 구간 음영 그라데이션(videoplayer 원본): 구간마다 좌→우 3색 셰이딩, 구간 경계는 하드.
|
||||
// 전진 주황: #ffc257 → #ff8a25 → #ff7b1b / 역방향 청록: #5ca887 → #35a7a7 → #06a4c8
|
||||
// 통과 음영(구간마다 좌→우 3색). 정방향 주황 / 역방향 청록.
|
||||
const buildShaded = useCallback(
|
||||
(fwd: [string, string, string], bwd: [string, string, string]): string => {
|
||||
const segs = barSegments;
|
||||
if (!segs.length) return '';
|
||||
const cols = (d: 1 | -1) => (d > 0 ? fwd : bwd);
|
||||
const pct = (px: number) =>
|
||||
clamp(((px - TRACK_START_PX) / TRACK_WIDTH_PX) * 100, 0, 100);
|
||||
const cols = (d: 1 | -1) => (d === forwardDir ? fwd : bwd);
|
||||
const pct = (px: number) => clamp(((px - TRACK_START_PX) / TRACK_WIDTH_PX) * 100, 0, 100);
|
||||
const stops: string[] = [];
|
||||
for (const s of segs) {
|
||||
const a = pct(s.startPx);
|
||||
const b = pct(s.endPx);
|
||||
const c = cols(s.dir);
|
||||
const a = pct(s.startPx), b = pct(s.endPx), c = cols(s.dir);
|
||||
stops.push(`${c[0]} ${a.toFixed(2)}%`);
|
||||
stops.push(`${c[1]} ${((a + b) / 2).toFixed(2)}%`);
|
||||
stops.push(`${c[2]} ${b.toFixed(2)}%`);
|
||||
}
|
||||
return `linear-gradient(to right, ${stops.join(', ')})`;
|
||||
},
|
||||
[barSegments],
|
||||
[barSegments, forwardDir],
|
||||
);
|
||||
// 통과(지나간): 구간별 음영 그라데이션. 미통과: 단색(전진 회색 / 역방향 청회색).
|
||||
// 통과(지나간): 음영. 미통과: 단색(정방향 회색 / 역방향 청회색).
|
||||
const trackGradient = useMemo(
|
||||
() => buildShaded(['#ffc257', '#ff8a25', '#ff7b1b'], ['#5ca887', '#35a7a7', '#06a4c8']),
|
||||
[buildShaded],
|
||||
@@ -591,13 +727,14 @@ export function StationBar({
|
||||
// 방향(색)이 바뀌는 전환점 px — 구분선 위치.
|
||||
const dividers = useMemo(() => barSegments.slice(1).map((s) => s.startPx), [barSegments]);
|
||||
|
||||
// 현재 위치가 역방향(km 감소, 하늘색) 구간이면 커서도 파란색.
|
||||
// 커서 배지 색 = 현재 위치한 '구간' 색과 동일(바 배경과 일치). 역방향(목적지 반대) 구간이면 파란색.
|
||||
// → 배지·바 배경이 항상 같은 색(기존 방식). 구간 색은 forwardDir 기준 정/역방향.
|
||||
const currentReverse = useMemo<boolean>(() => {
|
||||
for (const s of barSegments) {
|
||||
if (cursorPx >= s.startPx && cursorPx <= s.endPx) return s.dir < 0;
|
||||
if (cursorPx >= s.startPx && cursorPx <= s.endPx) return s.dir !== forwardDir;
|
||||
}
|
||||
return false;
|
||||
}, [barSegments, cursorPx]);
|
||||
}, [barSegments, cursorPx, forwardDir]);
|
||||
|
||||
// 컨테이너 폭 기준 균등 스케일
|
||||
useEffect(() => {
|
||||
@@ -638,7 +775,7 @@ export function StationBar({
|
||||
const el = wrapRef.current;
|
||||
if (el && duration > 0) {
|
||||
const t = timeRef.current ?? 0;
|
||||
const pos = TRACK_START_PX + clamp(t / duration, 0, 1) * timeTrackWidth;
|
||||
const pos = pxAtTime(t); // 이동거리축: 현재 진행도 → px
|
||||
el.style.setProperty('--pos-px', `${pos}px`);
|
||||
el.style.setProperty('--cursor-x', `${renderX(pos)}px`);
|
||||
}
|
||||
@@ -646,18 +783,17 @@ export function StationBar({
|
||||
};
|
||||
raf = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [timeRef, duration, timeTrackWidth]);
|
||||
}, [timeRef, duration, pxAtTime]);
|
||||
|
||||
// 클릭/드래그 트랙 px → 시간(프레임) 선형 변환으로 seek (시간축 일관).
|
||||
// 클릭/드래그 트랙 px → 이동거리 비율 → 시간(역변환)으로 seek (이동거리축 일관).
|
||||
const seekToTrackX = useCallback(
|
||||
(trackX: number) => {
|
||||
if (duration <= 0) return;
|
||||
const px = clamp(trackX, TRACK_START_PX, TRACK_END_PX);
|
||||
// 미도착 구간(END−endGapPx 우측)을 클릭하면 영상 끝(duration)으로 클램프.
|
||||
const targetTime = ((px - TRACK_START_PX) / timeTrackWidth) * duration;
|
||||
const targetTime = timeAtFrac((px - TRACK_START_PX) / timeTrackWidth);
|
||||
onSeek(clamp(targetTime, 0, duration));
|
||||
},
|
||||
[duration, onSeek, timeTrackWidth],
|
||||
[duration, onSeek, timeTrackWidth, timeAtFrac],
|
||||
);
|
||||
|
||||
const seekFromClientX = useCallback(
|
||||
@@ -709,31 +845,74 @@ export function StationBar({
|
||||
|
||||
// 측점입력(예: 158k200) → 그 측점을 보는(최근접) 프레임으로 seek (실데이터 기반).
|
||||
// 해당 영상이 커버하는 측점(체이니지) 범위 밖이면 무시(seek 안 함).
|
||||
// 측점 검색 순환 상태 — 같은 측점값을 연속 Enter 할 때 '직전 점프 위치'에서 다음으로 이어가기 위함.
|
||||
const jumpRef = useRef<{ km: number; time: number } | null>(null);
|
||||
const handleJumpToMileage = useCallback(
|
||||
(km: number) => {
|
||||
const arr = viewedRef.current;
|
||||
if (!arr.length || duration <= 0) return;
|
||||
// 연속 체이니지(chain) 기준으로 입력 측점에 가장 가까운 프레임 탐색 + 커버 범위 산출.
|
||||
let best = -1;
|
||||
let bd = Infinity;
|
||||
let lo = Infinity;
|
||||
let hi = -Infinity;
|
||||
// 커버 측점 범위 산출 (구간 밖 입력은 무시).
|
||||
let lo = Infinity, hi = -Infinity;
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
const c = arr[i].chain;
|
||||
if (c < lo) lo = c;
|
||||
if (c > hi) hi = c;
|
||||
const d = Math.abs(c - km);
|
||||
if (d < bd) {
|
||||
bd = d;
|
||||
best = i;
|
||||
}
|
||||
}
|
||||
// 구간 밖(커버 측점 범위 ± 여유 20m)이면 무시.
|
||||
const MARGIN = 20;
|
||||
if (km < lo - MARGIN || km > hi + MARGIN) return;
|
||||
if (best >= 0) onSeek(clamp(arr[best].time, 0, duration));
|
||||
|
||||
// 1순위: '화면에 보이는 마커(structureMarks)' 중 표시 측점값이 일치하는 것들의 시각.
|
||||
// → 마커는 좌표근접 탐지까지 포함하므로(조차장 등 chain 이 어긋난 통과 포함) 검색이 모든
|
||||
// 동그라미 위치로 이동 가능. px 는 timeAtFrac 로 시각 역변환. 매칭은 표시(10m 반올림) 일치.
|
||||
const tgt10 = Math.round(km / 10);
|
||||
let times: number[] = structureMarks
|
||||
.filter((m) => m.km >= 0 && m.kmExists !== false && Math.round(m.km / 10) === tgt10)
|
||||
.map((m) => timeAtFrac((m.px - TRACK_START_PX) / timeTrackWidth));
|
||||
// 2순위(마커 없음=임의 측점): 연속 체이니지 기준 통과(허용오차 내 연속구간=1통과).
|
||||
if (!times.length) {
|
||||
const TOL = routeMeta?.routeInfo?.stationTolerance ?? 20;
|
||||
let i = 0;
|
||||
while (i < arr.length) {
|
||||
if (Math.abs(arr[i].chain - km) < TOL) {
|
||||
let j = i, bj = i, bd = Math.abs(arr[i].chain - km);
|
||||
while (j < arr.length && Math.abs(arr[j].chain - km) < TOL) {
|
||||
const d = Math.abs(arr[j].chain - km);
|
||||
if (d < bd) { bd = d; bj = j; }
|
||||
j++;
|
||||
}
|
||||
times.push(arr[bj].time);
|
||||
i = j;
|
||||
} else i++;
|
||||
}
|
||||
if (!times.length) {
|
||||
let best = 0, bd = Infinity;
|
||||
for (let k = 0; k < arr.length; k++) {
|
||||
const d = Math.abs(arr[k].chain - km);
|
||||
if (d < bd) { bd = d; best = k; }
|
||||
}
|
||||
times.push(arr[best].time);
|
||||
}
|
||||
}
|
||||
// 시간 오름차순 + 근접 중복 제거(같은 통과가 두 경로/마커로 잡힌 경우).
|
||||
times.sort((a, b) => a - b);
|
||||
const passes: number[] = [];
|
||||
for (const t of times) if (!passes.length || t - passes[passes.length - 1] > 0.3) passes.push(t);
|
||||
|
||||
// 기준 시각: 같은 측점을 '직전 점프 위치 그대로'에서 다시 Enter 하면 연속(다음 통과),
|
||||
// 재생/클릭으로 커서가 움직였거나 다른 측점이면 현재 커서(라이브) 시각에서 새로 시작.
|
||||
const liveT = timeRef?.current ?? currentTime;
|
||||
const same = !!jumpRef.current
|
||||
&& Math.abs(jumpRef.current.km - km) < 1e-6
|
||||
&& Math.abs(liveT - jumpRef.current.time) < 0.1;
|
||||
const baseT = same ? jumpRef.current!.time : liveT;
|
||||
// 통과방향(시간 증가)으로 baseT 다음 통과. 끝까지 가면 처음으로 순환.
|
||||
const EPS = 1e-3;
|
||||
const target = passes.find((t) => t > baseT + EPS) ?? passes[0];
|
||||
|
||||
jumpRef.current = { km, time: target };
|
||||
onSeek(clamp(target, 0, duration));
|
||||
},
|
||||
[duration, onSeek],
|
||||
[duration, onSeek, routeMeta, currentTime, timeRef, structureMarks, timeAtFrac, timeTrackWidth],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -764,11 +943,8 @@ export function StationBar({
|
||||
startStationName={startStationName}
|
||||
endStationName={endStationName}
|
||||
endGapPx={endGapPx}
|
||||
routeDir={
|
||||
routeMeta?.routeInfo?.direction?.includes('하') ? '하'
|
||||
: routeMeta?.routeInfo?.direction?.includes('상') ? '상'
|
||||
: null
|
||||
}
|
||||
routeDir={routeDirCh}
|
||||
overlapExclude={poiOverlapExclude}
|
||||
/>
|
||||
</div>
|
||||
<TimelineCursor
|
||||
|
||||
@@ -35,12 +35,13 @@ export function PlaybackControls({
|
||||
const handleQueryKeyDown = (e: KeyboardEvent<HTMLInputElement>): void => {
|
||||
if (e.key !== 'Enter') return;
|
||||
const mileage = parseMileageQuery(query);
|
||||
if (mileage !== null) {
|
||||
onJumpToMileage(mileage);
|
||||
setQuery('');
|
||||
}
|
||||
// Enter 후에도 입력값 유지 → 같은 측점 재Enter 시 통과방향 다음 위치로 순환 검색.
|
||||
if (mileage !== null) onJumpToMileage(mileage);
|
||||
};
|
||||
|
||||
// 포커스를 잃으면(다른 곳 클릭 등) 입력값 삭제.
|
||||
const handleQueryBlur = (): void => setQuery('');
|
||||
|
||||
return (
|
||||
<div className={styles.controlsRow}>
|
||||
<div className={styles.transportGroup}>
|
||||
@@ -67,6 +68,7 @@ export function PlaybackControls({
|
||||
value={query}
|
||||
onChange={handleQueryChange}
|
||||
onKeyDown={handleQueryKeyDown}
|
||||
onBlur={handleQueryBlur}
|
||||
placeholder="측점입력"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -51,9 +51,9 @@ function splitStructLabel(text: string): string[] {
|
||||
interface TimelineProps {
|
||||
posPx: number;
|
||||
onSeekDown: (e: MouseEvent<HTMLDivElement>) => void;
|
||||
/** 통과 구간 트랙 gradient (전진=주황/후진=청록). */
|
||||
/** 통과 구간 트랙 gradient (정방향=주황/역방향=청록). */
|
||||
trackGradient: string;
|
||||
/** 미통과 구간 트랙 gradient (전진=회색/후진=청회색). */
|
||||
/** 미통과 구간 트랙 gradient (정방향=회색/역방향=청회색). */
|
||||
trackGradientIdle: string;
|
||||
/** 색(방향)이 바뀌는 전환점 px(스테이지 좌표) — 구분선 위치. */
|
||||
dividers: number[];
|
||||
@@ -69,6 +69,8 @@ interface TimelineProps {
|
||||
endGapPx?: number;
|
||||
/** 영상 진행 방향('상'|'하'). 평행 상/하행 구조물 겹침 시 해당 방향을 우선 표출. */
|
||||
routeDir?: '상' | '하' | null;
|
||||
/** 겹침제외(POI 옵션). true=겹친 구조물 1개만, false=모두 표시. */
|
||||
overlapExclude?: boolean;
|
||||
}
|
||||
|
||||
export function Timeline({
|
||||
@@ -83,6 +85,7 @@ export function Timeline({
|
||||
endStationName,
|
||||
endGapPx = 0,
|
||||
routeDir = null,
|
||||
overlapExclude = true,
|
||||
}: TimelineProps) {
|
||||
// 하단 스테이션바는 겹쳐도 무방 → 역사(역, KAKAO_RAIL)는 dedup 대상에서 제외하고 항상 표출.
|
||||
// (종점 '대전조차장'이 ~70px 앞 '법동가도교'에 밀려 사라지던 문제 방지.)
|
||||
@@ -90,18 +93,84 @@ export function Timeline({
|
||||
// 평행 상/하행 구조물(예: 회덕터널 상/하)은 ~6px 차이라 한쪽만 남는데, 영상 방향(routeDir)에
|
||||
// 해당하는 쪽(하행=‘(하)’, 상행=‘(상)’)을 우선 남긴다. → 드론이 실제 지나는 선로와 일치.
|
||||
const isStation = (s: StructMark): boolean => s.category === '역사' || s.category === '역';
|
||||
const dirTag = routeDir === '상' ? '(상)' : routeDir === '하' ? '(하)' : '';
|
||||
// '(상' / '(하' 로 매칭 — 이름이 "법동가도교(상,인상,고속)" 처럼 괄호 안 다중 토큰이어도 인식.
|
||||
// (이전 '(상)' 완전일치는 다중토큰 이름에서 매칭 실패해 잘못된 변형이 선택되던 버그)
|
||||
const dirTag = routeDir === '상' ? '(상' : routeDir === '하' ? '(하' : '';
|
||||
const baseStruct = (t: string): string => t.replace(/\s*[((].*$/, '').trim();
|
||||
const others = structures.filter((s) => !isStation(s));
|
||||
const ordered = dirTag
|
||||
? [...others].sort((a, b) =>
|
||||
(a.title.includes(dirTag) ? 0 : 1) - (b.title.includes(dirTag) ? 0 : 1) || a.px - b.px)
|
||||
: [...others].sort((a, b) => a.px - b.px);
|
||||
const keptOthers: StructMark[] = [];
|
||||
// 같은 구조물의 상/하/인상 변형(같은 base = 같은 좌표)은 '겹침제외 옵션과 무관하게' 진행방향 1개만.
|
||||
// (dir-우선 정렬돼 있어 방향 변형이 먼저 = 채택. 예: 법동가도교(하)/(인상) → (하) 1개)
|
||||
const byBase = new Map<string, StructMark>();
|
||||
for (const it of ordered) {
|
||||
if (keptOthers.every((k) => Math.abs(k.px - it.px) >= 130)) keptOthers.push(it);
|
||||
const b = baseStruct(it.title);
|
||||
if (!byBase.has(b)) byBase.set(b, it);
|
||||
}
|
||||
const keptOthers = [...byBase.values()];
|
||||
// 라벨은 긴 이름 2줄(아래 splitStructLabel)로 폭을 줄여 표시. 그래도 가까워 겹치면 그대로 둠(엇갈림 없음).
|
||||
void overlapExclude;
|
||||
const structs = [...structures.filter(isStation), ...keptOthers].sort((a, b) => a.px - b.px);
|
||||
|
||||
// 측점값 라벨 겹침 숨김: px 오름차순으로 보며, 직전에 '표시한' 라벨과 시각적으로 겹치면
|
||||
// (중앙 간격 < 두 라벨 평균 폭) 뒤엣것을 숨긴다 → 먼저 나온(좌측) 것만 1개 표시.
|
||||
// 같은/근접 측점을 여러 번 통과해 측점값이 겹쳐 보이던 문제 방지. (아이콘·점선은 그대로 둠)
|
||||
const labelW = (v: string): number => v.length * 7.5 + 4; // 13px bold + 2px stroke 대략 폭(px)
|
||||
const showMileage: boolean[] = new Array(structs.length).fill(false);
|
||||
{
|
||||
let lastPx = -Infinity;
|
||||
let lastW = 0;
|
||||
for (let i = 0; i < structs.length; i++) {
|
||||
const s = structs[i];
|
||||
// 측점값 없음(s.km<0) 또는 '그 위치에 실제 측점값이 존재하지 않음'(kmExists=false,
|
||||
// 좌표 반경만으로 잡힌 통과)은 측점값 라벨을 그리지 않는다. (마커 동그라미는 별도로 유지)
|
||||
if (s.km < 0 || s.kmExists === false) continue;
|
||||
const w = labelW(fmtKm(s.km));
|
||||
if (s.px - lastPx >= (lastW + w) / 2) {
|
||||
showMileage[i] = true;
|
||||
lastPx = s.px;
|
||||
lastW = w;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 라벨 그룹핑: 같은 항목(동일 title)을 드론이 여러 번 지나면 통과 마커가 흩어져 이름이 반복된다.
|
||||
// → 동명 통과를 거리와 무관하게 하나로 묶어, 각 통과에 점선 드롭 + 양끝을 잇는 하단 수평 점선(브래킷)을
|
||||
// 그리고 묶음 중앙에 라벨 1개만 표시한다. (단일 통과는 브래킷 없이 자기 위치 라벨 1개)
|
||||
// 아이콘·측점값은 통과별로 그대로 표출.
|
||||
const labelGroups: {
|
||||
title: string;
|
||||
km: number;
|
||||
category: string;
|
||||
unreached?: boolean;
|
||||
centerPx: number; // 라벨 위치 및 passed 판정 기준 (묶음 중앙)
|
||||
bracketPxs: number[]; // 길이 ≥2면 브래킷(드롭+수평선) 표시 대상 통과 px (정렬)
|
||||
}[] = [];
|
||||
{
|
||||
const byTitle = new Map<string, StructMark[]>();
|
||||
for (const s of structs) {
|
||||
const a = byTitle.get(s.title);
|
||||
if (a) a.push(s);
|
||||
else byTitle.set(s.title, [s]);
|
||||
}
|
||||
for (const arr of byTitle.values()) {
|
||||
const sorted = [...arr].sort((a, b) => a.px - b.px);
|
||||
// 동명 통과(역사 포함)는 하나로 묶어 브래킷(드롭+수평선) + 묶음 라벨 1개. 단일 통과는 자기 위치 라벨.
|
||||
const lo = sorted[0].px;
|
||||
const hi = sorted[sorted.length - 1].px;
|
||||
labelGroups.push({
|
||||
title: sorted[0].title,
|
||||
km: sorted[0].km,
|
||||
category: sorted[0].category,
|
||||
unreached: sorted.some((m) => m.unreached),
|
||||
centerPx: (lo + hi) / 2,
|
||||
bracketPxs: sorted.length > 1 ? sorted.map((m) => m.px) : [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 구조물 마커 클릭 시 속성 팝업(클릭 좌표 기준 fixed — 트랙 래퍼 transform 영향 회피).
|
||||
const [sel, setSel] = useState<{ s: StructMark; x: number; y: number } | null>(null);
|
||||
|
||||
@@ -146,7 +215,7 @@ export function Timeline({
|
||||
{/* 데이터 기반 색 트랙(그라데이션): 전진=주황 / 후진=하늘색.
|
||||
전체는 저톤(드론 순/역방향 미리보기), 재생되어 커서가 지나간 구간은 원래 색으로 복원. */}
|
||||
<div className={styles.track}>
|
||||
{/* 미재생(미통과): 회색/청회색 (전체 폭) */}
|
||||
{/* 미재생(미통과): 방향별 저톤 미리보기 (전체 폭) */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
@@ -155,12 +224,10 @@ export function Timeline({
|
||||
height: '100%',
|
||||
width: px(TRACK_WIDTH_PX),
|
||||
background: trackGradientIdle,
|
||||
opacity: 1,
|
||||
borderRadius: 'inherit',
|
||||
}}
|
||||
/>
|
||||
{/* 재생 불가 구간(종점역 미도착): 트랙 우측 endGapPx 전체.
|
||||
재생영역(회색 idle)보다 약간 밝은 단색 배경으로만 구분(빗금 없음). */}
|
||||
{/* 재생 불가 구간(종점역 미도착): 트랙 우측 endGapPx 전체. */}
|
||||
{endGapPx > 0 && (
|
||||
<div
|
||||
title="재생 불가 구간 (종점역 미도착)"
|
||||
@@ -176,8 +243,7 @@ export function Timeline({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* 재생된 구간: 원래 색 복원 (커서까지 clip). 폭은 CSS 변수(--pos-px)로 매 프레임
|
||||
직접 갱신되어 React 리렌더 없이 부드럽게 늘어난다. */}
|
||||
{/* 재생된 구간: 원래 색 복원 (커서까지 clip, --pos-px CSS변수로 매 프레임 갱신). */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
@@ -223,7 +289,7 @@ export function Timeline({
|
||||
{/* 측점값 라벨 — 각 시설물 측점값(아이콘 위) */}
|
||||
<div className={styles.mileageRow}>
|
||||
{structs.map((s, i) =>
|
||||
s.km >= 0 ? (
|
||||
s.km >= 0 && showMileage[i] ? (
|
||||
<MileageMarker
|
||||
key={`stkm-${i}`}
|
||||
marker={{ id: `stkm-${i}`, value: fmtKm(s.km), left: s.px, mileage: 0 }}
|
||||
@@ -295,33 +361,69 @@ export function Timeline({
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 구조물 클릭 핫스팟 — 클릭 시 속성 팝업(회덕화물역 등 역사 포함). seekArea(z20) 위로. */}
|
||||
<div style={{ position: 'absolute', inset: 0, zIndex: 21, pointerEvents: 'none' }}>
|
||||
{structs.map((s, i) =>
|
||||
s.props && s.props.length ? (
|
||||
<div
|
||||
key={`hit-${i}`}
|
||||
title={s.title}
|
||||
onMouseDown={(e) => { e.stopPropagation(); setSel({ s, x: e.clientX, y: e.clientY }); }}
|
||||
style={{ position: 'absolute', left: s.px - 12, top: 28, width: 24, height: 28, cursor: 'pointer', pointerEvents: 'auto' }}
|
||||
/>
|
||||
) : null,
|
||||
)}
|
||||
{/* 구조물 클릭 핫스팟 제거 — 스테이션바 클릭은 속성 팝업을 띄우지 않고 seek 으로만 동작.
|
||||
(POI 정보는 화면 오버레이 라벨 클릭에서 확인) */}
|
||||
|
||||
{/* 동일 항목 묶음 — 각 통과 위치를 수직 점선으로 내려, 라벨 '문자 세로 중앙'을 지나는
|
||||
수평 점선으로 잇는다. 수평선은 라벨 뒤로 지나가 글자 양옆으로만 보인다(두번째 참고 이미지).
|
||||
z-index 미지정(auto) → DOM 상 뒤의 labelRow 가 위에 그려져 글자가 선을 덮는다. */}
|
||||
<div style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }}>
|
||||
{labelGroups
|
||||
.filter((g) => g.bracketPxs.length > 1)
|
||||
.map((g, gi) => {
|
||||
const lo = g.bracketPxs[0];
|
||||
const hi = g.bracketPxs[g.bracketPxs.length - 1];
|
||||
// 라벨: 절대 top 53, 줄높이 18px(structName). 문자 세로 중앙 y = 53 + 줄수*9.
|
||||
const lineCount = splitStructLabel(g.title).length;
|
||||
const lineY = 53 + lineCount * 9;
|
||||
const dash = '1.5px dashed rgba(232, 232, 232, 0.6)';
|
||||
return (
|
||||
<Fragment key={`grp-${gi}`}>
|
||||
{/* 문자 세로 중앙을 지나는 수평 점선 (라벨 뒤) */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: lo,
|
||||
top: lineY,
|
||||
width: hi - lo,
|
||||
height: 0,
|
||||
borderTop: dash,
|
||||
}}
|
||||
/>
|
||||
{/* 각 통과 수직 점선 (트랙 하단 → 수평선) */}
|
||||
{g.bracketPxs.map((bp, j) => (
|
||||
<div
|
||||
key={j}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: bp,
|
||||
top: 46,
|
||||
width: 0,
|
||||
height: lineY - 46,
|
||||
borderLeft: dash,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 구조물명 라벨 (바 아래) — 통과 시 아이콘과 함께 색 변경. 긴 명칭은 2줄. */}
|
||||
{/* 구조물명 라벨 (바 아래) — 통과 시 아이콘과 함께 색 변경. 긴 명칭은 2줄.
|
||||
같은 항목 다중통과는 labelGroups(클러스터)로 묶여 클러스터당 라벨 1개만 표출. */}
|
||||
<div className={styles.labelRow}>
|
||||
{structs.map((s, i) => {
|
||||
const W = s.category === '터널' ? 24 : 22;
|
||||
{labelGroups.map((g, i) => {
|
||||
const W = g.category === '터널' ? 24 : 22;
|
||||
// 미도착 종점역은 항상 미통과(neutral) 색 유지.
|
||||
const passed = s.unreached ? false : posPx >= s.px - W / 2;
|
||||
const lines = splitStructLabel(s.title);
|
||||
const passed = g.unreached ? false : posPx >= g.centerPx - W / 2;
|
||||
// 폭 처리: 긴 이름은 2줄(균등 분할)로 폭을 줄여 표시. 그래도 가까워 겹치면 그대로 둠.
|
||||
const lines = splitStructLabel(g.title);
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`${styles.segmentLabel} ${styles.structName} ${passed ? styles.accent : styles.neutral}`}
|
||||
style={cssVars({ '--x': px(s.px) })}
|
||||
title={`${s.category} · ${s.title} (${fmtKm(s.km)})`}
|
||||
style={cssVars({ '--x': px(g.centerPx) })}
|
||||
title={`${g.category} · ${g.title} (${fmtKm(g.km)})`}
|
||||
>
|
||||
{lines.length === 1 ? lines[0] : <>{lines[0]}<br />{lines[1]}</>}
|
||||
</div>
|
||||
|
||||
@@ -22,6 +22,18 @@ interface SettingsStore {
|
||||
/** 화면 POI 겹침제외(겹치면 1개만). true=겹친 것 숨김(기본), false=모든 POI 표시. */
|
||||
poiOverlapExclude: boolean;
|
||||
setPoiOverlapExclude: (v: boolean) => void;
|
||||
/** 좌측 노선 패널(RoutePanel) 표시 여부. '화면표시 옵션' 토글로 제어. 기본 표시. */
|
||||
showRoutePanel: boolean;
|
||||
setShowRoutePanel: (v: boolean) => void;
|
||||
/** 측점 진단 HUD(드론 GPS·투영측점) 하단바 표시 여부. 기본 off. */
|
||||
showStationDiag: boolean;
|
||||
setShowStationDiag: (v: boolean) => void;
|
||||
/** 선형(중심선) 영상 오버레이 표시 여부. 기본 표시. (VideoPlayer 바 토글로 제어) */
|
||||
showCenterline: boolean;
|
||||
setShowCenterline: (v: boolean) => void;
|
||||
/** 드론 궤적 영상 오버레이 표시 여부. 기본 표시. (VideoPlayer 바 토글로 제어) */
|
||||
showDronePath: boolean;
|
||||
setShowDronePath: (v: boolean) => void;
|
||||
}
|
||||
|
||||
const DEFAULT_GRADE_FILTER: Record<string, boolean> = {
|
||||
@@ -39,6 +51,14 @@ export const useSettingsStore = create<SettingsStore>()(
|
||||
set((s) => ({ gradeFilter: { ...s.gradeFilter, [grade]: checked } })),
|
||||
poiOverlapExclude: true,
|
||||
setPoiOverlapExclude: (v) => set({ poiOverlapExclude: v }),
|
||||
showRoutePanel: true,
|
||||
setShowRoutePanel: (v) => set({ showRoutePanel: v }),
|
||||
showStationDiag: false,
|
||||
setShowStationDiag: (v) => set({ showStationDiag: v }),
|
||||
showCenterline: true,
|
||||
setShowCenterline: (v) => set({ showCenterline: v }),
|
||||
showDronePath: true,
|
||||
setShowDronePath: (v) => set({ showDronePath: v }),
|
||||
}),
|
||||
{
|
||||
name: 'ghivideo.settings',
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/** 측점값(체이니지) 공유 유틸 — 드론 GPS 를 측점 폴리라인에 투영해 측점값/최근접측점 산출.
|
||||
* StationBar(배지)·VideoPlayer(측점 진단 HUD) 가 동일 계산을 공유한다. */
|
||||
|
||||
export interface ChainLine {
|
||||
pts: { x: number; y: number; km: number; lat: number; lon: number; title: string }[];
|
||||
k: number; // 경도→m 환산 (cos(lat0)*111000)
|
||||
}
|
||||
|
||||
/** "157K970" → 157970(m). 실패 시 -1. */
|
||||
export function kmFromTitle(title: string): number {
|
||||
const m = title.match(/(\d+)[Kk](\d+)/);
|
||||
return m ? parseInt(m[1], 10) * 1000 + parseInt(m[2], 10) : -1;
|
||||
}
|
||||
|
||||
/** 미터값 → "157k970" (10m 단위). */
|
||||
export function fmtKm10(m: number): string {
|
||||
const r = Math.round(m / 10) * 10;
|
||||
return `${Math.floor(r / 1000)}k${String(r % 1000).padStart(3, '0')}`;
|
||||
}
|
||||
|
||||
/** 측점 POI 목록 → 평면투영 폴리라인(km 오름차순). 측점이 없으면 null. */
|
||||
export function buildChainLine(stations: { title: string; lat: number; lon: number }[]): ChainLine | null {
|
||||
const sts = stations.filter((s) => kmFromTitle(s.title) >= 0);
|
||||
if (!sts.length) return null;
|
||||
const sorted = [...sts].sort((a, b) => kmFromTitle(a.title) - kmFromTitle(b.title));
|
||||
const lat0 = sorted.reduce((s, p) => s + p.lat, 0) / sorted.length;
|
||||
const k = Math.cos((lat0 * Math.PI) / 180) * 111000;
|
||||
return {
|
||||
pts: sorted.map((p) => ({ x: p.lon * k, y: p.lat * 111000, km: kmFromTitle(p.title), lat: p.lat, lon: p.lon, title: p.title })),
|
||||
k,
|
||||
};
|
||||
}
|
||||
|
||||
/** 드론 lat/lon 을 폴리라인에 투영 → { km(측점값), offsetM(선로 수직이격) }. */
|
||||
export function projectToChain(lat: number, lon: number, line: ChainLine): { km: number; offsetM: number } {
|
||||
const px = lon * line.k;
|
||||
const py = lat * 111000;
|
||||
const pts = line.pts;
|
||||
let bestD = Infinity;
|
||||
let bestKm = pts.length ? pts[0].km : -1;
|
||||
for (let i = 0; i < pts.length - 1; i++) {
|
||||
const a = pts[i], b = pts[i + 1];
|
||||
const dx = b.x - a.x, dy = b.y - a.y, L2 = dx * dx + dy * dy;
|
||||
const t = L2 === 0 ? 0 : Math.max(0, Math.min(1, ((px - a.x) * dx + (py - a.y) * dy) / L2));
|
||||
const cx = a.x + dx * t, cy = a.y + dy * t;
|
||||
const d = (px - cx) ** 2 + (py - cy) ** 2;
|
||||
if (d < bestD) { bestD = d; bestKm = a.km + (b.km - a.km) * t; }
|
||||
}
|
||||
return { km: bestKm, offsetM: Math.sqrt(bestD) };
|
||||
}
|
||||
|
||||
/** 드론 lat/lon 의 최근접 측점(점) → { title, km, distM }. */
|
||||
export function nearestChainPoint(lat: number, lon: number, line: ChainLine): { title: string; km: number; distM: number } | null {
|
||||
const pts = line.pts;
|
||||
if (!pts.length) return null;
|
||||
let nIdx = 0, nD = Infinity;
|
||||
for (let i = 0; i < pts.length; i++) {
|
||||
const dx = (lat - pts[i].lat) * 111000;
|
||||
const dy = (lon - pts[i].lon) * line.k;
|
||||
const d = dx * dx + dy * dy;
|
||||
if (d < nD) { nD = d; nIdx = i; }
|
||||
}
|
||||
return { title: pts[nIdx].title, km: pts[nIdx].km, distM: Math.sqrt(nD) };
|
||||
}
|
||||
Reference in New Issue
Block a user