feat(B06): 유토곡선 다중 곡선화 — 종단/횡단 기준 x 지반유형 처리 3종 + 오버레이 범례

임도는 산복 사면을 따라가 편절·편성이 지배적이라, 종단 계획고로 본 절성토 균형과
실제 횡단 단면적으로 본 균형이 크게 어긋난다. 어느 쪽을 정식 기준으로 삼을지
사용자가 그래프를 보고 결정할 수 있도록 두 기준을 한 축에 겹쳐 그린다.
지반유형 처리도 암반 경계선 분리 전까지는 미결이라 세 가지를 함께 낸다.

법령 근거([임도의 설계 및 시설기준] 별표):
- 2.다.(3)(나) "시공계획고는 절토량과 성토량이 균형을 이루게 하되" → 종단 기준 곡선
- 2.다.(4)(마) 측점마다 절토고·성토고·단면적 기입           → 횡단 기준 곡선
- 2.다.(4)(다) "절토부분은 토사·암반으로 구분"              → 암반 분리(미결 1번)의 근거
같은 별표에 토공 운반장비 선정거리 기준은 없다. 장비 경계값의 근거는 법령이 아니라
3공구 도면 실측치다.

곡선 6종 = 기준 2(횡단/종단) x 환산 3(지반유형별/토사 단일/환산 없음).
색이 기준, 선모양이 환산 방식을 뜻한다.

- _UI_MassHaul.ts: 공통 적분 함수 integrate(samples, conversion, groundMode)로 통일.
  두 기준이 AreaSample[]까지 정규화된 뒤 같은 코드를 탄다 — 평균단면법·환산 로직을
  이중으로 두지 않는다. computeMassHaulSeries() 신설.
  computeMassHaul()은 시그니처·결과를 그대로 유지해 _UI_Page.ts의 확정 저장 경로는
  건드리지 않는다.
  종단 기준: difference_m(= 계획고 - 지반고, 음수가 절토) x 노반폭.
  노반폭·지반유형은 종단 샘플에 없으므로 최근접 설계 측점에서 상속한다.
- _UI_MassHaul_View.ts: 다중 곡선 렌더 + 그래프 상단 오버레이 범례.
  Y 스케일은 표시 여부와 무관하게 항상 전체 곡선 기준으로 잡는다 — 곡선을 껐다 켤 때마다
  축이 움직이면 비교 자체가 불가능하다.
  면(band)은 맨 앞 곡선 하나에만 칠한다(여러 개면 서로 가려 못 읽는다).
  곡선은 범례 역순으로 그려 첫 곡선이 맨 위에 온다.
- _UI_Section_View.ts: 표시 집합을 sessionStorage에 보존. 전부 끈 상태도 사용자의
  선택이므로 기본값으로 되돌리지 않는다. 요약 수치는 켜 둔 첫 곡선의 것이며,
  어느 곡선인지 요약 끝에 반드시 밝힌다.
- _UI_Style_MassHaul.css(신규): _UI_Style_Cross.css가 742줄로 700줄 제한을 넘어
  유토곡선 블록을 분리했다(분리 후 606줄). 범례는 sticky + height:0이라 가로 스크롤에도
  왼쪽에 남으면서 곡선 위로 뜬다.
- ui_template_locale_b2.ts: 곡선명·범례 문구 9개 키. 하드코딩 없음.

