Files
Aislo/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_View.ts
T
eomsangdonandClaude Opus 5 39d7c2daee 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>
2026-08-02 13:10:07 +09:00

396 lines
15 KiB
TypeScript

/* =============================================================================
* B06_wf3_ProfileCross_UI_MassHaul_View.ts
* 유토곡선(Mass Haul Diagram) SVG 렌더러.
*
* 종단면도(`_UI_Longitudinal`) 바로 아래에 붙어 **같은 X 매핑**을 쓴다. 폭·좌우 여백
* (LONG_PAD)·누가거리 최댓값(longitudinalMaxChainage)을 종단도와 동일하게 받아,
* 같은 측점이 두 그래프에서 같은 가로 위치에 오도록 맞춘다.
*
* 기준(종단/횡단) 2종 × 지반유형 처리 3종의 곡선을 한 축에 겹쳐 그리고, 그래프 상단
* 오버레이 범례 버튼으로 켜고 끈다. **Y 스케일은 표시 여부와 무관하게 항상 전체 곡선
* 기준으로 잡는다** — 곡선을 껐다 켤 때마다 축이 움직이면 비교 자체가 불가능해진다.
*
* Y축은 데이터 범위 기반 자동 스케일이다. 참고 도면의 수직축척이 공구마다 다르므로
* (1공구 V=1:10,000 / 3공구 V=1:50,000) 고정 축척을 쓰면 안 된다.
* 근거: docs/raw/2026-08-02_유토곡선_3공구_분석.md 3.2절
* ========================================================================== */
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";
import {
L,
LONG_PAD,
longitudinalMaxChainage,
stationLabel,
svgElement,
svgText,
} from "./B06_wf3_ProfileCross_UI_Section_Common";
/** 축 눈금이 읽히는 최소 높이. 이보다 낮아지면 그래프가 뭉개진다. */
export const MASS_HAUL_MIN_HEIGHT = 90;
/** 패널을 건드리지 않았을 때의 유토곡선 높이. */
export const MASS_HAUL_HEIGHT = 190;
const BASIS_LABEL: Record<MassHaulBasis, LocaleKey> = {
cross: "B06_MassHaul_Basis_Cross",
longitudinal: "B06_MassHaul_Basis_Long",
};
const GROUND_MODE_LABEL: Record<MassHaulGroundMode, LocaleKey> = {
ground_type: "B06_MassHaul_Ground_ByType",
soil_only: "B06_MassHaul_Ground_SoilOnly",
natural: "B06_MassHaul_Ground_Natural",
};
/** 범례·곡선에 함께 쓰는 이름. 색은 기준, 선 모양은 환산 방식을 뜻한다. */
export function massHaulSeriesLabel(series: MassHaulSeries): string {
return `${L(BASIS_LABEL[series.basis])} · ${L(GROUND_MODE_LABEL[series.groundMode])}`;
}
function seriesClass(series: MassHaulSeries, base: string): string {
return `${base} ${base}--${series.basis} ${base}--${series.groundMode}`;
}
/** 누가토량 값 표기 — 천 단위 구분 + 소수점 1자리. */
export function formatVolume(value: number): string {
return value.toLocaleString(undefined, {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
});
}
/**
* Y축 상·하한을 잡는다. 0선은 항상 보이도록 범위에 포함하고, 위아래로 5% 여유를 준다.
* 값이 전부 0이면 폭이 0이 되어 나눗셈이 깨지므로 최소 폭을 씌운다.
*
* 범위는 **표시 중인 곡선이 아니라 전체 곡선**에서 잡는다(축 고정).
*/
function volumeRange(series: MassHaulSeries[]): { min: number; max: number } {
let rawMin = 0;
let rawMax = 0;
for (const entry of series) {
rawMin = Math.min(rawMin, entry.result.min_cumulative_m3);
rawMax = Math.max(rawMax, entry.result.max_cumulative_m3);
}
const padding = Math.max((rawMax - rawMin) * 0.05, 1);
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>,
longitudinal: LongitudinalSection,
selectedStationId: string | null,
stationInterval: number,
widthPx: number,
heightPx: number,
minimumWidthPx: number,
onSelectStation: (stationId: string) => void,
): SVGSVGElement {
const svg = svgElement("svg", {
class: "b06-section__chart b06-masshaul",
width: widthPx,
height: heightPx,
viewBox: `0 0 ${widthPx} ${heightPx}`,
role: "img",
"aria-label": L("B06_MassHaul_Title"),
});
svg.style.width = "100%";
svg.style.minWidth = `${minimumWidthPx}px`;
svg.append(svgElement("rect", { width: widthPx, height: heightPx, class: "b06-chart__bg" }));
// 종단면도와 완전히 같은 X 매핑 — 여백·최댓값 모두 종단 렌더러와 공유한다.
const maxChainage = longitudinalMaxChainage(longitudinal);
const plotWidth = widthPx - LONG_PAD.left - LONG_PAD.right;
const plotHeight = heightPx - LONG_PAD.top - LONG_PAD.bottom;
const x = (chainage: number) => LONG_PAD.left + (chainage / maxChainage) * plotWidth;
const { min, max } = volumeRange(series);
const span = Math.max(max - min, 1e-6);
const y = (volume: number) => LONG_PAD.top + ((max - volume) / span) * plotHeight;
// 패널을 줄이면 그래프 몫이 60px대까지 내려간다 — 눈금 5개를 그대로 두면 라벨이 서로 겹친다.
const tickRatios = plotHeight < 110 ? [0, 0.5, 1] : [0, 0.25, 0.5, 0.75, 1];
for (const ratio of tickRatios) {
const gridY = LONG_PAD.top + ratio * plotHeight;
const value = max - ratio * span;
svg.append(
svgElement("line", {
x1: LONG_PAD.left,
y1: gridY,
x2: widthPx - LONG_PAD.right,
y2: gridY,
class: "b06-chart__grid",
}),
svgText(formatVolume(value), {
x: LONG_PAD.left - 9,
y: gridY + 4,
"text-anchor": "end",
class: "b06-chart__tick",
}),
);
}
const zeroY = y(0);
const visible = series.filter((entry) => visibleKeys.has(entry.key));
// 면(band)은 첫 번째 표시 곡선에만 깐다 — 여러 곡선에 겹쳐 칠하면 서로 가려 못 읽는다.
const banded = visible[0];
if (banded) {
const areaTop: string[] = [];
const areaBottom: string[] = [];
for (const point of banded.result.points) {
areaTop.push(`${x(point.chainage_m)},${y(point.cumulative_volume_m3)}`);
areaBottom.unshift(`${x(point.chainage_m)},${zeroY}`);
}
svg.append(
svgElement("polygon", {
points: [...areaTop, ...areaBottom].join(" "),
class: seriesClass(banded, "b06-masshaul__band"),
}),
);
}
// 측점선 — 종단도와 같은 자리에 서고, 눌러서 측점을 고를 수 있다(3자 선택 동기화).
// 라벨은 바로 위 종단면도가 이미 달고 있어 여기서는 생략한다(중복 표기 방지).
for (const station of longitudinal.stations) {
const stationX = x(station.chainage_m);
const selected = station.station_id === selectedStationId;
const marker = svgElement("g", {
class: `b06-chart__station${selected ? " b06-chart__station--selected" : ""}`,
tabindex: "0",
role: "button",
"aria-label": `${stationLabel(station.chainage_m, stationInterval)} ${L("B06_MassHaul_Title")}`,
});
marker.addEventListener("click", () => onSelectStation(station.station_id));
marker.addEventListener("keydown", (event) => {
if (event.key === "Enter" || event.key === " ") onSelectStation(station.station_id);
});
marker.append(
svgElement("line", {
x1: stationX,
y1: LONG_PAD.top,
x2: stationX,
y2: heightPx - LONG_PAD.bottom,
class: "b06-chart__station-hit",
}),
svgElement("line", {
x1: stationX,
y1: LONG_PAD.top,
x2: stationX,
y2: heightPx - LONG_PAD.bottom,
class: `b06-chart__station-line b06-chart__station-line--${selected ? "selected" : station.kind}`,
}),
);
svg.append(marker);
}
// 0선 — 절토 우세와 성토 우세를 가르는 기준선이라 격자보다 진하게 그린다.
svg.append(
svgElement("line", {
x1: LONG_PAD.left,
y1: zeroY,
x2: widthPx - LONG_PAD.right,
y2: zeroY,
class: "b06-masshaul__zero",
}),
);
// 뒤에 그린 곡선이 위로 오므로 범례 순서의 역순으로 그려 첫 곡선(정식)을 가장 위에 둔다.
for (const entry of [...visible].reverse()) {
svg.append(
svgElement("polyline", {
points: entry.result.points
.map((point) => `${x(point.chainage_m)},${y(point.cumulative_volume_m3)}`)
.join(" "),
class: seriesClass(entry, "b06-masshaul__curve"),
}),
);
}
// 선택 측점 강조 — 첫 표시 곡선 위에 점을 찍어 아래 물량 표기 줄과 시선을 잇는다.
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,
y1: LONG_PAD.top,
x2: LONG_PAD.left,
y2: heightPx - LONG_PAD.bottom,
class: "b06-chart__axis",
}),
svgText(L("B06_MassHaul_YAxis"), {
x: 15,
y: heightPx / 2,
"text-anchor": "middle",
transform: `rotate(-90 15 ${heightPx / 2})`,
class: "b06-chart__axis-label",
}),
svgText(L("B06_MassHaul_XAxis"), {
x: widthPx / 2,
y: heightPx - 4,
"text-anchor": "middle",
class: "b06-chart__axis-label",
}),
);
return svg;
}
/**
* 그래프 상단에 겹쳐 놓는 표시/숨김 버튼 줄.
*
* 가로 스크롤 컨테이너 안이라 CSS에서 `position: sticky; left: 0`으로 왼쪽에 고정하고,
* `height: 0`으로 흐름 높이를 먹지 않게 해 곡선 위로 떠 있게 만든다(오버레이).
*/
export function createMassHaulLegend(
series: MassHaulSeries[],
visibleKeys: ReadonlySet<string>,
onToggle: (key: string) => void,
): HTMLElement {
const legend = document.createElement("div");
legend.className = "b06-masshaul__legend";
legend.setAttribute("role", "group");
legend.setAttribute("aria-label", L("B06_MassHaul_Legend_Title"));
for (const entry of series) {
const shown = visibleKeys.has(entry.key);
const button = document.createElement("button");
button.type = "button";
button.className = seriesClass(entry, "b06-masshaul__legend-item");
button.classList.toggle("is-off", !shown);
button.setAttribute("aria-pressed", String(shown));
const label = massHaulSeriesLabel(entry);
button.title = `${label} — ${L(shown ? "B06_MassHaul_Legend_Hide" : "B06_MassHaul_Legend_Show")}`;
const swatch = document.createElement("span");
swatch.className = "b06-masshaul__legend-swatch";
swatch.setAttribute("aria-hidden", "true");
const text = document.createElement("span");
text.textContent = label;
button.append(swatch, text);
button.addEventListener("click", () => onToggle(entry.key));
legend.append(button);
}
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;
}
/**
* 곡선 아래 총괄 요약 줄. 절토는 자연상태(내역 기준)와 환산 후(곡선 기준)를 함께 적는다.
* 곡선이 여러 개라 수치가 어느 곡선의 것인지 끝에 반드시 밝힌다.
*/
export function createMassHaulSummary(series: MassHaulSeries): HTMLElement {
const result = series.result;
const row = document.createElement("div");
row.className = "b06-masshaul__summary";
const cut = result.cut_natural_m3;
const chips: Array<[string, string]> = [
[
L("B06_MassHaul_CutNatural"),
`${formatVolume(cut.soil + cut.ripping_rock + cut.blasting_rock)}㎥`,
],
[L("B06_Design_Ground_Soil"), `${formatVolume(cut.soil)}㎥`],
[L("B06_Design_Ground_Ripping"), `${formatVolume(cut.ripping_rock)}㎥`],
[L("B06_Design_Ground_Blasting"), `${formatVolume(cut.blasting_rock)}㎥`],
[L("B06_MassHaul_CutCompacted"), `${formatVolume(result.cut_compacted_m3)}㎥`],
[L("B06_MassHaul_Fill"), `${formatVolume(result.fill_compacted_m3)}㎥`],
];
// 잉여와 부족은 동시에 성립하지 않으므로 해당하는 쪽만 보여 준다.
chips.push(
result.shortage_m3 > 0
? [L("B06_MassHaul_Shortage"), `${formatVolume(result.shortage_m3)}㎥`]
: [L("B06_MassHaul_Surplus"), `${formatVolume(result.surplus_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);
}
const basis = document.createElement("span");
basis.className = seriesClass(series, "b06-masshaul__basis");
basis.textContent = massHaulSeriesLabel(series);
row.append(basis);
return row;
}