- 종단면도 X축 제목을 그래프 상단으로 올린다. 아래쪽은 측점 라벨 몫으로 비워, 바로 밑에 붙는 유토곡선과 X축을 공유하는 것처럼 읽히게 한다(B05 종단 패널도 같은 렌더러). - 유토곡선 범례를 그래프 위 오버레이에서 패널 본문 하단으로 내린다. 개수가 늘어도 높이 배분이 흔들리지 않게 한 줄 고정 + 가로 스크롤. 유토곡선 X축 제목은 삭제(중복). - 측점 세로선 어긋남 해소: 유토곡선 SVG만 감싸던 `.b06-masshaul__frame`이 스크롤 컨테이너 폭 계산에 끼어들어 두 SVG의 렌더 폭이 달랐다. SVG는 preserveAspectRatio 기본값이 meet라 렌더 폭 차이가 그대로 X 매핑 차이가 된다. 상자를 없애 같은 부모의 형제로 맞췄다. - 선택 횡단도가 sticky 패널에 가리지 않게 패널 실측 높이를 `--b06-cross-scroll-margin`으로 넘겨 `scroll-margin-top`에 물리고, 스크롤을 center → nearest로 바꿨다. 패널 리사이즈가 끝나면 선택 카드를 새 높이 기준으로 다시 끌어올린다. - 패널 위 휠은 페이지 대신 그래프를 좌우로 민다. 더 밀 곳이 없으면 기본 동작을 넘긴다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
392 lines
15 KiB
TypeScript
392 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",
|
|
}),
|
|
// X축 제목은 종단면도 상단 하나로 합쳤다 — 여기서 또 적으면 두 그래프가 별개 축으로 읽힌다.
|
|
svgText(L("B06_MassHaul_YAxis"), {
|
|
x: 15,
|
|
y: heightPx / 2,
|
|
"text-anchor": "middle",
|
|
transform: `rotate(-90 15 ${heightPx / 2})`,
|
|
class: "b06-chart__axis-label",
|
|
}),
|
|
);
|
|
return svg;
|
|
}
|
|
|
|
/**
|
|
* 곡선 표시/숨김 버튼 줄.
|
|
*
|
|
* 그래프 **아래**에 둔다(2026-08-02 사용자 지시). 예전에는 그래프 위에 겹치는 오버레이였는데,
|
|
* 가로 스크롤 컨테이너 안에 있으면 컨테이너 폭 계산에 끼어들어 종단면도와 X축이 어긋난다.
|
|
* 지금은 스크롤 컨테이너 **밖** 패널 본문에 붙어 곡선 폭에 전혀 관여하지 않는다.
|
|
*/
|
|
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;
|
|
}
|