/* ============================================================================= * B05_Profile_Api_HaulPlan.ts * 유토 **배분**(평형선·운반거리·장비)을 서버에서 미리 받아 두는 자리. * * 왜 서버인가(2026-09-06 사용자 확정) — 배분 산식은 노하우가 몰린 자리라 브라우저 번들에 * 남기지 않는다. 화면은 누가토량까지만 스스로 내고(`common_util_mass_haul`), 그 결과를 * 여기로 보내 배분을 받아 쥔다. 계산은 여전히 한 벌이다 — 서버가 같은 TS 를 Node 로 돈다. * * **조용히 따라오게 한다** — 편집이 멈추면 뒤에서 물어 두므로, 유토곡선 패널을 펼치는 * 순간에는 이미 도착해 있다. 늦게 온 응답은 버린다(최신 요청만 채택). * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; import type { HaulPlan } from "@util/common_util_mass_haul_balance"; import type { StageContext } from "../A00_Common/design/logic/design_pipeline_types"; import type { MassHaulStageOutput } from "./logic/B05_Profile_MassHaul_Stage"; /** 서버가 돌려주는 배분 한 벌 — **화면이 쓰는 꼴 그대로**라 그리기 코드가 손대지 않는다. * `import type` 이라 배분 모듈이 번들에 실리지 않는다(빌드에서 지워진다). */ export type HaulPlanPayload = HaulPlan | null; /** 편집이 멈춘 것으로 볼 시간(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); } } /** * 계산 묶음 `haul` 단계(PLAN 346-2 · 계약 `design_pipeline_types.ts`) — masshaul 의 횡단 기준 결과를 서버 * Node 로 배분 · 같은 열쇠면 안 물음 · 새 조작이 오면 `ctx.signal` 로 끊김(옛 번호 결과는 묶음이 버림). * 못 받으면 앞 배분(`ctx.prev`) 그대로. */ export function createHaulStage(projectId: string, routeId: () => number | undefined) { return async (ctx: StageContext<"haul">): Promise => { const result = (ctx.out.masshaul as MassHaulStageOutput | null)?.haulInput; const route = routeId(); if (!result || !route) return (ctx.prev as HaulPlanPayload) ?? null; const key = keyOf(result); const at = keptKey(projectId, route, key); if (kept.has(at)) return kept.get(at) ?? null; const plan = await postHaulPlan(projectId, route, result, key, ctx.signal); return plan === undefined ? ((ctx.prev as HaulPlanPayload) ?? null) : plan; }; } export interface HaulPlanPrefetch { /** 새 누가토량 결과가 나왔음을 알린다 — 잠잠해지면 서버에 물어본다. */ schedule: (result: unknown) => 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 sentKey: string | null = null; /** 받은 값을 화면에 반영하려고 다시 그리는 중 — 그 그리기가 거는 요청은 무시한다. */ let applying = false; /** 못 받은 열쇠는 잊는다 — 다음 그리기가 같은 값으로 다시 보냄(342-1). */ function forget(seq: number): void { if (seq === sequence) sentKey = null; } async function send(result: unknown, seq: number, key: string): Promise { const route = routeId(); if (!route) return forget(seq); const got = 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) { if (applying) return; // 응답을 반영하려고 다시 그리는 중이다. const key = keyOf(result); if (key === sentKey) return; // 같은 값 — 응답이 부른 재그리기가 되돌아온 것이다. sentKey = key; pending = result; 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, seq, key), SETTLE_MS); }, current: () => plan, dispose() { window.clearTimeout(timer); sequence += 1; // 남아 있는 응답을 모두 무효로 만든다. }, }; }