검증(테스트1 / route 48 / 24측점 중 23측점 설계):
- 계산 엔진 단위 테스트 18건 통과, tsc --noEmit 통과, 전 파일 700줄 이하
- 범례 6개 생성, 토글 2 -> 6 -> 0 -> 2 정상
- Y 눈금이 세 상태 모두 60.5 / -46.3 / -153.0 으로 동일 (축 고정 확인)
- X축 정렬: 종단도 측점선 [62 ... 1454] 24개 = 유토곡선 [62 ... 1454] 24개
- 새로고침 후 켜 둔 곡선 복원 확인

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-02 11:18:37 +09:00
co-authored by Claude Opus 5
parent e684131e6b
commit 6a87baa212
7 changed files with 521 additions and 134 deletions
@@ -2,9 +2,9 @@
* B06_wf3_ProfileCross_UI_MassHaul.ts
* 유토곡선(Mass Haul Diagram) 계산 엔진 — 순수 함수, DOM 의존 없음.
*
* 측점별 절·성토 단면적(백엔드 `compute_cross_design` 산출값)에서 구간 토량을 만들고,
* 토량환산계수로 기준을 통일해 누가토량을 누적한다. 결과는 B08 수량산출·B09 견적이
* 그대로 재사용할 수 있도록 계산과 렌더링을 분리해 둔다(렌더러는 `_UI_MassHaul_View`).
* 구간 토량을 평균단면법으로 만들고 토량환산계수로 기준을 통일해 누가토량을 누적한다.
* 결과는 B08 수량산출·B09 견적이 그대로 재사용할 수 있도록 계산과 렌더링을 분리해
* 둔다(렌더러는 `_UI_MassHaul_View`).
*
* ── 계산 기준: 다짐상태 ──────────────────────────────────────────────
* "운반거리의 산정 시에 모든 수량은 다짐상태로 환산하여 계산하고, 내역서에 적용하는
@@ -12,6 +12,20 @@
* 유토곡선은 운반계획 도면이므로 다짐상태를 기준상태로 삼는다. 내역서용 자연상태
* 수량은 `cut_natural_m3`로 함께 보존해 B08이 되돌려 쓸 수 있게 한다.
*
* ── 두 기준(종단/횡단)을 함께 내는 이유 ──────────────────────────────
* [임도의 설계 및 시설기준] 2.다.(3)(나)는 "시공계획고는 절토량과 성토량이 균형을
* 이루게 하되"라고 정해 **종단 계획고**를 균형 판정 대상으로 삼는다. 반면 실제 물량은
* 같은 기준 2.다.(4)(마)의 측점별 횡단 단면적에서 나온다. 임도는 산복 사면을 따라가
* 편절·편성이 지배적이므로 두 기준이 크게 어긋날 수 있다. 어느 쪽을 정식 기준으로 삼을지
* 사용자가 그래프를 보고 판단할 수 있게, 두 기준을 같은 축에 겹쳐 그린다.
*
* ── 지반유형 처리를 3가지로 내는 이유 ────────────────────────────────
* 같은 기준 2.다.(4)(다)는 "절토부분은 토사·암반으로 구분하되, 암반부분은 추정선으로
* 기입한다"고 정한다. 그러나 현재 `compute_cross_design()`은 측점당 절토 단면적을
* **하나만** 내므로 암반 경계선 위아래를 나누지 못하고, 측점 전체가 단일 지반유형으로
* 환산된다(미결사항 1번). 분리 로직이 서기 전까지 환산 방식이 결과를 얼마나 흔드는지
* 눈으로 확인할 수 있도록 세 가지를 모두 계산해 둔다.
*
* 분석 근거: docs/raw/2026-08-02_유토곡선_3공구_분석.md
* ========================================================================== */
@@ -19,6 +33,7 @@ import type {
CrossSection,
EarthworkConversion,
GroundType,
LongitudinalSection,
} from "./B06_wf3_ProfileCross_Api_Fetch";
/** 지반유형별 토량(㎥). 도면 표기 EA(토사)/RR(리핑암)/BR(발파암)에 대응한다. */
@@ -32,17 +47,17 @@ export interface GroundVolumes {
export interface MassHaulPoint {
station_id: string;
chainage_m: number;
/** 직전 측점부터 이 측점까지 구간의 순토량(다짐상태 기준, ㎥). 첫 측점은 0. */
/** 직전 측점부터 이 측점까지 구간의 순토량(㎥). 첫 측점은 0. */
net_volume_m3: number;
/** 시점부터의 누가토량(다짐상태 기준, ㎥). */
/** 시점부터의 누가토량(㎥). */
cumulative_volume_m3: number;
}
export interface MassHaulResult {
points: MassHaulPoint[];
/** 총 절토량(자연상태, 지반유형별) — 내역서 기준값. */
/** 총 절토량(자연상태, 지반유형별) — 내역서 기준값. 환산 방식과 무관하게 실제 지반유형으로 쌓는다. */
cut_natural_m3: GroundVolumes;
/** 총 절토량(다짐 환산 후 합계) — 곡선 기준값. */
/** 총 절토량(환산 후 합계) — 곡선 기준값. */
cut_compacted_m3: number;
/** 총 성토량(설계 성토량 = 다짐상태). */
fill_compacted_m3: number;
@@ -58,14 +73,59 @@ export interface MassHaulResult {
conversion: EarthworkConversion;
}
/** 토량을 어디서 뽑았는지. `cross`가 정식(실측 단면적), `longitudinal`은 계획고 기반 개략값. */
export type MassHaulBasis = "cross" | "longitudinal";
/**
* 절토량에 어떤 환산계수를 물릴지.
* `ground_type` — 측점 지반유형별 C (현행 정식)
* `soil_only` — 전 구간 토사 C 단일 적용 (암 판정을 신뢰하지 않는 하한 케이스)
* `natural` — 환산 미적용. 절토 자연상태 그대로 성토 설계값과 뺀다(무보정 대조군)
*/
export type MassHaulGroundMode = "ground_type" | "soil_only" | "natural";
export interface MassHaulSeries {
/** `${basis}_${groundMode}` — 표시 토글 저장 키이자 CSS 변형 클래스 접미사. */
key: string;
basis: MassHaulBasis;
groundMode: MassHaulGroundMode;
result: MassHaulResult;
}
const GROUND_TYPES: GroundType[] = ["soil", "ripping_rock", "blasting_rock"];
/** 곡선을 그릴 기준·환산 조합. 배열 순서가 곧 범례 순서다. */
const SERIES_MATRIX: Array<{ basis: MassHaulBasis; groundMode: MassHaulGroundMode }> = [
{ basis: "cross", groundMode: "ground_type" },
{ basis: "longitudinal", groundMode: "ground_type" },
{ basis: "cross", groundMode: "soil_only" },
{ basis: "longitudinal", groundMode: "soil_only" },
{ basis: "cross", groundMode: "natural" },
{ basis: "longitudinal", groundMode: "natural" },
];
/** 기본으로 켜 두는 곡선 — 두 기준의 정식 환산만 보이고 나머지는 사용자가 켠다. */
export const MASS_HAUL_DEFAULT_VISIBLE = ["cross_ground_type", "longitudinal_ground_type"];
export function massHaulSeriesKey(basis: MassHaulBasis, groundMode: MassHaulGroundMode): string {
return `${basis}_${groundMode}`;
}
/** 환산계수가 비어 있거나 값이 이상하면 1.0으로 떨어뜨려 계산이 멈추지 않게 한다. */
function compactedFactor(conversion: EarthworkConversion, ground: GroundType): number {
const factor = conversion?.[ground]?.compacted;
return Number.isFinite(factor) && factor > 0 ? factor : 1;
}
function factorFor(
conversion: EarthworkConversion,
ground: GroundType,
groundMode: MassHaulGroundMode,
): number {
if (groundMode === "natural") return 1;
return compactedFactor(conversion, groundMode === "soil_only" ? "soil" : ground);
}
function emptyVolumes(): GroundVolumes {
return { soil: 0, ripping_rock: 0, blasting_rock: 0 };
}
@@ -74,29 +134,37 @@ function finiteArea(value: number | undefined): number {
return Number.isFinite(value) && (value as number) > 0 ? (value as number) : 0;
}
function normalizeGround(value: GroundType | undefined): GroundType {
return value && GROUND_TYPES.includes(value) ? value : "soil";
}
/** 적분 입력 한 점. 기준이 달라도 여기까지 오면 같은 코드를 탄다. */
interface AreaSample {
station_id: string;
chainage_m: number;
cut_area_m2: number;
fill_area_m2: number;
ground: GroundType;
}
/**
* 측점별 설계 결과에서 유토곡선을 계산한다.
* 단면적 시퀀스를 누가토량으로 적분한다.
*
* 구간 토량은 평균단면법으로 구한다. 지반유형은 측점마다 다를 수 있으므로,
* 각 측점이 자기 쪽 절반(`단면적 × Δd / 2`)을 자기 지반유형으로 가져간다.
* 두 절반을 합치면 `(A_i + A_i+1) / 2 × Δd`가 되어 평균단면법과 정확히 일치한다.
*
* 설계(`design`)가 없는 측점은 단면적을 모르므로 건너뛴다. 유효 측점이 2개 미만이면
* 곡선을 만들 수 없어 null을 반환한다.
* 구간 토량은 평균단면법으로 구한다. 지반유형은 점마다 다를 수 있으므로, 각 점이 자기 쪽
* 절반(`단면적 × Δd / 2`)을 자기 지반유형으로 가져간다. 두 절반을 합치면
* `(A_i + A_i+1) / 2 × Δd`가 되어 평균단면법과 정확히 일치한다.
*/
export function computeMassHaul(
crossSections: CrossSection[],
function integrate(
samples: AreaSample[],
conversion: EarthworkConversion,
groundMode: MassHaulGroundMode,
): MassHaulResult | null {
const sections = crossSections
.filter((section) => section.design && Number.isFinite(section.chainage_m))
.sort((a, b) => a.chainage_m - b.chainage_m);
if (sections.length < 2) return null;
if (samples.length < 2) return null;
const points: MassHaulPoint[] = [
{
station_id: sections[0].station_id,
chainage_m: sections[0].chainage_m,
station_id: samples[0].station_id,
chainage_m: samples[0].chainage_m,
net_volume_m3: 0,
cumulative_volume_m3: 0,
},
@@ -108,33 +176,28 @@ export function computeMassHaul(
let minCumulative = 0;
let maxCumulative = 0;
for (let index = 1; index < sections.length; index += 1) {
const previous = sections[index - 1];
const current = sections[index];
for (let index = 1; index < samples.length; index += 1) {
const previous = samples[index - 1];
const current = samples[index];
const spanM = current.chainage_m - previous.chainage_m;
// 같은 위치에 측점이 겹쳐 들어오면 구간 부피가 0이라 곡선에 기여하지 않는다.
// 같은 위치에 점이 겹쳐 들어오면 구간 부피가 0이라 곡선에 기여하지 않는다.
if (!(spanM > 0)) continue;
const halfSpan = spanM / 2;
// 절토: 양 끝 측점이 각자 자기 지반유형으로 절반씩 가져간다.
let segmentCompacted = 0;
// 절토: 양 끝 점이 각자 자기 지반유형으로 절반씩 가져간다.
let segmentCut = 0;
for (const end of [previous, current]) {
const design = end.design;
if (!design) continue;
const naturalM3 = finiteArea(design.cut_area_m2) * halfSpan;
const naturalM3 = end.cut_area_m2 * halfSpan;
if (naturalM3 <= 0) continue;
const ground = GROUND_TYPES.includes(design.ground_type) ? design.ground_type : "soil";
cutNatural[ground] += naturalM3;
segmentCompacted += naturalM3 * compactedFactor(conversion, ground);
cutNatural[end.ground] += naturalM3;
segmentCut += naturalM3 * factorFor(conversion, end.ground, groundMode);
}
// 성토: 설계 성토량이 곧 다짐상태 물량이라 환산하지 않는다.
const segmentFill =
(finiteArea(previous.design?.fill_area_m2) + finiteArea(current.design?.fill_area_m2)) *
halfSpan;
const segmentFill = (previous.fill_area_m2 + current.fill_area_m2) * halfSpan;
const net = segmentCompacted - segmentFill;
cutCompacted += segmentCompacted;
const net = segmentCut - segmentFill;
cutCompacted += segmentCut;
fillCompacted += segmentFill;
cumulative += net;
minCumulative = Math.min(minCumulative, cumulative);
@@ -163,6 +226,100 @@ export function computeMassHaul(
};
}
/** 설계가 지정된 측점만 누가거리 순으로 모은다. 두 기준이 공통으로 쓰는 기준점 목록. */
function designedSections(crossSections: CrossSection[]): CrossSection[] {
return crossSections
.filter((section) => section.design && Number.isFinite(section.chainage_m))
.sort((a, b) => a.chainage_m - b.chainage_m);
}
/** 횡단 기준 — 엔진이 낸 실측 단면적을 그대로 쓴다. */
function crossAreaSamples(sections: CrossSection[]): AreaSample[] {
return sections.map((section) => ({
station_id: section.station_id,
chainage_m: section.chainage_m,
cut_area_m2: finiteArea(section.design?.cut_area_m2),
fill_area_m2: finiteArea(section.design?.fill_area_m2),
ground: normalizeGround(section.design?.ground_type),
}));
}
/**
* 종단 기준 — 계획고와 지반고의 차이(절토고/성토고)에 노반폭을 곱한 개략 단면적.
*
* `difference_m = 계획고 − 지반고`이므로 음수면 절토, 양수면 성토다
* (`B05_wf2_Route_UI_Profile_Alignment.ts`가 이미 쓰는 부호 규약과 같다).
* 노반폭과 지반유형은 종단 샘플에 없으므로 **가장 가까운 설계 측점**에서 가져온다.
* 설계된 측점이 하나도 없으면 노반폭을 알 수 없어 곡선을 만들지 않는다.
*/
function longitudinalAreaSamples(
longitudinal: LongitudinalSection,
sections: CrossSection[],
): AreaSample[] {
const profile = longitudinal.design_profiles?.[0];
if (!profile || !sections.length) return [];
const samples = profile.samples
.filter((sample) => Number.isFinite(sample.chainage_m) && Number.isFinite(sample.difference_m))
.sort((a, b) => a.chainage_m - b.chainage_m);
if (samples.length < 2) return [];
// 두 배열 모두 누가거리 순이라 포인터 하나로 최근접 측점을 훑는다.
let cursor = 0;
return samples.map((sample, index) => {
while (
cursor + 1 < sections.length &&
Math.abs(sections[cursor + 1].chainage_m - sample.chainage_m) <=
Math.abs(sections[cursor].chainage_m - sample.chainage_m)
) {
cursor += 1;
}
const nearest = sections[cursor];
const width = finiteArea(nearest.design?.roadbed_width_m);
const height = sample.difference_m;
return {
station_id: `${nearest.station_id}@${index}`,
chainage_m: sample.chainage_m,
cut_area_m2: height < 0 ? -height * width : 0,
fill_area_m2: height > 0 ? height * width : 0,
ground: normalizeGround(nearest.design?.ground_type),
};
});
}
/**
* 측점별 설계 결과에서 유토곡선을 계산한다(횡단 기준·지반유형별 환산).
* 확정 저장과 B08 인계에 쓰는 **정식 곡선**이라 시그니처를 그대로 유지한다.
*/
export function computeMassHaul(
crossSections: CrossSection[],
conversion: EarthworkConversion,
): MassHaulResult | null {
return integrate(crossAreaSamples(designedSections(crossSections)), conversion, "ground_type");
}
/**
* 비교용 곡선 묶음. 기준 2종 × 지반유형 처리 3종을 모두 계산한다.
* 계산이 불가능한 조합(설계 측점 부족, 종단 계획선 없음)은 조용히 빠진다.
*/
export function computeMassHaulSeries(
longitudinal: LongitudinalSection,
crossSections: CrossSection[],
conversion: EarthworkConversion,
): MassHaulSeries[] {
const sections = designedSections(crossSections);
const sampleSets: Record<MassHaulBasis, AreaSample[]> = {
cross: crossAreaSamples(sections),
longitudinal: longitudinalAreaSamples(longitudinal, sections),
};
const series: MassHaulSeries[] = [];
for (const { basis, groundMode } of SERIES_MATRIX) {
const result = integrate(sampleSets[basis], conversion, groundMode);
if (result)
series.push({ key: massHaulSeriesKey(basis, groundMode), basis, groundMode, result });
}
return series;
}
/** 확정 시 DB(`longitudinal_sections.data.mass_haul`)에 넣을 직렬화 형태로 정리한다. */
export function massHaulPayload(result: MassHaulResult): Record<string, unknown> {
const round = (value: number): number => Math.round(value * 100) / 100;
@@ -6,13 +6,22 @@
* (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 { MassHaulResult } from "./B06_wf3_ProfileCross_UI_MassHaul";
import type {
MassHaulBasis,
MassHaulGroundMode,
MassHaulSeries,
} from "./B06_wf3_ProfileCross_UI_MassHaul";
import type { LocaleKey } from "@ui/ui_template_locale";
import {
L,
LONG_PAD,
@@ -27,6 +36,26 @@ 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, {
@@ -38,16 +67,23 @@ export function formatVolume(value: number): string {
/**
* Y축 상·하한을 잡는다. 0선은 항상 보이도록 범위에 포함하고, 위아래로 5% 여유를 준다.
* 값이 전부 0이면 폭이 0이 되어 나눗셈이 깨지므로 최소 폭을 씌운다.
*
* 범위는 **표시 중인 곡선이 아니라 전체 곡선**에서 잡는다(축 고정).
*/
function volumeRange(result: MassHaulResult): { min: number; max: number } {
const rawMin = Math.min(0, result.min_cumulative_m3);
const rawMax = Math.max(0, result.max_cumulative_m3);
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 };
}
export function createMassHaulChart(
result: MassHaulResult,
series: MassHaulSeries[],
visibleKeys: ReadonlySet<string>,
longitudinal: LongitudinalSection,
selectedStationId: string | null,
stationInterval: number,
@@ -74,7 +110,7 @@ export function createMassHaulChart(
const plotHeight = heightPx - LONG_PAD.top - LONG_PAD.bottom;
const x = (chainage: number) => LONG_PAD.left + (chainage / maxChainage) * plotWidth;
const { min, max } = volumeRange(result);
const { min, max } = volumeRange(series);
const span = Math.max(max - min, 1e-6);
const y = (volume: number) => LONG_PAD.top + ((max - volume) / span) * plotHeight;
@@ -100,21 +136,25 @@ export function createMassHaulChart(
);
}
// 곡선과 0선 사이를 채워 절토 우세(위)·성토 우세(아래) 구간을 눈으로 가른다.
const zeroY = y(0);
const points = result.points;
const areaTop: string[] = [];
const areaBottom: string[] = [];
for (const point of points) {
areaTop.push(`${x(point.chainage_m)},${y(point.cumulative_volume_m3)}`);
areaBottom.unshift(`${x(point.chainage_m)},${zeroY}`);
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"),
}),
);
}
svg.append(
svgElement("polygon", {
points: [...areaTop, ...areaBottom].join(" "),
class: "b06-masshaul__band",
}),
);
// 측점선 — 종단도와 같은 자리에 서고, 눌러서 측점을 고를 수 있다(3자 선택 동기화).
// 라벨은 바로 위 종단면도가 이미 달고 있어 여기서는 생략한다(중복 표기 방지).
@@ -150,8 +190,8 @@ export function createMassHaulChart(
svg.append(marker);
}
// 0선 — 절토 우세와 성토 우세를 가르는 기준선이라 격자보다 진하게 그린다.
svg.append(
// 0선 — 절토 우세와 성토 우세를 가르는 기준선이라 격자보다 진하게 그린다.
svgElement("line", {
x1: LONG_PAD.left,
y1: zeroY,
@@ -159,12 +199,21 @@ export function createMassHaulChart(
y2: zeroY,
class: "b06-masshaul__zero",
}),
svgElement("polyline", {
points: points
.map((point) => `${x(point.chainage_m)},${y(point.cumulative_volume_m3)}`)
.join(" "),
class: "b06-masshaul__curve",
}),
);
// 뒤에 그린 곡선이 위로 오므로 범례 순서의 역순으로 그려 첫 곡선(정식)을 가장 위에 둔다.
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"),
}),
);
}
svg.append(
svgElement("line", {
x1: LONG_PAD.left,
y1: LONG_PAD.top,
@@ -189,8 +238,48 @@ export function createMassHaulChart(
return svg;
}
/** 곡선 아래 총괄 요약 줄. 절토는 자연상태(내역 기준)와 다짐환산(곡선 기준)을 함께 적는다. */
export function createMassHaulSummary(result: MassHaulResult): HTMLElement {
/**
* 그래프 상단에 겹쳐 놓는 표시/숨김 버튼 줄.
*
* 가로 스크롤 컨테이너 안이라 CSS에서 `position: sticky; left: 0`으로 왼쪽에 고정하고,
* `height: 0`으로 흐름 높이를 먹지 않게 해 곡선 위로 떠 있게 만든다(오버레이).
*/
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;
}
/**
* 곡선 아래 총괄 요약 줄. 절토는 자연상태(내역 기준)와 환산 후(곡선 기준)를 함께 적는다.
* 곡선이 여러 개라 수치가 어느 곡선의 것인지 끝에 반드시 밝힌다.
*/
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;
@@ -222,8 +311,8 @@ export function createMassHaulSummary(result: MassHaulResult): HTMLElement {
row.append(chip);
}
const basis = document.createElement("span");
basis.className = "b06-masshaul__basis";
basis.textContent = L("B06_MassHaul_Basis");
basis.className = seriesClass(series, "b06-masshaul__basis");
basis.textContent = massHaulSeriesLabel(series);
row.append(basis);
return row;
}
@@ -41,6 +41,7 @@ import {
} from "./B06_wf3_ProfileCross_UI_Standard_Panel";
import "./B06_wf3_ProfileCross_UI_Style.css";
import "./B06_wf3_ProfileCross_UI_Style_Cross.css";
import "./B06_wf3_ProfileCross_UI_Style_MassHaul.css";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
@@ -29,9 +29,14 @@ import {
createLongitudinalProfile,
longitudinalMinimumWidth,
} from "./B06_wf3_ProfileCross_UI_Longitudinal";
import { computeMassHaul, type MassHaulResult } from "./B06_wf3_ProfileCross_UI_MassHaul";
import {
computeMassHaulSeries,
MASS_HAUL_DEFAULT_VISIBLE,
type MassHaulSeries,
} from "./B06_wf3_ProfileCross_UI_MassHaul";
import {
createMassHaulChart,
createMassHaulLegend,
createMassHaulSummary,
MASS_HAUL_HEIGHT,
MASS_HAUL_MIN_HEIGHT,
@@ -62,6 +67,20 @@ const MIN_PANEL_HEIGHT = MIN_LONG_HEIGHT + MASS_HAUL_MIN_HEIGHT + PANEL_CHROME_P
const MAX_PANEL_HEIGHT_RATIO = 0.8;
const PANEL_HEIGHT_KEY = "b06:profile-panel-height";
const PANEL_COLLAPSED_KEY = "b06:profile-panel-collapsed";
const MASS_HAUL_VISIBLE_KEY = "b06:masshaul-visible";
/** 켜 둔 곡선 목록은 세션에만 남긴다(패널 높이·접힘과 같은 규칙). */
function readVisibleSeries(): Set<string> {
try {
const raw = sessionStorage.getItem(MASS_HAUL_VISIBLE_KEY);
if (!raw) return new Set(MASS_HAUL_DEFAULT_VISIBLE);
const parsed: unknown = JSON.parse(raw);
// 전부 끈 상태(빈 배열)도 사용자의 선택이므로 기본값으로 되돌리지 않는다.
return Array.isArray(parsed) ? new Set(parsed.map(String)) : new Set(MASS_HAUL_DEFAULT_VISIBLE);
} catch {
return new Set(MASS_HAUL_DEFAULT_VISIBLE);
}
}
/**
* 패널 높이를 두 그래프에 나눈다 (2026-08-02 사용자 지시).
@@ -126,6 +145,8 @@ export function createSectionView(
let renderWidth = 0;
let resizeTimer = 0;
let panelResizeTimer = 0;
// 유토곡선 표시 곡선 집합. 토글해도 Y 스케일은 전체 곡선 기준이라 축이 움직이지 않는다.
const visibleSeries = readVisibleSeries();
// 카드 단위 재빌드에 재사용하는 렌더 컨텍스트 (draw에서 갱신)
let cachedYScale: YScaleOptions | undefined;
let cachedStationInterval = 1;
@@ -218,6 +239,14 @@ export function createSectionView(
rockBoundary,
);
/** 범례 버튼 — 곡선 하나를 켜고 끈다. 축은 전체 곡선 기준이라 여기서 움직이지 않는다. */
const toggleSeries = (key: string): void => {
if (visibleSeries.has(key)) visibleSeries.delete(key);
else visibleSeries.add(key);
sessionStorage.setItem(MASS_HAUL_VISIBLE_KEY, JSON.stringify([...visibleSeries]));
drawPanel();
};
/** 상단 패널 내용(종단면도 + 유토곡선 + 요약)만 다시 그린다. */
function drawPanel(): void {
if (!currentDetail || !Number.isFinite(renderWidth) || renderWidth <= 0) return;
@@ -248,13 +277,19 @@ export function createSectionView(
),
];
const massHaul: MassHaulResult | null = currentConversion
? computeMassHaul(detail.cross_sections, currentConversion)
: null;
if (massHaul) {
nodes.push(
const series: MassHaulSeries[] = currentConversion
? computeMassHaulSeries(detail.longitudinal, detail.cross_sections, currentConversion)
: [];
if (series.length) {
// 범례는 곡선 위에 겹쳐야 해서 SVG와 한 상자에 넣는다(상자 CSS가 오버레이를 만든다).
const frame = document.createElement("div");
frame.className = "b06-masshaul__frame";
frame.style.minWidth = `${minWidth}px`;
frame.append(
createMassHaulLegend(series, visibleSeries, toggleSeries),
createMassHaulChart(
massHaul,
series,
visibleSeries,
detail.longitudinal,
selectedStationId,
cachedStationInterval,
@@ -264,17 +299,20 @@ export function createSectionView(
(stationId) => selectStation(stationId, true),
),
);
nodes.push(frame);
}
chartWrap.replaceChildren(...nodes);
panelTitle.textContent = `${L("B06_Profile_View_Longitudinal")} · ${L("B06_MassHaul_Title")}`;
const summary = panel.querySelector(".b06-masshaul__summary");
summary?.remove();
if (massHaul) {
// 요약 수치는 켜 둔 곡선 중 첫 번째 것 — 곡선이 여러 개라 어느 것인지 요약 끝에 밝힌다.
const summarySeries = series.find((entry) => visibleSeries.has(entry.key));
if (summarySeries) {
panelCount.textContent = "";
panelBody.append(createMassHaulSummary(massHaul));
panelBody.append(createMassHaulSummary(summarySeries));
} else {
panelCount.textContent = L("B06_MassHaul_Empty");
panelCount.textContent = L(series.length ? "B06_MassHaul_AllHidden" : "B06_MassHaul_Empty");
}
chartWrap.scrollLeft = keepScrollLeft;
}
@@ -97,61 +97,6 @@
overflow-x: auto;
}
/* ── 유토곡선 (Mass Haul Diagram) ─────────────────────────────────────
색상은 theme.css 변수만 쓴다. 누가토량 곡선은 참고 도면의 자주색에 대응하는
브랜드 보라(계획고 마커와 같은 계열)로 둔다. */
.b06-masshaul__curve {
fill: none;
stroke: var(--color-royal-amethyst);
stroke-width: 2;
stroke-linejoin: round;
}
/* 곡선과 0선 사이 — 절토 우세/성토 우세 구간을 한눈에 가르는 옅은 면. */
.b06-masshaul__band {
fill: var(--color-royal-amethyst);
opacity: 0.12;
stroke: none;
}
/* 0선: 절토 우세와 성토 우세의 경계라 격자보다 진하게. */
.b06-masshaul__zero {
stroke: var(--color-text-secondary);
stroke-width: 1.2;
stroke-dasharray: 4 3;
}
.b06-masshaul__summary {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-8) var(--spacing-16);
padding: var(--spacing-8) var(--spacing-16);
border-top: 1px solid var(--color-border);
color: var(--color-text-secondary);
font-size: var(--text-caption);
}
.b06-masshaul__chip {
display: inline-flex;
align-items: baseline;
gap: var(--spacing-8);
}
.b06-masshaul__chip em {
font-style: normal;
}
.b06-masshaul__chip strong {
color: var(--color-text-body);
font-variant-numeric: tabular-nums;
}
.b06-masshaul__basis {
margin-inline-start: auto;
color: var(--color-text-muted);
}
.b06-section__chart {
display: block;
max-width: none;
@@ -0,0 +1,142 @@
/* =============================================================================
* B06_wf3_ProfileCross_UI_Style_MassHaul.css
* 유토곡선(Mass Haul Diagram) 스타일 — 곡선·오버레이 범례·요약줄.
* 700줄 제한 대응으로 _UI_Style_Cross.css에서 분리(종·횡단 도면 본체는 원본 유지).
*
* 곡선이 여러 개라 읽는 규칙을 둘로 나눈다:
* 색 = 산출 기준 (횡단 = 브랜드 보라 / 종단 = 성공색 초록)
* 선모양 = 환산 방식 (실선 = 지반유형별 / 파선 = 토사 단일 / 점선 = 환산 없음)
* 색상은 theme.css 변수만 쓴다. 횡단 기준의 보라는 참고 도면의 자주색에 대응한다.
* ========================================================================== */
.b06-masshaul__frame {
position: relative;
width: 100%;
}
/* 범례는 그래프 위에 겹친다. `height: 0`이라 세로 흐름을 밀지 않아 곡선 위로 떠 있고,
가로 스크롤 컨테이너 안에서도 `sticky; left: 0`이라 옆으로 밀어도 왼쪽에 남는다. */
.b06-masshaul__legend {
position: sticky;
left: 0;
z-index: 2;
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: var(--spacing-4) var(--spacing-8);
height: 0;
padding-inline-start: var(--spacing-8);
}
.b06-masshaul__legend-item {
display: inline-flex;
align-items: center;
gap: var(--spacing-8);
padding: var(--spacing-4) var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-pills);
background: var(--color-surface-raised);
color: var(--color-text-body);
font-size: var(--text-caption);
line-height: 1.2;
cursor: pointer;
}
.b06-masshaul__legend-item.is-off {
color: var(--color-text-muted);
}
/* 스와치는 곡선을 그대로 축소한 것 — 색과 선모양이 그래프와 같아야 범례 구실을 한다. */
.b06-masshaul__legend-swatch {
width: 18px;
height: 0;
border-top: 2px solid var(--color-royal-amethyst);
}
.b06-masshaul__legend-item--longitudinal .b06-masshaul__legend-swatch {
border-top-color: var(--color-success);
}
.b06-masshaul__legend-item--soil_only .b06-masshaul__legend-swatch {
border-top-style: dashed;
}
.b06-masshaul__legend-item--natural .b06-masshaul__legend-swatch {
border-top-style: dotted;
}
.b06-masshaul__legend-item.is-off .b06-masshaul__legend-swatch {
border-top-color: var(--color-smoke);
}
.b06-masshaul__curve {
fill: none;
stroke: var(--color-royal-amethyst);
stroke-width: 2;
stroke-linejoin: round;
}
.b06-masshaul__curve--longitudinal {
stroke: var(--color-success);
}
.b06-masshaul__curve--soil_only {
stroke-width: 1.6;
stroke-dasharray: 7 4;
}
.b06-masshaul__curve--natural {
stroke-width: 1.6;
stroke-dasharray: 2 3;
}
/* 곡선과 0선 사이 — 절토 우세/성토 우세 구간을 한눈에 가르는 옅은 면.
여러 곡선에 겹쳐 칠하면 서로 가리므로 렌더러가 맨 앞 곡선 하나에만 붙인다. */
.b06-masshaul__band {
fill: var(--color-royal-amethyst);
opacity: 0.12;
stroke: none;
}
.b06-masshaul__band--longitudinal {
fill: var(--color-success);
}
/* 0선: 절토 우세와 성토 우세의 경계라 격자보다 진하게. */
.b06-masshaul__zero {
stroke: var(--color-text-secondary);
stroke-width: 1.2;
stroke-dasharray: 4 3;
}
.b06-masshaul__summary {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-8) var(--spacing-16);
padding: var(--spacing-8) var(--spacing-16);
border-top: 1px solid var(--color-border);
color: var(--color-text-secondary);
font-size: var(--text-caption);
}
.b06-masshaul__chip {
display: inline-flex;
align-items: baseline;
gap: var(--spacing-8);
}
.b06-masshaul__chip em {
font-style: normal;
}
.b06-masshaul__chip strong {
color: var(--color-text-body);
font-variant-numeric: tabular-nums;
}
/* 요약 수치가 어느 곡선의 것인지 밝히는 꼬리표. */
.b06-masshaul__basis {
margin-inline-start: auto;
color: var(--color-text-muted);
}
+15
View File
@@ -225,6 +225,21 @@ export const ui_locales_b2 = {
B06_MassHaul_Shortage: ["토취", "Borrow"],
/* 운반계획 도면은 다짐상태 기준으로 계산한다(국도건설공사 설계실무 요령). */
B06_MassHaul_Basis: ["다짐상태 기준", "Compacted basis"],
B06_MassHaul_AllHidden: [
"표시할 곡선을 하나 이상 켜 주세요.",
"Turn on at least one curve to display.",
],
/* 토량 산출 기준 — 임도의 설계 및 시설기준 2.다.(3)(나)는 종단 계획고를,
2.다.(4)(마)는 측점별 횡단 단면적을 각각 근거로 삼는다. */
B06_MassHaul_Basis_Cross: ["횡단 기준", "Cross-section basis"],
B06_MassHaul_Basis_Long: ["종단 기준", "Profile basis"],
/* 절토량에 물리는 토량환산계수 처리 방식 (암반 경계선 분리 전 비교용). */
B06_MassHaul_Ground_ByType: ["지반유형별 환산", "Per ground type"],
B06_MassHaul_Ground_SoilOnly: ["토사 단일 환산", "Soil factor only"],
B06_MassHaul_Ground_Natural: ["환산 없음", "No conversion"],
B06_MassHaul_Legend_Title: ["표시할 유토곡선", "Visible mass haul curves"],
B06_MassHaul_Legend_Show: ["표시", "Show"],
B06_MassHaul_Legend_Hide: ["숨김", "Hide"],
/* --- B06 측점 표준횡단 설계 지정 --- */
B06_Design_Ground_Legend: ["지반유형", "Ground type"],