/* ============================================================================= * 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 | null = null; function openDatabase(): Promise { 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( mode: IDBTransactionMode, work: (store: IDBObjectStore) => IDBRequest, ): Promise { return openDatabase().then( (db) => new Promise((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 { return (await runTransaction("readonly", (store) => store.get(cacheKey(projectId, url)), )) as CachedAsset | null; } async function writeAsset(asset: CachedAsset): Promise { await runTransaction("readwrite", (store) => store.put(asset) as IDBRequest); } /** 다른 프로젝트 자료를 모두 지운다. B그룹 페이지에 들어올 때 호출한다. */ export async function purgeOtherProjects(projectId: string): Promise { const db = await openDatabase(); if (!db) return; await new Promise((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 { 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 { 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( projectId: string, url: string, options: CachedFetchOptions = {}, ): Promise { const bytes = await fetchCachedBytes(projectId, url, options); return JSON.parse(new TextDecoder().decode(bytes)) as T; } /** 화면에 쓰기 전에 미리 받아 둔다(대시보드에서 B그룹으로 들어갈 때). 실패는 무시한다. */ export function prefetchAsset(projectId: string, url: string): void { void fetchCachedBytes(projectId, url).catch(() => { /* 미리 받기 실패는 화면 동작에 영향을 주지 않는다. */ }); } /** 확정된 지표면의 3D 파일과 등고선을 미리 받아 둔다. * * 사용자가 실제로 보는 것은 이 둘이라 이것만 챙긴다(포인트클라우드는 제외 — 2026-08-01 * 사용자 지시). 이미 보관함에 있으면 아무 것도 하지 않는다. */ export async function prefetchSurfaceAssets(projectId: string): Promise { 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`); } catch { /* 미리 받기는 실패해도 화면 동작에 영향을 주지 않는다. */ } }