feat(B05,B06): 최종 누가토량 배지 + B06 유토곡선 그래프 제거

사용자 지시(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) <noreply@anthropic.com>
This commit is contained in:
2026-09-06 19:44:59 +09:00
co-authored by Claude Opus 5
parent d057e64e9f
commit 818042e45c
7 changed files with 172 additions and 91 deletions
+6 -23
View File
@@ -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(
@@ -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;
+8 -4
View File
@@ -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,
+24 -48
View File
@@ -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);
@@ -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<string> {
}
}
/** 종단면도 높이 — 유토곡선을 빼면서 남는 자리를 **전부** 종단이 쓴다(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 {
+38
View File
@@ -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);
}
@@ -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");
},
};
}