Files
Aislo/B11_Status/B11_Status_UI_Loading.ts
eomsangdon f99f1f68d3 feat(B03,B04,B05): 초기 설계가 도는 동안 B05·B06 진입을 막는다
파일을 올리면 지표면 확정 직후 "노선 설계로 이동하세요"를 적고 그 뒤에 초기 설계
체인이 돈다. 그 사이 B05에 들어가 만지면 반쯤 계산된 값을 편집하게 되고, 그 편집이
섞인 채 초기값 스냅샷이 찍힌다. 계산이 끝날 때까지 준비 화면(B11)에서 기다리게 한다.

- initial_snapshot: 마커(initial_design.lock) 생성·해제·조회와 스냅샷 무효화,
  편집 정본 제거를 한곳에 둔다. 마커는 스냅샷 4트리 밖(프로젝트 루트)이라 복원에
  딸려 들어가지 않는다.
- 체인 두 곳(자동·재설계): 시작에 마커, finally에서 해제. 재설계 체인은 끝에 스냅샷을
  무효화한다 — 사용자 입력을 유지한 채 재계산하므로 그 결과를 찍으면 가짜 초기값이
  된다. 다음 [초기화]가 폴백을 타며 새 초기값을 만들고 그때 촬영한다.
- /surface/status: 마커가 있으면 in_progress + initial_design으로 답한다. stage 1이
  COMPLETE면 진행률 파일을 무시하는 기존 분기를 건드리지 않으려고 별도 확인이다.
- 진입 게이트(routeAfterPreloadCheck)와 B11이 그 상태를 보고 기다린다. 상태 조회가
  실패하면 막지 않고, B11 대기는 20분 상한을 둔다.
- [초기화] 폴백: 체인 전에 structures.json과 drainage/edits를 지운다. 남기면 구조물
  측점이 사용자 편집분에서 다시 파생돼 초기값이 오염된다(2026-08-29 실측).

테스트 4건 추가. 249 passed·8 failed(기슭막이 이관 때 생긴 기존 실패, 변경 전 동일).
2026-08-29 15:09:09 +09:00

145 lines
5.6 KiB
TypeScript

