From 2ff37a6e41a05114a4d40afd4fadad7bc436381f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 8 Aug 2026 14:14:46 +0900 Subject: [PATCH] =?UTF-8?q?feat(B03):=20=EC=9E=85=EB=A0=A5=20=EC=9E=90?= =?UTF-8?q?=EB=A3=8C=20=EC=BB=A8=ED=85=8C=EC=9D=B4=EB=84=88=20=ED=86=B5?= =?UTF-8?q?=ED=95=A9=C2=B73=EC=97=B4=20=EB=B0=B0=EC=B9=98=20+=20=EC=97=85?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=20=EC=A4=91=20=ED=99=94=EB=A9=B4=20=EC=9E=A0?= =?UTF-8?q?=EA=B8=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 지형 래스터(tif)만 선택 항목이고 나머지는 모두 필수라, 계획노선과 지형 자료를 따로 묶을 이유가 없다. - 그룹 2개(원청 계획노선/지형 분석자료)를 "입력 자료" 한 그룹으로 통합 - 계획노선 카드에만 걸려 있던 전용 서식(1열 폭·보라 배경·테두리) 제거 — 다른 카드와 같은 템플릿으로 통일 - 카드 배치 2열 -> 3열 (1280px 이하 2열, 720px 이하 1열) - 선택 항목(tif)은 확장자 옆에 "선택" 표시 - [파일 업로드]를 누르면 버튼을 잠그고 안에 회전 원을 넣어 진행 중임을 보이며, 업로드·분석이 끝날 때까지 화면 조작을 막는다(중복 업로드·단계 이동 방지) 파일 분리: 업로드 실행부(청크 업로드/분석 대기/결과 표시/교체 확인 모달)를 B03_FileInput_UI_Upload.ts로 이관해 페이지 파일을 700줄 제한 안으로 되돌림(830 -> 622). Co-Authored-By: Claude Fable 5 --- B03_FileInput/B03_FileInput_UI_Page.ts | 257 +++++++---------------- B03_FileInput/B03_FileInput_UI_Style.css | 29 ++- B03_FileInput/B03_FileInput_UI_Upload.ts | 194 +++++++++++++++++ ui_template/ui_template_locale_b1.ts | 6 +- 4 files changed, 296 insertions(+), 190 deletions(-) create mode 100644 B03_FileInput/B03_FileInput_UI_Upload.ts diff --git a/B03_FileInput/B03_FileInput_UI_Page.ts b/B03_FileInput/B03_FileInput_UI_Page.ts index 14bec209..1e4966df 100644 --- a/B03_FileInput/B03_FileInput_UI_Page.ts +++ b/B03_FileInput/B03_FileInput_UI_Page.ts @@ -1,9 +1,7 @@ import { CURRENT_PROJECT_ID_KEY, - PROGRESS_UPDATE_INTERVAL_MS, ROUTES, UPLOAD_ALLOWED_EXT, - UPLOAD_CHUNK_SIZE_MB, UPLOAD_MAX_FILES, UPLOAD_MAX_MB, } from "@config/config_frontend"; @@ -12,14 +10,7 @@ import { createButton, createTag, showToast } from "@ui/ui_template_elements"; import { createGeneralLayout } from "@ui/ui_template_general_layout"; import { createWorkflowOverlays } from "@ui/ui_template_overlay"; import { createStepBar, WORKFLOW_STEP_ICONS } from "@ui/ui_template_workflow_layout"; -import { - checkWF1AnalysisStatus, - createUploadSession, - fetchUploadOverview, - finalizeUploadSession, - uploadFileChunk, - type UploadedFileResult, -} from "./B03_FileInput_Api_Fetch"; +import { fetchUploadOverview, type UploadedFileResult } from "./B03_FileInput_Api_Fetch"; import { navigateTo } from "../A00_Common/router"; import { attachTempBatch } from "../B01_Dashboard/B01_Dashboard_Api_Temp"; import { createTempPicker } from "./B03_FileInput_UI_TempPicker"; @@ -29,11 +20,13 @@ import { goToWorkflowStage, WORKFLOW_STEP_ROUTES, } from "../A00_Common/b_workflow_nav"; +import { restoreB03ProjectState } from "./B03_FileInput_State"; import { - restoreB03ProjectState, - saveB03UploadedFile, - updateB03AnalysisState, -} from "./B03_FileInput_State"; + confirmReplaceUpload, + pollWF1Analysis, + renderUploadResults, + uploadOneFile, +} from "./B03_FileInput_UI_Upload"; import { createFileCardTemplate, formatBytes, @@ -65,6 +58,10 @@ export async function renderB03FileInput(root: HTMLElement): Promise { let uploadButton: HTMLButtonElement; let resumeBanner: HTMLDivElement; let activeProjectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? ""; + // 업로드가 도는 동안은 화면 전체를 잠근다 — 같은 파일을 두 번 올리거나 도중에 다른 + // 단계로 넘어가는 것을 막는다(2026-08-08 사용자 지시). + let pageRoot: HTMLElement | null = null; + let isUploading = false; const subtitle = document.createElement("p"); subtitle.className = "b03-file__subtitle"; @@ -89,6 +86,11 @@ export async function renderB03FileInput(root: HTMLElement): Promise { pageError.className = "b03-file__error"; pageError.setAttribute("role", "alert"); + // 업로드 버튼 안에서 도는 원 — 공용 스피너를 버튼 크기로 줄여 쓴다. + const uploadSpinner = document.createElement("span"); + uploadSpinner.className = "ui-spinner b03-file__upload-spinner"; + uploadSpinner.setAttribute("aria-hidden", "true"); + resumeBanner = document.createElement("div"); resumeBanner.className = "b03-file__resume"; @@ -133,7 +135,28 @@ export async function renderB03FileInput(root: HTMLElement): Promise { } } + /** 업로드 중 표시 — 버튼 안에 도는 원을 넣고 화면을 잠근다. */ + function setUploading(busy: boolean): void { + isUploading = busy; + pageRoot?.classList.toggle("b03-file--busy", busy); + const label = uploadButton.querySelector(".ui-btn__label"); + if (busy) { + uploadButton.disabled = true; + uploadButton.prepend(uploadSpinner); + if (label) label.textContent = L("B03_File_Upload_Busy"); + return; + } + uploadSpinner.remove(); + if (label) label.textContent = L("B03_File_Upload_Button"); + updateUploadButton(); + } + function updateUploadButton(): void { + // 업로드가 도는 동안에는 어떤 이유로도 다시 눌리면 안 된다. + if (isUploading) { + uploadButton.disabled = true; + return; + } // 보관함 자료를 지정했으면 슬롯 검사와 무관하게 업로드(=이동)를 열어 준다. if (tempPicker.selected()) { uploadButton.disabled = false; @@ -200,14 +223,6 @@ export async function renderB03FileInput(root: HTMLElement): Promise { 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; @@ -304,49 +319,6 @@ export async function renderB03FileInput(root: HTMLElement): Promise { return null; } - /** - * 완료된 슬롯 재업로드 확인 모달 — 기존 파일·분석 결과가 교체된다는 경고에 사용자의 - * 명시적 확인을 받는다(2026-08-04 사용자 지시). 확인 시에만 resolve(true). - */ - 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(); - }); - } - /** * 재접속 현황(서버 정본) 적용 — 업로드 완료 파일을 슬롯 카드에 표시하고, 중단된 청크 * 세션은 파일 재선택 전에도 안내하며, 전체 완료면 완료 배지를 띄운다. @@ -403,7 +375,11 @@ export async function renderB03FileInput(root: HTMLElement): Promise { 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 extLabel = state.extensions.join(", "); + card.querySelector(".b03-file__card-ext")!.textContent = state.isRequired + ? extLabel + : `${extLabel} · ${L("B03_File_Card_Optional")}`; const input = card.querySelector(".b03-file__slot-input")!; input.accept = state.extensions.join(","); const select = card.querySelector(".b03-file__card-select")!; @@ -441,19 +417,6 @@ export async function renderB03FileInput(root: HTMLElement): Promise { 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(); @@ -492,100 +455,12 @@ export async function renderB03FileInput(root: HTMLElement): Promise { } } - async function uploadOneFile( - projectId: string, - state: FileSlotState, - completeUpload: boolean, - ): 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, completeUpload); - localStorage.removeItem(storageKey); - saveB03UploadedFile(projectId, { - slot: state.slot, - fileName: file.name, - fileSize: file.size, + /** 분석 대기 — 자동 확정이 보류되면 그 사유를 화면과 알림으로 남긴다. */ + const pollAnalysis = (projectId: string): Promise => + pollWF1Analysis(projectId, (message) => { + pageError.textContent = message; + showToast(message, "warning"); }); - 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; - } - if ( - status.current_stage === "awaiting_confirmation" && - status.message.includes("자동 확정 보류") - ) { - pageError.textContent = status.message; - showToast(status.message, "warning"); - return false; - } - } catch {} - await new Promise((resolve) => setTimeout(resolve, 5000)); - } - return false; - } /** * 보관함 자료를 이 프로젝트로 옮기고 초기 분석까지 이어 간다. @@ -611,7 +486,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise { return; } showToast(L("B03_File_Analysis_InProgress"), "info"); - const analysisComplete = await pollWF1Analysis(activeProjectId); + const analysisComplete = await pollAnalysis(activeProjectId); if (analysisComplete) { navigateTo(completionRoute); } else { @@ -625,9 +500,15 @@ export async function renderB03FileInput(root: HTMLElement): Promise { } async function startChunkedUpload(targetStates = selectedStates()): Promise { + if (isUploading) return; // 보관함에서 불러온 자료가 지정돼 있으면 그것을 옮기는 것이 이 버튼의 동작이다. if (tempPicker.selected()) { - await attachSelectedTempBatch(); + setUploading(true); + try { + await attachSelectedTempBatch(); + } finally { + setUploading(false); + } return; } activeProjectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? ""; @@ -638,19 +519,22 @@ export async function renderB03FileInput(root: HTMLElement): Promise { } pageError.textContent = ""; + setUploading(true); const uploaded: UploadedFileResult[] = []; try { for (let index = 0; index < targetStates.length; index += 1) { const state = targetStates[index]; uploaded.push( - ...(await uploadOneFile(activeProjectId, state, index === targetStates.length - 1)), + ...(await uploadOneFile(activeProjectId, state, index === targetStates.length - 1, () => + renderSlot(state.slot), + )), ); } - renderUploadResults(uploaded); + renderUploadResults(resultList, uploaded); showToast(L("B03_File_Upload_Success"), "success"); showToast(L("B03_File_Analysis_InProgress"), "info"); - const analysisComplete = await pollWF1Analysis(activeProjectId); + const analysisComplete = await pollAnalysis(activeProjectId); if (analysisComplete) { navigateTo(completionRoute); @@ -663,6 +547,8 @@ export async function renderB03FileInput(root: HTMLElement): Promise { if (failed) showErrorMessage(failed.slot, detail); pageError.textContent = `${L("B03_File_Upload_Failed")} ${detail}`; showToast(L("B03_File_Upload_Failed"), "error"); + } finally { + setUploading(false); } } @@ -727,13 +613,19 @@ export async function renderB03FileInput(root: HTMLElement): Promise { resultList, ); - const routeGroup = createCardGroup(L("B03_File_Group_Route"), ["csv"]); - routeGroup.classList.add("b03-file__group--route"); - const filesGroup = createCardGroup(L("B03_File_Group_Terrain"), ["las_laz", "prj", "tfw", "tif"]); + // 계획노선과 지형 자료는 지형 래스터(tif)만 빼면 모두 필수라 따로 묶지 않는다 + // (2026-08-08 사용자 지시). + const inputsGroup = createCardGroup(L("B03_File_Group_Inputs"), [ + "csv", + "las_laz", + "prj", + "tfw", + "tif", + ]); const cardsContainer = document.createElement("div"); cardsContainer.className = "b03-file__control-panel b03-file__cards-container-panel"; - cardsContainer.append(routeGroup, filesGroup); + cardsContainer.append(inputsGroup); const workflowState = activeProjectId ? await fetchWorkflowState(activeProjectId).catch(() => undefined) @@ -762,6 +654,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise { showTitlePanel: false, }); layout.root.append(overlays.root); + pageRoot = layout.root; root.replaceChildren(layout.root); for (const slot of slots.keys()) renderSlot(slot); @@ -773,7 +666,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise { projectId: activeProjectId, label: L("B03_File_Restore_State"), container: resumeBanner, - poll: pollWF1Analysis, + poll: pollAnalysis, onComplete: () => navigateTo(completionRoute), }); } diff --git a/B03_FileInput/B03_FileInput_UI_Style.css b/B03_FileInput/B03_FileInput_UI_Style.css index 8a11dc90..dd55313f 100644 --- a/B03_FileInput/B03_FileInput_UI_Style.css +++ b/B03_FileInput/B03_FileInput_UI_Style.css @@ -195,17 +195,28 @@ .b03-file__group-content { display: grid; - grid-template-columns: repeat(2, 1fr); /* 2행 2열 구조 */ + grid-template-columns: repeat(3, 1fr); /* 3열 구조 */ gap: var(--spacing-24); } -.b03-file__group--route .b03-file__group-content { - grid-template-columns: 1fr; +/* 업로드 중에는 화면 조작을 막는다 — 중복 업로드·단계 이동 방지(2026-08-08 사용자 지시). */ +.b03-file--busy { + pointer-events: none; } -.b03-file__group--route .b03-file__card { - border-color: var(--color-royal-amethyst, #3e0079); - background: var(--color-mist-violet, #edecff); +.b03-file--busy .b03-file__card-select, +.b03-file--busy .b03-file__card-remove, +.b03-file--busy .b03-file__dropzone { + opacity: 0.5; +} + +/* 업로드 버튼 안에서 도는 원 — 공용 스피너를 버튼 글자 크기에 맞춰 줄인다. */ +.b03-file__upload-spinner { + width: 14px; + height: 14px; + border-width: 2px; + border-color: rgb(255 255 255 / 40%); + border-top-color: var(--color-primary-text); } /* Wiza 8px radius 카드 */ @@ -448,6 +459,12 @@ border-bottom: 0; } +@media (max-width: 1280px) { + .b03-file__group-content { + grid-template-columns: repeat(2, 1fr); /* 좁은 창에서는 2열 */ + } +} + @media (max-width: 720px) { .b03-file { padding: var(--spacing-16) var(--spacing-12); diff --git a/B03_FileInput/B03_FileInput_UI_Upload.ts b/B03_FileInput/B03_FileInput_UI_Upload.ts new file mode 100644 index 00000000..4f6af066 --- /dev/null +++ b/B03_FileInput/B03_FileInput_UI_Upload.ts @@ -0,0 +1,194 @@ +/* ============================================================================= + * 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 { createButton } from "@ui/ui_template_elements"; +import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; +import { + checkWF1AnalysisStatus, + createUploadSession, + finalizeUploadSession, + 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(); + }); +} + +/** 업로드 결과 목록(파일명 + 저장 경로)을 다시 그린다. */ +export function renderUploadResults( + list: HTMLElement, + results: readonly UploadedFileResult[], +): void { + list.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); + list.append(item); + } +} + +/** + * 파일 1건을 청크로 올린다. 중단된 세션이 있으면 그 지점부터 이어 올린다. + * 진행 상황은 `onProgress`로만 알린다 — 갱신 주기는 config 값으로 제한한다. + */ +export async function uploadOneFile( + projectId: string, + state: FileSlotState, + completeUpload: boolean, + onProgress: () => void, +): Promise { + const file = state.file; + if (!file) return []; + state.error = undefined; + state.uploadStatus = "uploading"; + onProgress(); + + 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; + onProgress(); + } + } + + const response = await finalizeUploadSession(projectId, session, totalChunks, completeUpload); + 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`로 사유를 넘기고 대기를 끝낸다. + */ +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; +} diff --git a/ui_template/ui_template_locale_b1.ts b/ui_template/ui_template_locale_b1.ts index 75ee1b7c..67f5ea74 100644 --- a/ui_template/ui_template_locale_b1.ts +++ b/ui_template/ui_template_locale_b1.ts @@ -225,6 +225,7 @@ export const ui_locales_b1 = { B03_File_Selected_Title: ["선택한 파일", "Selected files"], B03_File_Selected_Empty: ["선택한 파일이 없습니다.", "No files selected."], B03_File_Upload_Button: ["파일 업로드", "Upload files"], + B03_File_Upload_Busy: ["업로드 중…", "Uploading…"], B03_File_Error_Project: [ "현재 프로젝트가 선택되지 않았습니다. 프로젝트를 먼저 생성하거나 선택하세요.", "No current project is selected. Create or select a project first.", @@ -253,8 +254,8 @@ export const ui_locales_b1 = { B03_File_Result_Path: ["저장 경로", "Stored path"], B03_File_Group_Required: ["필수 파일", "Required files"], B03_File_Group_Optional: ["선택 파일", "Optional files"], - B03_File_Group_Route: ["원청 계획노선 (필수)", "Client Planned Route (Required)"], - B03_File_Group_Terrain: ["지형 분석자료", "Terrain Analysis Files"], + /* 지형 래스터만 선택 항목이라 계획노선·지형 자료를 한 묶음으로 둔다(2026-08-08). */ + B03_File_Group_Inputs: ["입력 자료", "Input files"], B03_File_Slot_PlannedRoute: ["계획노선 좌표", "Planned Route Coordinates"], B03_File_Slot_PointCloud: ["포인트클라우드", "Point Cloud"], B03_File_Slot_Projection: ["좌표계 정의", "Projection"], @@ -285,6 +286,7 @@ export const ui_locales_b1 = { B03_File_Card_Select: ["파일 선택", "Select file"], B03_File_Card_Remove: ["파일 제거", "Remove file"], + B03_File_Card_Optional: ["선택", "Optional"], B03_File_Error_DuplicateSlot: [ "같은 유형의 파일이 이미 선택되어 있습니다.", "A file for this slot is already selected.",