import { ui_locales } from "@ui/ui_template_locale"; export type FileSlot = "csv" | "las_laz" | "prj" | "tfw" | "tif" | "dxf"; export type UploadStatus = "pending" | "uploading" | "completed" | "failed"; export interface SlotConfig { slot: FileSlot; labelKey: keyof typeof ui_locales; icon: string; extensions: readonly string[]; isRequired: boolean; } export interface FileSlotState extends SlotConfig { file?: File; /** * 계획노선 shapefile의 동반 파일(.shx/.dbf/.cpg/.prj). 슬롯 하나가 파일 한 벌을 * 받는 유일한 경우다 — GDAL이 열려면 형제 파일이 같이 있어야 한다(2026-08-31). * 대표 파일(`file`)은 언제나 .shp이고, 동반 파일은 그보다 먼저 전송한다. */ companions?: File[]; uploadSessionId?: string; uploadStatus: UploadStatus; progressBytes: number; speedMbs: number; etaSeconds: number | null; error?: string; /** * 서버(DB `input_files`)에 이미 업로드 완료된 파일 정보 — 재접속 현황의 정본. * 로컬 파일을 새로 고르지 않아도 카드에 완료 상태로 표시하고, 이 슬롯에 새 파일을 * 올리면 교체 확인 모달을 띄우는 근거가 된다(2026-08-04 사용자 지시). */ serverUploaded?: { name: string; sizeMb: number }; } export interface StoredUploadSession { key: string; projectId: string; slot: FileSlot; fileName: string; fileSize: number; uploadSessionId: string; chunkSizeBytes: number; totalChunks: number; completedChunks: number; updatedAt: number; } const SLOT_CONFIGS: readonly SlotConfig[] = [ { slot: "csv", labelKey: "B03_File_Slot_PlannedRoute", icon: "⌁", extensions: [".csv", ".shp"], isRequired: true, }, { slot: "las_laz", labelKey: "B03_File_Slot_PointCloud", icon: "●", extensions: [".las", ".laz"], isRequired: true, }, { slot: "prj", labelKey: "B03_File_Slot_Projection", icon: "◇", extensions: [".prj"], isRequired: true, }, { slot: "tfw", labelKey: "B03_File_Slot_RasterCoord", icon: "□", extensions: [".tfw"], isRequired: true, }, { slot: "tif", labelKey: "B03_File_Slot_TerrainDem", icon: "▧", extensions: [".tif"], isRequired: false, }, ]; export function getExtension(fileName: string): string { const index = fileName.lastIndexOf("."); return index >= 0 ? fileName.slice(index).toLowerCase() : ""; } export function getBaseName(fileName: string): string { const index = fileName.lastIndexOf("."); return index >= 0 ? fileName.slice(0, index) : fileName; } /** shapefile 동반 파일. `.prj`가 여기 들어 있어 지형 PRJ와 basename으로 갈린다. */ const SHAPEFILE_COMPANION_EXT = [".shx", ".dbf", ".cpg", ".prj"]; /** * 고른 파일을 「계획노선 shapefile 한 벌」과 나머지로 가른다. * * 확장자만 보고 슬롯을 찾으면 노선 PRJ와 지형 PRJ가 같은 슬롯을 다툰다. `.shp`와 * **basename이 같은** 것만 노선 세트로 묶고, 남은 `.prj`는 지형 슬롯으로 보낸다. */ export function splitShapefileSelection(files: readonly File[]): { shapefile: { primary: File; companions: File[] } | null; rest: File[]; } { const primary = files.find((file) => getExtension(file.name) === ".shp"); if (!primary) return { shapefile: null, rest: [...files] }; const stem = getBaseName(primary.name); const companions: File[] = []; const rest: File[] = []; for (const file of files) { if (file === primary) continue; const extension = getExtension(file.name); if ( SHAPEFILE_COMPANION_EXT.includes(extension) && getBaseName(file.name) === stem ) { companions.push(file); } else { rest.push(file); } } return { shapefile: { primary, companions }, rest }; } export function formatBytes(bytes: number): string { const gb = bytes / 1024 / 1024 / 1024; if (gb >= 1) return `${gb.toFixed(2)} GB`; return `${(bytes / 1024 / 1024).toFixed(2)} MB`; } export function formatEta(seconds: number | null): string { if (seconds === null || !Number.isFinite(seconds)) return "-"; if (seconds < 60) return `${Math.ceil(seconds)}s`; return `${Math.ceil(seconds / 60)}m`; } export function makeSessionKey(projectId: string, file: File): string { return `b03_upload_${projectId}_${file.name}_${file.size}`; } export function initializeSlots(): Map { const map = new Map(); for (const config of SLOT_CONFIGS) { map.set(config.slot, { ...config, uploadStatus: "pending", progressBytes: 0, speedMbs: 0, etaSeconds: null, }); } return map; } export function createFileCardTemplate(): HTMLTemplateElement { const template = document.createElement("template"); template.id = "file-card-template"; template.innerHTML = `
`; return template; }