feat(B06): 절토면적을 암반 경계선 기준 토사/암반으로 분리하고 유토곡선에 배선

절토부의 토사·암반 구분은 환산계수가 아니라 횡단도 기하로 갈린다. 지표면~암반 경계선이
토사, 그 아래가 암이다. 기존에는 측점당 절토 단면적이 하나뿐이라 리핑암/발파암 측점의
상단 토사층까지 암 환산계수로 곱해졌다(실데이터 기준 다짐환산 +21% 과대).

- Engine_Design: `_split_cut_areas()` 신설. `d=0`, `d=t0` 교차점을 브레이크포인트로 넣어
  정확 적분(무작위 2,000회 검산 `soil+rock==cut` 최대오차 1.4e-14).
  `cut_soil_area_m2` / `cut_rock_area_m2` / `cut_rock_kind` 3필드 추가(기존 키 불변).
- UI_Cross_Areas(신규): 면적 readout을 4칩으로 확장하고 절토(토사)/절토(암반)/성토 밴드를
  그린다. 선택된 카드에서만 붙어 다른 측점을 고르면 자동 해제된다.
- UI_MassHaul: `AreaSample`을 2분할로 교체해 부분별 환산계수를 물리고, 측점별 구간 물량
  내역(토사/암반/다짐환산/성토)을 남긴다. 구 데이터는 전량 측점 지반유형으로 폴백.
- UI_MassHaul_View: 선택 측점의 구간 물량 표기 줄과 곡선 위 강조점 추가. 측점 매칭은
  id가 아니라 누가거리로 한다(종단 기준 곡선의 점 id는 파생값이라 형태가 다름).

