- 종단 상단 표시줄을 법정 판정 3항목으로 축소: 최대 기울기/상한(별표2 Ⅰ.2.라),
절·성토 불균형(Ⅰ.1.나.(4)(다)), 종단곡선 필요 n곳(Ⅰ.2.마 — 대수차 5% 초과인데
곡선이 빠진 변화점). 변화점·종단곡선 개수·기본 곡선길이 L·중복 경고칩은 제거.
- [초기선 복원] → [편집 되돌리기] — 실제 동작은 화면 편집 델타 삭제이지 저장 지점
복귀가 아니다. 툴팁에 동작과 [초기화]와의 차이를 적었다.
- 3D 뷰포트 안내 라벨 삭제(로딩 완료 후 남던 조작법 문구).
- 배수유역 버튼 정비: 유역 다시 나누기 / 선택한 관 삭제 / 배관 배치 초기화 +
각 툴팁에 실제 동작 명시(선택 삭제는 툴팁 자체가 없었다).
- 유토곡선 요약 12칩 → 5칩(절토·성토·잉여|부족·운반·검산). 토질 3종 내역·다짐
환산·블록 수·장거리 운반·사토는 해당 칩 툴팁으로. B05·B06 공용 함수라 동시 반영.
- 설계속도 축 도입(임도는 속도를 낼 수 없는 노선 — 기본 20km/h):
· 임도 종류는 프로젝트 등록값(projects.road_type)을 읽어 B05가 읽기 전용 표시
(main→간선임도, fire→산불진화임도, 그 외→작업임도). 화면에서 고치지 않는다.
· 설계속도 선택(간선·산불진화 20/30/40, 작업 20 고정) 신설 — 종단기울기 상한과
종단곡선 반경이 등급이 아니라 이 값으로 정해진다(지식DB 설계제원_총괄 §6·§7).
· B02 임도 종류 선택지를 현행 3종으로 정리(지선 폐지·계류보전은 사방 구분).
· 백엔드: ROUTE_GRADE_CLASSES 3종+branch 호환, selectable_design_speeds 신설,
resolve_design_speed 신설, legal_grade_limit_pct·resolve_grade_options에
설계속도 인자, sections/context가 road_type 제공(B06도 저장값 승계).
· 계획선 정책은 그릴 때마다 현재 기준으로 동기화 — 저장분의 옛 상한이 남지 않는다.
- 검증: pytest 125 통과(설계속도 12건 신규), tsc, 헤드 브라우저 — 진입 시 상한 9%,
40km/h 전환 시 7% 즉시 반영, 원복 9%, 안내문 '간선임도 · 설계속도 20km/h ·
일반지형', 유토곡선 5칩, 유역 버튼 새 이름·툴팁, 3D 라벨 빈 문자열 확인.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
684 lines
28 KiB
TypeScript
684 lines
28 KiB
TypeScript
/* =============================================================================
|
|
* common_util_mass_haul_view.ts
|
|
* 유토곡선(Mass Haul Diagram) SVG 렌더러.
|
|
*
|
|
* 종단면도(`_UI_Longitudinal`) 바로 아래에 붙어 **같은 X 매핑**을 쓴다. 폭·좌우 여백
|
|
* (LONG_PAD)·누가거리 최댓값(longitudinalMaxChainage)을 종단도와 동일하게 받아,
|
|
* 같은 측점이 두 그래프에서 같은 가로 위치에 오도록 맞춘다.
|
|
*
|
|
* 산출 기준 2종(횡단/종단)의 곡선을 한 축에 겹쳐 그리고, 그래프 아래 범례 버튼으로 켜고
|
|
* 끈다. **Y 스케일은 표시 여부와 무관하게 항상 전체 곡선 기준으로 잡는다** — 곡선을 껐다
|
|
* 켤 때마다 축이 움직이면 비교 자체가 불가능해진다.
|
|
*
|
|
* Y축은 데이터 범위 기반 자동 스케일이다. 참고 도면의 수직축척이 공구마다 다르므로
|
|
* (1공구 V=1:10,000 / 3공구 V=1:50,000) 고정 축척을 쓰면 안 된다.
|
|
* 근거: docs/raw/2026-08-02_유토곡선_3공구_분석.md 3.2절
|
|
* ========================================================================== */
|
|
|
|
import type { MassHaulBasis, MassHaulPoint, MassHaulSeries } from "./common_util_mass_haul";
|
|
import type { MassHaulStationSource } from "./common_util_mass_haul_types";
|
|
import type { HaulPlan } from "./common_util_mass_haul_balance";
|
|
import type { LocaleKey } from "@ui/ui_template_locale";
|
|
import { MASS_HAUL_BALANCE_KEY } from "./common_util_mass_haul";
|
|
import { appendBalanceLayer, haulPlanChips } from "./common_util_mass_haul_balance_view";
|
|
import { segmentOf } from "./common_util_mass_haul_curve";
|
|
import { L, stationLabel, svgElement, svgText } from "./common_util_svg";
|
|
|
|
/**
|
|
* 유토곡선 X축을 **호출한 쪽 종단 그래프와 맞추는** 값. 좌우 여백과 누가거리 최댓값이
|
|
* 종단도와 같아야 같은 측점이 같은 가로 위치에 온다. 페이지마다 종단 렌더러가 다르므로
|
|
* (B06 `_UI_Longitudinal`, B05 종단 그래프) 상수를 이 모듈에 두지 않고 받아 쓴다.
|
|
*/
|
|
export interface MassHaulAxis {
|
|
maxChainageM: number;
|
|
padLeft: number;
|
|
padRight: number;
|
|
/**
|
|
* Y축 선·눈금을 그릴 x(px). 생략하면 `padLeft`.
|
|
*
|
|
* B05는 데이터 시작을 반 칸(originOffset) 들여쓰므로 `padLeft = 62 + originOffset`인데,
|
|
* 축까지 그 자리에 그리면 위 종단 그래프의 축(62px)과 어긋나 **축이 두 개**로 보인다
|
|
* (2026-08-03 사용자 보고). 축은 여기(종단과 같은 자리)에, 데이터는 padLeft에 둔다.
|
|
*/
|
|
axisX?: number;
|
|
/** 그래프 위 여백(px). 생략하면 기본(10). B05는 범례 오버레이만큼 크게 준다. */
|
|
padTop?: number;
|
|
}
|
|
|
|
/** 축 눈금이 읽히는 최소 높이. 이보다 낮아지면 그래프가 뭉개진다. */
|
|
export const MASS_HAUL_MIN_HEIGHT = 90;
|
|
/** 패널을 건드리지 않았을 때의 유토곡선 높이. */
|
|
export const MASS_HAUL_HEIGHT = 190;
|
|
|
|
/**
|
|
* 유토곡선 전용 상하 여백. 좌우는 종단면도와 **반드시 같아야**(LONG_PAD) X축이 맞으므로
|
|
* 건드리지 않고, 위아래만 좁힌다. 위쪽을 좁히는 이유는 바로 위 종단면도의 측점 라벨과
|
|
* 붙여 두 그래프가 측점 표기를 공유하는 것처럼 보이게 하기 위해서다(2026-08-02 사용자 지시).
|
|
* 아래쪽은 X축 제목을 종단면도 상단으로 합치면서 비었다.
|
|
*/
|
|
const MASS_PAD_TOP = 10;
|
|
const MASS_PAD_BOTTOM = 8;
|
|
|
|
const BASIS_LABEL: Record<MassHaulBasis, LocaleKey> = {
|
|
cross: "B06_MassHaul_Basis_Cross",
|
|
longitudinal: "B06_MassHaul_Basis_Long",
|
|
};
|
|
|
|
/** 범례·곡선에 함께 쓰는 이름. 색이 곧 산출 기준이다. */
|
|
export function massHaulSeriesLabel(series: MassHaulSeries): string {
|
|
return L(BASIS_LABEL[series.basis]);
|
|
}
|
|
|
|
function seriesClass(series: MassHaulSeries, base: string): string {
|
|
return `${base} ${base}--${series.basis}`;
|
|
}
|
|
|
|
/**
|
|
* 곡선 경로. **`common_util_mass_haul_curve`가 쓰는 것과 같은 기하**로 그려야 한다 — 평형선·장비
|
|
* 경계현·평균운반거리선의 양 끝이 그 기하와의 교점으로 계산되므로, 그리기와 재기가 어긋나면
|
|
* 수평선 끝이 곡선에서 떠 보인다(2026-08-02에 겪은 문제).
|
|
*
|
|
* 지금은 직선 모드(`PARABOLIC_MASS_CURVE = false`)라 `L` 명령만 나온다. 포물선 모드를 켜면
|
|
* 제어점 `(x₀ + Δ/2, V₀ + a₀Δ/2)`의 2차 베지에가 구간 포물선과 **정확히** 같아진다.
|
|
*/
|
|
function curvePath(
|
|
points: MassHaulPoint[],
|
|
x: (chainage: number) => number,
|
|
y: (volume: number) => number,
|
|
): string {
|
|
if (!points.length) return "";
|
|
const parts = [`M ${x(points[0].chainage_m)},${y(points[0].cumulative_volume_m3)}`];
|
|
for (let index = 1; index < points.length; index += 1) {
|
|
const point = points[index];
|
|
const segment = segmentOf(points, index);
|
|
if (!segment) {
|
|
parts.push(`L ${x(point.chainage_m)},${y(point.cumulative_volume_m3)}`);
|
|
continue;
|
|
}
|
|
if (Math.abs(segment.curvature) < 1e-12) {
|
|
// 직선 모드 — 제어점을 둘 이유가 없다. 명령 자체를 직선으로 낸다.
|
|
parts.push(`L ${x(point.chainage_m)},${y(point.cumulative_volume_m3)}`);
|
|
continue;
|
|
}
|
|
const controlX = segment.x0 + segment.span / 2;
|
|
const controlY = segment.v0 + (segment.a0 * segment.span) / 2;
|
|
parts.push(
|
|
`Q ${x(controlX)},${y(controlY)} ${x(point.chainage_m)},${y(point.cumulative_volume_m3)}`,
|
|
);
|
|
}
|
|
return parts.join(" ");
|
|
}
|
|
|
|
/** 누가토량 값 표기 — 천 단위 구분 + 소수점 1자리. */
|
|
export function formatVolume(value: number): string {
|
|
return value.toLocaleString(undefined, {
|
|
minimumFractionDigits: 1,
|
|
maximumFractionDigits: 1,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Y축 기본 스케일(㎥). 계획선을 편집할 때마다 범위가 데이터로 다시 잡히면 그래프가
|
|
* 출렁여 변화를 읽기 어렵다(2026-08-04 사용자 확정) — 기본 ±200㎥으로 고정하고,
|
|
* 곡선이 이 범위를 벗어날 때만 그 방향으로 넓힌다.
|
|
*/
|
|
const VOLUME_RANGE_BASE_M3 = 200;
|
|
|
|
/**
|
|
* Y축 상·하한을 잡는다. 기본 −200~+200㎥ 고정(0선 항상 포함), 곡선이 넘치는 쪽만
|
|
* 데이터에 5% 여유를 더해 확장한다. B05·B06이 같은 함수를 쓰므로 두 화면이 함께 고정된다.
|
|
*/
|
|
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: Math.min(-VOLUME_RANGE_BASE_M3, rawMin - padding),
|
|
max: Math.max(VOLUME_RANGE_BASE_M3, rawMax + padding),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 선택된 측점의 곡선 점을 찾는다.
|
|
*
|
|
* 측점 id로 직접 맞추지 않고 **누가거리로 맞춘다** — 종단 기준 곡선의 점 id는 종단 샘플
|
|
* 인덱스가 붙은 파생값(`${station_id}@${index}`)이라 측점 id와 형태가 다르기 때문이다.
|
|
*/
|
|
export function massHaulPointAt(
|
|
series: MassHaulSeries,
|
|
longitudinal: MassHaulStationSource,
|
|
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;
|
|
}
|
|
|
|
interface CalloutPlacement {
|
|
anchorX: number;
|
|
anchorY: number;
|
|
left: number;
|
|
right: number;
|
|
top: number;
|
|
bottom: number;
|
|
/** 주어진 가로 구간에서 표시 중인 곡선이 지나는 세로 범위(없으면 null). */
|
|
curveYAt: (fromPx: number, toPx: number) => { top: number; bottom: number } | null;
|
|
}
|
|
|
|
const CALLOUT_LINE_H = 14;
|
|
const CALLOUT_PAD_X = 9;
|
|
const CALLOUT_PAD_Y = 7;
|
|
/** 말풍선과 곡선 사이 최소 틈(px). */
|
|
const CALLOUT_CLEARANCE = 6;
|
|
|
|
/** SVG는 자동 크기가 없어 폭을 어림해야 한다. 한글은 라틴 글자보다 넓다. */
|
|
function textWidth(value: string): number {
|
|
let width = 0;
|
|
for (const char of value) width += /[가-힣ㄱ-ㅎㅏ-ㅣ]/.test(char) ? 11 : 6.1;
|
|
return width;
|
|
}
|
|
|
|
/**
|
|
* 선택 측점의 구간 물량 말풍선(둥근 사각형)을 **곡선과 겹치지 않는 자리에** 그린다.
|
|
*
|
|
* 말풍선이 놓일 가로 구간에서 곡선이 실제로 지나는 세로 범위를 먼저 재고, 그 위/아래
|
|
* 빈 곳 중 들어갈 수 있는 쪽에 놓는다(2026-08-02 사용자 지시). 오른쪽·왼쪽 자리를 모두
|
|
* 재서 여유가 큰 쪽을 고르며, 어느 쪽에도 안 들어가면 여유가 더 큰 쪽 끝에 붙인다.
|
|
*/
|
|
function appendVolumeCallout(
|
|
svg: SVGSVGElement,
|
|
point: MassHaulPoint,
|
|
stationInterval: number,
|
|
box: CalloutPlacement,
|
|
): void {
|
|
const lines = [
|
|
`${stationLabel(point.chainage_m, stationInterval)}` +
|
|
` · ${L("B06_MassHaul_Station_Net")} ${formatVolume(point.net_volume_m3)}` +
|
|
` · ${L("B06_MassHaul_Station_Cumulative")} ${formatVolume(point.cumulative_volume_m3)}`,
|
|
`${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)} (㎥)`,
|
|
];
|
|
const width = Math.min(
|
|
Math.max(...lines.map(textWidth)) + CALLOUT_PAD_X * 2,
|
|
Math.max(box.right - box.left, 80),
|
|
);
|
|
const height = lines.length * CALLOUT_LINE_H + CALLOUT_PAD_Y * 2;
|
|
|
|
const clampX = (value: number): number =>
|
|
Math.min(Math.max(value, box.left), Math.max(box.left, box.right - width));
|
|
// 오른쪽 먼저, 안 되면 왼쪽 — 각 자리마다 위/아래 여유를 재서 가장 넉넉한 조합을 고른다.
|
|
let best: { x: number; y: number; room: number; fits: boolean } | null = null;
|
|
for (const candidateX of [clampX(box.anchorX + 14), clampX(box.anchorX - 14 - width)]) {
|
|
const band = box.curveYAt(candidateX, candidateX + width);
|
|
const roomAbove = (band ? band.top : box.bottom) - box.top;
|
|
const roomBelow = box.bottom - (band ? band.bottom : box.top);
|
|
for (const [room, top] of [
|
|
[roomAbove, (band ? band.top : box.bottom) - height - CALLOUT_CLEARANCE],
|
|
[roomBelow, (band ? band.bottom : box.top) + CALLOUT_CLEARANCE],
|
|
] as Array<[number, number]>) {
|
|
const fits = room >= height + CALLOUT_CLEARANCE;
|
|
const better = !best || (fits && !best.fits) || (fits === best.fits && room > best.room);
|
|
if (better) best = { x: candidateX, y: top, room, fits };
|
|
}
|
|
}
|
|
if (!best) return;
|
|
const boxY = Math.min(
|
|
Math.max(best.y, box.top + 2),
|
|
Math.max(box.top + 2, box.bottom - height - 2),
|
|
);
|
|
|
|
const group = svgElement("g", { class: "b06-masshaul__callout" });
|
|
// 꼬리는 앵커가 말풍선 가로 범위 안에 있을 때만 — 밖이면 지시선으로 잇는다.
|
|
const tailX = Math.min(Math.max(box.anchorX, best.x + 12), best.x + width - 12);
|
|
const below = boxY > box.anchorY;
|
|
if (box.anchorX >= best.x && box.anchorX <= best.x + width) {
|
|
const edge = below ? boxY : boxY + height;
|
|
const tip = below ? edge - 7 : edge + 7;
|
|
group.append(
|
|
svgElement("polygon", {
|
|
points: `${tailX - 6},${edge} ${tailX + 6},${edge} ${tailX},${tip}`,
|
|
class: "b06-masshaul__callout-tail",
|
|
}),
|
|
);
|
|
} else {
|
|
group.append(
|
|
svgElement("line", {
|
|
x1: box.anchorX,
|
|
y1: box.anchorY,
|
|
x2: box.anchorX < best.x ? best.x : best.x + width,
|
|
y2: boxY + height / 2,
|
|
class: "b06-masshaul__callout-leader",
|
|
}),
|
|
);
|
|
}
|
|
group.append(
|
|
svgElement("rect", { x: best.x, y: boxY, width, height, rx: 8, ry: 8 }),
|
|
...lines.map((line, index) =>
|
|
svgText(line, {
|
|
x: best.x + CALLOUT_PAD_X,
|
|
y: boxY + CALLOUT_PAD_Y + CALLOUT_LINE_H * (index + 0.8),
|
|
class: "b06-masshaul__callout-text",
|
|
}),
|
|
),
|
|
);
|
|
svg.append(group);
|
|
}
|
|
|
|
export function createMassHaulChart(
|
|
series: MassHaulSeries[],
|
|
visibleKeys: ReadonlySet<string>,
|
|
longitudinal: MassHaulStationSource,
|
|
axis: MassHaulAxis,
|
|
selectedStationId: string | null,
|
|
stationInterval: number,
|
|
widthPx: number,
|
|
heightPx: number,
|
|
minimumWidthPx: number,
|
|
onSelectStation: (stationId: string) => void,
|
|
haulPlan: HaulPlan | null,
|
|
/**
|
|
* Y축 눈금을 호출한 쪽에 알려 준다 — 가로 스크롤에도 왼쪽에 **고정되는 축**을 따로
|
|
* 그리려면 SVG와 같은 눈금값이 필요하다(B05 종단 패널이 그렇게 쓴다).
|
|
* 이 값을 안 쓰는 화면(B06)은 콜백을 넘기지 않으면 된다.
|
|
*/
|
|
onAxis?: (axis: { padLeft: number; ticks: Array<{ y: number; label: 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 = Math.max(axis.maxChainageM, 1);
|
|
const axisX = axis.axisX ?? axis.padLeft;
|
|
const plotWidth = widthPx - axis.padLeft - axis.padRight;
|
|
// B05 오버레이는 범례 버튼이 그래프 위(top 34px)에 떠서 상단 여유를 더 받는다.
|
|
const padTop = axis.padTop ?? MASS_PAD_TOP;
|
|
const plotHeight = heightPx - padTop - MASS_PAD_BOTTOM;
|
|
const x = (chainage: number) => axis.padLeft + (chainage / maxChainage) * plotWidth;
|
|
|
|
// 기준 버튼이 라디오가 되면서(택1 표시) Y 범위도 **표시 중인 곡선**으로 잡는다 —
|
|
// 숨은 기준까지 합쳐 잡으면 선택한 그래프가 눌려 보인다. 아무것도 안 켰으면 전체로 폴백.
|
|
const rangeSource = series.filter((entry) => visibleKeys.has(entry.key));
|
|
const { min, max } = volumeRange(rangeSource.length ? rangeSource : series);
|
|
const span = Math.max(max - min, 1e-6);
|
|
const y = (volume: number) => padTop + ((max - volume) / span) * plotHeight;
|
|
|
|
// 패널을 줄이면 그래프 몫이 60px대까지 내려간다 — 눈금 5개를 그대로 두면 라벨이 서로 겹친다.
|
|
const tickRatios = plotHeight < 110 ? [0, 0.5, 1] : [0, 0.25, 0.5, 0.75, 1];
|
|
const axisTicks: Array<{ y: number; label: string }> = [];
|
|
for (const ratio of tickRatios) {
|
|
const gridY = padTop + ratio * plotHeight;
|
|
const value = max - ratio * span;
|
|
// 고정 축 라벨에는 단위를 붙인다 — 바로 위 종단 그래프의 표고축(`…m`)과 같은 자리에
|
|
// 서므로, 단위가 없으면 어느 축인지 읽히지 않는다(2026-08-03 사용자 지적).
|
|
axisTicks.push({ y: gridY, label: `${formatVolume(value)}㎥` });
|
|
svg.append(
|
|
svgElement("line", {
|
|
x1: axisX,
|
|
y1: gridY,
|
|
x2: widthPx - axis.padRight,
|
|
y2: gridY,
|
|
class: "b06-chart__grid",
|
|
}),
|
|
svgText(formatVolume(value), {
|
|
x: axisX - 9,
|
|
y: gridY + 4,
|
|
"text-anchor": "end",
|
|
class: "b06-chart__tick",
|
|
}),
|
|
);
|
|
}
|
|
|
|
const zeroY = y(0);
|
|
// 0선은 격자보다 진하게 따로 그리므로 고정 축 눈금에도 함께 실어 준다.
|
|
axisTicks.push({ y: zeroY, label: "0㎥" });
|
|
onAxis?.({ padLeft: axisX, ticks: axisTicks });
|
|
const visible = series.filter((entry) => visibleKeys.has(entry.key));
|
|
|
|
/** 주어진 가로 구간에서 표시 중인 곡선이 지나는 세로 범위. 말풍선·balloon 배치가 함께 쓴다. */
|
|
const curveYAt = (fromPx: number, toPx: number): { top: number; bottom: number } | null => {
|
|
let lowest = Number.POSITIVE_INFINITY;
|
|
let highest = Number.NEGATIVE_INFINITY;
|
|
for (const entry of visible) {
|
|
for (const point of entry.result.points) {
|
|
const px = x(point.chainage_m);
|
|
if (px < fromPx || px > toPx) continue;
|
|
const py = y(point.cumulative_volume_m3);
|
|
lowest = Math.min(lowest, py);
|
|
highest = Math.max(highest, py);
|
|
}
|
|
}
|
|
return Number.isFinite(lowest) ? { top: lowest, bottom: highest } : null;
|
|
};
|
|
|
|
// 면(band)은 첫 번째 표시 곡선에만 깐다 — 여러 곡선에 겹쳐 칠하면 서로 가려 못 읽는다.
|
|
const banded = visible[0];
|
|
if (banded) {
|
|
const last = banded.result.points[banded.result.points.length - 1];
|
|
svg.append(
|
|
svgElement("path", {
|
|
// 곡선과 **같은 경로**로 위쪽 테두리를 그린 뒤 0선을 따라 닫는다.
|
|
d:
|
|
`${curvePath(banded.result.points, x, y)} ` +
|
|
`L ${x(last.chainage_m)},${zeroY} L ${x(banded.result.points[0].chainage_m)},${zeroY} Z`,
|
|
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: padTop,
|
|
x2: stationX,
|
|
y2: heightPx - MASS_PAD_BOTTOM,
|
|
class: "b06-chart__station-hit",
|
|
}),
|
|
svgElement("line", {
|
|
x1: stationX,
|
|
y1: padTop,
|
|
x2: stationX,
|
|
y2: heightPx - MASS_PAD_BOTTOM,
|
|
class: `b06-chart__station-line b06-chart__station-line--${selected ? "selected" : station.kind}`,
|
|
}),
|
|
);
|
|
svg.append(marker);
|
|
}
|
|
|
|
// 0선 — 절토 우세와 성토 우세를 가르는 기준선이라 격자보다 진하게 그리고 눈금값도 따로 적는다.
|
|
// Y 범위 계산(`volumeRange`)이 0을 항상 품으므로 이 선은 어떤 노선에서도 화면에 남는다.
|
|
svg.append(
|
|
svgElement("line", {
|
|
x1: axisX,
|
|
y1: zeroY,
|
|
x2: widthPx - axis.padRight,
|
|
y2: zeroY,
|
|
class: "b06-masshaul__zero",
|
|
}),
|
|
svgText("0", {
|
|
x: axisX - 9,
|
|
y: zeroY + 4,
|
|
"text-anchor": "end",
|
|
class: "b06-chart__tick b06-masshaul__zero-tick",
|
|
}),
|
|
);
|
|
|
|
// 뒤에 그린 곡선이 위로 오므로 범례 순서의 역순으로 그려 첫 곡선(정식)을 가장 위에 둔다.
|
|
for (const entry of [...visible].reverse()) {
|
|
svg.append(
|
|
svgElement("path", {
|
|
d: curvePath(entry.result.points, x, y),
|
|
class: seriesClass(entry, "b06-masshaul__curve"),
|
|
}),
|
|
);
|
|
}
|
|
|
|
// EP(종점) 잔량 라벨 — 곡선 끝점의 누가토량이 곧 노선 전체의 잉여(+)/부족(−)이라
|
|
// 눈금을 되짚지 않아도 읽히게 값 자체를 적는다(2026-08-03 사용자 지시). 단위 ㎥ 명기.
|
|
if (banded) {
|
|
const last = banded.result.points[banded.result.points.length - 1];
|
|
const epX = x(last.chainage_m);
|
|
const epY = y(last.cumulative_volume_m3);
|
|
const text = `EP ${formatVolume(last.cumulative_volume_m3)}㎥`;
|
|
const labelWidth = textWidth(text) + 10;
|
|
// 종점은 오른쪽 끝이라 라벨을 왼쪽으로 눕히고, 위아래는 플롯 안으로 클램프한다.
|
|
const labelX = Math.max(epX - 8, axis.padLeft + labelWidth);
|
|
const labelY = Math.min(Math.max(epY - 8, padTop + 12), heightPx - MASS_PAD_BOTTOM - 4);
|
|
svg.append(
|
|
svgElement("circle", { cx: epX, cy: epY, r: 3.5, class: "b06-masshaul__ep-dot" }),
|
|
svgElement("rect", {
|
|
x: labelX - labelWidth,
|
|
y: labelY - 12,
|
|
width: labelWidth,
|
|
height: 16,
|
|
rx: 4,
|
|
ry: 4,
|
|
class: "b06-masshaul__ep-plate",
|
|
}),
|
|
svgText(text, {
|
|
x: labelX - 5,
|
|
y: labelY,
|
|
"text-anchor": "end",
|
|
class: "b06-masshaul__ep-label",
|
|
}),
|
|
);
|
|
}
|
|
|
|
// 토량 분배 레이어 — 곡선 **위**, 선택 말풍선 **아래**. 순서를 바꾸면 평형선이 곡선을
|
|
// 가리거나(위로 올리면) 선택 말풍선이 balloon에 묻힌다(아래로 내리면).
|
|
if (haulPlan) {
|
|
appendBalanceLayer(svg, haulPlan, {
|
|
x,
|
|
y,
|
|
left: axis.padLeft,
|
|
right: widthPx - axis.padRight,
|
|
top: padTop,
|
|
bottom: heightPx - MASS_PAD_BOTTOM,
|
|
curveYAt,
|
|
stationInterval,
|
|
});
|
|
}
|
|
|
|
// 선택 측점 강조 — 곡선 위 점 + 구간 물량 말풍선.
|
|
const focus = banded ? massHaulPointAt(banded, longitudinal, selectedStationId) : null;
|
|
if (focus) {
|
|
const focusX = x(focus.chainage_m);
|
|
svg.append(
|
|
svgElement("circle", {
|
|
cx: focusX,
|
|
cy: y(focus.cumulative_volume_m3),
|
|
r: 4,
|
|
class: "b06-masshaul__focus",
|
|
}),
|
|
);
|
|
appendVolumeCallout(svg, focus, stationInterval, {
|
|
anchorX: focusX,
|
|
anchorY: y(focus.cumulative_volume_m3),
|
|
left: axis.padLeft,
|
|
right: widthPx - axis.padRight,
|
|
top: padTop,
|
|
bottom: heightPx - MASS_PAD_BOTTOM,
|
|
curveYAt,
|
|
});
|
|
}
|
|
|
|
svg.append(
|
|
svgElement("line", {
|
|
x1: axisX,
|
|
y1: padTop,
|
|
x2: axisX,
|
|
y2: heightPx - MASS_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",
|
|
}),
|
|
);
|
|
|
|
// 그래프 이름표 — 종단면도와 같은 양식이되 **하단**에 놓고, 곡선이 지나가는 자리라
|
|
// 불투명 배경을 깐다(2026-08-02 사용자 지시).
|
|
const nameText = L("B06_MassHaul_Title");
|
|
const nameWidth = textWidth(nameText) + 14;
|
|
const nameY = heightPx - MASS_PAD_BOTTOM - 6;
|
|
svg.append(
|
|
svgElement("rect", {
|
|
x: widthPx / 2 - nameWidth / 2,
|
|
y: nameY - 12,
|
|
width: nameWidth,
|
|
height: 17,
|
|
rx: 4,
|
|
ry: 4,
|
|
class: "b06-masshaul__name-plate",
|
|
}),
|
|
svgText(nameText, {
|
|
x: widthPx / 2,
|
|
y: nameY,
|
|
"text-anchor": "middle",
|
|
class: "b06-chart__axis-label",
|
|
}),
|
|
);
|
|
return svg;
|
|
}
|
|
|
|
/**
|
|
* 곡선 표시/숨김 버튼 줄.
|
|
*
|
|
* 그래프 **아래**에 둔다(2026-08-02 사용자 지시). 예전에는 그래프 위에 겹치는 오버레이였는데,
|
|
* 가로 스크롤 컨테이너 안에 있으면 컨테이너 폭 계산에 끼어들어 종단면도와 X축이 어긋난다.
|
|
* 지금은 스크롤 컨테이너 **밖** 패널 본문에 붙어 곡선 폭에 전혀 관여하지 않는다.
|
|
*/
|
|
export function createMassHaulLegend(
|
|
series: MassHaulSeries[],
|
|
visibleKeys: ReadonlySet<string>,
|
|
onToggle: (key: string) => void,
|
|
onResetBalloons?: () => 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"));
|
|
const addItem = (key: string, label: string, variantClass: string): void => {
|
|
const shown = visibleKeys.has(key);
|
|
const button = document.createElement("button");
|
|
button.type = "button";
|
|
button.className = variantClass;
|
|
button.classList.toggle("is-off", !shown);
|
|
button.setAttribute("aria-pressed", String(shown));
|
|
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(key));
|
|
legend.append(button);
|
|
};
|
|
for (const entry of series) {
|
|
addItem(entry.key, massHaulSeriesLabel(entry), seriesClass(entry, "b06-masshaul__legend-item"));
|
|
}
|
|
// 토량 분배는 곡선이 아니라 곡선 위에 얹는 레이어라 스와치 모양을 따로 준다.
|
|
addItem(
|
|
MASS_HAUL_BALANCE_KEY,
|
|
L("B06_MassHaul_Balance_Layer"),
|
|
"b06-masshaul__legend-item b06-masshaul__legend-item--balance",
|
|
);
|
|
// 끌어 옮긴 balloon을 한 번에 자동 배치로 되돌린다(프론트 캐시 + 다음 확정 시 영구저장소).
|
|
if (onResetBalloons && visibleKeys.has(MASS_HAUL_BALANCE_KEY)) {
|
|
const reset = document.createElement("button");
|
|
reset.type = "button";
|
|
reset.className = "b06-masshaul__legend-item b06-masshaul__legend-item--reset";
|
|
reset.textContent = L("B06_MassHaul_Balloon_Reset");
|
|
reset.title = L("B06_MassHaul_Balloon_Reset_Tip");
|
|
reset.addEventListener("click", onResetBalloons);
|
|
legend.append(reset);
|
|
}
|
|
return legend;
|
|
}
|
|
|
|
/**
|
|
* 곡선 아래 총괄 요약 줄. 절토는 자연상태(내역 기준)와 환산 후(곡선 기준)를 함께 적는다.
|
|
* 곡선이 여러 개라 수치가 어느 곡선의 것인지 끝에 반드시 밝힌다.
|
|
*/
|
|
export function createMassHaulSummary(
|
|
series: MassHaulSeries,
|
|
haulPlan: HaulPlan | null,
|
|
): HTMLElement {
|
|
const result = series.result;
|
|
const row = document.createElement("div");
|
|
row.className = "b06-masshaul__summary";
|
|
const cut = result.cut_natural_m3;
|
|
const cutTotal = cut.soil + cut.ripping_rock + cut.blasting_rock;
|
|
/* 요약 줄이 12칩까지 늘어 한눈에 안 읽혔다(2026-08-19 사용자 지적). 판단에 쓰는
|
|
* 값만 칩으로 남기고, 세부 내역은 그 칩의 툴팁으로 내린다.
|
|
* 절토(자연) — 토질 3종 내역은 툴팁
|
|
* 성토(다짐) — 절토 다짐 환산은 툴팁(토량환산계수 f 적용 결과)
|
|
* 잉여 또는 부족 — 둘은 동시에 성립하지 않는다
|
|
* 운반·검산 — 토량 분배를 켰을 때만(haulPlanChips)
|
|
* B05 종단 패널과 B06 횡단 화면이 같은 함수를 쓰므로 양쪽이 함께 바뀐다. */
|
|
const chips: Array<[string, string, string?]> = [
|
|
[
|
|
L("B06_MassHaul_CutNatural"),
|
|
`${formatVolume(cutTotal)}㎥`,
|
|
[
|
|
`${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)}㎥`,
|
|
].join(" · "),
|
|
],
|
|
[
|
|
L("B06_MassHaul_Fill"),
|
|
`${formatVolume(result.fill_compacted_m3)}㎥`,
|
|
`${L("B06_MassHaul_CutCompacted")} ${formatVolume(result.cut_compacted_m3)}㎥`,
|
|
],
|
|
];
|
|
// 잉여와 부족은 동시에 성립하지 않으므로 해당하는 쪽만 보여 준다.
|
|
chips.push(
|
|
result.shortage_m3 > 0
|
|
? [L("B06_MassHaul_Shortage"), `${formatVolume(result.shortage_m3)}㎥`]
|
|
: [L("B06_MassHaul_Surplus"), `${formatVolume(result.surplus_m3)}㎥`],
|
|
);
|
|
// 토량 분배를 켜 두면 운반 총량·검산이 뒤에 붙는다(사토·토취는 발생 지점 기준 합계).
|
|
chips.push(...haulPlanChips(haulPlan));
|
|
for (const [label, value, tip] of chips) {
|
|
const chip = document.createElement("span");
|
|
chip.className = "b06-masshaul__chip";
|
|
if (tip) chip.title = tip;
|
|
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;
|
|
}
|