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; /** * 같은 카드에 더 담은 파일 — 지형 자료(포인트클라우드)만 여러 장을 받는다. * 드론 라이다는 사업지가 넓으면 도엽별로 나뉘어 오고, 전처리가 합쳐서 쓴다 * (2026-09-06 사용자 확정). 업로드는 이 목록을 한 장씩 차례로 올린다. */ extraFiles?: 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; metadata?: Record }; } 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: "⌁", // `.csv`는 받지 않는다 — 내부 계산이 만드는 파일이라 사용자가 넣는 자료가 아니다 // (2026-09-03 사용자 지시). 슬롯 키 `csv`는 저장·서버 규약이라 그대로 둔다. extensions: [".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, }, ]; /** 카드에 적을 파일 이름 — 여러 장이면 「첫 장 외 N장」. */ export function slotFileLabel(state: FileSlotState): string { const name = state.file?.name ?? state.serverUploaded?.name ?? ""; const extras = state.extraFiles?.length ?? 0; return extras > 0 ? `${name} 외 ${extras}장` : name; } /** * 같은 카드에 파일을 더 담는다 — 담았으면 true, 이 카드가 한 장짜리면 false. * 지형 자료(포인트클라우드)만 여러 장을 받는다. 같은 이름은 다시 담지 않는다. */ export function pushExtraFile(state: FileSlotState, file: File): boolean { if (state.slot !== "las_laz") return false; const extras = state.extraFiles ?? []; if (!extras.some((item) => item.name === file.name)) state.extraFiles = [...extras, file]; state.error = undefined; return true; } 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; }