Files
Aislo/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_View.ts
T
eomsangdonandClaude Opus 5 7bf2cff68e fix(B06): 상단 패널 축 제목·범례 위치 정리와 측점 세로선 정렬, 휠 가로 스크롤
- 종단면도 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>
2026-08-02 13:26:42 +09:00

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;
}