From ac4e22f623b6b4ad826efea27fed0285ee92bda7 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 2 Aug 2026 16:12:07 +0900 Subject: [PATCH] =?UTF-8?q?feat(B06):=20=EC=9C=A0=ED=86=A0=EA=B3=A1?= =?UTF-8?q?=EC=84=A0=20=ED=86=A0=EB=9F=89=20=EB=B6=84=EB=B0=B0(=ED=8F=89?= =?UTF-8?q?=ED=98=95=EC=84=A0=C2=B7=EC=9A=B4=EB=B0=98=20=EB=B8=94=EB=A1=9D?= =?UTF-8?q?=C2=B7=ED=8F=89=EA=B7=A0=EC=9A=B4=EB=B0=98=EA=B1=B0=EB=A6=AC)?= =?UTF-8?q?=20=EC=97=94=EC=A7=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 누가토량 곡선은 "얼마가 남는가"만 말한다. 어디 흙을 어디로 얼마나 옮기는지를 뽑아내는 계층을 얹었다 — 평형선, 운반 블록, 운반토량 Q, 평균운반거리 L, EA/RR/BR 안분, 장비 선정. 작도 규칙 근거는 clouds-daily.tistory.com/249 와 교과서 표준 작도법이다 (참고 도면 3공구의 수치·작도는 근거로 삼지 않았다). 평형선을 0선 하나로 고정하면 임도처럼 절토가 우세한 노선에서 사토가 전부 종점 한 덩어리로 몰린다. 그래서 곡선을 왼쪽부터 훑으며 커서를 옮기고, 성토가 절토를 다 못 받는 자리에서 평형선을 계단으로 옮긴다. 구간이 겹치지도 비지도 않고 단차의 합이 최종 누가토량과 정확히 맞는다. - 신규 _UI_MassHaul_Balance.ts: computeHaulPlan() 순수 계산기 - 신규 _UI_MassHaul_Balance_View.ts: 계단형 평형선 + 반종거 수평선 + 육각 balloon - MassHaulPoint에 cut_rr_m3/cut_br_m3 추가 (블록별 암종 안분 입력) - EARTHWORK_HAUL_EQUIPMENT_LIMITS_M을 context 응답으로 노출 (프론트 사본 없음) - massHaulPayload에 haul_plan 직렬화 (B08 인계) 검증: 수동 6종 + 무작위 100건에서 보존·안분합·L 범위·구간 무결성 성립. 렌더 스모크 4개 크기 NaN 0건. 영구저장소 프로젝트(26측점) 실측 시 블록 6개, 토취 75.4㎥가 최종 누가토량 -75.4㎥와 일치. Co-Authored-By: Claude Opus 5 --- .../B06_wf3_ProfileCross_Api_Fetch.ts | 11 + .../B06_wf3_ProfileCross_Router.py | 6 + .../B06_wf3_ProfileCross_Schema.py | 13 + .../B06_wf3_ProfileCross_UI_MassHaul.ts | 40 +- ...06_wf3_ProfileCross_UI_MassHaul_Balance.ts | 439 ++++++++++++++++++ ...3_ProfileCross_UI_MassHaul_Balance_View.ts | 326 +++++++++++++ .../B06_wf3_ProfileCross_UI_MassHaul_View.ts | 42 +- .../B06_wf3_ProfileCross_UI_Page.ts | 6 +- .../B06_wf3_ProfileCross_UI_Section_View.ts | 39 +- ...B06_wf3_ProfileCross_UI_Style_MassHaul.css | 98 ++++ ui_template/ui_template_locale_b2.ts | 12 + 11 files changed, 1013 insertions(+), 19 deletions(-) create mode 100644 B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Balance.ts create mode 100644 B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Balance_View.ts diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts index e3ce91d8..70ec9bbc 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts @@ -63,6 +63,15 @@ export interface EarthworkConversionFactor { export type EarthworkConversion = Record; +/** + * 평균운반거리 상한 하나(m). `max_distance_m: null`이면 상한 없음(나머지를 전부 받는다). + * 정의처는 config_system.EARTHWORK_HAUL_EQUIPMENT_LIMITS_M 한 곳뿐이라 프론트는 받아만 쓴다. + */ +export interface HaulEquipmentLimit { + key: string; + max_distance_m: number | null; +} + export interface SectionContextResponse { project_id: string; route_id: number | null; @@ -78,6 +87,8 @@ export interface SectionContextResponse { rock_boundary_step_m: number; /** 지반유형별 토량환산계수. 유토곡선은 프론트가 이 값으로 계산한다. */ earthwork_conversion: EarthworkConversion; + /** 평균운반거리별 운반장비 경계. 유토곡선의 토량 분배가 이 값으로 장비를 고른다. */ + haul_equipment_limits?: HaulEquipmentLimit[]; } /** 종단 요약 조회 결과 (SectionSummaryResponse) */ diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py index 2c12a562..67fb67e7 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py @@ -45,6 +45,7 @@ from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Schema import ( CompanyStandardResponse, CrossDesignRequest, CrossDesignResponse, + HaulEquipmentLimit, SectionConfirmRequest, SectionConfirmResponse, SectionContextResponse, @@ -61,6 +62,7 @@ from common_util.common_util_workflow_state import complete_stage, get_workflow_ from config.config_db import get_db_pool from config.config_system import ( EARTHWORK_CONVERSION_FACTORS, + EARTHWORK_HAUL_EQUIPMENT_LIMITS_M, FOREST_ROAD_MIN_WIDTH_M, SECTION_VERTICAL_EXAGGERATION, STANDARD_CROSS_SECTION, @@ -100,6 +102,10 @@ async def get_section_context(project_id: UUID) -> SectionContextResponse | JSON 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, + haul_equipment_limits=[ + HaulEquipmentLimit(key=key, max_distance_m=limit) + for key, limit in EARTHWORK_HAUL_EQUIPMENT_LIMITS_M + ], ) except Exception: logger.exception("B06 종횡단 컨텍스트 조회 실패: project_id=%s", project_id) diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Schema.py b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Schema.py index 7ad15ee0..81d99d39 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Schema.py +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Schema.py @@ -116,6 +116,17 @@ class EarthworkConversionFactor(BaseModel): compacted: float = Field(..., gt=0) +class HaulEquipmentLimit(BaseModel): + """평균운반거리 상한 하나(m). max_distance_m=None이면 상한 없음(나머지를 전부 받는다). + + 정의처는 config.config_system.EARTHWORK_HAUL_EQUIPMENT_LIMITS_M 한 곳뿐이다. + 프론트(유토곡선 토량 분배)는 이 값을 받아 쓰고 사본을 두지 않는다. + """ + + key: str + max_distance_m: float | None = None + + class SectionContextResponse(BaseModel): """B06 진입 시 필요한 확정 경로 컨텍스트와 기본 옵션.""" @@ -133,6 +144,8 @@ class SectionContextResponse(BaseModel): rock_boundary_step_m: float = 0.1 # 지반유형별 토량환산계수. 유토곡선 계산은 프론트가 이 값으로 수행한다. earthwork_conversion: dict[str, EarthworkConversionFactor] = Field(default_factory=dict) + # 평균운반거리별 운반장비 경계. 유토곡선의 토량 분배가 이 값으로 장비를 고른다. + haul_equipment_limits: list[HaulEquipmentLimit] = Field(default_factory=list) class SectionSummaryResponse(BaseModel): diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul.ts index e776945c..9e5f2b02 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul.ts @@ -40,6 +40,8 @@ import type { GroundType, LongitudinalSection, } from "./B06_wf3_ProfileCross_Api_Fetch"; +import type { HaulPlan } from "./B06_wf3_ProfileCross_UI_MassHaul_Balance"; +import { haulPlanPayload } from "./B06_wf3_ProfileCross_UI_MassHaul_Balance"; /** 지반유형별 토량(㎥). 도면 표기 EA(토사)/RR(리핑암)/BR(발파암)에 대응한다. */ export interface GroundVolumes { @@ -59,6 +61,13 @@ export interface MassHaulPoint { /** 구간 절토량(자연상태) — 토사분/암반분. 측점 선택 시 물량 표기에 그대로 쓴다. */ cut_soil_m3: number; cut_rock_m3: number; + /** + * 암반분을 암종까지 가른 값(자연상태, `cut_rr_m3 + cut_br_m3 === cut_rock_m3`). + * 운반 블록의 EA/RR/BR 안분(`_UI_MassHaul_Balance`)이 이 구간 단위를 되받아 쓴다 — + * 한 구간의 양 끝 측점이 서로 다른 암종일 수 있어 합계만으로는 되돌릴 수 없다. + */ + cut_rr_m3: number; + cut_br_m3: number; /** 구간 절토량(환산 후) — 곡선에 실제로 들어간 값. */ cut_compacted_m3: number; /** 구간 성토량(다짐상태 = 설계 물량). */ @@ -106,6 +115,12 @@ const SERIES_BASES: MassHaulBasis[] = ["cross", "longitudinal"]; */ export const MASS_HAUL_DEFAULT_VISIBLE: MassHaulBasis[] = ["cross"]; +/** + * 토량 분배(평형선) 레이어의 표시 토글 키. 곡선 기준(`MassHaulBasis`)이 아니라 곡선 **위에 + * 얹는 레이어**라 별도 키로 둔다 — 같은 범례 줄에서 함께 켜고 끈다. + */ +export const MASS_HAUL_BALANCE_KEY = "balance"; + /** 환산계수가 비어 있거나 값이 이상하면 1.0으로 떨어뜨려 계산이 멈추지 않게 한다. */ function factorFor(conversion: EarthworkConversion, ground: GroundType): number { const factor = conversion?.[ground]?.compacted; @@ -179,6 +194,8 @@ function integrate(samples: AreaSample[], conversion: EarthworkConversion): Mass cumulative_volume_m3: 0, cut_soil_m3: 0, cut_rock_m3: 0, + cut_rr_m3: 0, + cut_br_m3: 0, cut_compacted_m3: 0, fill_m3: 0, }, @@ -201,7 +218,8 @@ function integrate(samples: AreaSample[], conversion: EarthworkConversion): Mass // 절토: 양 끝 점이 각자 절반씩 가져가고, 각 점 안에서 토사분·암반분이 따로 환산된다. let segmentCut = 0; let segmentCutSoil = 0; - let segmentCutRock = 0; + let segmentCutRipping = 0; + let segmentCutBlasting = 0; for (const end of [previous, current]) { const soilM3 = end.cut_soil_area_m2 * halfSpan; const rockM3 = end.cut_rock_area_m2 * halfSpan; @@ -212,7 +230,8 @@ function integrate(samples: AreaSample[], conversion: EarthworkConversion): Mass } if (rockM3 > 0 && end.rock_kind) { cutNatural[end.rock_kind] += rockM3; - segmentCutRock += rockM3; + if (end.rock_kind === "blasting_rock") segmentCutBlasting += rockM3; + else segmentCutRipping += rockM3; segmentCut += rockM3 * factorFor(conversion, end.rock_kind); } } @@ -232,7 +251,9 @@ function integrate(samples: AreaSample[], conversion: EarthworkConversion): Mass net_volume_m3: net, cumulative_volume_m3: cumulative, cut_soil_m3: segmentCutSoil, - cut_rock_m3: segmentCutRock, + cut_rock_m3: segmentCutRipping + segmentCutBlasting, + cut_rr_m3: segmentCutRipping, + cut_br_m3: segmentCutBlasting, cut_compacted_m3: segmentCut, fill_m3: segmentFill, }); @@ -359,12 +380,19 @@ export function computeMassHaulSeries( return series; } -/** 확정 시 DB(`longitudinal_sections.data.mass_haul`)에 넣을 직렬화 형태로 정리한다. */ -export function massHaulPayload(result: MassHaulResult): Record { +/** + * 확정 시 DB(`longitudinal_sections.data.mass_haul`)에 넣을 직렬화 형태로 정리한다. + * 토량 분배(평형선)까지 냈으면 `haul_plan`으로 함께 실어 B08 수량·B09 견적이 되받게 한다. + */ +export function massHaulPayload( + result: MassHaulResult, + haulPlan?: HaulPlan | null, +): Record { const round = (value: number): number => Math.round(value * 100) / 100; return { basis: "compacted", conversion: result.conversion, + ...(haulPlan ? { haul_plan: haulPlanPayload(haulPlan) } : {}), cut_natural_m3: { soil: round(result.cut_natural_m3.soil), ripping_rock: round(result.cut_natural_m3.ripping_rock), @@ -383,6 +411,8 @@ export function massHaulPayload(result: MassHaulResult): Record // 구간 절토 내역(자연상태) — B08 내역서가 EA/RR/BR로 되받는 단위. cut_soil_m3: round(point.cut_soil_m3), cut_rock_m3: round(point.cut_rock_m3), + cut_rr_m3: round(point.cut_rr_m3), + cut_br_m3: round(point.cut_br_m3), fill_m3: round(point.fill_m3), })), }; diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Balance.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Balance.ts new file mode 100644 index 00000000..20cfbb92 --- /dev/null +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Balance.ts @@ -0,0 +1,439 @@ +/* ============================================================================= + * B06_wf3_ProfileCross_UI_MassHaul_Balance.ts + * 토량 분배(평형선) 계산 엔진 — 순수 함수, DOM 의존 없음. + * + * 누가토량 곡선(`_UI_MassHaul`)은 "얼마가 남는가"만 말한다. 이 파일은 그 곡선에서 + * **어디 흙을 어디로 얼마나 옮기는가**를 뽑아 낸다: 평형선 → 운반 블록 → 운반토량 `Q` → + * 평균운반거리 `L` → 지반유형 안분 → 장비 선정. + * + * ── 작도 규칙(참고자료) ───────────────────────────────────────────── + * + * - 상승 = 절토, 하강 = 성토. 극대·극소점 = 절·성토 경계. + * - 산 모양 = 좌→우 운반, 골 모양 = 우→좌 운반. + * - 평형선(수평선)이 곡선을 자르는 두 점 사이는 절토량 = 성토량. + * - 운반토량 `Q` = 평형선에서 곡선 정점까지의 종거. + * - 평균운반거리 `L` = **종거의 1/2 지점을 지나는 수평선 B′C′의 길이**(반종거 수평선). + * - 배분 3원칙 = ① 운반거리 최소 ② 높은 곳 → 낮은 곳 ③ 토량을 모아 운반. + * + * ── 평형선을 어떻게 자동으로 잡는가 ───────────────────────────────── + * 참고자료는 "가장 유리한 평형점을 도상에서 구한다"까지만 말한다. 이를 결정론으로 옮길 때 + * **0선 하나로 고정하면 안 된다** — 임도는 절토가 크게 우세해 사토가 전부 종점 한 덩어리로 + * 몰린다. 그래서 곡선을 왼쪽부터 훑으며 평형선 높이를 **계단으로 올린다**: + * + * 커서(곡선 위, 높이 = 현재 평형선)에서 다음 극값을 본다. + * - 올라갔다가(절토) 다시 현재 높이 **아래**까지 내려오면 → 성토가 절토를 다 받는다. + * 평형선은 그대로, 블록은 [커서 … 곡선이 평형선으로 되돌아온 점]. + * - 올라갔다가 현재 높이 **위**에서 멈추면 → 남는 절토가 생긴다. 평형선을 그 골 높이로 + * 올리고, 못 받은 만큼을 **그 자리에서** 사토로 계상한다. + * - 내려가는 경우는 정확히 대칭(토취). + * + * 이렇게 하면 ① 구간이 겹치거나 비지 않고 ② 사토·토취가 발생 지점에 남으며 + * ③ 단차의 합이 최종 누가토량과 정확히 일치한다. + * + * ── 단위 ───────────────────────────────────────────────────────── + * 곡선이 다짐상태 기준이므로 `Q`·EA/RR/BR 안분도 **다짐상태**다. 내역서용 자연상태 수량은 + * `MassHaulResult.cut_natural_m3` / 측점별 `cut_soil_m3`·`cut_rr_m3`·`cut_br_m3`가 따로 든다. + * ========================================================================== */ + +import type { EarthworkConversion, GroundType } from "./B06_wf3_ProfileCross_Api_Fetch"; +import type { MassHaulPoint, MassHaulResult } from "./B06_wf3_ProfileCross_UI_MassHaul"; + +/** 운반거리 상한 하나(㎥ 아님, m). `max_distance_m: null`이면 상한 없음(최종 후보). */ +export interface HaulEquipmentLimit { + key: string; + max_distance_m: number | null; +} + +/** 운반 방향. 산 모양이면 좌→우(`forward`), 골 모양이면 우→좌(`backward`). */ +export type HaulDirection = "forward" | "backward"; + +export interface HaulBlock { + /** 1부터. 도면 balloon의 블록 번호. */ + index: number; + /** 이 블록의 평형선 높이(누가토량 축, ㎥). */ + base_m3: number; + /** 평형선이 곡선을 자르는 두 점(누가거리 m). */ + from_m: number; + to_m: number; + /** 정점(극대/극소) 위치와 값 — balloon 지시선 앵커. */ + apex_m: number; + apex_volume_m3: number; + /** 운반토량 `Q`(㎥, 다짐상태) = 종거. */ + volume_m3: number; + /** 평균운반거리 `L`(m) = 반종거 수평선 길이. */ + haul_distance_m: number; + /** 반종거 수평선의 양 끝(누가거리 m) — 렌더러가 그 선을 그대로 그린다. */ + haul_from_m: number; + haul_to_m: number; + direction: HaulDirection; + /** `Q`를 절토 구간의 지반유형 구성비로 안분한 값(㎥, 다짐상태). 합 = `volume_m3`. */ + ea_m3: number; + rr_m3: number; + br_m3: number; + /** config 경계로 고른 장비 키. 경계값을 못 받으면 null(프론트에 사본을 두지 않는다). */ + equipment: string | null; +} + +/** 받아 줄 짝이 없어 남은 토량. `spoil` = 사토(잉여), `borrow` = 토취(부족). */ +export interface HaulResidual { + kind: "spoil" | "borrow"; + from_m: number; + to_m: number; + volume_m3: number; + /** 평형선이 이 잔량만큼 계단으로 옮겨 간다 — 렌더러가 단차를 그대로 그린다. */ + level_from_m3: number; + level_to_m3: number; +} + +/** 계단형 평형선 한 칸. 렌더러가 칸 사이를 수직선으로 이어 계단을 만든다. */ +export interface BalanceStep { + from_m: number; + to_m: number; + level_m3: number; +} + +export interface HaulPlan { + blocks: HaulBlock[]; + residuals: HaulResidual[]; + steps: BalanceStep[]; + spoil_m3: number; + borrow_m3: number; + /** 운반 블록 토량 합계(㎥) — 실제로 장비가 옮기는 양. */ + hauled_m3: number; +} + +const EPSILON = 1e-9; + +/** + * 이보다 작은 진동은 블록으로 세지 않는다. 측점 하나짜리 요철까지 블록을 만들면 balloon이 + * 수십 개 깔려 도면을 못 읽는다. 곡선 진폭 대비 비율이라 노선 규모에 자동으로 맞는다. + */ +const MIN_SWING_RATIO = 0.02; + +function factorFor(conversion: EarthworkConversion, ground: GroundType): number { + const factor = conversion?.[ground]?.compacted; + return Number.isFinite(factor) && factor > 0 ? factor : 1; +} + +/** 곡선을 선형보간해 임의 위치의 누가토량을 읽는다(양 끝은 클램프). */ +function valueAt(points: MassHaulPoint[], x: number): number { + const last = points[points.length - 1]; + if (x <= points[0].chainage_m) return points[0].cumulative_volume_m3; + if (x >= last.chainage_m) return last.cumulative_volume_m3; + for (let index = 1; index < points.length; index += 1) { + const a = points[index - 1]; + const b = points[index]; + if (x > b.chainage_m) continue; + const span = b.chainage_m - a.chainage_m; + if (span <= 0) return b.cumulative_volume_m3; + const ratio = (x - a.chainage_m) / span; + return a.cumulative_volume_m3 + (b.cumulative_volume_m3 - a.cumulative_volume_m3) * ratio; + } + return last.cumulative_volume_m3; +} + +/** + * `[fromX, toX]`를 왼쪽부터 훑어 곡선이 `level`을 **처음** 지나는 x. 없으면 null. + * 평형선·반종거 수평선의 교점이 전부 이 함수 하나로 나온다. + */ +function crossFrom( + points: MassHaulPoint[], + fromX: number, + toX: number, + level: number, +): number | null { + if (!(toX > fromX)) return null; + for (let index = 1; index < points.length; index += 1) { + const x0 = Math.max(points[index - 1].chainage_m, fromX); + const x1 = Math.min(points[index].chainage_m, toX); + if (!(x1 > x0)) continue; + const y0 = valueAt(points, x0); + const y1 = valueAt(points, x1); + if ((y0 - level) * (y1 - level) > 0) continue; + if (Math.abs(y1 - y0) < EPSILON) return x0; + return x0 + ((level - y0) / (y1 - y0)) * (x1 - x0); + } + return null; +} + +/** 곡선의 극값 인덱스 수열(양 끝점 포함). 기울기 부호가 바뀌는 자리가 극값이다. */ +function extremaIndices(points: MassHaulPoint[]): number[] { + const list = [0]; + let sign = 0; + for (let index = 1; index < points.length; index += 1) { + const delta = points[index].cumulative_volume_m3 - points[index - 1].cumulative_volume_m3; + if (Math.abs(delta) < EPSILON) continue; + const next = delta > 0 ? 1 : -1; + if (sign !== 0 && next !== sign && list[list.length - 1] !== index - 1) list.push(index - 1); + sign = next; + } + if (list[list.length - 1] !== points.length - 1) list.push(points.length - 1); + return list; +} + +/** + * 잔진동 제거. 진폭이 `minSwing` 미만인 극값은 **짝이 되는 이웃과 함께** 지운다 — + * 하나만 지우면 극대·극소 교대가 깨져 뒤 계산이 방향을 잃는다. 양 끝점은 남긴다. + */ +function pruneExtrema(points: MassHaulPoint[], indices: number[], minSwing: number): number[] { + const valueOf = (index: number): number => points[index].cumulative_volume_m3; + let list = indices.slice(); + let changed = true; + while (changed && list.length > 2) { + changed = false; + for (let i = 1; i < list.length - 1; i += 1) { + const left = Math.abs(valueOf(list[i]) - valueOf(list[i - 1])); + const right = Math.abs(valueOf(list[i]) - valueOf(list[i + 1])); + if (Math.min(left, right) >= minSwing) continue; + const partner = left <= right ? i - 1 : i + 1; + const drop = new Set([i]); + if (partner > 0 && partner < list.length - 1) drop.add(partner); + list = list.filter((_, position) => !drop.has(position)); + changed = true; + break; + } + } + return list; +} + +interface CutMix { + ea: number; + rr: number; + br: number; +} + +/** + * `[fromX, toX]` 절토 구간의 지반유형 구성(다짐상태 가중치). 구간이 측점 사이를 반만 + * 물면 그 비율만큼만 센다 — 평형선 교점은 측점에 딱 떨어지지 않는다. + */ +function cutMix( + points: MassHaulPoint[], + fromX: number, + toX: number, + conversion: EarthworkConversion, +): CutMix { + const mix: CutMix = { ea: 0, rr: 0, br: 0 }; + for (let index = 1; index < points.length; index += 1) { + const a = points[index - 1].chainage_m; + const b = points[index].chainage_m; + const span = b - a; + if (span <= 0) continue; + const overlap = Math.min(b, toX) - Math.max(a, fromX); + if (overlap <= 0) continue; + const ratio = overlap / span; + mix.ea += points[index].cut_soil_m3 * ratio * factorFor(conversion, "soil"); + mix.rr += points[index].cut_rr_m3 * ratio * factorFor(conversion, "ripping_rock"); + mix.br += points[index].cut_br_m3 * ratio * factorFor(conversion, "blasting_rock"); + } + return mix; +} + +/** 구성비로 `Q`를 안분한다. 구간에 절토 기록이 전혀 없으면 지반유형 미상 = 토사로 둔다. */ +function apportion(mix: CutMix, volume: number): Pick { + const total = mix.ea + mix.rr + mix.br; + if (!(total > 0)) return { ea_m3: volume, rr_m3: 0, br_m3: 0 }; + return { + ea_m3: (mix.ea / total) * volume, + rr_m3: (mix.rr / total) * volume, + br_m3: (mix.br / total) * volume, + }; +} + +/** + * 평균운반거리에 맞는 장비를 고른다. 경계 정의처는 `config_system.EARTHWORK_HAUL_EQUIPMENT_LIMITS_M` + * **한 곳뿐**이라, 값을 못 받았으면 프론트에 사본을 두지 않고 그냥 비운다. + */ +function pickEquipment(limits: HaulEquipmentLimit[] | undefined, distanceM: number): string | null { + if (!limits?.length) return null; + for (const limit of limits) { + if (limit.max_distance_m === null || distanceM <= limit.max_distance_m) return limit.key; + } + return limits[limits.length - 1].key; +} + +/** + * 누가토량 곡선에서 토량 분배(평형선·운반 블록·사토/토취)를 뽑는다. + * 블록도 잔량도 안 나오면(평탄한 곡선) null. + */ +export function computeHaulPlan( + result: MassHaulResult, + limits: HaulEquipmentLimit[] | undefined, +): HaulPlan | null { + const points = result.points; + if (points.length < 2) return null; + + const range = Math.max(result.max_cumulative_m3 - result.min_cumulative_m3, 0); + const minSwing = Math.max(range * MIN_SWING_RATIO, 1); + const extrema = pruneExtrema(points, extremaIndices(points), minSwing); + + const blocks: HaulBlock[] = []; + const residuals: HaulResidual[] = []; + const steps: BalanceStep[] = []; + + const pushBlock = ( + fromM: number, + toM: number, + base: number, + apexX: number, + apexY: number, + direction: HaulDirection, + cutFrom: number, + cutTo: number, + ): void => { + const volume = Math.abs(apexY - base); + if (!(volume > EPSILON) || !(toM > fromM)) return; + // 반종거 수평선 — 종거의 절반 높이에서 곡선을 자른 두 점 사이가 평균운반거리다. + const half = direction === "forward" ? base + volume / 2 : base - volume / 2; + const haulFrom = crossFrom(points, fromM, apexX, half) ?? fromM; + const haulTo = crossFrom(points, apexX, toM, half) ?? toM; + const distance = Math.max(haulTo - haulFrom, 0); + blocks.push({ + index: blocks.length + 1, + base_m3: base, + from_m: fromM, + to_m: toM, + apex_m: apexX, + apex_volume_m3: apexY, + volume_m3: volume, + haul_distance_m: distance, + haul_from_m: haulFrom, + haul_to_m: haulTo, + direction, + equipment: pickEquipment(limits, distance), + ...apportion(cutMix(points, cutFrom, cutTo, result.conversion), volume), + }); + steps.push({ from_m: fromM, to_m: toM, level_m3: base }); + }; + + const pushResidual = (fromM: number, toM: number, from: number, to: number): void => { + const delta = to - from; + if (Math.abs(delta) < EPSILON) return; + residuals.push({ + kind: delta > 0 ? "spoil" : "borrow", + from_m: fromM, + to_m: toM, + volume_m3: Math.abs(delta), + level_from_m3: from, + level_to_m3: to, + }); + }; + + let level = points[0].cumulative_volume_m3; + let cursor = points[0].chainage_m; + let k = 1; + + while (k < extrema.length) { + const apexX = points[extrema[k]].chainage_m; + const apexY = points[extrema[k]].cumulative_volume_m3; + if (apexX <= cursor + EPSILON) { + k += 1; + continue; + } + const rising = apexY > level + EPSILON; + const falling = apexY < level - EPSILON; + if (!rising && !falling) { + cursor = apexX; + k += 1; + continue; + } + + // 짝이 되는 다음 극값이 없으면 받아 줄 성토(또는 절토)가 없다 — 전량 사토/토취. + if (k + 1 >= extrema.length) { + steps.push({ from_m: cursor, to_m: apexX, level_m3: level }); + pushResidual(cursor, apexX, level, apexY); + level = apexY; + cursor = apexX; + k += 1; + continue; + } + const nextX = points[extrema[k + 1]].chainage_m; + const nextY = points[extrema[k + 1]].cumulative_volume_m3; + + if (rising) { + if (nextY <= level + EPSILON) { + // 성토가 절토를 다 받는다 — 평형선 그대로, 곡선이 되돌아온 점에서 블록을 닫는다. + const end = crossFrom(points, apexX, nextX, level) ?? nextX; + pushBlock(cursor, end, level, apexX, apexY, "forward", cursor, apexX); + cursor = end; + k += 1; + } else { + // 못 받은 절토가 남는다 — 평형선을 그 골 높이로 올리고 차액을 사토로 계상한다. + const start = crossFrom(points, cursor, apexX, nextY) ?? cursor; + steps.push({ from_m: cursor, to_m: start, level_m3: level }); + pushResidual(cursor, start, level, nextY); + pushBlock(start, nextX, nextY, apexX, apexY, "forward", start, apexX); + level = nextY; + cursor = nextX; + k += 2; + } + } else if (nextY >= level - EPSILON) { + // 골 — 오른쪽 절토가 왼쪽 성토를 채운다(우→좌 운반). + const end = crossFrom(points, apexX, nextX, level) ?? nextX; + pushBlock(cursor, end, level, apexX, apexY, "backward", apexX, end); + cursor = end; + k += 1; + } else { + // 절토가 모자라 성토를 못 채운다 — 평형선을 내리고 차액을 토취로 계상한다. + const start = crossFrom(points, cursor, apexX, nextY) ?? cursor; + steps.push({ from_m: cursor, to_m: start, level_m3: level }); + pushResidual(cursor, start, level, nextY); + pushBlock(start, nextX, nextY, apexX, apexY, "backward", apexX, nextX); + level = nextY; + cursor = nextX; + k += 2; + } + } + + // 극값 가지치기로 잘려 나간 꼬리가 있으면 총량이 어긋난다 — 종점 잔량으로 흡수한다. + const endX = points[points.length - 1].chainage_m; + const endY = points[points.length - 1].cumulative_volume_m3; + if (Math.abs(endY - level) > EPSILON) { + steps.push({ from_m: cursor, to_m: endX, level_m3: level }); + pushResidual(cursor, endX, level, endY); + } + + if (!blocks.length && !residuals.length) return null; + let spoil = 0; + let borrow = 0; + for (const residual of residuals) { + if (residual.kind === "spoil") spoil += residual.volume_m3; + else borrow += residual.volume_m3; + } + return { + blocks, + residuals, + steps, + spoil_m3: spoil, + borrow_m3: borrow, + hauled_m3: blocks.reduce((sum, block) => sum + block.volume_m3, 0), + }; +} + +/** 확정 저장·B08 인계용 직렬화. 값은 소수 둘째 자리에서 끊는다. */ +export function haulPlanPayload(plan: HaulPlan): Record { + const round = (value: number): number => Math.round(value * 100) / 100; + return { + spoil_m3: round(plan.spoil_m3), + borrow_m3: round(plan.borrow_m3), + hauled_m3: round(plan.hauled_m3), + blocks: plan.blocks.map((block) => ({ + index: block.index, + from_m: round(block.from_m), + to_m: round(block.to_m), + base_m3: round(block.base_m3), + volume_m3: round(block.volume_m3), + haul_distance_m: round(block.haul_distance_m), + direction: block.direction, + equipment: block.equipment, + ea_m3: round(block.ea_m3), + rr_m3: round(block.rr_m3), + br_m3: round(block.br_m3), + })), + residuals: plan.residuals.map((residual) => ({ + kind: residual.kind, + from_m: round(residual.from_m), + to_m: round(residual.to_m), + volume_m3: round(residual.volume_m3), + })), + }; +} diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Balance_View.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Balance_View.ts new file mode 100644 index 00000000..426ed976 --- /dev/null +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Balance_View.ts @@ -0,0 +1,326 @@ +/* ============================================================================= + * B06_wf3_ProfileCross_UI_MassHaul_Balance_View.ts + * 토량 분배(평형선) SVG 렌더러 — 유토곡선 위에 겹치는 레이어. + * + * 그리는 것은 넷이다: + * 1. 계단형 평형선 — 블록마다 수평, 사토·토취가 나는 자리에서 계단으로 옮겨 간다. + * 2. 종거 표시선 — 평형선에서 곡선 정점까지. 이 길이가 곧 운반토량 `Q`. + * 3. 반종거 수평선 — 종거의 1/2 높이에서 곡선을 자른 두 점 사이. 이 길이가 `L`이고 + * 끝에 방향 화살촉을 단다(산 = 좌→우, 골 = 우→좌). + * 4. 육각 balloon — `Q` / `L`·장비 / EA·RR·BR. 사토·토취는 단차 화살표 + 라벨. + * + * 좌표 변환(x/y)과 그릴 상자는 유토곡선 렌더러가 넘겨준다 — 축을 두 번 정의하지 않는다. + * 계산은 전부 `_UI_MassHaul_Balance`가 끝내 두므로 여기서는 배치만 판단한다. + * 700줄 제한 대응으로 `_UI_MassHaul_View`에서 분리했다. + * ========================================================================== */ + +import type { HaulBlock, HaulPlan, HaulResidual } from "./B06_wf3_ProfileCross_UI_MassHaul_Balance"; +import type { LocaleKey } from "@ui/ui_template_locale"; +import { L, svgElement, svgText } from "./B06_wf3_ProfileCross_UI_Section_Common"; + +/** 유토곡선 렌더러가 넘겨주는 좌표계와 그릴 수 있는 상자(px). */ +export interface BalanceLayerBox { + x: (chainageM: number) => number; + y: (volumeM3: number) => number; + left: number; + right: number; + top: number; + bottom: number; +} + +const EQUIPMENT_LABEL: Record = { + free_haul: "B06_MassHaul_Equip_FreeHaul", + dozer: "B06_MassHaul_Equip_Dozer", + dump_truck: "B06_MassHaul_Equip_Dump", +}; + +const LINE_HEIGHT = 11; +const PAD_X = 8; +const PAD_Y = 5; +/** 육각형 좌우 꼭짓점이 파고드는 깊이(px). */ +const NOTCH = 8; +/** balloon과 곡선 사이 최소 틈. */ +const GAP = 7; +/** 이보다 좁은 블록에는 balloon을 달지 않는다 — 서로 겹쳐 도면을 못 읽는다. */ +const MIN_BLOCK_WIDTH_PX = 34; + +/** SVG는 자동 크기가 없어 폭을 어림해야 한다. 한글은 라틴 글자보다 넓다. */ +function textWidth(value: string): number { + let width = 0; + for (const char of value) width += /[가-힣ㄱ-ㅎㅏ-ㅣ]/.test(char) ? 9.5 : 5.2; + return width; +} + +/** balloon 안 수치는 자리를 아껴야 한다 — 천 단위 구분만 두고 소수점은 버린다. */ +function compactVolume(value: number): string { + return Math.round(value).toLocaleString(); +} + +function compactDistance(value: number): string { + return value >= 100 ? `${Math.round(value)}` : value.toFixed(1); +} + +function equipmentLabel(key: string | null): string { + if (!key) return ""; + const locale = EQUIPMENT_LABEL[key]; + return locale ? L(locale) : key; +} + +/** + * 육각형 경로. 좌우 끝이 뾰족한 도면용 balloon 모양이라 폭이 좁아도 꼬리를 달기 쉽다. + */ +function hexagonPoints(cx: number, cy: number, width: number, height: number): string { + const halfW = width / 2; + const halfH = height / 2; + const notch = Math.min(NOTCH, width * 0.2); + return [ + `${cx - halfW},${cy}`, + `${cx - halfW + notch},${cy - halfH}`, + `${cx + halfW - notch},${cy - halfH}`, + `${cx + halfW},${cy}`, + `${cx + halfW - notch},${cy + halfH}`, + `${cx - halfW + notch},${cy + halfH}`, + ].join(" "); +} + +interface PlacedBox { + left: number; + right: number; + top: number; + bottom: number; +} + +function overlaps(a: PlacedBox, b: PlacedBox): boolean { + return !(a.right <= b.left || a.left >= b.right || a.bottom <= b.top || a.top >= b.bottom); +} + +/** + * 겹치지 않는 세로 자리를 찾는다. 선호 방향(위/아래)으로 한 칸씩 밀어 보고, 상자를 벗어나면 + * 반대쪽을 시도한다. 끝내 못 찾으면 처음 자리에 그대로 둔다 — 안 그리는 것보다 낫다. + */ +function findSlot( + placed: PlacedBox[], + centerX: number, + preferredY: number, + width: number, + height: number, + box: BalanceLayerBox, + upward: boolean, +): number { + const step = height + 4; + const half = height / 2; + for (const direction of upward ? [-1, 1] : [1, -1]) { + for (let attempt = 0; attempt < 6; attempt += 1) { + const centerY = preferredY + direction * step * attempt; + if (centerY - half < box.top || centerY + half > box.bottom) break; + const candidate: PlacedBox = { + left: centerX - width / 2, + right: centerX + width / 2, + top: centerY - half, + bottom: centerY + half, + }; + if (!placed.some((entry) => overlaps(entry, candidate))) return centerY; + } + } + return preferredY; +} + +/** 계단형 평형선 — 칸 사이는 수직선으로 이어 계단을 만든다. */ +function appendBalanceLine(group: SVGGElement, plan: HaulPlan, box: BalanceLayerBox): void { + const steps = [...plan.steps].sort((a, b) => a.from_m - b.from_m); + let previous: { x: number; y: number } | null = null; + for (const step of steps) { + const y = box.y(step.level_m3); + const x1 = box.x(step.from_m); + const x2 = box.x(step.to_m); + if (previous && Math.abs(previous.y - y) > 0.5) { + group.append( + svgElement("line", { + x1: previous.x, + y1: previous.y, + x2: previous.x, + y2: y, + class: "b06-balance__riser", + }), + ); + } + group.append(svgElement("line", { x1, y1: y, x2, y2: y, class: "b06-balance__line" })); + previous = { x: x2, y }; + } +} + +/** 종거(수직) + 반종거 수평선(방향 화살촉). 두 선이 곧 `Q`와 `L`의 눈금이다. */ +function appendMeasures(group: SVGGElement, block: HaulBlock, box: BalanceLayerBox): void { + const apexX = box.x(block.apex_m); + const baseY = box.y(block.base_m3); + const apexY = box.y(block.apex_volume_m3); + group.append( + svgElement("line", { + x1: apexX, + y1: baseY, + x2: apexX, + y2: apexY, + class: "b06-balance__ordinate", + }), + ); + + const half = box.y( + block.direction === "forward" + ? block.base_m3 + block.volume_m3 / 2 + : block.base_m3 - block.volume_m3 / 2, + ); + const from = box.x(block.haul_from_m); + const to = box.x(block.haul_to_m); + if (to - from < 2) return; + const tipX = block.direction === "forward" ? to : from; + const backX = block.direction === "forward" ? to - 6 : from + 6; + group.append( + svgElement("line", { x1: from, y1: half, x2: to, y2: half, class: "b06-balance__haul" }), + svgElement("polygon", { + points: `${tipX},${half} ${backX},${half - 3.5} ${backX},${half + 3.5}`, + class: "b06-balance__haul-arrow", + }), + ); +} + +/** 블록 balloon. 자리가 좁으면 줄을 줄여서라도 `Q`는 반드시 남긴다. */ +function appendBlockBalloon( + group: SVGGElement, + block: HaulBlock, + box: BalanceLayerBox, + placed: PlacedBox[], +): void { + const apexX = box.x(block.apex_m); + const apexY = box.y(block.apex_volume_m3); + const baseY = box.y(block.base_m3); + if (box.x(block.to_m) - box.x(block.from_m) < MIN_BLOCK_WIDTH_PX) return; + + const equipment = equipmentLabel(block.equipment); + const room = box.bottom - box.top; + const lines = [`${block.index} · ${compactVolume(block.volume_m3)}㎥`]; + if (room >= 110) { + lines.push(`L ${compactDistance(block.haul_distance_m)}m${equipment ? ` · ${equipment}` : ""}`); + } + if (room >= 150) { + lines.push( + `EA ${compactVolume(block.ea_m3)} · RR ${compactVolume(block.rr_m3)} · BR ${compactVolume(block.br_m3)}`, + ); + } + + const width = Math.max(...lines.map(textWidth)) + PAD_X * 2 + NOTCH; + const height = lines.length * LINE_HEIGHT + PAD_Y * 2; + // 산이면 정점 위, 골이면 정점 아래 — 곡선 바깥이라 곡선을 가리지 않는다. + const upward = block.direction === "forward"; + const preferred = apexY + (upward ? -(height / 2 + GAP) : height / 2 + GAP); + const clamped = Math.min(Math.max(preferred, box.top + height / 2), box.bottom - height / 2); + const centerX = Math.min(Math.max(apexX, box.left + width / 2), box.right - width / 2); + const centerY = findSlot(placed, centerX, clamped, width, height, box, upward); + placed.push({ + left: centerX - width / 2, + right: centerX + width / 2, + top: centerY - height / 2, + bottom: centerY + height / 2, + }); + + const balloon = svgElement("g", { class: "b06-balance__balloon" }); + const title = svgElement("title"); + title.textContent = + `${L("B06_MassHaul_Block")} ${block.index} · ` + + `${L("B06_MassHaul_HaulVolume")} ${compactVolume(block.volume_m3)}㎥ · ` + + `${L("B06_MassHaul_HaulDistance")} ${compactDistance(block.haul_distance_m)}m` + + (equipment ? ` · ${equipment}` : "") + + ` · EA ${compactVolume(block.ea_m3)} / RR ${compactVolume(block.rr_m3)} / BR ${compactVolume(block.br_m3)}㎥`; + // 지시선 — balloon이 밀려났을 때 어느 정점의 것인지 잇는다. + balloon.append( + title, + svgElement("line", { + x1: apexX, + y1: upward ? Math.min(apexY, baseY) : Math.max(apexY, baseY), + x2: centerX, + y2: centerY, + class: "b06-balance__leader", + }), + svgElement("polygon", { + points: hexagonPoints(centerX, centerY, width, height), + class: "b06-balance__balloon-shape", + }), + ...lines.map((line, index) => + svgText(line, { + x: centerX, + y: centerY - height / 2 + PAD_Y + LINE_HEIGHT * (index + 0.8), + "text-anchor": "middle", + class: "b06-balance__balloon-text", + }), + ), + ); + group.append(balloon); +} + +/** 사토·토취 — 평형선 단차를 세로 양방향 화살표로 찍고 그 옆에 수량을 적는다. */ +function appendResidual( + group: SVGGElement, + residual: HaulResidual, + box: BalanceLayerBox, + compact: boolean, +): void { + const centerX = Math.min( + Math.max((box.x(residual.from_m) + box.x(residual.to_m)) / 2, box.left + 4), + box.right - 4, + ); + const y1 = box.y(residual.level_from_m3); + const y2 = box.y(residual.level_to_m3); + const label = `${L(residual.kind === "spoil" ? "B06_MassHaul_Surplus" : "B06_MassHaul_Shortage")} ${compactVolume(residual.volume_m3)}㎥`; + const marker = svgElement("g", { + class: `b06-balance__residual b06-balance__residual--${residual.kind}`, + }); + const title = svgElement("title"); + title.textContent = label; + marker.append( + title, + svgElement("line", { x1: centerX, y1, x2: centerX, y2, class: "b06-balance__step-arrow" }), + svgElement("polygon", { + points: `${centerX},${y2} ${centerX - 3.5},${y2 + (y2 > y1 ? -6 : 6)} ${centerX + 3.5},${y2 + (y2 > y1 ? -6 : 6)}`, + class: "b06-balance__step-head", + }), + ); + // 좁은 그래프에서는 화살표만 남기고 수량은 툴팁으로 돌린다. + if (!compact) { + marker.append( + svgText(label, { + x: centerX + 5, + y: (y1 + y2) / 2 + 3, + class: "b06-balance__residual-text", + }), + ); + } + group.append(marker); +} + +/** + * 토량 분배 레이어를 유토곡선 SVG에 얹는다. 곡선·측점선 위, 선택 말풍선 아래에 놓아야 + * 읽는 순서가 맞으므로 호출 위치를 옮기지 말 것. + */ +export function appendBalanceLayer(svg: SVGSVGElement, plan: HaulPlan, box: BalanceLayerBox): void { + const group = svgElement("g", { class: "b06-balance" }); + appendBalanceLine(group, plan, box); + for (const block of plan.blocks) appendMeasures(group, block, box); + + const compact = box.bottom - box.top < 110; + for (const residual of plan.residuals) appendResidual(group, residual, box, compact); + const placed: PlacedBox[] = []; + for (const block of plan.blocks) appendBlockBalloon(group, block, box, placed); + svg.append(group); +} + +/** + * 곡선 아래 요약줄에 덧붙일 토량 분배 수치. + * 사토·토취는 요약줄이 이미 곡선 기준으로 적고 있고 계단 단차의 합이 그 값과 같으므로 + * 여기서 또 적지 않는다(같은 수치가 두 번 뜨면 서로 다른 값으로 오해한다). + */ +export function haulPlanChips(plan: HaulPlan | null): Array<[string, string]> { + if (!plan) return []; + return [ + [L("B06_MassHaul_HaulTotal"), `${compactVolume(plan.hauled_m3)}㎥`], + [L("B06_MassHaul_BlockCount"), `${plan.blocks.length}`], + ]; +} 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 8743b836..f8223177 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_View.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_View.ts @@ -21,7 +21,10 @@ import type { MassHaulPoint, MassHaulSeries, } from "./B06_wf3_ProfileCross_UI_MassHaul"; +import type { HaulPlan } from "./B06_wf3_ProfileCross_UI_MassHaul_Balance"; import type { LocaleKey } from "@ui/ui_template_locale"; +import { MASS_HAUL_BALANCE_KEY } from "./B06_wf3_ProfileCross_UI_MassHaul"; +import { appendBalanceLayer, haulPlanChips } from "./B06_wf3_ProfileCross_UI_MassHaul_Balance_View"; import { L, LONG_PAD, @@ -232,6 +235,7 @@ export function createMassHaulChart( heightPx: number, minimumWidthPx: number, onSelectStation: (stationId: string) => void, + haulPlan: HaulPlan | null, ): SVGSVGElement { const svg = svgElement("svg", { class: "b06-section__chart b06-masshaul", @@ -361,6 +365,19 @@ export function createMassHaulChart( ); } + // 토량 분배 레이어 — 곡선 **위**, 선택 말풍선 **아래**. 순서를 바꾸면 평형선이 곡선을 + // 가리거나(위로 올리면) 선택 말풍선이 balloon에 묻힌다(아래로 내리면). + if (haulPlan) { + appendBalanceLayer(svg, haulPlan, { + x, + y, + left: LONG_PAD.left, + right: widthPx - LONG_PAD.right, + top: MASS_PAD_TOP, + bottom: heightPx - MASS_PAD_BOTTOM, + }); + } + // 선택 측점 강조 — 곡선 위 점 + 구간 물량 말풍선. const focus = banded ? massHaulPointAt(banded, longitudinal, selectedStationId) : null; if (focus) { @@ -457,14 +474,13 @@ export function createMassHaulLegend( 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 addItem = (key: string, label: string, variantClass: string): void => { + const shown = visibleKeys.has(key); const button = document.createElement("button"); button.type = "button"; - button.className = seriesClass(entry, "b06-masshaul__legend-item"); + button.className = variantClass; 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"; @@ -472,9 +488,18 @@ export function createMassHaulLegend( const text = document.createElement("span"); text.textContent = label; button.append(swatch, text); - button.addEventListener("click", () => onToggle(entry.key)); + button.addEventListener("click", () => onToggle(key)); legend.append(button); + }; + for (const entry of series) { + addItem(entry.key, massHaulSeriesLabel(entry), seriesClass(entry, "b06-masshaul__legend-item")); } + // 토량 분배는 곡선이 아니라 곡선 위에 얹는 레이어라 스와치 모양을 따로 준다. + addItem( + MASS_HAUL_BALANCE_KEY, + L("B06_MassHaul_Balance_Layer"), + "b06-masshaul__legend-item b06-masshaul__legend-item--balance", + ); return legend; } @@ -482,7 +507,10 @@ export function createMassHaulLegend( * 곡선 아래 총괄 요약 줄. 절토는 자연상태(내역 기준)와 환산 후(곡선 기준)를 함께 적는다. * 곡선이 여러 개라 수치가 어느 곡선의 것인지 끝에 반드시 밝힌다. */ -export function createMassHaulSummary(series: MassHaulSeries): HTMLElement { +export function createMassHaulSummary( + series: MassHaulSeries, + haulPlan: HaulPlan | null, +): HTMLElement { const result = series.result; const row = document.createElement("div"); row.className = "b06-masshaul__summary"; @@ -504,6 +532,8 @@ export function createMassHaulSummary(series: MassHaulSeries): HTMLElement { ? [L("B06_MassHaul_Shortage"), `${formatVolume(result.shortage_m3)}㎥`] : [L("B06_MassHaul_Surplus"), `${formatVolume(result.surplus_m3)}㎥`], ); + // 토량 분배를 켜 두면 운반 총량·블록 수가 뒤에 붙는다(사토·토취는 발생 지점 기준 합계). + chips.push(...haulPlanChips(haulPlan)); for (const [label, value] of chips) { const chip = document.createElement("span"); chip.className = "b06-masshaul__chip"; diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts index c2392aef..60161f1d 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts @@ -34,6 +34,7 @@ import { type RockBoundaryControl, } from "./B06_wf3_ProfileCross_UI_Section_View"; import { computeMassHaul, massHaulPayload } from "./B06_wf3_ProfileCross_UI_MassHaul"; +import { computeHaulPlan } from "./B06_wf3_ProfileCross_UI_MassHaul_Balance"; import { designElevationAt } from "./B06_wf3_ProfileCross_UI_Section_Common"; import { createStandardPanel, @@ -344,6 +345,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { crossHalfWidth(), stationInterval, context?.earthwork_conversion, + context?.haul_equipment_limits, ); } } @@ -390,7 +392,9 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { currentRouteId, standardPanel?.getValues(), crossPatches.length ? crossPatches : undefined, - massHaul ? massHaulPayload(massHaul) : undefined, + massHaul + ? massHaulPayload(massHaul, computeHaulPlan(massHaul, context?.haul_equipment_limits)) + : 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_View.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts index 83131a7e..4471ffd0 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts @@ -18,6 +18,7 @@ import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; import type { CrossSection, EarthworkConversion, + HaulEquipmentLimit, SectionDetailResponse, } from "./B06_wf3_ProfileCross_Api_Fetch"; import type { RockBoundaryControl } from "./B06_wf3_ProfileCross_UI_Cross_Design"; @@ -33,9 +34,11 @@ import { } from "./B06_wf3_ProfileCross_UI_Longitudinal"; import { computeMassHaulSeries, + MASS_HAUL_BALANCE_KEY, MASS_HAUL_DEFAULT_VISIBLE, type MassHaulSeries, } from "./B06_wf3_ProfileCross_UI_MassHaul"; +import { computeHaulPlan } from "./B06_wf3_ProfileCross_UI_MassHaul_Balance"; import { createMassHaulChart, createMassHaulLegend, @@ -77,18 +80,22 @@ const PANEL_HEIGHT_KEY = "b06:profile-panel-height"; const PANEL_COLLAPSED_KEY = "b06:profile-panel-collapsed"; // 곡선 키·기본값이 바뀔 때마다 저장 키의 판을 올려 갈아탄다(옛 세션값을 읽으면 의도와 다른 // 곡선이 켜진다). v2: 키가 `${기준}_${환산}` → `${기준}`. v3: 기본 표시가 횡단 기준 하나로. -const MASS_HAUL_VISIBLE_KEY = "b06:masshaul-visible-v3"; +// v4: 토량 분배 레이어(`balance`)가 범례에 합류. +const MASS_HAUL_VISIBLE_KEY = "b06:masshaul-visible-v4"; + +/** 곡선 기준 + 토량 분배 레이어. 저장값이 없을 때 켜 두는 항목. */ +const DEFAULT_VISIBLE_KEYS: string[] = [...MASS_HAUL_DEFAULT_VISIBLE, MASS_HAUL_BALANCE_KEY]; /** 켜 둔 곡선 목록은 세션에만 남긴다(패널 높이·접힘과 같은 규칙). */ function readVisibleSeries(): Set { try { const raw = sessionStorage.getItem(MASS_HAUL_VISIBLE_KEY); - if (!raw) return new Set(MASS_HAUL_DEFAULT_VISIBLE); + if (!raw) return new Set(DEFAULT_VISIBLE_KEYS); const parsed: unknown = JSON.parse(raw); // 전부 끈 상태(빈 배열)도 사용자의 선택이므로 기본값으로 되돌리지 않는다. - return Array.isArray(parsed) ? new Set(parsed.map(String)) : new Set(MASS_HAUL_DEFAULT_VISIBLE); + return Array.isArray(parsed) ? new Set(parsed.map(String)) : new Set(DEFAULT_VISIBLE_KEYS); } catch { - return new Set(MASS_HAUL_DEFAULT_VISIBLE); + return new Set(DEFAULT_VISIBLE_KEYS); } } @@ -136,6 +143,7 @@ export interface SectionViewController { crossHalfWidth?: number, stationInterval?: number, earthworkConversion?: EarthworkConversion, + haulEquipmentLimits?: HaulEquipmentLimit[], ) => void; /** 측점 하나의 카드만 새로 만들어 교체한다 (전체 재렌더 없이 설계 변경 반영). */ refreshCard: (chainageM: number) => void; @@ -157,6 +165,7 @@ export function createSectionView( let currentCrossHalfWidth: number | undefined; let currentStationInterval: number | undefined; let currentConversion: EarthworkConversion | undefined; + let currentHaulLimits: HaulEquipmentLimit[] | undefined; let renderWidth = 0; let resizeTimer = 0; let panelResizeTimer = 0; @@ -450,6 +459,13 @@ export function createSectionView( const series: MassHaulSeries[] = currentConversion ? computeMassHaulSeries(detail.longitudinal, detail.cross_sections, currentConversion) : []; + // 토량 분배는 **면을 깐 곡선 하나**(= 켜 둔 첫 곡선)에만 얹는다 — 곡선마다 평형선을 + // 그리면 계단이 서로 엇갈려 어느 쪽 배분인지 읽히지 않는다. + const bandedSeries = series.find((entry) => visibleSeries.has(entry.key)); + const haulPlan = + bandedSeries && visibleSeries.has(MASS_HAUL_BALANCE_KEY) + ? computeHaulPlan(bandedSeries.result, currentHaulLimits) + : null; if (series.length) { // 유토곡선 SVG는 종단면도와 **같은 부모의 형제**여야 한다. 감싸는 상자를 하나라도 끼우면 // 그 상자가 스크롤 컨테이너 폭 계산에 끼어들어 두 그래프의 측점 세로선이 어긋난다. @@ -464,6 +480,7 @@ export function createSectionView( heights.mass, minWidth, (stationId) => selectStation(stationId, true), + haulPlan, ), ); } @@ -481,10 +498,10 @@ export function createSectionView( } // 요약 수치는 켜 둔 곡선 중 첫 번째 것 — 곡선이 여러 개라 어느 것인지 요약 끝에 밝힌다. // (선택 측점의 구간 물량은 곡선 위 말풍선이 맡는다.) - const summarySeries = series.find((entry) => visibleSeries.has(entry.key)); + const summarySeries = bandedSeries; if (summarySeries) { panelCount.textContent = ""; - panelBody.append(createMassHaulSummary(summarySeries)); + panelBody.append(createMassHaulSummary(summarySeries, haulPlan)); } else { panelCount.textContent = L(series.length ? "B06_MassHaul_AllHidden" : "B06_MassHaul_Empty"); } @@ -592,7 +609,14 @@ export function createSectionView( return { root, - render(detail, verticalExaggeration, crossHalfWidth, stationInterval, earthworkConversion) { + render( + detail, + verticalExaggeration, + crossHalfWidth, + stationInterval, + earthworkConversion, + haulEquipmentLimits, + ) { currentDetail = detail; currentExaggeration = Math.max(verticalExaggeration, 0.1); currentCrossHalfWidth = @@ -600,6 +624,7 @@ export function createSectionView( currentStationInterval = stationInterval !== undefined && stationInterval > 0 ? stationInterval : undefined; if (earthworkConversion) currentConversion = earthworkConversion; + if (haulEquipmentLimits?.length) currentHaulLimits = haulEquipmentLimits; // 진입 시 측점을 자동으로 고르지 않는다(2026-08-02 사용자 지시) — 0측점이 선택된 채로 // 시작하면 사용자가 고르지도 않은 카드가 강조돼 있고 유토곡선 말풍선도 떠 있다. renderWidth = contentWidth(); diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_MassHaul.css b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_MassHaul.css index 901648e8..133fd2f5 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_MassHaul.css +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_MassHaul.css @@ -169,3 +169,101 @@ font-size: 11px; font-variant-numeric: tabular-nums; } + +/* ============================================================================= + * 토량 분배(평형선) 레이어 + * + * 곡선(보라)과 구분되도록 계열을 통째로 바꾼다: 평형선·운반 표시는 경고색(황토), + * 사토는 위험색, 토취는 성공색. 곡선 위에 겹치므로 선을 얇게 두고 balloon만 불투명하다. + * ========================================================================== */ + +/* 평형선 — 블록마다 수평. 곡선보다 얇지만 실선이라 격자와 구분된다. */ +.b06-balance__line { + stroke: var(--color-warning); + stroke-width: 1.6; +} + +/* 계단의 수직 연결선 — 사토·토취가 나는 자리라 파선으로 "옮겨 갔음"을 드러낸다. */ +.b06-balance__riser { + stroke: var(--color-warning); + stroke-width: 1.2; + stroke-dasharray: 3 2; +} + +/* 종거(평형선 → 정점) = 운반토량 Q의 눈금. */ +.b06-balance__ordinate { + stroke: var(--color-warning); + stroke-width: 1; + stroke-dasharray: 2 3; + opacity: 0.85; +} + +/* 반종거 수평선 = 평균운반거리 L의 눈금. 화살촉이 운반 방향이다. */ +.b06-balance__haul { + stroke: var(--color-warning); + stroke-width: 1.6; +} + +.b06-balance__haul-arrow { + fill: var(--color-warning); + stroke: none; +} + +.b06-balance__leader { + stroke: var(--color-warning); + stroke-width: 1; + stroke-dasharray: 3 2; + opacity: 0.7; +} + +/* 육각 balloon — 곡선 위에 놓이므로 배경을 거의 불투명하게 깐다. */ +.b06-balance__balloon-shape { + fill: color-mix(in srgb, var(--color-surface-raised) 94%, transparent); + stroke: var(--color-warning); + stroke-width: 1.2; +} + +.b06-balance__balloon-text { + fill: var(--color-text-body); + font-size: 9px; + font-variant-numeric: tabular-nums; +} + +/* 사토·토취 단차. 남는 흙(사토)과 모자란 흙(토취)을 색으로 갈라 둔다. */ +.b06-balance__step-arrow { + stroke-width: 1.4; +} + +.b06-balance__residual--spoil .b06-balance__step-arrow, +.b06-balance__residual--spoil .b06-balance__residual-text { + stroke: var(--color-danger); + fill: var(--color-danger); +} + +.b06-balance__residual--borrow .b06-balance__step-arrow, +.b06-balance__residual--borrow .b06-balance__residual-text { + stroke: var(--color-success); + fill: var(--color-success); +} + +.b06-balance__residual--spoil .b06-balance__step-head { + fill: var(--color-danger); + stroke: none; +} + +.b06-balance__residual--borrow .b06-balance__step-head { + fill: var(--color-success); + stroke: none; +} + +.b06-balance__residual-text { + stroke: none; + font-size: 9px; + font-variant-numeric: tabular-nums; +} + +/* 범례에서 분배 레이어는 곡선이 아니라 평형선 계열임을 스와치로 알린다. */ +.b06-masshaul__legend-item--balance .b06-masshaul__legend-swatch { + border-top-color: var(--color-warning); + border-top-style: dashed; +} diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index 7b8c81ef..fb343856 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -237,6 +237,18 @@ export const ui_locales_b2 = { B06_MassHaul_Station_Net: ["순토량", "Net volume"], B06_MassHaul_Station_Cumulative: ["누가토량", "Cumulative"], + /* --- B06 토량 분배(평형선·운반 블록) --- */ + B06_MassHaul_Balance_Layer: ["토량 분배", "Haul plan"], + B06_MassHaul_Block: ["운반 블록", "Haul block"], + B06_MassHaul_HaulVolume: ["운반토량", "Haul volume"], + B06_MassHaul_HaulDistance: ["평균운반거리", "Average haul distance"], + B06_MassHaul_HaulTotal: ["운반토량 계", "Hauled total"], + B06_MassHaul_BlockCount: ["운반 블록 수", "Haul blocks"], + /* 운반장비 이름 — 거리 경계값은 config_system.EARTHWORK_HAUL_EQUIPMENT_LIMITS_M에만 둔다. */ + B06_MassHaul_Equip_FreeHaul: ["종무대", "Free haul"], + B06_MassHaul_Equip_Dozer: ["도쟈", "Dozer"], + B06_MassHaul_Equip_Dump: ["덤프", "Dump truck"], + /* --- B06 측점 표준횡단 설계 지정 --- */ B06_Design_Ground_Legend: ["지반유형", "Ground type"], B06_Design_Ground_Soil: ["토사", "Soil"],