제주 드론 도로영상 지원: DJI 로그 어댑터·분할영상 연속재생·카메라 자동감지·오버레이 개선
- 제주 DJI 시간기반 비행로그(CSV) 파싱 어댑터: 세그먼트 창 정렬, 가상 fps 환산 - 분할 영상(이름 오름차순) 연속재생 + 좌상단 영상목록 콤보(선택 재생), 세그먼트별 드론 로그 재정렬 - KML 다중 파일 병합 파싱: STA 체이니지 측점(71개) + 지장물 신포맷(타입/이름/텍스트박스_색상) - 시설물 라벨: 지정색 배경 박스 + 흰 글자, 팝업 중앙 정렬, 호버 구간 깜빡임 수정 - 카메라 자동감지(djmd: ZenmuseP1, focal 29.9mm) + <영상명>.camera.json PC 저장/폴더 자동 적용 - Yaw 자동추정(GPS 진행방위 기반) + 라벨 드래그 Yaw 역산 모드 - 스테이션바: GPS 이동거리 진행도(호버 시 정지), 노선밖 거리 표기, 원거리 구조물 마크 제외 - 서버: /api/camera 라우트, ecosystem 포트 54000·제주 데이터 경로 - docs/history: 작업 이력 21건 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -91,11 +91,26 @@ const CATEGORY_EMOJI: Record<string, string> = {
|
||||
'역사': '🚉',
|
||||
'철도역': '🚉',
|
||||
'지장물': '🏢',
|
||||
'지장물L': '🏢',
|
||||
'측점': '📍',
|
||||
'구교': '🌉',
|
||||
'출입문': '🚪',
|
||||
'교차로': '🚦',
|
||||
'도로': '🛣️',
|
||||
'강.하천': '🌊',
|
||||
'마을': '🏘️',
|
||||
'산': '⛰️',
|
||||
};
|
||||
|
||||
/** 라벨 색 밝기 판정 — 어두운 색이면 외곽선을 밝게 반전(가독성). */
|
||||
function isDarkLabelColor(hex: string): boolean {
|
||||
const m = hex.match(/^#([0-9a-f]{6})$/i);
|
||||
if (!m) return false;
|
||||
const n = parseInt(m[1], 16);
|
||||
const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
|
||||
return 0.299 * r + 0.587 * g + 0.114 * b < 96;
|
||||
}
|
||||
|
||||
// 영상 오버레이엔 모든 선로 구조물(교량/터널/구교)을 라벨로 표출한다.
|
||||
// 시설등급(시설종별) 표시 필터는 하단 스테이션바·RoutePanel 전용 → 영상은 항상 전부 표출.
|
||||
// (역사=철도역은 type='station'이라 여기 미포함 → 스테이션바 전용 유지.)
|
||||
@@ -113,8 +128,10 @@ const DISPLAY_DEFAULTS = {
|
||||
smoothSpeedRef: 0.010, // 즉시추종 기준 속도(클수록 더 강하게 평활)
|
||||
maxPoiRange: 1000,
|
||||
// false = 지면고도 기준(POI를 지면고도 z에 고정 → gap=드론고도−지면고도 자동변동, 드론 오르내려도 지물에 붙음).
|
||||
// 표고 미상(z=0) 라벨은 '경로 표고' 평면에 앵커 → 궤적과 같은 슬라이더로 함께 보정됨.
|
||||
// true = 드론−이격거리 고정(gap 상수). 드론 고도 변동 시 부정확.
|
||||
poiDroneHeight: true,
|
||||
// 기본 false: 실측 z(회덕)는 지물에 정확히 붙고, 미상(제주)은 경로표고로 일괄 보정 가능.
|
||||
poiDroneHeight: false,
|
||||
droneHeightDrop: 24,
|
||||
};
|
||||
|
||||
@@ -123,7 +140,7 @@ const DISPLAY_DEFAULTS = {
|
||||
// 포즈(poseAt)로 직접 투영 → 라인처럼 부드럽게 이동. (precompute = 가시성/겹침 결정 전용)
|
||||
interface LabelCache {
|
||||
stationLabels: { title: string; lat: number; lon: number; z: number }[];
|
||||
poiMarkers: { title: string; label: string; category: string; lat: number; lon: number; gz: number; compact: { k: string; v: string }[]; labelRow: number; labelRowCount: number }[];
|
||||
poiMarkers: { title: string; label: string; category: string; lat: number; lon: number; gz: number; compact: { k: string; v: string }[]; labelRow: number; labelRowCount: number; color?: string }[];
|
||||
}
|
||||
|
||||
// 중심선 + 나침반 렌더 캐시 (per-frame, renderCacheRef)
|
||||
@@ -322,6 +339,9 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
// FOV(초점) 보정 모드: POI 라벨을 실제 위치로 끌면 그 대응점으로 focal 을 역산.
|
||||
const [fovMode, setFovMode] = useState(false);
|
||||
const fovModeRef = useRef(false);
|
||||
// Yaw 보정 모드: POI 라벨을 실제 위치(좌우)로 끌면 필요한 Yaw± 를 역산.
|
||||
const [yawMode, setYawMode] = useState(false);
|
||||
const yawModeRef = useRef(false);
|
||||
// 라벨 속성 팝업(다중) — 항상 활성(모드 버튼 없음). 라벨 위에서만 캔버스가 입력을 받아 클릭→팝업.
|
||||
// id=`${kind}:${title}`. 위치는 RAF 가 라벨 displayed 좌표로 매 프레임 갱신(재생 중 따라 이동).
|
||||
type InfoPopup = { id: string; kind: 'poi' | 'station'; title: string; category: string; lat: number; lon: number; z: number; dist: number | null; sx: number; sy: number; props?: { k: string; v: string }[];
|
||||
@@ -392,6 +412,7 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
useEffect(() => { maxPoiRangeRef.current = maxPoiRange; }, [maxPoiRange]);
|
||||
useEffect(() => { editModeRef.current = editMode; }, [editMode]);
|
||||
useEffect(() => { fovModeRef.current = fovMode; }, [fovMode]);
|
||||
useEffect(() => { yawModeRef.current = yawMode; }, [yawMode]);
|
||||
|
||||
// 컴팩트 자동 팝업 동기화 — visStructRef(연속 온스크린 라벨) 마다 라벨 옆 DOM 팝업을 자동 '생성'만.
|
||||
// 제거는 RAF 가 단일 권한(onScreenLabels + 4프레임 유예)으로 담당 → add/remove 충돌(번쩍임) 제거.
|
||||
@@ -480,27 +501,35 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
useEffect(() => { overlapExcludeRef.current = poiOverlapExclude; }, [poiOverlapExclude]);
|
||||
// 좌측 노선 패널(RoutePanel) 표시 토글은 하단 재생바(VideoPlayer)로 이동.
|
||||
const storeBaseName = useGeoStore(s => s.baseName);
|
||||
const storeCameraInfo = useGeoStore(s => s.cameraInfo);
|
||||
const storeCameraJson = useGeoStore(s => s.cameraJson);
|
||||
const storePoiOverrides = useGeoStore(s => s.poiOverrides);
|
||||
const setPoiOverride = useGeoStore(s => s.setPoiOverride);
|
||||
const setPoiOverrides = useGeoStore(s => s.setPoiOverrides);
|
||||
const clearPoiOverride = useGeoStore(s => s.clearPoiOverride);
|
||||
// 경로 표고 슬라이더 상한 — 데이터(중심선) 최대 지면표고 + 여유 (없으면 60 폴백)
|
||||
// 경로 표고 슬라이더 상한 — 데이터 최대 표고 + 여유 (없으면 60 폴백).
|
||||
// 중심선이 없거나 z=0 인 데이터셋(예: 제주 DJI 로그)은 드론 고도(ASL)가 유일한 상한 근거 —
|
||||
// 지면은 드론보다 낮으므로 max(드론고도)까지 열어두면 어떤 지형이든 슬라이더로 도달 가능.
|
||||
const pathZMax = useMemo(() => {
|
||||
let m = 0;
|
||||
for (const c of storeCenterline) if (typeof c.z === 'number' && c.z > m) m = c.z;
|
||||
for (const f of storeFrames) if (typeof f.altitude === 'number' && f.altitude > m) m = f.altitude;
|
||||
return m > 0 ? Math.ceil(m + 10) : 60;
|
||||
}, [storeCenterline]);
|
||||
}, [storeCenterline, storeFrames]);
|
||||
useEffect(() => { overridesRef.current = storePoiOverrides; }, [storePoiOverrides]);
|
||||
|
||||
// ── 보정값 영속화 (baseName별 localStorage) ──────────────────────────────
|
||||
// 카메라 각도/내부표정(Yaw±/Pitch±/focal 등) + 표시설정을 데이터셋별로 저장·복원.
|
||||
// 한 번 튜닝하면 새로고침/재로드해도 유지 → "처음부터 맞게" 표시.
|
||||
const calibLoadedFor = useRef<string | null>(null);
|
||||
// 이 데이터셋에 저장된 보정값이 있었는지 — 카메라 감지값 자동 적용 여부 판단(사용자 튜닝 우선).
|
||||
const calibHadSavedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!storeBaseName || calibLoadedFor.current === storeBaseName) return;
|
||||
calibLoadedFor.current = storeBaseName;
|
||||
try {
|
||||
const raw = localStorage.getItem(`ghivideo:calib:${storeBaseName}`);
|
||||
calibHadSavedRef.current = !!raw;
|
||||
if (!raw) return;
|
||||
const c = JSON.parse(raw);
|
||||
if (c.params && typeof c.params === 'object') setParams(p => ({ ...p, ...c.params }));
|
||||
@@ -525,6 +554,100 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
return () => clearTimeout(t);
|
||||
}, [storeBaseName, params, maxPoiRange, smoothHalf, emaAlpha, smoothMinAlpha, smoothSpeedRef]);
|
||||
|
||||
// 영상(djmd)에서 감지한 카메라 초점거리 자동 적용.
|
||||
// 사용자가 f 를 직접 튜닝한 값(기본값 24 가 아닌 저장값)만 우선하고, 그 외(첫 로드,
|
||||
// 또는 저장은 됐지만 f 가 기본값 그대로인 경우)는 감지값을 적용한다.
|
||||
// (보정 자동저장은 폴더를 열기만 해도 기본값으로 저장되므로 '저장 존재' 만으론 튜닝 여부 판단 불가)
|
||||
const camAppliedFor = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!storeBaseName || !storeCameraInfo?.focalLen35) return;
|
||||
if (camAppliedFor.current === storeBaseName) return;
|
||||
camAppliedFor.current = storeBaseName;
|
||||
const f = storeCameraInfo.focalLen35;
|
||||
const model = storeCameraInfo.model;
|
||||
setParams(p => {
|
||||
const focalTuned = calibHadSavedRef.current &&
|
||||
Math.abs(p.focalLen - DEFAULT_CAMERA_PARAMS.focalLen) > 0.01;
|
||||
if (focalTuned) {
|
||||
console.log(`[camera] ${model} 감지 (${f}mm) — 사용자 튜닝 f=${p.focalLen}mm 유지 (패널 '적용'으로 교체 가능)`);
|
||||
return p;
|
||||
}
|
||||
console.log(`[camera] ${model} 감지 → focal ${f}mm 자동 적용`);
|
||||
return { ...p, focalLen: f };
|
||||
});
|
||||
}, [storeBaseName, storeCameraInfo]);
|
||||
|
||||
// 폴더의 <영상 base>.camera.json 카메라 파라미터 — 최우선 적용(localStorage/감지값 위에).
|
||||
// 이 effect 는 calib 복원·감지값 적용 effect 뒤에 선언되어 함수형 업데이트가 마지막에 반영된다.
|
||||
const camJsonAppliedFor = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!storeBaseName || !storeCameraJson) return;
|
||||
if (camJsonAppliedFor.current === storeBaseName) return;
|
||||
camJsonAppliedFor.current = storeBaseName;
|
||||
const cam = storeCameraJson;
|
||||
setParams(p => {
|
||||
const next = { ...p };
|
||||
for (const [k, v] of Object.entries(cam)) {
|
||||
if (k in next && typeof v === 'number' && isFinite(v)) (next as unknown as Record<string, number>)[k] = v;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
console.log('[camera] <base>.camera.json 파라미터 적용(최우선):', cam);
|
||||
}, [storeBaseName, storeCameraJson]);
|
||||
|
||||
// 현재 카메라 파라미터를 사용자 PC 에 <영상 base>.camera.json 으로 다운로드 저장.
|
||||
// Yaw±(자동추정/드래그 역산 결과) 포함 전체 파라미터가 담긴다.
|
||||
// 받은 파일을 영상 폴더에 넣어두면 다음 폴더 선택 때 최우선으로 자동 적용된다.
|
||||
const [camSaveState, setCamSaveState] = useState<'idle' | 'ok'>('idle');
|
||||
const saveCameraToPc = useCallback(() => {
|
||||
const geo = useGeoStore.getState();
|
||||
const videoName = geo.videoFiles[geo.videoIndex]?.name ?? (geo.baseName ? `${geo.baseName}.mp4` : 'camera.mp4');
|
||||
const base = videoName.replace(/\.[^.]+$/, '');
|
||||
const payload = {
|
||||
camera: paramsRef.current,
|
||||
model: geo.cameraInfo?.model,
|
||||
savedAt: new Date().toISOString(),
|
||||
};
|
||||
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${base}.camera.json`;
|
||||
a.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 5000);
|
||||
setCamSaveState('ok');
|
||||
setTimeout(() => setCamSaveState('idle'), 2500);
|
||||
}, []);
|
||||
|
||||
// Yaw 자동 추정 — 전진 비행 구간에서 GPS 진행 방위(정확) vs 짐벌 헤딩(나침반 오차 포함)의
|
||||
// 차이 중앙값을 Yaw± 로 설정. 카메라가 진행방향을 보며 촬영하는 도로 추종 비행 가정(근사치).
|
||||
const autoEstimateYaw = useCallback(() => {
|
||||
const frames = allDroneFramesRef.current;
|
||||
if (!frames || frames.length < 30) { alert('드론 로그가 부족해 자동 추정할 수 없습니다.'); return; }
|
||||
const fps = fpsRef.current;
|
||||
const cosLat = Math.cos((frames[0].lat * Math.PI) / 180);
|
||||
const STEP = 10; // 진행방위 계산 창(≈1초) — GPS 지터 평활
|
||||
const diffs: number[] = [];
|
||||
for (let i = STEP; i < frames.length; i++) {
|
||||
const a = frames[i - STEP], b = frames[i];
|
||||
const dt = (b.frame - a.frame) / fps;
|
||||
if (dt <= 0) continue;
|
||||
const dx = (b.lon - a.lon) * 111000 * cosLat;
|
||||
const dy = (b.lat - a.lat) * 111000;
|
||||
if (Math.hypot(dx, dy) / dt < 2) continue; // 정지/호버 구간 제외
|
||||
const course = (Math.atan2(dx, dy) * 180) / Math.PI; // 진행 방위 (North=0, CW)
|
||||
let d = course - frames[i - (STEP >> 1)].yaw; // 진행방위 − 짐벌 헤딩
|
||||
d = ((d + 540) % 360) - 180;
|
||||
if (Math.abs(d) < 60) diffs.push(d); // 옆을 보며 찍은 구간 제외
|
||||
}
|
||||
if (diffs.length < 20) { alert('전진 촬영 구간이 부족해 자동 추정할 수 없습니다.'); return; }
|
||||
diffs.sort((x, y) => x - y);
|
||||
const med = diffs[diffs.length >> 1];
|
||||
const next = Math.round(med * 10) / 10;
|
||||
setParam('yawOffset', next);
|
||||
console.log(`[yaw] 자동 추정: 유효 샘플 ${diffs.length}개, 중앙값 ${med.toFixed(2)}° → Yaw± ${next}° 적용`);
|
||||
}, [setParam]);
|
||||
|
||||
// POI 보정(override: DEM 표고/드래그)도 baseName별 자동 저장·복원 → 새로고침해도 유지.
|
||||
const ovLoadedFor = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
@@ -728,6 +851,9 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
const CLIP_Z = 0.1;
|
||||
const SMOOTH_HALF = currentSmoothHalf;
|
||||
const MAX_RANGE = currentMaxRange; // POI 표시 범위 — 드론 수평 직선거리(m)
|
||||
// 표고 미상(z=0) 라벨의 앵커 평면 — 드론 궤적과 동일한 '경로 표고' 값.
|
||||
// 사용자가 경로표고 슬라이더로 궤적을 도로에 맞추면 측점/POI 라벨도 함께 맞는다.
|
||||
const PATH_Z = dronePathZRef.current;
|
||||
|
||||
if (!frames.length) return;
|
||||
|
||||
@@ -752,20 +878,31 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
const stationLabels: LabelCache['stationLabels'] = [];
|
||||
for (const st of allSt) {
|
||||
const snap = nearestCL(st.lat, st.lon);
|
||||
const slat = snap?.lat ?? st.lat, slon = snap?.lon ?? st.lon, sz = snap?.z ?? st.z;
|
||||
const slat = snap?.lat ?? st.lat, slon = snap?.lon ?? st.lon;
|
||||
const szRaw = snap?.z ?? st.z;
|
||||
// 표고 미상(0 이하)이면 경로표고 평면에 앵커 — 공중부양/시차 밀림 방지.
|
||||
const sz = szRaw > 0 ? szRaw : PATH_Z;
|
||||
const cc = toCameraCoords(drone, slat, slon, sz, currentParams, worldOrigin);
|
||||
if (cc.Zc < CLIP_Z) continue;
|
||||
// 측점도 POI 와 동일하게 표시 범위 제한 — 원거리(수 km) 측점이 수평선에 뭉치는 것 방지.
|
||||
if ((cc.distH ?? 0) > MAX_RANGE) continue;
|
||||
const { pxRaw, pyRaw } = pixelFromCamera(cc, currentParams);
|
||||
if (pxRaw < -0.05 || pxRaw > 1.05 || pyRaw < -0.05 || pyRaw > 1.05) continue;
|
||||
// 포함 경계 ±0.15 로 넉넉히 — 경계에 걸친 라벨이 호버(정지) 중 GPS 지터로
|
||||
// 캐시 프레임마다 들락거리며 깜빡이는 것 방지. 실제 화면 밖 컬링은 드로우가 담당.
|
||||
if (pxRaw < -0.15 || pxRaw > 1.15 || pyRaw < -0.15 || pyRaw > 1.15) continue;
|
||||
// 화면좌표 대신 (스냅된)월드좌표 저장 → RAF 가 연속 포즈로 재투영.
|
||||
stationLabels.push({ title: st.title, lat: slat, lon: slon, z: sz });
|
||||
}
|
||||
|
||||
// 후보 수집 (거리 dist + 월드좌표 + 지면고도 gz) → 겹침 억제용
|
||||
const poiCand: { x: number; y: number; title: string; label: string; category: string; dist: number; lat: number; lon: number; gz: number; compact: { k: string; v: string }[] }[] = [];
|
||||
const poiCand: { x: number; y: number; title: string; label: string; category: string; dist: number; lat: number; lon: number; gz: number; compact: { k: string; v: string }[]; color?: string }[] = [];
|
||||
for (const poi of allPoi) {
|
||||
// 지면고도 gz: 보정값(DEM/드래그) 또는 최근접 선로표고. RAF 투영 기준값.
|
||||
const gz = overridesRef.current[poi.title]?.z ?? nearestCL(poi.lat, poi.lon)?.z ?? poi.z;
|
||||
// 지면고도 gz: 보정값(DEM/드래그) → 최근접 선로표고(유효값) → POI 자체 z → 경로표고 앵커.
|
||||
// 표고 미상(0)인 데이터셋(제주)은 경로표고 평면에 앵커되어 궤적과 함께 보정된다.
|
||||
const clz = nearestCL(poi.lat, poi.lon)?.z;
|
||||
const gz = overridesRef.current[poi.title]?.z
|
||||
?? (clz !== undefined && clz > 0 ? clz : undefined)
|
||||
?? (poi.z > 0 ? poi.z : PATH_Z);
|
||||
// 가시성 판정용 poiZ: 드론높이 모드면 드론−이격거리, 아니면 gz.
|
||||
const poiZ = currentDroneHeight
|
||||
? drone.altitude - (currentParams.geoidOffset ?? 0) - currentDroneDrop
|
||||
@@ -775,13 +912,18 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
// 표시 범위: 드론과의 수평 직선거리(distH)가 범위 안이면 표시, 밖이면 제외.
|
||||
if ((cc.distH ?? 0) > MAX_RANGE) continue;
|
||||
const { pxRaw, pyRaw } = pixelFromCamera(cc, currentParams);
|
||||
if (pxRaw < -0.02 || pxRaw > 1.02 || pyRaw < -0.02 || pyRaw > 1.02) continue;
|
||||
poiCand.push({ x: pxRaw, y: pyRaw, title: poi.title, label: guboonOf(poi.props) ?? cleanTitle(poi.title), category: poi.category, dist: cc.distH ?? 0, lat: poi.lat, lon: poi.lon, gz, compact: compactFieldsOf(poi.props) });
|
||||
// 포함 경계 ±0.15 — 경계 걸침 라벨의 호버 중 10Hz 깜빡임 방지(화면 밖 컬링은 드로우 담당).
|
||||
if (pxRaw < -0.15 || pxRaw > 1.15 || pyRaw < -0.15 || pyRaw > 1.15) continue;
|
||||
poiCand.push({ x: pxRaw, y: pyRaw, title: poi.title, label: guboonOf(poi.props) ?? cleanTitle(poi.title), category: poi.category, dist: cc.distH ?? 0, lat: poi.lat, lon: poi.lon, gz, compact: compactFieldsOf(poi.props), ...(poi.labelColor ? { color: poi.labelColor } : {}) });
|
||||
}
|
||||
// 겹침 억제: KAKAO_RAIL(철도역/역사) 라벨을 최우선 유지 → 겹치면 일반 POI 가 숨겨진다.
|
||||
// 같은 우선순위 내에서는 가까운(dist 작은) 것 우선. 임계 이내면 뒤(낮은우선/먼) 것 숨김.
|
||||
// 거리차 1m 미만은 제목순 고정 — 호버(정지) 중 GPS 지터로 승자가 프레임마다 뒤바뀌며
|
||||
// 라벨 쌍이 교대로 깜빡이는 것 방지.
|
||||
const railRank = (cat: string): number => (cat === '철도역' || cat === '역사' ? 0 : 1);
|
||||
poiCand.sort((a, b) => railRank(a.category) - railRank(b.category) || a.dist - b.dist);
|
||||
poiCand.sort((a, b) =>
|
||||
railRank(a.category) - railRank(b.category) ||
|
||||
(Math.abs(a.dist - b.dist) > 1 ? a.dist - b.dist : (a.title < b.title ? -1 : a.title > b.title ? 1 : 0)));
|
||||
// 겹침 시 진행방향 변형 우선: 같은 base 형제(예: 회덕제1가도교 상/하)가 겹치면
|
||||
// 하행='(하)' / 상행='(상)' 쪽을 남긴다(드론이 실제 지나는 선로). 무관한 POI엔 영향 없음.
|
||||
// 진행방향 변형 태그(닫는 괄호까지) — 하행='(하)', 상행='(상)'. '(하인상)' 등은 제외.
|
||||
@@ -816,7 +958,7 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
const ck = coordKey(c);
|
||||
const row = groupSeen.get(ck) ?? 0;
|
||||
groupSeen.set(ck, row + 1);
|
||||
return { title: c.title, label: c.label, category: c.category, lat: c.lat, lon: c.lon, gz: c.gz, compact: c.compact, labelRow: row, labelRowCount: groupCount.get(ck) ?? 1 };
|
||||
return { title: c.title, label: c.label, category: c.category, lat: c.lat, lon: c.lon, gz: c.gz, compact: c.compact, labelRow: row, labelRowCount: groupCount.get(ck) ?? 1, ...(c.color ? { color: c.color } : {}) };
|
||||
});
|
||||
|
||||
newMap.set(drone.frame, { stationLabels, poiMarkers });
|
||||
@@ -841,17 +983,20 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
}, [nearestCL]);
|
||||
|
||||
// 모든 데이터 로드 완료 시 사전 계산 시작
|
||||
// storeFrames 의존 필수: 분할 영상 세그먼트 전환(loadSegment)으로 frames 가 교체되면
|
||||
// 라벨 캐시(프레임별 가시 POI)를 새 구간 기준으로 재계산해야 함 — 없으면 이전 세그먼트
|
||||
// 캐시가 남아 두 번째 영상부터 시설물이 표출되지 않는다.
|
||||
useEffect(() => {
|
||||
if (!droneFramesLoaded || !geoDataLoaded || !clDataLoaded) return;
|
||||
startLabelPrecompute(paramsRef.current, smoothHalf, maxPoiRange, poiDroneHeight, droneHeightDrop);
|
||||
}, [droneFramesLoaded, geoDataLoaded, clDataLoaded, startLabelPrecompute, smoothHalf, maxPoiRange, poiDroneHeight, droneHeightDrop, poiOverlapExclude]);
|
||||
}, [droneFramesLoaded, geoDataLoaded, clDataLoaded, startLabelPrecompute, smoothHalf, maxPoiRange, poiDroneHeight, droneHeightDrop, poiOverlapExclude, dronePathZ, storeFrames]);
|
||||
|
||||
// params / smoothHalf / maxPoiDist 변경 시 사전 계산 재시작 (500ms debounce)
|
||||
// params / smoothHalf / maxPoiDist / 경로표고(표고 미상 라벨 앵커) 변경 시 사전 계산 재시작 (500ms debounce)
|
||||
useEffect(() => {
|
||||
if (!droneFramesLoaded || !geoDataLoaded || !clDataLoaded) return;
|
||||
const timer = setTimeout(() => startLabelPrecompute(params, smoothHalf, maxPoiRange, poiDroneHeight, droneHeightDrop), 500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [params, smoothHalf, maxPoiRange, poiDroneHeight, droneHeightDrop, storePois, storeStructures, droneFramesLoaded, geoDataLoaded, clDataLoaded, startLabelPrecompute, poiOverlapExclude]);
|
||||
}, [params, smoothHalf, maxPoiRange, poiDroneHeight, droneHeightDrop, storePois, storeStructures, droneFramesLoaded, geoDataLoaded, clDataLoaded, startLabelPrecompute, poiOverlapExclude, dronePathZ, storeFrames]);
|
||||
|
||||
// 현재 재생 시간 → 드론 프레임 ref 갱신
|
||||
useEffect(() => {
|
||||
@@ -1131,31 +1276,32 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
const py = (prevDpoi?.ry !== undefined && Math.abs(psy0 - prevDpoi.ry) <= 0.75) ? prevDpoi.ry : Math.round(psy0);
|
||||
d.rx = px; d.ry = py;
|
||||
displayedPoiRef.current.set(poiA.title, d);
|
||||
const r = 10;
|
||||
// 십자 마커
|
||||
ctx.strokeStyle = '#64c8ff'; ctx.lineWidth = 2.5;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(px-r, py); ctx.lineTo(px+r, py);
|
||||
ctx.moveTo(px, py-r); ctx.lineTo(px, py+r);
|
||||
ctx.stroke();
|
||||
// 이모지 + 텍스트
|
||||
const emoji = CATEGORY_EMOJI[poiA.category] ?? '📌';
|
||||
// 텍스트 박스만 표출(십자 마커/이모지 아이콘 없음):
|
||||
// 배경 = KML 텍스트박스_색상(poiA.color, 없으면 기본 하늘색), 글자 = 흰색.
|
||||
// 박스는 POI 지점(px)에 가로 중앙 정렬.
|
||||
const boxCol = poiA.color ?? '#64c8ff';
|
||||
// 라벨: 속성에 '구분'이 있으면 그 값(원문 그대로), 없으면 cleanTitle(title). poiCand에서 확정.
|
||||
const label = `${emoji} ${poiA.label}`;
|
||||
const lx = Math.max(2, px + 14);
|
||||
// 동일좌표 라벨 세로 배치: 행 중앙을 십자에 맞춰 위→아래로 안 겹치게 오프셋.
|
||||
const ROW_H = 24;
|
||||
const label = poiA.label;
|
||||
// 동일좌표 라벨 세로 배치: 행 중앙을 지점에 맞춰 위→아래로 안 겹치게 오프셋.
|
||||
const ROW_H = 28;
|
||||
const labelY = py + (poiA.labelRow - (poiA.labelRowCount - 1) / 2) * ROW_H;
|
||||
ctx.strokeStyle = 'rgba(0,0,0,0.85)'; ctx.lineWidth = 4;
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.strokeText(label, lx, labelY);
|
||||
ctx.fillStyle = '#64c8ff';
|
||||
ctx.fillText(label, lx, labelY);
|
||||
// 히트박스(십자 마커 ~ 글자 끝) — 아이콘·글자 어디를 눌러도 선택되게. (라벨 행 위치 기준)
|
||||
const tw = ctx.measureText(label).width;
|
||||
hitBoxes.push({ kind: 'poi', title: poiA.title, x0: px - r, y0: labelY - 13, x1: lx + tw + 2, y1: labelY + 13 });
|
||||
const poiVisible = labelOnScreen(px - r, labelY - 13, lx + tw + 2, labelY + 13);
|
||||
// 박스 높이 = 글자 크기(20px) × 1.2 — 라벨 상하 여유. 행 간격(ROW_H)도 같이 벌림.
|
||||
const PAD_X = 7, BOX_H = 24;
|
||||
const bx0 = Math.max(2, px - tw / 2 - PAD_X);
|
||||
const bx1 = bx0 + tw + PAD_X * 2;
|
||||
const by0 = labelY - BOX_H / 2, by1 = labelY + BOX_H / 2;
|
||||
ctx.fillStyle = boxCol;
|
||||
ctx.beginPath();
|
||||
if (typeof ctx.roundRect === 'function') ctx.roundRect(bx0, by0, bx1 - bx0, BOX_H, 5);
|
||||
else ctx.rect(bx0, by0, bx1 - bx0, BOX_H);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(label, bx0 + PAD_X, labelY);
|
||||
// 히트박스 = 텍스트 박스 영역 (어디를 눌러도 선택)
|
||||
hitBoxes.push({ kind: 'poi', title: poiA.title, x0: bx0, y0: by0 - 2, x1: bx1 + 2, y1: by1 + 2 });
|
||||
const poiVisible = labelOnScreen(bx0, by0, bx1, by1);
|
||||
if (poiVisible) onScreenLabels.add(`poi:${poiA.title}`);
|
||||
// 연속 온스크린 프레임 수 갱신(화면 밖이면 0으로 리셋).
|
||||
const streak = poiVisible ? (poiOnScreenStreakRef.current.get(poiA.title) ?? 0) + 1 : 0;
|
||||
@@ -1179,7 +1325,7 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
} // end if(cache) — 중심선/드론경로/측점/POI
|
||||
|
||||
// 편집/FOV 보정 모드: 드래그 중 마커 피드백 (마우스 따라다님)
|
||||
if ((editModeRef.current || fovModeRef.current) && dragRef.current && dragPosRef.current) {
|
||||
if ((editModeRef.current || fovModeRef.current || yawModeRef.current) && dragRef.current && dragPosRef.current) {
|
||||
const dx = vx(dragPosRef.current.x), dy = vy(dragPosRef.current.y);
|
||||
ctx.strokeStyle = '#ffe14d'; ctx.lineWidth = 2;
|
||||
ctx.beginPath(); ctx.arc(dx, dy, 14, 0, Math.PI * 2); ctx.stroke();
|
||||
@@ -1224,7 +1370,10 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
// 잡으면 라벨 높이만큼 먹혀 팝업이 글자에 붙는다 → 라벨 '바깥 가장자리' 기준으로 띄운다.
|
||||
const LABEL_HALF = 15; // 라벨 반높이(px) — sy 중심 ± 이 값이 글자/아이콘 영역
|
||||
const GAP = 12; // 라벨 가장자리 ~ 팝업 사이 실제 여백
|
||||
const tx = Math.min(Math.max(8, sx - 10), Math.max(8, W - pw - 4));
|
||||
// POI 라벨은 지점(sx) 기준 가로 중앙 정렬 박스 → 팝업도 박스 중앙 아래로 정렬.
|
||||
// 측점 라벨은 마커 오른쪽으로 그려지므로 기존(좌측 여유 10px) 유지.
|
||||
const txRaw = kind === 'poi' ? sx - pw / 2 : sx - 10;
|
||||
const tx = Math.min(Math.max(8, txRaw), Math.max(8, W - pw - 4));
|
||||
// 기본은 라벨 '아래'. 아래 공간이 부족하면(화면 하단 근접) 라벨 '위'로 플립.
|
||||
// ※ 히스테리시스(sticky): 경계에서 sy 가 미세하게 오르내려도 위↔아래로 왕복하지 않도록,
|
||||
// '위'로 간 뒤엔 아래에 HYST 이상 여유가 생겨야만 '아래'로 복귀. (왕복=팝업 순간이동=번쩍임 방지)
|
||||
@@ -1299,7 +1448,7 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
const { x: nx, y: ny } = clientToVideoNorm(e.clientX, e.clientY);
|
||||
|
||||
// 편집/세로화각 보정: POI 선택 + 드래그
|
||||
if (editModeRef.current || fovModeRef.current) {
|
||||
if (editModeRef.current || fovModeRef.current || yawModeRef.current) {
|
||||
const canvas = canvasRef.current; if (!canvas) return;
|
||||
const hit = hitTestLabel(nx, ny);
|
||||
if (!hit) return;
|
||||
@@ -1366,15 +1515,15 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
// 영상이 준비되기 전(폴더 선택 화면 등)엔 입력을 받지 않음 → 하위 UI(폴더 선택/드롭) 클릭 통과.
|
||||
if (!videoReady) { canvas.style.pointerEvents = 'none'; return; }
|
||||
canvas.style.pointerEvents = 'auto';
|
||||
canvas.style.cursor = (editMode || fovMode) ? 'move' : 'default';
|
||||
if (editMode || fovMode) { setInfoPopups([]); return; }
|
||||
canvas.style.cursor = (editMode || fovMode || yawMode) ? 'move' : 'default';
|
||||
if (editMode || fovMode || yawMode) { setInfoPopups([]); return; }
|
||||
const onMove = (e: MouseEvent) => {
|
||||
const { x: nx, y: ny } = clientToVideoNorm(e.clientX, e.clientY);
|
||||
canvas.style.cursor = hitTestLabel(nx, ny) ? 'pointer' : 'default';
|
||||
};
|
||||
parent.addEventListener('mousemove', onMove);
|
||||
return () => { parent.removeEventListener('mousemove', onMove); };
|
||||
}, [videoReady, editMode, fovMode, clientToVideoNorm, hitTestLabel]);
|
||||
}, [videoReady, editMode, fovMode, yawMode, clientToVideoNorm, hitTestLabel]);
|
||||
|
||||
const onPoiPointerUp = useCallback(() => {
|
||||
const drag = dragRef.current, pos = dragPosRef.current;
|
||||
@@ -1385,6 +1534,27 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
const drone = getSmoothedDrone(); if (!drone) return;
|
||||
const p = paramsRef.current, wo = worldOriginRef.current;
|
||||
|
||||
// ── Yaw(방위 오프셋) 보정 ──
|
||||
// 드롭한 화면점의 '가로'(px)만 맞춘다. 라벨의 절대 방위는 고정이므로,
|
||||
// 현재 카메라축 기준 수평각 θ0 = atan2(Xc, Zc) 과 드롭 지점이 요구하는
|
||||
// 수평각 θ1 = atan((px* − 0.5 − cx0)·sW / f) 의 차이만큼 카메라 방위를 돌리면 됨:
|
||||
// yawOffset_new = yawOffset_old + (θ0 − θ1)
|
||||
if (yawModeRef.current) {
|
||||
const poiZ = poiDroneHeightRef.current
|
||||
? drone.altitude - (p.geoidOffset ?? 0) - droneHeightDropRef.current
|
||||
: drag.z0;
|
||||
const cc = toCameraCoords(drone, drag.lat, drag.lon, poiZ, p, wo);
|
||||
if (cc.Zc < 0.1) return;
|
||||
const th0 = Math.atan2(cc.Xc, cc.Zc);
|
||||
const th1 = Math.atan(((pos.x - 0.5 - (p.cx0 ?? 0)) * (p.sensorW ?? 36)) / p.focalLen);
|
||||
const dYaw = (th0 - th1) * 180 / Math.PI;
|
||||
if (!isFinite(dYaw) || Math.abs(dYaw) > 45) { alert('보정량이 비정상적으로 큽니다 — 올바른 지물 위치로 끌었는지 확인하세요.'); return; }
|
||||
const next = Math.max(-180, Math.min(180, (p.yawOffset ?? 0) + dYaw));
|
||||
setParam('yawOffset', Math.round(next * 10) / 10);
|
||||
console.log(`[yaw] 드래그 역산: Δ${dYaw.toFixed(2)}° → Yaw± ${next.toFixed(1)}°`);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── 세로 화각(sensorH) 보정 ──
|
||||
// 드롭한 화면점의 '세로'(py)만 맞춘다. 가로(f/sW)는 이미 정확하므로 건드리지 않음
|
||||
// (f 를 바꾸면 가로까지 틀어져 스테이션선이 좌우로 벗어남).
|
||||
@@ -1632,26 +1802,32 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
{poiDroneHeight ? `■ 드론 높이와 POI 이격거리 ${droneHeightDrop}m` : '▶ 드론 높이와 POI 이격거리'}
|
||||
</button>
|
||||
{poiDroneHeight && (
|
||||
<ParamRow label="이격거리" tip={`POI를 드론보다 ${droneHeightDrop}m 아래에 배치 (값↑ = POI 아래로)`} value={droneHeightDrop} min={0} max={120} step={1} unit="m" decimals={0} onChange={v => setDroneHeightDrop(Math.round(v))} />
|
||||
<ParamRow label="이격거리" tip={`POI를 드론보다 ${droneHeightDrop}m 아래에 배치 (값↑ = POI 아래로)`} value={droneHeightDrop} min={0} max={300} step={1} unit="m" decimals={0} onChange={v => setDroneHeightDrop(Math.round(v))} />
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[10px] text-gray-500 uppercase tracking-wider mb-1.5 border-t border-gray-700 pt-2">POI 위치 편집 (마우스)
|
||||
<InfoTip text="드래그=수평위치 이동(가까운 프레임에서). 클릭 후 표고 슬라이더=높이. DEM(SRTM 30m)으로 각 POI 지점의 지면고도 적용(구글 지면고도와 일치). 옥상으로 뜨는 건물은 드래그/슬라이더로 보정. 자동저장." />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<button onClick={() => { setEditMode(v => { const nv = !v; if (nv) setFovMode(false); return nv; }); }}
|
||||
<button onClick={() => { setEditMode(v => { const nv = !v; if (nv) { setFovMode(false); setYawMode(false); } return nv; }); }}
|
||||
className={`w-full text-[11px] px-2 py-1 rounded border transition-colors ${editMode ? 'bg-yellow-500/80 border-yellow-300 text-black font-bold' : 'bg-black/60 border-gray-600 text-gray-200 hover:border-gray-400'}`}>
|
||||
{editMode ? '■ 편집 종료' : '✎ 편집 모드 (POI 위치/표고)'}
|
||||
</button>
|
||||
<button onClick={() => { setFovMode(v => { const nv = !v; if (nv) setEditMode(false); return nv; }); }}
|
||||
<button onClick={() => { setFovMode(v => { const nv = !v; if (nv) { setEditMode(false); setYawMode(false); } return nv; }); }}
|
||||
className={`w-full text-[11px] px-2 py-1 rounded border transition-colors ${fovMode ? 'bg-purple-500/80 border-purple-300 text-black font-bold' : 'bg-black/60 border-gray-600 text-gray-200 hover:border-gray-400'}`}>
|
||||
{fovMode ? '■ 세로화각 보정 종료' : '🎯 세로화각 보정 — POI를 실제 위치(상하)로 드래그'}
|
||||
<InfoTip text="라벨이 멀리(위)/가까이(아래)서 상하로 어긋날 때 사용. POI 하나를 영상 속 실제 위치로 끌면 세로 화각(sensorH)만 역산해 상하 정렬. 가로(초점 f)는 안 건드려 빨간 중심선이 좌우로 안 틀어짐. 세로 중심에서 충분히 떨어진(위/아래) POI로 보정." />
|
||||
</button>
|
||||
<button onClick={() => { setYawMode(v => { const nv = !v; if (nv) { setEditMode(false); setFovMode(false); } return nv; }); }}
|
||||
className={`w-full text-[11px] px-2 py-1 rounded border transition-colors ${yawMode ? 'bg-sky-500/80 border-sky-300 text-black font-bold' : 'bg-black/60 border-gray-600 text-gray-200 hover:border-gray-400'}`}>
|
||||
{yawMode ? '■ Yaw 보정 종료' : '🧭 Yaw 보정 — POI를 실제 위치(좌우)로 드래그'}
|
||||
<InfoTip text="라벨 전체가 좌우로 치우칠 때 사용. POI 하나를 영상 속 실제 지물 위치로 끌면 필요한 Yaw±(방위 오프셋)를 역산해 자동 설정. 자동추정(비행경로) 후 마무리 미세조정에 적합." />
|
||||
</button>
|
||||
<div className="text-[10px] text-gray-600">ℹ️ 라벨 클릭 → 속성 팝업(여러 개 누적, 재생 따라 이동). 빈 곳 클릭 → 팝업 닫기, 팝업 없으면 재생/정지.</div>
|
||||
<div className="text-[10px] text-gray-500">
|
||||
{dragTitle ? `드래그 중: ${dragTitle}`
|
||||
: fovMode ? `🎯 POI를 실제 위치로 끌면 세로화각 자동보정 (현재 senH=${(params.sensorH ?? 20.25).toFixed(2)}mm)`
|
||||
: yawMode ? `🧭 POI를 실제 위치(좌우)로 끌면 Yaw 자동보정 (현재 Yaw±=${params.yawOffset.toFixed(1)}°)`
|
||||
: `보정된 POI ${Object.keys(storePoiOverrides).length}개`}
|
||||
</div>
|
||||
{editMode && selPoi && (
|
||||
@@ -1700,6 +1876,13 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
<div className="text-[10px] text-gray-500 uppercase tracking-wider mb-1.5 border-b border-gray-700 pb-2 mb-2">자세 보정 오프셋<span className="ml-1 text-gray-600">(SRT + offset)</span></div>
|
||||
<div className="space-y-2 mb-3">
|
||||
<ParamRow label="Yaw ±" value={params.yawOffset} min={-180} max={180} step={0.1} unit="°" decimals={1} onChange={v => setParam('yawOffset', v)} />
|
||||
{/* Yaw 자동 추정(2단계 보정의 1단계) — 대략 맞춘 뒤 'Yaw 보정' 드래그 모드로 마무리 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={autoEstimateYaw}
|
||||
className="w-full text-[10px] px-2 py-0.5 rounded border border-gray-600 bg-gray-800 hover:bg-gray-700 text-gray-300"
|
||||
title="전진 비행 구간의 GPS 진행 방위와 짐벌 헤딩 차이(중앙값)로 Yaw± 자동 설정 (근사) — 이후 'Yaw 보정' 드래그로 미세 조정"
|
||||
>Yaw 자동추정 (비행경로 기준)</button>
|
||||
<ParamRow label="Pitch ±" value={params.pitch} min={-45} max={45} step={0.1} unit="°" decimals={1} onChange={v => setParam('pitch', v)} />
|
||||
<ParamRow label="Roll ±" value={params.roll} min={-45} max={45} step={0.1} unit="°" decimals={1} onChange={v => setParam('roll', v)} />
|
||||
</div>
|
||||
@@ -1712,6 +1895,30 @@ export default function StationOverlay({ currentFrame, currentTime, fps, visible
|
||||
<div className="text-[10px] text-gray-600 text-right">정표고→타원체고 (대전≈25.8)</div>
|
||||
</div>
|
||||
<div className="text-[10px] text-gray-500 uppercase tracking-wider mb-1.5 border-t border-gray-700 pt-2">내부표정 (초점·주점·센서)</div>
|
||||
{/* 영상(djmd) 자동 감지 정보 + 카메라값 서버 저장('<영상 base>.camera.json' — 재로드 시 자동 적용) */}
|
||||
<div className="flex items-center justify-between gap-1 mb-1.5 text-[10px] text-gray-400">
|
||||
<span className="truncate" title="영상 메타데이터(djmd)에서 감지된 카메라 정보">
|
||||
📷 {storeCameraInfo
|
||||
? `${storeCameraInfo.model}${storeCameraInfo.focalLen35 ? ` · ${storeCameraInfo.focalLen35}mm` : ''}${storeCameraInfo.fps ? ` · ${storeCameraInfo.fps}fps` : ''}`
|
||||
: '카메라 미감지'}
|
||||
</span>
|
||||
<div className="flex gap-1 shrink-0">
|
||||
{storeCameraInfo?.focalLen35 !== undefined && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setParams(p => ({ ...p, focalLen: storeCameraInfo!.focalLen35! }))}
|
||||
className="px-1.5 py-0.5 rounded border border-gray-600 bg-gray-700 hover:bg-gray-600 text-gray-200"
|
||||
title={`감지된 초점거리 ${storeCameraInfo!.focalLen35}mm 를 f 에 적용`}
|
||||
>적용</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={saveCameraToPc}
|
||||
className={`px-1.5 py-0.5 rounded border ${camSaveState === 'ok' ? 'border-emerald-400 bg-emerald-700/60 text-emerald-100' : 'border-gray-600 bg-gray-700 hover:bg-gray-600 text-gray-200'}`}
|
||||
title="현재 카메라 파라미터(Yaw± 보정 포함 전체)를 <영상이름>.camera.json 으로 PC에 다운로드 — 영상 폴더에 넣어두면 폴더 선택 시 자동 적용"
|
||||
>{camSaveState === 'ok' ? '저장됨 ✓' : 'PC에 저장'}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2 mb-3">
|
||||
<ParamRow label="f" value={params.focalLen} min={10} max={100} step={0.1} unit="mm" decimals={1} onChange={v => setParam('focalLen', v)} />
|
||||
<ParamRow label="cx₀" value={params.cx0} min={-0.5} max={0.5} step={0.005} unit="" decimals={3} onChange={v => setParam('cx0', v)} />
|
||||
|
||||
@@ -43,6 +43,12 @@ export interface VideoPlayerHandle {
|
||||
getVideoElement: () => HTMLVideoElement | null;
|
||||
}
|
||||
|
||||
/** 초 → "m:ss" (재생목록 길이 표시용). 미상이면 "--:--". */
|
||||
function fmtDur(d: number | null | undefined): string {
|
||||
if (d == null || !isFinite(d)) return '--:--';
|
||||
return `${Math.floor(d / 60)}:${String(Math.floor(d % 60)).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
interface VideoPlayerProps {
|
||||
onAddMemo: (time: number) => void;
|
||||
onToggleHelp?: () => void;
|
||||
@@ -119,6 +125,12 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
|
||||
const geoLoaded = useGeoStore((s) => s.loaded);
|
||||
// 드론 정보 — 하단바에 GPS/고도 항상 표시.
|
||||
const storeFrames = useGeoStore((s) => s.frames);
|
||||
// 분할 영상 재생목록 — 폴더 로드 시 이름 오름차순. 목록 클릭으로 세그먼트 직접 선택 재생.
|
||||
const videoFiles = useGeoStore((s) => s.videoFiles);
|
||||
const videoDurations = useGeoStore((s) => s.videoDurations);
|
||||
const videoIndex = useGeoStore((s) => s.videoIndex);
|
||||
// 영상목록 콤보(좌상단 타이틀 아래) 펼침 상태 — 기본 접힘.
|
||||
const [showPlaylist, setShowPlaylist] = useState(false);
|
||||
// 시설등급(시설종별) 표시 필터 — 재생바/패널이 구독하는 설정 스토어.
|
||||
const gradeFilter = useSettingsStore((s) => s.gradeFilter);
|
||||
const setGradeFilter = useSettingsStore((s) => s.setGradeFilter);
|
||||
@@ -155,6 +167,48 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
|
||||
}
|
||||
};
|
||||
|
||||
// 재생목록에서 세그먼트 선택 — 같은 항목이면 처음부터 재생, 다른 항목이면
|
||||
// 드론 로그를 해당 구간으로 재정렬(loadSegment)한 뒤 전환 재생.
|
||||
const handleSelectSegment = async (i: number): Promise<void> => {
|
||||
const geo = useGeoStore.getState();
|
||||
if (usePlayerStore.getState().source?.kind !== 'local') return;
|
||||
if (i === geo.videoIndex) {
|
||||
playerRef.current?.currentTime(0);
|
||||
} else {
|
||||
const file = await geo.loadSegment(i);
|
||||
if (!file) return;
|
||||
loadLocalFile(file);
|
||||
}
|
||||
const p = playerRef.current?.play();
|
||||
if (p && typeof p.catch === 'function') p.catch(() => {});
|
||||
};
|
||||
|
||||
// 분할 영상 연속재생 — 영상이 끝나면 같은 폴더의 다음 영상(이름 오름차순)을 자동 재생.
|
||||
// 드론 로그(시간 기반)는 loadSegment 가 누적 오프셋으로 재정렬해 궤적 정합을 유지한다.
|
||||
useEffect(() => {
|
||||
const player = playerRef.current;
|
||||
if (!player) return;
|
||||
const onEnded = (): void => {
|
||||
void (async () => {
|
||||
const geo = useGeoStore.getState();
|
||||
if (!geo.loaded || geo.videoFiles.length < 2) return;
|
||||
// 폴더 기반 로컬 재생에서만 동작 (서버 스트림/단일 파일은 대상 아님)
|
||||
if (usePlayerStore.getState().source?.kind !== 'local') return;
|
||||
const next = geo.videoIndex + 1;
|
||||
if (next >= geo.videoFiles.length) return;
|
||||
const file = await geo.loadSegment(next);
|
||||
if (!file) return;
|
||||
loadLocalFile(file);
|
||||
// ended 직후는 paused 상태라 loadLocalFile 의 이어재생 로직이 안 걸림 → 직접 재생.
|
||||
const p = player.play();
|
||||
if (p && typeof p.catch === 'function') p.catch(() => {});
|
||||
})();
|
||||
};
|
||||
player.on('ended', onEnded);
|
||||
return () => { player.off('ended', onEnded); };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [playerRef, loadLocalFile]);
|
||||
|
||||
// Expose methods to parent via ref
|
||||
useImperativeHandle(ref, () => ({
|
||||
loadLocalFile,
|
||||
@@ -425,6 +479,54 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 영상 목록 콤보 — 좌상단 타이틀(노선 배너) 아래. 버튼 클릭 시 목록이 아래로 펼쳐지고
|
||||
항목 선택 시 해당 영상으로 즉시 전환(드론 로그도 세그먼트에 맞춰 자동 재정렬).
|
||||
현재 재생 항목 재선택 시 처음부터 재생. */}
|
||||
{source?.kind === 'local' && videoFiles.length > 0 && (
|
||||
<div className="absolute left-2 z-40 w-80 pointer-events-auto" style={{ top: paramTop }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPlaylist((v) => !v)}
|
||||
title="폴더 영상 목록 펼치기/접기"
|
||||
className={`w-full flex items-center gap-1.5 px-2 py-1 rounded border text-xs text-left ${
|
||||
showPlaylist
|
||||
? 'bg-amber-400 border-amber-300 text-black font-semibold'
|
||||
: 'bg-black/70 border-gray-600 text-gray-200 hover:bg-black/90'
|
||||
}`}
|
||||
>
|
||||
<span className="shrink-0">▶</span>
|
||||
<span className="flex-1 truncate">{videoFiles[videoIndex]?.name ?? ''}</span>
|
||||
<span className={`shrink-0 font-mono ${showPlaylist ? 'text-black/70' : 'text-gray-400'}`}>
|
||||
{videoIndex + 1}/{videoFiles.length} {showPlaylist ? '▲' : '▼'}
|
||||
</span>
|
||||
</button>
|
||||
{showPlaylist && (
|
||||
<ul className="mt-1 max-h-56 overflow-y-auto bg-black/85 border border-gray-600 rounded-md">
|
||||
{videoFiles.map((f, i) => (
|
||||
<li key={`${i}-${f.name}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setShowPlaylist(false); void handleSelectSegment(i); }}
|
||||
title={i === videoIndex ? `${f.name} (재선택 시 처음부터)` : f.name}
|
||||
className={`w-full flex items-center gap-1.5 px-2 py-1 text-xs text-left ${
|
||||
i === videoIndex
|
||||
? 'bg-amber-400/90 text-black font-semibold'
|
||||
: 'text-gray-200 hover:bg-gray-700/80'
|
||||
}`}
|
||||
>
|
||||
<span className="w-4 shrink-0 text-center">{i === videoIndex ? '▶' : i + 1}</span>
|
||||
<span className="flex-1 truncate">{f.name}</span>
|
||||
<span className={`shrink-0 font-mono ${i === videoIndex ? 'text-black/70' : 'text-gray-400'}`}>
|
||||
{fmtDur(videoDurations[i])}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state placeholder — 가운데 반투명 폴더 선택 */}
|
||||
{!source && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center text-gray-400 pointer-events-none select-none" style={{ minHeight: '240px' }}>
|
||||
|
||||
@@ -33,11 +33,13 @@ interface GeoPoint {
|
||||
type: string;
|
||||
}
|
||||
|
||||
/** 프레임별 precompute 결과. km=배지용 최근접측점, chain=구간방향용 연속 체이니지. */
|
||||
/** 프레임별 precompute 결과. km=배지용 최근접측점, chain=구간방향용 연속 체이니지,
|
||||
* offM=측점 폴리라인까지 수평거리(m) — 노선 밖(접근/이탈) 판별용. */
|
||||
interface ViewedPoint {
|
||||
frameNum: number;
|
||||
km: number;
|
||||
chain: number;
|
||||
offM: number;
|
||||
time: number;
|
||||
}
|
||||
|
||||
@@ -47,9 +49,10 @@ interface StationLine {
|
||||
k: number; // 경도→m 환산 (cos(lat0)*111000)
|
||||
}
|
||||
|
||||
/** 드론 lat/lon 을 측점 폴리라인에 투영 → 연속 체이니지(m).
|
||||
* 100m 양자화된 최근접측점과 달리 전진/후진 전환 시점이 실제 이동과 일치. */
|
||||
function projectChainage(lat: number, lon: number, line: StationLine): number {
|
||||
/** 드론 lat/lon 을 측점 폴리라인에 투영 → 연속 체이니지(m) + 폴리라인까지 수평거리(m).
|
||||
* 100m 양자화된 최근접측점과 달리 전진/후진 전환 시점이 실제 이동과 일치.
|
||||
* distM 은 노선 밖(접근/이탈) 판별에 사용 — 밖에서는 chain 이 끝점에 클램프됨. */
|
||||
function projectChainageDist(lat: number, lon: number, line: StationLine): { chain: number; distM: number } {
|
||||
const px = lon * line.k;
|
||||
const py = lat * 111000;
|
||||
const pts = line.pts;
|
||||
@@ -70,7 +73,11 @@ function projectChainage(lat: number, lon: number, line: StationLine): number {
|
||||
bestKm = a.km + (b.km - a.km) * t;
|
||||
}
|
||||
}
|
||||
return bestKm;
|
||||
return { chain: bestKm, distM: isFinite(bestD) ? Math.sqrt(bestD) : Infinity };
|
||||
}
|
||||
|
||||
function projectChainage(lat: number, lon: number, line: StationLine): number {
|
||||
return projectChainageDist(lat, lon, line).chain;
|
||||
}
|
||||
|
||||
/** 데이터 기반 트랙 구간: km 증가(dir 1, 주황) / 감소(dir -1, 하늘색). px는 시간축. */
|
||||
@@ -232,32 +239,79 @@ export function StationBar({
|
||||
|
||||
useEffect(() => {
|
||||
const frames = storeFrames;
|
||||
if (!frames.length || !stations.length || !stationLine) { setReady(false); return; }
|
||||
if (!frames.length || !stations.length || !stationLine) {
|
||||
// 측점 없는 데이터셋(예: 제주 DJI 로그) 폴백 — 측점 기반 기능(배지/구간색/마크)은
|
||||
// ready=false 로 비활성이지만, 이동거리축(chainRef)은 드론 GPS 누적 이동거리로 구성한다.
|
||||
// → 커서 진행도가 시간 선형이 아닌 실제 이동량을 따라가 호버(정지) 시 커서도 멈춘다.
|
||||
setReady(false);
|
||||
viewedRef.current = [];
|
||||
if (!frames.length) { chainRef.current = null; return; }
|
||||
const n = frames.length;
|
||||
const W = 8; // 평활 반폭(프레임) — GPS 지터가 이동거리로 누적되는 것 방지(측점 경로와 동일 정책)
|
||||
const preLat = new Float64Array(n + 1);
|
||||
const preLon = new Float64Array(n + 1);
|
||||
for (let i = 0; i < n; i++) {
|
||||
preLat[i + 1] = preLat[i] + frames[i].lat;
|
||||
preLon[i + 1] = preLon[i] + frames[i].lon;
|
||||
}
|
||||
const cosLat = Math.cos((frames[0].lat * Math.PI) / 180);
|
||||
const time = new Float64Array(n);
|
||||
const frac = new Float64Array(n);
|
||||
let cum = 0, pLat = 0, pLon = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const lo = Math.max(0, i - W), hi = Math.min(n, i + W + 1);
|
||||
const la = (preLat[hi] - preLat[lo]) / (hi - lo);
|
||||
const ln = (preLon[hi] - preLon[lo]) / (hi - lo);
|
||||
if (i > 0) cum += Math.hypot((la - pLat) * 111000, (ln - pLon) * 111000 * cosLat);
|
||||
pLat = la; pLon = ln;
|
||||
time[i] = frames[i].frame / videoFps;
|
||||
frac[i] = cum;
|
||||
}
|
||||
const total = cum > 0 ? cum : 1;
|
||||
for (let i = 0; i < n; i++) frac[i] /= total;
|
||||
// depChain/arrChain 은 방향색(측점 기반) 전용 — 폴백에선 의미 없음(단조 증가 가정값).
|
||||
chainRef.current = { time, frac, depChain: 0, arrChain: 1 };
|
||||
return;
|
||||
}
|
||||
const n = frames.length;
|
||||
const out: ViewedPoint[] = new Array(n);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const f = frames[i];
|
||||
const pj = projectChainageDist(f.lat, f.lon, stationLine);
|
||||
out[i] = {
|
||||
frameNum: f.frame,
|
||||
km: nearestStationKm(f, stations),
|
||||
chain: projectChainage(f.lat, f.lon, stationLine),
|
||||
chain: pj.chain,
|
||||
offM: pj.distM,
|
||||
time: f.frame / videoFps,
|
||||
};
|
||||
}
|
||||
// 이동거리 축: 평활 측점값(±W 이동평균)의 프레임간 |Δ| 누적 = 실제 이동량 → 전체로 정규화(frac).
|
||||
// 호버(이동 無)면 frac 정체 → 커서 정지. 이동하면(전진/후진 무관) frac 증가 → 커서 항상 우측.
|
||||
// depChain/arrChain(앞·뒤 5% 평균)은 방향색 기준.
|
||||
// 이동거리 축(frac): 체이니지 |Δ| 가 아니라 GPS 실이동거리 누적(±W 이동평균 평활).
|
||||
// 체이니지 투영은 노선 밖(접근/이탈 구간)에서 폴리라인 끝점(0k000 등)에 클램프되어
|
||||
// 드론이 움직여도 커서가 멈추는 문제가 있었음(제주 — 비행 시작점이 노선 밖 700m).
|
||||
// GPS 누적거리는 호버 시 정체(커서 정지) + 노선 안팎 무관하게 실제 이동을 따라간다.
|
||||
// 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 preLat = new Float64Array(n + 1);
|
||||
const preLon = new Float64Array(n + 1);
|
||||
for (let i = 0; i < n; i++) {
|
||||
pre[i + 1] = pre[i] + out[i].chain;
|
||||
preLat[i + 1] = preLat[i] + frames[i].lat;
|
||||
preLon[i + 1] = preLon[i] + frames[i].lon;
|
||||
}
|
||||
const cosLat = Math.cos((frames[0].lat * Math.PI) / 180);
|
||||
const time = new Float64Array(n);
|
||||
const sm = new Float64Array(n);
|
||||
const frac = new Float64Array(n);
|
||||
let cum = 0;
|
||||
let cum = 0, pLat = 0, pLon = 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]);
|
||||
const la = (preLat[hi] - preLat[lo]) / (hi - lo);
|
||||
const ln = (preLon[hi] - preLon[lo]) / (hi - lo);
|
||||
if (i > 0) cum += Math.hypot((la - pLat) * 111000, (ln - pLon) * 111000 * cosLat);
|
||||
pLat = la; pLon = ln;
|
||||
time[i] = out[i].time;
|
||||
frac[i] = cum;
|
||||
}
|
||||
@@ -297,6 +351,13 @@ export function StationBar({
|
||||
return idx >= 0 ? viewedRef.current[idx].chain : null;
|
||||
}, [currentTime, ready, viewedIdxAtTime]);
|
||||
|
||||
// 현재 드론의 노선(측점 폴리라인) 이탈 거리(m) — 접근/이탈 구간 배지 표기용.
|
||||
const realOffM = useMemo<number | null>(() => {
|
||||
if (!ready) return null;
|
||||
const idx = viewedIdxAtTime(currentTime);
|
||||
return idx >= 0 ? viewedRef.current[idx].offM : null;
|
||||
}, [currentTime, ready, viewedIdxAtTime]);
|
||||
|
||||
// 종점역 '미도착' 폭(px) — 영상이 종점역 도착 전 끝날 때, 트랙 우측에 이만큼 비워둔다.
|
||||
// 영상 타임라인은 [START … END−endGapPx] 에만 매핑되고, 종점역 마커는 END(미도착)에 둔다.
|
||||
// 폭 = 미도착거리(m) / 노선길이(m) × 트랙폭 (옵션 2: 실제 비례). 안전상 15%로 상한.
|
||||
@@ -335,11 +396,12 @@ export function StationBar({
|
||||
return null;
|
||||
}, [routeMeta, directionChanges, duration]);
|
||||
|
||||
// 진행도(이동거리축): 시간 t → 누적 이동거리 비율(frac, 0~1). chainRef 미준비 시 시간선형 폴백.
|
||||
// 진행도(이동거리축): 시간 t → 누적 이동거리 비율(frac, 0~1). chainRef 미구성 시 시간선형 폴백.
|
||||
// chainRef 는 측점 유무와 무관하게 드론 프레임만 있으면 구성된다(위 폴백) → ready 비의존.
|
||||
const cumFracAtTime = useCallback(
|
||||
(t: number): number => {
|
||||
const c = chainRef.current;
|
||||
if (!c || !ready || duration <= 0)
|
||||
if (!c || duration <= 0)
|
||||
return duration > 0 ? clamp(t / duration, 0, 1) : 0;
|
||||
const { time, frac } = c;
|
||||
const n = time.length;
|
||||
@@ -355,10 +417,11 @@ export function StationBar({
|
||||
[ready, duration],
|
||||
);
|
||||
// 역변환(이동거리 비율 → 시간): 바 클릭 seek 용. frac 단조 비감소 → 이진 탐색.
|
||||
// cumFracAtTime 과 동일하게 ready 비의존(측점 없는 폴백 chainRef 도 사용).
|
||||
const timeAtFrac = useCallback(
|
||||
(f: number): number => {
|
||||
const c = chainRef.current;
|
||||
if (!c || !ready || duration <= 0) return clamp(f, 0, 1) * duration;
|
||||
if (!c || duration <= 0) return clamp(f, 0, 1) * duration;
|
||||
const { time, frac } = c;
|
||||
const n = frac.length;
|
||||
if (n === 0) return 0;
|
||||
@@ -387,8 +450,15 @@ export function StationBar({
|
||||
const cursorPx = progressPx;
|
||||
|
||||
// 커서 배지 = 폴더 데이터 기반 연속 체이니지(realChain)를 10m 단위로 표시. 데이터 없으면 빈 문자열.
|
||||
// 드론이 노선(측점 폴리라인)에서 OFF_ROUTE_M 이상 벗어난 구간(접근/이탈)은 체이니지가
|
||||
// 끝점에 클램프되어 무의미 → "노선밖 ○○m" 로 표기해 측점이 안 변하는 이유를 드러낸다.
|
||||
const OFF_ROUTE_M = 60;
|
||||
const cursorText =
|
||||
realChain !== null && realChain >= 0 ? formatMileage10(realChain) : '';
|
||||
realChain !== null && realChain >= 0
|
||||
? (realOffM !== null && realOffM > OFF_ROUTE_M
|
||||
? `노선밖 ${Math.round(realOffM)}m`
|
||||
: formatMileage10(realChain))
|
||||
: '';
|
||||
|
||||
// viewedRef(프레임→측점 km) 추이로 구간 색·전환점 산출.
|
||||
// km 증가 구간 = dir 1(주황), 감소 구간 = dir -1(하늘색). HYST로 최근접 지터 무시.
|
||||
@@ -498,6 +568,13 @@ export function StationBar({
|
||||
if (d < gmin) gmin = d;
|
||||
}
|
||||
if (!isFinite(gmin)) return [];
|
||||
// 이 영상(세그먼트)에서 실제로 근접 통과하지 않은 구조물은 마크 생략.
|
||||
// 종전엔 TH 가 최근접거리에 비례해 늘어나(gmin×3) 수 km 밖 구조물도 최근접
|
||||
// 프레임(세그먼트 끝)에 마크가 찍혀, 실제 통과 구조물과 겹쳐 보였음
|
||||
// (제주: 세그1 끝 귀덕천교 위에 금성천교·어음천교가 겹침 — 이들은 세그3 통과분).
|
||||
// 해당 구조물은 통과하는 세그먼트의 바에서 정상 표시된다.
|
||||
const PASS_MAX_M = 300;
|
||||
if (gmin > PASS_MAX_M) return [];
|
||||
const TH = Math.max(100, gmin * 3); // 통과 인정 거리(m)
|
||||
// 통과 프레임에서 진행방향으로 offsetM 만큼 이동(GPS 누적거리 기준).
|
||||
const shift = (idx: number): number => {
|
||||
|
||||
@@ -41,9 +41,11 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 20px;
|
||||
/* "노선밖 737m" 같은 긴 표기도 한 줄 유지 — 글자 축소 + 줄바꿈 금지(폭은 내용에 맞게 늘어남) */
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.4px;
|
||||
white-space: nowrap;
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.35),
|
||||
0 2px 6px rgba(0, 0, 0, 0.45);
|
||||
|
||||
@@ -23,8 +23,9 @@ import type {
|
||||
DirectionChange,
|
||||
PoiOverride,
|
||||
PoiOverrideMap,
|
||||
DetectedCameraInfo,
|
||||
} from '../types/geo';
|
||||
import { loadFolderGeoData, applyPoiOverrides } from '../utils/geoData';
|
||||
import { loadFolderGeoData, applyPoiOverrides, parseDroneFrames } from '../utils/geoData';
|
||||
|
||||
interface GeoStore {
|
||||
loaded: boolean;
|
||||
@@ -43,12 +44,30 @@ interface GeoStore {
|
||||
directionChanges: DirectionChange[];
|
||||
/** 마지막 폴더 로드에서 KMZ(POI·구조물 원본)가 누락됐는지. true면 재구축 필요. */
|
||||
kmzMissing: boolean;
|
||||
/** 폴더 내 영상 파일 전체(이름 오름차순) — 분할본 연속재생용. */
|
||||
videoFiles: File[];
|
||||
/** videoFiles 각 영상 길이(초). 측정 실패 시 null. */
|
||||
videoDurations: (number | null)[];
|
||||
/** 현재 재생 중인 세그먼트 인덱스 (videoFiles 기준). */
|
||||
videoIndex: number;
|
||||
/** 폴더 원본 파일 전체 — 세그먼트 전환 시 드론 CSV 재파싱용. */
|
||||
folderFiles: File[];
|
||||
/** 영상 djmd 트랙에서 감지한 카메라 정보 (예: ZenmuseP1, 초점거리). */
|
||||
cameraInfo: DetectedCameraInfo | null;
|
||||
/** 폴더의 `<영상 base>.camera.json` 카메라 파라미터 — 감지값/저장값보다 최우선 적용. */
|
||||
cameraJson: Record<string, number> | null;
|
||||
|
||||
/**
|
||||
* 폴더 선택 파일에서 지리정보를 파싱해 스토어에 적재한다.
|
||||
* 발견한 영상 File 을 반환(없으면 null) — 호출자가 loadLocalFile 로 재생.
|
||||
*/
|
||||
loadFromFolder: (files: FileList | File[]) => Promise<File | null>;
|
||||
/**
|
||||
* 분할 영상 세그먼트 전환 — 드론 로그(시간 기반)를 해당 영상 구간(누적 오프셋)으로
|
||||
* 재정렬해 frames 를 교체한다. 재생할 영상 File 을 반환(범위 밖이면 null).
|
||||
* baseName 은 폴더(첫 영상) 기준을 유지한다 — 카메라 보정 localStorage 연속성.
|
||||
*/
|
||||
loadSegment: (index: number) => Promise<File | null>;
|
||||
/** 단일 POI 보정 설정/갱신 (드래그 종료 시). title 키. */
|
||||
setPoiOverride: (title: string, ov: PoiOverride) => void;
|
||||
/** 단일 POI 보정 해제. */
|
||||
@@ -72,9 +91,15 @@ const EMPTY = {
|
||||
structures: [] as RouteStructure[],
|
||||
directionChanges: [] as DirectionChange[],
|
||||
kmzMissing: false,
|
||||
videoFiles: [] as File[],
|
||||
videoDurations: [] as (number | null)[],
|
||||
videoIndex: 0,
|
||||
folderFiles: [] as File[],
|
||||
cameraInfo: null as DetectedCameraInfo | null,
|
||||
cameraJson: null as Record<string, number> | null,
|
||||
};
|
||||
|
||||
export const useGeoStore = create<GeoStore>((set) => ({
|
||||
export const useGeoStore = create<GeoStore>((set, get) => ({
|
||||
...EMPTY,
|
||||
|
||||
loadFromFolder: async (files) => {
|
||||
@@ -93,10 +118,47 @@ export const useGeoStore = create<GeoStore>((set) => ({
|
||||
structures: data.structures,
|
||||
directionChanges: data.directionChanges,
|
||||
kmzMissing: data.kmzMissing,
|
||||
videoFiles: data.videoFiles,
|
||||
videoDurations: data.videoDurations,
|
||||
videoIndex: 0,
|
||||
folderFiles: data.folderFiles,
|
||||
cameraInfo: data.cameraInfo,
|
||||
cameraJson: data.cameraJson,
|
||||
});
|
||||
return data.videoFile;
|
||||
},
|
||||
|
||||
loadSegment: async (index) => {
|
||||
const s = get();
|
||||
const file = s.videoFiles[index];
|
||||
if (!file) return null;
|
||||
|
||||
// 누적 오프셋 = 앞 세그먼트 길이 합. 하나라도 측정 실패면 정렬 불가 → 궤적만 비운다(재생은 계속).
|
||||
let offsetSec = 0;
|
||||
let offsetKnown = true;
|
||||
for (let i = 0; i < index; i++) {
|
||||
const d = s.videoDurations[i];
|
||||
if (d == null) { offsetKnown = false; break; }
|
||||
offsetSec += d;
|
||||
}
|
||||
|
||||
if (!offsetKnown) {
|
||||
console.warn(`[geo] 세그먼트 ${index + 1} 오프셋 계산 불가(영상 길이 측정 실패) — 드론 궤적 미표시`);
|
||||
set({ frames: [], videoIndex: index });
|
||||
return file;
|
||||
}
|
||||
|
||||
const frames = await parseDroneFrames(s.folderFiles, s.baseName, {
|
||||
offsetSec,
|
||||
durationSec: s.videoDurations[index],
|
||||
});
|
||||
console.log(
|
||||
`[geo] 세그먼트 ${index + 1}/${s.videoFiles.length} 전환 — 로그 ${offsetSec.toFixed(1)}s~ 정렬, ${frames.length}행`,
|
||||
);
|
||||
set({ frames, videoIndex: index });
|
||||
return file;
|
||||
},
|
||||
|
||||
setPoiOverride: (title, ov) =>
|
||||
set((s) => {
|
||||
const poiOverrides = { ...s.poiOverrides, [title]: ov };
|
||||
|
||||
@@ -32,6 +32,8 @@ export interface GeoPoint {
|
||||
type: 'poi' | 'station';
|
||||
/** 원본 CSV 속성(라벨 클릭 팝업에 표시). 순서 보존 위해 배열. */
|
||||
props?: { k: string; v: string }[];
|
||||
/** 라벨 표시 색(CSS). KML 텍스트박스_색상/IconStyle 유래. 없으면 기본색. */
|
||||
labelColor?: string;
|
||||
}
|
||||
|
||||
/** 의미상 별칭 (POI = GeoPoint, Station = GeoPoint with type==='station') */
|
||||
@@ -152,6 +154,17 @@ export interface RouteMeta {
|
||||
structures?: RouteStructure[];
|
||||
}
|
||||
|
||||
/** 영상 파일(djmd 트랙)에서 자동 감지한 드론 카메라 정보 */
|
||||
export interface DetectedCameraInfo {
|
||||
/** 카메라 모델 (예: "DJI ZenmuseP1") */
|
||||
model: string;
|
||||
/** 35mm 환산 초점거리(mm) — djmd 필드 8.6(0.1mm 단위) 유래 */
|
||||
focalLen35?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
fps?: number;
|
||||
}
|
||||
|
||||
/** loadFolderGeoData 파싱 결과 (v2.0 데이터 형상) */
|
||||
export interface FolderGeoData {
|
||||
videoFile: File | null;
|
||||
@@ -170,4 +183,14 @@ export interface FolderGeoData {
|
||||
poiOverrides: PoiOverrideMap;
|
||||
/** KMZ(POI·구조물 원본)가 없거나 비어 있음 → 데이터 누락(재구축 필요). 측점·드론은 별개. */
|
||||
kmzMissing: boolean;
|
||||
/** 폴더 내 영상 파일 전체(이름 오름차순) — 분할본 연속재생용. videoFile === videoFiles[0]. */
|
||||
videoFiles: File[];
|
||||
/** videoFiles 와 짝을 이루는 각 영상 길이(초). 메타데이터 측정 실패 시 null. */
|
||||
videoDurations: (number | null)[];
|
||||
/** 폴더 원본 파일 전체 — 세그먼트 전환 시 드론 CSV 재파싱용. */
|
||||
folderFiles: File[];
|
||||
/** 영상 djmd 트랙에서 감지한 카메라 정보 (DJI 아닌 영상이면 null). */
|
||||
cameraInfo: DetectedCameraInfo | null;
|
||||
/** 폴더의 `<영상 base>.camera.json` 카메라 파라미터 (없으면 null). 최우선 적용. */
|
||||
cameraJson: Record<string, number> | null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* DJI 영상 카메라 메타데이터 자동 감지
|
||||
*
|
||||
* DJI 드론 MP4 에는 'djmd' 데이터 트랙(프레임별 protobuf 텔레메트리)이 mdat 에
|
||||
* 비디오 프레임과 인터리브되어 있다. 파일 앞부분(8MB)에서 첫 djmd 샘플을 찾아 추출:
|
||||
* - 카메라 모델 문자열 (필드 1.1.2, 예: "DJI ZenmuseP1")
|
||||
* - 렌즈 초점거리 (필드 8.6, 0.1mm 단위 35mm 환산)
|
||||
* - 해상도/fps (필드 1.7/1.6/1.8)
|
||||
*
|
||||
* 실측 검증(2026-07-09, 제주 데이터): DJI_20260629121835_0002.MP4 →
|
||||
* model "DJI ZenmuseP1", 8.6=299(=29.9mm — DL 24mm 렌즈 × 4K 비디오 크롭 ≈1.25),
|
||||
* 3840×2160, 59.94fps. (djmd 필드 6.4/6.5 는 위경도 라디안, 6.6 은 타원체고 mm)
|
||||
*/
|
||||
|
||||
import type { DetectedCameraInfo } from '../types/geo';
|
||||
|
||||
const HEAD_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
function readVarint(b: Uint8Array, i: number): [number, number] | null {
|
||||
let v = 0, s = 0;
|
||||
while (i < b.length && (b[i] & 0x80)) {
|
||||
v += (b[i] & 0x7f) * 2 ** s;
|
||||
s += 7; i++;
|
||||
if (s > 42) return null;
|
||||
}
|
||||
if (i >= b.length) return null;
|
||||
v += (b[i] & 0x7f) * 2 ** s;
|
||||
return [v, i + 1];
|
||||
}
|
||||
|
||||
/** [start,end) 구간을 protobuf 필드로 파싱해 경로("8.6")→값 수집. 깨진 구간은 조용히 중단. */
|
||||
function parseFields(
|
||||
b: Uint8Array, dv: DataView,
|
||||
start: number, end: number,
|
||||
prefix: string, out: Map<string, number>,
|
||||
): void {
|
||||
let i = start;
|
||||
while (i < end) {
|
||||
const kv = readVarint(b, i);
|
||||
if (!kv) return;
|
||||
const [key, ni] = kv;
|
||||
i = ni;
|
||||
const tag = key >> 3, wire = key & 7;
|
||||
if (tag === 0 || tag > 60) return;
|
||||
const path = prefix + tag;
|
||||
if (wire === 0) {
|
||||
const r = readVarint(b, i); if (!r) return;
|
||||
out.set(path, r[0]); i = r[1];
|
||||
} else if (wire === 5) {
|
||||
if (i + 4 > end) return;
|
||||
out.set(path, dv.getFloat32(i, true)); i += 4;
|
||||
} else if (wire === 1) {
|
||||
if (i + 8 > end) return;
|
||||
out.set(path, dv.getFloat64(i, true)); i += 8;
|
||||
} else if (wire === 2) {
|
||||
const r = readVarint(b, i); if (!r) return;
|
||||
const [len, p] = r;
|
||||
if (p + len > end) return;
|
||||
// 하위 메시지 시도 — 문자열/바이너리면 내부에서 알아서 중단됨(경로 prefix 로 충돌 없음)
|
||||
parseFields(b, dv, p, p + len, path + '.', out);
|
||||
i = p + len;
|
||||
} else return;
|
||||
}
|
||||
}
|
||||
|
||||
/** 영상 File 앞부분에서 DJI djmd 카메라 정보를 감지한다. DJI 영상이 아니면 null. */
|
||||
export async function detectCameraInfo(file: File): Promise<DetectedCameraInfo | null> {
|
||||
try {
|
||||
const buf = new Uint8Array(await file.slice(0, HEAD_BYTES).arrayBuffer());
|
||||
const dv = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
|
||||
|
||||
// 모델 문자열: 0x12 <len> "DJI ..." (필드 1.1.2)
|
||||
let m = -1;
|
||||
for (let i = 2; i < buf.length - 4; i++) {
|
||||
if (buf[i] === 0x44 && buf[i + 1] === 0x4a && buf[i + 2] === 0x49 && buf[i + 3] === 0x20 &&
|
||||
buf[i - 2] === 0x12 && buf[i - 1] >= 4 && buf[i - 1] <= 40) { m = i; break; }
|
||||
}
|
||||
if (m < 0) return null;
|
||||
const model = new TextDecoder().decode(buf.slice(m, m + buf[m - 1]));
|
||||
|
||||
// 샘플 시작(최상위 필드1, 0x0a <varint len> 0x0a ...)을 모델 문자열에서 역방향 탐색
|
||||
let sampleStart = -1;
|
||||
for (let back = 4; back <= 16; back++) {
|
||||
const i = m - 2 - back;
|
||||
if (i < 0) break;
|
||||
if (buf[i] !== 0x0a) continue;
|
||||
const r = readVarint(buf, i + 1);
|
||||
if (!r) continue;
|
||||
const [len, p] = r;
|
||||
if (buf[p] === 0x0a && p < m && p + len > m && p + len <= buf.length) { sampleStart = i; break; }
|
||||
}
|
||||
if (sampleStart < 0) return { model };
|
||||
|
||||
const out = new Map<string, number>();
|
||||
parseFields(buf, dv, sampleStart, Math.min(buf.length, sampleStart + 512), '', out);
|
||||
|
||||
const info: DetectedCameraInfo = { model };
|
||||
const focal10 = out.get('8.6');
|
||||
if (focal10 !== undefined && focal10 > 80 && focal10 < 3000) info.focalLen35 = focal10 / 10; // 8~300mm 만 신뢰
|
||||
const w = out.get('1.7'), h = out.get('1.6'), fps = out.get('1.8');
|
||||
if (w && w > 100) info.width = w;
|
||||
if (h && h > 100) info.height = h;
|
||||
if (typeof fps === 'number' && fps > 1 && fps < 1000) info.fps = Math.round(fps * 100) / 100;
|
||||
return info;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+315
-39
@@ -33,6 +33,7 @@ import type {
|
||||
PoiOverrideMap,
|
||||
} from '../types/geo';
|
||||
import { stationOrder, getWorldOrigin } from './geoSearch';
|
||||
import { detectCameraInfo } from './cameraMeta';
|
||||
import { unzipSync, strFromU8 } from 'fflate';
|
||||
|
||||
// ── CSV 파싱 헬퍼 ─────────────────────────────────────────────────────
|
||||
@@ -120,11 +121,33 @@ function findBuildingFile(files: File[], keyword: string): File | null {
|
||||
|
||||
const VIDEO_EXT = /\.(mp4|webm)$/i;
|
||||
|
||||
/** 영상 파일 찾기 (mp4/webm, building 제외). */
|
||||
/** 폴더 내 모든 영상 파일 (이름순 정렬 — DJI 분할본 _0002, _0003… 순서 보장). */
|
||||
export function findVideoFiles(files: File[]): File[] {
|
||||
return files
|
||||
.filter((f) => !isInBuilding(f) && VIDEO_EXT.test(baseNameOf(relPath(f))))
|
||||
.sort((a, b) => baseNameOf(relPath(a)).localeCompare(baseNameOf(relPath(b))));
|
||||
}
|
||||
|
||||
/** 영상 파일 찾기 (mp4/webm, building 제외). 여러 개면 이름순 첫 번째. */
|
||||
export function findVideoFile(files: File[]): File | null {
|
||||
return (
|
||||
files.find((f) => !isInBuilding(f) && VIDEO_EXT.test(baseNameOf(relPath(f)))) ?? null
|
||||
);
|
||||
return findVideoFiles(files)[0] ?? null;
|
||||
}
|
||||
|
||||
/** 영상 File 의 재생 길이(초) — 메타데이터만 읽는다(전체 로드 없음). 실패 시 null. */
|
||||
export function probeVideoDuration(file: File): Promise<number | null> {
|
||||
return new Promise((resolve) => {
|
||||
const url = URL.createObjectURL(file);
|
||||
const v = document.createElement('video');
|
||||
v.preload = 'metadata';
|
||||
const done = (d: number | null): void => {
|
||||
URL.revokeObjectURL(url);
|
||||
v.removeAttribute('src');
|
||||
resolve(d);
|
||||
};
|
||||
v.onloadedmetadata = () => done(isFinite(v.duration) ? v.duration : null);
|
||||
v.onerror = () => done(null);
|
||||
v.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
/** 영상 파일명에서 base(확장자 제거) 추출. */
|
||||
@@ -133,17 +156,44 @@ export function deriveBaseName(videoFile: File | null): string | null {
|
||||
return baseNameOf(relPath(videoFile)).replace(VIDEO_EXT, '');
|
||||
}
|
||||
|
||||
// ── 파서: 드론 프레임 (변경 없음) ─────────────────────────────────────
|
||||
// ── 파서: 드론 프레임 ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 드론 프레임 CSV 파싱 (UTF-8 BOM, 헤더 이름 인덱스).
|
||||
* 헤더: frame_cnt,latitude,longitude,altitude,yaw,pitch,roll,focal_len
|
||||
* DJI 비행로그(시간 기반) → 프레임 환산용 가상 fps.
|
||||
* frame = 영상 상대시간(초) × 이 값. 소비측(effectiveFps = maxFrame ÷ 영상길이)이
|
||||
* 같은 값을 역산해 시간으로 되돌리므로 상수 자체는 임의여도 정합이 유지된다.
|
||||
* 59.94 는 이 데이터셋(DJI 4K60) 실제 fps 와 일치시켜 HUD 표기도 자연스럽게 한 것.
|
||||
*/
|
||||
const DJI_VIRTUAL_FPS = 59.94;
|
||||
|
||||
/** 영상 세그먼트 창 — DJI 시간 기반 로그를 특정 영상 구간에 정렬할 때 사용. */
|
||||
export interface SegmentWindow {
|
||||
/** 전체 녹화 기준 이 영상의 시작 오프셋(초). 첫 세그먼트는 0. */
|
||||
offsetSec: number;
|
||||
/** 이 영상의 길이(초). 미상(null)이면 클리핑 없이 전체 로그를 쓴다. */
|
||||
durationSec: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 드론 프레임 CSV 파싱 (UTF-8 BOM, 헤더 이름 인덱스). 두 형식 지원:
|
||||
*
|
||||
* ① 프레임 기반(회덕 v2.0): frame_cnt,latitude,longitude,altitude,yaw,pitch,roll,focal_len
|
||||
* ② 시간 기반(DJI 비행로그, 제주): time(millisecond),latitude,longitude,…,
|
||||
* altitude_above_seaLevel/compass_heading/gimbal_heading(degrees) 등.
|
||||
* - 첫 행 time = 녹화 시작으로 보고(로그 폭 ≈ 분할영상 총길이 검증됨),
|
||||
* frame = ((time−t0)/1000 − offsetSec) × DJI_VIRTUAL_FPS 로 환산.
|
||||
* - 자세는 카메라 방향인 gimbal_* 우선, 없으면 기체 compass/pitch/roll 폴백.
|
||||
* - focal_len 이 없어 35mm 환산 24mm(DJI 광각 기본)로 둔다 → 카메라 파라미터 UI로 보정.
|
||||
* - segmentWindow.durationSec 가 있으면 해당 영상 구간 밖 행을 버린다
|
||||
* (분할 영상에서 effectiveFps 자기보정이 성립하려면 필수).
|
||||
*
|
||||
* 루트(building 제외)의 <base>.csv 를 식별한다. base 가 없으면 루트 .csv 중
|
||||
* POI 가 아닌 첫 파일을 사용한다(영상명 비의존 폴백).
|
||||
*/
|
||||
export async function parseDroneFrames(
|
||||
files: File[],
|
||||
baseName: string | null,
|
||||
segmentWindow: SegmentWindow | null = null,
|
||||
): Promise<DroneFrame[]> {
|
||||
const rootCsv = files.filter((f) => {
|
||||
if (isInBuilding(f)) return false;
|
||||
@@ -164,6 +214,51 @@ export async function parseDroneFrames(
|
||||
|
||||
const fi = makeFieldIndexer(rows[0]);
|
||||
|
||||
// ② 시간 기반(DJI 비행로그) — time(millisecond) 헤더로 감지.
|
||||
const iTime = fi('time(millisecond)');
|
||||
if (iTime >= 0) {
|
||||
const iLat = fi('latitude');
|
||||
const iLon = fi('longitude');
|
||||
const iAlt = fi('altitude_above_seaLevel(meter)');
|
||||
const iYaw = fi('gimbal_heading(degrees)') >= 0 ? fi('gimbal_heading(degrees)') : fi('compass_heading(degrees)');
|
||||
const iPitch = fi('gimbal_pitch(degrees)') >= 0 ? fi('gimbal_pitch(degrees)') : fi('pitch(degrees)');
|
||||
const iRoll = fi('gimbal_roll(degrees)') >= 0 ? fi('gimbal_roll(degrees)') : fi('roll(degrees)');
|
||||
|
||||
const raw = rows
|
||||
.slice(1)
|
||||
.map((r) => ({
|
||||
timeMs: parseFloat(cell(r, iTime)),
|
||||
lat: parseFloat(cell(r, iLat)),
|
||||
lon: parseFloat(cell(r, iLon)),
|
||||
altitude: parseFloat(cell(r, iAlt)),
|
||||
yaw: parseFloat(cell(r, iYaw)),
|
||||
pitch: parseFloat(cell(r, iPitch)),
|
||||
roll: parseFloat(cell(r, iRoll)) || 0,
|
||||
focalLen: 24,
|
||||
}))
|
||||
.filter((f) => !isNaN(f.lat) && !isNaN(f.timeMs));
|
||||
if (!raw.length) return [];
|
||||
|
||||
const t0 = raw[0].timeMs;
|
||||
const offsetSec = segmentWindow?.offsetSec ?? 0;
|
||||
const durationSec = segmentWindow?.durationSec ?? null;
|
||||
const maxFrame = durationSec != null ? durationSec * DJI_VIRTUAL_FPS : Infinity;
|
||||
|
||||
const frames = raw
|
||||
.map(({ timeMs, ...rest }) => ({
|
||||
...rest,
|
||||
frame: Math.round(((timeMs - t0) / 1000 - offsetSec) * DJI_VIRTUAL_FPS),
|
||||
}))
|
||||
.filter((f) => f.frame >= 0 && f.frame <= maxFrame);
|
||||
|
||||
console.log(
|
||||
`[geo] DJI 시간기반 로그: ${raw.length}행 중 ${frames.length}행 사용 ` +
|
||||
`(구간 ${offsetSec.toFixed(1)}s~${durationSec != null ? (offsetSec + durationSec).toFixed(1) : '끝'}s, 가상fps ${DJI_VIRTUAL_FPS})`,
|
||||
);
|
||||
return frames;
|
||||
}
|
||||
|
||||
// ① 프레임 기반(회덕 v2.0)
|
||||
return rows
|
||||
.slice(1)
|
||||
.map((r) => ({
|
||||
@@ -290,48 +385,91 @@ function childLocal(el: Element, local: string): Element | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** KMZ/KML 파싱 — 있으면 { pois, structures }, 없거나 실패 시 null.
|
||||
* bare .kml(구글 직접 다운로드) 우선, 없으면 .kmz(zip) 해제. */
|
||||
/** 한국어 색상명 → CSS 색 (KML 텍스트박스_색상 속성). 어두운 색은 렌더 시 밝은 외곽선 처리. */
|
||||
const KOR_LABEL_COLORS: Record<string, string> = {
|
||||
검정: '#1f2937', 흰색: '#ffffff', 회색: '#9ca3af',
|
||||
빨강: '#ef4444', 주황: '#fb923c', 노랑: '#facc15',
|
||||
연두: '#a3e635', 초록: '#22c55e', 하늘: '#38bdf8',
|
||||
파랑: '#3b82f6', 남색: '#3730a3', 보라: '#a855f7', 분홍: '#f472b6',
|
||||
};
|
||||
|
||||
function koreanColorToCss(v?: string): string | undefined {
|
||||
if (!v) return undefined;
|
||||
const key = v.trim();
|
||||
if (KOR_LABEL_COLORS[key]) return KOR_LABEL_COLORS[key];
|
||||
for (const k in KOR_LABEL_COLORS) if (key.includes(k)) return KOR_LABEL_COLORS[k];
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Placemark 인라인 IconStyle 색 — KML aabbggrr → CSS #rrggbb. */
|
||||
function inlineIconColor(pm: Element): string | undefined {
|
||||
const icon = pm.getElementsByTagName('IconStyle')[0];
|
||||
const c = icon?.getElementsByTagName('color')[0]?.textContent?.trim();
|
||||
if (!c || !/^[0-9a-fA-F]{8}$/.test(c)) return undefined;
|
||||
const bb = c.slice(2, 4), gg = c.slice(4, 6), rr = c.slice(6, 8);
|
||||
return `#${rr}${gg}${bb}`.toLowerCase();
|
||||
}
|
||||
|
||||
/** KMZ/KML 파싱 — 있으면 { pois, structures, stations }, 없거나 실패 시 null.
|
||||
* bare .kml(구글 직접 다운로드) 우선, 없으면 .kmz(zip) 해제.
|
||||
*
|
||||
* 속성 소스 2종을 모두 props 로 수집한다:
|
||||
* - description CDATA HTML 표 (구글어스/지오코딩 산출물 — 회덕 v2.0)
|
||||
* - ExtendedData/SchemaData/SimpleData (CAD·GIS 내보내기 — 제주 도로 KML)
|
||||
*
|
||||
* 측점(STA): 이름이 도로 체이니지(`6+998`) 형식이거나 STA 속성이 있는 Point 는
|
||||
* POI 가 아니라 측점(type='station')으로 수집한다. km 속성(단위 km) 우선, 없으면
|
||||
* 이름에서 미터값을 파싱해 기존 철도식 제목(`6k998`)으로 변환 — stationOrder/stationKm
|
||||
* 등 측점 파이프라인(스테이션바·노선패널·검색)과 그대로 호환된다. */
|
||||
export async function parseKmz(
|
||||
files: File[],
|
||||
): Promise<{ pois: GeoPoint[]; structures: RouteStructure[] } | null> {
|
||||
const kmlFile = files.find(
|
||||
): Promise<{ pois: GeoPoint[]; structures: RouteStructure[]; stations: GeoPoint[] } | null> {
|
||||
// 폴더 내 모든 KML/KMZ 를 파싱해 병합한다 (예: 제주 — 측점 KML + 지장물 KML 별도 파일).
|
||||
const kmlFiles = files.filter(
|
||||
(f) => !isInBuilding(f) && /\.kml$/i.test(baseNameOf(relPath(f))),
|
||||
);
|
||||
const kmzFile = files.find(
|
||||
const kmzFiles = files.filter(
|
||||
(f) => !isInBuilding(f) && /\.kmz$/i.test(baseNameOf(relPath(f))),
|
||||
);
|
||||
if (!kmlFile && !kmzFile) return null;
|
||||
if (!kmlFiles.length && !kmzFiles.length) return null;
|
||||
|
||||
let kmlText: string;
|
||||
try {
|
||||
if (kmlFile) {
|
||||
kmlText = await kmlFile.text(); // 구글 KML 은 UTF-8
|
||||
} else {
|
||||
const buf = new Uint8Array(await kmzFile!.arrayBuffer());
|
||||
const kmlTexts: string[] = [];
|
||||
for (const kf of kmlFiles) {
|
||||
try {
|
||||
kmlTexts.push(await kf.text()); // 구글 KML 은 UTF-8
|
||||
} catch (e) {
|
||||
console.warn(`[KML] 읽기 실패: ${baseNameOf(relPath(kf))}`, e);
|
||||
}
|
||||
}
|
||||
for (const kf of kmzFiles) {
|
||||
try {
|
||||
const buf = new Uint8Array(await kf.arrayBuffer());
|
||||
const entries = unzipSync(buf);
|
||||
const kmlName =
|
||||
Object.keys(entries).find((n) => /(^|\/)doc\.kml$/i.test(n)) ??
|
||||
Object.keys(entries).find((n) => /\.kml$/i.test(n));
|
||||
if (!kmlName || !entries[kmlName]) return null;
|
||||
kmlText = strFromU8(entries[kmlName]);
|
||||
if (kmlName && entries[kmlName]) kmlTexts.push(strFromU8(entries[kmlName]));
|
||||
} catch (e) {
|
||||
console.warn(`[KMZ] 읽기 실패: ${baseNameOf(relPath(kf))}`, e);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[KMZ/KML] 읽기 실패 → CSV 폴백', e);
|
||||
return null;
|
||||
}
|
||||
|
||||
const doc = new DOMParser().parseFromString(kmlText, 'application/xml');
|
||||
if (doc.getElementsByTagName('parsererror').length) return null;
|
||||
if (!kmlTexts.length) return null;
|
||||
|
||||
const pois: GeoPoint[] = [];
|
||||
const structures: RouteStructure[] = [];
|
||||
const stations: GeoPoint[] = [];
|
||||
let nStruct = 0;
|
||||
|
||||
const handlePlacemark = (pm: Element, folder: string): void => {
|
||||
const name = childLocal(pm, 'name')?.textContent?.trim() ?? '';
|
||||
const desc = childLocal(pm, 'description')?.textContent ?? '';
|
||||
const props = parseKmlDescProps(desc);
|
||||
// ExtendedData/SchemaData/SimpleData 속성 (CAD·GIS 내보내기) — HTML 표 props 와 통합.
|
||||
for (const sd of Array.from(pm.getElementsByTagName('SimpleData'))) {
|
||||
const k = sd.getAttribute('name') ?? '';
|
||||
const v = sd.textContent?.trim() ?? '';
|
||||
if (k) props.push({ k, v });
|
||||
}
|
||||
const pget = (k: string): string | undefined => props.find((p) => p.k === k)?.v;
|
||||
|
||||
// 좌표: <coordinates> lon,lat[,alt] 우선, 없으면 속성 lat/lon.
|
||||
@@ -347,6 +485,32 @@ export async function parseKmz(
|
||||
}
|
||||
if (isNaN(lat) || isNaN(lon) || lat < 33 || lat > 39 || lon < 124 || lon > 132) return;
|
||||
|
||||
// 새 포맷: 구글어스 CSV 변환 지장물 KML — Schema(타입/타입상세/이름/연장/텍스트박스_색상/높이),
|
||||
// 폴더 구분 없이 Placemark 직속. '타입' 키 존재로 감지한다(회덕 desc표/STA 스키마엔 없음).
|
||||
// 좌표는 SimpleData X/Y(저정밀)가 아니라 Point coordinates(고정밀)를 쓴다(위에서 이미 추출).
|
||||
if (pget('타입') !== undefined) {
|
||||
const title = pget('이름') || name;
|
||||
if (!title) return;
|
||||
const category = pget('타입') || '지장물';
|
||||
const lenM = parseFloat(pget('연장') ?? '');
|
||||
// 라벨 색: 텍스트박스_색상(한국어명) 우선, 없으면 인라인 IconStyle 색.
|
||||
const labelColor = koreanColorToCss(pget('텍스트박스_색상')) ?? inlineIconColor(pm);
|
||||
// z: '높이'는 표고가 아니라 구조물 높이라 사용하지 않음(팝업 props 로만 노출).
|
||||
// 지면표고 미상(0) → 오버레이 기본(드론고도−이격) 모드 및 중심선 z 폴백으로 표시.
|
||||
pois.push({
|
||||
title, category, lat, lon, z: 0, type: 'poi',
|
||||
...(labelColor ? { labelColor } : {}),
|
||||
props,
|
||||
});
|
||||
// 교량/터널은 스테이션바·노선패널 마크용 구조물로도 등록.
|
||||
if (category.includes('교량')) {
|
||||
structures.push({ id: `교량-${nStruct++}`, type: 'bridge', category: '교량', name: title, lat, lon, ...(isFinite(lenM) ? { lengthM: lenM } : {}), props });
|
||||
} else if (category.includes('터널')) {
|
||||
structures.push({ id: `터널-${nStruct++}`, type: 'tunnel', category: '터널', name: title, lat, lon, ...(isFinite(lenM) ? { lengthM: lenM } : {}), props });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const lenNum = parseFloat(pget('연장(m)') ?? '');
|
||||
if (folder.includes('교량')) {
|
||||
structures.push({ id: `교량-${nStruct++}`, type: 'bridge', category: '교량', name: pget('구분') || name, lat, lon, ...(isNaN(lenNum) ? {} : { lengthM: lenNum }), ...(pget('시설종별') ? { grade: pget('시설종별') } : {}), props });
|
||||
@@ -358,6 +522,21 @@ export async function parseKmz(
|
||||
const title = pget('출입문번호') || name;
|
||||
if (title) pois.push({ title, category: '출입문', lat, lon, z: parseFloat(pget('z') ?? pget('절대고도') ?? '0') || 0, type: 'poi', props });
|
||||
} else {
|
||||
// 도로 체이니지 측점 (예: "0+100" 또는 STA 속성) → type='station' 으로 수집.
|
||||
// km 속성(단위 km) 우선, 없으면 이름의 "k+m" 파싱. 제목은 철도식 NkNNN 으로 변환.
|
||||
const staName = pget('STA') || name;
|
||||
const staMatch = staName.match(/^(\d+)\+(\d+)$/);
|
||||
if (staMatch || pget('STA') !== undefined) {
|
||||
const kmProp = parseFloat(pget('km') ?? '');
|
||||
const meters = isFinite(kmProp)
|
||||
? Math.round(kmProp * 1000)
|
||||
: staMatch ? parseInt(staMatch[1], 10) * 1000 + parseInt(staMatch[2], 10) : NaN;
|
||||
if (!isFinite(meters)) return;
|
||||
const title = `${Math.floor(meters / 1000)}k${String(meters % 1000).padStart(3, '0')}`;
|
||||
// z 미상(KML 고도 0) — 소비측(loadFolderGeoData)에서 드론 고도 기반으로 보정.
|
||||
stations.push({ title, category: '측점', lat, lon, z: 0, type: 'station', props });
|
||||
return;
|
||||
}
|
||||
// 02)지장물 등 = 지오코딩 POI. 모든 POI 는 영상에 표출(철도역 포함).
|
||||
if (!name) return;
|
||||
const src = pget('source');
|
||||
@@ -384,9 +563,52 @@ export async function parseKmz(
|
||||
}
|
||||
}
|
||||
};
|
||||
if (doc.documentElement) walk(doc.documentElement, '');
|
||||
for (const text of kmlTexts) {
|
||||
const doc = new DOMParser().parseFromString(text, 'application/xml');
|
||||
if (doc.getElementsByTagName('parsererror').length) {
|
||||
console.warn('[KML] XML 파싱 실패 — 해당 파일 건너뜀');
|
||||
continue;
|
||||
}
|
||||
if (doc.documentElement) walk(doc.documentElement, '');
|
||||
}
|
||||
|
||||
return { pois, structures };
|
||||
return { pois, structures, stations };
|
||||
}
|
||||
|
||||
// ── 카메라 파라미터 파일 (<base>.camera.json) ─────────────────────────
|
||||
|
||||
/**
|
||||
* 폴더에서 카메라 파라미터 파일을 읽는다. 서버 '카메라값 저장'(PUT /api/camera/:videoId)이
|
||||
* 영상 옆에 만든 `<영상 base>.camera.json`(또는 `<영상 base>.json`) 형식:
|
||||
* { "camera": { focalLen, sensorW, sensorH, yawOffset, pitch, roll, offX, offY, offZ, geoidOffset, cx0, cy0 }, ... }
|
||||
* 폴더 내 어떤 영상의 base 와도 매칭 허용(분할본 공용). 숫자 필드만 통과시킨다.
|
||||
*/
|
||||
export async function parseCameraJson(
|
||||
files: File[],
|
||||
videoFiles: File[],
|
||||
): Promise<Record<string, number> | null> {
|
||||
const bases = videoFiles.map((v) => baseNameOf(relPath(v)).replace(VIDEO_EXT, '').toLowerCase());
|
||||
const file = files.find((f) => {
|
||||
if (isInBuilding(f)) return false;
|
||||
const name = baseNameOf(relPath(f)).toLowerCase();
|
||||
const m = name.match(/^(.+?)(\.camera)?\.json$/);
|
||||
if (!m) return false;
|
||||
if (/\.route\.json$|_poi_overrides\.json$/.test(name)) return false;
|
||||
return bases.includes(m[1]);
|
||||
});
|
||||
if (!file) return null;
|
||||
try {
|
||||
const obj = JSON.parse(await file.text());
|
||||
const cam = obj && typeof obj === 'object' ? (obj.camera ?? obj) : null;
|
||||
if (!cam || typeof cam !== 'object') return null;
|
||||
const out: Record<string, number> = {};
|
||||
for (const [k, v] of Object.entries(cam)) {
|
||||
if (typeof v === 'number' && isFinite(v)) out[k] = v;
|
||||
}
|
||||
return Object.keys(out).length ? out : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── route.json 보정 ───────────────────────────────────────────────────
|
||||
@@ -409,7 +631,9 @@ export async function parseRouteMeta(
|
||||
const file =
|
||||
(wantBase &&
|
||||
rootJson.find((f) => baseNameOf(relPath(f)).toLowerCase() === wantBase)) ||
|
||||
rootJson.find((f) => baseNameOf(relPath(f)).toLowerCase() === 'route.json');
|
||||
rootJson.find((f) => baseNameOf(relPath(f)).toLowerCase() === 'route.json') ||
|
||||
// 임의 접두 `<이름>.route.json` 허용 (예: 제주 중산간도로 경로 1-1.route.json)
|
||||
rootJson.find((f) => /\.route\.json$/i.test(baseNameOf(relPath(f))));
|
||||
if (!file) return null;
|
||||
|
||||
try {
|
||||
@@ -533,33 +757,80 @@ export async function loadFolderGeoData(
|
||||
): Promise<FolderGeoData> {
|
||||
const files = Array.from(input);
|
||||
|
||||
const videoFile = findVideoFile(files);
|
||||
const videoFiles = findVideoFiles(files);
|
||||
const videoFile = videoFiles[0] ?? null;
|
||||
const baseName = deriveBaseName(videoFile);
|
||||
|
||||
const [frames, stationResult, routeMeta, poiOverrides, kmz] = await Promise.all([
|
||||
parseDroneFrames(files, baseName),
|
||||
// 각 영상 길이를 메타데이터로 측정 — DJI 시간 기반 로그의 세그먼트 정렬(누적 오프셋)과
|
||||
// ended 연속재생(geoStore.loadSegment)에 쓴다. 분할본이 아니면 1개만 측정.
|
||||
const videoDurations: (number | null)[] = [];
|
||||
for (const vf of videoFiles) videoDurations.push(await probeVideoDuration(vf));
|
||||
|
||||
// 첫 세그먼트 창 — 로그는 전체 녹화(분할본 합)를 커버하므로 첫 영상 구간만 잘라 쓴다.
|
||||
const segmentWindow: SegmentWindow | null = videoFile
|
||||
? { offsetSec: 0, durationSec: videoDurations[0] }
|
||||
: null;
|
||||
if (videoFiles.length > 1) {
|
||||
console.log(
|
||||
`[geo] 분할 영상 ${videoFiles.length}개 감지 (이름 오름차순 연속재생): ` +
|
||||
videoFiles.map((vf, i) => `${baseNameOf(relPath(vf))}(${videoDurations[i]?.toFixed(1) ?? '?'}s)`).join(' → '),
|
||||
);
|
||||
}
|
||||
|
||||
const [frames, stationResult, routeMeta, poiOverrides, kmz, cameraInfo, cameraJson] = await Promise.all([
|
||||
parseDroneFrames(files, baseName, segmentWindow),
|
||||
parseStations(files),
|
||||
parseRouteMeta(files, baseName),
|
||||
parsePoiOverrides(files, baseName),
|
||||
parseKmz(files),
|
||||
videoFile ? detectCameraInfo(videoFile) : Promise.resolve(null),
|
||||
parseCameraJson(files, videoFiles),
|
||||
]);
|
||||
|
||||
if (cameraJson) {
|
||||
console.log('[camera] <base>.camera.json 발견 — 저장된 카메라 파라미터 적용:', cameraJson);
|
||||
}
|
||||
|
||||
// 감지된 초점거리를 드론 프레임 focalLen 에도 반영 (검색/투영 FOV 판정 일관성).
|
||||
if (cameraInfo?.focalLen35) {
|
||||
for (const f of frames) f.focalLen = cameraInfo.focalLen35;
|
||||
console.log(
|
||||
`[camera] ${cameraInfo.model} 감지 — focal ${cameraInfo.focalLen35}mm` +
|
||||
(cameraInfo.width ? `, ${cameraInfo.width}x${cameraInfo.height}@${cameraInfo.fps ?? '?'}fps` : ''),
|
||||
);
|
||||
} else if (cameraInfo) {
|
||||
console.log(`[camera] ${cameraInfo.model} 감지 (초점거리 정보 없음)`);
|
||||
}
|
||||
|
||||
// POI/구조물 출처: KMZ(원본)가 유일한 소스다(지장물·출입문·교량/터널/구교·철도역).
|
||||
// KMZ 정책상 필수 — 없거나 비어 있으면 "데이터 누락"으로 보고 경고만 남기고 빈 상태로 둔다
|
||||
// (CSV 폴백 없음. building/ POI·구조물 CSV는 사용하지 않음). 측점·중심선은 항상 CSV(측점)에서 온다.
|
||||
const kmzMissing = !kmz || (kmz.pois.length === 0 && kmz.structures.length === 0);
|
||||
// KMZ 파일 자체가 없을 때만 "데이터 누락"(kmzMissing=alert 대상)으로 본다.
|
||||
// 파일은 있는데 POI/구조물이 0건인 경우(예: 제주 — 노선 KML만 있고 POI 없음)는
|
||||
// 정상 데이터셋으로 취급하고 콘솔 경고만 남긴다.
|
||||
const kmzMissing = !kmz;
|
||||
if (kmzMissing) {
|
||||
console.warn(
|
||||
'[KMZ] 누락/비어있음 — POI·구조물이 표시되지 않습니다. ' +
|
||||
'[KMZ] 누락 — POI·구조물이 표시되지 않습니다. ' +
|
||||
'KMZ(원본)를 포함해 데이터를 재구축·전달하세요. (측점·드론 정보는 정상 로드)',
|
||||
);
|
||||
} else if (kmz!.pois.length === 0 && kmz!.structures.length === 0 && kmz!.stations.length === 0) {
|
||||
console.warn('[KMZ] POI·구조물·측점 0건 — 이 데이터셋엔 표출할 지물이 없습니다(드론 궤적은 무관).');
|
||||
} else {
|
||||
console.log(`[KMZ] POI ${kmz!.pois.length} · 구조물 ${kmz!.structures.length} 로드(원본)`);
|
||||
console.log(`[KMZ] POI ${kmz!.pois.length} · 구조물 ${kmz!.structures.length} · 측점 ${kmz!.stations.length} 로드(원본)`);
|
||||
}
|
||||
const pois = kmz?.pois ?? [];
|
||||
const baseStructures = kmz?.structures ?? [];
|
||||
|
||||
const stations = stationResult.stations.sort(
|
||||
// 측점 소스: CSV(01)측점 — 실측 표고 보유) 우선, 없으면 KML 체이니지 측점(STA) 사용.
|
||||
// KML 측점은 표고 미상 → z=0 그대로 둔다(미상 표식). 드론고도 기반 근사(−24m)는
|
||||
// 실제 지면과 수십 m 어긋나 라벨이 공중에 떠서 시차 불일치(카메라 전진 시 라벨 밀림)를
|
||||
// 유발했음 → 오버레이가 z=0 라벨을 '경로 표고' 평면에 앵커하는 방식으로 대체.
|
||||
let rawStations = stationResult.stations;
|
||||
if (!rawStations.length && kmz?.stations.length) {
|
||||
rawStations = kmz.stations;
|
||||
console.log(`[KMZ] 체이니지 측점 ${rawStations.length}개 로드 (표고 미상 → 오버레이 '경로 표고' 평면 앵커)`);
|
||||
}
|
||||
|
||||
const stations = rawStations.sort(
|
||||
(a, b) => stationOrder(a.title) - stationOrder(b.title),
|
||||
);
|
||||
const directionChanges = stationResult.directionChanges;
|
||||
@@ -591,5 +862,10 @@ export async function loadFolderGeoData(
|
||||
directionChanges,
|
||||
poiOverrides,
|
||||
kmzMissing,
|
||||
videoFiles,
|
||||
videoDurations,
|
||||
folderFiles: files,
|
||||
cameraInfo,
|
||||
cameraJson,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user