feat(B06): 횡단 카드 제목행에 성토사면 본래 경사길이 표기

사면 미교차 경고가 붙는 제목행 우측 끝에 성토사면 경사길이를 표기함.
값은 정본 설계선(`design_line`) 기준이라 기슭막이·집수정이 사면을 끊기 전
**본래 길이**이고, 양측 성토는 좌·우 두 값을 다 냄.

- `fillSlopeLengths()` 신설 — 기존 `protectedSpan`·`meetOffset` 재사용,
  성토측만 노견 끝(측구가 있으면 측구 바깥)에서 사면 끝까지 `run × √(1+1/n²)`.
- 미교차 측점은 `≥` 하한값 — 추세 외삽은 지반이 사면과 나란한 자리에서
  수십 m 씩 튀어(740m 측점 101.53m) 길이 표기에 못 씀.
- `meetOffset` 교차점을 두 걸음 사이 선형보간으로 냄 — 반폭 맞춤(1m 올림)에는
  영향이 없고 길이 표기가 탐색 간격만큼 튀는 것만 막음.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@
This commit is contained in:
2026-09-03 19:16:46 +09:00
parent 0f5b4e4df7
commit c21b68f2ce
4 changed files with 102 additions and 1 deletions
@@ -13,8 +13,36 @@ import {
buildRockBoundaryControl,
sectionModeLabel,
} from "./B06_Section_UI_Cross_Design";
import { type FillSlopeLength, fillSlopeLengths } from "./B06_Section_UI_Cross_Fit";
import { type DesignChangeHandler, L, stationLabel } from "./B06_Section_UI_Section_Common";
/**
* 성토사면 경사길이 표기 칸 — 성토측이 없으면 null.
*
* 값은 **구조물이 끊기 전 본래 사면**(정본 설계선 기준)이라, 기슭막이·집수정이 선 측점도
* 표준 횡단의 사면 길이를 그대로 보여 준다(2026-09-03 사용자 지시).
*/
function fillSlopeInfo(section: CrossSection): HTMLElement | null {
const lengths = fillSlopeLengths(section);
const sides = (["left", "right"] as const).filter((side) => lengths[side] !== null);
if (!sides.length) return null;
const both = sides.length > 1;
const info = document.createElement("span");
info.className = "b06-cross-card__fillslope";
info.textContent = `${L("B06_Cross_FillSlope")} ${sides
.map((side) => {
const length = lengths[side] as FillSlopeLength;
// 계산 반폭 안에서 원지반을 못 만난 사면은 거기까지만 잰 하한값이라 「≥」로 구분한다.
const value = `${length.open ? "≥" : ""}${length.lengthM.toFixed(2)}m`;
if (!both) return value;
const label = L(side === "left" ? "B06_Design_Ditch_Left" : "B06_Design_Ditch_Right");
return `${label} ${value}`;
})
.join(" · ")}`;
info.title = L("B06_Cross_FillSlope_Tip");
return info;
}
/**
* 카드 제목행을 만들어 카드에 붙인다(설계 조작이 있으면 조작 바까지).
*
@@ -67,6 +95,10 @@ export function appendCardHeader(
);
meta.append(kind);
}
// 성토사면 경사길이는 **행 우측 끝**(2026-09-03 사용자 지시) — meta 가 제목행의 오른쪽
// 끝이므로 그 마지막 칸에 붙인다.
const fillSlope = fillSlopeInfo(section);
if (fillSlope) meta.append(fillSlope);
// 단면유형 pill은 지반유형 우측·우측 맞춤으로 배치(1번). 좌측 구분선은 지반유형 세그먼트에 준다(3번).
modePill.classList.add("b06-cross-card__mode--right");
+58 -1
View File
@@ -58,6 +58,58 @@ export function toeFitHalfWidth(section: CrossSection): number | null {
return Math.min(Math.ceil(extent + 1), MAX_FIT_HALF_WIDTH_M);
}
/**
* 성토측 사면의 **본래** 경사길이(m) — 성토가 아닌 측은 null (2026-09-03 사용자 지시).
*
* 「본래」 = 구조물(기슭막이·집수정)이 사면을 끊기 전 표준 횡단 기준. 구조물은 프론트
* 오버레이라 백엔드 `design_line`을 바꾸지 않으므로, 그 선으로 재면 곧 본래 길이다.
* 구간은 노견 끝(그 측에 측구가 있으면 측구 바깥)부터 설계선이 원지반과 처음 만나는
* 점까지이고, 그 사이 성토선은 1:n 직선이라 수평 성분만으로 경사길이가 나온다.
*/
export interface FillSlopeLength {
/** 사면 경사길이(m). 미교차(`open`)면 계산 반폭까지의 **하한값**이다. */
lengthM: number;
/** 설계선 끝(계산 반폭)까지 원지반과 만나지 못한 사면 — 길이를 다 재지 못했다. */
open: boolean;
}
export function fillSlopeLengths(section: CrossSection): {
left: FillSlopeLength | null;
right: FillSlopeLength | null;
} {
const lengths: { left: FillSlopeLength | null; right: FillSlopeLength | null } = {
left: null,
right: null,
};
const design = section.design;
if (!design) return lengths;
const groundAt = groundInterpolator(section.samples);
const designAt = designInterpolator(design.design_line);
const edges = design.road_edges;
if (!groundAt || !designAt || !edges) return lengths;
const lineOffsets = design.design_line.map((point) => point.offset_m);
if (lineOffsets.length < 2) return lengths;
const { protectMax, protectMin } = protectedSpan(design, edges);
const slant = Math.hypot(1, 1 / Math.max(design.fill_slope_ratio, 1e-6));
for (const side of ["left", "right"] as const) {
const mode = design.section_mode;
if (mode === "both_cut" || mode === `${side}_cut`) continue;
const outward = side === "left" ? 1 : -1;
const start = side === "left" ? protectMax : protectMin;
const limit = side === "left" ? Math.max(...lineOffsets) : Math.min(...lineOffsets);
// 미교차 측점은 **재지 못한 것**이라 계산 반폭까지의 하한값만 준다 — `toeFitHalfWidth`가
// 쓰는 추세 외삽은 지반이 사면과 거의 나란한 자리에서 수십 m씩 튀어 길이 표기에는 못 쓴다
// (2026-09-03 실측: 740m 측점 좌측 외삽 101.53m, 실제 반폭 20m까지 고저차 7.16m 유지).
const meet = meetOffset(designAt, groundAt, start, limit, outward);
const end = Math.abs(meet) > Math.abs(limit) ? limit : meet;
lengths[side] = {
lengthM: Math.abs(end - start) * slant,
open: Math.abs(designAt(end) - groundAt(end)) > MEET_TOLERANCE_M,
};
}
return lengths;
}
/** 노면·노견(+측구) 구간의 바깥 경계 — 교차점 탐색은 여기서부터 바깥으로 간다. */
function protectedSpan(
design: CrossDesign,
@@ -98,7 +150,12 @@ function meetOffset(
const offset = startOffset + outward * Math.min(SCAN_STEP_M * index, span);
const diff = designAt(offset) - groundAt(offset);
if (Math.abs(diff) <= MEET_TOLERANCE_M) return offset;
if (previousDiff !== 0 && Math.sign(diff) !== Math.sign(previousDiff)) return offset;
if (previousDiff !== 0 && Math.sign(diff) !== Math.sign(previousDiff)) {
// 두 걸음 사이를 선형보간한다 — 반폭 맞춤에는 영향이 없지만(1m 올림), 같은 교차점을
// 길이로 읽는 성토사면 표기가 탐색 간격(0.1m)만큼 튀는 것을 막는다(2026-09-03).
const previous = offset - outward * SCAN_STEP_M;
return previous + (offset - previous) * (previousDiff / (previousDiff - diff));
}
previousDiff = diff;
}
return extrapolateMeet(designAt, groundAt, limitOffset, outward);
@@ -238,6 +238,12 @@
white-space: nowrap;
}
/* 성토사면 경사길이 — 제목행 우측 끝(2026-09-03). meta 의 마지막 칸이라 우측 맞춤이다. */
.b06-cross-card__fillslope {
color: var(--color-text-muted);
white-space: nowrap;
}
/* 지반유형 세그먼트(D-6): 제목행 1행 내 인라인 배치. 좌측 구분선(3번). */
.b06-design__seg--header {
flex: 0 0 auto;