Files
GhiVideo_v4/client/src/utils/geoData.ts
T
b23042andClaude Fable 5 84f4921826 제주 드론 도로영상 지원: 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>
2026-07-09 20:58:49 +09:00

872 lines
38 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 클라이언트 폴더 기반 지리정보 로딩 + CSV 파싱 (v2.0 데이터 형상 전용)
*
* `<input type="file" webkitdirectory>` 로 선택된 File[] 에서 v2.0 폴더 구조를
* 식별해 영상 / 드론 CSV / 측점·POI·구조물 CSV 를 파싱한다.
*
* v2.0 폴더 구조 (영상 base = 영상파일명에서 확장자 제거):
* <base>.MP4 영상
* <base>.csv 드론 프레임 (frame_cnt,latitude,...)
* <base>_POI.csv POI (source,query_label,title,category_clean,...,lat,lon,...)
* building/01)측점.csv 측점 (측점,X좌표,...,비고,lat,lon,...)
* building/02)지장물.csv POI (명칭,...,lat,lon)
* building/03)교량.csv 구조물 bridge (구분,...,연장(m),...,lat,lon,...)
* building/04)터널.csv 구조물 tunnel (구분,...,연장(m),...,lat,lon,...)
* building/05)출입문번호.csv 출입문 (기본 표시 제외 — 아래 참고)
* building/06)구교.csv 구조물 bridge (역구간,시설물명,...,연장(m),lat,lon,...)
*
* v2.0엔 center.csv 가 없다 → 측점(01)측점)을 측점값 순으로 이어 중심선을 생성한다.
*
* 인코딩(혼재 — 자동 감지): ArrayBuffer 앞 3바이트가 UTF-8 BOM(EF BB BF)이면 UTF-8,
* 아니면 EUC-KR. 디코딩 후 잔여 BOM(U+FEFF)을 제거한다. 파일별 하드코딩 없음.
*/
import type {
DroneFrame,
GeoPoint,
CenterlinePoint,
GeoOrigin,
FolderGeoData,
RouteMeta,
RouteStructure,
DirectionChange,
PoiOverrideMap,
} from '../types/geo';
import { stationOrder, getWorldOrigin } from './geoSearch';
import { detectCameraInfo } from './cameraMeta';
import { unzipSync, strFromU8 } from 'fflate';
// ── CSV 파싱 헬퍼 ─────────────────────────────────────────────────────
export function parseCsvLine(line: string): string[] {
const result: string[] = [];
let current = '';
let inQuotes = false;
for (const ch of line) {
if (ch === '"') {
inQuotes = !inQuotes;
} else if (ch === ',' && !inQuotes) {
result.push(current.trim());
current = '';
} else {
current += ch;
}
}
result.push(current.trim());
return result;
}
/**
* ArrayBuffer → 문자열. 인코딩 자동 감지:
* 앞 3바이트가 UTF-8 BOM(EF BB BF)이면 UTF-8, 아니면 EUC-KR.
* 디코딩 후 남을 수 있는 BOM(U+FEFF)을 제거한다.
*/
export function decodeBytes(buf: ArrayBuffer): string {
const head = new Uint8Array(buf, 0, Math.min(3, buf.byteLength));
const isUtf8Bom = head.length >= 3 && head[0] === 0xef && head[1] === 0xbb && head[2] === 0xbf;
const encoding = isUtf8Bom ? 'utf-8' : 'euc-kr';
const text = new TextDecoder(encoding).decode(buf);
// 디코딩 후 잔여 BOM(U+FEFF) 제거
return text.replace(/^/, '');
}
/** File → 파싱된 행 배열. 인코딩은 BOM 기반 자동 감지. */
export async function readCsv(file: File): Promise<string[][]> {
const buf = await file.arrayBuffer();
const text = decodeBytes(buf);
return text
.split(/\r?\n/)
.filter(Boolean)
.map(parseCsvLine);
}
/** 헤더 행 → (헤더명|위치인덱스) → 값 추출기. 헤더가 깨지면 위치 인덱스로 폴백. */
function makeFieldIndexer(header: string[]): (name: string, fallback?: number) => number {
const cleaned = header.map((h) => h.trim().replace(/^/, ''));
return (name: string, fallback?: number): number => {
const i = cleaned.indexOf(name);
if (i >= 0) return i;
return fallback ?? -1;
};
}
/** 안전한 셀 접근 (인덱스 음수/범위 밖이면 ''). */
function cell(row: string[], i: number): string {
return i >= 0 && i < row.length ? row[i] : '';
}
// ── 파일 식별 헬퍼 ────────────────────────────────────────────────────
/** File 의 폴더 내 상대경로 (webkitRelativePath 우선, 없으면 name). */
function relPath(f: File): string {
return f.webkitRelativePath || f.name;
}
/** 경로의 마지막 세그먼트(파일명). */
function baseNameOf(p: string): string {
const parts = p.split('/');
return parts[parts.length - 1];
}
/** building/ 하위 파일 여부 (마지막 디렉토리 세그먼트가 building). */
function isInBuilding(f: File): boolean {
const parts = relPath(f).split('/');
return parts.length >= 2 && parts[parts.length - 2].toLowerCase() === 'building';
}
/** building/ 하위에서 파일명이 키워드를 포함하는 첫 파일. (예 '01)측점', '03)교량') */
function findBuildingFile(files: File[], keyword: string): File | null {
return files.find((f) => isInBuilding(f) && baseNameOf(relPath(f)).includes(keyword)) ?? null;
}
const VIDEO_EXT = /\.(mp4|webm)$/i;
/** 폴더 내 모든 영상 파일 (이름순 정렬 — 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 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(확장자 제거) 추출. */
export function deriveBaseName(videoFile: File | null): string | null {
if (!videoFile) return null;
return baseNameOf(relPath(videoFile)).replace(VIDEO_EXT, '');
}
// ── 파서: 드론 프레임 ─────────────────────────────────────────────────
/**
* 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 = ((timet0)/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;
const name = baseNameOf(relPath(f));
if (!/\.csv$/i.test(name)) return false;
if (/POI/i.test(name)) return false;
return true;
});
if (!rootCsv.length) return [];
// base 일치 우선, 없으면 첫 루트 csv
const droneFile =
(baseName && rootCsv.find((f) => baseNameOf(relPath(f)) === `${baseName}.csv`)) ||
rootCsv[0];
const rows = await readCsv(droneFile);
if (rows.length < 2) return [];
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) => ({
frame: parseInt(cell(r, fi('frame_cnt', 0)), 10),
lat: parseFloat(cell(r, fi('latitude', 1))),
lon: parseFloat(cell(r, fi('longitude', 2))),
altitude: parseFloat(cell(r, fi('altitude', 3))),
yaw: parseFloat(cell(r, fi('yaw', 4))),
pitch: parseFloat(cell(r, fi('pitch', 5))),
roll: parseFloat(cell(r, fi('roll', 6))),
focalLen: parseFloat(cell(r, fi('focal_len', 7))),
}))
.filter((f) => !isNaN(f.lat));
}
// ── 파서: 측점 + 방향전환점 ───────────────────────────────────────────
/** "02:05" → 125 (초). 파싱 실패 시 NaN. */
function mmssToSeconds(s: string): number {
const m = s.match(/(\d+):(\d+)/);
if (!m) return NaN;
return parseInt(m[1], 10) * 60 + parseInt(m[2], 10);
}
/**
* 측점 CSV 파싱 (building/01)측점.csv, EUC-KR).
* 헤더: 측점,X좌표,Y좌표,Z좌표,비고,lat,lon,...
* title ← 측점, lat ← lat, lon ← lon, z ← Z좌표, category='측점', type='station'
* 비고(방향전환점) 도 함께 추출한다: `방향전환점(상행->하행, 02:05)`.
*/
export async function parseStations(files: File[]): Promise<{
stations: GeoPoint[];
directionChanges: DirectionChange[];
}> {
// 측점 파일 — building/ 또는 root(영상 옆) 어디든 허용(building 폴더 삭제 대비).
const file =
findBuildingFile(files, '01)측점') ??
files.find((f) => baseNameOf(relPath(f)).includes('01)측점')) ??
files.find((f) => baseNameOf(relPath(f)).includes('측점'));
if (!file) return { stations: [], directionChanges: [] };
const rows = await readCsv(file);
if (rows.length < 2) return { stations: [], directionChanges: [] };
const fi = makeFieldIndexer(rows[0]);
const iTitle = fi('측점', 0);
// Z좌표(col 3)는 로컬좌표(≈0). 실제 표고는 Z좌표_한국(EPSG:5186, 정표고)에 있다.
// 없으면 Z좌표로 폴백.
const iZKorea = fi('Z좌표_한국');
const iZ = iZKorea >= 0 ? iZKorea : fi('Z좌표', 3);
const iNote = fi('비고', 4);
const iLat = fi('lat', 5);
const iLon = fi('lon', 6);
const stations: GeoPoint[] = [];
const directionChanges: DirectionChange[] = [];
for (const r of rows.slice(1)) {
const lat = parseFloat(cell(r, iLat));
const lon = parseFloat(cell(r, iLon));
const title = cell(r, iTitle);
if (isNaN(lat) || isNaN(lon)) continue;
stations.push({
title,
category: '측점',
lat,
lon,
z: parseFloat(cell(r, iZ)) || 0,
type: 'station',
});
// 비고: 방향전환점(상행->하행, 02:05)
const note = cell(r, iNote);
const m = note.match(/방향전환점\s*\(\s*([^->]+?)\s*->\s*([^,)]+?)\s*,\s*([\d:]+)\s*\)/);
if (m) {
const atSeconds = mmssToSeconds(m[3]);
directionChanges.push({
station: title,
from: m[1].trim(),
to: m[2].trim(),
atSeconds: isNaN(atSeconds) ? -1 : atSeconds,
});
}
}
return { stations, directionChanges };
}
// ── 파서: KMZ (원본) ─────────────────────────────────────────────────
//
// KMZ(=doc.kml zip)는 building CSV의 원본이다. 폴더가 02)지장물·03)교량·04)터널·05)출입문번호·
// 06)구교 로 1:1 대응하고, 각 Placemark 가 좌표 + description HTML표(속성)를 담는다.
// → KMZ 에서 직접 POI/구조물을 추출하면 CSV 추출 중복이 사라진다. (측점(01)은 KMZ에 없음 → CSV 유지)
/** HTML 태그 제거 + 엔티티 디코드 + 트림. */
function stripHtml(s: string): string {
return s
.replace(/<[^>]*>/g, ' ')
.replace(/&gt;/g, '>').replace(/&lt;/g, '<').replace(/&amp;/g, '&')
.replace(/&nbsp;/g, ' ').replace(/&quot;/g, '"').replace(/&#39;/g, "'")
.replace(/\s+/g, ' ').trim();
}
/**
* description CDATA(HTML표) → 속성 배열. 행/셀 단위로 견고하게 파싱.
* 구글어스 재저장본은 `<tbody>`, `</b>` 뒤 `<br>`, `&gt;` 엔티티 등 변형이 있어
* `<tr>` 안의 두 `<td>` 셀에서 태그를 벗겨 (키, 값)으로 만든다(옛/새 형식 모두 처리).
*/
function parseKmlDescProps(descHtml: string): { k: string; v: string }[] {
const out: { k: string; v: string }[] = [];
const trRe = /<tr[^>]*>([\s\S]*?)<\/tr>/gi;
let tr: RegExpExecArray | null;
while ((tr = trRe.exec(descHtml))) {
const tds = [...tr[1].matchAll(/<td[^>]*>([\s\S]*?)<\/td>/gi)].map((m) => stripHtml(m[1]));
if (tds.length >= 2 && tds[0]) out.push({ k: tds[0], v: tds[1] });
}
return out;
}
/** 폴더 내 직계 자식 중 localName 일치 첫 요소. */
function childLocal(el: Element, local: string): Element | null {
for (const c of Array.from(el.children)) if (c.localName === local) return c;
return null;
}
/** 한국어 색상명 → 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[]; stations: GeoPoint[] } | null> {
// 폴더 내 모든 KML/KMZ 를 파싱해 병합한다 (예: 제주 — 측점 KML + 지장물 KML 별도 파일).
const kmlFiles = files.filter(
(f) => !isInBuilding(f) && /\.kml$/i.test(baseNameOf(relPath(f))),
);
const kmzFiles = files.filter(
(f) => !isInBuilding(f) && /\.kmz$/i.test(baseNameOf(relPath(f))),
);
if (!kmlFiles.length && !kmzFiles.length) return null;
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]) kmlTexts.push(strFromU8(entries[kmlName]));
} catch (e) {
console.warn(`[KMZ] 읽기 실패: ${baseNameOf(relPath(kf))}`, e);
}
}
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.
let lat = NaN, lon = NaN;
const coordEl = pm.getElementsByTagName('coordinates')[0];
if (coordEl?.textContent) {
const p = coordEl.textContent.trim().split(/[\s,]+/).map(Number);
if (p.length >= 2 && isFinite(p[0]) && isFinite(p[1])) { lon = p[0]; lat = p[1]; }
}
if (isNaN(lat) || isNaN(lon)) {
lat = parseFloat(pget('lat') ?? pget('latitude') ?? '');
lon = parseFloat(pget('lon') ?? pget('longitude') ?? '');
}
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 });
} else if (folder.includes('터널')) {
structures.push({ id: `터널-${nStruct++}`, type: 'tunnel', category: '터널', name: pget('구분') || name, lat, lon, ...(isNaN(lenNum) ? {} : { lengthM: lenNum }), ...(pget('시설종별') ? { grade: pget('시설종별') } : {}), props });
} else if (folder.includes('구교')) {
structures.push({ id: `구교-${nStruct++}`, type: 'bridge', category: '구교', name: pget('시설물명') || name, lat, lon, ...(isNaN(lenNum) ? {} : { lengthM: lenNum }), props });
} else if (folder.includes('출입문')) {
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');
const cat = pget('category_clean') || '지장물';
// 라벨: KML '구분' → 'title' → (없으면) placemark name 순.
const label = pget('구분') || pget('title') || name;
pois.push({ title: label, category: cat, lat, lon, z: parseFloat(pget('z') ?? pget('Z좌표') ?? pget('절대고도') ?? '0') || 0, type: 'poi', props });
// 철도역(KAKAO_RAIL)은 영상 POI 에 더해 '역사'로 하단 스테이션바에도 표출(lat/lon 으로 배치).
if (src === 'KAKAO_RAIL' || cat === '철도역' || cat === '역사') {
structures.push({ id: `역사-${nStruct++}`, type: 'station', category: '역사', name: label, lat, lon, props });
}
}
};
// 폴더 트리 재귀 — Placemark 가 직접 속한 (가장 안쪽) 폴더명을 folder 로 전달.
const walk = (el: Element, folder: string): void => {
for (const c of Array.from(el.children)) {
if (c.localName === 'Folder') {
walk(c, childLocal(c, 'name')?.textContent?.trim() ?? folder);
} else if (c.localName === 'Document') {
walk(c, folder);
} else if (c.localName === 'Placemark') {
handlePlacemark(c, folder);
}
}
};
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, 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 보정 ───────────────────────────────────────────────────
/**
* 폴더 보조 파일 route.json 파싱 (UTF-8, JSON).
* `<base>.route.json` 우선(case-insensitive), 없으면 `route.json`.
* building/ 제외, 루트 파일만. 파싱 실패 시 null.
*/
export async function parseRouteMeta(
files: File[],
baseName: string | null,
): Promise<RouteMeta | null> {
const rootJson = files.filter(
(f) => !isInBuilding(f) && /\.json$/i.test(baseNameOf(relPath(f))),
);
if (!rootJson.length) return null;
const wantBase = baseName ? `${baseName}.route.json`.toLowerCase() : null;
const file =
(wantBase &&
rootJson.find((f) => baseNameOf(relPath(f)).toLowerCase() === wantBase)) ||
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 {
const text = await file.text();
return JSON.parse(text) as RouteMeta;
} catch {
return null;
}
}
/** 이름 정규화(괄호 이하 제거) — route.json ↔ CSV 구조물 매칭용. */
function structBaseName(name: string): string {
return name.replace(/\s*[(].*$/, '').trim();
}
/**
* CSV 유래 구조물에 route.json structures 의 offset/station/이정 등을 보정(augment)한다.
* name 이 일치(괄호 이하 제거 후 부분일치)하는 항목의 값으로 override 하고,
* CSV 에 없는 구조물(route.json 전용)은 그대로 추가한다.
*/
export function mergeStructures(
csvStructures: RouteStructure[],
meta: RouteMeta | null,
): RouteStructure[] {
const metaList = meta?.structures;
if (!metaList || !metaList.length) return csvStructures;
const result = csvStructures.map((s) => ({ ...s }));
const used = new Set<number>();
for (const m of metaList) {
const mBase = structBaseName(m.name);
const idx = result.findIndex((s) => {
const sBase = structBaseName(s.name);
return sBase === mBase || sBase.includes(mBase) || mBase.includes(sBase);
});
if (idx >= 0) {
// 보정: route.json 에 명시된 필드만 override (CSV 좌표/연장은 유지).
const tgt = result[idx];
if (m.station != null) tgt.station = m.station;
if (m.offset != null) tgt.offset = m.offset;
if (m.startMileage != null) tgt.startMileage = m.startMileage;
if (m.endMileage != null) tgt.endMileage = m.endMileage;
if (m.lat != null) tgt.lat = m.lat;
if (m.lon != null) tgt.lon = m.lon;
used.add(idx);
} else {
// CSV 에 없는 route.json 전용 구조물 → 그대로 추가.
result.push({ ...m });
}
}
return result;
}
// ── POI 위치 보정 (마우스 드래그 저장값) ──────────────────────────────
/**
* 폴더에서 POI 보정 파일을 읽는다. `<base>_poi_overrides.json` 우선,
* 없으면 `poi_overrides.json`. building/ 제외, 루트만. 파싱 실패 시 {}.
* 형식: { baseName?, overrides: { "<title>": {lat,lon,z}, ... } } (또는 평면 맵).
*/
export async function parsePoiOverrides(
files: File[],
baseName: string | null,
): Promise<PoiOverrideMap> {
const rootJson = files.filter(
(f) => !isInBuilding(f) && /_poi_overrides\.json$/i.test(baseNameOf(relPath(f))),
);
if (!rootJson.length) return {};
const wantBase = baseName ? `${baseName}_poi_overrides.json`.toLowerCase() : null;
const file =
(wantBase && rootJson.find((f) => baseNameOf(relPath(f)).toLowerCase() === wantBase)) ||
rootJson[0];
try {
const obj = JSON.parse(await file.text());
const map = (obj && typeof obj === 'object' && obj.overrides) ? obj.overrides : obj;
const out: PoiOverrideMap = {};
for (const [title, v] of Object.entries(map as Record<string, unknown>)) {
const o = v as { lat?: number; lon?: number; z?: number };
if (o && isFinite(o.lat as number) && isFinite(o.lon as number)) {
out[title] = { lat: o.lat as number, lon: o.lon as number, z: Number(o.z) || 0 };
}
}
return out;
} catch {
return {};
}
}
/** 보정맵을 POI 배열에 적용 (title 일치 시 lat/lon/z 교체). 원본 불변. */
export function applyPoiOverrides(pois: GeoPoint[], overrides: PoiOverrideMap): GeoPoint[] {
if (!overrides || !Object.keys(overrides).length) return pois;
return pois.map((p) => {
const o = overrides[p.title];
return o ? { ...p, lat: o.lat, lon: o.lon, z: o.z } : p;
});
}
// ── 중심선 생성 (v2.0엔 center.csv 없음) ──────────────────────────────
/**
* 측점을 측점값(mileage) 순으로 정렬한 폴리라인을 중심선으로 생성한다.
* v2.0엔 center.csv 가 없으므로 측점(01)측점)이 중심선의 소스다.
* 이 중심선이 체이니지 투영/RoutePanel 에 쓰인다.
*/
export function buildCenterlineFromStations(stations: GeoPoint[]): CenterlinePoint[] {
return [...stations]
.filter((s) => !isNaN(s.lat) && !isNaN(s.lon))
.sort((a, b) => stationOrder(a.title) - stationOrder(b.title))
.map((s) => ({ lat: s.lat, lon: s.lon, z: s.z }));
}
// ── 통합 로더 ─────────────────────────────────────────────────────────
/**
* 폴더 내 파일에서 영상 + 지리정보(v2.0 형상)를 모두 파싱한다.
* 반환값을 geoStore.loadFromFolder 가 스토어에 적재한다.
*/
export async function loadFolderGeoData(
input: FileList | File[],
): Promise<FolderGeoData> {
const files = Array.from(input);
const videoFiles = findVideoFiles(files);
const videoFile = videoFiles[0] ?? null;
const baseName = deriveBaseName(videoFile);
// 각 영상 길이를 메타데이터로 측정 — 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 파일 자체가 없을 때만 "데이터 누락"(kmzMissing=alert 대상)으로 본다.
// 파일은 있는데 POI/구조물이 0건인 경우(예: 제주 — 노선 KML만 있고 POI 없음)는
// 정상 데이터셋으로 취급하고 콘솔 경고만 남긴다.
const kmzMissing = !kmz;
if (kmzMissing) {
console.warn(
'[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} · 측점 ${kmz!.stations.length} 로드(원본)`);
}
const pois = kmz?.pois ?? [];
const baseStructures = kmz?.structures ?? [];
// 측점 소스: 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;
// route.json 은 선택적 보정 레이어 — 있으면 구조물에 override/augment.
// 철도역(역사)은 KMZ의 KAKAO_RAIL placemark에서 구조물로 생성됨(스테이션바 표출).
const structures = mergeStructures(baseStructures, routeMeta);
// v2.0엔 center.csv 가 없다 → 측점 폴리라인으로 중심선 생성.
const centerline = buildCenterlineFromStations(stations);
let origin: GeoOrigin | null = null;
if (stations.length || frames.length) {
origin = getWorldOrigin(frames, [...stations, ...pois]);
} else if (centerline.length) {
origin = { lat: centerline[0].lat, lon: centerline[0].lon, alt: centerline[0].z };
}
return {
videoFile,
baseName,
frames,
pois,
stations,
centerline,
origin,
routeMeta,
structures,
directionChanges,
poiOverrides,
kmzMissing,
videoFiles,
videoDurations,
folderFiles: files,
cameraInfo,
cameraJson,
};
}