- 제주 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>
872 lines
38 KiB
TypeScript
872 lines
38 KiB
TypeScript
/**
|
||
* 클라이언트 폴더 기반 지리정보 로딩 + 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 = ((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;
|
||
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(/>/g, '>').replace(/</g, '<').replace(/&/g, '&')
|
||
.replace(/ /g, ' ').replace(/"/g, '"').replace(/'/g, "'")
|
||
.replace(/\s+/g, ' ').trim();
|
||
}
|
||
|
||
/**
|
||
* description CDATA(HTML표) → 속성 배열. 행/셀 단위로 견고하게 파싱.
|
||
* 구글어스 재저장본은 `<tbody>`, `</b>` 뒤 `<br>`, `>` 엔티티 등 변형이 있어
|
||
* `<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,
|
||
};
|
||
}
|