/* ============================================================================= * 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; /** * **화면에 보이는 누가거리 구간**(m). 넘기면 Y 범위를 이 구간의 누계 토량으로 잡는다 * (2026-09-04 사용자 지시 — 종단 그래프의 세로 자동 맞춤과 같은 창). 생략하면 예전처럼 * 전 구간 기준 ±200㎥ 고정이다. */ viewRange?: { fromM: number; toM: number }; /** 세로창 버티기·부드러운 이동 상태. 넘기면 창이 한 칸에 확 튀지 않는다(2026-09-04). */ window?: MassHaulWindowState; /** * 측점 세로선을 그리지 않는다. 곡선 전체를 한 화면에 눌러 담는 보기(토량 분배)에서는 * 측점선이 촘촘해 곡선을 덮기만 하고 자리도 못 읽는다(2026-09-06 사용자 지시). */ hideStations?: boolean; } /** * 세로창이 스크롤 한 칸에 몇 배씩 튀는 것을 막는 상태(2026-09-04 사용자 확정: 「버티기 + * 부드럽게」). 누가토량 곡선은 표고와 달리 가팔라, 급한 구간이 창에 들어오면 폭이 한 번에 * 4~5배로 바뀌었다(실측 228 → 1,019㎥). * * 규칙 둘. * ① **버티기** — 곡선이 지금 창 안에 들어오고 창을 절반 넘게 채우면 **그대로 둔다**. * ② **부드럽게** — 그래도 바꿔야 하면 한 번에 안 가고 다시 그릴 때마다 남은 만큼 좁힌다. */ export interface MassHaulWindowState { /** 지금 쓰고 있는 창. */ held: { min: number; max: number } | null; /** 목표에 도착했는가 — 아니면 부르는 쪽이 다음 프레임에 다시 그린다. */ settled: boolean; /** 예약해 둔 다음 프레임(중복 예약 방지). */ frame: number; } export function createMassHaulWindowState(): MassHaulWindowState { return { held: null, settled: true, frame: 0 }; } /** 아직 목표에 못 갔으면 다음 프레임에 한 번 더 그리게 예약한다. */ export function scheduleMassHaulSettle(state: MassHaulWindowState, redraw: () => void): void { if (state.frame) cancelAnimationFrame(state.frame); state.frame = 0; if (state.settled) return; state.frame = requestAnimationFrame(() => { state.frame = 0; redraw(); }); } /** 새 창에 두는 여유(위아래 각각). 종단 그래프와 같은 20%. */ const WINDOW_PAD_RATIO = 0.2; /** 곡선이 창을 이만큼 채우고 있으면 창을 그대로 둔다(버티기). */ const WINDOW_KEEP_FILL = 0.55; /** 다시 그릴 때마다 목표까지 남은 거리에서 좁히는 비율. 0.2면 약 0.2초에 걸쳐 미끄러진다 — * 0.4는 0.08초 만에 끝나 여전히 「툭」 바뀌어 보였고, 0.15는 그리는 횟수가 늘어 프레임을 * 더 먹었다(2026-09-04 실측). */ const WINDOW_EASE = 0.2; /** 축 눈금이 읽히는 최소 높이. 이보다 낮아지면 그래프가 뭉개진다. */ 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 = { 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[], viewRange?: { fromM: number; toM: number }, state?: MassHaulWindowState, ): { min: number; max: number } { let rawMin = 0; let rawMax = 0; if (viewRange) { // 보이는 구간만 훑는다. 창 경계를 걸친 선분이 안에서 솟구치므로 바깥 이웃 한 점도 본다. let found = false; for (const entry of series) { const points = entry.result.points; let first = -1; let last = -1; for (let index = 0; index < points.length; index += 1) { const chainage = points[index].chainage_m; if (chainage < viewRange.fromM || chainage > viewRange.toM) continue; if (first < 0) first = index; last = index; } if (first < 0) continue; for ( let index = Math.max(0, first - 1); index <= Math.min(points.length - 1, last + 1); index += 1 ) { const volume = points[index].cumulative_volume_m3; if (!Number.isFinite(volume)) continue; rawMin = found ? Math.min(rawMin, volume) : volume; rawMax = found ? Math.max(rawMax, volume) : volume; found = true; } } if (found) { // 창 안이 거의 평평하면(구간 토량 변화가 없으면) 최소 폭을 줘 선이 축에 붙지 않게 한다. const padding = Math.max((rawMax - rawMin) * WINDOW_PAD_RATIO, 1); const target = { min: rawMin - padding, max: rawMax + padding }; if (!state) return target; const held = state.held; if (held) { // 버티기 — 곡선이 지금 창 안에 들어오고 절반 넘게 채우면 아예 건드리지 않는다. const heldSpan = held.max - held.min; const inside = rawMin >= held.min && rawMax <= held.max; const fills = rawMax - rawMin >= heldSpan * WINDOW_KEEP_FILL; if (inside && fills) { state.settled = true; return held; } // 부드럽게 — 남은 만큼씩 좁혀 간다(다시 그릴 때마다 한 걸음). const next = { min: held.min + (target.min - held.min) * WINDOW_EASE, max: held.max + (target.max - held.max) * WINDOW_EASE, }; const tolerance = Math.max((target.max - target.min) * 0.01, 0.5); const done = Math.abs(next.min - target.min) <= tolerance && Math.abs(next.max - target.max) <= tolerance; state.held = done ? target : next; state.settled = done; return state.held; } state.held = target; state.settled = true; return target; } } 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( /** 붙일 자리 — 그래프 칸 밖을 자르는 겹이다(2026-09-04). */ svg: SVGSVGElement | SVGGElement, 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, 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, axis.viewRange, axis.window, ); const span = Math.max(max - min, 1e-6); const y = (volume: number) => padTop + ((max - volume) / span) * plotHeight; // 그래프 칸 밖으로 나간 것은 그리지 않는다(2026-09-04 사용자 보고) — 보이는 구간에 // 세로를 맞추면 0선과 그 채움색이 눈금 상한을 넘어 **범례·토글 버튼 위까지** 칠해졌다. // 종단 그래프는 진작 자르고 있었는데 유토곡선만 빠져 있었다. const plotBottom = heightPx - MASS_PAD_BOTTOM; const clipId = `b06-mass-clip-${Math.random().toString(36).slice(2, 9)}`; const clip = svgElement("clipPath", { id: clipId }); clip.append( svgElement("rect", { x: axisX, y: padTop, width: Math.max(0, widthPx - axis.padRight - axisX), height: Math.max(0, plotHeight), }), ); const defs = svgElement("defs", {}); defs.append(clip); // 두 겹으로 나누는 이유: 측점 세로선이 면과 곡선 **사이**에 서야 한다(그리는 순서 유지). const plotBack = svgElement("g", { "clip-path": `url(#${clipId})` }); const plotFront = svgElement("g", { "clip-path": `url(#${clipId})` }); svg.append(defs); // 패널을 줄이면 그래프 몫이 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선이 창 안에 있을 때만 눈금에 싣는다 — 밖으로 나갔는데도 실으면 고정 축 맨 위에 // 남의 값과 겹쳐 찍힌다(2026-09-04 사용자 보고). const zeroInside = zeroY >= padTop && zeroY <= plotBottom; if (zeroInside) 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]; plotBack.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"), }), ); } svg.append(plotBack); // 측점선 — 종단도와 같은 자리에 서고, 눌러서 측점을 고를 수 있다(3자 선택 동기화). // 라벨은 바로 위 종단면도가 이미 달고 있어 여기서는 생략한다(중복 표기 방지). for (const station of axis.hideStations ? [] : 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선 — 절토 우세와 성토 우세를 가르는 기준선이라 격자보다 진하게 그린다. // 보이는 구간에 세로를 맞추면 0이 창 밖으로 나갈 수 있다(2026-09-04 사용자 확정: // 「나가도 됨」) — 그때는 아예 그리지 않는다. 글자는 축 **왼쪽**이라 자르는 겹에 넣으면 // 사라지므로 따로 붙인다. if (zeroInside) { plotFront.append( svgElement("line", { x1: axisX, y1: zeroY, x2: widthPx - axis.padRight, y2: zeroY, class: "b06-masshaul__zero", }), ); svg.append( 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()) { plotFront.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); plotFront.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, }, // 그리는 자리는 자르는 겹 — 창 밖으로 나간 평형선·말풍선이 눈금 위로 새지 않는다. plotFront, ); } // 선택 측점 강조 — 곡선 위 점 + 구간 물량 말풍선. const focus = banded ? massHaulPointAt(banded, longitudinal, selectedStationId) : null; if (focus) { const focusX = x(focus.chainage_m); plotFront.append( svgElement("circle", { cx: focusX, cy: y(focus.cumulative_volume_m3), r: 4, class: "b06-masshaul__focus", }), ); appendVolumeCallout(plotFront, 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(plotFront); 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, 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)}㎥`], ); // 절·성토 균형 지표 — 판정·조정은 사용자 몫이고 프로그램은 값만 드러낸다(2026-09-03 // 사용자 확정). 곡선이 0선을 넘지 않으면 평형선·운반 블록이 아예 생기지 않으므로, // 벌룬이 없을 때 왜 없는지를 이 값 하나로 읽을 수 있어야 한다. const finalCumulative = result.points.length ? result.points[result.points.length - 1].cumulative_volume_m3 : 0; chips.push([ L("B06_MassHaul_FinalCumulative"), `${formatVolume(finalCumulative)}㎥`, L("B06_MassHaul_FinalCumulative_Tip"), ]); // 토량 분배를 켜 두면 운반 총량·검산이 뒤에 붙는다(사토·토취는 발생 지점 기준 합계). 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; }