/* ============================================================================= * design_calc_server.ts * 계산 자리 「서버」 길(PLAN 511-A) — 판(`design_calc_sites.ts`)이 묶음을 server 로 둔 때 조작 중 계산을 서버 * 창구(`POST /projects/{id}/calc` · `B06_Section_Router_Calc.py`)가 같은 TS 로 셈. 판이 both 면 지금 그대로. * serverCalc 한 번 묻기(signal 로 끊음 · 왕복 흉내 `delayMs` · 기록) * afterQuiet 잠잠 뒤 — 같은 칸 새 부름이 앞 것을 끊음(앞 것 = undefined · 늦은 답 버림) * calcChecked 동기 자리 — 브라우저 값 바로 · server 면 잠잠 뒤 같은 입력 서버 셈 → 대조 · 다르면 apply * calcOnServerOnly 비동기 자리 — server 면 서버 값만(잠잠 뒤 · 끊긴 부름 = undefined) * calcFromServer 그리기 자리 — server 면 서버 값만(입력 같은 답 · 없으면 앞 답) · 답 오면 redraw * 기록 = `window.__aisloCalcServer`(calls · local = 브라우저 ms · delayMs) — 시간 표 · 화면 검증용. * 조작 중 왕복 = 지침 5장 「네트워크 왕복 금지」의 시험용 예외(판 기본값 both). * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; import { calcOnServerNow, calcProjectId } from "./design_calc_sites"; import { plain } from "./logic/design_calc_node_trial"; /** 편집이 멈춘 것으로 볼 시간(ms) — 배분 선반입과 같은 값(`B05_Profile_Api_HaulPlan.ts`). */ export const QUIET_MS = 400; const LOG_SIZE = 200; const LOCAL_SIZE = 20; export interface CalcServerCall { key: string; id: string; /** 브라우저 셈 ms(같이 셈한 자리만). */ browserMs: number | null; /** 보내기 → 답(흉내 지연 포함). */ roundMs: number; /** Node 셈 · 파이썬 ↔ Node(창구 답). */ nodeMs: number | null; wallMs: number | null; bytes: number; /** 브라우저 값 = 서버 값(대조 자리만 · 아니면 null). */ same: boolean | null; applied: boolean; } interface CalcServerLog { calls: CalcServerCall[]; local: Record; delayMs: number; } const log = (): CalcServerLog => ((globalThis as { __aisloCalcServer?: CalcServerLog }).__aisloCalcServer ??= { calls: [], local: {}, delayMs: 0, }); const now = (): number => performance.now(); const r2 = (value: number): number => Math.round(value * 100) / 100; function wait(ms: number, signal?: AbortSignal): Promise { return new Promise((resolve, reject) => { if (signal?.aborted) return reject(new DOMException("끊김", "AbortError")); const timer = setTimeout(resolve, ms); signal?.addEventListener("abort", () => { clearTimeout(timer); reject(new DOMException("끊김", "AbortError")); }); }); } function record(call: CalcServerCall): void { const calls = log().calls; calls.push(call); if (calls.length > LOG_SIZE) calls.splice(0, calls.length - LOG_SIZE); } /** 서버 계산 한 번 — `{id: 값}` · 실패 = throw · `body` = 이미 굳힌 본문(입력이 뒤에 바뀌어도 그때 값). */ export async function serverCalc( key: string, run: string[], inputs: Record | string, signal?: AbortSignal, extra: Partial = {}, ): Promise> { const body = typeof inputs === "string" ? inputs : JSON.stringify({ run, inputs }); const t0 = now(); if (log().delayMs > 0) await wait(log().delayMs, signal); const response = await fetch(`${API_BASE_URL}/projects/${calcProjectId()}/calc`, { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, body, signal, }); const data = (await response.json()) as { results?: Record; ms?: number; wall_ms?: number; message?: string; }; if (!response.ok || !data.results) throw new Error(data.message ?? `HTTP ${response.status}`); // extra 그 객체로 기록 — 대조 뒤 같음 · apply 를 부른 쪽이 그 자리에서 채움 record( Object.assign(extra, { key, id: run.join("+"), browserMs: null, roundMs: r2(now() - t0), nodeMs: data.ms ?? null, wallMs: data.wall_ms ?? null, bytes: body.length, same: null, applied: true, ...extra, }), ); return data.results; } const quiet = new Map(); /** 잠잠 뒤 task — 같은 칸 새 부름 · outer 끊김 = 앞 것 끊음(undefined) · 실패도 undefined(경고 한 줄). */ export async function afterQuiet( slot: string, task: (signal: AbortSignal) => Promise, outer?: AbortSignal, ): Promise { quiet.get(slot)?.abort(); const controller = new AbortController(); quiet.set(slot, controller); outer?.addEventListener("abort", () => controller.abort()); try { await wait(QUIET_MS, controller.signal); return await task(controller.signal); } catch (error) { if (!controller.signal.aborted) console.warn(`[calc-server] ${slot} 실패`, error); return undefined; } finally { if (quiet.get(slot) === controller) quiet.delete(slot); } } /** 서버 묻는 꼴 — 없으면 시험 길(`inputs.args[id]` = 함수 인자 · 답 = `results[id]`). */ export interface CalcRequest { run: string[]; inputs: Record; pick: (results: Record) => unknown; } /** F7 구조물 면적 · C4 소단 구간 — 등록부 `node:` 입력 그대로. */ export const areasRequest = (rows: unknown[]): CalcRequest => ({ run: ["structure.layouts"], inputs: { detail: { cross_sections: rows } }, pick: (results) => (results["structure.layouts"] as { areas: unknown }).areas, }); export const bermRequest = (structures: unknown[]): CalcRequest => ({ run: ["cross.berm_spans"], inputs: { berm: { structures, chainages: [] } }, pick: (results) => (results["cross.berm_spans"] as { spans: unknown }).spans, }); export const trialRequest = (id: string, args: unknown[]): CalcRequest => ({ run: [id], inputs: { args: { [id]: args } }, pick: (results) => results[id], }); /** 브라우저 셈 + 시간 기록(판 상관없이) · server 면 잠잠 뒤 서버 대조 · 다르면 apply(서버 값). */ export function calcChecked( key: string, id: string, fn: (...args: A) => R, args: A, options: { apply?: (value: R) => void; request?: CalcRequest } = {}, ): R { const t0 = now(); const value = fn(...args); const browserMs = r2(now() - t0); const local = (log().local[id] ??= []); local.push(browserMs); if (local.length > LOCAL_SIZE) local.shift(); if (!calcOnServerNow(key)) return value; const request = options.request ?? trialRequest(id, args); const body = JSON.stringify({ run: request.run, inputs: request.inputs }); const mine = JSON.stringify(plain(value)); const extra: Partial = { browserMs, same: null, applied: false }; void afterQuiet(id, (signal) => serverCalc(key, request.run, body, signal, extra)).then( (results) => { if (!results) return; const theirs = request.pick(results); extra.same = JSON.stringify(theirs) === mine; if (!extra.same && options.apply) { options.apply(theirs as R); extra.applied = true; } }, ); return value; } /** 서버 값만(비동기 자리) — 잠잠 뒤 묻고 고른 값 · 새 부름 · outer 끊김 · 실패 = undefined. */ export async function calcOnServerOnly( key: string, slot: string, request: CalcRequest, outer?: AbortSignal, ): Promise { const body = JSON.stringify({ run: request.run, inputs: request.inputs }); const results = await afterQuiet( slot, (signal) => serverCalc(key, request.run, body, signal), outer, ); return results ? (request.pick(results) as R) : undefined; } const held = new Map(); const asked = new Map(); /** 그리기 자리 서버 값 — 같은 입력 답이면 fresh · 아니면 앞 답(없으면 undefined) + 잠잠 뒤 묻고 redraw. */ export function calcFromServer( key: string, id: string, args: unknown[], redraw: () => void, ): { value: R | undefined; fresh: boolean } { const request = trialRequest(id, args); const body = JSON.stringify({ run: request.run, inputs: request.inputs }); const hold = held.get(id); if (hold?.body === body) return { value: hold.value as R, fresh: true }; if (asked.get(id) !== body) { asked.set(id, body); void afterQuiet(id, (signal) => serverCalc(key, [id], body, signal)).then((results) => { if (asked.get(id) === body) asked.delete(id); if (!results) return; held.set(id, { body, value: results[id] }); redraw(); }); } return { value: hold?.value as R | undefined, fresh: false }; }