- 지형 파일 개수 제한 해제(정확히 1개 → 1장 이상), 한 카드에 여러 장 담기 (화면 표시 「용화_서편.las 외 1장」, 업로드는 한 장씩 차례로 전송) - 구조화 엔진이 여러 파일을 합친 범위로 한 벌 생성 — WF1 자동 전처리·B04 재분석 모두 프로젝트 지형 파일 전부를 대상으로 실행 - 점이 1억 개를 넘으면 씨닝 — 지면 분류점은 전부 남기고 나머지만 0.5m 칸 최저점으로 축소 (용화 실측: 4,900만점 → 249만점, 지면점 1,140,716개 그대로, 1m 지면격자 표고차 0.0000m) - 머리글만 읽어 5km 넘게 떨어진 파일은 업로드 거부 (임도는 길어도 2~3km) - 화면 조립부 700줄 준수를 위해 terrainCoverage 를 판정 모듈로 이동 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
273 lines
10 KiB
TypeScript
273 lines
10 KiB
TypeScript
/* =============================================================================
|
|
* B03_FileInput_UI_Page_Flow.ts
|
|
* 파일 입력 화면의 업로드 흐름 — 중단 세션 이어올리기, 보관함 자료 옮기기,
|
|
* 청크 업로드 시작, 초기 계산 중 재잠금, 서비스워커 등록.
|
|
*
|
|
* 화면 조립(`B03_FileInput_UI_Page.ts`)이 700줄을 넘어 떼어냈다(2026-09-04).
|
|
* 화면이 들고 있는 상태는 `ctx` 로 받아 쓰기만 한다 — 동작·순서는 종전과 같다.
|
|
* ========================================================================== */
|
|
|
|
import { CURRENT_PROJECT_ID_KEY, type RoutePath } from "@config/config_frontend";
|
|
import { createButton, showToast } from "@ui/ui_template_elements";
|
|
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
|
import { navigateTo } from "../A00_Common/router";
|
|
import { clearPreloadMark } from "../A00_Common/b_asset_cache";
|
|
import { clearRouteLatestCache } from "../B05_Profile/B05_Profile_Api_Fetch";
|
|
import { invalidateSectionDetail } from "../B06_Section/B06_Section_Section_Store";
|
|
import { attachTempBatch } from "../B01_Dashboard/B01_Dashboard_Api_Temp";
|
|
import { fetchWorkflowState } from "../A00_Common/b_workflow_nav";
|
|
import { validateSlots } from "./B03_FileInput_UI_Page_Rules";
|
|
import {
|
|
isInitialPipelineRunning,
|
|
pollInitialPipeline,
|
|
uploadOneFile,
|
|
} from "./B03_FileInput_UI_Upload";
|
|
import {
|
|
makeSessionKey,
|
|
type FileSlot,
|
|
type FileSlotState,
|
|
type StoredUploadSession,
|
|
} from "./B03_FileInput_UI_Support";
|
|
|
|
function L(key: keyof typeof ui_locales): string {
|
|
return ui_locales[key][currentLanguageIndex];
|
|
}
|
|
|
|
/** 화면이 들고 있는 상태·조작을 흐름 쪽에 넘겨 주는 창구. */
|
|
export interface UploadFlowContext {
|
|
projectId: () => string;
|
|
setProjectId: (value: string) => void;
|
|
slots: Map<FileSlot, FileSlotState>;
|
|
selectedStates: () => FileSlotState[];
|
|
lasFreeDesign: () => boolean;
|
|
isUploading: () => boolean;
|
|
setUploading: (busy: boolean) => void;
|
|
renderSlot: (slot: FileSlot) => void;
|
|
showErrorMessage: (slot: FileSlot, error: string) => void;
|
|
applyUploadOverview: () => Promise<void>;
|
|
tempPicker: { selected: () => { batch_id: string } | null; clear: () => void };
|
|
pageError: HTMLElement;
|
|
resumeBanner: HTMLElement;
|
|
completionRoute: RoutePath;
|
|
}
|
|
|
|
export interface UploadFlowHandle {
|
|
/** 분석 대기 — 재접속 복원 표시에서도 쓴다. */
|
|
pollAnalysis: (projectId: string) => Promise<boolean>;
|
|
detectPausedUploads: () => Promise<void>;
|
|
relockWhileInitialPipelineRuns: () => Promise<void>;
|
|
startChunkedUpload: (targetStates?: FileSlotState[]) => Promise<void>;
|
|
registerB03ServiceWorker: () => Promise<void>;
|
|
}
|
|
|
|
/** 새 자료가 들어오면 이 프로젝트로 만들어 둔 파생 캐시를 버린다. */
|
|
function clearDerivedCaches(projectId: string): void {
|
|
clearRouteLatestCache(projectId);
|
|
invalidateSectionDetail(projectId);
|
|
clearPreloadMark();
|
|
}
|
|
|
|
export function createUploadFlow(ctx: UploadFlowContext): UploadFlowHandle {
|
|
async function detectPausedUploads(): Promise<void> {
|
|
ctx.setProjectId(localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? "");
|
|
ctx.resumeBanner.replaceChildren();
|
|
ctx.resumeBanner.classList.remove("is-visible");
|
|
if (!ctx.projectId()) return;
|
|
|
|
for (const state of ctx.selectedStates()) {
|
|
const stored = localStorage.getItem(makeSessionKey(ctx.projectId(), state.file!));
|
|
if (!stored) continue;
|
|
const session = JSON.parse(stored) as StoredUploadSession;
|
|
state.uploadSessionId = session.uploadSessionId;
|
|
state.progressBytes = session.completedChunks * session.chunkSizeBytes;
|
|
ctx.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;
|
|
ctx.renderSlot(state.slot);
|
|
ctx.resumeBanner.replaceChildren();
|
|
ctx.resumeBanner.classList.remove("is-visible");
|
|
},
|
|
});
|
|
ctx.resumeBanner.append(text, resume, fresh);
|
|
ctx.resumeBanner.classList.add("is-visible");
|
|
break;
|
|
}
|
|
}
|
|
|
|
/** 분석 대기 — 자동 확정이 보류되면 그 사유를 화면과 알림으로 남긴다. */
|
|
const pollAnalysis = (projectId: string): Promise<boolean> =>
|
|
pollInitialPipeline(projectId, (message: string) => {
|
|
ctx.pageError.textContent = message;
|
|
showToast(message, "warning");
|
|
});
|
|
|
|
/**
|
|
* 보관함 자료를 이 프로젝트로 옮기고 초기 분석까지 이어 간다.
|
|
* 파일을 직접 고른 게 아니라 이미 서버에 있는 자료를 옮기는 것이라 청크 업로드를 타지
|
|
* 않는다 — 이동이 끝나면 같은 분석 대기 흐름으로 합류한다.
|
|
*/
|
|
async function attachSelectedTempBatch(): Promise<void> {
|
|
const batch = ctx.tempPicker.selected();
|
|
if (!batch) return;
|
|
ctx.setProjectId(localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? "");
|
|
if (!ctx.projectId()) {
|
|
ctx.pageError.textContent = L("B03_File_Error_Project");
|
|
return;
|
|
}
|
|
ctx.pageError.textContent = "";
|
|
try {
|
|
const result = await attachTempBatch(ctx.projectId(), batch.batch_id);
|
|
clearDerivedCaches(ctx.projectId());
|
|
ctx.tempPicker.clear();
|
|
showToast(L("B03_Temp_Attach_Success"), "success");
|
|
await ctx.applyUploadOverview();
|
|
if (!result.analysis_started) {
|
|
showToast(L("B03_Temp_Attach_NoAnalysis"), "warning");
|
|
return;
|
|
}
|
|
showToast(L("B03_File_Analysis_InProgress"), "info");
|
|
const analysisComplete = await pollAnalysis(ctx.projectId());
|
|
if (analysisComplete) {
|
|
navigateTo(ctx.completionRoute);
|
|
} else {
|
|
showToast(L("B03_File_Analysis_StillRunning"), "warning");
|
|
}
|
|
} catch (error) {
|
|
const detail = error instanceof Error ? error.message : L("B03_Temp_Attach_Failed");
|
|
ctx.pageError.textContent = `${L("B03_Temp_Attach_Failed")} ${detail}`;
|
|
showToast(L("B03_Temp_Attach_Failed"), "error");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 새로고침·재진입해도 초기 자동 계산이 도는 중이면 업로드 버튼을 다시 잠근다.
|
|
* 잠금이 화면 상태로만 남아 있으면 새로고침 한 번으로 풀려 자료를 겹쳐 올릴 수 있다.
|
|
*/
|
|
async function relockWhileInitialPipelineRuns(): Promise<void> {
|
|
if (!ctx.projectId() || ctx.isUploading()) return;
|
|
try {
|
|
const state = await fetchWorkflowState(ctx.projectId());
|
|
if (!isInitialPipelineRunning(state)) return;
|
|
} catch {
|
|
return; // 상태를 못 읽으면 잠그지 않는다 — 서버가 막아 준다.
|
|
}
|
|
ctx.setUploading(true);
|
|
showToast(L("B03_File_Analysis_InProgress"), "info");
|
|
try {
|
|
const done = await pollAnalysis(ctx.projectId());
|
|
if (done) showToast(L("B03_File_Upload_Success"), "success");
|
|
} finally {
|
|
ctx.setUploading(false);
|
|
void ctx.applyUploadOverview();
|
|
}
|
|
}
|
|
|
|
async function startChunkedUpload(targetStates = ctx.selectedStates()): Promise<void> {
|
|
if (ctx.isUploading()) return;
|
|
// 보관함에서 불러온 자료가 지정돼 있으면 그것을 옮기는 것이 이 버튼의 동작이다.
|
|
if (ctx.tempPicker.selected()) {
|
|
ctx.setUploading(true);
|
|
try {
|
|
await attachSelectedTempBatch();
|
|
} finally {
|
|
ctx.setUploading(false);
|
|
}
|
|
return;
|
|
}
|
|
ctx.setProjectId(localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? "");
|
|
const validation = validateSlots(
|
|
ctx.slots,
|
|
ctx.selectedStates(),
|
|
ctx.projectId(),
|
|
ctx.lasFreeDesign(),
|
|
);
|
|
if (validation) {
|
|
ctx.pageError.textContent = validation;
|
|
return;
|
|
}
|
|
|
|
ctx.pageError.textContent = "";
|
|
ctx.setUploading(true);
|
|
try {
|
|
// 지형 자료는 한 카드에 여러 장이 담길 수 있다 — 카드 순서대로 한 장씩 올린다.
|
|
const jobs = targetStates.flatMap((state) =>
|
|
[state.file!, ...(state.extraFiles ?? [])].map((file) => ({ state, file })),
|
|
);
|
|
for (let index = 0; index < jobs.length; index += 1) {
|
|
const { state, file } = jobs[index];
|
|
if (file !== state.file) {
|
|
// 앞 파일이 쓰던 전송 세션·진행률을 물려받지 않게 되돌린다.
|
|
state.uploadSessionId = undefined;
|
|
state.progressBytes = 0;
|
|
}
|
|
await uploadOneFile(
|
|
ctx.projectId(),
|
|
state,
|
|
index === jobs.length - 1,
|
|
() => ctx.renderSlot(state.slot),
|
|
ctx.lasFreeDesign(),
|
|
file,
|
|
);
|
|
}
|
|
clearDerivedCaches(ctx.projectId());
|
|
// 업로드 결과는 카드가 이미 완료 상태로 보여 준다 — 같은 내용을 목록으로 또 쌓지
|
|
// 않는다(2026-09-03 사용자 지시).
|
|
showToast(L("B03_File_Upload_Success"), "success");
|
|
|
|
showToast(L("B03_File_Analysis_InProgress"), "info");
|
|
const analysisComplete = await pollAnalysis(ctx.projectId());
|
|
|
|
if (analysisComplete) {
|
|
navigateTo(ctx.completionRoute);
|
|
} 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) ctx.showErrorMessage(failed.slot, detail);
|
|
ctx.pageError.textContent = `${L("B03_File_Upload_Failed")} ${detail}`;
|
|
showToast(L("B03_File_Upload_Failed"), "error");
|
|
} finally {
|
|
ctx.setUploading(false);
|
|
}
|
|
}
|
|
|
|
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");
|
|
}
|
|
}
|
|
|
|
return {
|
|
pollAnalysis,
|
|
detectPausedUploads,
|
|
relockWhileInitialPipelineRuns,
|
|
startChunkedUpload,
|
|
registerB03ServiceWorker,
|
|
};
|
|
}
|