- 휠 위 = 축소로 반전, 커서 지점을 축으로 한 dolly를 공용 유틸에서 직접 처리 - 회전 중심을 작은 구로 표시(돌리는 동안만, 화면상 크기 일정, 항상 위에 그림) - 등고선 렌더 비용 감소: 폴리라인을 주곡선·보조곡선 2덩어리로 병합(드로우콜 424 → 2), 라벨은 카메라가 움직였을 때만 재배치 - common_util_http_cache: 파일 mtime+크기 ETag, If-None-Match 일치 시 304 (preview·contour 적용, 파일이 바뀌면 자동 무효화) - A00_Common/b_asset_cache: IndexedDB 보관함(키 = projectId|url, 값 = 바이트+ETag). 보관본 즉시 사용 후 백그라운드 재검증, 3D는 보관 바이트를 직접 파싱 - 프로젝트 전환 시 타 프로젝트 보관분 삭제, 대시보드→B그룹 이동 시 확정 모델의 3D 프리뷰·등고선(1.0m) 미리 받기(포인트클라우드 제외)
239 lines
8.4 KiB
TypeScript
239 lines
8.4 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;
|
|
}
|
|
|
|
/** 화면에 쓰기 전에 미리 받아 둔다(대시보드에서 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<void> {
|
|
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 {
|
|
/* 미리 받기는 실패해도 화면 동작에 영향을 주지 않는다. */
|
|
}
|
|
}
|