/* ============================================================================= * B03_FileInput_UI_Upload.ts * 파일 입력 화면의 업로드 실행부 — 청크 업로드, 분석 대기, 결과 표시, 교체 확인 모달. * * 화면 조립(B03_FileInput_UI_Page.ts)에서 떼어낸 부분이다. 슬롯 상태를 그대로 받아 * 갱신하고, 화면 갱신은 호출측이 넘긴 콜백으로만 한다 — 이 파일은 DOM 구조를 모른다. * ========================================================================== */ import { PROGRESS_UPDATE_INTERVAL_MS, UPLOAD_CHUNK_SIZE_MB } from "@config/config_frontend"; import { fetchWorkflowState, type WorkflowState } from "../A00_Common/b_workflow_nav"; import { createButton } from "@ui/ui_template_elements"; import { fileFingerprint } from "./B03_FileInput_Fingerprint"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { checkWF1AnalysisStatus, createUploadSession, finalizeUploadSession, ReplaceOutputsConfirmRequired, uploadFileChunk, type UploadedFileResult, } from "./B03_FileInput_Api_Fetch"; import { saveB03UploadedFile, updateB03AnalysisState } from "./B03_FileInput_State"; import { makeSessionKey, type FileSlotState, type StoredUploadSession, } from "./B03_FileInput_UI_Support"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } /** * 완료된 슬롯 재업로드 확인 모달 — 기존 파일·분석 결과가 교체된다는 경고에 사용자의 * 명시적 확인을 받는다(2026-08-04 사용자 지시). 확인 시에만 resolve(true). */ export function confirmReplaceUpload(slotLabel: string, fileName: string): Promise { return new Promise((resolve) => { const backdrop = document.createElement("div"); backdrop.className = "b03-file__modal-backdrop"; const modal = document.createElement("div"); modal.className = "b03-file__modal"; modal.setAttribute("role", "alertdialog"); modal.setAttribute("aria-modal", "true"); const title = document.createElement("strong"); title.textContent = L("B03_File_Replace_Title"); const message = document.createElement("p"); message.textContent = `${slotLabel}: ${fileName}\n${L("B03_File_Replace_Message")}`; const actions = document.createElement("div"); actions.className = "b03-file__modal-actions"; const done = (accepted: boolean): void => { backdrop.remove(); resolve(accepted); }; const cancel = createButton({ label: L("B03_File_Replace_Cancel"), variant: "ghost", onClick: () => done(false), }); const accept = createButton({ label: L("B03_File_Replace_Confirm"), variant: "filled", onClick: () => done(true), }); actions.append(cancel, accept); modal.append(title, message, actions); backdrop.append(modal); backdrop.addEventListener("click", (event) => { if (event.target === backdrop) done(false); }); document.body.append(backdrop); accept.focus(); }); } /** 업로드 결과 목록(파일명 + 저장 경로)을 다시 그린다. */ /** * 파일 1건을 청크로 올린다. 중단된 세션이 있으면 그 지점부터 이어 올린다. * 진행 상황은 `onProgress`로만 알린다 — 갱신 주기는 config 값으로 제한한다. */ /** * 「이미 만들어 둔 결과가 지워집니다」를 사람에게 한 번 묻는다. * 되돌릴 수 없는 삭제라 확인 없이는 진행하지 않는다(2026-09-08 사용자 지시). * 자동 절차는 이 자리를 지나지 않는다 — 서버가 확인을 켜고 부른다. */ async function askReplaceOutputs(run: (confirmReplace: boolean) => Promise): Promise { try { return await run(false); } catch (error) { if (!(error instanceof ReplaceOutputsConfirmRequired)) throw error; const detail = error.targets.length ? ` 지워지는 것: ${error.targets.join(" · ")}` : ""; const agreed = window.confirm( `${error.message}${detail} 되돌릴 수 없습니다. 계속할까요?`, ); if (!agreed) throw new Error("업로드를 취소했습니다 — 기존 결과는 그대로 있습니다."); return await run(true); } } export async function uploadOneFile( projectId: string, state: FileSlotState, completeUpload: boolean, onProgress: () => void, lasFree = false, // 지형 자료는 한 카드에 여러 장이 담긴다 — 올릴 파일을 지정받는다(2026-09-06). target?: File, ): Promise { const file = target ?? state.file; if (!file) return []; state.error = undefined; state.uploadStatus = "uploading"; onProgress(); const chunkSizeBytes = UPLOAD_CHUNK_SIZE_MB * 1024 * 1024; // 같은 파일을 다시 고른 경우 전송을 통째로 건너뛴다 — 라이다는 한 번에 몇 분씩 걸린다. const fingerprint = state.uploadSessionId ? null : await fileFingerprint(file); let session = state.uploadSessionId; if (!session) { const created = await askReplaceOutputs((confirmReplace) => createUploadSession( projectId, file, chunkSizeBytes, fingerprint, completeUpload, lasFree, confirmReplace, ), ); if (created.already_uploaded) { state.progressBytes = file.size; state.etaSeconds = 0; state.uploadStatus = "completed"; onProgress(); return []; } session = created.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; onProgress(); } } const response = await askReplaceOutputs((confirmReplace) => finalizeUploadSession( projectId, session, totalChunks, completeUpload, fingerprint, lasFree, confirmReplace, ), ); 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"; onProgress(); return response.files; } /** * 지표면 분석(WF1)이 끝날 때까지 5초 간격으로 확인한다. * 자동 확정이 보류되면 `onHold`로 사유를 넘기고 대기를 끝낸다. */ /** * 초기 자동 계산이 아직 도는 중인지 — 업로드 버튼을 잠가 둘지 판단하는 기준. * * 자료를 올리면 서버가 전처리(B04) → 노선(B05) → 횡단(B06)까지 혼자 이어서 돈다. 그 사이에 * 자료를 또 올리면 분석 두 개가 같은 산출물 자리에서 부딪힌다. 그래서 **횡단 단계가 * 시작될 때까지**를 "도는 중"으로 본다 — 체인이 B06을 확정하면 3단계가 NOT_STARTED를 * 벗어난다(2026-08-08 사용자 지시). * * 전처리가 실패했으면 더 기다릴 게 없으므로 잠금을 푼다. */ export function isInitialPipelineRunning(state: WorkflowState | undefined): boolean { if (!state?.stages?.length) return false; const stageAt = (stageNo: number) => state.stages.find((stage) => stage.stage_no === stageNo); const fileInput = stageAt(0); const preprocess = stageAt(1); const section = stageAt(3); if (fileInput?.state !== "COMPLETE") return false; if (preprocess?.state === "FAILED") return false; return section?.state === "NOT_STARTED"; } /** 초기 자동 계산(B04~B06)이 끝날 때까지 기다린다. 끝나면 true. */ export async function pollInitialPipeline( projectId: string, onHold: (message: string) => void, maxAttempts = 360, ): Promise { const analysisDone = await pollWF1Analysis(projectId, onHold, maxAttempts); if (!analysisDone) return false; // 전처리가 끝나도 노선·횡단이 이어서 돈다 — 3단계가 열릴 때까지 더 기다린다. for (let attempt = 0; attempt < maxAttempts; attempt += 1) { try { const state = await fetchWorkflowState(projectId); if (!isInitialPipelineRunning(state)) return true; } catch { /* 일시적인 조회 실패는 다음 주기에 다시 확인한다. */ } await new Promise((resolve) => setTimeout(resolve, 3000)); } return false; } export async function pollWF1Analysis( projectId: string, onHold: (message: string) => void, 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; if ( status.current_stage === "awaiting_confirmation" && status.message.includes("자동 확정 보류") ) { onHold(status.message); return false; } } catch { /* 일시적인 조회 실패는 다음 주기에 다시 확인한다. */ } await new Promise((resolve) => setTimeout(resolve, 5000)); } return false; }