import { CURRENT_PROJECT_ID_KEY, ROUTES, type RoutePath } from "@config/config_frontend";
import { createButton } from "@ui/ui_template_elements";
import { createGeneralLayout } from "@ui/ui_template_general_layout";
import { t } from "@ui/ui_template_locale";
import { createProgressCircle } from "@ui/ui_template_progress";
import {
markProjectPreloaded,
preloadSurfaceAssets,
PRELOAD_NO_SURFACE,
purgeOtherProjects,
readPreloadTarget,
} from "../A00_Common/b_asset_cache";
import { navigateTo } from "../A00_Common/router";
import { fetchSurfaceStatus } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import "./B11_Status_UI_Style.css";
/* =============================================================================
* 자료 준비 화면 (B11)
*
* 대시보드에서 프로젝트를 골라 작업 화면으로 들어갈 때, 먼저 이 화면이 3D 지표면과
* 등고선을 브라우저에 담아 둔다. 담아 두면 B04·B05가 그것을 그대로 쓰므로 화면이 바로 뜬다.
* 이미 담겨 있으면 순식간에 지나간다.
*
* 자료를 준비하지 못하면(분석 실패·저장 경로 문제) 넘어가지 않고 사유를 알린다.
* ========================================================================== */
/** 준비에 실패한 채 그냥 넘어갔을 때 남기는 표식 — 어떤 확정 구성과도 일치하지 않는다. */
const PRELOAD_SKIPPED_SIGNATURE = "skipped";
/** 초기 설계 계산이 끝날 때까지 기다린다. 상태 조회가 실패하면 기다리지 않는다 —
* 조회가 막혔다고 사용자를 준비 화면에 가두면 안 된다. */
const DESIGNING_POLL_MS = 3000;
const DESIGNING_MAX_WAIT_MS = 20 * 60 * 1000;
async function waitWhileDesigning(
projectId: string,
onWait: (label: string) => void,
): Promise<void> {
const deadline = Date.now() + DESIGNING_MAX_WAIT_MS;
while (Date.now() < deadline) {
let status;
try {
status = await fetchSurfaceStatus(projectId);
} catch {
return;
}
if (status.current_stage !== "initial_design") return;
onWait(status.message || "초기 설계를 계산하는 중…");
await new Promise((resolve) => window.setTimeout(resolve, DESIGNING_POLL_MS));
}
}
export async function renderB11Loading(root: HTMLElement): Promise<void> {
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
const target = (readPreloadTarget() ?? ROUTES.B03_FILE_INPUT) as RoutePath;
const progress = createProgressCircle({ label: t("B11_Loading_Start"), size: 96 });
const message = document.createElement("p");
message.className = "b11-loading__message";
message.textContent = t("B11_Loading_Message");
const actions = document.createElement("div");
actions.className = "b11-loading__actions";
const body = document.createElement("div");
body.className = "b11-loading__body";
body.append(progress.root, message, actions);
const layout = createGeneralLayout({
pageClass: "b11-status",
title: t("B11_Loading_Title"),
subtitle: t("B11_Loading_Subtitle"),
content: [body],
});
root.replaceChildren(layout.root);
if (!projectId) {
showFailure(t("B11_Loading_NoProject"));
return;
}
try {
// 초기 설계 체인이 도는 동안은 여기서 기다린다(2026-08-29 사용자 확정) — 자료를 담아도
// 계산이 안 끝난 화면으로 보내면 반쯤 된 값을 만지게 된다.
await waitWhileDesigning(projectId, (label) => progress.set(0, label));
// 다른 프로젝트 자료가 남아 있으면 지운다 — 프로젝트끼리 섞이면 안 된다.
await purgeOtherProjects(projectId);
const signature = await preloadSurfaceAssets(projectId, (label, ratio) => {
progress.set(ratio, label);
});
// 표식은 담아 둔 구성 그대로 남긴다 — 확정이 바뀌면 다음 진입에서 다시 준비한다.
markProjectPreloaded(projectId, signature);
navigateTo(target);
} catch (error) {
// 확정 지표면이 아직 없는 것은 고장이 아니라 순서 문제다 — 장애 안내 대신 할 일을 알린다.
if (error instanceof Error && error.message === PRELOAD_NO_SURFACE) {
showNoSurface();
return;
}
const detail = error instanceof Error ? ` (${error.message})` : "";
showFailure(`${t("B11_Loading_Failed")}${detail}`);
}
function showNoSurface(): void {
progress.root.hidden = true;
message.textContent = t("B11_Loading_NoSurface");
actions.replaceChildren(
createButton({
label: t("B11_Loading_Btn_FileInput"),
variant: "filled",
onClick: () => navigateTo(ROUTES.B03_FILE_INPUT),
}),
createButton({
label: t("B11_Loading_Btn_Dashboard"),
variant: "ghost",
onClick: () => navigateTo(ROUTES.B01_ACCOUNT),
}),
);
}
function showFailure(text: string): void {
progress.root.hidden = true;
message.textContent = text;
message.classList.add("b11-loading__message--error");
actions.replaceChildren(
createButton({
label: t("B11_Loading_Btn_Continue"),
variant: "ghost",
// 준비를 못 했어도 같은 세션에서 다시 붙잡지 않도록 표시해 둔다.
// 확정 구성을 모르는 상태이므로 전용 표식을 남긴다 — 확정이 정상화되면 표식이
// 어긋나 준비 화면이 다시 뜬다.
onClick: () => {
if (projectId) markProjectPreloaded(projectId, PRELOAD_SKIPPED_SIGNATURE);
navigateTo(target);
},
}),
createButton({
label: t("B11_Loading_Btn_Dashboard"),
variant: "filled",
onClick: () => navigateTo(ROUTES.B01_ACCOUNT),
}),
);
}
}