237 lines
8.7 KiB
TypeScript
237 lines
8.7 KiB
TypeScript
/* =============================================================================
|
|
* 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<string, number[]>;
|
|
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<void> {
|
|
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, unknown> | string,
|
|
signal?: AbortSignal,
|
|
extra: Partial<CalcServerCall> = {},
|
|
): Promise<Record<string, unknown>> {
|
|
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<string, unknown>;
|
|
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<string, AbortController>();
|
|
|
|
/** 잠잠 뒤 task — 같은 칸 새 부름 · outer 끊김 = 앞 것 끊음(undefined) · 실패도 undefined(경고 한 줄). */
|
|
export async function afterQuiet<T>(
|
|
slot: string,
|
|
task: (signal: AbortSignal) => Promise<T>,
|
|
outer?: AbortSignal,
|
|
): Promise<T | undefined> {
|
|
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<string, unknown>;
|
|
pick: (results: Record<string, unknown>) => 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,
|
|
});
|
|
|
|
const trialRequest = (id: string, args: unknown[]): CalcRequest => ({
|
|
run: [id],
|
|
inputs: { args: { [id]: args } },
|
|
pick: (results) => results[id],
|
|
});
|
|
|
|
/** 브라우저 셈 + 시간 기록(판 상관없이) · server 면 잠잠 뒤 서버 대조 · 다르면 apply(서버 값). */
|
|
export function calcChecked<A extends unknown[], R>(
|
|
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<CalcServerCall> = { 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<R>(
|
|
key: string,
|
|
slot: string,
|
|
request: CalcRequest,
|
|
outer?: AbortSignal,
|
|
): Promise<R | undefined> {
|
|
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<string, { body: string; value: unknown }>();
|
|
const asked = new Map<string, string>();
|
|
|
|
/** 그리기 자리 서버 값 — 같은 입력 답이면 fresh · 아니면 앞 답(없으면 undefined) + 잠잠 뒤 묻고 redraw. */
|
|
export function calcFromServer<R>(
|
|
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 };
|
|
}
|