feat(B06): 유토곡선(Mass Haul Diagram) 패널 — 종단면도와 한 슬라이드 패널로 결합

참고 도면 `13 유토곡선(울진 울진 대흥 산65 외2(3공구)).pdf` 분석 결과를 반영해
B06 종단면도 바로 아래에 유토곡선을 추가하고, 둘을 접이식 상단 슬라이드 패널
하나로 묶었다.

계산 (프론트 전담, 확정 시에만 영구 저장)
- `_UI_MassHaul.ts` 신설: 평균단면법으로 구간 토량을 만들고 토량환산계수로 기준을
  통일해 누가토량을 누적하는 순수 함수. DOM 의존 0이라 B08/B09가 재사용 가능.
- 기준상태는 다짐상태 — 운반거리 산정 시 모든 수량은 다짐상태로 환산하고 내역서
  수량은 자연상태로 한다(국도건설공사 설계실무 요령). 내역서용 자연상태 수량은
  cut_natural_m3로 함께 보존한다.
- 지반유형이 다른 구간은 각 측점이 자기 절반(단면적 × Δd / 2)을 자기 지반유형으로
  가져간다. 두 절반의 합이 평균단면법과 정확히 일치한다.
- 측점 조작마다 백엔드를 왕복하지 않고 메모리에서 계산하며, 확정 시점에만
  longitudinal_sections.data.mass_haul에 저장한다.

X축 정렬
- longitudinalMaxChainage()를 공용으로 신설해 종단 렌더러와 유토곡선이 같은 X
  매핑을 쓰게 했다. 두 SVG는 chart-wrap 하나를 공유해 가로 스크롤도 함께 움직인다.

패널 높이
- 아래 경계 리사이저로 사용자가 조절한다. 기본값 이상으로 키우면 종단도는 220px을
  지키고 늘어난 몫은 유토곡선이 먹으며, 기본값보다 줄이면 둘이 같은 비율로 작아진다.
- 종단도가 줄면 Y스케일도 그 높이로 다시 잡아 표고가 잘리지 않게 했다.

토량환산계수
- config_system.py 5-4-3절 신설(유일한 정의처). 표준품셈 암종별 범위를 B06 지반유형
  3종에 대응시킨 제안값이며 현장 조정이 전제라 env 없이 상수로 두었다.

검증: tsc 0건, ruff 통과, prettier 적용, 전 파일 700줄 이하.
계산 엔진 테스트 10종 전건 통과(평균단면법·다짐환산·지반유형 배분·비정규 측점
간격·정렬·중복 측점·계수 부재 폴백·페이로드 반올림). 서버 기동 및 번들 포함 확인.

Phase 2(평형선·운반토량 Q·평균운반거리 L·장비 배분·M.N)는 장비 거리 경계와 M.N
정의가 미확정이라 제외했다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-02 09:02:52 +09:00
co-authored by Claude Opus 5
parent 9ff360164c
commit f35ca7e1c9
13 changed files with 865 additions and 47 deletions
@@ -51,6 +51,18 @@ export type StandardCrossKey = "soil" | "rock" | "paved";
export type StandardCrossSection = Record<StandardCrossKey, StandardCrossGroup>;
/**
* 지반유형 하나의 토량환산계수(둘 다 자연상태 기준 배율).
* loose = 흐트러진 상태 / 자연상태(팽창률), compacted = 다져진 상태 / 자연상태(다짐률).
* 정의처는 config_system.EARTHWORK_CONVERSION_FACTORS 한 곳뿐이다.
*/
export interface EarthworkConversionFactor {
loose: number;
compacted: number;
}
export type EarthworkConversion = Record<GroundType, EarthworkConversionFactor>;
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<string, unknown>,
): Promise<SectionConfirmResponse> {
const body: Record<string, unknown> = {};
if (standardCrossSection) body.standard_cross_section = standardCrossSection;
if (crossPatches?.length) body.cross_patches = crossPatches;
if (massHaul) body.mass_haul = massHaul;
return requestJson<SectionConfirmResponse>(`/projects/${projectId}/sections/${routeId}/confirm`, {
method: "POST",
body: JSON.stringify(body),
@@ -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:
@@ -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:
@@ -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):
@@ -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)
@@ -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<string, unknown> {
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),
})),
};
}
@@ -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;
}
@@ -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<void> {
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<void> {
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]);
@@ -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,
@@ -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<string, number>();
/* ── 상단 슬라이드 패널(종단면도 + 유토곡선) ─────────────────────────
* 패널·리사이저는 한 번만 만들고 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();
},
};
@@ -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;
+29
View File
@@ -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)
#
+16
View File
@@ -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"],