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)) {