260710_0
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
/* B03 파일 입력 페이지 */
|
||||
/* B03 파일 입력 페이지 - 가로형 드롭존 + 슬롯 그리드 + 청크 업로드 */
|
||||
|
||||
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";
|
||||
@@ -10,55 +12,199 @@ import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||
import {
|
||||
createButton,
|
||||
createCard,
|
||||
createTag,
|
||||
createWorkflowShell,
|
||||
hideLoadingOverlay,
|
||||
showLoadingOverlay,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { uploadProjectFiles, type UploadedFileResult } from "./B03_FileInput_Api_Fetch";
|
||||
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 "./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 validateFiles(files: readonly File[]): string | null {
|
||||
if (files.length === 0) return L("B03_File_Error_Required");
|
||||
if (files.length > UPLOAD_MAX_FILES) return L("B03_File_Error_Count");
|
||||
function getExtension(fileName: string): string {
|
||||
const index = fileName.lastIndexOf(".");
|
||||
return index >= 0 ? fileName.slice(index).toLowerCase() : "";
|
||||
}
|
||||
|
||||
const maximumBytes = UPLOAD_MAX_MB * 1024 * 1024;
|
||||
const normalizedNames = new Set<string>();
|
||||
let lasCount = 0;
|
||||
for (const file of files) {
|
||||
const extensionIndex = file.name.lastIndexOf(".");
|
||||
const extension = extensionIndex >= 0 ? file.name.slice(extensionIndex).toLowerCase() : "";
|
||||
if (!UPLOAD_ALLOWED_EXT.includes(extension as (typeof UPLOAD_ALLOWED_EXT)[number])) {
|
||||
return `${L("B03_File_Error_Extension")} ${file.name}`;
|
||||
}
|
||||
if (file.size === 0 || file.size > maximumBytes) {
|
||||
return `${L("B03_File_Error_Size")} ${file.name}`;
|
||||
}
|
||||
const normalizedName = file.name.toLocaleLowerCase();
|
||||
if (normalizedNames.has(normalizedName)) return `${L("B03_File_Error_Extension")} ${file.name}`;
|
||||
normalizedNames.add(normalizedName);
|
||||
if (extension === ".las" || extension === ".laz") lasCount += 1;
|
||||
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<FileSlot, FileSlotState> {
|
||||
const map = new Map<FileSlot, FileSlotState>();
|
||||
for (const config of SLOT_CONFIGS) {
|
||||
map.set(config.slot, {
|
||||
...config,
|
||||
uploadStatus: "pending",
|
||||
progressBytes: 0,
|
||||
speedMbs: 0,
|
||||
etaSeconds: null,
|
||||
});
|
||||
}
|
||||
return lasCount === 1 ? null : L("B03_File_Error_Las");
|
||||
return map;
|
||||
}
|
||||
|
||||
function createFileCardTemplate(): HTMLTemplateElement {
|
||||
const template = document.createElement("template");
|
||||
template.id = "file-card-template";
|
||||
template.innerHTML = `
|
||||
<article class="b03-file__card b03-file__card--empty">
|
||||
<div class="b03-file__card-header">
|
||||
<span class="b03-file__card-icon" aria-hidden="true"></span>
|
||||
<div class="b03-file__card-heading">
|
||||
<strong class="b03-file__card-label"></strong>
|
||||
<span class="b03-file__card-ext"></span>
|
||||
</div>
|
||||
<div class="b03-file__card-badge-container"></div>
|
||||
<button class="b03-file__card-remove" type="button" aria-label="파일 제거"></button>
|
||||
</div>
|
||||
<div class="b03-file__card-content">
|
||||
<button class="b03-file__card-select" type="button"></button>
|
||||
<input class="b03-file__slot-input" type="file" />
|
||||
<div class="b03-file__file-info">
|
||||
<span class="b03-file__file-name"></span>
|
||||
<span class="b03-file__file-size"></span>
|
||||
</div>
|
||||
<div class="b03-file__progress-section">
|
||||
<div class="b03-file__progress-bar-container">
|
||||
<div class="b03-file__progress-bar"></div>
|
||||
</div>
|
||||
<div class="b03-file__progress-info">
|
||||
<span class="b03-file__progress-bytes"></span>
|
||||
<span class="b03-file__progress-speed"></span>
|
||||
<span class="b03-file__progress-eta"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="b03-file__error-message" role="alert"></div>
|
||||
</div>
|
||||
</article>
|
||||
`;
|
||||
return template;
|
||||
}
|
||||
|
||||
export function renderB03FileInput(root: HTMLElement): void {
|
||||
let selectedFiles: File[] = [];
|
||||
const page = document.createElement("div");
|
||||
page.className = "b03-file";
|
||||
const slots = initializeSlots();
|
||||
const cardMap = new Map<FileSlot, HTMLElement>();
|
||||
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 header = document.createElement("div");
|
||||
header.className = "b03-file__header";
|
||||
const title = document.createElement("h1");
|
||||
title.className = "b03-file__title";
|
||||
title.textContent = L("B03_File_Title");
|
||||
const subtitle = document.createElement("p");
|
||||
subtitle.className = "b03-file__subtitle";
|
||||
subtitle.textContent = L("B03_File_Subtitle");
|
||||
header.append(title, subtitle);
|
||||
|
||||
const fileInput = document.createElement("input");
|
||||
fileInput.type = "file";
|
||||
@@ -75,41 +221,238 @@ export function renderB03FileInput(root: HTMLElement): void {
|
||||
dropzoneHint.textContent = L("B03_File_Select_Hint");
|
||||
dropzone.append(dropzoneLabel, dropzoneHint, fileInput);
|
||||
|
||||
const errorSlot = document.createElement("p");
|
||||
errorSlot.className = "b03-file__error";
|
||||
errorSlot.setAttribute("role", "alert");
|
||||
const selectedTitle = document.createElement("h2");
|
||||
selectedTitle.className = "b03-file__section-title";
|
||||
selectedTitle.textContent = L("B03_File_Selected_Title");
|
||||
const selectedList = document.createElement("ul");
|
||||
selectedList.className = "b03-file__list";
|
||||
const resultList = document.createElement("ul");
|
||||
resultList.className = "b03-file__results";
|
||||
const pageError = document.createElement("p");
|
||||
pageError.className = "b03-file__error";
|
||||
pageError.setAttribute("role", "alert");
|
||||
|
||||
function renderSelectedFiles(): void {
|
||||
selectedList.replaceChildren();
|
||||
if (selectedFiles.length === 0) {
|
||||
const empty = document.createElement("li");
|
||||
empty.className = "b03-file__empty";
|
||||
empty.textContent = L("B03_File_Selected_Empty");
|
||||
selectedList.append(empty);
|
||||
return;
|
||||
}
|
||||
for (const file of selectedFiles) {
|
||||
const item = document.createElement("li");
|
||||
const name = document.createElement("span");
|
||||
name.textContent = file.name;
|
||||
const size = document.createElement("span");
|
||||
size.textContent = `${(file.size / (1024 * 1024)).toFixed(2)} MB`;
|
||||
item.append(name, size);
|
||||
selectedList.append(item);
|
||||
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<HTMLDivElement>(".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 setSelectedFiles(files: readonly File[]): void {
|
||||
selectedFiles = Array.from(files);
|
||||
errorSlot.textContent = validateFiles(selectedFiles) ?? "";
|
||||
renderSelectedFiles();
|
||||
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<HTMLSpanElement>(".b03-file__file-name");
|
||||
const fileSize = card.querySelector<HTMLSpanElement>(".b03-file__file-size");
|
||||
const progress = card.querySelector<HTMLDivElement>(".b03-file__progress-bar");
|
||||
const progressBytes = card.querySelector<HTMLSpanElement>(".b03-file__progress-bytes");
|
||||
const progressSpeed = card.querySelector<HTMLSpanElement>(".b03-file__progress-speed");
|
||||
const progressEta = card.querySelector<HTMLSpanElement>(".b03-file__progress-eta");
|
||||
const error = card.querySelector<HTMLDivElement>(".b03-file__error-message");
|
||||
const remove = card.querySelector<HTMLButtonElement>(".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<HTMLElement>(".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<HTMLInputElement>(".b03-file__slot-input")!;
|
||||
input.accept = state.extensions.join(",");
|
||||
const select = card.querySelector<HTMLButtonElement>(".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<HTMLButtonElement>(".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 {
|
||||
@@ -125,41 +468,193 @@ export function renderB03FileInput(root: HTMLElement): void {
|
||||
}
|
||||
}
|
||||
|
||||
async function detectPausedUploads(): Promise<void> {
|
||||
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<UploadedFileResult[]> {
|
||||
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);
|
||||
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<boolean> {
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
||||
try {
|
||||
const status = await checkWF1AnalysisStatus(projectId);
|
||||
if (status.status === "completed") {
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// 상태 조회 실패는 무시하고 계속 폴링
|
||||
}
|
||||
// 5초마다 폴링 (최대 30분)
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function startChunkedUpload(targetStates = selectedStates()): Promise<void> {
|
||||
activeProjectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? "";
|
||||
const validation = validateSlots();
|
||||
if (validation) {
|
||||
pageError.textContent = validation;
|
||||
return;
|
||||
}
|
||||
|
||||
pageError.textContent = "";
|
||||
const uploaded: UploadedFileResult[] = [];
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
for (const state of targetStates) {
|
||||
uploaded.push(...(await uploadOneFile(activeProjectId, state)));
|
||||
}
|
||||
renderUploadResults(uploaded);
|
||||
showToast(L("B03_File_Upload_Success"), "success");
|
||||
hideLoadingOverlay();
|
||||
|
||||
// WF1 분석 상태 폴링 시작
|
||||
showToast("WF1 분석 진행 중... 완료되면 자동으로 이동합니다.", "info");
|
||||
const analysisComplete = await pollWF1Analysis(activeProjectId);
|
||||
|
||||
if (analysisComplete) {
|
||||
// 분석 완료 시 B04 페이지로 이동
|
||||
navigateTo(ROUTES.B04_WF1_SURFACE);
|
||||
} else {
|
||||
showToast("분석이 진행 중입니다. 잠시 후 새로고침해주세요.", "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");
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
async function registerB03ServiceWorker(): Promise<void> {
|
||||
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 {
|
||||
setSelectedFiles(fileInput.files ? Array.from(fileInput.files) : []);
|
||||
onFileSelected(fileInput.files ? Array.from(fileInput.files) : []);
|
||||
fileInput.value = "";
|
||||
}
|
||||
|
||||
function onB03_File_Drop(event: DragEvent): void {
|
||||
event.preventDefault();
|
||||
dropzone.classList.remove("is-dragging");
|
||||
setSelectedFiles(event.dataTransfer?.files ? Array.from(event.dataTransfer.files) : []);
|
||||
}
|
||||
|
||||
async function onB03_File_Upload_Click(): Promise<void> {
|
||||
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
|
||||
const validationError = validateFiles(selectedFiles);
|
||||
if (!projectId) {
|
||||
errorSlot.textContent = L("B03_File_Error_Project");
|
||||
return;
|
||||
}
|
||||
if (validationError) {
|
||||
errorSlot.textContent = validationError;
|
||||
return;
|
||||
}
|
||||
|
||||
errorSlot.textContent = "";
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
const response = await uploadProjectFiles(projectId, selectedFiles);
|
||||
renderUploadResults(response.files);
|
||||
showToast(L("B03_File_Upload_Success"), "success");
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : L("B03_File_Upload_Failed");
|
||||
errorSlot.textContent = `${L("B03_File_Upload_Failed")} ${detail}`;
|
||||
showToast(L("B03_File_Upload_Failed"), "error");
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
onFileSelected(event.dataTransfer?.files ? Array.from(event.dataTransfer.files) : []);
|
||||
}
|
||||
|
||||
fileInput.addEventListener("change", onB03_File_Select_Change);
|
||||
@@ -174,15 +669,30 @@ export function renderB03FileInput(root: HTMLElement): void {
|
||||
dropzone.addEventListener("dragleave", () => dropzone.classList.remove("is-dragging"));
|
||||
dropzone.addEventListener("drop", onB03_File_Drop);
|
||||
|
||||
const uploadButton = createButton({
|
||||
uploadButton = createButton({
|
||||
label: L("B03_File_Upload_Button"),
|
||||
variant: "filled",
|
||||
onClick: () => void onB03_File_Upload_Click(),
|
||||
onClick: () => void startChunkedUpload(),
|
||||
disabled: true,
|
||||
});
|
||||
const body = document.createElement("div");
|
||||
body.className = "b03-file__body";
|
||||
body.append(dropzone, errorSlot, selectedTitle, selectedList, uploadButton, resultList);
|
||||
page.append(header, createCard({ body: [body], raised: true }));
|
||||
root.replaceChildren(page);
|
||||
renderSelectedFiles();
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user