검증_sub2: B06 측점 하나 [저장] 뒤 다시 받음 = 바뀐 6 장(횡단 그 장 · 측점 하나) · 평면도 지형 첫 열기 1.3~4.4 s → 저장본 0.2~0.4 s · 안 받은 유역도 = 받는 중 → 4.4 s 뒤 보임 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
198 lines
7.3 KiB
TypeScript
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;
|
|
}
|
|
}
|