Files
Aislo/B03_FileInput/B03_FileInput_UI_Page.ts
T
2026-07-17 15:11:31 +09:00

583 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
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,
finalizeUploadSession,
uploadFileChunk,
type UploadedFileResult,
} from "./B03_FileInput_Api_Fetch";
import { navigateTo } from "../A00_Common/router";
import { workflowSteps } from "../A00_Common/b_page_scaffold";
import {
fetchWorkflowState,
goToWorkflowStage,
WORKFLOW_STEP_ROUTES,
} from "../A00_Common/b_workflow_nav";
import {
restoreB03ProjectState,
saveB03UploadedFile,
updateB03AnalysisState,
} from "./B03_FileInput_State";
import {
createFileCardTemplate,
formatBytes,
formatEta,
getExtension,
initializeSlots,
makeSessionKey,
type FileSlot,
type FileSlotState,
type StoredUploadSession,
type UploadStatus,
} from "./B03_FileInput_UI_Support";
import "./B03_FileInput_UI_Style.css";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
export async function renderB03FileInput(root: HTMLElement): Promise<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 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);
const workflowState = activeProjectId
? await fetchWorkflowState(activeProjectId).catch(() => undefined)
: undefined;
const steps = workflowSteps();
const progressContent = createStepBar(steps, 0, {
stages: workflowState?.stages,
currentStage: workflowState?.current_stage,
routes: WORKFLOW_STEP_ROUTES,
orientation: "vertical",
icons: WORKFLOW_STEP_ICONS,
onStepClick: (stepIndex) => {
if (activeProjectId) {
goToWorkflowStage(activeProjectId, WORKFLOW_STEP_ROUTES[stepIndex]);
}
},
});
const layout = createGeneralLayout({
pageClass: "b03-file",
content: [uploadControlPanel, cardsContainer],
});
layout.content.classList.add("b03-file__main-layout");
const overlays = createWorkflowOverlays({
title: L("B03_File_Title"),
progressContent,
showTitlePanel: false,
});
layout.root.append(overlays.root);
root.replaceChildren(layout.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),
});
}
}