/* ============================================================================= * 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` 이라 배분 모듈이 번들에 실리지 않는다(빌드에서 지워진다). */ export type HaulPlanPayload = HaulPlan | null; /** 편집이 멈춘 것으로 볼 시간(ms). 계획고를 연속으로 누르는 동안은 안 보낸다. */ const SETTLE_MS = 400; /** 배분 계산 대기 상한 — Node 실행 200ms 대라 넉넉히 잡는다. */ const TIMEOUT_MS = 20000; 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; /** * 입력이 달라졌는지 가리는 짧은 표식. 응답이 오면 곡선을 다시 그리고, 그리기가 다시 * `schedule` 을 부르므로 **표식이 없으면 요청이 끝없이 되돌아온다**(2026-09-06 실측). * 누가토량이 바뀌면 마지막 누계와 측점 수 중 하나는 반드시 달라진다. */ function keyOf(result: unknown): string { const points = (result as { points?: Array<{ cumulative_volume_m3?: number }> })?.points; if (!Array.isArray(points) || !points.length) return "empty"; const last = points[points.length - 1]?.cumulative_volume_m3 ?? 0; const mid = points[Math.floor(points.length / 2)]?.cumulative_volume_m3 ?? 0; return `${points.length}:${last.toFixed(3)}:${mid.toFixed(3)}`; } async function send(result: unknown, seq: number): Promise { const route = routeId(); if (!route) return; const controller = new AbortController(); const abort = window.setTimeout(() => controller.abort(), TIMEOUT_MS); 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; const payload = (await response.json()) as { haul_plan?: HaulPlanPayload }; // 늦게 온 응답은 버린다 — 그 사이 사용자가 계획고를 더 만졌을 수 있다. if (seq !== sequence) return; plan = payload.haul_plan ?? null; // 다시 그리기가 `schedule` 을 되부르므로 그 한 바퀴를 막는다. 표식(`sentKey`)만으로는 // 부족했다 — 반영 뒤 누계가 아주 조금 달라지는 경로가 있어 2~3바퀴가 더 돌았다 // (2026-09-06 보조 창 실측: 편집이 멈춘 뒤 2.8~6.3초에 걸쳐 3건). applying = true; try { onReady(); } finally { applying = false; } } catch { // 배분을 못 받아도 곡선 자체는 그대로 보인다 — 화면을 막지 않는다. } finally { window.clearTimeout(abort); } } 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); timer = window.setTimeout(() => void send(pending, seq), SETTLE_MS); }, current: () => plan, dispose() { window.clearTimeout(timer); sequence += 1; // 남아 있는 응답을 모두 무효로 만든다. }, }; }