From fceec5fb24dd48dd862f39bcde0f0268f5249437 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 3 Aug 2026 18:35:02 +0900 Subject: [PATCH] =?UTF-8?q?refactor(common):=20=EC=9C=A0=ED=86=A0=EA=B3=A1?= =?UTF-8?q?=EC=84=A0=20=EC=97=94=EC=A7=84=C2=B7=EB=A0=8C=EB=8D=94=EB=9F=AC?= =?UTF-8?q?=EB=A5=BC=20common=5Futil=EB=A1=9C=20=EA=B3=B5=EC=9A=A9?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B05 계획 유토곡선과 B06 정식 유토곡선이 같은 엔진을 쓰도록 모듈을 옮겼다. - B06_wf3_ProfileCross_UI_MassHaul{,_Balance,_Balance_View,_Balloon,_Curve, _Settle,_View}.ts + Style_MassHaul.css → common_util/common_util_mass_haul*. - common_util_mass_haul_types.ts 신설: GroundType/EarthworkConversion/ HaulEquipmentLimit/BalloonOffsets 정의처를 한 곳으로 모으고 B06 Api_Fetch가 재수출. 엔진 입력은 페이지 API 타입 대신 구조적 부분집합으로 받는다. - common_util_svg.ts 신설: svgElement/svgText/L/stationLabel/ inferStationInterval 이관, B06 _UI_Section_Common은 재수출로 경로 유지. - createMassHaulChart에 MassHaulAxis 인자 추가 — 종단 렌더러 상수 의존을 걷어내고 호출한 쪽이 X축(누가거리 최댓값·좌우 여백)을 주입한다. 동작 무변경. npm run typecheck 통과. Co-Authored-By: Claude Opus 5 (1M context) --- .../B06_wf3_ProfileCross_Api_Fetch.ts | 39 ++++----- .../B06_wf3_ProfileCross_UI_Page.ts | 8 +- .../B06_wf3_ProfileCross_UI_Section_Common.ts | 62 +++----------- .../B06_wf3_ProfileCross_UI_Section_View.ts | 15 +++- .../common_util_mass_haul.css | 0 .../common_util_mass_haul.ts | 38 ++++----- .../common_util_mass_haul_balance.ts | 12 +-- .../common_util_mass_haul_balance_view.ts | 18 ++-- .../common_util_mass_haul_balloon.ts | 6 +- .../common_util_mass_haul_curve.ts | 6 +- .../common_util_mass_haul_settle.ts | 12 +-- common_util/common_util_mass_haul_types.ts | 84 +++++++++++++++++++ .../common_util_mass_haul_view.ts | 77 ++++++++--------- common_util/common_util_svg.ts | 63 ++++++++++++++ 14 files changed, 273 insertions(+), 167 deletions(-) rename B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_MassHaul.css => common_util/common_util_mass_haul.css (100%) rename B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul.ts => common_util/common_util_mass_haul.ts (95%) rename B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Balance.ts => common_util/common_util_mass_haul_balance.ts (98%) rename B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Balance_View.ts => common_util/common_util_mass_haul_balance_view.ts (97%) rename B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Balloon.ts => common_util/common_util_mass_haul_balloon.ts (98%) rename B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Curve.ts => common_util/common_util_mass_haul_curve.ts (97%) rename B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Settle.ts => common_util/common_util_mass_haul_settle.ts (95%) create mode 100644 common_util/common_util_mass_haul_types.ts rename B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_View.ts => common_util/common_util_mass_haul_view.ts (92%) create mode 100644 common_util/common_util_svg.ts diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts index 2e6bd845..919f7089 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts @@ -15,6 +15,12 @@ * ========================================================================== */ import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend"; +import type { + BalloonOffsets, + EarthworkConversion, + GroundType, + HaulEquipmentLimit, +} from "@util/common_util_mass_haul_types"; export interface SectionOptionDefaults { station_interval_m: number; @@ -51,26 +57,15 @@ 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; - -/** - * 평균운반거리 상한 하나(m). `max_distance_m: null`이면 상한 없음(나머지를 전부 받는다). - * 정의처는 config_system.EARTHWORK_HAUL_EQUIPMENT_LIMITS_M 한 곳뿐이라 프론트는 받아만 쓴다. - */ -export interface HaulEquipmentLimit { - key: string; - max_distance_m: number | null; -} +// 지반유형·토량환산계수·운반장비 한계거리는 B05 계획 유토곡선과 공유하므로 정의처를 +// `@util/common_util_mass_haul_types` 한 곳에 두고 여기서는 재수출만 한다(사본 금지). +export type { + GroundType, + EarthworkConversionFactor, + EarthworkConversion, + HaulEquipmentLimit, + BalloonOffsets, +} from "@util/common_util_mass_haul_types"; export interface SectionContextResponse { project_id: string; @@ -188,9 +183,6 @@ export interface LongitudinalSection { profile_alignment?: unknown; } -/** 유토곡선 balloon을 끌어 옮긴 위치(띠 번호 → [dx, dy], px). */ -export type BalloonOffsets = Record; - export interface CrossSection extends SectionStation { samples: SectionSample[]; /** DB에 저장된 잠정 설계 지정(있을 때만). 상세 조회 시 얹혀 온다. */ @@ -213,7 +205,6 @@ export interface SectionConfirmResponse { } /** 측점 표준횡단 설계 지정값 (버튼 상태). */ -export type GroundType = "soil" | "ripping_rock" | "blasting_rock"; export type SectionMode = "left_cut" | "right_cut" | "both_cut" | "both_fill"; export type DitchSide = "left" | "right"; export type DitchType = "standard" | "l_type"; diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts index a033e779..5af3f0f7 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts @@ -34,9 +34,9 @@ import { createSectionView, 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 { balloonOffsetsPayload } from "./B06_wf3_ProfileCross_UI_MassHaul_Balance_View"; +import { computeMassHaul, massHaulPayload } from "@util/common_util_mass_haul"; +import { computeHaulPlan } from "@util/common_util_mass_haul_balance"; +import { balloonOffsetsPayload } from "@util/common_util_mass_haul_balance_view"; import { designElevationAt } from "./B06_wf3_ProfileCross_UI_Section_Common"; import { createStandardPanel, @@ -45,7 +45,7 @@ import { import "./B06_wf3_ProfileCross_UI_Style.css"; import "./B06_wf3_ProfileCross_UI_Style_Cross.css"; import "./B06_wf3_ProfileCross_UI_Style_Cross_Areas.css"; -import "./B06_wf3_ProfileCross_UI_Style_MassHaul.css"; +import "@util/common_util_mass_haul.css"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_Common.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_Common.ts index 32c6fe1a..5e016648 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_Common.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_Common.ts @@ -7,7 +7,6 @@ * 이 모듈을 참조한다. 색상 하드코딩 금지 원칙은 그대로이며 여기서는 지오메트리 계산만 다룬다. * ========================================================================== */ -import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import type { CrossDesignChange } from "./B06_wf3_ProfileCross_UI_Cross_Design"; import type { DesignProfile, @@ -19,7 +18,17 @@ import type { export type { CrossDesignChange }; export type DesignChangeHandler = (chainageM: number, change: CrossDesignChange) => void; -export const SVG_NS = "http://www.w3.org/2000/svg"; +// SVG 생성·측점 표기 유틸은 B05 계획 유토곡선과 공유하려고 `@util/common_util_svg`로 옮겼다. +// 여기서 재수출해 기존 B06 임포트 경로를 그대로 둔다(정의는 저쪽 한 곳뿐). +export { + SVG_NS, + L, + svgElement, + svgText, + inferStationInterval, + stationLabel, +} from "@util/common_util_svg"; + export const LONG_WIDTH = 1200; export const LONG_HEIGHT = 220; export const CROSS_WIDTH = 560; @@ -44,28 +53,6 @@ export interface YScaleOptions { globalMaxElevation: number; } -export function L(key: keyof typeof ui_locales): string { - return ui_locales[key][currentLanguageIndex]; -} - -export function svgElement( - tag: K, - attributes: Record = {}, -): SVGElementTagNameMap[K] { - const element = document.createElementNS(SVG_NS, tag); - Object.entries(attributes).forEach(([key, value]) => element.setAttribute(key, String(value))); - return element; -} - -export function svgText( - value: string, - attributes: Record, -): SVGTextElement { - const text = svgElement("text", attributes); - text.textContent = value; - return text; -} - export function validElevation( sample: SectionSample, ): sample is SectionSample & { elevation_m: number } { @@ -146,30 +133,3 @@ export function emptyView(message: string): HTMLElement { empty.textContent = message; return empty; } - -export function inferStationInterval(stations: Array<{ chainage_m: number }>): number { - const counts = new Map(); - for (let index = 1; index < stations.length; index += 1) { - const difference = stations[index].chainage_m - stations[index - 1].chainage_m; - if (difference <= 0) continue; - const rounded = Math.round(difference * 10) / 10; - counts.set(rounded, (counts.get(rounded) ?? 0) + 1); - } - return ( - [...counts.entries()].sort( - ([intervalA, countA], [intervalB, countB]) => countB - countA || intervalB - intervalA, - )[0]?.[0] ?? 1 - ); -} - -export function stationLabel(chainage: number, interval: number): string { - const safeInterval = interval > 0 ? interval : 1; - let stationNumber = Math.floor((chainage + 1e-6) / safeInterval); - let remainder = chainage - stationNumber * safeInterval; - if (Math.abs(remainder) < 0.05) remainder = 0; - if (remainder >= safeInterval - 0.05) { - stationNumber += 1; - remainder = 0; - } - return `${stationNumber}+${remainder.toFixed(1)}`; -} 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 e779b51f..63aaba81 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts @@ -37,19 +37,19 @@ import { 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"; +} from "@util/common_util_mass_haul"; +import { computeHaulPlan } from "@util/common_util_mass_haul_balance"; import { configureBalloonOffsets, resetBalloonOffsets, -} from "./B06_wf3_ProfileCross_UI_MassHaul_Balance_View"; +} from "@util/common_util_mass_haul_balance_view"; import { createMassHaulChart, createMassHaulLegend, createMassHaulSummary, MASS_HAUL_HEIGHT, MASS_HAUL_MIN_HEIGHT, -} from "./B06_wf3_ProfileCross_UI_MassHaul_View"; +} from "@util/common_util_mass_haul_view"; import { calculateYScale, CROSS_GRID_GAP, @@ -62,6 +62,8 @@ import { inferStationInterval, L, LONG_HEIGHT, + longitudinalMaxChainage, + LONG_PAD, type YScaleOptions, } from "./B06_wf3_ProfileCross_UI_Section_Common"; @@ -488,6 +490,11 @@ export function createSectionView( series, visibleSeries, detail.longitudinal, + { + maxChainageM: longitudinalMaxChainage(detail.longitudinal), + padLeft: LONG_PAD.left, + padRight: LONG_PAD.right, + }, selectedStationId, cachedStationInterval, chartWidth, diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_MassHaul.css b/common_util/common_util_mass_haul.css similarity index 100% rename from B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_MassHaul.css rename to common_util/common_util_mass_haul.css diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul.ts b/common_util/common_util_mass_haul.ts similarity index 95% rename from B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul.ts rename to common_util/common_util_mass_haul.ts index e38230bc..df9da3e0 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul.ts +++ b/common_util/common_util_mass_haul.ts @@ -1,10 +1,10 @@ /* ============================================================================= - * B06_wf3_ProfileCross_UI_MassHaul.ts + * common_util_mass_haul.ts * 유토곡선(Mass Haul Diagram) 계산 엔진 — 순수 함수, DOM 의존 없음. * * 구간 토량을 평균단면법으로 만들고 토량환산계수로 기준을 통일해 누가토량을 누적한다. * 결과는 B08 수량산출·B09 견적이 그대로 재사용할 수 있도록 계산과 렌더링을 분리해 - * 둔다(렌더러는 `_UI_MassHaul_View`). + * 둔다(렌더러는 `common_util_mass_haul_view`). * * ── 계산 기준: 다짐상태 ────────────────────────────────────────────── * "운반거리의 산정 시에 모든 수량은 다짐상태로 환산하여 계산하고, 내역서에 적용하는 @@ -34,14 +34,14 @@ * ========================================================================== */ import type { - CrossDesign, - CrossSection, EarthworkConversion, 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"; + MassHaulDesign, + MassHaulLongitudinal, + MassHaulSection, +} from "./common_util_mass_haul_types"; +import type { HaulPlan } from "./common_util_mass_haul_balance"; +import { haulPlanPayload } from "./common_util_mass_haul_balance"; /** 지반유형별 토량(㎥). 도면 표기 EA(토사)/RR(리핑암)/BR(발파암)에 대응한다. */ export interface GroundVolumes { @@ -63,7 +63,7 @@ export interface MassHaulPoint { cut_rock_m3: number; /** * 암반분을 암종까지 가른 값(자연상태, `cut_rr_m3 + cut_br_m3 === cut_rock_m3`). - * 운반 블록의 EA/RR/BR 안분(`_UI_MassHaul_Balance`)이 이 구간 단위를 되받아 쓴다 — + * 운반 블록의 EA/RR/BR 안분(`common_util_mass_haul_balance`)이 이 구간 단위를 되받아 쓴다 — * 한 구간의 양 끝 측점이 서로 다른 암종일 수 있어 합계만으로는 되돌릴 수 없다. */ cut_rr_m3: number; @@ -83,7 +83,7 @@ export interface MassHaulPoint { * 유토곡선은 측점 사이에서 직선이 아니라 **포물선**이다 — 단면적이 선형으로 변하면 * 그 적분인 누가토량은 2차식이 된다. 평균운반거리(반종거 현)를 직선으로 재면 참고 도면 대비 * 약 40% 짧게 나와 장비 경계 판정이 어긋난다(도면의 20m 구간 반종거가 20/√2 = 14.14m). - * 이 값이 있어야 `_UI_MassHaul_Balance`가 구간마다 2차식을 세울 수 있다. + * 이 값이 있어야 `common_util_mass_haul_balance`가 구간마다 2차식을 세울 수 있다. */ net_area_m2: number; } @@ -173,7 +173,7 @@ interface AreaSample { * 엔진이 낸 절토 분리값을 읽는다. 구 데이터(분리 필드 없음)는 절토 전량을 측점 * 지반유형으로 돌려 **기존 단일 지반유형 환산과 같은 결과**를 유지한다. */ -function splitCut(design: CrossDesign | undefined): { +function splitCut(design: MassHaulDesign | undefined): { soil: number; rock: number; rock_kind: GroundType | null; @@ -308,7 +308,7 @@ function integrate(samples: AreaSample[], conversion: EarthworkConversion): Mass } /** 설계가 지정된 측점만 누가거리 순으로 모은다. 두 기준이 공통으로 쓰는 기준점 목록. */ -function designedSections(crossSections: CrossSection[]): CrossSection[] { +function designedSections(crossSections: MassHaulSection[]): MassHaulSection[] { return crossSections .filter((section) => section.design && Number.isFinite(section.chainage_m)) .sort((a, b) => a.chainage_m - b.chainage_m); @@ -319,14 +319,14 @@ function designedSections(crossSections: CrossSection[]): CrossSection[] { * 스스로 흘러내린다. 판정 경사(config)를 못 받았으면 **전부 불가**로 본다 — 프론트에 사본을 * 두지 않으며, 모르면 운반비를 세는 쪽이 안전하다. */ -function naturalSpoilAllowed(design: CrossDesign | undefined, minSlope?: number): boolean { +function naturalSpoilAllowed(design: MassHaulDesign | undefined, minSlope?: number): boolean { if (!Number.isFinite(minSlope)) return false; const slope = design?.fill_ground_slope; return Number.isFinite(slope) && (slope as number) >= (minSlope as number); } /** 횡단 기준 — 엔진이 낸 실측 단면적을 그대로 쓴다. */ -function crossAreaSamples(sections: CrossSection[], minSlope?: number): AreaSample[] { +function crossAreaSamples(sections: MassHaulSection[], minSlope?: number): AreaSample[] { return sections.map((section) => { const cut = splitCut(section.design); return { @@ -353,8 +353,8 @@ function crossAreaSamples(sections: CrossSection[], minSlope?: number): AreaSamp * 물려 두 기준이 같은 환산을 타게 한다(비율을 못 구하면 전량 토사로 둔다). */ function longitudinalAreaSamples( - longitudinal: LongitudinalSection, - sections: CrossSection[], + longitudinal: MassHaulLongitudinal, + sections: MassHaulSection[], ): AreaSample[] { const profile = longitudinal.design_profiles?.[0]; if (!profile || !sections.length) return []; @@ -397,7 +397,7 @@ function longitudinalAreaSamples( * 확정 저장과 B08 인계에 쓰는 **정식 곡선**이라 시그니처를 그대로 유지한다. */ export function computeMassHaul( - crossSections: CrossSection[], + crossSections: MassHaulSection[], conversion: EarthworkConversion, naturalSpoilMinSlope?: number, ): MassHaulResult | null { @@ -412,8 +412,8 @@ export function computeMassHaul( * 계산이 불가능한 기준(설계 측점 부족, 종단 계획선 없음)은 조용히 빠진다. */ export function computeMassHaulSeries( - longitudinal: LongitudinalSection, - crossSections: CrossSection[], + longitudinal: MassHaulLongitudinal, + crossSections: MassHaulSection[], conversion: EarthworkConversion, naturalSpoilMinSlope?: number, ): MassHaulSeries[] { diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Balance.ts b/common_util/common_util_mass_haul_balance.ts similarity index 98% rename from B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Balance.ts rename to common_util/common_util_mass_haul_balance.ts index 63328776..31c454a7 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Balance.ts +++ b/common_util/common_util_mass_haul_balance.ts @@ -1,8 +1,8 @@ /* ============================================================================= - * B06_wf3_ProfileCross_UI_MassHaul_Balance.ts + * common_util_mass_haul_balance.ts * 토량 분배(평형선 · 운반 블록 · 장비 띠) 계산 엔진 — 순수 함수, DOM 의존 없음. * - * 누가토량 곡선(`_UI_MassHaul`)은 "얼마가 남는가"만 말한다. 이 파일은 그 곡선에서 + * 누가토량 곡선(`common_util_mass_haul`)은 "얼마가 남는가"만 말한다. 이 파일은 그 곡선에서 * **어디 흙을 어디로 얼마나 옮기는가**를 뽑아 낸다: 평형선 → 운반 블록 → 장비 띠 → * 띠별 운반토량 `Q` · 평균운반거리 `L` · 지반유형 안분. * @@ -47,7 +47,7 @@ * `MassHaulResult.cut_natural_m3` / 측점별 `cut_soil_m3`·`cut_rr_m3`·`cut_br_m3`가 따로 든다. * ========================================================================== */ -import type { MassHaulPoint, MassHaulResult } from "./B06_wf3_ProfileCross_UI_MassHaul"; +import type { MassHaulPoint, MassHaulResult } from "./common_util_mass_haul"; import { crossFrom, EPSILON, @@ -55,16 +55,16 @@ import { extremaIndices, pruneExtrema, segmentOf, -} from "./B06_wf3_ProfileCross_UI_MassHaul_Curve"; +} from "./common_util_mass_haul_curve"; import { apportion, cutMix, naturalSpoilRatio, settleResiduals, sortedLimits, -} from "./B06_wf3_ProfileCross_UI_MassHaul_Settle"; +} from "./common_util_mass_haul_settle"; -export { haulPlanPayload } from "./B06_wf3_ProfileCross_UI_MassHaul_Settle"; +export { haulPlanPayload } from "./common_util_mass_haul_settle"; /** 운반거리 상한 하나(m). `max_distance_m: null`이면 상한 없음(나머지를 전부 받는다). */ export interface HaulEquipmentLimit { diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Balance_View.ts b/common_util/common_util_mass_haul_balance_view.ts similarity index 97% rename from B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Balance_View.ts rename to common_util/common_util_mass_haul_balance_view.ts index 4657fe57..58eec917 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Balance_View.ts +++ b/common_util/common_util_mass_haul_balance_view.ts @@ -1,5 +1,5 @@ /* ============================================================================= - * B06_wf3_ProfileCross_UI_MassHaul_Balance_View.ts + * common_util_mass_haul_balance_view.ts * 토량 분배(평형선 · 장비 띠) SVG 렌더러 — 유토곡선 위에 겹치는 레이어. * * 그리는 것: @@ -12,8 +12,8 @@ * 사토·토취는 도형 없이 **언더바만**(2026-08-02 사용자 지시). * * 좌표 변환(x/y)과 그릴 상자는 유토곡선 렌더러가 넘겨준다 — 축을 두 번 정의하지 않는다. - * 계산은 전부 `_UI_MassHaul_Balance`가 끝내 두므로 여기서는 배치만 판단한다. - * balloon의 도형·자리 찾기·드래그·위치 보관은 `_UI_MassHaul_Balloon`이 맡는다. + * 계산은 전부 `common_util_mass_haul_balance`가 끝내 두므로 여기서는 배치만 판단한다. + * balloon의 도형·자리 찾기·드래그·위치 보관은 `common_util_mass_haul_balloon`이 맡는다. * ========================================================================== */ import type { @@ -22,8 +22,8 @@ import type { HaulPlan, HaulResidual, HaulTransfer, -} from "./B06_wf3_ProfileCross_UI_MassHaul_Balance"; -import type { BalanceLayerBox, PlacedBox } from "./B06_wf3_ProfileCross_UI_MassHaul_Balloon"; +} from "./common_util_mass_haul_balance"; +import type { BalanceLayerBox, PlacedBox } from "./common_util_mass_haul_balloon"; import { attachBalloonDrag, balloonOffsets, @@ -42,15 +42,15 @@ import { PAD_Y, textWidth, TWO_LINE_ROOM_PX, -} from "./B06_wf3_ProfileCross_UI_MassHaul_Balloon"; -import { L, stationLabel, svgElement, svgText } from "./B06_wf3_ProfileCross_UI_Section_Common"; +} from "./common_util_mass_haul_balloon"; +import { L, stationLabel, svgElement, svgText } from "./common_util_svg"; -export type { BalanceLayerBox } from "./B06_wf3_ProfileCross_UI_MassHaul_Balloon"; +export type { BalanceLayerBox } from "./common_util_mass_haul_balloon"; export { balloonOffsetsPayload, configureBalloonOffsets, resetBalloonOffsets, -} from "./B06_wf3_ProfileCross_UI_MassHaul_Balloon"; +} from "./common_util_mass_haul_balloon"; /** * 옮긴 balloon이 그래프 밖으로 나가지 않게 이동량을 자른다. 화면을 줄이면 상자가 좁아져 diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Balloon.ts b/common_util/common_util_mass_haul_balloon.ts similarity index 98% rename from B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Balloon.ts rename to common_util/common_util_mass_haul_balloon.ts index 5fb9d37b..292049db 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Balloon.ts +++ b/common_util/common_util_mass_haul_balloon.ts @@ -1,5 +1,5 @@ /* ============================================================================= - * B06_wf3_ProfileCross_UI_MassHaul_Balloon.ts + * common_util_mass_haul_balloon.ts * 유토곡선 balloon의 공통 부품 — 좌표 상자 · 도형 · 자리 찾기 · 드래그 · 위치 보관. * * 토량 분배 렌더러(`_UI_MassHaul_Balance_View`)가 700줄을 넘겨, 「무엇을 그리나」(그쪽)와 @@ -7,9 +7,9 @@ * **함께** 쓰므로 배치·드래그 규칙이 한 곳에서만 정의된다. * ========================================================================== */ -import { L } from "./B06_wf3_ProfileCross_UI_Section_Common"; +import { L } from "./common_util_svg"; import type { LocaleKey } from "@ui/ui_template_locale"; -import { svgElement } from "./B06_wf3_ProfileCross_UI_Section_Common"; +import { svgElement } from "./common_util_svg"; /** 유토곡선 렌더러가 넘겨주는 좌표계와 그릴 수 있는 상자(px). */ export interface BalanceLayerBox { diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Curve.ts b/common_util/common_util_mass_haul_curve.ts similarity index 97% rename from B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Curve.ts rename to common_util/common_util_mass_haul_curve.ts index 795d7db5..b005a5f2 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Curve.ts +++ b/common_util/common_util_mass_haul_curve.ts @@ -1,5 +1,5 @@ /* ============================================================================= - * B06_wf3_ProfileCross_UI_MassHaul_Curve.ts + * common_util_mass_haul_curve.ts * 누가토량 곡선의 기하 — 포물선 보간 · 수평선 교점 · 극값 추출. * * ── 측점 사이를 무엇으로 잇는가: **직선**(2026-08-02 사용자 확정) ────────── @@ -14,10 +14,10 @@ * * 되돌릴 일이 생기면 `PARABOLIC_MASS_CURVE` 하나만 켜면 된다 — 그리기·재기가 함께 바뀐다. * - * 토량 분배 계산(`_UI_MassHaul_Balance`)이 700줄을 넘겨 곡선 기하만 여기로 떼어냈다. + * 토량 분배 계산(`common_util_mass_haul_balance`)이 700줄을 넘겨 곡선 기하만 여기로 떼어냈다. * ========================================================================== */ -import type { MassHaulPoint } from "./B06_wf3_ProfileCross_UI_MassHaul"; +import type { MassHaulPoint } from "./common_util_mass_haul"; export const EPSILON = 1e-9; diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Settle.ts b/common_util/common_util_mass_haul_settle.ts similarity index 95% rename from B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Settle.ts rename to common_util/common_util_mass_haul_settle.ts index eb44049c..59ef0485 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Settle.ts +++ b/common_util/common_util_mass_haul_settle.ts @@ -1,24 +1,24 @@ /* ============================================================================= - * B06_wf3_ProfileCross_UI_MassHaul_Settle.ts + * common_util_mass_haul_settle.ts * 토량 분배의 **정산** 쪽 — 지반유형 안분 · 장비 선정 · 잉여/부족 상쇄 · 직렬화. * - * `_UI_MassHaul_Balance`가 700줄을 넘겨, 「곡선에서 블록·띠를 어떻게 뽑나」(그쪽)와 + * `common_util_mass_haul_balance`가 700줄을 넘겨, 「곡선에서 블록·띠를 어떻게 뽑나」(그쪽)와 * 「뽑은 물량을 무엇으로 나누고 어디로 보내나」(여기)로 갈랐다. * * 의존 방향은 **한 쪽뿐**이다: Balance → Settle(함수), Settle → Balance(타입만). * 타입 import는 실행 시 사라지므로 순환이 생기지 않는다. * ========================================================================== */ -import type { EarthworkConversion, GroundType } from "./B06_wf3_ProfileCross_Api_Fetch"; -import type { MassHaulPoint } from "./B06_wf3_ProfileCross_UI_MassHaul"; +import type { EarthworkConversion, GroundType } from "./common_util_mass_haul_types"; +import type { MassHaulPoint } from "./common_util_mass_haul"; import type { HaulBand, HaulEquipmentLimit, HaulPlan, HaulResidual, HaulTransfer, -} from "./B06_wf3_ProfileCross_UI_MassHaul_Balance"; -import { EPSILON } from "./B06_wf3_ProfileCross_UI_MassHaul_Curve"; +} from "./common_util_mass_haul_balance"; +import { EPSILON } from "./common_util_mass_haul_curve"; /** 환산계수가 비어 있거나 값이 이상하면 1.0으로 떨어뜨려 계산이 멈추지 않게 한다. */ function factorFor(conversion: EarthworkConversion, ground: GroundType): number { diff --git a/common_util/common_util_mass_haul_types.ts b/common_util/common_util_mass_haul_types.ts new file mode 100644 index 00000000..81cfce79 --- /dev/null +++ b/common_util/common_util_mass_haul_types.ts @@ -0,0 +1,84 @@ +/* ============================================================================= + * common_util_mass_haul_types.ts + * 유토곡선 엔진이 읽는 최소 입력 타입 — B05(계획 기준)와 B06(정식 기준)이 함께 쓴다. + * + * 엔진은 횡단 설계 결과에서 **자기가 실제로 읽는 필드만** 알면 된다. 페이지별 API 응답 + * 타입(`B06_..._Api_Fetch.CrossSection` 등)을 그대로 요구하면 B05가 B06 모듈을 끌어와야 + * 하므로, 구조적 부분집합만 여기 두고 각 페이지 타입이 그대로 대입되게 한다. + * + * 지반유형·토량환산계수는 정의처가 `config_system.py` 한 곳이므로 여기서도 한 번만 적고, + * 페이지 API 타입 모듈이 이 정의를 재수출한다(사본 금지). + * ========================================================================== */ + +export type GroundType = "soil" | "ripping_rock" | "blasting_rock"; + +/** + * 지반유형 하나의 토량환산계수(둘 다 자연상태 기준 배율). + * loose = 흐트러진 상태 / 자연상태(팽창률), compacted = 다져진 상태 / 자연상태(다짐률). + * 정의처는 config_system.EARTHWORK_CONVERSION_FACTORS 한 곳뿐이다. + */ +export interface EarthworkConversionFactor { + loose: number; + compacted: number; +} + +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; +} + +/** 유토곡선 balloon을 끌어 옮긴 위치(띠 번호 → [dx, dy], px). */ +export type BalloonOffsets = Record; + +/** 엔진이 읽는 횡단 설계 필드만 추린 구조. 페이지 `CrossDesign`이 그대로 대입된다. */ +export interface MassHaulDesign { + ground_type: GroundType; + roadbed_width_m: number; + cut_area_m2: number; + cut_soil_area_m2?: number; + cut_rock_area_m2?: number; + cut_rock_kind?: GroundType | null; + fill_area_m2: number; + /** 성토측 자연 지반 경사(rise/run). 자연방토 판정 입력. 성토측이 없으면 null. */ + fill_ground_slope?: number | null; +} + +/** 엔진이 읽는 측점 필드만 추린 구조. 페이지 `CrossSection`이 그대로 대입된다. */ +export interface MassHaulSection { + station_id: string; + chainage_m: number; + design?: MassHaulDesign; +} + +/** 계획선 샘플 중 개략 토량 산출에 쓰는 필드(`difference_m = 계획고 − 지반고`). */ +export interface MassHaulProfileSample { + chainage_m: number; + difference_m: number; +} + +export interface MassHaulProfile { + samples: MassHaulProfileSample[]; +} + +/** 엔진이 읽는 종단 필드만 추린 구조. 페이지 `LongitudinalSection`이 그대로 대입된다. */ +export interface MassHaulLongitudinal { + length_m: number; + design_profiles?: MassHaulProfile[]; +} + +/** 유토곡선에 측점선을 세울 때 읽는 필드. `kind`는 선 색 변형 클래스에 그대로 붙는다. */ +export interface MassHaulStation { + station_id: string; + chainage_m: number; + kind: string; +} + +export interface MassHaulStationSource { + stations: MassHaulStation[]; +} diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_View.ts b/common_util/common_util_mass_haul_view.ts similarity index 92% rename from B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_View.ts rename to common_util/common_util_mass_haul_view.ts index 123db1bd..232b8096 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_View.ts +++ b/common_util/common_util_mass_haul_view.ts @@ -1,5 +1,5 @@ /* ============================================================================= - * B06_wf3_ProfileCross_UI_MassHaul_View.ts + * common_util_mass_haul_view.ts * 유토곡선(Mass Haul Diagram) SVG 렌더러. * * 종단면도(`_UI_Longitudinal`) 바로 아래에 붙어 **같은 X 매핑**을 쓴다. 폭·좌우 여백 @@ -15,25 +15,25 @@ * 근거: docs/raw/2026-08-02_유토곡선_3공구_분석.md 3.2절 * ========================================================================== */ -import type { LongitudinalSection } from "./B06_wf3_ProfileCross_Api_Fetch"; -import type { - MassHaulBasis, - MassHaulPoint, - MassHaulSeries, -} from "./B06_wf3_ProfileCross_UI_MassHaul"; -import type { HaulPlan } from "./B06_wf3_ProfileCross_UI_MassHaul_Balance"; +import type { MassHaulBasis, MassHaulPoint, MassHaulSeries } from "./common_util_mass_haul"; +import type { MassHaulStationSource } from "./common_util_mass_haul_types"; +import type { HaulPlan } from "./common_util_mass_haul_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 { segmentOf } from "./B06_wf3_ProfileCross_UI_MassHaul_Curve"; -import { - L, - LONG_PAD, - longitudinalMaxChainage, - stationLabel, - svgElement, - svgText, -} from "./B06_wf3_ProfileCross_UI_Section_Common"; +import { MASS_HAUL_BALANCE_KEY } from "./common_util_mass_haul"; +import { appendBalanceLayer, haulPlanChips } from "./common_util_mass_haul_balance_view"; +import { segmentOf } from "./common_util_mass_haul_curve"; +import { L, stationLabel, svgElement, svgText } from "./common_util_svg"; + +/** + * 유토곡선 X축을 **호출한 쪽 종단 그래프와 맞추는** 값. 좌우 여백과 누가거리 최댓값이 + * 종단도와 같아야 같은 측점이 같은 가로 위치에 온다. 페이지마다 종단 렌더러가 다르므로 + * (B06 `_UI_Longitudinal`, B05 종단 그래프) 상수를 이 모듈에 두지 않고 받아 쓴다. + */ +export interface MassHaulAxis { + maxChainageM: number; + padLeft: number; + padRight: number; +} /** 축 눈금이 읽히는 최소 높이. 이보다 낮아지면 그래프가 뭉개진다. */ export const MASS_HAUL_MIN_HEIGHT = 90; @@ -64,7 +64,7 @@ function seriesClass(series: MassHaulSeries, base: string): string { } /** - * 곡선 경로. **`_UI_MassHaul_Curve`가 쓰는 것과 같은 기하**로 그려야 한다 — 평형선·장비 + * 곡선 경로. **`common_util_mass_haul_curve`가 쓰는 것과 같은 기하**로 그려야 한다 — 평형선·장비 * 경계현·평균운반거리선의 양 끝이 그 기하와의 교점으로 계산되므로, 그리기와 재기가 어긋나면 * 수평선 끝이 곡선에서 떠 보인다(2026-08-02에 겪은 문제). * @@ -132,7 +132,7 @@ function volumeRange(series: MassHaulSeries[]): { min: number; max: number } { */ export function massHaulPointAt( series: MassHaulSeries, - longitudinal: LongitudinalSection, + longitudinal: MassHaulStationSource, stationId: string | null, ): MassHaulPoint | null { if (!stationId) return null; @@ -265,7 +265,8 @@ function appendVolumeCallout( export function createMassHaulChart( series: MassHaulSeries[], visibleKeys: ReadonlySet, - longitudinal: LongitudinalSection, + longitudinal: MassHaulStationSource, + axis: MassHaulAxis, selectedStationId: string | null, stationInterval: number, widthPx: number, @@ -286,11 +287,11 @@ export function createMassHaulChart( 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; + // 종단면도와 완전히 같은 X 매핑 — 여백·최댓값 모두 호출한 쪽 종단 렌더러에서 받는다. + const maxChainage = Math.max(axis.maxChainageM, 1); + const plotWidth = widthPx - axis.padLeft - axis.padRight; const plotHeight = heightPx - MASS_PAD_TOP - MASS_PAD_BOTTOM; - const x = (chainage: number) => LONG_PAD.left + (chainage / maxChainage) * plotWidth; + const x = (chainage: number) => axis.padLeft + (chainage / maxChainage) * plotWidth; const { min, max } = volumeRange(series); const span = Math.max(max - min, 1e-6); @@ -303,14 +304,14 @@ export function createMassHaulChart( const value = max - ratio * span; svg.append( svgElement("line", { - x1: LONG_PAD.left, + x1: axis.padLeft, y1: gridY, - x2: widthPx - LONG_PAD.right, + x2: widthPx - axis.padRight, y2: gridY, class: "b06-chart__grid", }), svgText(formatVolume(value), { - x: LONG_PAD.left - 9, + x: axis.padLeft - 9, y: gridY + 4, "text-anchor": "end", class: "b06-chart__tick", @@ -390,14 +391,14 @@ export function createMassHaulChart( // Y 범위 계산(`volumeRange`)이 0을 항상 품으므로 이 선은 어떤 노선에서도 화면에 남는다. svg.append( svgElement("line", { - x1: LONG_PAD.left, + x1: axis.padLeft, y1: zeroY, - x2: widthPx - LONG_PAD.right, + x2: widthPx - axis.padRight, y2: zeroY, class: "b06-masshaul__zero", }), svgText("0", { - x: LONG_PAD.left - 9, + x: axis.padLeft - 9, y: zeroY + 4, "text-anchor": "end", class: "b06-chart__tick b06-masshaul__zero-tick", @@ -420,8 +421,8 @@ export function createMassHaulChart( appendBalanceLayer(svg, haulPlan, { x, y, - left: LONG_PAD.left, - right: widthPx - LONG_PAD.right, + left: axis.padLeft, + right: widthPx - axis.padRight, top: MASS_PAD_TOP, bottom: heightPx - MASS_PAD_BOTTOM, curveYAt, @@ -444,8 +445,8 @@ export function createMassHaulChart( appendVolumeCallout(svg, focus, stationInterval, { anchorX: focusX, anchorY: y(focus.cumulative_volume_m3), - left: LONG_PAD.left, - right: widthPx - LONG_PAD.right, + left: axis.padLeft, + right: widthPx - axis.padRight, top: MASS_PAD_TOP, bottom: heightPx - MASS_PAD_BOTTOM, curveYAt, @@ -454,9 +455,9 @@ export function createMassHaulChart( svg.append( svgElement("line", { - x1: LONG_PAD.left, + x1: axis.padLeft, y1: MASS_PAD_TOP, - x2: LONG_PAD.left, + x2: axis.padLeft, y2: heightPx - MASS_PAD_BOTTOM, class: "b06-chart__axis", }), diff --git a/common_util/common_util_svg.ts b/common_util/common_util_svg.ts new file mode 100644 index 00000000..d2498638 --- /dev/null +++ b/common_util/common_util_svg.ts @@ -0,0 +1,63 @@ +/* ============================================================================= + * common_util_svg.ts + * SVG 도면 렌더러가 공유하는 최소 유틸리티 — 요소 생성·텍스트·측점 표기. + * + * B06 종·횡단 렌더러가 쓰던 것을 유토곡선 모듈 공용화(2026-08-03)에 맞춰 옮겼다. + * B05 계획 유토곡선과 B06 정식 유토곡선이 **같은 표기 규칙**을 쓰게 하는 것이 목적이며, + * B06 쪽은 `_UI_Section_Common`이 그대로 재수출해 기존 임포트 경로를 유지한다. + * ========================================================================== */ + +import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; + +export const SVG_NS = "http://www.w3.org/2000/svg"; + +export function L(key: keyof typeof ui_locales): string { + return ui_locales[key][currentLanguageIndex]; +} + +export function svgElement( + tag: K, + attributes: Record = {}, +): SVGElementTagNameMap[K] { + const element = document.createElementNS(SVG_NS, tag); + Object.entries(attributes).forEach(([key, value]) => element.setAttribute(key, String(value))); + return element; +} + +export function svgText( + value: string, + attributes: Record, +): SVGTextElement { + const text = svgElement("text", attributes); + text.textContent = value; + return text; +} + +/** 측점 간격을 실제 측점 배열에서 되짚는다(최빈 간격). 라벨 표기의 분모다. */ +export function inferStationInterval(stations: Array<{ chainage_m: number }>): number { + const counts = new Map(); + for (let index = 1; index < stations.length; index += 1) { + const difference = stations[index].chainage_m - stations[index - 1].chainage_m; + if (difference <= 0) continue; + const rounded = Math.round(difference * 10) / 10; + counts.set(rounded, (counts.get(rounded) ?? 0) + 1); + } + return ( + [...counts.entries()].sort( + ([intervalA, countA], [intervalB, countB]) => countB - countA || intervalB - intervalA, + )[0]?.[0] ?? 1 + ); +} + +/** 측점 라벨 — "STA" 접두사 없이 `측점번호+잔여거리`(예: `4+0.0`). */ +export function stationLabel(chainage: number, interval: number): string { + const safeInterval = interval > 0 ? interval : 1; + let stationNumber = Math.floor((chainage + 1e-6) / safeInterval); + let remainder = chainage - stationNumber * safeInterval; + if (Math.abs(remainder) < 0.05) remainder = 0; + if (remainder >= safeInterval - 0.05) { + stationNumber += 1; + remainder = 0; + } + return `${stationNumber}+${remainder.toFixed(1)}`; +}