손 안 댄 프로젝트를 열면 저장본 열쇠가 맞아 매번 16.9MB 를 받았고, 브라우저 보관이
모듈 Map 이라 새로고침 한 번에 날아가 또 받았음(보조 창 실측: 첫 로그인~B05 도달 29MB
중 16.9MB 가 이것). 해시 질의(0b30f5b0)는 낡았을 때만 막았지 맞을 때는 그대로였음.
- b_asset_cache 에 readCachedBytes / writeCachedBytes / purgeAssetsWithPrefix 추가.
앞 둘은 네트워크를 안 타는 순수 보관함 접근이고, 마지막은 주소 앞머리가 같은 옛
보관본을 지움 — 코리도는 주소에 열쇠가 박혀 정본이 바뀌면 새 주소가 되므로 17MB 짜리가
쌓이지 않게 담기 전에 치움.
- 코리도 조회가 보관함을 먼저 봄. 주소에 열쇠가 있어 정본이 바뀌면 저절로 다른 주소가
되므로 옛 보관본을 잘못 쓸 일이 없음.
자체검증(공용 브라우저) — 담기·읽기 왕복 정상(44바이트 왕복, 해시 보존), 열쇠만 다른 두 벌을
담은 뒤 앞머리 지우기로 둘 다 제거 확인. typecheck 통과.
※ 실제 16.9MB 절감은 저장본이 최신인 프로젝트에서 확인해야 함 — 이 프로젝트는 검증 중
계획고를 만져 저장본이 낡은 상태라 코리도 자체가 안 실림(__corridorSource null).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
397 lines
15 KiB
TypeScript
397 lines
15 KiB
TypeScript
/* =============================================================================
|
|
* 3D 자료 브라우저 보관함 (IndexedDB)
|
|
*
|
|
* 지표면 3D 파일과 등고선은 한 번 만들면 잘 바뀌지 않는데 용량이 크다. 매번 새로 받으면
|
|
* 페이지를 열 때마다 기다려야 하므로, 받은 것을 브라우저에 저장해 두고 다음부터는 그것을
|
|
* 곧바로 화면에 올린다. 저장본을 쓰는 동시에 뒤에서 서버에 "바뀐 것 있나"만 물어보고,
|
|
* 바뀌었으면 새로 받아 갱신한다(서버의 ETag 사용).
|
|
*
|
|
* 프로젝트가 바뀌면 이전 프로젝트 자료는 지운다 — 다른 프로젝트 데이터가 섞이면 안 된다.
|
|
* ========================================================================== */
|
|
|
|
import { API_BASE_URL } from "@config/config_frontend";
|
|
import { fetchConfirmedSurface } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
|
|
|
const DB_NAME = "aislo-asset-cache";
|
|
// 담아 둔 자료의 형식이나 내용이 바뀌면 이 번호를 올린다 — 올리면 기존 보관분을 통째로 버린다.
|
|
// v2: 도엽 표시용 사본(잘라낸 자료)을 철회했다. 그 사본을 담고 있던 브라우저는 비워야 한다.
|
|
const DB_VERSION = 2;
|
|
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)) db.deleteObjectStore(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();
|
|
}
|
|
});
|
|
}
|
|
|
|
/** 보관함에서 바이트만 꺼낸다 — 네트워크를 타지 않는다. 없으면 null.
|
|
* 주소에 열쇠가 박히는 자료(3D 코리도)는 이걸로 **먼저 보고** 없을 때만 받는다. */
|
|
export async function readCachedBytes(projectId: string, url: string): Promise<ArrayBuffer | null> {
|
|
try {
|
|
const cached = await readAsset(projectId, url);
|
|
return cached?.body ?? null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** 받아 둔 바이트를 보관함에 담는다. 실패해도 화면은 그대로 간다(용량 초과 등). */
|
|
export async function writeCachedBytes(
|
|
projectId: string,
|
|
url: string,
|
|
body: ArrayBuffer,
|
|
): Promise<void> {
|
|
try {
|
|
await writeAsset({
|
|
key: cacheKey(projectId, url),
|
|
projectId,
|
|
url,
|
|
etag: null,
|
|
savedAt: Date.now(),
|
|
body,
|
|
});
|
|
} catch {
|
|
// 담지 못해도 다음에 다시 받으면 된다 — 막지 않는다.
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 한 프로젝트 안에서 **주소 앞머리가 같은 옛 보관본**을 지운다.
|
|
*
|
|
* 3D 코리도처럼 주소에 열쇠(해시)가 박히는 자료는 정본이 바뀔 때마다 새 주소가 되어
|
|
* 옛 보관본이 그대로 쌓인다. 하나가 17MB 대라 두어 벌만 남아도 브라우저 보관함을 크게
|
|
* 먹는다. 새것을 담기 **전에** 같은 앞머리를 치운다(2026-09-06).
|
|
*/
|
|
export async function purgeAssetsWithPrefix(projectId: string, prefix: 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 && value.url.startsWith(prefix)) 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;
|
|
}
|
|
|
|
/** 배수유역도 배경으로 쓰는 도엽 레이어(유일한 정의처 — 준비화면과 B05 패널이 함께 쓴다). */
|
|
export const DRAINAGE_SHEET_LAYERS = ["도엽_등고선", "도엽_하천중심선"] as const;
|
|
|
|
/** 도엽 레이어(GeoJSON)를 보관함에서 먼저 찾는다.
|
|
*
|
|
* 서버는 프로젝트 주변만 잘라 좌표 자릿수를 줄인 표시용 사본을 ETag와 함께 내보낸다.
|
|
* 분석용 원본과는 별개 파일이므로, 담아 두었다가 그대로 다시 써도 화면이 어긋나지 않는다. */
|
|
export async function fetchCachedSheetLayer<T>(projectId: string, layer: string): Promise<T> {
|
|
return fetchCachedJson<T>(
|
|
projectId,
|
|
`${API_BASE_URL}/projects/${projectId}/geojson?layer=${encodeURIComponent(layer)}`,
|
|
);
|
|
}
|
|
|
|
/* ── 준비 화면 연동 ────────────────────────────────────────────────────────
|
|
* 무엇을 이미 준비했는지, 준비가 끝나면 어느 화면으로 보낼지를 탭 단위로 기억한다.
|
|
* 대시보드로 나갔다가 같은 프로젝트로 돌아오면 준비 화면을 건너뛴다(2026-08-01 사용자 지시).
|
|
*
|
|
* 표식은 프로젝트 번호가 아니라 **확정 구성(signature)** 이다. 관리자가 B04에서 다른
|
|
* 필터·표현으로 다시 확정하면 표식이 달라져 준비 화면이 한 번 더 돌고 새 자료를 담는다.
|
|
* 프로젝트 번호만 봤다면 옛 자료를 계속 쓰게 된다(2026-08-01 사용자 지시). */
|
|
const PRELOADED_SIGNATURE_KEY = "frd_preloaded_signature";
|
|
const PRELOAD_TARGET_KEY = "frd_preload_target";
|
|
|
|
const preloadStamp = (projectId: string, signature: string): string => `${projectId}|${signature}`;
|
|
|
|
export function isProjectPreloaded(projectId: string, signature: string): boolean {
|
|
try {
|
|
return (
|
|
window.sessionStorage.getItem(PRELOADED_SIGNATURE_KEY) === preloadStamp(projectId, signature)
|
|
);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export function markProjectPreloaded(projectId: string, signature: string): void {
|
|
try {
|
|
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);
|
|
} 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;
|
|
|
|
/**
|
|
* 아직 확정된 지표면이 없다는 뜻의 오류 표식.
|
|
*
|
|
* 고장이 아니라 "분석·확정이 아직 안 끝난 정상 상태"다. 준비 화면이 이 표식을 보고
|
|
* 장애 안내 대신 다음에 할 일(파일 입력)을 안내한다.
|
|
*/
|
|
export const PRELOAD_NO_SURFACE = "PRELOAD_NO_SURFACE";
|
|
|
|
/** 확정된 지표면의 3D 파일과 그 등고선을 보관함에 채운다(준비 화면에서 호출).
|
|
*
|
|
* 사용자가 실제로 보는 것은 이 둘이라 이것만 챙긴다 — 포인트클라우드·배수유역은 제외
|
|
* (2026-08-01 사용자 지시). 이미 보관돼 있으면 거의 즉시 끝난다.
|
|
* 평활 여부·등고선 간격은 짐작하지 않고 확정 저장값을 그대로 쓴다 — 짐작하면 B04·B05가
|
|
* 서로 다른 파일을 받아 같은 지형을 두 번 내려받게 된다.
|
|
* 확정 지표면을 찾지 못하면 오류를 던져 준비 화면이 안내 문구를 띄우게 한다.
|
|
* 반환값은 담아 둔 구성의 signature — 호출측이 준비 표식으로 저장한다. */
|
|
export async function preloadSurfaceAssets(
|
|
projectId: string,
|
|
report: PreloadReporter = () => {},
|
|
): Promise<string> {
|
|
report("확정된 지표면을 확인하는 중…", null);
|
|
const confirmed = await fetchConfirmedSurface(projectId);
|
|
if (!confirmed.model_id) throw new Error(PRELOAD_NO_SURFACE);
|
|
|
|
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}`, {
|
|
onProgress: (ratio) => report("3D 지표면을 준비하는 중…", ratio),
|
|
});
|
|
|
|
report("등고선을 준비하는 중…", null);
|
|
await fetchCachedBytes(
|
|
projectId,
|
|
`${base}/contour?interval=${interval}&smooth=${smooth}&recalculate=false`,
|
|
{ onProgress: (ratio) => report("등고선을 준비하는 중…", ratio) },
|
|
);
|
|
|
|
// 배수유역도 배경(도엽 표시본·위성사진)도 함께 담는다 — 없으면 B05가 진입할 때마다 받는다.
|
|
// 이 자료가 없어도 화면은 뜨므로 실패해도 준비를 멈추지 않는다.
|
|
report("배경 지도를 준비하는 중…", null);
|
|
await Promise.all(
|
|
DRAINAGE_SHEET_LAYERS.map((layer) => fetchCachedSheetLayer(projectId, layer).catch(() => null)),
|
|
);
|
|
// 위성사진은 <img>로 표시하므로 보관함이 아니라 브라우저 자체 캐시를 데워 둔다.
|
|
await fetch(`${API_BASE_URL}/projects/${projectId}/vworld-map?layer_name=satellite`, {
|
|
credentials: "include",
|
|
}).catch(() => null);
|
|
|
|
report("준비 완료", 1);
|
|
return confirmed.signature;
|
|
}
|