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
+4 -4
View File
@@ -342,9 +342,8 @@ export function createDesignProfile(ctx: DesignProfileContext) {
history.record();
}
/** 계획선 측점 줄 = 측점 한 벌(서버 일반 측점 + 지금 구조물 측점 · 272) — 표 · ▲▼ 가 옛 자리
* 구조물 측점(옮긴 관 · 서버가 다시 맞추기 전)을 안 봄 · 그래프 · 3D 와 같은 목록. 사라진 구조물
* 측점의 꼭짓점은 [저장] 때 서버와 같게 옮기거나 지움(`followMovedNodes`). 늘 저장분에서 셈. */
/** 계획선 측점 줄 = 측점 한 벌(서버 일반 + 지금 구조물 측점 · 272) — 표 · ▲▼ · 그래프 · 3D · 횡단 다시 셈(342-1)이
* 같은 목록 · 사라진 구조물 측점의 꼭짓점은 서버와 같게 옮기거나 지움(`followMovedNodes`) · 늘 저장분에서 셈. */
function listed(): AlignmentBase | null {
if (!storedBase || !detail) return storedBase;
const server = detail.longitudinal.stations;
@@ -393,10 +392,11 @@ export function createDesignProfile(ctx: DesignProfileContext) {
detail: () => detail,
routeId: () => routeId,
edits: () => store.edits(),
alignment: () => alignment,
debounceMs: () => (root.dataset.view === "cross" ? 120 : CROSS_PREVIEW_DEBOUNCE_MS),
onApplied: (updated) => {
draw();
callbacks?.onCrossDesignsUpdated?.(updated);
if (updated.length) callbacks?.onCrossDesignsUpdated?.(updated);
},
});
const scheduleCrossPreview = (): void => crossPreview.schedule();
+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`와 정확히 같은 매핑이 되도록 반 칸 들여쓰기를
+105 -72
View File
@@ -25,7 +25,7 @@
* ========================================================================== */
import { computeCrossDesign } from "@util/common_util_cross_design";
import type { StandardCrossSectionSpec } from "@util/common_util_cross_design";
import type { CrossDesignOptions, StandardCrossSectionSpec } from "@util/common_util_cross_design";
import { previewCrossDesigns } from "./B06_Section_Api_Fetch";
import type { CrossSection, SectionDetailResponse } from "./B06_Section_Api_Fetch";
import { bermSpecAt, readBermSpans, readRockBoundarySession } from "./B06_Section_UI_Page_Persist";
@@ -46,6 +46,7 @@ import {
planElevationAt,
toAlignmentBase,
} from "../B05_Profile/B05_Profile_UI_Profile_Alignment";
import type { ProfileAlignment } from "../B05_Profile/B05_Profile_UI_Profile_Alignment";
import { readAlignment, toDesignProfile } from "../B05_Profile/B05_Profile_UI_Profile_Data";
import { readState } from "../A00_Common/b_page_state";
import { readAlignmentDraft } from "../B05_Profile/B05_Profile_UI_Profile_Edit";
@@ -64,6 +65,8 @@ export interface CrossRefreshInput {
/** 제자리 갱신 대상 — 공유 캐시가 들고 있는 그 객체여야 두 화면이 같이 따라온다. */
detail: SectionDetailResponse;
edits: CrossRefreshEdits;
/** 화면이 쥔 계획선(측점 한 벌 · 342-1) — 없으면 종단도가 맡긴 선 → 저장분 + `edits`. */
alignment?: ProfileAlignment | null;
/**
* 결과를 **아직 써도 되는지** 묻는다(false면 반영하지 않는다). 로컬 계산은 즉시 끝나
* 늦은 응답이 없지만, 옛 데이터 폴백(서버 프리뷰)에서는 여전히 문지기가 필요하다.
@@ -149,10 +152,33 @@ function preserveUserFields(
}
/**
* 전 측점 횡단을 현재 계획선으로 다시 계산해 `detail.cross_sections[].design`을 제자리 교체한다.
* 돌려주는 값은 실제로 바뀐 측점의 누가거리 목록 — 호출한 쪽이 그 카드만 다시 그리면 된다.
* 계획선 ▲ 한 번에 값이 바뀌는 측점은 몇 개뿐 — 전부 돌려주면 카드를 다 갈아 끼워 멈춤(54-22).
* 계획선 한 벌(342-1) — 종단도가 화면에 그린 선(서버 측점 대신 관 지점 · 옮긴 꼭짓점)을 맡겨 두는 자리.
* 다시 셈이 저장분 선으로 계획고를 적으면 그리기 판정(화면 선)과 mm 단위로 갈려 늘 낡음이 됐다
* (유토곡선 「다시 계산하는 중」 머묾 · 341 부록 2 4장). B05 · B06 · 3D 가 모두 이 선을 받는다.
*/
type ScreenLine = (routeId: number) => ProfileAlignment | null;
let screenLine: ScreenLine | null = null;
/** 종단도가 자기 선을 맡김 — 돌려준 함수로 거둠(자기 것일 때만). */
export function holdScreenAlignment(line: ScreenLine): () => void {
screenLine = line;
return () => {
if (screenLine === line) screenLine = null;
};
}
/** 낡음 판정을 화면 선 기준으로 — 저장분 선끼리 견주면 화면과 반대 판정이 났다(341 부록 2 4장). */
export function staleAgainstScreen(detail: SectionDetailResponse, routeId: number): boolean {
const screen = screenLine?.(routeId);
if (!screen) return hasStaleDesigns(detail);
return hasStaleDesigns({
longitudinal: {
design_profiles: [toDesignProfile(screen, detail.longitudinal.design_profiles?.[0])],
},
cross_sections: detail.cross_sections,
});
}
/**
* 낡은 설계만 다시 셈 — B05 [3D 업데이트]가 예상형상을 짓기 전에 부름(317-1).
* 편집 = 세션 계획선 초안 → 없으면 저장분 · 초안이 있으면 늘 맞춤(B06 `reconcileStaleDesigns` 와 같은 규칙).
@@ -161,7 +187,7 @@ export async function refreshStaleDesigns(
input: Omit<CrossRefreshInput, "edits" | "shouldApply">,
): Promise<number[]> {
const draft = readAlignmentDraft(input.routeId);
if (!draft && !hasStaleDesigns(input.detail)) return [];
if (!draft && !staleAgainstScreen(input.detail, input.routeId)) return [];
const saved = (
input.detail.longitudinal.profile_alignment as
| {
@@ -179,6 +205,11 @@ export async function refreshStaleDesigns(
return refreshCrossDesigns({ ...input, edits });
}
/**
* 전 측점 횡단을 현재 계획선으로 다시 계산해 `detail.cross_sections[].design`을 제자리 교체한다.
* 돌려주는 값은 실제로 바뀐 측점의 누가거리 목록 — 호출한 쪽이 그 카드만 다시 그리면 된다.
* 계획선 ▲ 한 번에 값이 바뀌는 측점은 몇 개뿐 — 전부 돌려주면 카드를 다 갈아 끼워 멈춤(54-22).
*/
export async function refreshCrossDesigns(input: CrossRefreshInput): Promise<number[]> {
const sections = input.detail.cross_sections;
const before = new Map(sections.map((section) => [section, JSON.stringify(section.design)]));
@@ -290,14 +321,19 @@ function readCutSlopeRatios(projectId: string, routeId: number): Map<number, num
function refreshLocally(input: CrossRefreshInput): number[] | null {
const { projectId, detail, edits } = input;
const stored = readAlignment(detail.longitudinal);
if (!stored) return null; // 선형 저장분이 없는 옛 데이터 — 서버가 풀어 준다.
// 선 = 화면이 쥔 선 → 종단도가 맡긴 선 → 저장분 + 편집(종단도가 아직 없을 때) · 342-1
const alignment =
input.alignment ??
screenLine?.(input.routeId) ??
(stored
? buildAlignment(toAlignmentBase(stored), {
station_offsets: edits.station_offsets ?? {},
curve_radii: edits.curve_radii ?? {},
})
: null);
if (!alignment) return null; // 선형 저장분이 없는 옛 데이터 — 서버가 풀어 준다.
const standard = effectiveStandardCross(projectId) as StandardCrossSectionSpec | null;
if (!standard) return null; // 컨텍스트를 아직 못 받음 — 이번만 서버로.
const alignment = buildAlignment(toAlignmentBase(stored), {
station_offsets: edits.station_offsets ?? {},
curve_radii: edits.curve_radii ?? {},
});
// 방금 푼 계획선을 **공유 캐시에도 얹는다**. 여기서 만드는 횡단 설계는 편집이 반영된
// 계획고 기준인데 `design_profiles` 만 저장분으로 남으면 두 값의 기준이 어긋나, 낡음
// 판정이 영원히 참이 되어 유토곡선이 빈 채로 남는다(2026-09-03 사용자 보고: B05 편집 중
@@ -367,69 +403,66 @@ function refreshLocally(input: CrossRefreshInput): number[] | null {
const choice = choiceAt(section.chainage_m);
const extendLeft = extendAt(section.chainage_m, design, "left");
const extendRight = extendAt(section.chainage_m, design, "right");
const elevation = planElevationAt(alignment, section.chainage_m);
const options: CrossDesignOptions = {
groundType:
choice?.ground_type ??
(typeof design.ground_type === "string" ? design.ground_type : "ripping_rock"),
sectionMode:
choice?.section_mode ??
(typeof design.section_mode === "string" ? design.section_mode : "left_cut"),
ditchSide:
choice?.ditch_side ?? (typeof design.ditch_side === "string" ? design.ditch_side : null),
// 저장분은 측구가 없으면 `ditch_type: null` 이다 — 서버와 같이 기본형으로 되돌린다.
ditchType:
choice?.ditch_type ??
(typeof design.ditch_type === "string" ? design.ditch_type : "standard"),
paved: pavedBySpecs(section),
// 물넘이 자리 = 측구 없음 · 지대 낮은 쪽 한 경사(286-2 · 287-4 · 서버 `tilt_ranges` 짝 = 얹힌 제원)
tilted: Boolean(section.ford_pavement || section.underdrain),
tiltPct: section.ford_pavement?.slope_pct ?? section.underdrain?.slope_pct ?? null,
standard,
berm: bermAt(section.chainage_m, design),
rockBoundaryOffsetM,
cutSlopeRatio: cutSlopeAt(section.chainage_m, design),
twoStageSlope: choice?.two_stage_slope ?? Boolean(design.two_stage_slope),
// ⚠ **선택과 결과를 갈라 넘긴다**(2026-09-09). 선택이 있으면 그것을 따르고,
// 없으면 옛 저장분의 결과를 **자동값과 다를 때만** 선택으로 살린다.
ditchChoice: choice
? (choice.ditch_choice ?? null)
: typeof design.ditch_choice === "boolean"
? design.ditch_choice
: null,
ditchEnabled: typeof design.ditch_enabled === "boolean" ? design.ditch_enabled : null,
// 세월교 월류 하강 = 그 측점에 얹힌 세월교 제원(초안 · 저장분 같은 자리 · 서버 `ford_drop_at` 짝 · 314)
surfaceDropM:
Number(section.ford?.overflow_depth_m) > 0 ? Number(section.ford?.overflow_depth_m) : 0,
// 곡선부 확폭 입력 — 측점 기록에 실려 온다(서버 엔진과 같은 값, 2026-09-06).
planRadiusM: section.plan_radius_m ?? null,
curveOuterSide:
section.curve_outer_side === "left" || section.curve_outer_side === "right"
? section.curve_outer_side
: null,
curveWideningM: section.curve_widening_m ?? null,
shoulderExtendLeftM: extendLeft,
shoulderExtendRightM: extendRight,
// 사토장 — **저장분 폭을 그대로 잇는다**. 폭은 구간 용량에서 서버가 정한 값이라
// 브라우저가 다시 풀지 않는다(다시 풀면 그 측점만 폭이 달라져 작업장 모양이 깨진다).
// 이어 붙이지 않으면 계획선을 만질 때마다 사토장이 그림에서 사라진다.
spoilFill:
typeof design.spoil_fill_width_m === "number" &&
design.spoil_fill_width_m > 0 &&
(design.spoil_fill_side === "left" || design.spoil_fill_side === "right")
? {
side: design.spoil_fill_side,
widthM: design.spoil_fill_width_m,
slopeRatioN: null,
}
: null,
};
let next;
try {
next = computeCrossDesign(
section.samples ?? [],
planElevationAt(alignment, section.chainage_m),
{
groundType:
choice?.ground_type ??
(typeof design.ground_type === "string" ? design.ground_type : "ripping_rock"),
sectionMode:
choice?.section_mode ??
(typeof design.section_mode === "string" ? design.section_mode : "left_cut"),
ditchSide:
choice?.ditch_side ??
(typeof design.ditch_side === "string" ? design.ditch_side : null),
// 저장분은 측구가 없으면 `ditch_type: null` 이다 — 서버와 같이 기본형으로 되돌린다.
ditchType:
choice?.ditch_type ??
(typeof design.ditch_type === "string" ? design.ditch_type : "standard"),
paved: pavedBySpecs(section),
// 물넘이 자리 = 측구 없음 · 지대 낮은 쪽 한 경사(286-2 · 287-4 · 서버 `tilt_ranges` 짝 = 얹힌 제원)
tilted: Boolean(section.ford_pavement || section.underdrain),
tiltPct: section.ford_pavement?.slope_pct ?? section.underdrain?.slope_pct ?? null,
standard,
berm: bermAt(section.chainage_m, design),
rockBoundaryOffsetM,
cutSlopeRatio: cutSlopeAt(section.chainage_m, design),
twoStageSlope: choice?.two_stage_slope ?? Boolean(design.two_stage_slope),
// ⚠ **선택과 결과를 갈라 넘긴다**(2026-09-09). 선택이 있으면 그것을 따르고,
// 없으면 옛 저장분의 결과를 **자동값과 다를 때만** 선택으로 살린다.
ditchChoice: choice
? (choice.ditch_choice ?? null)
: typeof design.ditch_choice === "boolean"
? design.ditch_choice
: null,
ditchEnabled: typeof design.ditch_enabled === "boolean" ? design.ditch_enabled : null,
// 세월교 월류 하강 = 그 측점에 얹힌 세월교 제원(초안 · 저장분 같은 자리 · 서버 `ford_drop_at` 짝 · 314)
surfaceDropM:
Number(section.ford?.overflow_depth_m) > 0 ? Number(section.ford?.overflow_depth_m) : 0,
// 곡선부 확폭 입력 — 측점 기록에 실려 온다(서버 엔진과 같은 값, 2026-09-06).
planRadiusM: section.plan_radius_m ?? null,
curveOuterSide:
section.curve_outer_side === "left" || section.curve_outer_side === "right"
? section.curve_outer_side
: null,
curveWideningM: section.curve_widening_m ?? null,
shoulderExtendLeftM: extendLeft,
shoulderExtendRightM: extendRight,
// 사토장 — **저장분 폭을 그대로 잇는다**. 폭은 구간 용량에서 서버가 정한 값이라
// 브라우저가 다시 풀지 않는다(다시 풀면 그 측점만 폭이 달라져 작업장 모양이 깨진다).
// 이어 붙이지 않으면 계획선을 만질 때마다 사토장이 그림에서 사라진다.
spoilFill:
typeof design.spoil_fill_width_m === "number" &&
design.spoil_fill_width_m > 0 &&
(design.spoil_fill_side === "left" || design.spoil_fill_side === "right")
? {
side: design.spoil_fill_side,
widthM: design.spoil_fill_width_m,
slopeRatioN: null,
}
: null,
},
);
next = computeCrossDesign(section.samples ?? [], elevation, options);
} catch {
continue; // 샘플 부족·값 손상 측점은 건너뛴다(서버 엔진과 같은 태도).
}
+29 -5
View File
@@ -15,7 +15,10 @@
from __future__ import annotations
import asyncio
import hashlib
import json
import logging
from collections import OrderedDict
from typing import Any
from uuid import UUID
@@ -37,6 +40,16 @@ router = APIRouter(prefix="/api/projects", tags=["B06 Profile Cross"])
_NPM_SCRIPT = "build:server-calc"
# 측점 수 상한 — 정상 노선은 수백 곳이다. 그보다 크면 비정상 요청으로 본다.
_MAX_POINTS = 5000
# 열쇠(누가토량 결과 + 계수 · 장비 경계) → 배분 — 같은 열쇠면 Node 를 다시 안 띄움
# (342-1 · 부록 4 R25). ponytail: 프로세스 메모리 · 최근 몇 벌만 — 재시작 뒤 첫 열기는
# 다시 셈(_derived 저장본은 343 틀 뒤).
_KEPT: OrderedDict[str, Any] = OrderedDict()
_KEPT_MAX = 32
def _plan_key(result: dict[str, Any], context: Any) -> str:
body = json.dumps({"result": result, "context": context}, sort_keys=True, default=str)
return hashlib.sha1(body.encode("utf-8")).hexdigest()
@router.post("/{project_id}/sections/{route_id}/haul-plan", response_model=None)
@@ -69,15 +82,17 @@ async def compute_haul_plan(
factors = await conversion_factors_for(project_id)
# 장비 거리 경계도 프로젝트 값으로 — 도쟈 한계거리를 고쳤으면 배분이 그 값으로 선다.
limits = await haul_limits_for(project_id)
context = _mass_haul_context(haul_inputs, factors, limits)
key = _plan_key(result, context)
if key in _KEPT:
_KEPT.move_to_end(key)
return JSONResponse(content={"status": "success", "haul_plan": _KEPT[key]})
try:
output = await asyncio.to_thread(
run_bundle_json,
BUNDLE,
_NPM_SCRIPT,
{
"haul_plan_for": result,
"context": _mass_haul_context(haul_inputs, factors, limits),
},
{"haul_plan_for": result, "context": context},
)
except Exception:
logger.exception("유토 배분 계산 실패: project_id=%s route_id=%s", project_id, route_id)
@@ -85,5 +100,14 @@ async def compute_haul_plan(
status_code=500,
content={"status": "error", "message": "유토 배분 계산에 실패했습니다."},
)
plan = output.get("haul_plan") if isinstance(output, dict) else None
# 묶음 실패(None)는 실패로 — 성공 + 빈 배분으로 주면 화면이 그 열쇠를 다시 안 물었다(342-1).
if not isinstance(output, dict):
return JSONResponse(
status_code=500,
content={"status": "error", "message": "유토 배분 계산에 실패했습니다."},
)
plan = output.get("haul_plan")
_KEPT[key] = plan
if len(_KEPT) > _KEPT_MAX:
_KEPT.popitem(last=False)
return JSONResponse(content={"status": "success", "haul_plan": plan})
@@ -15,14 +15,13 @@ import {
writeShoulderExtend,
} from "./B06_Section_Cross_Design_Session";
import { showToast } from "@ui/ui_template_elements";
import { refreshCrossDesigns } from "./B06_Section_Cross_Refresh";
import { refreshCrossDesigns, staleAgainstScreen } from "./B06_Section_Cross_Refresh";
import {
bermSpansFromStructures,
readBermSpans,
writeBermSpans,
} from "./B06_Section_UI_Page_Persist";
import { readAlignmentDraft } from "../B05_Profile/B05_Profile_UI_Profile_Edit";
import { hasStaleDesigns } from "./B06_Section_UI_Section_Common";
import { L } from "./B06_Section_UI_Page_Common";
import type { SectionDetailResponse } from "./B06_Section_Api_Fetch";
import type { CrossDesignChange, SectionViewController } from "./B06_Section_UI_Section_View";
@@ -167,7 +166,8 @@ export function createDesignSync(ctx: DesignSyncContext): DesignSyncController {
// 저장분끼리는 늘 일치해 「낡지 않음」으로 나오는데, B05가 세션에 남긴 편집은 그 안에
// 없어 B06이 재계산을 통째로 건너뛰었다 — 같은 시점에 B05 절토 4,774.1㎥ ↔ B06
// 4,515.0㎥ 로 갈렸다(2026-09-03 실측). 재계산은 브라우저 안에서 끝나 값싸다.
if (!options?.force && !draft && !hasStaleDesigns(sectionDetail)) return;
// 판정 · 다시 셈 모두 종단도가 쥔 선(측점 한 벌) 기준 — 저장분 선끼리 견주면 화면과 갈림(342-1).
if (!options?.force && !draft && !staleAgainstScreen(sectionDetail, currentRouteId)) return;
// 진입 정합은 화면을 잠그지 않는다 — 카드가 도착하는 대로 조용히 갱신된다
// (CLAUDE.md 5장).
try {
@@ -133,6 +133,11 @@ export function staleKey(
.join("|");
}
/** 낡음 지문(`staleKey`)에 든 측점 누가거리 — 다시 셈 뒤에도 남은 측점을 표시할 때(342-1). */
export function staleChainages(key: string): number[] {
return key ? key.split("|").map((entry) => Number(entry.split(":")[0])) : [];
}
interface StaleCandidate {
chainage_m: number;
// 측점에 얹힌 구조물 제원 — 저장분 설계값(포장 · 기울기 · 노면 하강)과 맞대 본다(314)
@@ -15,6 +15,7 @@ const STUBS = {
refreshCalls += 1;
return [];
},
holdScreenAlignment: () => () => {}, // 화면 선 맡김(342-1) — 이 시험 몫 아님
},
};
globalThis.window = { setTimeout: (fn) => (fn(), 1), clearTimeout: () => {} };
@@ -63,6 +64,7 @@ const fordFresh = { ...fordStale, design: { ...fordStale.design, tilted: true }
detail: () => detailOf([fordStale]),
routeId: () => 1,
edits: () => ({ station_offsets: {}, curve_radii: {} }),
alignment: () => null,
debounceMs: () => 0,
onApplied: () => {},
});
+3 -2
View File
@@ -56,8 +56,9 @@ def test_draw_and_button_wiring() -> None:
panel = _read("A00_Common", "design", "design_profile.ts")
assert "onStaleDesigns: crossPreview.scheduleIfStale" in panel
render = _read("B05_Profile", "B05_Profile_UI_Profile_Render.ts")
assert "ctx.onStaleDesigns?.(stale);" in render
assert 'pendingRecalc: stale !== ""' in render
# 다시 셈 뒤에도 남은 낡음은 기다림 끝 — 곡선은 그리고 그 측점만 표시(342-1)
assert 'const pending = ctx.onStaleDesigns?.(stale) ?? stale !== "";' in render
assert "pendingRecalc: pending," in render
corridor = _read("B05_Profile", "B05_Profile_UI_Corridor.ts")
body = corridor.split("export async function refreshCorridor", 1)[1].split("\n}\n", 1)[0]
assert "await refreshStaleDesigns({ projectId, routeId, detail })" in body
@@ -0,0 +1,60 @@
# -*- coding: utf-8 -*-
"""342-1 흙 배분 서버 — 같은 열쇠(누가토량 결과 + 계수 · 장비 경계)면 Node 를 다시 안 띄움.
묶음 실패는 500 — 성공 + 빈 배분으로 주면 화면이 그 열쇠를 다시 안 물었음.
"""
import asyncio
import json
import sys
from pathlib import Path
from uuid import uuid4
PROJECT_ROOT = Path(__file__).resolve().parents[2]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from B06_Section import B06_Section_Router_HaulPlan as router # noqa: E402
def _patch(monkeypatch, outputs: list) -> list:
calls: list = []
async def _none(_project_id):
return {}
def _run(_bundle, _script, payload):
calls.append(payload)
return outputs.pop(0)
monkeypatch.setattr(router, "haul_inputs_for", _none)
monkeypatch.setattr(router, "conversion_factors_for", _none)
monkeypatch.setattr(router, "haul_limits_for", _none)
monkeypatch.setattr(router, "_mass_haul_context", lambda *_: {"limits": []})
monkeypatch.setattr(router, "run_bundle_json", _run)
router._KEPT.clear()
return calls
def _post(result: dict):
response = asyncio.run(router.compute_haul_plan(uuid4(), 7, {"result": result}))
return response.status_code, json.loads(response.body)
def test_같은_열쇠는_Node_한_번(monkeypatch):
calls = _patch(monkeypatch, [{"haul_plan": {"tag": "a"}}, {"haul_plan": {"tag": "b"}}])
a = {"points": [{"cumulative_volume_m3": 1.0}]}
b = {"points": [{"cumulative_volume_m3": 2.0}]}
assert _post(a) == (200, {"status": "success", "haul_plan": {"tag": "a"}})
assert _post(a)[1]["haul_plan"] == {"tag": "a"}
assert len(calls) == 1
assert _post(b)[1]["haul_plan"] == {"tag": "b"}
assert len(calls) == 2
def test_묶음_실패는_500_기억_안_함(monkeypatch):
calls = _patch(monkeypatch, [None, {"haul_plan": None}])
a = {"points": [{"cumulative_volume_m3": 1.0}]}
assert _post(a)[0] == 500
assert _post(a) == (200, {"status": "success", "haul_plan": None})
assert len(calls) == 2
@@ -99,6 +99,7 @@ def test_source_shared_rule_and_b05_entry_recompute():
assert "scheduleCrossPreview();" in B05_PANEL
# B06: 같은 규칙 하나만 쓴다 — 옛 암 2단계 필드 누락 조건도 공용 유틸로 옮겼다
# (2026-09-03 일원화: 조건이 갈리면 같은 데이터가 두 화면에서 다른 값이 된다).
assert "hasStaleDesigns(sectionDetail)" in B06_PAGE
# 판정은 종단도가 쥔 화면 선 기준(342-1 · 공용 규칙 hasStaleDesigns 를 그 선으로 부름)
assert "staleAgainstScreen(sectionDetail, currentRouteId)" in B06_PAGE
assert "two_stage_slope === undefined" in COMMON
assert "two_stage_slope === undefined" not in B06_PAGE