/* ============================================================================= * B05_Profile_Api_HaulPlan.ts * 유토 **배분**(평형선·운반거리·장비)을 서버에서 미리 받아 두는 자리. * * 기본은 서버(계산 자리 판 haul = server) — 화면은 누가토량까지만 스스로 내고(`common_util_mass_haul`), * 그 결과를 여기로 보내 배분을 받아 쥔다. 판 haul = both(요구 321 · 511-C)면 배분 모듈을 **그때만** * dynamic import 해 브라우저가 셈(기본값이면 안 받음). 계산은 한 벌 — 서버도 같은 TS 를 Node 로 돈다. * * **조용히 따라오게 한다** — 편집이 멈추면 뒤에서 물어 두므로, 유토곡선 패널을 펼치는 * 순간에는 이미 도착해 있다. 늦게 온 응답은 버린다(최신 요청만 채택). * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; import type { HaulPlan } from "@util/common_util_mass_haul_balance"; import type { MassHaulResult } from "@util/common_util_mass_haul"; import type { EarthworkConversion, HaulEquipmentLimit } from "@util/common_util_mass_haul_types"; import { calcInBrowser } from "../A00_Common/design/design_calc_sites"; /** 서버가 돌려주는 배분 한 벌 — **화면이 쓰는 꼴 그대로**라 그리기 코드가 손대지 않는다. * `import type` 이라 배분 모듈이 첫 묶음에 안 실림(판 both 때만 따로 받음). */ export type HaulPlanPayload = HaulPlan | null; /** 브라우저 셈 문맥 — 서버 `_mass_haul_context` 와 같은 값(sections/context 가 같은 함수로 냄). */ export interface HaulPlanContext { conversion: EarthworkConversion; haulLimits?: HaulEquipmentLimit[]; } /** 판 haul = both — 서버 /haul-plan(`_run_view`)과 같은 인자로 같은 함수. * ponytail: 채집석 · 구조물 잔토는 서버 `haul_inputs_for` 가 늘 빈 값이라 null — 값 내기 시작하면 sections/context 로 실어 옴. */ async function browserHaulPlan(result: unknown, ctx: HaulPlanContext): Promise { const { computeHaulPlan } = await import("@util/common_util_mass_haul_balance"); return computeHaulPlan(result as MassHaulResult, ctx.haulLimits, { conversion: ctx.conversion, drawing: true, }); } /** 편집이 멈춘 것으로 볼 시간(ms). 계획고를 연속으로 누르는 동안은 안 보낸다. */ const SETTLE_MS = 400; /** 배분 계산 대기 상한 — Node 실행 200ms 대라 넉넉히 잡는다. */ const TIMEOUT_MS = 20000; /** 열쇠별로 받아 둔 배분 — 되돌리기 · 보기 오감처럼 같은 누가토량이 다시 오면 묻지 않음(342-1). */ const KEPT_PLANS = 8; /** 받은 배분 — `프로젝트:노선|열쇠` → 배분 · 오래된 것부터 버림 · 선반입이 씀. */ const kept = new Map(); /** * 입력이 달라졌는지 가리는 표식. 응답이 오면 곡선을 다시 그리고, 그리기가 다시 * `schedule` 을 부르므로 **표식이 없으면 요청이 끝없이 되돌아온다**(2026-09-06 실측). * 누가토량 결과 전체 — 끝 · 가운데 누계만 보던 옛 표식은 사이 측점이 바뀌어도 같았다(342-1). */ function keyOf(result: unknown): string { const points = (result as { points?: unknown[] })?.points; if (!Array.isArray(points) || !points.length) return "empty"; return JSON.stringify(points); } const keptKey = (projectId: string, route: number, key: string): string => `${projectId}:${route}|${key}`; function keep(at: string, plan: HaulPlanPayload): void { kept.delete(at); kept.set(at, plan); if (kept.size > KEPT_PLANS) kept.delete(kept.keys().next().value as string); } /** 서버에 배분을 물음 — 받으면 기억 · 못 받으면(실패 · 끊김) undefined. `signal` 이 끊으면 함께 끊음. */ async function postHaulPlan( projectId: string, route: number, result: unknown, key: string, signal?: AbortSignal, ): Promise { if (signal?.aborted) return undefined; const controller = new AbortController(); const abort = window.setTimeout(() => controller.abort(), TIMEOUT_MS); const relay = () => controller.abort(); signal?.addEventListener("abort", relay); try { const response = await fetch( `${API_BASE_URL}/projects/${projectId}/sections/${route}/haul-plan`, { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ result }), signal: controller.signal, }, ); if (!response.ok) return undefined; const payload = (await response.json()) as { haul_plan?: HaulPlanPayload }; const plan = payload.haul_plan ?? null; keep(keptKey(projectId, route, key), plan); return plan; } catch { return undefined; // 배분을 못 받아도 곡선 자체는 그대로 보인다 — 화면을 막지 않는다. } finally { window.clearTimeout(abort); signal?.removeEventListener("abort", relay); } } export interface HaulPlanPrefetch { /** 새 누가토량 결과가 나왔음을 알린다 — 잠잠해지면 서버에 물어본다(판 both = 브라우저 셈). */ schedule: (result: unknown, ctx?: HaulPlanContext | null) => void; /** 지금 쥐고 있는 배분. 아직 못 받았으면 null. */ current: () => HaulPlanPayload; /** 화면을 떠날 때 예약을 지운다. */ dispose: () => void; } /** * 배분 선반입기를 만든다. `onReady` 는 값이 새로 도착했을 때만 불린다 — * 부르는 쪽은 그때 곡선을 다시 그리면 된다. */ export function createHaulPlanPrefetch( projectId: string, routeId: () => number | undefined, onReady: () => void, ): HaulPlanPrefetch { let timer = 0; let sequence = 0; let plan: HaulPlanPayload = null; let pending: unknown = null; let pendingCtx: HaulPlanContext | null = null; /** 마지막으로 보낸 입력의 표식 — 같은 값이면 다시 묻지 않는다. */ let sentKey: string | null = null; /** 받은 값을 화면에 반영하려고 다시 그리는 중 — 그 그리기가 거는 요청은 무시한다. */ let applying = false; /** 못 받은 열쇠는 잊는다 — 다음 그리기가 같은 값으로 다시 보냄(342-1). */ function forget(seq: number): void { if (seq === sequence) sentKey = null; } async function send( result: unknown, ctx: HaulPlanContext | null, seq: number, key: string, ): Promise { const route = routeId(); if (!route) return forget(seq); const got = ctx && (await calcInBrowser("haul")) ? await browserHaulPlan(result, ctx).catch(() => undefined) : await postHaulPlan(projectId, route, result, key); if (got === undefined) return forget(seq); // 늦게 온 응답은 버린다 — 그 사이 사용자가 계획고를 더 만졌을 수 있다. if (seq !== sequence) return; plan = got; // 다시 그리기가 `schedule` 을 되부르므로 그 한 바퀴를 막는다. 표식(`sentKey`)만으로는 // 부족했다 — 반영 뒤 누계가 아주 조금 달라지는 경로가 있어 2~3바퀴가 더 돌았다 // (2026-09-06 보조 창 실측: 편집이 멈춘 뒤 2.8~6.3초에 걸쳐 3건). applying = true; try { onReady(); } finally { applying = false; } } return { schedule(result, ctx) { if (applying) return; // 응답을 반영하려고 다시 그리는 중이다. const key = keyOf(result); if (key === sentKey) return; // 같은 값 — 응답이 부른 재그리기가 되돌아온 것이다. sentKey = key; pending = result; pendingCtx = ctx ?? null; sequence += 1; const seq = sequence; window.clearTimeout(timer); // 받아 둔 열쇠면 묻지 않고 그대로 — 그리는 쪽은 이 뒤에 `current()` 를 읽는다. const route = routeId(); const known = route ? kept.get(keptKey(projectId, route, key)) : undefined; if (known !== undefined) return void (plan = known); timer = window.setTimeout(() => void send(pending, pendingCtx, seq, key), SETTLE_MS); }, current: () => plan, dispose() { window.clearTimeout(timer); sequence += 1; // 남아 있는 응답을 모두 무효로 만든다. }, }; }