fix(종횡단): 342-1 계획선 한 벌 · 남은 낡음 표시 · 흙 배분 열쇠

- 횡단 다시 셈이 화면이 쥔 선(측점 한 벌 · 관 지점 · 옮긴 꼭짓점)을 받음 — 종단도가 선을 맡기고 B06 진입 정합 · 3D [업데이트] 앞 다시 셈도 같은 선 · 낡음 판정도 그 선 기준(staleAgainstScreen)
- 다시 셈 뒤에도 낡은 측점이 남으면 유토곡선은 그리고 그 측점만 표시 · 콘솔 경고 한 줄 · 흙 배분도 건너뛰지 않음(09-03 「새 값만 보여주기」 바꿈 · 사용자 확정 10-16)
- 흙 배분: 실패한 열쇠 다시 보냄 · 받은 열쇠(누가토량 결과 전체)는 다시 안 물음 · 서버도 같은 열쇠 기억 · 묶음 실패는 500
- 옛 시험 셋 새 동작으로 맞춤 · 서버 열쇠 시험 더함

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy
This commit is contained in:
2026-10-16 11:15:22 +09:00
co-authored by Claude Opus 5.5
parent 2879f9bea9
commit 48ddca36a2
13 changed files with 290 additions and 108 deletions
+24 -10
View File
@@ -21,6 +21,8 @@ export type HaulPlanPayload = HaulPlan | null;
const SETTLE_MS = 400;
/** 배분 계산 대기 상한 — Node 실행 200ms 대라 넉넉히 잡는다. */
const TIMEOUT_MS = 20000;
/** 열쇠별로 받아 둔 배분 — 되돌리기 · 보기 오감처럼 같은 누가토량이 다시 오면 묻지 않음(342-1). */
const KEPT_PLANS = 8;
export interface HaulPlanPrefetch {
/** 새 누가토량 결과가 나왔음을 알린다 — 잠잠해지면 서버에 물어본다. */
@@ -48,23 +50,28 @@ export function createHaulPlanPrefetch(
let sentKey: string | null = null;
/** 받은 값을 화면에 반영하려고 다시 그리는 중 — 그 그리기가 거는 요청은 무시한다. */
let applying = false;
/** 받은 배분 — 열쇠(누가토량 결과 전체) → 배분 · 오래된 것부터 버림. */
const kept = new Map<string, HaulPlanPayload>();
/**
* 입력이 달라졌는지 가리는 짧은 표식. 응답이 오면 곡선을 다시 그리고, 그리기가 다시
* 입력이 달라졌는지 가리는 표식. 응답이 오면 곡선을 다시 그리고, 그리기가 다시
* `schedule` 을 부르므로 **표식이 없으면 요청이 끝없이 되돌아온다**(2026-09-06 실측).
* 누가토량이 바뀌면 마지막 누계와 측점 수 중 하나는 반드시 달라진다.
* 누가토량 결과 전체 — 끝 · 가운데 누계만 보던 옛 표식은 사이 측점이 바뀌어도 같았다(342-1).
*/
function keyOf(result: unknown): string {
const points = (result as { points?: Array<{ cumulative_volume_m3?: number }> })?.points;
const points = (result as { points?: unknown[] })?.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)}`;
return JSON.stringify(points);
}
async function send(result: unknown, seq: number): Promise<void> {
/** 못 받은 열쇠는 잊는다 — 다음 그리기가 같은 값으로 다시 보냄(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;
if (!route) return forget(seq);
const controller = new AbortController();
const abort = window.setTimeout(() => controller.abort(), TIMEOUT_MS);
try {
@@ -78,11 +85,14 @@ export function createHaulPlanPrefetch(
signal: controller.signal,
},
);
if (!response.ok) return;
if (!response.ok) return forget(seq);
const payload = (await response.json()) as { haul_plan?: HaulPlanPayload };
// 늦게 온 응답은 버린다 — 그 사이 사용자가 계획고를 더 만졌을 수 있다.
if (seq !== sequence) return;
plan = payload.haul_plan ?? null;
kept.delete(key);
kept.set(key, plan);
if (kept.size > KEPT_PLANS) kept.delete(kept.keys().next().value as string);
// 다시 그리기가 `schedule` 을 되부르므로 그 한 바퀴를 막는다. 표식(`sentKey`)만으로는
// 부족했다 — 반영 뒤 누계가 아주 조금 달라지는 경로가 있어 2~3바퀴가 더 돌았다
// (2026-09-06 보조 창 실측: 편집이 멈춘 뒤 2.8~6.3초에 걸쳐 3건).
@@ -94,6 +104,7 @@ export function createHaulPlanPrefetch(
}
} catch {
// 배분을 못 받아도 곡선 자체는 그대로 보인다 — 화면을 막지 않는다.
forget(seq);
} finally {
window.clearTimeout(abort);
}
@@ -109,7 +120,10 @@ export function createHaulPlanPrefetch(
sequence += 1;
const seq = sequence;
window.clearTimeout(timer);
timer = window.setTimeout(() => void send(pending, seq), SETTLE_MS);
// 받아 둔 열쇠면 묻지 않고 그대로 — 그리는 쪽은 이 뒤에 `current()` 를 읽는다.
const known = kept.get(key);
if (known !== undefined) return void (plan = known);
timer = window.setTimeout(() => void send(pending, seq, key), SETTLE_MS);
},
current: () => plan,
dispose() {
+11 -1
View File
@@ -141,6 +141,8 @@ export interface RouteMassHaulDrawParams {
* 「새 값만 보여주기」). Panel이 `hasStaleDesigns`로 판정해 넘긴다.
*/
pendingRecalc?: boolean;
/** 다시 셈 뒤에도 남은 낡은 측점 — 곡선은 그리고 이 측점만 표시(342-1 · 사용자 확정 10-16). */
staleChainages?: number[];
}
export interface RouteMassHaulPanel {
@@ -379,11 +381,19 @@ export function createRouteMassHaulPanel(
// 안에서 끝나 수십 ms면 끝나므로, 그동안 **직전 곡선을 그대로 두고** 상태줄로만
// 알린다. 아직 한 번도 못 그렸을 때만 문구로 자리를 채운다.
if (params.pendingRecalc) {
pendingChip.textContent = "다시 계산 중…";
if (drawnOnce) pendingChip.hidden = false;
else note("횡단을 지금 계획선에 맞춰 다시 계산하는 중입니다.");
return;
}
pendingChip.hidden = true;
const stale = params.staleChainages ?? [];
pendingChip.hidden = !stale.length;
pendingChip.textContent = stale.length
? `낡은 측점 ${stale.length}곳 · ${stale
.slice(0, 5)
.map((chainage) => `${chainage.toFixed(2)} m`)
.join(" · ")}${stale.length > 5 ? " …" : ""}`
: "다시 계산 중…";
// B06과 같은 계산 — 횡단 기준(정식)과 종단 기준(개략 비교)을 함께 낸다.
// 결과를 아껴 두지 **않는다** — 횡단 설계가 제자리에서 갈리므로 객체 동일성으로는
// 바뀐 것을 못 잰다(2026-09-04 캐시를 넣었다가 곡선이 안 따라와 되돌림).
+35 -6
View File
@@ -9,8 +9,9 @@
* 끌기 중에는 계속 호출되므로 디바운스하고, 늦게 온 응답은 seq 비교로 버린다.
* ========================================================================== */
import type { AlignmentEdits } from "./B05_Profile_UI_Profile_Alignment";
import { refreshCrossDesigns } from "../B06_Section/B06_Section_Cross_Refresh";
import type { AlignmentEdits, ProfileAlignment } from "./B05_Profile_UI_Profile_Alignment";
import { holdScreenAlignment, refreshCrossDesigns } from "../B06_Section/B06_Section_Cross_Refresh";
import { staleChainages } from "../B06_Section/B06_Section_UI_Section_Common";
import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch";
export interface CrossPreviewContext {
@@ -18,6 +19,8 @@ export interface CrossPreviewContext {
detail: () => SectionDetailResponse | null;
routeId: () => number | null;
edits: () => AlignmentEdits;
/** 화면이 쥔 계획선(측점 한 벌) — 다시 셈 · B06 · 3D 가 같은 선을 받음(342-1). */
alignment: () => ProfileAlignment | null;
/** 부를 때마다 읽음 — 보기마다 다름. */
debounceMs: () => number;
/** 반영이 끝난 뒤 다시 그린다 — 다시 계산한 측점 누가거리를 넘긴다(횡단 카드 갱신용). */
@@ -27,8 +30,12 @@ export interface CrossPreviewContext {
export interface CrossPreview {
/** 편집이 있을 때마다 부른다 — 마지막 값만 서버로 나간다. */
schedule: () => void;
/** 그리기가 잰 낡음 지문 — 새 지문일 때만 `schedule`(같은 낡음 되풀이 · 서버 폴백 반복 막음 · 317-1). */
scheduleIfStale: (key: string) => void;
/**
* 그리기가 잰 낡음 지문 — 새 지문일 때만 `schedule`(같은 낡음 되풀이 · 서버 폴백 반복 막음 · 317-1).
* 돌려주는 값 = 다시 셈을 기다리는 중인가. 다시 셈 뒤에도 남은 낡음은 false — 곡선은 그리고
* 그 측점만 표시(342-1 · 09-03 「새 값만 보여주기」를 바꿈 · 사용자 확정 10-16).
*/
scheduleIfStale: (key: string) => boolean;
/** 패널을 걷을 때 대기 중인 요청 타이머를 끈다. */
dispose: () => void;
}
@@ -37,19 +44,34 @@ export function createCrossPreview(ctx: CrossPreviewContext): CrossPreview {
let timer = 0;
let seq = 0;
let lastStale = "";
/** 다시 셈 예약 · 진행 중 — 이 동안의 낡음은 「다시 계산 중」. */
let busy = false;
let warned = "";
const release = holdScreenAlignment((routeId) =>
routeId === ctx.routeId() ? ctx.alignment() : null,
);
const preview: CrossPreview = {
scheduleIfStale(key) {
if (key && key !== lastStale) preview.schedule();
else if (key && !busy && key !== warned) {
warned = key;
console.warn(
`[횡단] 다시 셈 뒤에도 낡은 측점 ${staleChainages(key).length}곳 — 곡선은 그리고 그 측점만 표시`,
staleChainages(key),
);
}
lastStale = key;
return key !== "" && busy;
},
schedule() {
if (!ctx.detail() || ctx.routeId() === null) return;
busy = true;
window.clearTimeout(timer);
timer = window.setTimeout(() => {
const detail = ctx.detail();
const routeId = ctx.routeId();
if (!detail || routeId === null) return;
if (!detail || routeId === null) return void (busy = false);
const current = (seq += 1);
// 재계산은 B06과 **같은 창구**를 쓴다 — 표준 단면값·암 경계 오프셋이 빠지면 서버가
// 다른 설계를 그려 같은 데이터가 두 화면에서 다른 값이 된다(2026-09-03 일원화).
@@ -60,19 +82,26 @@ export function createCrossPreview(ctx: CrossPreviewContext): CrossPreview {
routeId,
detail,
edits: ctx.edits(),
alignment: ctx.alignment(),
// 늦게 온 옛 응답이 새 설계를 덮지 않게 반영 직전에 한 번 더 확인한다.
shouldApply: isCurrent,
})
.then((updated) => {
if (updated.length) ctx.onApplied(updated);
if (current === seq) busy = false;
// 남은 낡음이 있으면 바뀐 것이 없어도 다시 그림 — 「다시 계산 중」에 멈추지 않게(342-1)
if (updated.length || lastStale) ctx.onApplied(updated);
})
.catch(() => {
/* 프리뷰 실패는 무시 — 화면의 계획선은 그대로 두고 다음 편집에서 다시 시도한다. */
if (current !== seq) return;
busy = false;
if (lastStale) ctx.onApplied([]);
});
}, ctx.debounceMs());
},
dispose() {
window.clearTimeout(timer);
release();
},
};
return preview;
+7 -4
View File
@@ -15,6 +15,7 @@ import {
} from "../B06_Section/B06_Section_UI_Longitudinal";
import {
LONG_PAD,
staleChainages,
staleKey,
windowElevationRange,
} from "../B06_Section/B06_Section_UI_Section_Common";
@@ -59,8 +60,9 @@ import type { RouteProfilePanelCallbacks } from "./B05_Profile_UI_Profile_Panel"
/** 본체가 들고 있는 상태를 읽고, 그리는 도중 바뀌는 것만 되돌려 주는 통로. */
export interface ProfileRenderContext {
body: HTMLElement;
/** 그릴 때 잰 낡은 횡단 지문("" = 모두 맞음) — 패널이 새 지문일 때만 다시 셈(317-1). */
onStaleDesigns?: (key: string) => void;
/** 그릴 때 잰 낡은 횡단 지문("" = 모두 맞음) — 패널이 새 지문일 때만 다시 셈(317-1).
* 돌려주는 값 = 다시 셈을 기다리는 중(false 면 남은 낡음 — 곡선은 그리고 그 측점만 표시 · 342-1). */
onStaleDesigns?: (key: string) => boolean;
callbacks: RouteProfilePanelCallbacks | undefined;
massHaul: { draw: (params: RouteMassHaulDrawParams) => void };
tableOverlay: {
@@ -230,7 +232,7 @@ export function renderProfile(ctx: ProfileRenderContext): void {
longitudinal: { design_profiles: designProfiles },
cross_sections: detail.cross_sections,
});
ctx.onStaleDesigns?.(stale);
const pending = ctx.onStaleDesigns?.(stale) ?? stale !== "";
// 그래프에는 비정규 측점을 일반 측점처럼(세로선+라벨) 섞어 넣는다.
const graphData = normalizedLongitudinal(longitudinal);
// 측점 한 벌(272) — 서버의 구조물 측점은 사이드바 목록(관을 옮기면 따라옴)이 갈음하고, 같은 m
@@ -424,7 +426,8 @@ export function renderProfile(ctx: ProfileRenderContext): void {
// 비교 대상은 **편집이 반영된 계획선**(`designProfiles`)이다. 저장분
// (`detail.longitudinal.design_profiles`)과 견주면 편집 중에는 영원히 어긋난 것으로
// 나와 곡선이 계속 빈 화면이 된다(2026-09-03 사용자 보고).
pendingRecalc: stale !== "",
pendingRecalc: pending,
staleChainages: pending ? [] : staleChainages(stale),
axis: {
maxChainageM: maxChainageOf(longitudinal),
// 종단 그래프의 `chainageMapper`와 정확히 같은 매핑이 되도록 반 칸 들여쓰기를