feat(B05/B06): 옛 계획고 유토곡선 노출 차단 + 사면 미교차 측점 경고

① 저장된 횡단이 지금 계획선과 어긋나면(재계산 예약 상태) 유토곡선을 그리지 않고
「횡단을 지금 계획선에 맞춰 다시 계산하는 중입니다」만 띄움. 옛 계획고로 만든 면적이
2초간 보였다가 정본으로 갈아 끼워지던 것을 없앰(2026-09-03 사용자 확정 「새 값만
보여주기」). 용화 실측 — 65측점 중 56개가 낡음, 계획고 차 −9.36~+5.83m,
저장분 성토 42,050㎥ ↔ 정본 14,881㎥.

② 사면이 계산 반폭 끝까지 원지반과 만나지 않아 면적이 잘린 측점에 카드 경고 표기.
판정은 면적을 내는 엔진 한 곳(B06_Section_Engine_Design)에서 `slope_unclosed` 로
내려 화면·수량·도면이 같은 기준을 봄. 계산은 손대지 않음(2026-09-03 사용자 확정
「영원히 못 만나는 지형이 있을 수 있으니 경고로 대체」).

검증 — 공용 브라우저 B06 실측: 2.6초 재계산 안내만 표시(옛 값 미노출), 4.4초 정본
표시, 사면 미교차 경고 13/65측점. pytest 366 passed·17 skipped, typecheck·prettier 통과.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-03 09:10:46 +09:00
co-authored by Claude Opus 5
parent 698f8f0505
commit 23b1e7e5b0
9 changed files with 81 additions and 10 deletions
@@ -124,6 +124,12 @@ export interface RouteMassHaulDrawParams {
onSelectStation: (stationId: string) => void;
/** 측점선이 아닌 빈 곳 클릭 — 선택 해제(2026-08-04 사용자 지시). */
onClearSelection?: () => void;
/**
* 저장된 횡단이 지금 계획선과 어긋나 재계산이 예약된 상태. 그 사이의 면적은 **옛
* 계획고로 만든 값**이라 그리면 사용자가 옛 그림을 보게 된다(2026-09-03 사용자 확정:
* 「새 값만 보여주기」). Panel이 `staleDesignChainages`로 판정해 넘긴다.
*/
pendingRecalc?: boolean;
}
export interface RouteMassHaulPanel {
@@ -306,6 +312,13 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa
note("토량환산계수를 불러오지 못해 유토곡선을 계산할 수 없습니다.");
return;
}
// 저장된 횡단이 지금 계획선과 어긋나 있으면 그 면적은 **옛 계획고로 만든 값**이다.
// 그걸 잠깐 그렸다가 재계산 결과로 갈아 끼우면 사용자가 옛 그림을 본다(2026-09-03
// 사용자 확정: 「새 값만 보여주기」). Panel이 이미 재계산을 예약해 두므로 기다린다.
if (params.pendingRecalc) {
note("횡단을 지금 계획선에 맞춰 다시 계산하는 중입니다.");
return;
}
// B06과 같은 계산 — 횡단 기준(정식)과 종단 기준(개략 비교)을 함께 낸다.
const series: MassHaulSeries[] = computeMassHaulSeries(
params.longitudinal,
+4 -1
View File
@@ -12,7 +12,7 @@ import {
createLongitudinalProfile,
longitudinalMinimumWidth,
} from "../B06_Section/B06_Section_UI_Longitudinal";
import { LONG_PAD } from "../B06_Section/B06_Section_UI_Section_Common";
import { LONG_PAD, staleDesignChainages } from "../B06_Section/B06_Section_UI_Section_Common";
import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch";
import { normalizedLongitudinal, toDesignProfile } from "./B05_Profile_UI_Profile_Data";
import {
@@ -326,6 +326,9 @@ export function renderProfile(ctx: ProfileRenderContext): void {
// 측점별 횡단 설계(기본 프리뷰 포함)를 입력으로 쓴다 — B06과 같은 재료다.
longitudinal: { length_m: longitudinal.length_m, design_profiles: designProfiles },
crossSections: detail.cross_sections,
// 저장된 횡단이 지금 계획선과 어긋나면 그 면적은 옛 계획고로 만든 값이다 —
// Panel이 재계산을 예약해 두므로 유토곡선은 그 결과만 그린다(2026-09-03 사용자 확정).
pendingRecalc: staleDesignChainages(detail).length > 0,
axis: {
maxChainageM: maxChainageOf(longitudinal),
// 종단 그래프의 `chainageMapper`와 정확히 같은 매핑이 되도록 반 칸 들여쓰기를
+6
View File
@@ -435,6 +435,12 @@ export interface CrossDesign {
fill_area_m2: number;
/** 성토측 자연 지반 경사(rise/run). 자연방토 판정 입력. 성토측이 없으면 null. */
fill_ground_slope?: number | null;
/**
* 사면이 샘플 범위(±계산 반폭) 끝에서도 원지반과 만나지 않은 측점. 면적이 거기서
* 잘려 절·성토량이 실제와 다르다 — 계산은 그대로 두고 카드에 경고만 단다
* (2026-09-03 사용자 확정: 「영원히 못 만나는 지형이 있을 수 있으니 경고로 대체」).
*/
slope_unclosed?: boolean;
ditch_area_m2: number;
design_line: Array<{ offset_m: number; elevation_m: number }>;
/** 확정 시 병합되는 암 경계선 오프셋(m). 세션 값이 우선이며 복원 폴백으로 쓴다. */
+12
View File
@@ -31,6 +31,10 @@ from config.config_system import (
)
# 사면이 원지반과 만났다고 볼 높이차(m). 이보다 크면 샘플 끝에서 잘린 것으로 본다.
_SLOPE_CLOSE_TOLERANCE_M = 0.01
def _side_role(section_mode: str) -> tuple[str, str]:
"""단면유형 → (좌측 역할, 우측 역할). 역할은 'cut' 또는 'fill'."""
if section_mode == "left_cut":
@@ -533,6 +537,12 @@ def compute_cross_design(
# 측구 굴착은 설계선에 포함돼 절토 면적에 자연 반영된다(별도 가산 없음 — 이중계상 방지).
cut_area, fill_area = _trapezoid_areas(offsets, diffs)
fill_ground_slope = geometry.fill_ground_slope()
# 사면이 샘플 범위 끝에서도 원지반과 만나지 않으면 면적이 거기서 잘린다 — 그만큼
# 절·성토량이 실제와 다르고 유토곡선도 그 값을 그대로 쌓는다. 영원히 안 만나는
# 지형이 있을 수 있으므로 계산은 손대지 않고 **경고만** 낸다(2026-09-03 사용자 확정).
slope_unclosed = bool(diffs) and (
abs(diffs[0]) > _SLOPE_CLOSE_TOLERANCE_M or abs(diffs[-1]) > _SLOPE_CLOSE_TOLERANCE_M
)
# 절토면적 토사/암반 분리 — 지표면~암반 경계선이 토사, 그 아래가 암이다. 경계선 위치가
# 곧 유토곡선 EA/RR/BR 비율을 만들므로, 사용자가 경계선을 올리내리면 이 값이 함께 바뀐다.
@@ -629,6 +639,8 @@ def compute_cross_design(
"cut_rock_area_m2": round(cut_rock_area, 4),
"cut_rock_kind": cut_rock_kind,
"fill_area_m2": round(fill_area, 4),
# 사면이 샘플 범위 끝까지 원지반을 못 만나 면적이 잘린 측점 — 경고 표기용.
"slope_unclosed": slope_unclosed,
# 성토측 자연 지반 경사(rise/run) — 자연방토 판정 입력. 성토측이 없으면 None.
"fill_ground_slope": (
round(fill_ground_slope, 4) if fill_ground_slope is not None else None
+1
View File
@@ -29,6 +29,7 @@ PREVIEW_DESIGN_FIELDS = (
"fill_area_m2",
"fill_ground_slope",
"design_elevation_m",
"slope_unclosed",
)
+9
View File
@@ -309,6 +309,15 @@ export function createCrossSectionCard(
const meta = document.createElement("div");
meta.className = "b06-cross-card__meta";
// 사면이 계산 반폭 끝까지 원지반을 못 만난 측점 — 면적이 거기서 잘려 유토곡선까지
// 그 값을 쌓는다. 계산은 손대지 않고 사실만 알린다(2026-09-03 사용자 확정).
if (section.design?.slope_unclosed) {
const openSlope = document.createElement("span");
openSlope.className = "b06-cross-card__warning";
openSlope.textContent = `${L("B06_Cross_SlopeUnclosed")}`;
openSlope.title = L("B06_Cross_SlopeUnclosed_Tip");
meta.append(openSlope);
}
if (section.structure) {
const structure = document.createElement("span");
structure.className = "b06-cross-card__structure";
+17 -9
View File
@@ -84,6 +84,7 @@ import {
L,
longitudinalMaxChainage,
LONG_PAD,
staleDesignChainages,
type YScaleOptions,
} from "./B06_Section_UI_Section_Common";
@@ -516,14 +517,19 @@ export function createSectionView(
),
];
const series: MassHaulSeries[] = currentConversion
? computeMassHaulSeries(
detail.longitudinal,
detail.cross_sections,
currentConversion,
currentNaturalSpoilSlope,
)
: [];
// 저장된 횡단이 지금 계획선과 어긋나면 그 면적은 **옛 계획고로 만든 값**이다. 진입
// 직후 `reconcileStaleDesigns()`가 다시 계산해 갈아 끼우므로, 그 사이 옛 값으로 곡선을
// 그리면 사용자가 옛 그림을 본다(2026-09-03 사용자 확정: 「새 값만 보여주기」).
const pendingRecalc = staleDesignChainages(detail).length > 0;
const series: MassHaulSeries[] =
currentConversion && !pendingRecalc
? computeMassHaulSeries(
detail.longitudinal,
detail.cross_sections,
currentConversion,
currentNaturalSpoilSlope,
)
: [];
// 토량 분배는 **면을 깐 곡선 하나**(= 켜 둔 첫 곡선)에만 얹는다 — 곡선마다 평형선을
// 그리면 계단이 서로 엇갈려 어느 쪽 배분인지 읽히지 않는다.
const bandedSeries = series.find((entry) => visibleSeries.has(entry.key));
@@ -588,7 +594,9 @@ export function createSectionView(
panelCount.textContent = "";
panelBody.append(createMassHaulSummary(summarySeries, haulPlan));
} else {
panelCount.textContent = L(series.length ? "B06_MassHaul_AllHidden" : "B06_MassHaul_Empty");
panelCount.textContent = pendingRecalc
? L("B06_MassHaul_Recalculating")
: L(series.length ? "B06_MassHaul_AllHidden" : "B06_MassHaul_Empty");
}
chartWrap.scrollLeft = keepScrollLeft;
// 상단 패널이 sticky라 선택 카드가 그 아래로 숨는다 — 패널 높이만큼 스크롤 여백을 잡아 준다.
@@ -232,6 +232,12 @@
flex: 0 0 auto;
}
/* 사면 미교차 경고 — 면적이 계산 반폭에서 잘린 측점(2026-09-03). */
.b06-cross-card__warning {
color: var(--color-warning);
white-space: nowrap;
}
/* 지반유형 세그먼트(D-6): 제목행 1행 내 인라인 배치. 좌측 구분선(3번). */
.b06-design__seg--header {
flex: 0 0 auto;
+13
View File
@@ -341,6 +341,13 @@ export const ui_locales_b2 = {
"측점에 지반유형을 지정하면 유토곡선이 표시됩니다.",
"The mass haul diagram appears once ground types are assigned to stations.",
],
/* ( ) .
(2026-09-03 ). */
B06_Cross_SlopeUnclosed: ["사면 미교차", "Slope not closed"],
B06_Cross_SlopeUnclosed_Tip: [
"사면이 계산 반폭 끝까지 원지반과 만나지 않아 절·성토 면적이 그 자리에서 잘렸습니다. 유토곡선·수량도 잘린 값을 씁니다.",
"The slope never meets existing ground within the computed half-width, so the cut/fill area is truncated there. The mass haul curve and quantities use the truncated value.",
],
B06_MassHaul_CutNatural: ["절토(자연)", "Cut (natural)"],
B06_MassHaul_CutCompacted: ["절토(다짐환산)", "Cut (compacted)"],
B06_MassHaul_Fill: ["성토", "Fill"],
@@ -356,6 +363,12 @@ export const ui_locales_b2 = {
"노선 끝의 누가토량 — 0에 가까울수록 절·성토 균형. 곡선이 0선을 넘지 않으면 평형선·운반 블록이 생기지 않습니다.",
"Cumulative volume at the end of the route — the closer to zero, the better cut/fill balance. No balance lines or haul blocks appear while the curve never crosses zero.",
],
/*
(2026-09-03 : ). */
B06_MassHaul_Recalculating: [
"횡단을 지금 계획선에 맞춰 다시 계산하는 중입니다.",
"Recalculating cross sections against the current profile.",
],
B06_MassHaul_AllHidden: [
"표시할 곡선을 하나 이상 켜 주세요.",
"Turn on at least one curve to display.",