import { CURRENT_PROJECT_ID_KEY, PROGRESS_UPDATE_INTERVAL_MS, UPLOAD_ALLOWED_EXT, UPLOAD_CHUNK_SIZE_MB, UPLOAD_MAX_FILES, UPLOAD_MAX_MB, } from "@config/config_frontend"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { createButton, createTag, createWorkflowShell, showToast } from "@ui/ui_template_elements"; import { checkWF1AnalysisStatus, createUploadSession, finalizeUploadSession, uploadFileChunk, type UploadedFileResult, } from "./B03_FileInput_Api_Fetch"; import { navigateTo } from "../A00_Common/router"; import { ROUTES } from "@config/config_frontend"; import { restoreB03ProjectState, saveB03UploadedFile, updateB03AnalysisState, } from "./B03_FileInput_State"; import "./B03_FileInput_UI_Style.css"; type FileSlot = "las_laz" | "prj" | "tfw" | "tif" | "dxf"; type UploadStatus = "pending" | "uploading" | "completed" | "failed"; interface SlotConfig { slot: FileSlot; labelKey: keyof typeof ui_locales; icon: string; extensions: readonly string[]; isRequired: boolean; } interface FileSlotState extends SlotConfig { file?: File; uploadSessionId?: string; uploadStatus: UploadStatus; progressBytes: number; speedMbs: number; etaSeconds: number | null; error?: string; } 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: "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, }, ]; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } function getExtension(fileName: string): string { const index = fileName.lastIndexOf("."); return index >= 0 ? fileName.slice(index).toLowerCase() : ""; } 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`; } function formatEta(seconds: number | null): string { if (seconds === null || !Number.isFinite(seconds)) return "-"; if (seconds < 60) return `${Math.ceil(seconds)}s`; const minutes = Math.ceil(seconds / 60); return `${minutes}m`; } function makeSessionKey(projectId: string, file: File): string { return `b03_upload_${projectId}_${file.name}_${file.size}`; } 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; } function createFileCardTemplate(): HTMLTemplateElement { const template = document.createElement("template"); template.id = "file-card-template"; template.innerHTML = `
`; return template; } export function renderB03FileInput(root: HTMLElement): void { const slots = initializeSlots(); const cardMap = new Map(); const resultList = document.createElement("ul"); resultList.className = "b03-file__results"; let uploadButton: HTMLButtonElement; let resumeBanner: HTMLDivElement; let activeProjectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? ""; const shell = createWorkflowShell({ title: L("B03_File_Title"), steps: [ L("B03_File_Title"), L("WF_Step_Surface"), L("WF_Step_Route"), L("WF_Step_ProfileCross"), L("WF_Step_DesignDetail"), L("WF_Step_Quantity"), L("WF_Step_Estimation"), ], activeStep: 0, }); shell.root.classList.add("b03-file"); shell.leftPanel.remove(); const contentContainer = document.createElement("div"); contentContainer.className = "b03-file__main-layout"; const subtitle = document.createElement("p"); subtitle.className = "b03-file__subtitle"; subtitle.textContent = L("B03_File_Subtitle"); const fileInput = document.createElement("input"); fileInput.type = "file"; fileInput.multiple = true; fileInput.accept = UPLOAD_ALLOWED_EXT.join(","); fileInput.className = "b03-file__native-input"; const dropzone = document.createElement("div"); dropzone.className = "b03-file__dropzone"; dropzone.tabIndex = 0; const dropzoneLabel = document.createElement("strong"); dropzoneLabel.textContent = L("B03_File_Select_Label"); const dropzoneHint = document.createElement("span"); dropzoneHint.textContent = L("B03_File_Select_Hint"); dropzone.append(dropzoneLabel, dropzoneHint, fileInput); const pageError = document.createElement("p"); pageError.className = "b03-file__error"; pageError.setAttribute("role", "alert"); resumeBanner = document.createElement("div"); resumeBanner.className = "b03-file__resume"; const template = createFileCardTemplate(); function selectedStates(): FileSlotState[] { return Array.from(slots.values()).filter((state) => state.file); } function setCardState(slot: FileSlot, stateName: "empty" | "selected" | UploadStatus): void { const card = cardMap.get(slot); if (!card) return; card.classList.remove( "b03-file__card--empty", "b03-file__card--selected", "b03-file__card--uploading", "b03-file__card--completed", "b03-file__card--failed", "b03-file__card--error", ); const cssState = stateName === "failed" ? "error" : stateName; card.classList.add(`b03-file__card--${cssState}`); const badgeContainer = card.querySelector(".b03-file__card-badge-container"); if (badgeContainer) { badgeContainer.replaceChildren(); if (stateName === "empty") { badgeContainer.append(createTag("미등록", "neutral")); } else if (stateName === "selected") { badgeContainer.append(createTag("대기중", "neutral")); } else if (stateName === "uploading") { badgeContainer.append(createTag("업로드중", "warning")); } else if (stateName === "completed") { badgeContainer.append(createTag("완료", "success")); } else if (stateName === "failed") { badgeContainer.append(createTag("오류", "danger")); } } } function updateUploadButton(): void { const validation = validateSlots(); uploadButton.disabled = validation !== null; pageError.textContent = validation ?? ""; } function renderSlot(slot: FileSlot): void { const state = slots.get(slot); const card = cardMap.get(slot); if (!state || !card) return; const fileName = card.querySelector(".b03-file__file-name"); const fileSize = card.querySelector(".b03-file__file-size"); const progress = card.querySelector(".b03-file__progress-bar"); const progressBytes = card.querySelector(".b03-file__progress-bytes"); const progressSpeed = card.querySelector(".b03-file__progress-speed"); const progressEta = card.querySelector(".b03-file__progress-eta"); const error = card.querySelector(".b03-file__error-message"); const remove = card.querySelector(".b03-file__card-remove"); const percent = state.file ? Math.min(100, (state.progressBytes / state.file.size) * 100) : 0; if (fileName) fileName.textContent = state.file?.name ?? ""; if (fileSize) fileSize.textContent = state.file ? formatBytes(state.file.size) : ""; if (progress) progress.style.width = `${percent}%`; if (progressBytes) { progressBytes.textContent = `${L("B03_File_Progress_Bytes")}: ${formatBytes( state.progressBytes, )} / ${state.file ? formatBytes(state.file.size) : "-"}`; } if (progressSpeed) { progressSpeed.textContent = `${L("B03_File_Progress_Speed")}: ${state.speedMbs.toFixed( 1, )} MB/s`; } if (progressEta) { progressEta.textContent = `${L("B03_File_Progress_Eta")}: ${formatEta(state.etaSeconds)}`; } if (error) error.textContent = state.error ?? ""; if (remove) remove.hidden = !state.file; if (state.error) setCardState(slot, "failed"); else if (!state.file) setCardState(slot, "empty"); else setCardState(slot, state.uploadStatus === "pending" ? "selected" : state.uploadStatus); updateUploadButton(); } function showErrorMessage(slot: FileSlot, error: string): void { const state = slots.get(slot); if (!state) return; state.error = error; state.uploadStatus = "failed"; renderSlot(slot); } function clearErrorMessage(slot: FileSlot): void { const state = slots.get(slot); if (!state) return; state.error = undefined; if (state.uploadStatus === "failed") state.uploadStatus = "pending"; renderSlot(slot); } function validateFileForSlot(file: File, state: FileSlotState): string | null { const extension = getExtension(file.name); const maxBytes = UPLOAD_MAX_MB * 1024 * 1024; if (!state.extensions.includes(extension)) return L("B03_File_Error_SlotType"); if (file.size === 0 || file.size > maxBytes) return L("B03_File_Error_Size"); return null; } function assignFileToSlot(file: File, targetSlot?: FileSlot): void { const extension = getExtension(file.name); const state = targetSlot ? slots.get(targetSlot) : Array.from(slots.values()).find((candidate) => candidate.extensions.includes(extension)); if (!state) { pageError.textContent = `${L("B03_File_Error_Extension")} ${file.name}`; return; } const validation = validateFileForSlot(file, state); if (validation) { showErrorMessage(state.slot, `${validation} ${file.name}`); return; } if (!targetSlot && state.file && state.file.name !== file.name) { showErrorMessage(state.slot, `${L("B03_File_Error_DuplicateSlot")} ${file.name}`); return; } state.file = file; state.uploadSessionId = undefined; state.uploadStatus = "pending"; state.progressBytes = 0; state.speedMbs = 0; state.etaSeconds = null; state.error = undefined; renderSlot(state.slot); } function onFileSelected(files: readonly File[], targetSlot?: FileSlot): void { if (files.length === 0) return; if (selectedStates().length + files.length > UPLOAD_MAX_FILES) { pageError.textContent = L("B03_File_Error_Count"); return; } for (const file of files) assignFileToSlot(file, targetSlot); void detectPausedUploads(); } function removeFile(slot: FileSlot): void { const state = slots.get(slot); if (!state) return; if (activeProjectId && state.file) { localStorage.removeItem(makeSessionKey(activeProjectId, state.file)); } state.file = undefined; state.uploadSessionId = undefined; state.uploadStatus = "pending"; state.progressBytes = 0; state.speedMbs = 0; state.etaSeconds = null; state.error = undefined; renderSlot(slot); } function validateSlots(): string | null { if (!activeProjectId) return L("B03_File_Error_Project"); const selected = selectedStates(); if (selected.length === 0) return L("B03_File_Error_Required"); if (selected.length > UPLOAD_MAX_FILES) return L("B03_File_Error_Count"); const missingRequired = Array.from(slots.values()).some( (state) => state.isRequired && !state.file, ); if (missingRequired) return L("B03_File_Error_RequiredSlots"); const lasState = slots.get("las_laz"); if (!lasState?.file) return L("B03_File_Error_Las"); for (const state of selected) { if (state.error) return state.error; const validation = validateFileForSlot(state.file!, state); if (validation) return validation; } return null; } function createFileCard(state: FileSlotState): HTMLElement { const fragment = template.content.cloneNode(true) as DocumentFragment; const card = fragment.querySelector(".b03-file__card"); if (!card) throw new Error("file-card-template is invalid"); card.dataset.slotId = state.slot; card.querySelector(".b03-file__card-icon")!.textContent = state.icon; card.querySelector(".b03-file__card-label")!.textContent = L(state.labelKey); card.querySelector(".b03-file__card-ext")!.textContent = state.extensions.join(", "); const input = card.querySelector(".b03-file__slot-input")!; input.accept = state.extensions.join(","); const select = card.querySelector(".b03-file__card-select")!; select.textContent = L("B03_File_Card_Select"); select.addEventListener("click", () => input.click()); input.addEventListener("change", () => { onFileSelected(input.files ? Array.from(input.files) : [], state.slot); input.value = ""; }); const remove = card.querySelector(".b03-file__card-remove")!; remove.textContent = "×"; remove.title = L("B03_File_Card_Remove"); remove.setAttribute("aria-label", L("B03_File_Card_Remove")); remove.addEventListener("click", () => removeFile(state.slot)); cardMap.set(state.slot, card); return card; } function createCardGroup(title: string, groupSlots: readonly FileSlot[]): HTMLElement { const group = document.createElement("section"); group.className = "b03-file__group"; if (title) { const groupTitle = document.createElement("h3"); groupTitle.className = "b03-file__group-title"; groupTitle.textContent = title; group.append(groupTitle); } const content = document.createElement("div"); content.className = "b03-file__group-content"; for (const slot of groupSlots) { const state = slots.get(slot); if (state) content.append(createFileCard(state)); } group.append(content); return group; } function renderUploadResults(results: readonly UploadedFileResult[]): void { resultList.replaceChildren(); for (const result of results) { const item = document.createElement("li"); const filename = document.createElement("strong"); filename.textContent = result.original_filename; const path = document.createElement("span"); path.textContent = `${L("B03_File_Result_Path")}: ${result.relative_path}`; item.append(filename, path); resultList.append(item); } } async function detectPausedUploads(): Promise { activeProjectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? ""; resumeBanner.replaceChildren(); resumeBanner.classList.remove("is-visible"); if (!activeProjectId) return; for (const state of selectedStates()) { const stored = localStorage.getItem(makeSessionKey(activeProjectId, state.file!)); if (!stored) continue; const session = JSON.parse(stored) as StoredUploadSession; state.uploadSessionId = session.uploadSessionId; state.progressBytes = session.completedChunks * session.chunkSizeBytes; renderSlot(state.slot); const text = document.createElement("span"); text.textContent = `${L("B03_File_Status_Detected")}: ${session.fileName}`; const resume = createButton({ label: L("B03_File_Resume_Button"), variant: "ghost", onClick: () => void startChunkedUpload([state]), }); const fresh = createButton({ label: L("B03_File_New_Button"), variant: "ghost", onClick: () => { localStorage.removeItem(session.key); state.uploadSessionId = undefined; state.progressBytes = 0; renderSlot(state.slot); resumeBanner.replaceChildren(); resumeBanner.classList.remove("is-visible"); }, }); resumeBanner.append(text, resume, fresh); resumeBanner.classList.add("is-visible"); break; } } async function uploadOneFile( projectId: string, state: FileSlotState, ): Promise { const file = state.file; if (!file) return []; clearErrorMessage(state.slot); state.uploadStatus = "uploading"; renderSlot(state.slot); const chunkSizeBytes = UPLOAD_CHUNK_SIZE_MB * 1024 * 1024; const session = state.uploadSessionId ?? (await createUploadSession(projectId, file, chunkSizeBytes)).upload_session_id; state.uploadSessionId = session; const totalChunks = Math.max(1, Math.ceil(file.size / chunkSizeBytes)); const storageKey = makeSessionKey(projectId, file); const startedAt = performance.now(); let lastPaintAt = 0; for ( let chunkIndex = Math.floor(state.progressBytes / chunkSizeBytes); chunkIndex < totalChunks; chunkIndex += 1 ) { const start = chunkIndex * chunkSizeBytes; const end = Math.min(file.size, start + chunkSizeBytes); const chunkStartedAt = performance.now(); await uploadFileChunk(projectId, session, chunkIndex, file.slice(start, end)); const elapsedSec = Math.max(0.001, (performance.now() - chunkStartedAt) / 1000); state.progressBytes = end; state.speedMbs = (end - start) / 1024 / 1024 / elapsedSec; state.etaSeconds = state.speedMbs > 0 ? (file.size - end) / 1024 / 1024 / state.speedMbs : null; const stored: StoredUploadSession = { key: storageKey, projectId, slot: state.slot, fileName: file.name, fileSize: file.size, uploadSessionId: session, chunkSizeBytes, totalChunks, completedChunks: chunkIndex + 1, updatedAt: Date.now(), }; localStorage.setItem(storageKey, JSON.stringify(stored)); const now = performance.now(); if (now - lastPaintAt > PROGRESS_UPDATE_INTERVAL_MS || chunkIndex === totalChunks - 1) { lastPaintAt = now; renderSlot(state.slot); } } const response = await finalizeUploadSession(projectId, session, totalChunks); localStorage.removeItem(storageKey); saveB03UploadedFile(projectId, { slot: state.slot, fileName: file.name, fileSize: file.size, }); state.progressBytes = file.size; state.speedMbs = file.size / 1024 / 1024 / Math.max(0.001, (performance.now() - startedAt) / 1000); state.etaSeconds = 0; state.uploadStatus = "completed"; renderSlot(state.slot); return response.files; } async function pollWF1Analysis(projectId: string, maxAttempts = 360): Promise { for (let attempt = 0; attempt < maxAttempts; attempt += 1) { try { const status = await checkWF1AnalysisStatus(projectId); updateB03AnalysisState(projectId, status.status, status.progress_percent); if (status.status === "completed") { return true; } } catch {} await new Promise((resolve) => setTimeout(resolve, 5000)); } return false; } async function startChunkedUpload(targetStates = selectedStates()): Promise { activeProjectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? ""; const validation = validateSlots(); if (validation) { pageError.textContent = validation; return; } pageError.textContent = ""; const uploaded: UploadedFileResult[] = []; try { for (const state of targetStates) { uploaded.push(...(await uploadOneFile(activeProjectId, state))); } renderUploadResults(uploaded); showToast(L("B03_File_Upload_Success"), "success"); showToast(L("B03_File_Analysis_InProgress"), "info"); const analysisComplete = await pollWF1Analysis(activeProjectId); if (analysisComplete) { navigateTo(ROUTES.B04_WF1_SURFACE); } else { showToast(L("B03_File_Analysis_StillRunning"), "warning"); } } catch (error) { const failed = targetStates.find((state) => state.uploadStatus === "uploading"); const detail = error instanceof Error ? error.message : L("B03_File_Upload_Failed"); if (failed) showErrorMessage(failed.slot, detail); pageError.textContent = `${L("B03_File_Upload_Failed")} ${detail}`; showToast(L("B03_File_Upload_Failed"), "error"); } } async function registerB03ServiceWorker(): Promise { if (!("serviceWorker" in navigator)) { showToast(L("B03_File_ServiceWorker_Unavailable"), "warning"); return; } try { const registration = await navigator.serviceWorker.register( new URL("./B03_FileInput_ServiceWorker.ts", import.meta.url), { type: "module" }, ); registration.active?.postMessage({ type: "B03_SW_PING" }); showToast(L("B03_File_ServiceWorker_Ready"), "info"); } catch { showToast(L("B03_File_ServiceWorker_Unavailable"), "warning"); } } function onB03_File_Select_Change(): void { onFileSelected(fileInput.files ? Array.from(fileInput.files) : []); fileInput.value = ""; } function onB03_File_Drop(event: DragEvent): void { event.preventDefault(); dropzone.classList.remove("is-dragging"); onFileSelected(event.dataTransfer?.files ? Array.from(event.dataTransfer.files) : []); } fileInput.addEventListener("change", onB03_File_Select_Change); dropzone.addEventListener("click", () => fileInput.click()); dropzone.addEventListener("keydown", (event) => { if (event.key === "Enter" || event.key === " ") fileInput.click(); }); dropzone.addEventListener("dragover", (event) => { event.preventDefault(); dropzone.classList.add("is-dragging"); }); dropzone.addEventListener("dragleave", () => dropzone.classList.remove("is-dragging")); dropzone.addEventListener("drop", onB03_File_Drop); uploadButton = createButton({ label: L("B03_File_Upload_Button"), variant: "filled", onClick: () => void startChunkedUpload(), disabled: true, }); const uploadControlPanel = document.createElement("div"); uploadControlPanel.className = "b03-file__control-panel"; uploadControlPanel.append(subtitle, dropzone, resumeBanner, pageError, uploadButton, resultList); const filesGroup = createCardGroup("", ["las_laz", "prj", "tfw", "tif"]); // 타이틀 공백으로 전달 const cardsContainer = document.createElement("div"); cardsContainer.className = "b03-file__control-panel b03-file__cards-container-panel"; // 동일한 양식으로 감싸기 cardsContainer.append(filesGroup); contentContainer.append(uploadControlPanel, cardsContainer); shell.rightArea.replaceChildren(contentContainer); root.replaceChildren(shell.root); for (const slot of slots.keys()) renderSlot(slot); void registerB03ServiceWorker(); void detectPausedUploads(); if (activeProjectId) { restoreB03ProjectState({ projectId: activeProjectId, label: L("B03_File_Restore_State"), container: resumeBanner, poll: pollWF1Analysis, onComplete: () => navigateTo(ROUTES.B04_WF1_SURFACE), }); } }