/* ============================================================================= * 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(["plan"]); export type FetchState = "none" | "loading" | "ready" | "error"; interface Slot { fingerprint: string | null | undefined; request?: Promise; response?: DesignDrawingResponse; /** 두 번 다 못 받음 — 미리받기는 건너뜀 · 열면 다시 */ failed?: boolean; /** 상한으로 버림 — 미리받기는 다시 안 받음 · 열면 다시 */ evicted?: boolean; usedAt: number; } let project: string | undefined; let items: DesignDrawingItem[] = []; const slots = new Map(); /** 사용자가 연 도면을 받는 중 — 미리받기가 쉼 */ 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 { 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 => { 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 { 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 { 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; } }