import { ui_locales } from "@ui/ui_template_locale"; /** * 카드 한 장 = 파일 한 개. 계획노선 shapefile은 파일이 다섯이므로 카드도 다섯이다 * (2026-08-31 사용자 지시) — 어느 파일이 왔고 어느 것이 비었는지 화면에서 바로 보인다. * `route_prj`(노선 좌표계)와 `prj`(지형 좌표계)는 확장자가 같아 basename으로 가른다. */ export type FileSlot = | "csv" | "shx" | "dbf" | "cpg" | "route_prj" | "las_laz" | "prj" | "tfw" | "tif" | "dxf"; /** 왼쪽(계획노선) 컨테이너에 놓이는 슬롯. */ export const ROUTE_SLOTS: readonly FileSlot[] = [ "csv", "shx", "dbf", "cpg", "route_prj", ]; /** 오른쪽(지형·LAS) 컨테이너에 놓이는 슬롯. */ export const TERRAIN_SLOTS: readonly FileSlot[] = [ "las_laz", "prj", "tfw", "tif", ]; /** 노선 도형이 shapefile일 때 함께 있어야 하는 슬롯(.cpg는 없으면 CP949). */ export const SHAPEFILE_DEPENDENT_SLOTS: readonly FileSlot[] = [ "shx", "dbf", "route_prj", ]; 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; 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: "shx", labelKey: "B03_File_Slot_RouteIndex", icon: "⋮", extensions: [".shx"], isRequired: false, }, { slot: "dbf", labelKey: "B03_File_Slot_RouteAttribute", icon: "▤", extensions: [".dbf"], isRequired: false, }, { slot: "cpg", labelKey: "B03_File_Slot_RouteEncoding", icon: "⌨", extensions: [".cpg"], isRequired: false, }, { slot: "route_prj", labelKey: "B03_File_Slot_RouteProjection", icon: "◈", extensions: [".prj"], isRequired: false, }, { 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; } /** * 고른 파일을 카드(슬롯)에 배정한다. * * `.prj`만 확장자로 갈리지 않는다 — 노선 좌표계와 지형 좌표계가 같은 확장자다. * **노선 도형(.shp)과 basename이 같은 것**만 노선 좌표계 카드로 보내고, 나머지는 * 지형 좌표계 카드로 보낸다. `routeStem`은 이미 골라 둔 노선 도형의 basename으로, * 노선 PRJ를 나중에 따로 추가하는 경우를 받아 준다. */ export function planSlotAssignments( files: readonly File[], slotConfigs: readonly SlotConfig[], routeStem?: string, ): { file: File; slot?: FileSlot }[] { const batchStem = files .filter((file) => getExtension(file.name) === ".shp") .map((file) => getBaseName(file.name))[0]; const stem = batchStem ?? routeStem; return files.map((file) => { const extension = getExtension(file.name); if (extension === ".prj") { const isRoute = stem !== undefined && getBaseName(file.name) === stem; return { file, slot: (isRoute ? "route_prj" : "prj") as FileSlot }; } const config = slotConfigs.find( (candidate) => candidate.slot !== "route_prj" && candidate.extensions.includes(extension), ); return { file, slot: config?.slot }; }); } /** 슬롯 설정 목록 — 배정 규칙이 카드 정의와 같은 것을 쓰도록 밖으로 연다. */ export function slotConfigs(): readonly SlotConfig[] { return SLOT_CONFIGS; } 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; }