Files
Aislo/A00_Common/b_asset_cache.ts
T
eomsangdon 77a637d7c9 feat(B11): 자료 준비 화면 신설 — 작업 화면 진입 전 3D·등고선 선적재
- B11_Status_UI_Loading + 라우트 b11-loading: 공통 프로그레스 서클로 단계·진행률 표시,
  끝나면 원래 가려던 화면으로 자동 이동
- goToWorkflowStage: 아직 준비하지 않은 프로젝트면 준비 화면 경유, 같은 프로젝트로
  다시 들어오면 건너뜀(탭 단위 기억). 다른 프로젝트면 보관분 교체
- 선적재 범위: 확정 지표면 3D + 그 등고선만 (종단·횡단은 0.86MB·0.06초로 이미 즉시라 제외,
  배수유역·포인트클라우드도 제외)
- 실패 시 자동 이동하지 않고 담당자 연락 안내 + [그래도 이동]/[대시보드로]
- B04 등고선 간격 시작값을 B05 저장값과 공유(B04 변경은 DB에 쓰지 않음)
- 새 문구는 ui_locales에 한국어·영어 등록
2026-08-01 10:34:08 +09:00

305 lines
11 KiB
TypeScript

/* =============================================================================
* 3D 자료 브라우저 보관함 (IndexedDB)
*
* 지표면 3D 파일과 등고선은 한 번 만들면 잘 바뀌지 않는데 용량이 크다. 매번 새로 받으면
* 페이지를 열 때마다 기다려야 하므로, 받은 것을 브라우저에 저장해 두고 다음부터는 그것을
* 곧바로 화면에 올린다. 저장본을 쓰는 동시에 뒤에서 서버에 "바뀐 것 있나"만 물어보고,
* 바뀌었으면 새로 받아 갱신한다(서버의 ETag 사용).
*
* 프로젝트가 바뀌면 이전 프로젝트 자료는 지운다 — 다른 프로젝트 데이터가 섞이면 안 된다.
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
const DB_NAME = "aislo-asset-cache";
const DB_VERSION = 1;
const STORE = "assets";
export interface CachedAsset {
/** `${projectId}|${url}` */
key: string;
projectId: string;
url: string;
etag: string | null;
savedAt: number;
body: ArrayBuffer;
}
let dbPromise: Promise<IDBDatabase | null> | null = null;
function openDatabase(): Promise<IDBDatabase | null> {
if (dbPromise) return dbPromise;
dbPromise = new Promise((resolve) => {
if (!("indexedDB" in window)) {
resolve(null);
return;
}
const request = window.indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(STORE)) {
const store = db.createObjectStore(STORE, { keyPath: "key" });
store.createIndex("projectId", "projectId", { unique: false });
}
};
request.onsuccess = () => resolve(request.result);
// 사생활 보호 모드 등으로 열리지 않으면 보관함 없이 동작한다(항상 새로 받는다).
request.onerror = () => resolve(null);
});
return dbPromise;
}
function runTransaction<T>(
mode: IDBTransactionMode,
work: (store: IDBObjectStore) => IDBRequest<T>,
): Promise<T | null> {
return openDatabase().then(
(db) =>
new Promise<T | null>((resolve) => {
if (!db) {
resolve(null);
return;
}
try {
const transaction = db.transaction(STORE, mode);
const request = work(transaction.objectStore(STORE));
request.onsuccess = () => resolve(request.result ?? null);
request.onerror = () => resolve(null);
} catch {
resolve(null);
}
}),
);
}
const cacheKey = (projectId: string, url: string): string => `${projectId}|${url}`;
async function readAsset(projectId: string, url: string): Promise<CachedAsset | null> {
return (await runTransaction<CachedAsset>("readonly", (store) =>
store.get(cacheKey(projectId, url)),
)) as CachedAsset | null;
}
async function writeAsset(asset: CachedAsset): Promise<void> {
await runTransaction("readwrite", (store) => store.put(asset) as IDBRequest<unknown>);
}
/** 다른 프로젝트 자료를 모두 지운다. B그룹 페이지에 들어올 때 호출한다. */
export async function purgeOtherProjects(projectId: string): Promise<void> {
const db = await openDatabase();
if (!db) return;
await new Promise<void>((resolve) => {
try {
const transaction = db.transaction(STORE, "readwrite");
const store = transaction.objectStore(STORE);
const cursorRequest = store.openCursor();
cursorRequest.onsuccess = () => {
const cursor = cursorRequest.result;
if (!cursor) return;
const value = cursor.value as CachedAsset;
if (value.projectId !== projectId) cursor.delete();
cursor.continue();
};
transaction.oncomplete = () => resolve();
transaction.onerror = () => resolve();
} catch {
resolve();
}
});
}
export interface CachedFetchOptions {
/** 내려받는 동안 진행률(0~1, 모르면 null)을 알려준다. 저장본을 쓰면 호출되지 않는다. */
onProgress?: (ratio: number | null) => void;
}
/** 네트워크에서 받아 보관함에 저장한다. */
async function downloadAndStore(
projectId: string,
url: string,
options: CachedFetchOptions,
): Promise<ArrayBuffer> {
const response = await fetch(url, { credentials: "include" });
if (!response.ok) throw new Error(`요청 실패: ${response.status}`);
const total = Number(response.headers.get("content-length") ?? 0);
const etag = response.headers.get("etag");
let body: ArrayBuffer;
if (response.body && options.onProgress) {
// 진행률을 보여 주기 위해 조각으로 읽는다.
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let received = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
received += value.length;
options.onProgress(total > 0 ? received / total : null);
}
const merged = new Uint8Array(received);
let offset = 0;
chunks.forEach((chunk) => {
merged.set(chunk, offset);
offset += chunk.length;
});
body = merged.buffer;
} else {
body = await response.arrayBuffer();
}
await writeAsset({
key: cacheKey(projectId, url),
projectId,
url,
etag,
savedAt: Date.now(),
body,
});
return body;
}
/** 저장본이 최신인지 뒤에서 확인하고, 바뀌었으면 새로 받아 저장한다. */
function revalidateInBackground(projectId: string, url: string, etag: string | null): void {
if (!etag) return;
void fetch(url, { credentials: "include", headers: { "If-None-Match": etag } })
.then(async (response) => {
if (response.status === 304 || !response.ok) return;
const body = await response.arrayBuffer();
await writeAsset({
key: cacheKey(projectId, url),
projectId,
url,
etag: response.headers.get("etag"),
savedAt: Date.now(),
body,
});
})
.catch(() => {
/* 오프라인 등으로 확인하지 못해도 저장본을 계속 쓴다. */
});
}
/** 저장본이 있으면 즉시 돌려주고 뒤에서 갱신 확인, 없으면 받아서 저장한 뒤 돌려준다. */
export async function fetchCachedBytes(
projectId: string,
url: string,
options: CachedFetchOptions = {},
): Promise<ArrayBuffer> {
const cached = await readAsset(projectId, url);
if (cached?.body) {
revalidateInBackground(projectId, url, cached.etag);
return cached.body;
}
return downloadAndStore(projectId, url, options);
}
/** JSON 자료용. 저장본을 쓰면 파싱만 하고 네트워크를 타지 않는다. */
export async function fetchCachedJson<T>(
projectId: string,
url: string,
options: CachedFetchOptions = {},
): Promise<T> {
const bytes = await fetchCachedBytes(projectId, url, options);
return JSON.parse(new TextDecoder().decode(bytes)) as T;
}
/* ── 준비 화면 연동 ────────────────────────────────────────────────────────
* 어느 프로젝트를 이미 준비했는지, 준비가 끝나면 어느 화면으로 보낼지를 탭 단위로 기억한다.
* 대시보드로 나갔다가 같은 프로젝트로 돌아오면 준비 화면을 건너뛴다(2026-08-01 사용자 지시). */
const PRELOADED_PROJECT_KEY = "frd_preloaded_project";
const PRELOAD_TARGET_KEY = "frd_preload_target";
export function isProjectPreloaded(projectId: string): boolean {
try {
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);
}