Files
Aislo/B05_Profile/B05_Profile_Api_HaulPlan.ts
T
eomsangdonandClaude Opus 5.5 281c71b55b refactor(logic): 348-1 순수 계산 TS 49 파일 <페이지>/logic/ 로 옮김 — 이름 앞머리 그대로 · 동작 고침 없음
- 옮김: B04 2 · B05 23 · B06 11 · A00_Common/design 13 (9,299 줄) · 이미 logic/ 에 있던 16 파일 그대로
- 안 옮김: 묶음 입구(package.json build:*) · 파이썬 · 순수지만 계산 아닌 8 (보기 결선 · 콜백 · API 타입 · 갈아끼움 알림판)
- import 266 줄(142 파일) · 시험 · 주석 · 짝 표시 문자열 경로 고침 · B05 · B06 CLAUDE.md 짝 줄 logic/
- 시험 도우미 새 경로: 가짜 require 열쇠 · B05()/B06() 부름 · 형제 import 자리 · tsc 묶어 돌리는 시험 · 정규식 · rglob
- design_view_cross 줄 한도 시험 685 → 688 (긴 import 경로 줄바꿈 3줄)
- 확인: typecheck 0 · 서버 묶음 넷(corridor · server-calc · b07-extent · b07-note) 다시 지어 코드 같음(//#region 경로 주석만 다름) · 관련 시험 270 파일 — 남은 실패 19 는 옮기기 전과 같음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
2026-10-16 13:44:50 +09:00

181 lines
8.0 KiB
TypeScript

/* =============================================================================
* 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<string, HaulPlanPayload>();
/**
* 입력이 달라졌는지 가리는 표식. 응답이 오면 곡선을 다시 그리고, 그리기가 다시
* `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<HaulPlanPayload | undefined> {
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<HaulPlanPayload> => {
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<void> {
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; // 남아 있는 응답을 모두 무효로 만든다.
},
};
}