- cross 단계(B06_Section_Cross_Stage) = profile · ground · input.cross → 측점 열쇠마다 횡단 설계(CrossOutput.byKey) · 같은 엔진 · 측점 지문이 같으면 앞 결과 · 바뀐 범위 밖은 ctx.prev 재사용 - masshaul 단계(logic/B05_Profile_MassHaul_Stage) = cross · profile → 유토곡선 계열 + 흙 배분 입력 - haul 단계(Api_HaulPlan createHaulStage) = 서버 Node 배분 · 선반입과 같은 열쇠 기억 · 새 조작이면 끊김 · 못 받으면 앞 배분 - 시험: 옛 초안 옮김 · 쓴 자리 읽기 · 노선 바뀜 넘김(노선 밖 2 건) · cross 6 → 같은 입력 0 → 측점 하나 1 · masshaul 계열 · haul 같은 열쇠 POST 1 번 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy
181 lines
8.0 KiB
TypeScript
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/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; // 남아 있는 응답을 모두 무효로 만든다.
|
|
},
|
|
};
|
|
}
|