diff --git a/A00_Common/b_asset_cache.ts b/A00_Common/b_asset_cache.ts index 12ff208b..be365263 100644 --- a/A00_Common/b_asset_cache.ts +++ b/A00_Common/b_asset_cache.ts @@ -204,35 +204,101 @@ export async function fetchCachedJson( 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 { +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 { + 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 { + 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); +} diff --git a/A00_Common/b_workflow_nav.ts b/A00_Common/b_workflow_nav.ts index 04b88720..f8c62270 100644 --- a/A00_Common/b_workflow_nav.ts +++ b/A00_Common/b_workflow_nav.ts @@ -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 prefetchSurfaceAssets(projectId)); + // 이 프로젝트를 아직 준비하지 않았으면 준비 화면(B11)을 먼저 거친다. 거기서 3D 지표면과 + // 등고선을 브라우저에 담고 원래 가려던 화면으로 넘겨준다. 같은 프로젝트로 다시 들어오면 + // 건너뛴다(2026-08-01 사용자 지시). + if (!isProjectPreloaded(projectId)) { + setPreloadTarget(route); + navigateTo(ROUTES.B11_LOADING); + return; + } navigateTo(route); } diff --git a/A00_Common/router.ts b/A00_Common/router.ts index 3a2c83ef..df75959b 100644 --- a/A00_Common/router.ts +++ b/A00_Common/router.ts @@ -55,6 +55,8 @@ const routeTable: Partial Promise>> = { (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 { ROUTES.B09_WF6_ESTIMATION, ROUTES.B10_PAYMENT, ROUTES.B11_STATUS, + ROUTES.B11_LOADING, ]; if (workflowRoutes.includes(route)) { diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts index 06bda3b8..64519cb6 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts @@ -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 { } async function loadProjectData(projectId: string): Promise { - 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("포인트 데이터 로딩 중…"); diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts index 658c7fe5..aca1d247 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts @@ -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); }, diff --git a/B11_Status/B11_Status_UI_Loading.ts b/B11_Status/B11_Status_UI_Loading.ts new file mode 100644 index 00000000..72f3a0a7 --- /dev/null +++ b/B11_Status/B11_Status_UI_Loading.ts @@ -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 { + 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), + }), + ); + } +} diff --git a/B11_Status/B11_Status_UI_Style.css b/B11_Status/B11_Status_UI_Style.css index e0862773..9d87977f 100644 --- a/B11_Status/B11_Status_UI_Style.css +++ b/B11_Status/B11_Status_UI_Style.css @@ -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; diff --git a/config/config_frontend.ts b/config/config_frontend.ts index 55a4ddde..e6fefe39 100644 --- a/config/config_frontend.ts +++ b/config/config_frontend.ts @@ -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, ]; /* ----------------------------------------------------------------------------- diff --git a/ui_template/ui_template_locale.ts b/ui_template/ui_template_locale.ts index 8061fda4..76f96a69 100644 --- a/ui_template/ui_template_locale.ts +++ b/ui_template/ui_template_locale.ts @@ -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"],