diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul.ts index 87df1ec3..68cbf8db 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul.ts @@ -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 = { + 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 { const round = (value: number): number => Math.round(value * 100) / 100; diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_View.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_View.ts index 1c89a957..ce43dbb6 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_View.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_View.ts @@ -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 = { + cross: "B06_MassHaul_Basis_Cross", + longitudinal: "B06_MassHaul_Basis_Long", +}; + +const GROUND_MODE_LABEL: Record = { + 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, 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, + 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; } diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts index c7fb756c..c2392aef 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts @@ -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]; diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts index c6434dbb..78dadd57 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts @@ -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 { + 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; } diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_Cross.css b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_Cross.css index 5edc4a1d..d0755c10 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_Cross.css +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_Cross.css @@ -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; diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_MassHaul.css b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_MassHaul.css new file mode 100644 index 00000000..e3634cff --- /dev/null +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_MassHaul.css @@ -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); +} diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index 22a0c1d1..5f85f2c7 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -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"],