diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts index 01660763..fd2f8cae 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts @@ -51,6 +51,18 @@ export type StandardCrossKey = "soil" | "rock" | "paved"; export type StandardCrossSection = Record; +/** + * 지반유형 하나의 토량환산계수(둘 다 자연상태 기준 배율). + * loose = 흐트러진 상태 / 자연상태(팽창률), compacted = 다져진 상태 / 자연상태(다짐률). + * 정의처는 config_system.EARTHWORK_CONVERSION_FACTORS 한 곳뿐이다. + */ +export interface EarthworkConversionFactor { + loose: number; + compacted: number; +} + +export type EarthworkConversion = Record; + export interface SectionContextResponse { project_id: string; route_id: number | null; @@ -64,6 +76,8 @@ export interface SectionContextResponse { /** 암 경계선 기본 오프셋(m)과 상/하 제어 스텝(m). */ rock_boundary_default_offset_m: number; rock_boundary_step_m: number; + /** 지반유형별 토량환산계수. 유토곡선은 프론트가 이 값으로 계산한다. */ + earthwork_conversion: EarthworkConversion; } /** 종단 요약 조회 결과 (SectionSummaryResponse) */ @@ -342,10 +356,13 @@ export async function confirmSections( routeId: number, standardCrossSection?: StandardCrossSection, crossPatches?: CrossSectionPatch[], + /** 프론트에서 계산한 유토곡선 결과. 확정 시점에만 영구 저장한다. */ + massHaul?: Record, ): Promise { const body: Record = {}; if (standardCrossSection) body.standard_cross_section = standardCrossSection; if (crossPatches?.length) body.cross_patches = crossPatches; + if (massHaul) body.mass_haul = massHaul; return requestJson(`/projects/${projectId}/sections/${routeId}/confirm`, { method: "POST", body: JSON.stringify(body), diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Repository.py b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Repository.py index 522a4e6a..707c5649 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Repository.py +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Repository.py @@ -6,6 +6,7 @@ longitudinal_sections(종단면 1건), cross_sections(측점별 다건) 테이 """ import json +from collections.abc import Callable from pathlib import PurePosixPath from typing import Any from uuid import UUID @@ -561,19 +562,15 @@ async def merge_cross_section_design_patch( return True -async def merge_longitudinal_section_options( +async def _patch_latest_longitudinal_data( connection: aiomysql.Connection, - *, route_id: int, - options_patch: dict[str, Any], + apply_patch: Callable[[dict[str, Any]], None], ) -> bool: - """경로 최신 종단면 data.options에 patch를 병합 저장한다. + """경로 최신 종단면 행의 data(JSON)를 읽어 apply_patch로 고친 뒤 되쓴다. - 표준 횡단면 설정 등 확정 시점 옵션을 기존 생성 옵션 스냅샷을 보존한 채 갱신한다. - 갱신 여부를 반환한다. + 행이 없으면 아무것도 하지 않고 False를 반환한다. """ - if not options_patch: - return False async with connection.cursor() as cursor: await cursor.execute( """ @@ -592,11 +589,7 @@ async def merge_longitudinal_section_options( data = json.loads(data) if not isinstance(data, dict): data = {} - options = data.get("options") - if not isinstance(options, dict): - options = {} - options.update(options_patch) - data["options"] = options + apply_patch(data) await cursor.execute( "UPDATE longitudinal_sections SET data = %s WHERE id = %s", (json.dumps(data, ensure_ascii=False), int(row[0])), @@ -604,6 +597,47 @@ async def merge_longitudinal_section_options( return True +async def merge_longitudinal_section_options( + connection: aiomysql.Connection, + *, + route_id: int, + options_patch: dict[str, Any], +) -> bool: + """경로 최신 종단면 data.options에 patch를 병합 저장한다. + + 표준 횡단면 설정 등 확정 시점 옵션을 기존 생성 옵션 스냅샷을 보존한 채 갱신한다. + 갱신 여부를 반환한다. + """ + if not options_patch: + return False + + def apply_patch(data: dict[str, Any]) -> None: + options = data.get("options") + if not isinstance(options, dict): + options = {} + options.update(options_patch) + data["options"] = options + + return await _patch_latest_longitudinal_data(connection, route_id, apply_patch) + + +async def merge_longitudinal_section_data( + connection: aiomysql.Connection, + *, + route_id: int, + data_patch: dict[str, Any], +) -> bool: + """경로 최신 종단면 data의 **최상위 키**에 patch를 병합 저장한다. + + 생성 옵션이 아닌 산출 결과(유토곡선 등)를 보관하는 데 쓴다. 갱신 여부를 반환한다. + """ + if not data_patch: + return False + return await _patch_latest_longitudinal_data( + connection, route_id, lambda data: data.update(data_patch) + ) + + async def confirm_sections_for_route(connection: aiomysql.Connection, route_id: int) -> None: """경로의 종횡단면 상태를 CONFIRMED로 변경한다.""" async with connection.cursor() as cursor: diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py index e361066b..2c12a562 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py @@ -35,6 +35,7 @@ from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Repository import ( insert_cross_sections, list_recent_company_projects, merge_cross_section_design_patch, + merge_longitudinal_section_data, merge_longitudinal_section_options, update_cross_section_design, ) @@ -59,6 +60,7 @@ from common_util.common_util_surface_confirmation import get_surface_confirmatio from common_util.common_util_workflow_state import complete_stage, get_workflow_state from config.config_db import get_db_pool from config.config_system import ( + EARTHWORK_CONVERSION_FACTORS, FOREST_ROAD_MIN_WIDTH_M, SECTION_VERTICAL_EXAGGERATION, STANDARD_CROSS_SECTION, @@ -97,6 +99,7 @@ async def get_section_context(project_id: UUID) -> SectionContextResponse | JSON standard_cross_section=STANDARD_CROSS_SECTION, rock_boundary_default_offset_m=STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M, rock_boundary_step_m=STANDARD_ROCK_BOUNDARY_STEP_M, + earthwork_conversion=EARTHWORK_CONVERSION_FACTORS, ) except Exception: logger.exception("B06 종횡단 컨텍스트 조회 실패: project_id=%s", project_id) @@ -623,6 +626,13 @@ async def confirm_sections( route_id=route_id, options_patch={"standard_cross_section": request.standard_cross_section}, ) + # 유토곡선 결과는 생성 옵션이 아니므로 data.options가 아니라 최상위 키에 둔다. + if request and request.mass_haul: + await merge_longitudinal_section_data( + connection, + route_id=route_id, + data_patch={"mass_haul": request.mass_haul}, + ) # 프론트 세션 보관값(암 경계선 오프셋 등)을 측점별 design에 병합. if request and request.cross_patches: for patch_item in request.cross_patches: diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Schema.py b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Schema.py index 12582da1..7ad15ee0 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Schema.py +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Schema.py @@ -81,6 +81,9 @@ class SectionConfirmRequest(BaseModel): standard_cross_section: dict[str, Any] | None = None # 측점별 세션 보관값(암 경계선 오프셋 등) — 확정 시점에 일괄 DB 병합. cross_patches: list[CrossSectionPatch] | None = None + # 프론트가 계산한 유토곡선 결과. longitudinal_sections.data.mass_haul에 저장한다. + # 계산은 프론트 전담(네트워크 왕복 회피)이라 백엔드는 검산 없이 스냅샷으로 보관한다. + mass_haul: dict[str, Any] | None = None class SectionConfirmResponse(BaseModel): @@ -102,6 +105,17 @@ class SectionOptionDefaults(BaseModel): vertical_exaggeration: float +class EarthworkConversionFactor(BaseModel): + """지반유형 하나의 토량환산계수(둘 다 자연상태 기준 배율). + + loose = 흐트러진 상태 / 자연상태(팽창률), compacted = 다져진 상태 / 자연상태(다짐률). + 정의처는 config.config_system.EARTHWORK_CONVERSION_FACTORS 한 곳뿐이다. + """ + + loose: float = Field(..., gt=0) + compacted: float = Field(..., gt=0) + + class SectionContextResponse(BaseModel): """B06 진입 시 필요한 확정 경로 컨텍스트와 기본 옵션.""" @@ -117,6 +131,8 @@ class SectionContextResponse(BaseModel): # 암 경계선 기본 오프셋(m)과 상/하 제어 스텝(m). rock_boundary_default_offset_m: float = -0.5 rock_boundary_step_m: float = 0.1 + # 지반유형별 토량환산계수. 유토곡선 계산은 프론트가 이 값으로 수행한다. + earthwork_conversion: dict[str, EarthworkConversionFactor] = Field(default_factory=dict) class SectionSummaryResponse(BaseModel): diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Longitudinal.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Longitudinal.ts index 696706ef..2902c8dc 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Longitudinal.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Longitudinal.ts @@ -14,6 +14,7 @@ import { LONG_HEIGHT, LONG_PAD, LONG_WIDTH, + longitudinalMaxChainage, stationLabel, svgElement, svgText, @@ -176,7 +177,8 @@ export function createLongitudinalProfile( svg.style.minWidth = `${minimumWidthPx}px`; svg.append(svgElement("rect", { width: widthPx, height: heightPx, class: "b06-chart__bg" })); - const maxChainage = Math.max(data.length_m, samples[samples.length - 1]?.chainage_m ?? 1, 1); + // 유토곡선과 X축을 맞추려면 최댓값 계산이 한 곳이어야 한다(_UI_Section_Common). + const maxChainage = longitudinalMaxChainage(data); // 계획선이 지반선 밖으로 나가도 잘리지 않도록 세로 범위에 함께 반영한다. const elevations = samples .map((sample) => sample.elevation_m) diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul.ts new file mode 100644 index 00000000..87df1ec3 --- /dev/null +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul.ts @@ -0,0 +1,189 @@ +/* ============================================================================= + * B06_wf3_ProfileCross_UI_MassHaul.ts + * 유토곡선(Mass Haul Diagram) 계산 엔진 — 순수 함수, DOM 의존 없음. + * + * 측점별 절·성토 단면적(백엔드 `compute_cross_design` 산출값)에서 구간 토량을 만들고, + * 토량환산계수로 기준을 통일해 누가토량을 누적한다. 결과는 B08 수량산출·B09 견적이 + * 그대로 재사용할 수 있도록 계산과 렌더링을 분리해 둔다(렌더러는 `_UI_MassHaul_View`). + * + * ── 계산 기준: 다짐상태 ────────────────────────────────────────────── + * "운반거리의 산정 시에 모든 수량은 다짐상태로 환산하여 계산하고, 내역서에 적용하는 + * 수량은 자연상태로 한다." (2021년도 국도건설공사 설계실무 요령 / 표준품셈 계열) + * 유토곡선은 운반계획 도면이므로 다짐상태를 기준상태로 삼는다. 내역서용 자연상태 + * 수량은 `cut_natural_m3`로 함께 보존해 B08이 되돌려 쓸 수 있게 한다. + * + * 분석 근거: docs/raw/2026-08-02_유토곡선_3공구_분석.md + * ========================================================================== */ + +import type { + CrossSection, + EarthworkConversion, + GroundType, +} from "./B06_wf3_ProfileCross_Api_Fetch"; + +/** 지반유형별 토량(㎥). 도면 표기 EA(토사)/RR(리핑암)/BR(발파암)에 대응한다. */ +export interface GroundVolumes { + soil: number; + ripping_rock: number; + blasting_rock: number; +} + +/** 측점 하나의 유토곡선 좌표. */ +export interface MassHaulPoint { + station_id: string; + chainage_m: number; + /** 직전 측점부터 이 측점까지 구간의 순토량(다짐상태 기준, ㎥). 첫 측점은 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; + /** 최종 누가토량. 양수면 잉여(사토), 음수면 부족(토취). */ + final_cumulative_m3: number; + /** 최종 잉여 토량(사토 대상, ㎥). 부족하면 0. */ + surplus_m3: number; + /** 최종 부족 토량(토취 대상, ㎥). 잉여면 0. */ + shortage_m3: number; + min_cumulative_m3: number; + max_cumulative_m3: number; + /** 계산에 실제 적용한 환산계수 — 나중에 검산할 수 있도록 함께 보존한다. */ + conversion: EarthworkConversion; +} + +const GROUND_TYPES: GroundType[] = ["soil", "ripping_rock", "blasting_rock"]; + +/** 환산계수가 비어 있거나 값이 이상하면 1.0으로 떨어뜨려 계산이 멈추지 않게 한다. */ +function compactedFactor(conversion: EarthworkConversion, ground: GroundType): number { + const factor = conversion?.[ground]?.compacted; + return Number.isFinite(factor) && factor > 0 ? factor : 1; +} + +function emptyVolumes(): GroundVolumes { + return { soil: 0, ripping_rock: 0, blasting_rock: 0 }; +} + +function finiteArea(value: number | undefined): number { + return Number.isFinite(value) && (value as number) > 0 ? (value as number) : 0; +} + +/** + * 측점별 설계 결과에서 유토곡선을 계산한다. + * + * 구간 토량은 평균단면법으로 구한다. 지반유형은 측점마다 다를 수 있으므로, + * 각 측점이 자기 쪽 절반(`단면적 × Δd / 2`)을 자기 지반유형으로 가져간다. + * 두 절반을 합치면 `(A_i + A_i+1) / 2 × Δd`가 되어 평균단면법과 정확히 일치한다. + * + * 설계(`design`)가 없는 측점은 단면적을 모르므로 건너뛴다. 유효 측점이 2개 미만이면 + * 곡선을 만들 수 없어 null을 반환한다. + */ +export function computeMassHaul( + crossSections: CrossSection[], + conversion: EarthworkConversion, +): 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; + + const points: MassHaulPoint[] = [ + { + station_id: sections[0].station_id, + chainage_m: sections[0].chainage_m, + net_volume_m3: 0, + cumulative_volume_m3: 0, + }, + ]; + const cutNatural = emptyVolumes(); + let cutCompacted = 0; + let fillCompacted = 0; + let cumulative = 0; + let minCumulative = 0; + let maxCumulative = 0; + + for (let index = 1; index < sections.length; index += 1) { + const previous = sections[index - 1]; + const current = sections[index]; + const spanM = current.chainage_m - previous.chainage_m; + // 같은 위치에 측점이 겹쳐 들어오면 구간 부피가 0이라 곡선에 기여하지 않는다. + if (!(spanM > 0)) continue; + const halfSpan = spanM / 2; + + // 절토: 양 끝 측점이 각자 자기 지반유형으로 절반씩 가져간다. + let segmentCompacted = 0; + for (const end of [previous, current]) { + const design = end.design; + if (!design) continue; + const naturalM3 = finiteArea(design.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); + } + + // 성토: 설계 성토량이 곧 다짐상태 물량이라 환산하지 않는다. + const segmentFill = + (finiteArea(previous.design?.fill_area_m2) + finiteArea(current.design?.fill_area_m2)) * + halfSpan; + + const net = segmentCompacted - segmentFill; + cutCompacted += segmentCompacted; + fillCompacted += segmentFill; + cumulative += net; + minCumulative = Math.min(minCumulative, cumulative); + maxCumulative = Math.max(maxCumulative, cumulative); + points.push({ + station_id: current.station_id, + chainage_m: current.chainage_m, + net_volume_m3: net, + cumulative_volume_m3: cumulative, + }); + } + + if (points.length < 2) return null; + + return { + points, + cut_natural_m3: cutNatural, + cut_compacted_m3: cutCompacted, + fill_compacted_m3: fillCompacted, + final_cumulative_m3: cumulative, + surplus_m3: Math.max(cumulative, 0), + shortage_m3: Math.max(-cumulative, 0), + min_cumulative_m3: minCumulative, + max_cumulative_m3: maxCumulative, + conversion, + }; +} + +/** 확정 시 DB(`longitudinal_sections.data.mass_haul`)에 넣을 직렬화 형태로 정리한다. */ +export function massHaulPayload(result: MassHaulResult): Record { + const round = (value: number): number => Math.round(value * 100) / 100; + return { + basis: "compacted", + conversion: result.conversion, + cut_natural_m3: { + soil: round(result.cut_natural_m3.soil), + ripping_rock: round(result.cut_natural_m3.ripping_rock), + blasting_rock: round(result.cut_natural_m3.blasting_rock), + }, + cut_compacted_m3: round(result.cut_compacted_m3), + fill_compacted_m3: round(result.fill_compacted_m3), + final_cumulative_m3: round(result.final_cumulative_m3), + surplus_m3: round(result.surplus_m3), + shortage_m3: round(result.shortage_m3), + points: result.points.map((point) => ({ + station_id: point.station_id, + chainage_m: round(point.chainage_m), + net_volume_m3: round(point.net_volume_m3), + cumulative_volume_m3: round(point.cumulative_volume_m3), + })), + }; +} diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_View.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_View.ts new file mode 100644 index 00000000..f1fc03de --- /dev/null +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_View.ts @@ -0,0 +1,227 @@ +/* ============================================================================= + * B06_wf3_ProfileCross_UI_MassHaul_View.ts + * 유토곡선(Mass Haul Diagram) SVG 렌더러. + * + * 종단면도(`_UI_Longitudinal`) 바로 아래에 붙어 **같은 X 매핑**을 쓴다. 폭·좌우 여백 + * (LONG_PAD)·누가거리 최댓값(longitudinalMaxChainage)을 종단도와 동일하게 받아, + * 같은 측점이 두 그래프에서 같은 가로 위치에 오도록 맞춘다. + * + * 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 { + L, + LONG_PAD, + longitudinalMaxChainage, + stationLabel, + svgElement, + svgText, +} from "./B06_wf3_ProfileCross_UI_Section_Common"; + +/** 축 눈금이 읽히는 최소 높이. 이보다 낮아지면 그래프가 뭉개진다. */ +export const MASS_HAUL_MIN_HEIGHT = 90; +/** 패널을 건드리지 않았을 때의 유토곡선 높이. */ +export const MASS_HAUL_HEIGHT = 190; + +/** 누가토량 값 표기 — 천 단위 구분 + 소수점 1자리. */ +export function formatVolume(value: number): string { + return value.toLocaleString(undefined, { + minimumFractionDigits: 1, + maximumFractionDigits: 1, + }); +} + +/** + * 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); + const padding = Math.max((rawMax - rawMin) * 0.05, 1); + return { min: rawMin - padding, max: rawMax + padding }; +} + +export function createMassHaulChart( + result: MassHaulResult, + longitudinal: LongitudinalSection, + selectedStationId: string | null, + stationInterval: number, + widthPx: number, + heightPx: number, + minimumWidthPx: number, + onSelectStation: (stationId: string) => void, +): SVGSVGElement { + const svg = svgElement("svg", { + class: "b06-section__chart b06-masshaul", + width: widthPx, + height: heightPx, + viewBox: `0 0 ${widthPx} ${heightPx}`, + role: "img", + "aria-label": L("B06_MassHaul_Title"), + }); + svg.style.width = "100%"; + svg.style.minWidth = `${minimumWidthPx}px`; + svg.append(svgElement("rect", { width: widthPx, height: heightPx, class: "b06-chart__bg" })); + + // 종단면도와 완전히 같은 X 매핑 — 여백·최댓값 모두 종단 렌더러와 공유한다. + const maxChainage = longitudinalMaxChainage(longitudinal); + const plotWidth = widthPx - LONG_PAD.left - LONG_PAD.right; + 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 span = Math.max(max - min, 1e-6); + const y = (volume: number) => LONG_PAD.top + ((max - volume) / span) * plotHeight; + + for (const ratio of [0, 0.25, 0.5, 0.75, 1]) { + const gridY = LONG_PAD.top + ratio * plotHeight; + const value = max - ratio * span; + svg.append( + svgElement("line", { + x1: LONG_PAD.left, + y1: gridY, + x2: widthPx - LONG_PAD.right, + y2: gridY, + class: "b06-chart__grid", + }), + svgText(formatVolume(value), { + x: LONG_PAD.left - 9, + y: gridY + 4, + "text-anchor": "end", + class: "b06-chart__tick", + }), + ); + } + + // 곡선과 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}`); + } + svg.append( + svgElement("polygon", { + points: [...areaTop, ...areaBottom].join(" "), + class: "b06-masshaul__band", + }), + ); + + // 측점선 — 종단도와 같은 자리에 서고, 눌러서 측점을 고를 수 있다(3자 선택 동기화). + // 라벨은 바로 위 종단면도가 이미 달고 있어 여기서는 생략한다(중복 표기 방지). + for (const station of longitudinal.stations) { + const stationX = x(station.chainage_m); + const selected = station.station_id === selectedStationId; + const marker = svgElement("g", { + class: `b06-chart__station${selected ? " b06-chart__station--selected" : ""}`, + tabindex: "0", + role: "button", + "aria-label": `${stationLabel(station.chainage_m, stationInterval)} ${L("B06_MassHaul_Title")}`, + }); + marker.addEventListener("click", () => onSelectStation(station.station_id)); + marker.addEventListener("keydown", (event) => { + if (event.key === "Enter" || event.key === " ") onSelectStation(station.station_id); + }); + marker.append( + svgElement("line", { + x1: stationX, + y1: LONG_PAD.top, + x2: stationX, + y2: heightPx - LONG_PAD.bottom, + class: "b06-chart__station-hit", + }), + svgElement("line", { + x1: stationX, + y1: LONG_PAD.top, + x2: stationX, + y2: heightPx - LONG_PAD.bottom, + class: `b06-chart__station-line b06-chart__station-line--${selected ? "selected" : station.kind}`, + }), + ); + svg.append(marker); + } + + svg.append( + // 0선 — 절토 우세와 성토 우세를 가르는 기준선이라 격자보다 진하게 그린다. + svgElement("line", { + x1: LONG_PAD.left, + y1: zeroY, + x2: widthPx - LONG_PAD.right, + 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", + }), + svgElement("line", { + x1: LONG_PAD.left, + y1: LONG_PAD.top, + x2: LONG_PAD.left, + y2: heightPx - LONG_PAD.bottom, + class: "b06-chart__axis", + }), + svgText(L("B06_MassHaul_YAxis"), { + x: 15, + y: heightPx / 2, + "text-anchor": "middle", + transform: `rotate(-90 15 ${heightPx / 2})`, + class: "b06-chart__axis-label", + }), + svgText(L("B06_MassHaul_XAxis"), { + x: widthPx / 2, + y: heightPx - 4, + "text-anchor": "middle", + class: "b06-chart__axis-label", + }), + ); + return svg; +} + +/** 곡선 아래 총괄 요약 줄. 절토는 자연상태(내역 기준)와 다짐환산(곡선 기준)을 함께 적는다. */ +export function createMassHaulSummary(result: MassHaulResult): HTMLElement { + const row = document.createElement("div"); + row.className = "b06-masshaul__summary"; + const cut = result.cut_natural_m3; + const chips: Array<[string, string]> = [ + [ + L("B06_MassHaul_CutNatural"), + `${formatVolume(cut.soil + cut.ripping_rock + cut.blasting_rock)}㎥`, + ], + [L("B06_Design_Ground_Soil"), `${formatVolume(cut.soil)}㎥`], + [L("B06_Design_Ground_Ripping"), `${formatVolume(cut.ripping_rock)}㎥`], + [L("B06_Design_Ground_Blasting"), `${formatVolume(cut.blasting_rock)}㎥`], + [L("B06_MassHaul_CutCompacted"), `${formatVolume(result.cut_compacted_m3)}㎥`], + [L("B06_MassHaul_Fill"), `${formatVolume(result.fill_compacted_m3)}㎥`], + ]; + // 잉여와 부족은 동시에 성립하지 않으므로 해당하는 쪽만 보여 준다. + chips.push( + result.shortage_m3 > 0 + ? [L("B06_MassHaul_Shortage"), `${formatVolume(result.shortage_m3)}㎥`] + : [L("B06_MassHaul_Surplus"), `${formatVolume(result.surplus_m3)}㎥`], + ); + for (const [label, value] of chips) { + const chip = document.createElement("span"); + chip.className = "b06-masshaul__chip"; + const name = document.createElement("em"); + name.textContent = label; + const amount = document.createElement("strong"); + amount.textContent = value; + chip.append(name, amount); + row.append(chip); + } + const basis = document.createElement("span"); + basis.className = "b06-masshaul__basis"; + basis.textContent = L("B06_MassHaul_Basis"); + 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 48771cac..c7fb756c 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts @@ -33,6 +33,7 @@ import { createSectionView, type RockBoundaryControl, } from "./B06_wf3_ProfileCross_UI_Section_View"; +import { computeMassHaul, massHaulPayload } from "./B06_wf3_ProfileCross_UI_MassHaul"; import { designElevationAt } from "./B06_wf3_ProfileCross_UI_Section_Common"; import { createStandardPanel, @@ -336,7 +337,13 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { function renderSectionDetail(): void { if (sectionDetail) { showSectionView(); - sectionView.render(sectionDetail, verticalExaggeration(), crossHalfWidth(), stationInterval); + sectionView.render( + sectionDetail, + verticalExaggeration(), + crossHalfWidth(), + stationInterval, + context?.earthwork_conversion, + ); } } @@ -372,11 +379,17 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { rock_boundary_offset_m: offset, }), ); + // 유토곡선은 화면 표시 내내 프론트 메모리에만 있다가 확정 시점에만 영구 저장된다. + const massHaul = + sectionDetail && context?.earthwork_conversion + ? computeMassHaul(sectionDetail.cross_sections, context.earthwork_conversion) + : null; await confirmSections( projectId, currentRouteId, standardPanel?.getValues(), crossPatches.length ? crossPatches : undefined, + massHaul ? massHaulPayload(massHaul) : undefined, ); showToast(L("B06_Profile_Confirm_Success"), "success"); goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[4]); diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_Common.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_Common.ts index 2a8696c4..b8ebda3c 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_Common.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_Common.ts @@ -11,6 +11,7 @@ import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import type { CrossDesignChange } from "./B06_wf3_ProfileCross_UI_Cross_Design"; import type { DesignProfile, + LongitudinalSection, SectionDetailResponse, SectionSample, } from "./B06_wf3_ProfileCross_Api_Fetch"; @@ -92,7 +93,25 @@ export function designElevationAt( return last.elevation_m; } -export function calculateYScale(detail: SectionDetailResponse): YScaleOptions | undefined { +/** + * 종단 X축이 덮는 누가거리의 최댓값. + * + * 종단면도와 유토곡선이 **같은 X 매핑**을 써야 측점 위치가 어긋나지 않으므로, + * 두 렌더러가 이 함수 하나만 보게 한다(각자 계산하면 조용히 틀어진다). + */ +export function longitudinalMaxChainage(data: LongitudinalSection): number { + const samples = data.samples.filter(validElevation); + return Math.max(data.length_m, samples[samples.length - 1]?.chainage_m ?? 1, 1); +} + +/** + * 종·횡단 공통 Y스케일. `longHeightPx`는 종단면도의 실제 렌더 높이로, 패널 리사이즈로 + * 종단도가 줄면 그 높이를 넘겨야 표고 범위가 잘리지 않는다(기본은 고정 높이). + */ +export function calculateYScale( + detail: SectionDetailResponse, + longHeightPx: number = LONG_HEIGHT, +): YScaleOptions | undefined { const elevations = [ ...detail.longitudinal.samples.map((sample) => sample.elevation_m), ...detail.cross_sections.flatMap((section) => @@ -105,7 +124,7 @@ export function calculateYScale(detail: SectionDetailResponse): YScaleOptions | if (!elevations.length) return undefined; const globalMinElevation = Math.min(...elevations); const globalMaxElevation = Math.max(...elevations); - const plotHeight = LONG_HEIGHT - LONG_PAD.top - LONG_PAD.bottom; + const plotHeight = Math.max(longHeightPx - LONG_PAD.top - LONG_PAD.bottom, 1); return { pixelsPerMeter: plotHeight / Math.max(globalMaxElevation - globalMinElevation, 1), globalMinElevation, 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 d78c761e..cd6220a9 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts @@ -1,16 +1,25 @@ /* ============================================================================= * B06_wf3_ProfileCross_UI_Section_View.ts - * 종·횡단 도면 뷰 컨트롤러: 종단 렌더러·횡단 카드 렌더러를 조립하고, 측점 선택/스크롤, - * 반응형 폭 측정(ResizeObserver), 같은 행 높이 통일, 단건 카드 갱신을 관장한다. + * 종·횡단 도면 뷰 컨트롤러: 상단 패널(종단면도 + 유토곡선)과 횡단 카드 그리드를 조립하고, + * 측점 선택/스크롤, 반응형 폭 측정(ResizeObserver), 패널 높이 조절, 같은 행 높이 통일, + * 단건 카드 갱신을 관장한다. * * 700줄 제한 대응으로 렌더러 본체는 분리했다: - * - 공통 상수·유틸: `_UI_Section_Common` - * - 종단 렌더러: `_UI_Longitudinal` + * - 공통 상수·유틸: `_UI_Section_Common` + * - 종단 렌더러: `_UI_Longitudinal` + * - 유토곡선 계산: `_UI_MassHaul` + * - 유토곡선 렌더러: `_UI_MassHaul_View` * - 횡단 카드 렌더러: `_UI_Cross_View` * 외부(Page)에는 `createSectionView`와 `CrossDesignChange` 타입만 노출한다. * ========================================================================== */ -import type { CrossSection, SectionDetailResponse } from "./B06_wf3_ProfileCross_Api_Fetch"; +import { createPanelResizer } from "@ui/ui_template_resizer"; +import { attachCollapsible } from "@ui/ui_template_collapsible"; +import type { + CrossSection, + EarthworkConversion, + SectionDetailResponse, +} from "./B06_wf3_ProfileCross_Api_Fetch"; import type { RockBoundaryControl } from "./B06_wf3_ProfileCross_UI_Cross_Design"; import { createCrossSectionCard, @@ -20,6 +29,13 @@ import { createLongitudinalProfile, longitudinalMinimumWidth, } from "./B06_wf3_ProfileCross_UI_Longitudinal"; +import { computeMassHaul, type MassHaulResult } from "./B06_wf3_ProfileCross_UI_MassHaul"; +import { + createMassHaulChart, + createMassHaulSummary, + MASS_HAUL_HEIGHT, + MASS_HAUL_MIN_HEIGHT, +} from "./B06_wf3_ProfileCross_UI_MassHaul_View"; import { calculateYScale, CROSS_GRID_GAP, @@ -37,6 +53,49 @@ import { export type { CrossDesignChange, DesignChangeHandler, RockBoundaryControl }; +/** 헤더·요약줄·테두리가 먹는 세로 공간. 패널 높이에서 이만큼 빼야 그래프 몫이 된다. */ +const PANEL_CHROME_PX = 96; +/** 손대지 않았을 때의 패널 높이 — 이 값이 축소 비례의 기준(H₀)이 된다. */ +const BASE_PANEL_HEIGHT = LONG_HEIGHT + MASS_HAUL_HEIGHT + PANEL_CHROME_PX; +const MIN_LONG_HEIGHT = 110; +const MIN_PANEL_HEIGHT = MIN_LONG_HEIGHT + MASS_HAUL_MIN_HEIGHT + PANEL_CHROME_PX; +const MAX_PANEL_HEIGHT_RATIO = 0.8; +const PANEL_HEIGHT_KEY = "b06:profile-panel-height"; +const PANEL_COLLAPSED_KEY = "b06:profile-panel-collapsed"; + +/** + * 패널 높이를 두 그래프에 나눈다 (2026-08-02 사용자 지시). + * 기본값 이상으로 키우면 → 종단도는 현재 높이 그대로 두고 늘어난 몫은 유토곡선이 먹는다. + * 기본값보다 줄이면 → 두 그래프가 같은 비율로 함께 작아진다. + */ +function chartHeights(panelHeightPx: number): { long: number; mass: number } { + if (!Number.isFinite(panelHeightPx) || panelHeightPx <= 0) { + return { long: LONG_HEIGHT, mass: MASS_HAUL_HEIGHT }; + } + if (panelHeightPx >= BASE_PANEL_HEIGHT) { + return { + long: LONG_HEIGHT, + mass: Math.max(MASS_HAUL_MIN_HEIGHT, panelHeightPx - PANEL_CHROME_PX - LONG_HEIGHT), + }; + } + const ratio = panelHeightPx / BASE_PANEL_HEIGHT; + return { + long: Math.max(MIN_LONG_HEIGHT, Math.round(LONG_HEIGHT * ratio)), + mass: Math.max(MASS_HAUL_MIN_HEIGHT, Math.round(MASS_HAUL_HEIGHT * ratio)), + }; +} + +/** + * 종단 렌더러가 돌려준 `chart-wrap`에서 SVG만 꺼낸다. + * 종단도와 유토곡선이 **하나의 가로 스크롤 컨테이너**를 공유해야 X축 정렬이 유지된다 + * (각자 wrap을 가지면 스크롤이 따로 놀아 같은 측점이 어긋나 보인다). + */ +function unwrapChart(node: HTMLElement): Element { + return node.classList.contains("b06-section__chart-wrap") && node.firstElementChild + ? node.firstElementChild + : node; +} + export interface SectionViewController { root: HTMLElement; render: ( @@ -44,6 +103,7 @@ export interface SectionViewController { verticalExaggeration: number, crossHalfWidth?: number, stationInterval?: number, + earthworkConversion?: EarthworkConversion, ) => void; /** 측점 하나의 카드만 새로 만들어 교체한다 (전체 재렌더 없이 설계 변경 반영). */ refreshCard: (chainageM: number) => void; @@ -62,8 +122,10 @@ export function createSectionView( let currentExaggeration = 1; let currentCrossHalfWidth: number | undefined; let currentStationInterval: number | undefined; + let currentConversion: EarthworkConversion | undefined; let renderWidth = 0; let resizeTimer = 0; + let panelResizeTimer = 0; // 카드 단위 재빌드에 재사용하는 렌더 컨텍스트 (draw에서 갱신) let cachedYScale: YScaleOptions | undefined; let cachedStationInterval = 1; @@ -71,6 +133,53 @@ export function createSectionView( // 같은 행 카드는 같은 높이가 되도록 draw에서 측점별 행 높이를 계산해 둔다(단건 갱신도 이 값 재사용). const cachedRowHeight = new Map(); + /* ── 상단 슬라이드 패널(종단면도 + 유토곡선) ───────────────────────── + * 패널·리사이저는 한 번만 만들고 draw()에서는 차트 내용만 갈아 끼운다. + * 매 렌더마다 새로 만들면 리사이저 핸들과 접힘 상태가 초기화된다. */ + const panel = document.createElement("section"); + panel.className = "b06-section__panel ui-collapsible"; + const panelHeader = document.createElement("header"); + panelHeader.className = "ui-collapsible__title"; + const panelTitle = document.createElement("h3"); + const panelCount = document.createElement("span"); + panelHeader.append(panelTitle, panelCount); + const chartWrap = document.createElement("div"); + chartWrap.className = "b06-section__chart-wrap"; + const panelBody = document.createElement("div"); + panelBody.className = "b06-section__panel-body"; + panelBody.append(chartWrap); + panel.append(panelHeader, panelBody); + attachCollapsible(panel); + // 접힘 상태는 세션에만 남긴다(리사이저와 같은 규칙). + if (sessionStorage.getItem(PANEL_COLLAPSED_KEY) === "true") panel.classList.add("is-collapsed"); + // attachCollapsible보다 **뒤에** 붙여야 토글이 끝난 상태를 읽는다(헤더에 붙이면 토글 전 값이 저장된다). + panel.addEventListener("click", (event) => { + if (!(event.target as HTMLElement).closest(".ui-collapsible__title")) return; + sessionStorage.setItem(PANEL_COLLAPSED_KEY, String(panel.classList.contains("is-collapsed"))); + }); + + const panelResizer = createPanelResizer({ + axis: "vertical", + target: panel, + cssVar: "--b06-profile-height", + // 손잡이가 패널 아래쪽에 있으므로 아래로 끌면 커진다. + direction: 1, + min: MIN_PANEL_HEIGHT, + max: () => (root.parentElement?.clientHeight ?? window.innerHeight) * MAX_PANEL_HEIGHT_RATIO, + storageKey: PANEL_HEIGHT_KEY, + onResize: () => { + // 끄는 동안 매 프레임 SVG를 다시 그리면 손이 늦게 따라온다 — 멈춘 뒤 한 번만 그린다. + window.clearTimeout(panelResizeTimer); + panelResizeTimer = window.setTimeout(drawPanel, 120); + }, + }); + panel.append(panelResizer.root); + + const panelHeight = (): number => { + const raw = Number.parseFloat(getComputedStyle(panel).getPropertyValue("--b06-profile-height")); + return Number.isFinite(raw) && raw > 0 ? raw : BASE_PANEL_HEIGHT; + }; + const contentWidth = (): number => { // 요소가 DOM 밖(detached)이면 computed padding이 ""라 parseFloat이 NaN을 만든다. // NaN이 renderWidth로 흘러가면 종단 SVG 전 좌표가 NaN이 되므로 0으로 방어한다. @@ -107,34 +216,73 @@ export function createSectionView( rockBoundary, ); + /** 상단 패널 내용(종단면도 + 유토곡선 + 요약)만 다시 그린다. */ + function drawPanel(): void { + if (!currentDetail || !Number.isFinite(renderWidth) || renderWidth <= 0) return; + // 차트를 갈아 끼우면 가로 스크롤이 0으로 돌아간다 — 측점 버튼 하나 눌렀다고 + // 보던 자리가 맨 앞으로 튀면 못 쓴다. 위치를 잡아 뒀다 되돌린다. + const keepScrollLeft = chartWrap.scrollLeft; + const detail = currentDetail; + const heights = chartHeights(panelHeight()); + const minWidth = longitudinalMinimumWidth(detail.longitudinal, cachedStationInterval); + const chartWidth = Math.max(renderWidth, minWidth); + // 종단도 높이가 줄면 Y스케일도 그 높이로 다시 잡아야 표고가 잘리지 않는다. + cachedYScale = calculateYScale(detail, heights.long); + + const nodes: Element[] = [ + unwrapChart( + createLongitudinalProfile( + detail.longitudinal, + selectedStationId, + currentExaggeration, + cachedYScale, + (stationId) => selectStation(stationId, true), + cachedStationInterval, + chartWidth, + heights.long, + minWidth, + detail.longitudinal.design_profiles ?? [], + ), + ), + ]; + + const massHaul: MassHaulResult | null = currentConversion + ? computeMassHaul(detail.cross_sections, currentConversion) + : null; + if (massHaul) { + nodes.push( + createMassHaulChart( + massHaul, + detail.longitudinal, + selectedStationId, + cachedStationInterval, + chartWidth, + heights.mass, + minWidth, + (stationId) => selectStation(stationId, true), + ), + ); + } + 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) { + panelCount.textContent = ""; + panelBody.append(createMassHaulSummary(massHaul)); + } else { + panelCount.textContent = L("B06_MassHaul_Empty"); + } + chartWrap.scrollLeft = keepScrollLeft; + } + const draw = (): void => { if (!currentDetail || !Number.isFinite(renderWidth) || renderWidth <= 0) return; - root.replaceChildren(); const detail = currentDetail; - cachedYScale = calculateYScale(detail); cachedStationInterval = currentStationInterval ?? inferStationInterval(detail.longitudinal.stations); - - const longitudinalPanel = document.createElement("section"); - longitudinalPanel.className = "b06-section__panel"; - const longitudinalMinWidth = longitudinalMinimumWidth( - detail.longitudinal, - cachedStationInterval, - ); - longitudinalPanel.append( - createLongitudinalProfile( - detail.longitudinal, - selectedStationId, - currentExaggeration, - cachedYScale, - (stationId) => selectStation(stationId, true), - cachedStationInterval, - Math.max(renderWidth, longitudinalMinWidth), - LONG_HEIGHT, - longitudinalMinWidth, - detail.longitudinal.design_profiles ?? [], - ), - ); + drawPanel(); const crossHeading = document.createElement("div"); crossHeading.className = "b06-section__heading"; @@ -181,7 +329,10 @@ export function createSectionView( } else { grid.append(emptyView(L("B06_Profile_View_NoCross"))); } - root.append(longitudinalPanel, crossHeading, grid); + // panel은 이미 root의 자식이라 replaceChildren이 떼었다 붙이면서 스크롤을 잃는다. + const keepScrollLeft = chartWrap.scrollLeft; + root.replaceChildren(panel, crossHeading, grid); + chartWrap.scrollLeft = keepScrollLeft; }; const refreshCard = (chainageM: number): void => { @@ -194,6 +345,8 @@ export function createSectionView( // 단건 갱신은 draw에서 정해둔 행 높이를 재사용해 같은 행 카드와 높이를 유지한다. if (existing) existing.replaceWith(buildCrossCard(section, cachedRowHeight.get(section.station_id))); + // 단면적이 바뀌면 유토곡선도 함께 흔들리므로 상단 패널만 다시 그린다(카드 전체 재렌더 없음). + drawPanel(); }; const resizeObserver = new ResizeObserver(() => { @@ -210,13 +363,14 @@ export function createSectionView( return { root, - render(detail, verticalExaggeration, crossHalfWidth, stationInterval) { + render(detail, verticalExaggeration, crossHalfWidth, stationInterval, earthworkConversion) { currentDetail = detail; currentExaggeration = Math.max(verticalExaggeration, 0.1); currentCrossHalfWidth = crossHalfWidth !== undefined && crossHalfWidth > 0 ? crossHalfWidth : undefined; currentStationInterval = stationInterval !== undefined && stationInterval > 0 ? stationInterval : undefined; + if (earthworkConversion) currentConversion = earthworkConversion; selectedStationId ??= detail.longitudinal.stations[0]?.station_id ?? null; renderWidth = contentWidth(); draw(); @@ -230,6 +384,8 @@ export function createSectionView( }, dispose() { window.clearTimeout(resizeTimer); + window.clearTimeout(panelResizeTimer); + panelResizer.dispose(); resizeObserver.disconnect(); }, }; 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 f0d4b01c..034275bd 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_Cross.css +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_Cross.css @@ -26,10 +26,39 @@ background: var(--color-surface-raised); } +/* 상단 슬라이드 패널(종단면도 + 유토곡선). + 높이는 인라인이 아니라 CSS 변수로만 받는다 — 인라인 height는 접기 규칙을 이겨 + 패널이 접히지 않는다(ui_template_resizer.ts 주석). sticky는 그 자체로 위치 지정 + 요소라 리사이저 손잡이(absolute)의 기준이 되므로 position: relative가 따로 필요 없다. */ .b06-section__panel { position: sticky; z-index: 2; top: 0; + display: flex; + flex-direction: column; + height: var(--b06-profile-height, auto); +} + +/* 접었을 때는 저장된 높이를 무시하고 제목 행만 남긴다(클래스 2개라 위 규칙을 이긴다). */ +.b06-section__panel.is-collapsed { + height: auto; +} + +/* 공용 리사이저는 손잡이를 패널 위쪽 경계에 두지만(B05 하단 패널 기준), + 이 패널은 화면 위에 붙어 있으므로 아래쪽 경계로 옮긴다(아래로 끌면 커짐). */ +.b06-section__panel > .ui-resizer--vertical { + top: auto; + bottom: -3px; +} + +.b06-section__panel-body { + display: flex; + flex: 1; + flex-direction: column; + gap: var(--spacing-8); + min-height: 0; + padding-bottom: var(--spacing-8); + overflow: hidden; } .b06-section__panel > header, @@ -54,11 +83,72 @@ font-size: var(--text-body); } +/* 종단면도와 유토곡선이 이 컨테이너 하나를 공유한다 — 가로 스크롤을 함께 써야 + 같은 측점이 두 그래프에서 같은 자리에 선다. wrap을 나누면 정렬이 깨진다. */ .b06-section__chart-wrap { + display: flex; + flex: 1; + flex-direction: column; width: 100%; + min-height: 0; 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/config/config_system.py b/config/config_system.py index 45fed56f..f1161fad 100644 --- a/config/config_system.py +++ b/config/config_system.py @@ -414,6 +414,35 @@ SECTION_DITCH_SIDES = ("left", "right") SECTION_DITCH_TYPES = ("standard", "l_type") +# ───────────────────────────────────────────────────────────────────────── +# 5-4-3. 토량환산계수 (B06 유토곡선) +# +# loose(L) = 흐트러진 상태 토량 / 자연상태 토량 (팽창률) +# compacted(C) = 다져진 상태 토량 / 자연상태 토량 (다짐률) +# +# 유토곡선은 운반계획 도면이므로 **다짐상태 기준**으로 계산한다. +# "운반거리의 산정 시에 모든 수량은 다짐상태로 환산하여 계산하고, +# 내역서에 적용하는 수량은 자연상태로 한다." +# (2021년도 국도건설공사 설계실무 요령 / 2016 건설공사 표준품셈 계열) +# +# 아래 값은 표준품셈 토량환산계수표의 암종별 범위를 B06 지반유형 3종에 대응시킨 +# 제안값이며 임도 전용 고시 수치가 아니다. +# 토사 ← 풍화토(L 1.10~1.25 / C 0.80~0.90) ~ 점토(L 1.20~1.35 / C 0.75~0.90) +# 리핑암 ← 풍화암(L 1.30~1.35 / C 1.00~1.15) ~ 연암(L 1.30~1.50 / C 1.00~1.30) 하단 +# 발파암 ← 보통암(L 1.55~1.70 / C 1.20~1.40) 중앙 +# 표준품셈도 "토질 시험하여 적용하는 것을 원칙으로 하되 소량인 경우 환산계수표에 +# 따를 수 있다"고 하므로 현장별 조정이 전제다. 값을 바꾸려면 여기만 고치면 된다. +# +# 이 상수가 **토량환산계수의 유일한 정의처**다(타 파일 중복 정의 금지). +# 분석 근거: docs/raw/2026-08-02_유토곡선_3공구_분석.md 8절 +# ───────────────────────────────────────────────────────────────────────── +EARTHWORK_CONVERSION_FACTORS = { + "soil": {"loose": 1.25, "compacted": 0.90}, + "ripping_rock": {"loose": 1.35, "compacted": 1.15}, + "blasting_rock": {"loose": 1.60, "compacted": 1.30}, +} + + # ───────────────────────────────────────────────────────────────────────── # 5-5. 종단 계획선(계획고) 설계 기준 (B05 WF2) # diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index d629646d..22a0c1d1 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -210,6 +210,22 @@ export const ui_locales_b2 = { "No cross-section data to display.", ], + /* --- B06 유토곡선 (종단면도 아래 상단 패널) --- */ + B06_MassHaul_Title: ["유토곡선", "Mass Haul Diagram"], + B06_MassHaul_YAxis: ["누가토량 (㎥)", "Cumulative volume (㎥)"], + B06_MassHaul_XAxis: ["BP 기준 누적거리", "Chainage from BP"], + B06_MassHaul_Empty: [ + "측점에 지반유형을 지정하면 유토곡선이 표시됩니다.", + "The mass haul diagram appears once ground types are assigned to stations.", + ], + B06_MassHaul_CutNatural: ["절토(자연)", "Cut (natural)"], + B06_MassHaul_CutCompacted: ["절토(다짐환산)", "Cut (compacted)"], + B06_MassHaul_Fill: ["성토", "Fill"], + B06_MassHaul_Surplus: ["사토", "Surplus"], + B06_MassHaul_Shortage: ["토취", "Borrow"], + /* 운반계획 도면은 다짐상태 기준으로 계산한다(국도건설공사 설계실무 요령). */ + B06_MassHaul_Basis: ["다짐상태 기준", "Compacted basis"], + /* --- B06 측점 표준횡단 설계 지정 --- */ B06_Design_Ground_Legend: ["지반유형", "Ground type"], B06_Design_Ground_Soil: ["토사", "Soil"],