feat(B11): 자료 준비 화면 신설 — 작업 화면 진입 전 3D·등고선 선적재

- B11_Status_UI_Loading + 라우트 b11-loading: 공통 프로그레스 서클로 단계·진행률 표시,
  끝나면 원래 가려던 화면으로 자동 이동
- goToWorkflowStage: 아직 준비하지 않은 프로젝트면 준비 화면 경유, 같은 프로젝트로
  다시 들어오면 건너뜀(탭 단위 기억). 다른 프로젝트면 보관분 교체
- 선적재 범위: 확정 지표면 3D + 그 등고선만 (종단·횡단은 0.86MB·0.06초로 이미 즉시라 제외,
  배수유역·포인트클라우드도 제외)
- 실패 시 자동 이동하지 않고 담당자 연락 안내 + [그래도 이동]/[대시보드로]
- B04 등고선 간격 시작값을 B05 저장값과 공유(B04 변경은 DB에 쓰지 않음)
- 새 문구는 ui_locales에 한국어·영어 등록
This commit is contained in:
2026-08-01 10:34:08 +09:00
parent c970812cf3
commit 77a637d7c9
9 changed files with 259 additions and 33 deletions
+93 -27
View File
@@ -204,35 +204,101 @@ export async function fetchCachedJson<T>(
return JSON.parse(new TextDecoder().decode(bytes)) as T;
}
/** 화면에 쓰기 전에 미리 받아 둔다(대시보드에서 B그룹으로 들어갈 때). 실패는 무시한다. */
export function prefetchAsset(projectId: string, url: string): void {
void fetchCachedBytes(projectId, url).catch(() => {
/* 미리 받기 실패는 화면 동작에 영향을 주지 않는다. */
});
}
/* ── 준비 화면 연동 ────────────────────────────────────────────────────────
* 어느 프로젝트를 이미 준비했는지, 준비가 끝나면 어느 화면으로 보낼지를 탭 단위로 기억한다.
* 대시보드로 나갔다가 같은 프로젝트로 돌아오면 준비 화면을 건너뛴다(2026-08-01 사용자 지시). */
const PRELOADED_PROJECT_KEY = "frd_preloaded_project";
const PRELOAD_TARGET_KEY = "frd_preload_target";
/** 확정된 지표면의 3D 파일과 등고선을 미리 받아 둔다.
*
* 사용자가 실제로 보는 것은 이 둘이라 이것만 챙긴다(포인트클라우드는 제외 — 2026-08-01
* 사용자 지시). 이미 보관함에 있으면 아무 것도 하지 않는다. */
export async function prefetchSurfaceAssets(projectId: string): Promise<void> {
export function isProjectPreloaded(projectId: string): boolean {
try {
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/surface/models`, {
credentials: "include",
});
if (!response.ok) return;
const data = (await response.json()) as {
models?: Array<{ id: number; model_type?: string; status?: string }>;
};
const confirmed = (data.models ?? []).find((model) => model.status === "CONFIRMED");
if (!confirmed) return;
// 스무딩 지원 방식(dtm·tin)은 화면 기본값이 스무딩 적용본이다.
const method = (confirmed.model_type ?? "").toLowerCase();
const smooth = method === "dtm" || method === "tin";
const base = `${API_BASE_URL}/projects/${projectId}/surface/models/${confirmed.id}`;
prefetchAsset(projectId, `${base}/preview?smooth=${smooth}`);
prefetchAsset(projectId, `${base}/contour?interval=1&smooth=${smooth}&recalculate=false`);
return window.sessionStorage.getItem(PRELOADED_PROJECT_KEY) === projectId;
} catch {
/* 미리 받기는 실패해도 화면 동작에 영향을 주지 않는다. */
return false;
}
}
export function markProjectPreloaded(projectId: string): void {
try {
window.sessionStorage.setItem(PRELOADED_PROJECT_KEY, projectId);
} catch {
/* 세션 저장 실패는 준비 화면이 한 번 더 뜨는 정도의 영향뿐이다. */
}
}
export function setPreloadTarget(route: string): void {
try {
window.sessionStorage.setItem(PRELOAD_TARGET_KEY, route);
} catch {
/* 저장 실패 시 준비 화면이 기본 화면으로 보낸다. */
}
}
export function readPreloadTarget(): string | null {
try {
return window.sessionStorage.getItem(PRELOAD_TARGET_KEY);
} catch {
return null;
}
}
/** 준비 화면이 표시할 단계 안내. ratio가 null이면 진행률을 모른다는 뜻이다. */
export type PreloadReporter = (label: string, ratio: number | null) => void;
/** 사용자가 고른 등고선 간격(B05에서 저장한 값). 없으면 1.0m.
* B04(관리자 확인용 화면)도 이 값을 시작값으로 쓴다 — 사용자가 정한 값이 우선이다. */
export async function fetchUserContourInterval(projectId: string): Promise<number> {
try {
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/route/latest`, {
credentials: "include",
});
if (!response.ok) return 1.0;
const data = (await response.json()) as {
surface_params?: { contour_interval_m?: number };
};
const interval = data.surface_params?.contour_interval_m;
return typeof interval === "number" && interval > 0 ? interval : 1.0;
} catch {
return 1.0;
}
}
/** 확정된 지표면의 3D 파일과 그 등고선을 보관함에 채운다(준비 화면에서 호출).
*
* 사용자가 실제로 보는 것은 이 둘이라 이것만 챙긴다 — 포인트클라우드·배수유역은 제외
* (2026-08-01 사용자 지시). 이미 보관돼 있으면 거의 즉시 끝난다.
* 확정 지표면을 찾지 못하면 오류를 던져 준비 화면이 안내 문구를 띄우게 한다. */
export async function preloadSurfaceAssets(
projectId: string,
report: PreloadReporter = () => {},
): Promise<void> {
report("확정된 지표면을 확인하는 중…", null);
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/surface/models`, {
credentials: "include",
});
if (!response.ok) throw new Error("지표면 목록을 불러오지 못했습니다.");
const data = (await response.json()) as {
models?: Array<{ id: number; model_type?: string; status?: string }>;
};
const confirmed = (data.models ?? []).find((model) => model.status === "CONFIRMED");
if (!confirmed) throw new Error("확정된 지표면 모델이 없습니다.");
// 스무딩을 지원하는 방식(dtm·tin)은 화면 기본값이 스무딩 적용본이다.
const method = (confirmed.model_type ?? "").toLowerCase();
const smooth = method === "dtm" || method === "tin";
const base = `${API_BASE_URL}/projects/${projectId}/surface/models/${confirmed.id}`;
const interval = await fetchUserContourInterval(projectId);
report("3D 지표면을 준비하는 중…", 0);
await fetchCachedBytes(projectId, `${base}/preview?smooth=${smooth}`, {
onProgress: (ratio) => report("3D 지표면을 준비하는 중…", ratio),
});
report("등고선을 준비하는 중…", null);
await fetchCachedBytes(
projectId,
`${base}/contour?interval=${interval}&smooth=${smooth}&recalculate=false`,
{ onProgress: (ratio) => report("등고선을 준비하는 중…", ratio) },
);
report("준비 완료", 1);
}
+9 -4
View File
@@ -5,7 +5,7 @@ import {
type RoutePath,
} from "@config/config_frontend";
import type { WorkflowStage } from "@ui/ui_template_workflow_layout";
import { prefetchSurfaceAssets, purgeOtherProjects } from "./b_asset_cache";
import { isProjectPreloaded, setPreloadTarget } from "./b_asset_cache";
import { navigateTo } from "./router";
export interface WorkflowState {
@@ -37,8 +37,13 @@ export async function fetchWorkflowState(projectId: string): Promise<WorkflowSta
export function goToWorkflowStage(projectId: string, route: RoutePath): void {
localStorage.setItem(CURRENT_PROJECT_ID_KEY, projectId);
// 다른 프로젝트 자료를 지우고, 이 프로젝트의 지표면 3D·등고선을 미리 받아 둔다.
// 화면 이동을 막지 않도록 뒤에서 돌린다.
void purgeOtherProjects(projectId).then(() => prefetchSurfaceAssets(projectId));
// 프로젝트를 아직 준비하지 않았으면 준비 화면(B11)을 먼저 거친다. 거기서 3D 지표면과
// 등고선을 브라우저에 담고 원래 가려던 화면으로 넘겨준다. 같은 프로젝트로 다시 들어오면
// 건너뛴다(2026-08-01 사용자 지시).
if (!isProjectPreloaded(projectId)) {
setPreloadTarget(route);
navigateTo(ROUTES.B11_LOADING);
return;
}
navigateTo(route);
}
+3
View File
@@ -55,6 +55,8 @@ const routeTable: Partial<Record<RoutePath, () => Promise<PageRenderer>>> = {
(await import("../B10_Payment/B10_Payment_UI_Page")).renderB10Payment,
[ROUTES.B11_STATUS]: async () =>
(await import("../B11_Status/B11_Status_UI_Page")).renderB11Status,
[ROUTES.B11_LOADING]: async () =>
(await import("../B11_Status/B11_Status_UI_Loading")).renderB11Loading,
};
/** 로그인 여부 (토큰 존재 확인) */
@@ -112,6 +114,7 @@ export async function renderCurrentRoute(outlet: HTMLElement): Promise<void> {
ROUTES.B09_WF6_ESTIMATION,
ROUTES.B10_PAYMENT,
ROUTES.B11_STATUS,
ROUTES.B11_LOADING,
];
if (workflowRoutes.includes(route)) {
+6 -2
View File
@@ -9,7 +9,7 @@ import {
} from "@ui/ui_template_elements";
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
import { fetchDashboardMe } from "../B01_Dashboard/B01_Dashboard_Api_Fetch";
import { purgeOtherProjects } from "../A00_Common/b_asset_cache";
import { fetchUserContourInterval, purgeOtherProjects } from "../A00_Common/b_asset_cache";
import { workflowSteps } from "../A00_Common/b_page_scaffold";
import {
fetchWorkflowState,
@@ -320,12 +320,16 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
}
async function loadProjectData(projectId: string): Promise<void> {
const [inputs, status, modelResponse] = await Promise.all([
const [inputs, status, modelResponse, contourInterval] = await Promise.all([
listSurfaceInputFiles(projectId),
fetchSurfaceStatus(projectId),
listSurfaceModels(projectId),
// 등고선 간격은 사용자가 B05에서 저장한 값을 시작값으로 쓴다. 여기서 바꿔도 DB에는
// 저장하지 않는다 — 관리자 확인용이라 사용자 설정을 건드리지 않는다(2026-08-01).
fetchUserContourInterval(projectId),
]);
models = modelResponse.models;
terrainViewer.setContourInterval(contourInterval);
renderInputFiles(inputs.files);
renderStatus(status);
viewer.setLoading("포인트 데이터 로딩 중…");
@@ -27,6 +27,8 @@ export interface SurfaceTerrainViewer {
onAxesVisibilityChange: (listener: (visible: boolean) => void) => void;
isSmoothingEnabled: () => boolean;
getContourInterval: () => number;
/** 등고선 간격 시작값을 정한다(사용자가 B05에서 저장한 값). 다시 그리지는 않는다. */
setContourInterval: (interval: number) => void;
resetOptions: () => void;
dispose: () => void;
}
@@ -701,6 +703,9 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
isSmoothingEnabled() {
return !smoothCheck.disabled && smoothCheck.checked;
},
setContourInterval(interval) {
if (Number.isFinite(interval) && interval > 0) intervalInput.value = String(interval);
},
getContourInterval() {
return Number.parseFloat(intervalInput.value);
},
+88
View File
@@ -0,0 +1,88 @@
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,
purgeOtherProjects,
readPreloadTarget,
} from "../A00_Common/b_asset_cache";
import { navigateTo } from "../A00_Common/router";
import "./B11_Status_UI_Style.css";
/* =============================================================================
* (B11)
*
* , 3D
* . B04·B05가 .
* .
*
* ( · ) .
* ========================================================================== */
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 {
// 다른 프로젝트 자료가 남아 있으면 지운다 — 프로젝트끼리 섞이면 안 된다.
await purgeOtherProjects(projectId);
await preloadSurfaceAssets(projectId, (label, ratio) => {
progress.set(ratio, label);
});
markProjectPreloaded(projectId);
navigateTo(target);
} catch (error) {
const detail = error instanceof Error ? ` (${error.message})` : "";
showFailure(`${t("B11_Loading_Failed")}${detail}`);
}
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);
navigateTo(target);
},
}),
createButton({
label: t("B11_Loading_Btn_Dashboard"),
variant: "filled",
onClick: () => navigateTo(ROUTES.B01_ACCOUNT),
}),
);
}
}
+34
View File
@@ -26,6 +26,40 @@
color: var(--color-text-body);
}
/* 자료 준비 화면 — 서클과 안내 문구를 가운데 모아 둔다. */
.b11-loading__body {
display: flex;
min-height: 320px;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--spacing-24);
padding: var(--spacing-24);
}
.b11-loading__message {
max-width: 46ch;
margin: 0;
color: var(--color-text-secondary);
font-size: var(--text-body-sm);
line-height: 1.6;
text-align: center;
word-break: keep-all;
}
.b11-loading__message--error {
color: var(--color-danger);
}
.b11-loading__actions {
display: flex;
gap: var(--spacing-8);
}
.b11-loading__actions:empty {
display: none;
}
@media (max-width: 860px) {
.b11-status__flow {
grid-template-columns: 1fr 1fr;
+3
View File
@@ -92,6 +92,8 @@ export const ROUTES = {
B09_WF6_ESTIMATION: "b09-wf6-estimation",
B10_PAYMENT: "b10-payment",
B11_STATUS: "b11-status",
// 대시보드에서 B그룹으로 처음 들어갈 때 3D·등고선을 미리 받아 두는 준비 화면.
B11_LOADING: "b11-loading",
} as const;
export type RouteKey = keyof typeof ROUTES;
@@ -113,6 +115,7 @@ export const PROTECTED_ROUTES: readonly RoutePath[] = [
ROUTES.B09_WF6_ESTIMATION,
ROUTES.B10_PAYMENT,
ROUTES.B11_STATUS,
ROUTES.B11_LOADING,
];
/* -----------------------------------------------------------------------------
+18
View File
@@ -988,6 +988,24 @@ export const ui_locales = {
"결재·문서 생성 상태를 확인하고 결과물을 내려받으세요.",
"Check payment and document status, and download results.",
],
// 자료 준비 화면 (대시보드 → 작업 화면 진입 시 3D·등고선 선적재)
B11_Loading_Title: ["자료 준비 중", "Preparing data"],
B11_Loading_Subtitle: ["잠시만 기다려 주세요.", "This will take a moment."],
B11_Loading_Message: [
"작업 화면에서 바로 쓸 수 있도록 3D 지표면과 등고선을 준비합니다.",
"Loading the 3D surface and contours so the workspace opens instantly.",
],
B11_Loading_Start: ["자료를 준비하는 중…", "Preparing data…"],
B11_Loading_NoProject: [
"프로젝트가 선택되지 않았습니다. 대시보드에서 프로젝트를 먼저 고르세요.",
"No project selected. Choose a project on the dashboard first.",
],
B11_Loading_Failed: [
"자료를 준비하지 못했습니다. 분석 결과나 저장 경로에 문제가 있을 수 있습니다. 담당자에게 연락해 주세요.",
"Could not prepare the data. The analysis result or storage path may be broken. Please contact support.",
],
B11_Loading_Btn_Continue: ["그래도 화면으로 이동", "Continue anyway"],
B11_Loading_Btn_Dashboard: ["대시보드로", "Back to dashboard"],
B11_Status_Flow_Title: ["결재 진행 상태", "Payment Progress"],
B11_Status_Step_Request: ["발행 요청", "Invoice Requested"],
B11_Status_Step_Issue: ["세금계산서 발행", "Invoice Issued"],