From 818042e45c834bc24549c776d47de851bd97d258 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 6 Sep 2026 19:44:59 +0900 Subject: [PATCH] =?UTF-8?q?feat(B05,B06):=20=EC=B5=9C=EC=A2=85=20=EB=88=84?= =?UTF-8?q?=EA=B0=80=ED=86=A0=EB=9F=89=20=EB=B0=B0=EC=A7=80=20+=20B06=20?= =?UTF-8?q?=EC=9C=A0=ED=86=A0=EA=B3=A1=EC=84=A0=20=EA=B7=B8=EB=9E=98?= =?UTF-8?q?=ED=94=84=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 지시(2026-09-06): 유토곡선은 자리를 많이 먹는데 정작 늘 봐야 하는 값은 마지막 지점 누가토량 하나임. 기준은 횡단으로 고정(종단 기준 곡선은 뒤에 삭제 예정). - 총괄값 계열을 범례 선택과 무관하게 **횡단 기준**으로 뽑음 - 공용 배지 `common_util_mass_haul_badge.ts` — 종단 그래프 좌측 상단 오버레이, 세부 내역은 툴팁 - B05: 상단줄의 「누가토량」 항목을 배지로 옮김(유토곡선 패널은 그대로 유지) - B06: 유토곡선 그래프·범례·요약줄 삭제, 그 높이를 종단면도가 전부 사용 검증: B06 배지 −5,595.5㎥·범례 없음·종단 228px, B05 배지 −6,842.6㎥·상단줄에서 제거. 테스트 390 통과. Co-Authored-By: Claude Opus 5 (1M context) --- B05_Profile/B05_Profile_UI_Profile_Balance.ts | 29 ++----- .../B05_Profile_UI_Profile_MassHaul.ts | 5 +- B05_Profile/B05_Profile_UI_Profile_Panel.ts | 12 ++- B06_Section/B06_Section_UI_Section_View.ts | 72 ++++++---------- .../B06_Section_UI_Section_View_Panel.ts | 21 ++--- common_util/common_util_mass_haul.css | 38 ++++++++ common_util/common_util_mass_haul_badge.ts | 86 +++++++++++++++++++ 7 files changed, 172 insertions(+), 91 deletions(-) create mode 100644 common_util/common_util_mass_haul_badge.ts diff --git a/B05_Profile/B05_Profile_UI_Profile_Balance.ts b/B05_Profile/B05_Profile_UI_Profile_Balance.ts index 3c3e262e..16dbea88 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Balance.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Balance.ts @@ -16,12 +16,6 @@ import type { ProfileAlignment } from "./B05_Profile_UI_Profile_Alignment"; import type { MinCoverViolation } from "./B05_Profile_UI_Profile_MinCover"; -import type { MassHaulSummaryValues } from "./B05_Profile_UI_Profile_MassHaul"; - -/** 누가토량 표기 — 천 단위 구분 + 소수점 1자리(유토곡선 요약과 같은 규칙). */ -function volume(value: number): string { - return `${value.toLocaleString("ko-KR", { minimumFractionDigits: 1, maximumFractionDigits: 1 })}㎥`; -} export interface BalanceBarParams { /** 표시줄 컨테이너. 그릴 때마다 통째로 갈아 끼운다. */ @@ -42,11 +36,11 @@ export interface BalanceBarParams { minCoverViolations?: MinCoverViolation[]; /** 요약줄 **오른쪽 끝**에 붙이는 묶음(줌·Y레인지 조작구) — 2026-09-04. */ trailing?: HTMLElement; - /** 유토곡선 총괄값 — 「최대 기울기」 오른쪽에 적는다(2026-09-06 사용자 지시). - * 유토곡선 패널을 접어도 이 값은 남아야 계획선을 만지며 볼 수 있다. */ - massHaul?: MassHaulSummaryValues | null; /** 법정 평면 최소곡선반지름 위반(2026-09-06) — 자동 보정 없이 **경고만** 낸다. */ planCurve?: { count: number; worstRadiusM: number; limitM: number; hairpin: number } | null; + // 유토곡선 총괄값은 이 줄에서 **뺐다**(2026-09-06 사용자 지시) — 그래프 좌측 상단에 + // 겹쳐 뜨는 배지(`common_util_mass_haul_badge`)가 맡는다. B06 은 곡선을 아예 빼므로 + // 같은 배지를 써야 두 화면이 같은 자리에서 같은 값을 보인다. /** [초기선 복원] — 편집·비정규 측점을 모두 지운다. */ onResetAll: () => void; } @@ -88,13 +82,9 @@ export function renderBalanceBar(params: BalanceBarParams): void { // 절·성토 불균형은 2026-09-03 사용자 지시로 뺐다. 이 값은 **종단 기준**이었다 — // 계획선과 지반선 사이 세로 면적(㎡)의 절·성토 비이지 실제 토량이 아니다. 의미 있는 // 균형은 횡단 단면적을 쌓아 부피로 내는 하단 유토곡선 요약(㎥)이고, 그쪽이 이미 있다. - // 유토곡선 총괄값 — 「최대 기울기」 **바로 오른쪽**(2026-09-06 사용자 지시). 곡선을 접어도 - // 남는다. 세부 내역(절토·성토·잉여/부족)은 이 항목의 툴팁으로 내렸다 — 예전 요약 막대가 - // 세부를 툴팁에 두던 방식 그대로다. - const mass = params.massHaul; - if (mass) { - entries.push(["누가토량", volume(mass.finalM3), mass.finalM3 < 0 ? "over" : undefined]); - } + // 유토곡선 총괄값은 2026-09-06 사용자 지시로 이 줄에서 **뺐다** — 그래프 좌측 상단에 + // 겹쳐 뜨는 배지(`common_util_mass_haul_badge`)가 맡는다. B06 은 곡선을 아예 빼므로 + // 같은 배지를 써야 두 화면이 같은 자리에서 같은 값을 보인다. // 평면 곡선반경 — 법정 하한을 밑도는 측점이 있을 때만 적는다(별표2 Ⅰ.2.다.(1)). // 프로그램은 값만 드러내고 고치지 않는다: 노선을 바꿀지는 사용자 판단이다. const plan = params.planCurve; @@ -128,13 +118,6 @@ export function renderBalanceBar(params: BalanceBarParams): void { : "배향곡선 하한 미만은 없음", ].join("\n"); } - if (label === "누가토량" && mass) { - item.title = [ - `절토(자연) ${volume(mass.cutNaturalM3)} · 절토(다짐) ${volume(mass.cutCompactedM3)}`, - `성토(다짐) ${volume(mass.fillCompactedM3)}`, - mass.shortageM3 > 0 ? `부족 ${volume(mass.shortageM3)}` : `잉여 ${volume(mass.surplusM3)}`, - ].join("\n"); - } if (label === "최대 기울기" && violations.length) { item.title = violations .map( diff --git a/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts b/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts index 7280947b..f35dd73f 100644 --- a/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts +++ b/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts @@ -350,8 +350,9 @@ export function createRouteMassHaulPanel( context.naturalSpoilMinSlope, ); if (!series.length) return onSummary(null); - const visible = readVisible(); - const picked = series.find((entry) => visible.has(entry.key)) ?? series[0]; + // 총괄값 기준은 **횡단 고정**이다(2026-09-06 사용자: 종단 기준 곡선은 뒤에 없앨 예정). + // 범례에서 무엇을 켜 두었든 배지·상단줄이 흔들리지 않는다. + const picked = series.find((entry) => entry.basis === "cross") ?? series[0]; const result = picked.result; const cut = result.cut_natural_m3; const points = result.points; diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel.ts b/B05_Profile/B05_Profile_UI_Profile_Panel.ts index 2a60f6d8..3116f244 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel.ts @@ -14,6 +14,7 @@ import { readStateRaw, stateKey, writeStateRaw } from "../A00_Common/b_page_stat import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch"; import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; import { createPanelResizer } from "@ui/ui_template_resizer"; +import { createMassHaulBadge } from "@util/common_util_mass_haul_badge"; import { createDrainagePanel } from "./B05_Profile_UI_Drainage_Panel"; import type { StructureInstance, StructureType } from "./B05_Profile_Api_Structures"; import { @@ -145,13 +146,17 @@ export function createRouteProfilePanel( if (subPanelDragging) scheduleLightSync(); else draw(); }; - /** 유토곡선 총괄값 — 상단줄이 쓴다. 패널을 접어도 값이 남게 여기에 들고 있는다. */ + /** 유토곡선 총괄값 — **그래프 좌측 상단 배지**가 보인다(2026-09-06 사용자 지시). + * 유토곡선을 접어도, B06 처럼 곡선을 아예 안 그려도 이 값은 남아야 한다. */ + const massBadge = createMassHaulBadge(); + // B05 는 그래프 위에 도구줄(상단줄)이 한 줄 있다 — 그 아래로 내려 겹치지 않게 한다. + massBadge.root.style.setProperty("--mass-haul-badge-top", "34px"); + bodyWrap.append(massBadge.root); let massHaulSummary: MassHaulSummaryValues | null = null; const massHaul = createRouteMassHaulPanel(subPanelChanged, (summary) => { const before = massHaulSummary; massHaulSummary = summary; - // 값이 그대로면 상단줄을 다시 그리지 않는다 — 계획고를 연속으로 누를 때 헛일이 된다. - if (before?.finalM3 !== summary?.finalM3) renderBalance(); + if (before?.finalM3 !== summary?.finalM3) massBadge.set(summary); }); bodyWrap.append(massHaul.overlay, massHaul.handle); // 오버레이의 가로 스크롤을 종단 스크롤러와 양방향 동기화 — 측점 세로선 정렬 유지. @@ -366,7 +371,6 @@ export function createRouteProfilePanel( tools: tools.render(), trailing: profileZoom.bar, alignment, - massHaul: massHaulSummary, legacyAlignment: !!detail && hasLegacyAlignment(detail.longitudinal), edited: store.edited(), hasIrregularStations: irregularStations.length > 0, diff --git a/B06_Section/B06_Section_UI_Section_View.ts b/B06_Section/B06_Section_UI_Section_View.ts index 137fde3f..1c2c1bf1 100644 --- a/B06_Section/B06_Section_UI_Section_View.ts +++ b/B06_Section/B06_Section_UI_Section_View.ts @@ -47,10 +47,9 @@ import { visibleChainageRange, visibleElevationRange, } from "./B06_Section_UI_Section_View_Chart"; -import { applyLegendToggle } from "@util/common_util_mass_haul"; import { configureBalloonOffsets } from "@util/common_util_mass_haul_balance_view"; -import { MASS_HAUL_MIN_HEIGHT } from "@util/common_util_mass_haul_view"; -import { createMassHaulRenderer } from "./B06_Section_UI_Section_View_MassHaul"; +import { computeMassHaulSeries } from "@util/common_util_mass_haul"; +import { badgeValuesFrom, createMassHaulBadge } from "@util/common_util_mass_haul_badge"; import { applyElevationWindow, needsFullRedraw, @@ -65,7 +64,6 @@ import { MIN_PANEL_HEIGHT, PANEL_CHROME_PX, PANEL_HEIGHT_KEY, - readVisibleSeries, } from "./B06_Section_UI_Section_View_Panel"; import { CROSS_GRID_GAP, @@ -138,13 +136,10 @@ export function createSectionView( let currentCrossHalfWidth: number | undefined; let currentStationInterval: number | undefined; let currentConversion: EarthworkConversion | undefined; - let currentHaulLimits: HaulEquipmentLimit[] | undefined; let currentNaturalSpoilSlope: number | undefined; let renderWidth = 0; let resizeTimer = 0; let panelResizeTimer = 0; - // 유토곡선 표시 곡선 집합. 토글해도 Y 스케일은 전체 곡선 기준이라 축이 움직이지 않는다. - const visibleSeries = readVisibleSeries(); // 그래프 상자의 마지막 실측 높이와 보정 예약 플래그(빈 공간·세로 스크롤 둘 다 막는다). let lastChartAvailable = 0; let chartFitScheduled = false; @@ -451,18 +446,10 @@ export function createSectionView( ); }; - /** 범례 버튼 — 곡선 하나를 켜고 끈다. 축은 전체 곡선 기준이라 여기서 움직이지 않는다. */ - const toggleSeries = (key: string): void => { - // 곡선 기준(횡단/종단)은 라디오 — 하나를 고르면 그 기준으로 계산된 그래프만 보인다. - const next = applyLegendToggle(visibleSeries, key); - visibleSeries.clear(); - next.forEach((entry) => visibleSeries.add(entry)); - writeStateRaw("masshaul-visible", JSON.stringify([...visibleSeries])); - drawPanel(); - }; - /** 유토곡선만 새 창으로 다시 그리는 함수 — 그릴 때마다 새로 만든다(붙인 노드는 그 안). */ - let renderMassHaul: ((fromM: number, toM: number) => void) | null = null; + /** 최종 누가토량 배지 — 종단 그래프 좌측 상단(2026-09-06 사용자 지시). */ + const massBadge = createMassHaulBadge(); + panelBody.append(massBadge.root); /** 세로 창 갱신기 — 종단은 변환, 유토곡선은 곡선만. */ let updateChartWindow: (() => ElevationWindowResult | null) | null = null; @@ -478,9 +465,7 @@ export function createSectionView( // 화면에 붙기 전(detached)에는 잴 수 없으니 그때만 `PANEL_CHROME_PX` 추정치로 시작한다. const measured = chartWrap.clientHeight; lastChartAvailable = - measured > 0 - ? measured - : Math.max(panelHeight() - PANEL_CHROME_PX, MIN_LONG_HEIGHT + MASS_HAUL_MIN_HEIGHT); + measured > 0 ? measured : Math.max(panelHeight() - PANEL_CHROME_PX, MIN_LONG_HEIGHT); const heights = chartHeights(lastChartAvailable); const minWidth = longitudinalMinimumWidth(detail.longitudinal, cachedStationInterval); const chartWidth = Math.max(renderWidth, minWidth); @@ -498,32 +483,23 @@ export function createSectionView( }); const longAxis = chart.axis; const nodes: Element[] = [chart.node]; - const { viewFromM, viewToM } = chart; chartWrap.replaceChildren(...nodes); - // 유토곡선 몫은 전용 모듈이 만들어 붙인다(2026-09-03 · 700줄 제한). 가로로 스크롤할 - // 때는 **이 함수만** 다시 불러 곡선의 세로 창을 따라오게 한다 — 종단면도는 건드리지 - // 않는다(2026-09-04: 상단 패널을 통째로 다시 그리면 화면이 한 번 끊긴다). - renderMassHaul = createMassHaulRenderer( - { - detail, - conversion: currentConversion, - haulLimits: currentHaulLimits, - naturalSpoilSlope: currentNaturalSpoilSlope, - visibleSeries, - selectedStationId, - stationInterval: cachedStationInterval, - chartWidth, - minWidth, - massHeight: heights.mass, - longHeight: heights.long, - selectStation: (stationId) => selectStation(stationId, true), - toggleSeries, - redraw: drawPanel, - }, - { chartWrap, panel, panelBody, statusNode: panelCount }, - ); - renderMassHaul(viewFromM, viewToM); + // 유토곡선 그래프는 B06 에서 **그리지 않는다**(2026-09-06 사용자 지시) — 자리를 많이 + // 먹는데 정작 필요한 값은 마지막 지점 누가토량 하나다. 곡선은 B05 유토곡선 패널에서 + // 펼쳐 본다. 여기서는 같은 계산으로 값만 내 좌측 상단 배지에 올린다(기준: 횡단). + if (currentConversion) { + const series = computeMassHaulSeries( + detail.longitudinal, + detail.cross_sections, + currentConversion, + currentNaturalSpoilSlope, + ); + const cross = series.find((entry) => entry.basis === "cross") ?? series[0]; + massBadge.set(cross ? badgeValuesFrom(cross.result) : null); + } else { + massBadge.set(null); + } // 종단 고정 Y축 — 0크기 sticky 앵커라 **첫 자식**으로 넣어야 세로 기준이 컨테이너 // 상단이 된다(SVG 뒤에 넣으면 앵커가 차트 아래로 밀린다). if (longAxis) { @@ -541,7 +517,6 @@ export function createSectionView( chartWrap.scrollLeft, chartWrap.clientWidth || chartWidth, ); - renderMassHaul?.(fromM, toM); return applyElevationWindow( chartWrap, visibleElevationRange(detail, fromM, toM) ?? undefined, @@ -673,7 +648,9 @@ export function createSectionView( crossHalfWidth, stationInterval, earthworkConversion, - haulEquipmentLimits, + // 운반 장비 한계는 곡선(운반 블록)에만 쓰던 값이라 B06 에서는 더 받지 않는다 + // (2026-09-06 유토곡선 그래프 제거). 인자 자리는 호출부 호환을 위해 남긴다. + _haulEquipmentLimits, balloonScope, naturalSpoilMinSlope, ) { @@ -684,7 +661,6 @@ export function createSectionView( currentStationInterval = stationInterval !== undefined && stationInterval > 0 ? stationInterval : undefined; if (earthworkConversion) currentConversion = earthworkConversion; - if (haulEquipmentLimits?.length) currentHaulLimits = haulEquipmentLimits; if (Number.isFinite(naturalSpoilMinSlope)) currentNaturalSpoilSlope = naturalSpoilMinSlope; // balloon 위치는 **영구저장소 값이 이긴다** — 다른 브라우저에서 옮긴 자리를 그대로 받는다. configureBalloonOffsets(balloonScope ?? "default", detail.balloon_offsets ?? undefined); diff --git a/B06_Section/B06_Section_UI_Section_View_Panel.ts b/B06_Section/B06_Section_UI_Section_View_Panel.ts index d847b227..421f0fb2 100644 --- a/B06_Section/B06_Section_UI_Section_View_Panel.ts +++ b/B06_Section/B06_Section_UI_Section_View_Panel.ts @@ -1,12 +1,13 @@ import { MASS_HAUL_DEFAULT_VISIBLE, normalizeVisibleBasis } from "@util/common_util_mass_haul"; import { readStateRaw, stateKey } from "../A00_Common/b_page_state"; -import { MASS_HAUL_HEIGHT, MASS_HAUL_MIN_HEIGHT } from "@util/common_util_mass_haul_view"; import { LONG_HEIGHT } from "./B06_Section_UI_Section_Common"; export const PANEL_CHROME_PX = 102; -export const BASE_PANEL_HEIGHT = LONG_HEIGHT + MASS_HAUL_HEIGHT + PANEL_CHROME_PX; +/* 유토곡선 그래프를 B06 에서 뺐다(2026-09-06 사용자 지시) — 패널 높이도 종단면도 몫만 + 잡는다. 곡선은 B05 유토곡선 패널에서 보고, 여기서는 좌측 상단 배지가 총괄값을 보인다. */ +export const BASE_PANEL_HEIGHT = LONG_HEIGHT + PANEL_CHROME_PX; export const MIN_LONG_HEIGHT = 110; -export const MIN_PANEL_HEIGHT = MIN_LONG_HEIGHT + MASS_HAUL_MIN_HEIGHT + PANEL_CHROME_PX; +export const MIN_PANEL_HEIGHT = MIN_LONG_HEIGHT + PANEL_CHROME_PX; export const MAX_PANEL_HEIGHT_RATIO = 0.8; /* 패널 접힘·높이·유토곡선 범례는 화면 취향(①) — 키는 등록표(`b_page_state`)가 만든다. 범례 키는 B05 와 **같은 값**이어야 한다(두 화면이 같은 그림의 두 창). */ @@ -29,20 +30,12 @@ export function readVisibleSeries(): Set { } } +/** 종단면도 높이 — 유토곡선을 빼면서 남는 자리를 **전부** 종단이 쓴다(2026-09-06). */ export function chartHeights(availableHeightPx: number): { long: number; mass: number } { if (!Number.isFinite(availableHeightPx) || availableHeightPx <= 0) { - return { long: LONG_HEIGHT, mass: MASS_HAUL_HEIGHT }; + return { long: LONG_HEIGHT, mass: 0 }; } - const available = Math.max(availableHeightPx, MIN_LONG_HEIGHT + MASS_HAUL_MIN_HEIGHT); - if (available >= LONG_HEIGHT + MASS_HAUL_HEIGHT) { - return { long: LONG_HEIGHT, mass: available - LONG_HEIGHT }; - } - const ratio = available / (LONG_HEIGHT + MASS_HAUL_HEIGHT); - const mass = Math.min( - Math.max(MASS_HAUL_MIN_HEIGHT, Math.round(MASS_HAUL_HEIGHT * ratio)), - available - MIN_LONG_HEIGHT, - ); - return { long: available - mass, mass }; + return { long: Math.max(MIN_LONG_HEIGHT, availableHeightPx), mass: 0 }; } export function unwrapChart(node: HTMLElement): Element { diff --git a/common_util/common_util_mass_haul.css b/common_util/common_util_mass_haul.css index 138dd9da..74b2e127 100644 --- a/common_util/common_util_mass_haul.css +++ b/common_util/common_util_mass_haul.css @@ -371,3 +371,41 @@ border-top-color: var(--color-warning); border-top-style: dashed; } + +/* ── 최종 누가토량 배지 (2026-09-06 사용자 지시) ────────────────────────── + 종단 그래프 좌측 상단에 겹쳐 띄운다. 유토곡선을 접거나(B05) 아예 빼도(B06) + 이 값은 늘 보여야 한다. 그래프 조작을 막지 않도록 이벤트는 통과시키되, + 툴팁은 읽혀야 하므로 배지 자신만 이벤트를 받는다. */ +.mass-haul-badge { + position: absolute; + top: var(--mass-haul-badge-top, 6px); + /* Y축 띠(고정 축) 오른쪽에 놓는다 — 8px 로 두면 축 라벨 위에 겹쳐 값이 잘린다 + (2026-09-06 실측). 축 폭이 다른 화면은 이 변수를 덮어쓴다. */ + left: var(--mass-haul-badge-left, 86px); + z-index: 4; + display: inline-flex; + align-items: center; + gap: var(--spacing-4, 4px); + padding: 2px var(--spacing-8, 8px); + border: 1px solid var(--color-border); + border-radius: var(--radius-pills, 999px); + background: color-mix(in srgb, var(--color-surface-raised) 88%, transparent); + color: var(--color-text-body); + font-size: var(--text-caption); + line-height: 1.3; + white-space: nowrap; + pointer-events: auto; +} + +.mass-haul-badge em { + color: var(--color-text-secondary); + font-style: normal; +} + +.mass-haul-badge strong { + font-weight: var(--font-weight-bold, 700); +} + +.mass-haul-badge.is-over strong { + color: var(--color-danger, #f43f5e); +} diff --git a/common_util/common_util_mass_haul_badge.ts b/common_util/common_util_mass_haul_badge.ts new file mode 100644 index 00000000..f8702813 --- /dev/null +++ b/common_util/common_util_mass_haul_badge.ts @@ -0,0 +1,86 @@ +/* ============================================================================= + * common_util_mass_haul_badge.ts + * 「최종 누가토량」 배지 — 종단 그래프 **좌측 상단에 겹쳐** 띄우는 한 줄. + * + * 왜 배지인가(2026-09-06 사용자 지시) — 유토곡선 그래프는 자리를 많이 먹는데, 계획선을 + * 만지며 늘 봐야 하는 값은 **마지막 지점의 누가토량 하나**다. 그래서 그 값만 그래프 위에 + * 겹쳐 두고, 곡선 자체는 B05 의 유토곡선 패널에서만 펼쳐 본다(B06 에서는 곡선을 뺐다). + * + * 기준은 **횡단**이다 — 종단 기준 곡선은 계획선만 보는 개략값이라 뒤에 없앨 예정이다 + * (2026-09-06 사용자). 값을 넣는 쪽에서 횡단 기준 계열을 골라 넘긴다. + * ========================================================================== */ + +/** 배지에 실리는 값 한 벌 — 유토곡선 계산 결과에서 뽑는다. */ +export interface MassHaulBadgeValues { + /** 마지막 측점의 누계 토량(㎥). 음수면 부족(토취). */ + finalM3: number; + cutNaturalM3: number; + cutCompactedM3: number; + fillCompactedM3: number; + surplusM3: number; + shortageM3: number; +} + +/** 누가토량 표기 — 천 단위 구분 + 소수점 1자리(유토곡선 요약과 같은 규칙). */ +function volume(value: number): string { + return `${value.toLocaleString("ko-KR", { maximumFractionDigits: 1 })}㎥`; +} + +export interface MassHaulBadge { + /** 그래프 컨테이너(`position: relative`)에 붙일 요소. */ + root: HTMLElement; + /** 값을 갈아 끼운다. null 이면 배지를 숨긴다(아직 계산 못 한 상태). */ + set: (values: MassHaulBadgeValues | null) => void; +} + +/** 유토곡선 계산 결과 한 벌에서 배지 값을 뽑는다 — B05·B06 이 같은 규칙을 쓴다. */ +export function badgeValuesFrom(result: { + points: ReadonlyArray<{ cumulative_volume_m3: number }>; + cut_natural_m3: { soil: number; ripping_rock: number; blasting_rock: number }; + cut_compacted_m3: number; + fill_compacted_m3: number; + surplus_m3: number; + shortage_m3: number; +}): MassHaulBadgeValues { + const cut = result.cut_natural_m3; + const points = result.points; + return { + finalM3: points.length ? points[points.length - 1].cumulative_volume_m3 : 0, + cutNaturalM3: cut.soil + cut.ripping_rock + cut.blasting_rock, + cutCompactedM3: result.cut_compacted_m3, + fillCompactedM3: result.fill_compacted_m3, + surplusM3: result.surplus_m3, + shortageM3: result.shortage_m3, + }; +} + +/** 배지를 만든다. 붙이는 것은 부르는 쪽 몫이다(그래프 컨테이너 첫 자식). */ +export function createMassHaulBadge(): MassHaulBadge { + const root = document.createElement("span"); + root.className = "mass-haul-badge"; + root.hidden = true; + const caption = document.createElement("em"); + caption.textContent = "누가토량"; + const value = document.createElement("strong"); + root.append(caption, value); + return { + root, + set(values) { + if (!values) { + root.hidden = true; + return; + } + root.hidden = false; + root.classList.toggle("is-over", values.finalM3 < 0); + value.textContent = volume(values.finalM3); + root.title = [ + `절토(자연) ${volume(values.cutNaturalM3)} · 절토(다짐) ${volume(values.cutCompactedM3)}`, + `성토(다짐) ${volume(values.fillCompactedM3)}`, + values.shortageM3 > 0 + ? `부족 ${volume(values.shortageM3)}` + : `잉여 ${volume(values.surplusM3)}`, + "기준: 횡단", + ].join("\n"); + }, + }; +}