perf(B04/B05): 확정 지표면 요약 API 신설 + 포인트클라우드 24MB 수신 제거

- GET /surface/confirmed 추가: 확정 구성(모델·필터·표현·평활·등고선간격)과
  지형 가장자리만 반환(수 KB, 0.02s). 진입 판정·준비화면·B05의 단일 출처.
- B05 진입이 받던 포인트클라우드 JSON 23.8MB 제거 — 실제로 쓰던 값은 bounds뿐.
- 준비 표식을 프로젝트 ID에서 확정 signature로 변경: B04에서 다시 확정하면
  대시보드 복귀·새 브라우저·B그룹 단계 이동 어느 경로로 들어와도 최신본을 담는다.
- preloadSurfaceAssets가 평활 여부를 추측하던 부분 제거(확정 저장값 사용) —
  추측이 어긋나면 같은 지형을 두 번 내려받았다.
- B04 진입 시 필터·표현·평활·등고선간격을 확정본 값으로 초기화(확정 없으면 기존 기본값).
- 모델 확정 직후 준비 표식과 B05 세션 캐시를 비워 옛 지형이 남지 않게 함.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-01 11:34:44 +09:00
co-authored by Claude Opus 5
parent 77a637d7c9
commit 96561de120
10 changed files with 243 additions and 65 deletions
+36 -41
View File
@@ -10,6 +10,7 @@
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { fetchConfirmedSurface } from "../B04_wf1_Surface/B04_wf1_Surface_Api_Fetch";
const DB_NAME = "aislo-asset-cache";
const DB_VERSION = 1;
@@ -205,27 +206,44 @@ export async function fetchCachedJson<T>(
}
/* ── 준비 화면 연동 ────────────────────────────────────────────────────────
* 어느 프로젝트를 이미 준비했는지, 준비가 끝나면 어느 화면으로 보낼지를 탭 단위로 기억한다.
* 대시보드로 나갔다가 같은 프로젝트로 돌아오면 준비 화면을 건너뛴다(2026-08-01 사용자 지시). */
const PRELOADED_PROJECT_KEY = "frd_preloaded_project";
* 무엇을 이미 준비했는지, 준비가 끝나면 어느 화면으로 보낼지를 탭 단위로 기억한다.
* 대시보드로 나갔다가 같은 프로젝트로 돌아오면 준비 화면을 건너뛴다(2026-08-01 사용자 지시).
*
* 표식은 프로젝트 번호가 아니라 **확정 구성(signature)** 이다. 관리자가 B04에서 다른
* 필터·표현으로 다시 확정하면 표식이 달라져 준비 화면이 한 번 더 돌고 새 자료를 담는다.
* 프로젝트 번호만 봤다면 옛 자료를 계속 쓰게 된다(2026-08-01 사용자 지시). */
const PRELOADED_SIGNATURE_KEY = "frd_preloaded_signature";
const PRELOAD_TARGET_KEY = "frd_preload_target";
export function isProjectPreloaded(projectId: string): boolean {
const preloadStamp = (projectId: string, signature: string): string => `${projectId}|${signature}`;
export function isProjectPreloaded(projectId: string, signature: string): boolean {
try {
return window.sessionStorage.getItem(PRELOADED_PROJECT_KEY) === projectId;
return (
window.sessionStorage.getItem(PRELOADED_SIGNATURE_KEY) === preloadStamp(projectId, signature)
);
} catch {
return false;
}
}
export function markProjectPreloaded(projectId: string): void {
export function markProjectPreloaded(projectId: string, signature: string): void {
try {
window.sessionStorage.setItem(PRELOADED_PROJECT_KEY, projectId);
window.sessionStorage.setItem(PRELOADED_SIGNATURE_KEY, preloadStamp(projectId, signature));
} catch {
/* 세션 저장 실패는 준비 화면이 한 번 더 뜨는 정도의 영향뿐이다. */
}
}
/** 담아 둔 표식을 지운다 — 확정이 바뀌어 자료를 다시 담아야 할 때 호출한다. */
export function clearPreloadMark(): void {
try {
window.sessionStorage.removeItem(PRELOADED_SIGNATURE_KEY);
} catch {
/* 지우지 못해도 다음 표식 비교에서 불일치로 걸러진다. */
}
}
export function setPreloadTarget(route: string): void {
try {
window.sessionStorage.setItem(PRELOAD_TARGET_KEY, route);
@@ -245,49 +263,25 @@ export function readPreloadTarget(): string | 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 사용자 지시). 이미 보관돼 있으면 거의 즉시 끝난다.
* 확정 지표면을 찾지 못하면 오류를 던져 준비 화면이 안내 문구를 띄우게 한다. */
* 평활 여부·등고선 간격은 짐작하지 않고 확정 저장값을 그대로 쓴다 — 짐작하면 B04·B05가
* 서로 다른 파일을 받아 같은 지형을 두 번 내려받게 된다.
* 확정 지표면을 찾지 못하면 오류를 던져 준비 화면이 안내 문구를 띄우게 한다.
* 반환값은 담아 둔 구성의 signature — 호출측이 준비 표식으로 저장한다. */
export async function preloadSurfaceAssets(
projectId: string,
report: PreloadReporter = () => {},
): Promise<void> {
): Promise<string> {
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("확정된 지표면 모델이 없습니다.");
const confirmed = await fetchConfirmedSurface(projectId);
if (!confirmed.model_id) 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);
const smooth = confirmed.smooth ?? false;
const interval = confirmed.contour_interval_m ?? 1.0;
const base = `${API_BASE_URL}/projects/${projectId}/surface/models/${confirmed.model_id}`;
report("3D 지표면을 준비하는 중…", 0);
await fetchCachedBytes(projectId, `${base}/preview?smooth=${smooth}`, {
@@ -301,4 +295,5 @@ export async function preloadSurfaceAssets(
{ onProgress: (ratio) => report("등고선을 준비하는 중…", ratio) },
);
report("준비 완료", 1);
return confirmed.signature;
}
+23 -7
View File
@@ -5,6 +5,7 @@ import {
type RoutePath,
} from "@config/config_frontend";
import type { WorkflowStage } from "@ui/ui_template_workflow_layout";
import { fetchConfirmedSurface } from "../B04_wf1_Surface/B04_wf1_Surface_Api_Fetch";
import { isProjectPreloaded, setPreloadTarget } from "./b_asset_cache";
import { navigateTo } from "./router";
@@ -37,13 +38,28 @@ export async function fetchWorkflowState(projectId: string): Promise<WorkflowSta
export function goToWorkflowStage(projectId: string, route: RoutePath): void {
localStorage.setItem(CURRENT_PROJECT_ID_KEY, projectId);
// 이 프로젝트를 아직 준비하지 않았으면 준비 화면(B11)을 먼저 거친다. 거기서 3D 지표면과
// 등고선을 브라우저에 담고 원래 가려던 화면으로 넘겨준다. 같은 프로젝트로 다시 들어오면
// 건너뛴다(2026-08-01 사용자 지시).
if (!isProjectPreloaded(projectId)) {
setPreloadTarget(route);
navigateTo(ROUTES.B11_LOADING);
void routeAfterPreloadCheck(projectId, route);
}
/**
* 담아 둔 자료가 지금 확정본과 같은지 확인하고 화면을 정한다.
*
* 확정 구성을 묻는 요청은 수 KB라 이동할 때마다 물어도 부담이 없다. 담아 둔 표식과 다르면
* (관리자가 B04에서 다시 확정했거나, 새 브라우저이거나, 다른 프로젝트를 들렀다 온 경우)
* 준비 화면(B11)을 거쳐 최신 자료를 담고 원래 가려던 화면으로 넘어간다(2026-08-01 사용자 지시).
* 확인에 실패하면 준비 화면으로 보내 거기서 사유를 안내한다.
*/
async function routeAfterPreloadCheck(projectId: string, route: RoutePath): Promise<void> {
let signature: string | null = null;
try {
signature = (await fetchConfirmedSurface(projectId)).signature;
} catch {
signature = null;
}
if (signature && isProjectPreloaded(projectId, signature)) {
navigateTo(route);
return;
}
navigateTo(route);
setPreloadTarget(route);
navigateTo(ROUTES.B11_LOADING);
}
@@ -111,6 +111,29 @@ export interface SurfaceModelListResponse {
models: SurfaceModelSummary[];
}
/** 확정 지표면 요약 (SurfaceConfirmedResponse).
* 포인트 배열 없이 확정 구성과 지형 가장자리만 담는다 — 진입 판정·준비화면·B05 공용. */
export interface SurfaceConfirmedResponse {
status: string;
project_id: string;
model_id: number | null;
source_filter: string | null;
method: string | null;
smooth: boolean | null;
contour_interval_m: number | null;
/** 확정 구성이 바뀌었는지 한 줄로 비교하기 위한 값. */
signature: string;
point_count: number | null;
bounds: {
x_min: number;
x_max: number;
y_min: number;
y_max: number;
z_min: number;
z_max: number;
} | null;
}
/** 공통 fetch 헬퍼: 타임아웃 + 인증 헤더 + 오류 응답 변환.
*
* `timeoutMs`를 주면 그 값으로 끊는다. 배수유역 격자 해석처럼 수십 초가 걸리는 요청은
@@ -199,6 +222,14 @@ export async function fetchSurfacePointCloud(
);
}
/** 확정 지표면 구성 + 지형 가장자리만 조회한다(수 KB).
* 포인트클라우드 전체(수십 MB)를 받지 않고도 3D 좌표 환산에 필요한 값을 얻는다. */
export async function fetchConfirmedSurface(projectId: string): Promise<SurfaceConfirmedResponse> {
return requestJson<SurfaceConfirmedResponse>(`/projects/${projectId}/surface/confirmed`, {
method: "GET",
});
}
export async function fetchSurfaceGroundStats(
projectId: string,
): Promise<SurfaceGroundStatsResponse> {
+79 -1
View File
@@ -28,6 +28,7 @@ from B04_wf1_Surface.B04_wf1_Surface_Repository import (
from B04_wf1_Surface.B04_wf1_Surface_Schema import (
SurfaceAnalyzeRequest,
SurfaceAnalyzeResponse,
SurfaceConfirmedResponse,
SurfaceConfirmRequest,
SurfaceConfirmResponse,
SurfaceGroundStatsResponse,
@@ -42,7 +43,10 @@ from common_util.common_util_auth import require_system_admin
from common_util.common_util_http_cache import cached_file_response
from common_util.common_util_json import atomic_write_json
from common_util.common_util_storage import resolve_stored_project_path
from common_util.common_util_surface_confirmation import surface_confirmation_defaults
from common_util.common_util_surface_confirmation import (
get_surface_confirmation_params,
surface_confirmation_defaults,
)
from common_util.common_util_workflow_state import (
fail_stage,
start_stage,
@@ -368,6 +372,80 @@ async def get_surface_point_cloud(
)
@router.get("/{project_id}/surface/confirmed", response_model=SurfaceConfirmedResponse)
async def get_confirmed_surface(project_id: UUID) -> SurfaceConfirmedResponse | JSONResponse:
"""확정 지표면 구성과 지형 가장자리만 반환한다(포인트 배열 없음).
B05 3D 배치·B11 준비화면·진입 판정이 모두 이 응답 하나를 기준으로 삼는다.
구성이 바뀌면 signature가 달라지므로 프론트가 담아 둔 자료의 갱신 여부를 판단할 수 있다.
"""
pool = get_db_pool()
try:
async with pool.acquire() as connection:
stored_path = await get_project_storage_relative_path(connection, project_id)
models = await list_surface_models(connection, project_id)
params = await get_surface_confirmation_params(connection, str(project_id))
confirmed = next((model for model in models if model["status"] == "CONFIRMED"), None)
source_filter = params.get("source_filter")
# 가장자리는 B05가 3D 마커 좌표를 환산할 때 쓰므로, 기존 포인트클라우드 응답과
# 같은 파일(확정 필터의 지면 포인트)에서 읽어 값이 어긋나지 않게 한다.
processed_dir = Path(resolve_stored_project_path(stored_path)) / "B04_wf1_Surface"
processed_dir = processed_dir / "processed"
source_path = processed_dir / "structured.npz"
if source_filter:
filtered = processed_dir / f"ground_points_{source_filter}.npz"
if filtered.is_file():
source_path = filtered
bounds_payload: dict[str, float] | None = None
point_count: int | None = None
if source_path.is_file():
with np.load(source_path) as stored:
bounds = np.asarray(stored["bounds"], dtype=np.float64)
if "point_count" in stored:
point_count = int(stored["point_count"])
bounds_payload = {
"x_min": float(bounds[0, 0]),
"x_max": float(bounds[0, 1]),
"y_min": float(bounds[1, 0]),
"y_max": float(bounds[1, 1]),
"z_min": float(bounds[2, 0]),
"z_max": float(bounds[2, 1]),
}
signature = "|".join(
str(value)
for value in (
confirmed["id"] if confirmed else "none",
source_filter,
params.get("method"),
params.get("smooth"),
params.get("contour_interval_m"),
)
)
return SurfaceConfirmedResponse(
project_id=str(project_id),
model_id=int(confirmed["id"]) if confirmed else None,
source_filter=source_filter,
method=params.get("method"),
smooth=params.get("smooth"),
contour_interval_m=params.get("contour_interval_m"),
signature=signature,
point_count=point_count,
bounds=bounds_payload,
)
except LookupError as exc:
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
except Exception:
logger.exception("B04 확정 지표면 요약 조회 실패: project_id=%s", project_id)
return JSONResponse(
status_code=500,
content={"status": "error", "message": "확정 지표면 정보를 불러오지 못했습니다."},
)
@router.get("/{project_id}/surface/ground-stats", response_model=SurfaceGroundStatsResponse)
async def get_surface_ground_stats(project_id: UUID) -> SurfaceGroundStatsResponse | JSONResponse:
"""manifest에서 필터별 지면 포인트 통계를 반환한다."""
+19
View File
@@ -110,6 +110,25 @@ class SurfacePointCloudSampleResponse(BaseModel):
rgb: list[list[int]] | None = None
class SurfaceConfirmedResponse(BaseModel):
"""확정 지표면 요약 — 화면 진입 판정·준비화면·B05가 공통으로 쓰는 단일 출처.
포인트 배열 없이 확정값과 지형 가장자리만 담아 수 KB로 유지한다.
signature는 확정 구성이 바뀌었는지 프론트가 한 줄로 비교하기 위한 값이다.
"""
status: str = "success"
project_id: str
model_id: int | None = None
source_filter: str | None = None
method: str | None = None
smooth: bool | None = None
contour_interval_m: float | None = None
signature: str
point_count: int | None = None
bounds: dict[str, float] | None = None
class SurfaceGroundStatsResponse(BaseModel):
"""필터별 지면 포인트 통계 응답."""
+20 -6
View File
@@ -9,7 +9,8 @@ import {
} from "@ui/ui_template_elements";
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
import { fetchDashboardMe } from "../B01_Dashboard/B01_Dashboard_Api_Fetch";
import { fetchUserContourInterval, purgeOtherProjects } from "../A00_Common/b_asset_cache";
import { clearPreloadMark, purgeOtherProjects } from "../A00_Common/b_asset_cache";
import { clearRouteLatestCache } from "../B05_wf2_Route/B05_wf2_Route_Api_Fetch";
import { workflowSteps } from "../A00_Common/b_page_scaffold";
import {
fetchWorkflowState,
@@ -19,6 +20,7 @@ import {
} from "../A00_Common/b_workflow_nav";
import {
confirmSurfaceModel,
fetchConfirmedSurface,
fetchSurfacePointCloud,
fetchSurfaceStatus,
listSurfaceInputFiles,
@@ -320,16 +322,24 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
}
async function loadProjectData(projectId: string): Promise<void> {
const [inputs, status, modelResponse, contourInterval] = await Promise.all([
const [inputs, status, modelResponse, confirmed] = await Promise.all([
listSurfaceInputFiles(projectId),
fetchSurfaceStatus(projectId),
listSurfaceModels(projectId),
// 등고선 간격은 사용자가 B05에서 저장한 값을 시작값으로 쓴다. 여기서 바꿔도 DB에는
// 저장하지 않는다 — 관리자 확인용이라 사용자 설정을 건드리지 않는다(2026-08-01).
fetchUserContourInterval(projectId),
// 확정본 구성(필터·표현·평활·등고선 간격)을 그대로 시작값으로 쓴다. 여기서 바꿔도
// DB에는 저장하지 않는다 — 관리자 확인용이라 사용자 설정을 건드리지 않는다(2026-08-01).
fetchConfirmedSurface(projectId),
]);
models = modelResponse.models;
terrainViewer.setContourInterval(contourInterval);
// 확정본과 같은 조합에서 시작해야 B05와 같은 파일을 보고, 보관함도 한 벌만 쓴다.
// 확정 이력이 없을 때만 개발 기본값(csf·dtm)으로 둔다.
if (confirmed.model_id) {
if (confirmed.source_filter) filterGroup.select.value = confirmed.source_filter;
if (confirmed.method) methodGroup.select.value = confirmed.method;
terrainViewer.setSmoothing(confirmed.smooth ?? false);
}
if (confirmed.contour_interval_m)
terrainViewer.setContourInterval(confirmed.contour_interval_m);
renderInputFiles(inputs.files);
renderStatus(status);
viewer.setLoading("포인트 데이터 로딩 중…");
@@ -360,6 +370,10 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
smooth: terrainViewer.isSmoothingEnabled(),
contour_interval_m: terrainViewer.getContourInterval(),
});
// 확정본이 바뀌었으므로 브라우저가 담아 둔 옛 자료를 더 이상 쓰지 않게 한다.
// 준비 표식을 지우면 아래 goToWorkflowStage가 준비 화면을 거쳐 새 자료를 담는다.
clearPreloadMark();
clearRouteLatestCache(projectId);
showToast(
L("B04_Surface_Confirm_Success")
.replace("{filter}", filterGroup.select.value)
@@ -26,6 +26,8 @@ export interface SurfaceTerrainViewer {
onCameraChange: (listener: (state: SurfaceCameraState) => void) => void;
onAxesVisibilityChange: (listener: (visible: boolean) => void) => void;
isSmoothingEnabled: () => boolean;
/** 스무딩 시작값을 정한다(확정본 저장값). 다시 그리지는 않는다. */
setSmoothing: (enabled: boolean) => void;
getContourInterval: () => number;
/** 등고선 간격 시작값을 정한다(사용자가 B05에서 저장한 값). 다시 그리지는 않는다. */
setContourInterval: (interval: number) => void;
@@ -703,6 +705,10 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
isSmoothingEnabled() {
return !smoothCheck.disabled && smoothCheck.checked;
},
setSmoothing(enabled) {
smoothPreferred = enabled;
syncSmoothingSupport();
},
setContourInterval(interval) {
if (Number.isFinite(interval) && interval > 0) intervalInput.value = String(interval);
},
+13
View File
@@ -261,6 +261,19 @@ export async function fetchLatestRoute(projectId: string): Promise<RouteLatestRe
});
}
/** B05가 최신 경로·확정 설정값을 탭 세션에 담아 둘 때 쓰는 키(유일한 정의처). */
export const routeLatestCacheKey = (projectId: string): string => `b05:latest:${projectId}`;
/** 담아 둔 최신 경로 값을 버린다. B04에서 지표면을 다시 확정하면 옛 확정값이 남아
* B05가 이전 지형을 그리게 되므로, 확정 직후 이 값을 지운다. */
export function clearRouteLatestCache(projectId: string): void {
try {
window.sessionStorage.removeItem(routeLatestCacheKey(projectId));
} catch {
/* 세션 접근 실패 시에는 다음 진입에서 DB를 읽게 되므로 그대로 둔다. */
}
}
/* ── 배수유역도 (B05_wf2_Route_Router_Drainage.py) ───────────────────────── */
/** 관 매설 구조물 측점 후보 1개. reason: stream=세류 교차, spacing=300m 보충. */
+7 -7
View File
@@ -10,13 +10,14 @@ import {
WORKFLOW_STEP_ROUTES,
} from "../A00_Common/b_workflow_nav";
import {
fetchSurfacePointCloud,
fetchConfirmedSurface,
listSurfaceModels,
type SurfaceModelSummary,
} from "../B04_wf1_Surface/B04_wf1_Surface_Api_Fetch";
import {
confirmRoute,
fetchLatestRoute,
routeLatestCacheKey,
saveDrainageBoundary,
solveRoute,
updateContourInterval,
@@ -246,7 +247,7 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
* 확정 이력이 있으면 매 진입마다 DB(latest) 조회 대신 브라우저 세션 캐시를
* 우선 사용해 응답속도를 높인다. 캐시 미스면 latest를 조회해 적재하고,
* solve·확정 성공 시 신선한 값으로 갱신한다(세션 = 탭 단위, 탭 종료 시 소멸). */
const latestCacheKey = `b05:latest:${activeProjectId}`;
const latestCacheKey = routeLatestCacheKey(activeProjectId);
function readLatestCache(): RouteLatestResponse | null {
try {
@@ -672,17 +673,16 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
if (!confirmedSurface) {
showToast("확정된 지표면 모델이 없습니다.", "error");
} else {
const cloud = await fetchSurfacePointCloud(
activeProjectId,
latestResponse.surface_params.source_filter,
);
// 지형 가장자리만 필요하다 — 포인트클라우드 전체(수십 MB)는 받지 않는다.
const confirmed = await fetchConfirmedSurface(activeProjectId);
if (!confirmed.bounds) throw new Error("지표면 범위 정보를 찾을 수 없습니다.");
await viewer.loadSurface(
activeProjectId,
confirmedSurface.id,
latestResponse.surface_params.method,
latestResponse.surface_params.smooth,
latestResponse.surface_params.contour_interval_m,
toBounds(cloud.bounds),
toBounds(confirmed.bounds),
);
// 지형이 올라온 뒤에야 마커·측점선이 지형 위에 놓인다.
renderLatest(latestResponse);
+9 -3
View File
@@ -22,6 +22,9 @@ import "./B11_Status_UI_Style.css";
* 자료를 준비하지 못하면(분석 실패·저장 경로 문제) 넘어가지 않고 사유를 알린다.
* ========================================================================== */
/** 준비에 실패한 채 그냥 넘어갔을 때 남기는 표식 — 어떤 확정 구성과도 일치하지 않는다. */
const PRELOAD_SKIPPED_SIGNATURE = "skipped";
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;
@@ -54,10 +57,11 @@ export async function renderB11Loading(root: HTMLElement): Promise<void> {
try {
// 다른 프로젝트 자료가 남아 있으면 지운다 — 프로젝트끼리 섞이면 안 된다.
await purgeOtherProjects(projectId);
await preloadSurfaceAssets(projectId, (label, ratio) => {
const signature = await preloadSurfaceAssets(projectId, (label, ratio) => {
progress.set(ratio, label);
});
markProjectPreloaded(projectId);
// 표식은 담아 둔 구성 그대로 남긴다 — 확정이 바뀌면 다음 진입에서 다시 준비한다.
markProjectPreloaded(projectId, signature);
navigateTo(target);
} catch (error) {
const detail = error instanceof Error ? ` (${error.message})` : "";
@@ -73,8 +77,10 @@ export async function renderB11Loading(root: HTMLElement): Promise<void> {
label: t("B11_Loading_Btn_Continue"),
variant: "ghost",
// 준비를 못 했어도 같은 세션에서 다시 붙잡지 않도록 표시해 둔다.
// 확정 구성을 모르는 상태이므로 전용 표식을 남긴다 — 확정이 정상화되면 표식이
// 어긋나 준비 화면이 다시 뜬다.
onClick: () => {
if (projectId) markProjectPreloaded(projectId);
if (projectId) markProjectPreloaded(projectId, PRELOAD_SKIPPED_SIGNATURE);
navigateTo(target);
},
}),