Files
Aislo/B07_DesignDetail/B07_DesignDetail_Prefetch.ts
T

198 lines
7.3 KiB
TypeScript

/* =============================================================================
* B07_DesignDetail_Prefetch.ts
* 도면 미리받기 — **페이지 밖** 모듈(508-3b · 요구 325).
*
* 페이지를 옮겨도 받기는 이어 감 · B07 로 돌아오면 목록 지문(서버 `fingerprint`)이 바뀐 도면만
* 버리고 다시 받음 · 차례 = 목록 차례 가벼운 것 → 무거운 것(평면도) 끝 · 사용자가 연 도면이 맨 앞 ·
* B07 [저장] 중 쉼 · 실패 = 한 번 다시 → 그래도면 「다시 받기」(누르면 다시) ·
* 담는 장 수 상한(`LIMIT` · 가까이 쓴 차례로 버림 — 버린 장은 열 때 다시 받음).
* 화면 · CAD 는 만지지 않음 — 상태 바뀜은 `onFetchState` 한 칸으로만 알림(페이지가 다시 세우면 갈아 낌).
* ========================================================================== */
import {
fetchDesignDrawing,
type DesignDrawingItem,
type DesignDrawingResponse,
} from "./B07_DesignDetail_Api_Fetch";
import { appendStructureEntities } from "./B07_DesignDetail_UI_Cad_Structures";
import { isSaving } from "./B07_DesignDetail_UI_Save";
/** 담는 장 수 상한 — 유역도 한 장이 수십 MB 라 메모리를 막음. */
export const LIMIT = 40;
/** 무거운 도면(평면도 · 유역도) — 차례 끝 */
const HEAVY = new Set<DesignDrawingItem["kind"]>(["plan"]);
export type FetchState = "none" | "loading" | "ready" | "error";
interface Slot {
fingerprint: string | null | undefined;
request?: Promise<DesignDrawingResponse>;
response?: DesignDrawingResponse;
/** 두 번 다 못 받음 — 미리받기는 건너뜀 · 열면 다시 */
failed?: boolean;
/** 상한으로 버림 — 미리받기는 다시 안 받음 · 열면 다시 */
evicted?: boolean;
usedAt: number;
}
let project: string | undefined;
let items: DesignDrawingItem[] = [];
const slots = new Map<string, Slot>();
/** 사용자가 연 도면을 받는 중 — 미리받기가 쉼 */
let urgent = 0;
let pumping = false;
let tick = 0;
let notify: ((id: string, state: FetchState) => void) | undefined;
const wait = (ms: number) => new Promise((done) => window.setTimeout(done, ms));
/** 상태 바뀜을 받을 곳 하나(페이지가 세울 때마다 갈아 낌). */
export function onFetchState(listener: ((id: string, state: FetchState) => void) | undefined) {
notify = listener;
}
export function fetchState(id: string): FetchState {
const slot = slots.get(id);
if (!slot) return "none";
if (slot.response) return "ready";
if (slot.request) return "loading";
return slot.failed ? "error" : "none";
}
/**
* 목록을 맞춤 — 다른 프로젝트면 모두 버림 · 지문이 바뀐(또는 목록에서 빠진) 도면만 버림 ·
* 돌리기는 `startPrefetch`(페이지가 첫 장을 고른 뒤).
*/
export function syncDrawings(projectId: string, list: DesignDrawingItem[]): void {
if (project !== projectId) {
slots.clear();
project = projectId;
}
items = list;
const now = new Map(list.map((item) => [item.id, item]));
for (const [id, slot] of slots) {
const item = now.get(id);
// 지문 없음(서버가 못 셈) = 늘 다시 받음
if (!item?.fingerprint || item.fingerprint !== slot.fingerprint) slots.delete(id);
}
}
/** 미리받기를 (다시) 돌림 — 이미 돌면 그대로(맞춘 목록을 이어 받음). */
export function startPrefetch(): void {
void pump();
}
/** 저장 · 다시 그리기 뒤 — 다음 열기는 서버에서 */
export function forgetDrawing(id: string): void {
slots.delete(id);
}
/** 도각을 고친 뒤 — 모두 버리고 다시 받음 */
export function forgetAll(): void {
slots.clear();
void pump();
}
/** 도면 하나(구조물까지 얹은 응답) — 겹쳐 부르면 같은 Promise · 실패한 장은 다시 받음. */
function request(item: DesignDrawingItem, retries: number): Promise<DesignDrawingResponse> {
const known = slots.get(item.id);
if (known?.response) {
known.usedAt = ++tick;
return Promise.resolve(known.response);
}
if (known?.request) return known.request;
const projectId = project as string;
const slot: Slot = { fingerprint: item.fingerprint, usedAt: ++tick };
slots.set(item.id, slot);
const once = async (): Promise<DesignDrawingResponse> => {
const response = await fetchDesignDrawing(projectId, item.id);
// 구조물(배수관 · 기슭막이 · 세월교 · BOX · 물넘이포장)은 B06 산식이 화면에 있어 여기서 얹음
// — 저장본(확정)은 이미 담겨 있어 그대로
if (item.kind === "cross" && !response.confirmed) {
await appendStructureEntities(projectId, response.route_id, response.drawing);
}
return response;
};
slot.request = (async () => {
for (let attempt = 0; ; attempt++) {
try {
return await once();
} catch (error) {
if (attempt >= retries) throw error;
}
}
})();
notify?.(item.id, "loading");
slot.request.then(
(response) => {
if (slots.get(item.id) !== slot) return; // 그 사이 지문이 바뀌어 버림
slot.response = response;
slot.request = undefined;
trim(item.id);
notify?.(item.id, "ready");
},
() => {
if (slots.get(item.id) !== slot) return;
slot.request = undefined;
slot.failed = true;
notify?.(item.id, "error");
},
);
return slot.request;
}
/** 담은 장이 상한을 넘으면 가장 오래 안 쓴 것부터 버림(방금 받은 장 · 받는 중은 남김). */
function trim(keep: string): void {
const held = [...slots].filter(([, slot]) => slot.response);
let excess = held.length - LIMIT;
for (const [id, slot] of held.sort((a, b) => a[1].usedAt - b[1].usedAt)) {
if (excess <= 0) break;
if (id === keep) continue;
slots.set(id, { fingerprint: slot.fingerprint, evicted: true, usedAt: slot.usedAt });
notify?.(id, "none");
excess--;
}
}
/**
* 사용자가 연 도면 — 미리받기 차례를 기다리지 않고 바로 받음(미리받기는 그동안 쉼) ·
* 못 받았던 장 · 버린 장도 다시 받음(「다시 받기」).
*/
export async function openDrawing(item: DesignDrawingItem): Promise<DesignDrawingResponse> {
const known = slots.get(item.id);
if (known && !known.response && !known.request) slots.delete(item.id);
urgent++;
try {
return await request(item, 1);
} finally {
urgent--;
}
}
/** 다음에 미리 받을 도면 — 가벼운 것(목록 차례) 먼저 · 무거운 것 끝 · 이미 받음 · 실패 · 버림은 건너뜀. */
function nextItem(): DesignDrawingItem | undefined {
const free = (item: DesignDrawingItem) => !slots.has(item.id);
return items.find((item) => !HEAVY.has(item.kind) && free(item)) ?? items.find(free);
}
/** 한 번에 하나씩 · 사용자가 연 도면 · [저장]이 도는 동안 쉼 · 상한이 차면 멈춤. */
async function pump(): Promise<void> {
if (pumping) return;
pumping = true;
try {
for (;;) {
while (urgent > 0 || isSaving()) await wait(120);
const held = [...slots.values()].filter((slot) => slot.response || slot.request).length;
const item = held < LIMIT ? nextItem() : undefined;
if (!item || !project) return;
try {
await request(item, 1);
} catch {
/* 두 번 다 못 받음 — 「다시 받기」로 남기고 다음 장 */
}
}
} finally {
pumping = false;
}
}