암반 경계선 오프셋을 바꾸면 분리 면적과 유토곡선 환산량이 함께 움직인다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-02 13:10:07 +09:00
co-authored by Claude Opus 5
parent bc02fcc1d4
commit 39d7c2daee
11 changed files with 624 additions and 52 deletions
@@ -237,6 +237,15 @@ export interface CrossDesign {
};
design_elevation_m: number;
cut_area_m2: number;
/**
* 절토 내역(합 = `cut_area_m2`). 지표면~암반 경계선이 토사, 그 아래가 암이다.
* 경계선을 올리내리면 두 값이 함께 바뀌고 유토곡선도 따라 움직인다.
* 구 데이터에는 없으므로 optional — 없으면 `cut_area_m2` 전량을 지반유형으로 본다.
*/
cut_soil_area_m2?: number;
cut_rock_area_m2?: number;
/** 암반부에 적용할 지반유형(`ripping_rock`/`blasting_rock`). 토사 측점은 null. */
cut_rock_kind?: GroundType | null;
fill_area_m2: number;
ditch_area_m2: number;
design_line: Array<{ offset_m: number; elevation_m: number }>;
@@ -136,6 +136,45 @@ def _trapezoid_areas(offsets: list[float], diffs: list[float]) -> tuple[float, f
return cut_area, fill_area
def _split_cut_areas(
offsets: list[float], diffs: list[float], soil_depth_m: float
) -> tuple[float, float]:
"""절토 면적을 암반 경계선 기준으로 (토사, 암반)으로 나눈다.
암반 경계선은 지반선 평행 복사(`지반고 + rock_boundary_offset_m`)이므로 토사층 두께
`t0`가 절토 구간 전체에서 균일하다. 따라서 오프셋별 절토 종거 `d = 지반고 - 설계고`에
대해 토사분은 `min(max(d, 0), t0)`, 암반분은 `max(d - t0, 0)`이며 두 값의 합은 항상
`max(d, 0)`이라 `_trapezoid_areas`의 절토 면적과 정확히 일치한다.
두 함수 모두 `d = 0`과 `d = t0`에서 꺾이므로 그 교차점을 구간 분할점으로 넣어야
사다리꼴 적분이 근사가 아닌 정확값이 된다.
"""
t0 = max(float(soil_depth_m), 0.0)
soil_area = 0.0
rock_area = 0.0
for index in range(1, len(offsets)):
x0, x1 = offsets[index - 1], offsets[index]
d0, d1 = diffs[index - 1], diffs[index]
width = x1 - x0
if width <= 0:
continue
ratios = [0.0, 1.0]
for level in (0.0, t0):
if (d0 - level) * (d1 - level) < 0:
ratios.append((level - d0) / (d1 - d0))
ratios.sort()
for step in range(1, len(ratios)):
ratio_a, ratio_b = ratios[step - 1], ratios[step]
span = width * (ratio_b - ratio_a)
if span <= 0:
continue
d_a = d0 + (d1 - d0) * ratio_a
d_b = d0 + (d1 - d0) * ratio_b
soil_area += (min(max(d_a, 0.0), t0) + min(max(d_b, 0.0), t0)) / 2.0 * span
rock_area += (max(d_a - t0, 0.0) + max(d_b - t0, 0.0)) / 2.0 * span
return soil_area, rock_area
def _ground_interpolator(valid: list[tuple[float, float]]):
"""정렬된 (offset, 지반고) 샘플의 선형 보간 함수를 만든다(범위 밖 끝값 클램프)."""
@@ -501,6 +540,22 @@ def compute_cross_design(
# 측구 굴착은 설계선에 포함돼 절토 면적에 자연 반영된다(별도 가산 없음 — 이중계상 방지).
cut_area, fill_area = _trapezoid_areas(offsets, diffs)
# 절토면적 토사/암반 분리 — 지표면~암반 경계선이 토사, 그 아래가 암이다. 경계선 위치가
# 곧 유토곡선 EA/RR/BR 비율을 만들므로, 사용자가 경계선을 올리내리면 이 값이 함께 바뀐다.
# 토사 지반은 암반 경계선 자체가 없어 전량 토사, 암 지반인데 경계선 값이 없으면(구 데이터)
# 분리 근거가 없으므로 전량 암으로 둔다(기존 단일 지반유형 환산과 같은 결과).
if preset_key != "rock":
cut_soil_area, cut_rock_area = cut_area, 0.0
cut_rock_kind: str | None = None
elif rock_boundary_offset_m is None:
cut_soil_area, cut_rock_area = 0.0, cut_area
cut_rock_kind = ground_type
else:
cut_soil_area, cut_rock_area = _split_cut_areas(
offsets, diffs, abs(float(rock_boundary_offset_m))
)
cut_rock_kind = ground_type
# 측구 공칭 단면적(수량 산출 참고용): 일반=사다리꼴, L형=직각삼각형 근사.
if not geometry.has_ditch:
ditch_area = 0.0
@@ -575,6 +630,10 @@ def compute_cross_design(
},
"design_elevation_m": round(float(design_elevation_m), 4),
"cut_area_m2": round(cut_area, 4),
# 절토 내역(합=cut_area_m2). 유토곡선이 지반유형별 환산계수를 물리는 단위다.
"cut_soil_area_m2": round(cut_soil_area, 4),
"cut_rock_area_m2": round(cut_rock_area, 4),
"cut_rock_kind": cut_rock_kind,
"fill_area_m2": round(fill_area, 4),
"ditch_area_m2": round(ditch_area, 4),
"design_line": design_line,
@@ -0,0 +1,250 @@
/* =============================================================================
* B06_wf3_ProfileCross_UI_Cross_Areas.ts
* 횡단 절·성토 면적 readout과 면적 영역 하이라이트 밴드.
*
* 절토는 암반 경계선(지면선 평행 복사)을 기준으로 위=토사, 아래=암반으로 갈린다. 경계선
* 위치가 곧 유토곡선 EA/RR/BR 비율을 만들므로, 사용자가 경계선을 올리내리면 여기 표시되는
* 면적과 유토곡선이 함께 움직인다. 면적값 산출의 정본은 백엔드
* (`B06_wf3_ProfileCross_Engine_Design._split_cut_areas`)이고, 이 모듈은 **같은 규칙으로
* 영역만 다시 그린다**(수치를 여기서 다시 계산해 표시하지 않는다 — 이중 정의 금지).
*
* 하이라이트 규칙 (2026-08-02 사용자 지시):
* - 선택된 횡단도에서만 동작한다. 다른 측점을 고르면 카드가 다시 그려지며 자동 해제된다.
* - 값 칩 또는 면적 영역을 누르면 그 영역만 강조하고, 다시 누르면 해제한다.
*
* 700줄 제한 대응으로 `_UI_Cross_Design`(588줄)에서 면적 readout을 이 파일로 옮겨 왔다.
* ========================================================================== */
import type { CrossDesign, SectionSample } from "./B06_wf3_ProfileCross_Api_Fetch";
import { L, svgElement } from "./B06_wf3_ProfileCross_UI_Section_Common";
/** 강조 대상. `cut_total`은 토사·암반 두 밴드를 함께 켠다. */
export type CrossAreaKey = "cut_soil" | "cut_rock" | "cut_total" | "fill";
export type AreaHighlightSetter = (key: CrossAreaKey | null) => void;
/** 두께가 0 이하인 구간을 잘라내는 판정 여유(㎡ 아닌 m 단위 종거). */
const EPSILON = 1e-9;
function interpolator(points: Array<{ offset: number; value: number }>) {
return (offset: number): number => {
if (offset <= points[0].offset) return points[0].value;
const last = points[points.length - 1];
if (offset >= last.offset) return last.value;
for (let index = 1; index < points.length; index += 1) {
if (offset > points[index].offset) continue;
const a = points[index - 1];
const b = points[index];
const span = b.offset - a.offset;
if (span <= 0) return b.value;
return a.value + (b.value - a.value) * ((offset - a.offset) / span);
}
return last.value;
};
}
/**
* `top > bottom`인 구간만 잘라 폴리곤 점 문자열을 만든다.
* 두께가 0이 되는 지점을 끼워 넣어 밴드 끝이 지면선·설계선 교점에서 정확히 닫히게 한다.
*/
function bandPolygons(
xs: number[],
top: number[],
bottom: number[],
px: (offset: number) => number,
py: (elevation: number) => number,
): string[] {
const polygons: string[] = [];
let upper: string[] = [];
let lower: string[] = [];
const flush = (): void => {
if (upper.length >= 2) polygons.push([...upper, ...lower.reverse()].join(" "));
upper = [];
lower = [];
};
const add = (offset: number, high: number, low: number): void => {
upper.push(`${px(offset)},${py(high)}`);
lower.push(`${px(offset)},${py(low)}`);
};
const crossing = (index: number): void => {
const gapA = top[index - 1] - bottom[index - 1];
const gapB = top[index] - bottom[index];
if (gapA === gapB) return;
const ratio = gapA / (gapA - gapB);
const offset = xs[index - 1] + (xs[index] - xs[index - 1]) * ratio;
const level = top[index - 1] + (top[index] - top[index - 1]) * ratio;
add(offset, level, level);
};
for (let index = 0; index < xs.length; index += 1) {
const gap = top[index] - bottom[index];
const previousGap = index > 0 ? top[index - 1] - bottom[index - 1] : 0;
if (gap > EPSILON) {
if (index > 0 && previousGap <= EPSILON) crossing(index);
add(xs[index], top[index], bottom[index]);
} else if (index > 0 && previousGap > EPSILON) {
crossing(index);
flush();
}
}
flush();
return polygons;
}
/** 토사층 두께(m). 토사 측점은 암반 경계선이 없어 null, 경계선 값이 없는 암 측점은 0(전량 암). */
function soilDepth(design: CrossDesign): number | null {
if (!design.cut_rock_kind) return null;
return Math.abs(design.rock_boundary_offset_m ?? 0);
}
/**
* 절토(토사)·절토(암반)·성토 영역을 SVG에 깔고, 강조 토글 함수를 돌려준다.
* 밴드는 평소 투명에 가깝게 두되 클릭 대상은 되도록 남겨 "면적을 눌러도 강조"가 성립한다.
*/
export function appendCrossAreaBands(
svg: SVGSVGElement,
design: CrossDesign,
groundSamples: SectionSample[],
x: (offset: number) => number,
toDisplayY: (elevation: number) => number,
onSelect: (key: CrossAreaKey) => void,
): AreaHighlightSetter {
const ground = groundSamples
.filter((sample) => sample.valid !== false && Number.isFinite(sample.elevation_m ?? NaN))
.map((sample) => ({ offset: sample.offset_m ?? 0, value: sample.elevation_m as number }))
.sort((a, b) => a.offset - b.offset);
const line = (design.design_line ?? [])
.filter((point) => Number.isFinite(point.offset_m) && Number.isFinite(point.elevation_m))
.map((point) => ({ offset: point.offset_m, value: point.elevation_m }))
.sort((a, b) => a.offset - b.offset);
if (ground.length < 2 || line.length < 2) return () => undefined;
const groundAt = interpolator(ground);
const designAt = interpolator(line);
// 설계선이 덮는 범위 밖에는 절·성토가 정의되지 않는다 — 두 범위의 교집합만 그린다.
const minOffset = Math.max(ground[0].offset, line[0].offset);
const maxOffset = Math.min(ground[ground.length - 1].offset, line[line.length - 1].offset);
if (!(maxOffset > minOffset)) return () => undefined;
const xs = [
...new Set(
[
...ground.map((point) => point.offset),
...line.map((point) => point.offset),
minOffset,
maxOffset,
]
.filter((offset) => offset >= minOffset && offset <= maxOffset)
.map((offset) => Math.round(offset * 1e6) / 1e6),
),
].sort((a, b) => a - b);
const groundLevels = xs.map(groundAt);
const designLevels = xs.map(designAt);
const depth = soilDepth(design);
const rockLevels = depth === null ? null : groundLevels.map((level) => level - depth);
const bands: Array<{ key: CrossAreaKey; top: number[]; bottom: number[] }> = [
{ key: "fill", top: designLevels, bottom: groundLevels },
];
if (rockLevels) {
bands.push({
key: "cut_soil",
top: groundLevels,
bottom: designLevels.map((level, index) => Math.max(level, rockLevels[index])),
});
bands.push({ key: "cut_rock", top: rockLevels, bottom: designLevels });
} else {
bands.push({ key: "cut_soil", top: groundLevels, bottom: designLevels });
}
const groups = new Map<CrossAreaKey, SVGGElement>();
for (const band of bands) {
const polygons = bandPolygons(xs, band.top, band.bottom, x, toDisplayY);
if (!polygons.length) continue;
const group = svgElement("g", { class: `b06-chart__area b06-chart__area--${band.key}` });
for (const points of polygons) group.append(svgElement("polygon", { points }));
group.addEventListener("click", () => onSelect(band.key));
groups.set(band.key, group);
svg.append(group);
}
return (key) => {
for (const [bandKey, group] of groups) {
const active =
key === bandKey ||
(key === "cut_total" && (bandKey === "cut_soil" || bandKey === "cut_rock"));
group.classList.toggle("is-active", active);
}
};
}
/**
* 절·성토 면적값 오버레이(E-4) — 그래프 중상단에 표시한다.
* `onSelect`가 오면 칩이 강조 토글 버튼이 된다(선택된 카드에서만 넘어온다).
*/
export function buildAreaReadout(
design: CrossDesign | undefined,
onSelect?: (key: CrossAreaKey) => void,
): { root: HTMLElement; setActive: AreaHighlightSetter } {
const readout = document.createElement("div");
readout.className = "b06-cross-card__areas";
if (!design) {
const unset = document.createElement("span");
unset.className = "b06-design__area b06-design__area--unset";
unset.textContent = L("B06_Design_Unset");
readout.append(unset);
return { root: readout, setActive: () => undefined };
}
// 토사/암반 칩은 암 측점에서만 의미가 있다(토사 측점은 절토계와 같은 값이라 중복 표기).
const entries: Array<{ key: CrossAreaKey; label: string; value: number }> = [];
if (design.cut_rock_kind) {
entries.push(
{
key: "cut_soil",
label: L("B06_Design_Cut_Soil_Area"),
value: design.cut_soil_area_m2 ?? 0,
},
{
key: "cut_rock",
label: L("B06_Design_Cut_Rock_Area"),
value: design.cut_rock_area_m2 ?? 0,
},
);
}
entries.push(
{ key: "cut_total", label: L("B06_Design_Cut_Area"), value: design.cut_area_m2 },
{ key: "fill", label: L("B06_Design_Fill_Area"), value: design.fill_area_m2 },
);
const chips = new Map<CrossAreaKey, HTMLElement>();
for (const entry of entries) {
const text = `${entry.label} ${entry.value.toFixed(2)}`;
if (!onSelect) {
const chip = document.createElement("span");
chip.className = `b06-design__area b06-design__area--${entry.key}`;
chip.textContent = text;
readout.append(chip);
continue;
}
const chip = document.createElement("button");
chip.type = "button";
chip.className = `b06-design__area b06-design__area--${entry.key}`;
chip.textContent = text;
chip.title = L("B06_Design_Area_Highlight");
chip.setAttribute("aria-pressed", "false");
chip.addEventListener("click", () => onSelect(entry.key));
chips.set(entry.key, chip);
readout.append(chip);
}
return {
root: readout,
setActive: (key) => {
for (const [chipKey, chip] of chips) {
const active = chipKey === key;
chip.classList.toggle("is-active", active);
chip.setAttribute("aria-pressed", String(active));
}
},
};
}
@@ -432,27 +432,6 @@ export function buildDesignControls(
return { bar, groundSegment };
}
/** 절·성토 면적값 오버레이(E-4) — 그래프 중상단에 배경색과 함께 표시한다. */
export function buildAreaReadout(design: CrossDesign | undefined): HTMLElement {
const readout = document.createElement("div");
readout.className = "b06-cross-card__areas";
if (design) {
const cut = document.createElement("span");
cut.className = "b06-design__area b06-design__area--cut";
cut.textContent = `${L("B06_Design_Cut_Area")} ${design.cut_area_m2.toFixed(2)}`;
const fill = document.createElement("span");
fill.className = "b06-design__area b06-design__area--fill";
fill.textContent = `${L("B06_Design_Fill_Area")} ${design.fill_area_m2.toFixed(2)}`;
readout.append(cut, fill);
} else {
const unset = document.createElement("span");
unset.className = "b06-design__area b06-design__area--unset";
unset.textContent = L("B06_Design_Unset");
readout.append(unset);
}
return readout;
}
/**
* 횡단 SVG에 표준단면 설계선을 겹쳐 그린다. 지면선과 겹치는 구간(사면이 지반을 추종하는
* 부분)만 점선으로 그려 뒤에 깔린 지표선이 비쳐 보이게 하고, 나머지는 실선으로 둔다.
@@ -8,11 +8,16 @@
* ========================================================================== */
import type { CrossSection, SectionSample } from "./B06_wf3_ProfileCross_Api_Fetch";
import {
appendCrossAreaBands,
type AreaHighlightSetter,
buildAreaReadout,
type CrossAreaKey,
} from "./B06_wf3_ProfileCross_UI_Cross_Areas";
import {
appendCrossDesignOverlay,
appendPavementOverlay,
appendRockBoundaryOverlay,
buildAreaReadout,
buildDesignControls,
buildRockBoundaryControl,
sectionModeLabel,
@@ -369,9 +374,30 @@ export function createCrossSectionCard(
svg.append(svgElement("polyline", { points, class: "b06-chart__cross-profile" })),
);
// 면적 강조는 선택된 카드에서만 붙인다 — 다른 측점을 고르면 카드가 다시 그려지며 해제된다.
let activeArea: CrossAreaKey | null = null;
let setBandActive: AreaHighlightSetter = () => undefined;
let setChipActive: AreaHighlightSetter = () => undefined;
const toggleArea = (key: CrossAreaKey): void => {
activeArea = activeArea === key ? null : key;
setBandActive(activeArea);
setChipActive(activeArea);
};
if (section.design) {
const toDisplayY = (elevation: number): number =>
y(elevationMid + (elevation - elevationMid) * exaggeration);
// 면적 밴드는 지면선 위·설계선 아래에 깔아 선이 밴드에 가리지 않게 한다.
if (selected) {
setBandActive = appendCrossAreaBands(
svg,
section.design,
sourceSamples,
x,
toDisplayY,
toggleArea,
);
}
// 포장층 → 설계선 → 암 경계선 순으로 겹쳐, 설계선이 포장 박스 위에 오게 한다.
appendPavementOverlay(svg, section.design, x, toDisplayY);
appendCrossDesignOverlay(svg, section.design, x, toDisplayY, sourceSamples);
@@ -449,8 +475,13 @@ export function createCrossSectionCard(
const chartWrap = document.createElement("div");
chartWrap.className = "b06-cross-card__chart-wrap";
attachZoomPan(svg, widthPx, heightPx);
// 절·성토 면적값은 그래프 중상단 오버레이로 표시(E-4).
chartWrap.append(svg, buildAreaReadout(section.design));
// 절·성토 면적값은 그래프 중상단 오버레이로 표시(E-4). 선택된 카드의 칩은 강조 토글이 된다.
const readout = buildAreaReadout(
section.design,
selected && section.design ? toggleArea : undefined,
);
setChipActive = readout.setActive;
chartWrap.append(svg, readout.root);
// 암 경계선 제어는 그래프 X축 제목 행 우측에 배치(E-7). 암 지반에서만.
if (rockBoundary && section.design?.geometry_preset === "rock") {
const rockControl = buildRockBoundaryControl(section, rockBoundary);
@@ -19,17 +19,21 @@
* 편절·편성이 지배적이므로 두 기준이 크게 어긋날 수 있다. 어느 쪽을 정식 기준으로 삼을지
* 사용자가 그래프를 보고 판단할 수 있게, 두 기준을 같은 축에 겹쳐 그린다.
*
* ── 지반유형 처리를 3가지로 내는 이유 ────────────────────────────────
* ── 절토를 토사/암반으로 나눠 환산하는 이유 ──────────────────────────
* 같은 기준 2.다.(4)(다)는 "절토부분은 토사·암반으로 구분하되, 암반부분은 추정선으로
* 기입한다"고 정한다. 그러나 현재 `compute_cross_design()`은 측점당 절토 단면적을
* **하나만** 내므로 암반 경계선 위아래를 나누지 못하고, 측점 전체가 단일 지반유형으
* 환산된다(미결사항 1번). 분리 로직이 서기 전까지 환산 방식이 결과를 얼마나 흔드는지
* 눈으로 확인할 수 있도록 세 가지를 모두 계산해 둔다.
* 기입한다"고 정한다. 절토면적은 계수로 나누는 것이 아니라 **횡단도 기하로 갈린다** —
* 지표면~암반 경계선이 토사, 그 아래가 암이다. 엔진(`compute_cross_design()`)이 그 경계
* 나눈 `cut_soil_area_m2` / `cut_rock_area_m2`를 각각 자기 환산계수로 곱해 합산한다.
* 참고 도면(3공구)의 작업별 EA:RR 비율이 34:66~71:29로 흩어지는 것이 이 구조의 근거다.
*
* 지반유형 처리 3종(`ground_type`/`soil_only`/`natural`)은 환산 방식이 결과를 얼마나
* 흔드는지 눈으로 대조하기 위한 비교군으로 남겨 둔다.
*
* 분석 근거: docs/raw/2026-08-02_유토곡선_3공구_분석.md
* ========================================================================== */
import type {
CrossDesign,
CrossSection,
EarthworkConversion,
GroundType,
@@ -43,7 +47,7 @@ export interface GroundVolumes {
blasting_rock: number;
}
/** 측점 하나의 유토곡선 좌표. */
/** 측점 하나의 유토곡선 좌표. 구간 물량은 **직전 측점부터 이 측점까지**의 몫이다(첫 측점은 0). */
export interface MassHaulPoint {
station_id: string;
chainage_m: number;
@@ -51,6 +55,13 @@ export interface MassHaulPoint {
net_volume_m3: number;
/** 시점부터의 누가토량(㎥). */
cumulative_volume_m3: number;
/** 구간 절토량(자연상태) — 토사분/암반분. 측점 선택 시 물량 표기에 그대로 쓴다. */
cut_soil_m3: number;
cut_rock_m3: number;
/** 구간 절토량(환산 후) — 곡선에 실제로 들어간 값. */
cut_compacted_m3: number;
/** 구간 성토량(다짐상태 = 설계 물량). */
fill_m3: number;
}
export interface MassHaulResult {
@@ -138,13 +149,41 @@ function normalizeGround(value: GroundType | undefined): GroundType {
return value && GROUND_TYPES.includes(value) ? value : "soil";
}
/** 적분 입력 한 점. 기준이 달라도 여기까지 오면 같은 코드를 탄다. */
/**
* 적분 입력 한 점. 기준이 달라도 여기까지 오면 같은 코드를 탄다.
* 절토는 암반 경계선으로 이미 갈린 상태로 들어온다(토사분 / 암반분).
*/
interface AreaSample {
station_id: string;
chainage_m: number;
cut_area_m2: number;
cut_soil_area_m2: number;
cut_rock_area_m2: number;
/** 암반분에 물릴 지반유형. 토사 측점은 null(암반분 0). */
rock_kind: GroundType | null;
fill_area_m2: number;
ground: GroundType;
}
/**
* 엔진이 낸 절토 분리값을 읽는다. 구 데이터(분리 필드 없음)는 절토 전량을 측점
* 지반유형으로 돌려 **기존 단일 지반유형 환산과 같은 결과**를 유지한다.
*/
function splitCut(design: CrossDesign | undefined): {
soil: number;
rock: number;
rock_kind: GroundType | null;
} {
const ground = normalizeGround(design?.ground_type);
const total = finiteArea(design?.cut_area_m2);
if (design?.cut_soil_area_m2 === undefined || design?.cut_rock_area_m2 === undefined) {
return ground === "soil"
? { soil: total, rock: 0, rock_kind: null }
: { soil: 0, rock: total, rock_kind: ground };
}
return {
soil: finiteArea(design.cut_soil_area_m2),
rock: finiteArea(design.cut_rock_area_m2),
rock_kind: design.cut_rock_kind ?? (ground === "soil" ? null : ground),
};
}
/**
@@ -167,6 +206,10 @@ function integrate(
chainage_m: samples[0].chainage_m,
net_volume_m3: 0,
cumulative_volume_m3: 0,
cut_soil_m3: 0,
cut_rock_m3: 0,
cut_compacted_m3: 0,
fill_m3: 0,
},
];
const cutNatural = emptyVolumes();
@@ -184,13 +227,23 @@ function integrate(
if (!(spanM > 0)) continue;
const halfSpan = spanM / 2;
// 절토: 양 끝 점이 각자 자기 지반유형으로 절반씩 가져간다.
// 절토: 양 끝 점이 각자 절반씩 가져가고, 각 점 안에서 토사분·암반분이 따로 환산된다.
let segmentCut = 0;
let segmentCutSoil = 0;
let segmentCutRock = 0;
for (const end of [previous, current]) {
const naturalM3 = end.cut_area_m2 * halfSpan;
if (naturalM3 <= 0) continue;
cutNatural[end.ground] += naturalM3;
segmentCut += naturalM3 * factorFor(conversion, end.ground, groundMode);
const soilM3 = end.cut_soil_area_m2 * halfSpan;
const rockM3 = end.cut_rock_area_m2 * halfSpan;
if (soilM3 > 0) {
cutNatural.soil += soilM3;
segmentCutSoil += soilM3;
segmentCut += soilM3 * factorFor(conversion, "soil", groundMode);
}
if (rockM3 > 0 && end.rock_kind) {
cutNatural[end.rock_kind] += rockM3;
segmentCutRock += rockM3;
segmentCut += rockM3 * factorFor(conversion, end.rock_kind, groundMode);
}
}
// 성토: 설계 성토량이 곧 다짐상태 물량이라 환산하지 않는다.
@@ -207,6 +260,10 @@ function integrate(
chainage_m: current.chainage_m,
net_volume_m3: net,
cumulative_volume_m3: cumulative,
cut_soil_m3: segmentCutSoil,
cut_rock_m3: segmentCutRock,
cut_compacted_m3: segmentCut,
fill_m3: segmentFill,
});
}
@@ -235,13 +292,17 @@ function designedSections(crossSections: CrossSection[]): CrossSection[] {
/** 횡단 기준 — 엔진이 낸 실측 단면적을 그대로 쓴다. */
function crossAreaSamples(sections: CrossSection[]): AreaSample[] {
return sections.map((section) => ({
station_id: section.station_id,
chainage_m: section.chainage_m,
cut_area_m2: finiteArea(section.design?.cut_area_m2),
fill_area_m2: finiteArea(section.design?.fill_area_m2),
ground: normalizeGround(section.design?.ground_type),
}));
return sections.map((section) => {
const cut = splitCut(section.design);
return {
station_id: section.station_id,
chainage_m: section.chainage_m,
cut_soil_area_m2: cut.soil,
cut_rock_area_m2: cut.rock,
rock_kind: cut.rock_kind,
fill_area_m2: finiteArea(section.design?.fill_area_m2),
};
});
}
/**
@@ -251,6 +312,9 @@ function crossAreaSamples(sections: CrossSection[]): AreaSample[] {
* (`B05_wf2_Route_UI_Profile_Alignment.ts`가 이미 쓰는 부호 규약과 같다).
* 노반폭과 지반유형은 종단 샘플에 없으므로 **가장 가까운 설계 측점**에서 가져온다.
* 설계된 측점이 하나도 없으면 노반폭을 알 수 없어 곡선을 만들지 않는다.
*
* 개략 단면적에는 암반 경계선이 없으므로, **가장 가까운 측점의 토사:암반 면적비**를 그대로
* 물려 두 기준이 같은 환산을 타게 한다(비율을 못 구하면 전량 토사로 둔다).
*/
function longitudinalAreaSamples(
longitudinal: LongitudinalSection,
@@ -276,12 +340,17 @@ function longitudinalAreaSamples(
const nearest = sections[cursor];
const width = finiteArea(nearest.design?.roadbed_width_m);
const height = sample.difference_m;
const cutArea = height < 0 ? -height * width : 0;
const cut = splitCut(nearest.design);
const total = cut.soil + cut.rock;
const rockRatio = total > 0 ? cut.rock / total : 0;
return {
station_id: `${nearest.station_id}@${index}`,
chainage_m: sample.chainage_m,
cut_area_m2: height < 0 ? -height * width : 0,
cut_soil_area_m2: cutArea * (1 - rockRatio),
cut_rock_area_m2: cutArea * rockRatio,
rock_kind: cut.rock_kind,
fill_area_m2: height > 0 ? height * width : 0,
ground: normalizeGround(nearest.design?.ground_type),
};
});
}
@@ -341,6 +410,10 @@ export function massHaulPayload(result: MassHaulResult): Record<string, unknown>
chainage_m: round(point.chainage_m),
net_volume_m3: round(point.net_volume_m3),
cumulative_volume_m3: round(point.cumulative_volume_m3),
// 구간 절토 내역(자연상태) — B08 내역서가 EA/RR/BR로 되받는 단위.
cut_soil_m3: round(point.cut_soil_m3),
cut_rock_m3: round(point.cut_rock_m3),
fill_m3: round(point.fill_m3),
})),
};
}
@@ -19,6 +19,7 @@ import type { LongitudinalSection } from "./B06_wf3_ProfileCross_Api_Fetch";
import type {
MassHaulBasis,
MassHaulGroundMode,
MassHaulPoint,
MassHaulSeries,
} from "./B06_wf3_ProfileCross_UI_MassHaul";
import type { LocaleKey } from "@ui/ui_template_locale";
@@ -81,6 +82,32 @@ function volumeRange(series: MassHaulSeries[]): { min: number; max: number } {
return { min: rawMin - padding, max: rawMax + padding };
}
/**
* 선택된 측점의 곡선 점을 찾는다.
*
* 측점 id로 직접 맞추지 않고 **누가거리로 맞춘다** — 종단 기준 곡선의 점 id는 종단 샘플
* 인덱스가 붙은 파생값(`${station_id}@${index}`)이라 측점 id와 형태가 다르기 때문이다.
*/
export function massHaulPointAt(
series: MassHaulSeries,
longitudinal: LongitudinalSection,
stationId: string | null,
): MassHaulPoint | null {
if (!stationId) return null;
const station = longitudinal.stations.find((entry) => entry.station_id === stationId);
if (!station) return null;
let best: MassHaulPoint | null = null;
let bestGap = Number.POSITIVE_INFINITY;
for (const point of series.result.points) {
const gap = Math.abs(point.chainage_m - station.chainage_m);
if (gap < bestGap) {
bestGap = gap;
best = point;
}
}
return bestGap <= 0.5 ? best : null;
}
export function createMassHaulChart(
series: MassHaulSeries[],
visibleKeys: ReadonlySet<string>,
@@ -213,6 +240,19 @@ export function createMassHaulChart(
);
}
// 선택 측점 강조 — 첫 표시 곡선 위에 점을 찍어 아래 물량 표기 줄과 시선을 잇는다.
const focus = banded ? massHaulPointAt(banded, longitudinal, selectedStationId) : null;
if (focus) {
svg.append(
svgElement("circle", {
cx: x(focus.chainage_m),
cy: y(focus.cumulative_volume_m3),
r: 4,
class: "b06-masshaul__focus",
}),
);
}
svg.append(
svgElement("line", {
x1: LONG_PAD.left,
@@ -274,6 +314,43 @@ export function createMassHaulLegend(
return legend;
}
/**
* 선택 측점의 **구간 물량** 표기 줄 (2026-08-02 사용자 지시).
* 횡단도를 고르든 종단도 측점을 고르든 같은 선택 경로를 타므로 여기 한 곳만 그리면 된다.
* 물량은 "직전 측점 → 이 측점" 구간 몫이며, 횡단도 카드는 면적(㎡)만 표시한다(역할 분리).
*/
export function createMassHaulStationInfo(
series: MassHaulSeries,
longitudinal: LongitudinalSection,
selectedStationId: string | null,
stationInterval: number,
): HTMLElement | null {
const point = massHaulPointAt(series, longitudinal, selectedStationId);
if (!point) return null;
const row = document.createElement("div");
row.className = "b06-masshaul__station";
const chips: Array<[string, string]> = [
[L("B06_MassHaul_Station_Segment"), stationLabel(point.chainage_m, stationInterval)],
[L("B06_Design_Cut_Soil_Area"), `${formatVolume(point.cut_soil_m3)}`],
[L("B06_Design_Cut_Rock_Area"), `${formatVolume(point.cut_rock_m3)}`],
[L("B06_MassHaul_CutCompacted"), `${formatVolume(point.cut_compacted_m3)}`],
[L("B06_MassHaul_Fill"), `${formatVolume(point.fill_m3)}`],
[L("B06_MassHaul_Station_Net"), `${formatVolume(point.net_volume_m3)}`],
[L("B06_MassHaul_Station_Cumulative"), `${formatVolume(point.cumulative_volume_m3)}`],
];
for (const [label, value] of chips) {
const chip = document.createElement("span");
chip.className = "b06-masshaul__chip";
const name = document.createElement("em");
name.textContent = label;
const amount = document.createElement("strong");
amount.textContent = value;
chip.append(name, amount);
row.append(chip);
}
return row;
}
/**
* 곡선 아래 총괄 요약 줄. 절토는 자연상태(내역 기준)와 환산 후(곡선 기준)를 함께 적는다.
* 곡선이 여러 개라 수치가 어느 곡선의 것인지 끝에 반드시 밝힌다.
@@ -37,6 +37,7 @@ import {
import {
createMassHaulChart,
createMassHaulLegend,
createMassHaulStationInfo,
createMassHaulSummary,
MASS_HAUL_HEIGHT,
MASS_HAUL_MIN_HEIGHT,
@@ -58,8 +59,11 @@ import {
export type { CrossDesignChange, DesignChangeHandler, RockBoundaryControl };
/** 헤더·요약줄·테두리가 먹는 세로 공간. 패널 높이에서 이만큼 빼야 그래프 몫이 된다. */
const PANEL_CHROME_PX = 96;
/**
* 헤더·요약줄·선택측점 물량줄·테두리가 먹는 세로 공간. 패널 높이에서 이만큼 빼야 그래프 몫이 된다.
* (`.b06-section__panel-body`가 `overflow: hidden`이라 이 값이 모자라면 아래 줄이 잘린다.)
*/
const PANEL_CHROME_PX = 128;
/** 손대지 않았을 때의 패널 높이 — 이 값이 축소 비례의 기준(H₀)이 된다. */
const BASE_PANEL_HEIGHT = LONG_HEIGHT + MASS_HAUL_HEIGHT + PANEL_CHROME_PX;
const MIN_LONG_HEIGHT = 110;
@@ -309,12 +313,20 @@ export function createSectionView(
chartWrap.replaceChildren(...nodes);
panelTitle.textContent = `${L("B06_Profile_View_Longitudinal")} · ${L("B06_MassHaul_Title")}`;
const summary = panel.querySelector(".b06-masshaul__summary");
summary?.remove();
panel.querySelector(".b06-masshaul__summary")?.remove();
panel.querySelector(".b06-masshaul__station")?.remove();
// 요약 수치는 켜 둔 곡선 중 첫 번째 것 — 곡선이 여러 개라 어느 것인지 요약 끝에 밝힌다.
const summarySeries = series.find((entry) => visibleSeries.has(entry.key));
if (summarySeries) {
panelCount.textContent = "";
// 선택 측점의 구간 물량은 요약 위에 붙인다(횡단도는 면적만, 물량은 여기서 본다).
const stationInfo = createMassHaulStationInfo(
summarySeries,
detail.longitudinal,
selectedStationId,
cachedStationInterval,
);
if (stationInfo) panelBody.append(stationInfo);
panelBody.append(createMassHaulSummary(summarySeries));
} else {
panelCount.textContent = L(series.length ? "B06_MassHaul_AllHidden" : "B06_MassHaul_Empty");
@@ -515,10 +515,20 @@
pointer-events: none;
}
.b06-design__area--cut {
.b06-design__area--cut,
.b06-design__area--cut_total {
color: rgb(220 38 38);
}
/* 절토 내역 — 토사(지표면~암반 경계선)와 암반(경계선 아래)을 색으로 갈라 둔다. */
.b06-design__area--cut_soil {
color: rgb(217 119 6);
}
.b06-design__area--cut_rock {
color: rgb(71 85 105);
}
.b06-design__area--fill {
color: rgb(37 99 235);
}
@@ -527,6 +537,50 @@
color: var(--color-text-muted);
}
/* 선택된 카드에서만 칩이 버튼이 된다 — 부모가 pointer-events: none이라 여기서 되살린다. */
button.b06-design__area {
padding: 0 2px;
border: none;
border-radius: var(--radius-inputs);
background: none;
font-family: inherit;
font-size: inherit;
cursor: pointer;
pointer-events: auto;
}
/* 켠 칩은 글자색을 유지한 채 같은 색으로 옅게 칠하고 테두리를 둘러 대비를 살린다. */
button.b06-design__area.is-active {
background: color-mix(in srgb, currentcolor 18%, transparent);
box-shadow: inset 0 0 0 1px currentcolor;
font-weight: var(--font-weight-medium);
}
/* 면적 강조 밴드 — 평소엔 클릭 대상으로만 남고, 켜질 때만 채워진다. */
.b06-chart__area > polygon {
fill: transparent;
stroke: none;
cursor: pointer;
}
.b06-chart__area--cut_soil.is-active > polygon {
fill: color-mix(in srgb, rgb(217 119 6) 28%, transparent);
stroke: rgb(217 119 6);
stroke-width: 1.2;
}
.b06-chart__area--cut_rock.is-active > polygon {
fill: color-mix(in srgb, rgb(71 85 105) 28%, transparent);
stroke: rgb(71 85 105);
stroke-width: 1.2;
}
.b06-chart__area--fill.is-active > polygon {
fill: color-mix(in srgb, rgb(37 99 235) 28%, transparent);
stroke: rgb(37 99 235);
stroke-width: 1.2;
}
/* 횡단 표준단면 설계선 오버레이. 기본은 실선. */
.b06-chart__design-cross {
fill: none;
@@ -140,3 +140,23 @@
margin-inline-start: auto;
color: var(--color-text-muted);
}
/* 선택 측점의 구간 물량 줄 — 총괄 요약과 구분되게 배경을 옅게 깐다. */
.b06-masshaul__station {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-8) var(--spacing-16);
padding: var(--spacing-8) var(--spacing-16);
border-top: 1px solid var(--color-border);
background: color-mix(in srgb, var(--color-royal-amethyst) 7%, transparent);
color: var(--color-text-secondary);
font-size: var(--text-caption);
}
/* 선택 측점이 곡선의 어디인지 찍는 점 — 물량 줄과 시선을 잇는다. */
.b06-masshaul__focus {
fill: var(--color-royal-amethyst);
stroke: var(--color-surface);
stroke-width: 1.5;
}
+8
View File
@@ -240,6 +240,10 @@ export const ui_locales_b2 = {
B06_MassHaul_Legend_Title: ["표시할 유토곡선", "Visible mass haul curves"],
B06_MassHaul_Legend_Show: ["표시", "Show"],
B06_MassHaul_Legend_Hide: ["숨김", "Hide"],
/* 선택 측점의 구간 물량 — 직전 측점부터 이 측점까지의 몫이다. */
B06_MassHaul_Station_Segment: ["구간 물량", "Segment volume"],
B06_MassHaul_Station_Net: ["순토량", "Net volume"],
B06_MassHaul_Station_Cumulative: ["누가토량", "Cumulative"],
/* --- B06 측점 표준횡단 설계 지정 --- */
B06_Design_Ground_Legend: ["지반유형", "Ground type"],
@@ -255,6 +259,10 @@ export const ui_locales_b2 = {
B06_Design_Ditch_Left: ["좌", "Left"],
B06_Design_Ditch_Right: ["우", "Right"],
B06_Design_Cut_Area: ["절토", "Cut"],
/* 절토 내역 — 지표면~암반 경계선이 토사, 그 아래가 암반(합=절토). */
B06_Design_Cut_Soil_Area: ["절토(토사)", "Cut (soil)"],
B06_Design_Cut_Rock_Area: ["절토(암반)", "Cut (rock)"],
B06_Design_Area_Highlight: ["누르면 해당 면적을 강조합니다", "Click to highlight this area"],
B06_Design_Fill_Area: ["성토", "Fill"],
B06_Design_Unset: ["미지정", "Not set"],
B06_Design_DitchType_Legend: ["측구형식", "Ditch type"],