697 lines
24 KiB
TypeScript
697 lines
24 KiB
TypeScript
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<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 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 {
|
||
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 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<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 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 {
|
||
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<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);
|
||
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<boolean> {
|
||
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<void> {
|
||
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<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 {
|
||
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),
|
||
});
|
||
}
|
||
}
|