refactor(common): 유토곡선 엔진·렌더러를 common_util로 공용화

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) <noreply@anthropic.com>
This commit is contained in:
2026-08-03 18:35:02 +09:00
co-authored by Claude Opus 5
parent e4700546bb
commit fceec5fb24
14 changed files with 273 additions and 167 deletions
@@ -15,6 +15,12 @@
* ========================================================================== */ * ========================================================================== */
import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend"; 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 { export interface SectionOptionDefaults {
station_interval_m: number; station_interval_m: number;
@@ -51,26 +57,15 @@ export type StandardCrossKey = "soil" | "rock" | "paved";
export type StandardCrossSection = Record<StandardCrossKey, StandardCrossGroup>; export type StandardCrossSection = Record<StandardCrossKey, StandardCrossGroup>;
/** // 지반유형·토량환산계수·운반장비 한계거리는 B05 계획 유토곡선과 공유하므로 정의처를
* 지반유형 하나의 토량환산계수(둘 다 자연상태 기준 배율). // `@util/common_util_mass_haul_types` 한 곳에 두고 여기서는 재수출만 한다(사본 금지).
* loose = 흐트러진 상태 / 자연상태(팽창률), compacted = 다져진 상태 / 자연상태(다짐률). export type {
* 정의처는 config_system.EARTHWORK_CONVERSION_FACTORS 한 곳뿐이다. GroundType,
*/ EarthworkConversionFactor,
export interface EarthworkConversionFactor { EarthworkConversion,
loose: number; HaulEquipmentLimit,
compacted: number; BalloonOffsets,
} } from "@util/common_util_mass_haul_types";
export type EarthworkConversion = Record<GroundType, EarthworkConversionFactor>;
/**
* 평균운반거리 상한 하나(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 { export interface SectionContextResponse {
project_id: string; project_id: string;
@@ -188,9 +183,6 @@ export interface LongitudinalSection {
profile_alignment?: unknown; profile_alignment?: unknown;
} }
/** 유토곡선 balloon을 끌어 옮긴 위치(띠 번호 → [dx, dy], px). */
export type BalloonOffsets = Record<string, [number, number]>;
export interface CrossSection extends SectionStation { export interface CrossSection extends SectionStation {
samples: SectionSample[]; samples: SectionSample[];
/** DB에 저장된 잠정 설계 지정(있을 때만). 상세 조회 시 얹혀 온다. */ /** 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 SectionMode = "left_cut" | "right_cut" | "both_cut" | "both_fill";
export type DitchSide = "left" | "right"; export type DitchSide = "left" | "right";
export type DitchType = "standard" | "l_type"; export type DitchType = "standard" | "l_type";
@@ -34,9 +34,9 @@ import {
createSectionView, createSectionView,
type RockBoundaryControl, type RockBoundaryControl,
} from "./B06_wf3_ProfileCross_UI_Section_View"; } from "./B06_wf3_ProfileCross_UI_Section_View";
import { computeMassHaul, massHaulPayload } from "./B06_wf3_ProfileCross_UI_MassHaul"; import { computeMassHaul, massHaulPayload } from "@util/common_util_mass_haul";
import { computeHaulPlan } from "./B06_wf3_ProfileCross_UI_MassHaul_Balance"; import { computeHaulPlan } from "@util/common_util_mass_haul_balance";
import { balloonOffsetsPayload } from "./B06_wf3_ProfileCross_UI_MassHaul_Balance_View"; import { balloonOffsetsPayload } from "@util/common_util_mass_haul_balance_view";
import { designElevationAt } from "./B06_wf3_ProfileCross_UI_Section_Common"; import { designElevationAt } from "./B06_wf3_ProfileCross_UI_Section_Common";
import { import {
createStandardPanel, createStandardPanel,
@@ -45,7 +45,7 @@ import {
import "./B06_wf3_ProfileCross_UI_Style.css"; import "./B06_wf3_ProfileCross_UI_Style.css";
import "./B06_wf3_ProfileCross_UI_Style_Cross.css"; import "./B06_wf3_ProfileCross_UI_Style_Cross.css";
import "./B06_wf3_ProfileCross_UI_Style_Cross_Areas.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 { function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex]; return ui_locales[key][currentLanguageIndex];
@@ -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 { CrossDesignChange } from "./B06_wf3_ProfileCross_UI_Cross_Design";
import type { import type {
DesignProfile, DesignProfile,
@@ -19,7 +18,17 @@ import type {
export type { CrossDesignChange }; export type { CrossDesignChange };
export type DesignChangeHandler = (chainageM: number, change: CrossDesignChange) => void; 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_WIDTH = 1200;
export const LONG_HEIGHT = 220; export const LONG_HEIGHT = 220;
export const CROSS_WIDTH = 560; export const CROSS_WIDTH = 560;
@@ -44,28 +53,6 @@ export interface YScaleOptions {
globalMaxElevation: number; globalMaxElevation: number;
} }
export function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
export function svgElement<K extends keyof SVGElementTagNameMap>(
tag: K,
attributes: Record<string, string | number> = {},
): 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<string, string | number>,
): SVGTextElement {
const text = svgElement("text", attributes);
text.textContent = value;
return text;
}
export function validElevation( export function validElevation(
sample: SectionSample, sample: SectionSample,
): sample is SectionSample & { elevation_m: number } { ): sample is SectionSample & { elevation_m: number } {
@@ -146,30 +133,3 @@ export function emptyView(message: string): HTMLElement {
empty.textContent = message; empty.textContent = message;
return empty; return empty;
} }
export function inferStationInterval(stations: Array<{ chainage_m: number }>): number {
const counts = new Map<number, number>();
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)}`;
}
@@ -37,19 +37,19 @@ import {
MASS_HAUL_BALANCE_KEY, MASS_HAUL_BALANCE_KEY,
MASS_HAUL_DEFAULT_VISIBLE, MASS_HAUL_DEFAULT_VISIBLE,
type MassHaulSeries, type MassHaulSeries,
} from "./B06_wf3_ProfileCross_UI_MassHaul"; } from "@util/common_util_mass_haul";
import { computeHaulPlan } from "./B06_wf3_ProfileCross_UI_MassHaul_Balance"; import { computeHaulPlan } from "@util/common_util_mass_haul_balance";
import { import {
configureBalloonOffsets, configureBalloonOffsets,
resetBalloonOffsets, resetBalloonOffsets,
} from "./B06_wf3_ProfileCross_UI_MassHaul_Balance_View"; } from "@util/common_util_mass_haul_balance_view";
import { import {
createMassHaulChart, createMassHaulChart,
createMassHaulLegend, createMassHaulLegend,
createMassHaulSummary, createMassHaulSummary,
MASS_HAUL_HEIGHT, MASS_HAUL_HEIGHT,
MASS_HAUL_MIN_HEIGHT, MASS_HAUL_MIN_HEIGHT,
} from "./B06_wf3_ProfileCross_UI_MassHaul_View"; } from "@util/common_util_mass_haul_view";
import { import {
calculateYScale, calculateYScale,
CROSS_GRID_GAP, CROSS_GRID_GAP,
@@ -62,6 +62,8 @@ import {
inferStationInterval, inferStationInterval,
L, L,
LONG_HEIGHT, LONG_HEIGHT,
longitudinalMaxChainage,
LONG_PAD,
type YScaleOptions, type YScaleOptions,
} from "./B06_wf3_ProfileCross_UI_Section_Common"; } from "./B06_wf3_ProfileCross_UI_Section_Common";
@@ -488,6 +490,11 @@ export function createSectionView(
series, series,
visibleSeries, visibleSeries,
detail.longitudinal, detail.longitudinal,
{
maxChainageM: longitudinalMaxChainage(detail.longitudinal),
padLeft: LONG_PAD.left,
padRight: LONG_PAD.right,
},
selectedStationId, selectedStationId,
cachedStationInterval, cachedStationInterval,
chartWidth, chartWidth,
@@ -1,10 +1,10 @@
/* ============================================================================= /* =============================================================================
* B06_wf3_ProfileCross_UI_MassHaul.ts * common_util_mass_haul.ts
* (Mass Haul Diagram) , DOM . * (Mass Haul Diagram) , DOM .
* *
* . * .
* B08 ·B09 * B08 ·B09
* ( `_UI_MassHaul_View`). * ( `common_util_mass_haul_view`).
* *
* 기준: 다짐상태 * 기준: 다짐상태
* " , * " ,
@@ -34,14 +34,14 @@
* ========================================================================== */ * ========================================================================== */
import type { import type {
CrossDesign,
CrossSection,
EarthworkConversion, EarthworkConversion,
GroundType, GroundType,
LongitudinalSection, MassHaulDesign,
} from "./B06_wf3_ProfileCross_Api_Fetch"; MassHaulLongitudinal,
import type { HaulPlan } from "./B06_wf3_ProfileCross_UI_MassHaul_Balance"; MassHaulSection,
import { haulPlanPayload } from "./B06_wf3_ProfileCross_UI_MassHaul_Balance"; } 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(발파암)에 대응한다. */ /** 지반유형별 토량(㎥). 도면 표기 EA(토사)/RR(리핑암)/BR(발파암)에 대응한다. */
export interface GroundVolumes { export interface GroundVolumes {
@@ -63,7 +63,7 @@ export interface MassHaulPoint {
cut_rock_m3: number; cut_rock_m3: number;
/** /**
* (, `cut_rr_m3 + cut_br_m3 === cut_rock_m3`). * (, `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; cut_rr_m3: number;
@@ -83,7 +83,7 @@ export interface MassHaulPoint {
* **** * ****
* 2 . ( ) * 2 . ( )
* 40% ( 20m 20/2 = 14.14m). * 40% ( 20m 20/2 = 14.14m).
* `_UI_MassHaul_Balance` 2 . * `common_util_mass_haul_balance` 2 .
*/ */
net_area_m2: number; net_area_m2: number;
} }
@@ -173,7 +173,7 @@ interface AreaSample {
* . ( ) * . ( )
* ** ** . * ** ** .
*/ */
function splitCut(design: CrossDesign | undefined): { function splitCut(design: MassHaulDesign | undefined): {
soil: number; soil: number;
rock: number; rock: number;
rock_kind: GroundType | null; 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 return crossSections
.filter((section) => section.design && Number.isFinite(section.chainage_m)) .filter((section) => section.design && Number.isFinite(section.chainage_m))
.sort((a, b) => a.chainage_m - b.chainage_m); .sort((a, b) => a.chainage_m - b.chainage_m);
@@ -319,14 +319,14 @@ function designedSections(crossSections: CrossSection[]): CrossSection[] {
* . (config) ** ** * . (config) ** **
* , . * , .
*/ */
function naturalSpoilAllowed(design: CrossDesign | undefined, minSlope?: number): boolean { function naturalSpoilAllowed(design: MassHaulDesign | undefined, minSlope?: number): boolean {
if (!Number.isFinite(minSlope)) return false; if (!Number.isFinite(minSlope)) return false;
const slope = design?.fill_ground_slope; const slope = design?.fill_ground_slope;
return Number.isFinite(slope) && (slope as number) >= (minSlope as number); 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) => { return sections.map((section) => {
const cut = splitCut(section.design); const cut = splitCut(section.design);
return { return {
@@ -353,8 +353,8 @@ function crossAreaSamples(sections: CrossSection[], minSlope?: number): AreaSamp
* ( ). * ( ).
*/ */
function longitudinalAreaSamples( function longitudinalAreaSamples(
longitudinal: LongitudinalSection, longitudinal: MassHaulLongitudinal,
sections: CrossSection[], sections: MassHaulSection[],
): AreaSample[] { ): AreaSample[] {
const profile = longitudinal.design_profiles?.[0]; const profile = longitudinal.design_profiles?.[0];
if (!profile || !sections.length) return []; if (!profile || !sections.length) return [];
@@ -397,7 +397,7 @@ function longitudinalAreaSamples(
* B08 ** ** . * B08 ** ** .
*/ */
export function computeMassHaul( export function computeMassHaul(
crossSections: CrossSection[], crossSections: MassHaulSection[],
conversion: EarthworkConversion, conversion: EarthworkConversion,
naturalSpoilMinSlope?: number, naturalSpoilMinSlope?: number,
): MassHaulResult | null { ): MassHaulResult | null {
@@ -412,8 +412,8 @@ export function computeMassHaul(
* ( , ) . * ( , ) .
*/ */
export function computeMassHaulSeries( export function computeMassHaulSeries(
longitudinal: LongitudinalSection, longitudinal: MassHaulLongitudinal,
crossSections: CrossSection[], crossSections: MassHaulSection[],
conversion: EarthworkConversion, conversion: EarthworkConversion,
naturalSpoilMinSlope?: number, naturalSpoilMinSlope?: number,
): MassHaulSeries[] { ): MassHaulSeries[] {
@@ -1,8 +1,8 @@
/* ============================================================================= /* =============================================================================
* B06_wf3_ProfileCross_UI_MassHaul_Balance.ts * common_util_mass_haul_balance.ts
* ( · · ) , DOM . * ( · · ) , DOM .
* *
* (`_UI_MassHaul`) "얼마가 남는가" . * (`common_util_mass_haul`) "얼마가 남는가" .
* ** ** 낸다: 평형선 * ** ** 낸다: 평형선
* `Q` · `L` · . * `Q` · `L` · .
* *
@@ -47,7 +47,7 @@
* `MassHaulResult.cut_natural_m3` / `cut_soil_m3`·`cut_rr_m3`·`cut_br_m3` . * `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 { import {
crossFrom, crossFrom,
EPSILON, EPSILON,
@@ -55,16 +55,16 @@ import {
extremaIndices, extremaIndices,
pruneExtrema, pruneExtrema,
segmentOf, segmentOf,
} from "./B06_wf3_ProfileCross_UI_MassHaul_Curve"; } from "./common_util_mass_haul_curve";
import { import {
apportion, apportion,
cutMix, cutMix,
naturalSpoilRatio, naturalSpoilRatio,
settleResiduals, settleResiduals,
sortedLimits, 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`이면 상한 없음(나머지를 전부 받는다). */ /** 운반거리 상한 하나(m). `max_distance_m: null`이면 상한 없음(나머지를 전부 받는다). */
export interface HaulEquipmentLimit { export interface HaulEquipmentLimit {
@@ -1,5 +1,5 @@
/* ============================================================================= /* =============================================================================
* B06_wf3_ProfileCross_UI_MassHaul_Balance_View.ts * common_util_mass_haul_balance_view.ts
* ( · ) SVG . * ( · ) SVG .
* *
* : * :
@@ -12,8 +12,8 @@
* · ****(2026-08-02 ). * · ****(2026-08-02 ).
* *
* (x/y) . * (x/y) .
* `_UI_MassHaul_Balance` . * `common_util_mass_haul_balance` .
* balloon의 · ·· `_UI_MassHaul_Balloon` . * balloon의 · ·· `common_util_mass_haul_balloon` .
* ========================================================================== */ * ========================================================================== */
import type { import type {
@@ -22,8 +22,8 @@ import type {
HaulPlan, HaulPlan,
HaulResidual, HaulResidual,
HaulTransfer, HaulTransfer,
} from "./B06_wf3_ProfileCross_UI_MassHaul_Balance"; } from "./common_util_mass_haul_balance";
import type { BalanceLayerBox, PlacedBox } from "./B06_wf3_ProfileCross_UI_MassHaul_Balloon"; import type { BalanceLayerBox, PlacedBox } from "./common_util_mass_haul_balloon";
import { import {
attachBalloonDrag, attachBalloonDrag,
balloonOffsets, balloonOffsets,
@@ -42,15 +42,15 @@ import {
PAD_Y, PAD_Y,
textWidth, textWidth,
TWO_LINE_ROOM_PX, TWO_LINE_ROOM_PX,
} from "./B06_wf3_ProfileCross_UI_MassHaul_Balloon"; } from "./common_util_mass_haul_balloon";
import { L, stationLabel, svgElement, svgText } from "./B06_wf3_ProfileCross_UI_Section_Common"; 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 { export {
balloonOffsetsPayload, balloonOffsetsPayload,
configureBalloonOffsets, configureBalloonOffsets,
resetBalloonOffsets, resetBalloonOffsets,
} from "./B06_wf3_ProfileCross_UI_MassHaul_Balloon"; } from "./common_util_mass_haul_balloon";
/** /**
* balloon이 . * balloon이 .
@@ -1,5 +1,5 @@
/* ============================================================================= /* =============================================================================
* B06_wf3_ProfileCross_UI_MassHaul_Balloon.ts * common_util_mass_haul_balloon.ts
* balloon의 · · · · . * balloon의 · · · · .
* *
* (`_UI_MassHaul_Balance_View`) 700 , () * (`_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 type { LocaleKey } from "@ui/ui_template_locale";
import { svgElement } from "./B06_wf3_ProfileCross_UI_Section_Common"; import { svgElement } from "./common_util_svg";
/** 유토곡선 렌더러가 넘겨주는 좌표계와 그릴 수 있는 상자(px). */ /** 유토곡선 렌더러가 넘겨주는 좌표계와 그릴 수 있는 상자(px). */
export interface BalanceLayerBox { export interface BalanceLayerBox {
@@ -1,5 +1,5 @@
/* ============================================================================= /* =============================================================================
* B06_wf3_ProfileCross_UI_MassHaul_Curve.ts * common_util_mass_haul_curve.ts
* · · . * · · .
* *
* : ****(2026-08-02 ) * : ****(2026-08-02 )
@@ -14,10 +14,10 @@
* *
* `PARABOLIC_MASS_CURVE` · . * `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; export const EPSILON = 1e-9;
@@ -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(). * ** **이다: Balance Settle(), Settle Balance().
* import는 . * import는 .
* ========================================================================== */ * ========================================================================== */
import type { EarthworkConversion, GroundType } from "./B06_wf3_ProfileCross_Api_Fetch"; import type { EarthworkConversion, GroundType } from "./common_util_mass_haul_types";
import type { MassHaulPoint } from "./B06_wf3_ProfileCross_UI_MassHaul"; import type { MassHaulPoint } from "./common_util_mass_haul";
import type { import type {
HaulBand, HaulBand,
HaulEquipmentLimit, HaulEquipmentLimit,
HaulPlan, HaulPlan,
HaulResidual, HaulResidual,
HaulTransfer, HaulTransfer,
} from "./B06_wf3_ProfileCross_UI_MassHaul_Balance"; } from "./common_util_mass_haul_balance";
import { EPSILON } from "./B06_wf3_ProfileCross_UI_MassHaul_Curve"; import { EPSILON } from "./common_util_mass_haul_curve";
/** 환산계수가 비어 있거나 값이 이상하면 1.0으로 떨어뜨려 계산이 멈추지 않게 한다. */ /** 환산계수가 비어 있거나 값이 이상하면 1.0으로 떨어뜨려 계산이 멈추지 않게 한다. */
function factorFor(conversion: EarthworkConversion, ground: GroundType): number { function factorFor(conversion: EarthworkConversion, ground: GroundType): number {
@@ -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<GroundType, EarthworkConversionFactor>;
/**
* 평균운반거리 상한 하나(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<string, [number, number]>;
/** 엔진이 읽는 횡단 설계 필드만 추린 구조. 페이지 `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[];
}
@@ -1,5 +1,5 @@
/* ============================================================================= /* =============================================================================
* B06_wf3_ProfileCross_UI_MassHaul_View.ts * common_util_mass_haul_view.ts
* (Mass Haul Diagram) SVG . * (Mass Haul Diagram) SVG .
* *
* (`_UI_Longitudinal`) ** X ** . · * (`_UI_Longitudinal`) ** X ** . ·
@@ -15,25 +15,25 @@
* 근거: docs/raw/2026-08-02_유토곡선_3공구_분석.md 3.2 * 근거: docs/raw/2026-08-02_유토곡선_3공구_분석.md 3.2
* ========================================================================== */ * ========================================================================== */
import type { LongitudinalSection } from "./B06_wf3_ProfileCross_Api_Fetch"; import type { MassHaulBasis, MassHaulPoint, MassHaulSeries } from "./common_util_mass_haul";
import type { import type { MassHaulStationSource } from "./common_util_mass_haul_types";
MassHaulBasis, import type { HaulPlan } from "./common_util_mass_haul_balance";
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 type { LocaleKey } from "@ui/ui_template_locale";
import { MASS_HAUL_BALANCE_KEY } from "./B06_wf3_ProfileCross_UI_MassHaul"; import { MASS_HAUL_BALANCE_KEY } from "./common_util_mass_haul";
import { appendBalanceLayer, haulPlanChips } from "./B06_wf3_ProfileCross_UI_MassHaul_Balance_View"; import { appendBalanceLayer, haulPlanChips } from "./common_util_mass_haul_balance_view";
import { segmentOf } from "./B06_wf3_ProfileCross_UI_MassHaul_Curve"; import { segmentOf } from "./common_util_mass_haul_curve";
import { import { L, stationLabel, svgElement, svgText } from "./common_util_svg";
L,
LONG_PAD, /**
longitudinalMaxChainage, * X축을 ** ** .
stationLabel, * .
svgElement, * (B06 `_UI_Longitudinal`, B05 ) .
svgText, */
} from "./B06_wf3_ProfileCross_UI_Section_Common"; export interface MassHaulAxis {
maxChainageM: number;
padLeft: number;
padRight: number;
}
/** 축 눈금이 읽히는 최소 높이. 이보다 낮아지면 그래프가 뭉개진다. */ /** 축 눈금이 읽히는 최소 높이. 이보다 낮아지면 그래프가 뭉개진다. */
export const MASS_HAUL_MIN_HEIGHT = 90; 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 ). * (2026-08-02 ).
* *
@@ -132,7 +132,7 @@ function volumeRange(series: MassHaulSeries[]): { min: number; max: number } {
*/ */
export function massHaulPointAt( export function massHaulPointAt(
series: MassHaulSeries, series: MassHaulSeries,
longitudinal: LongitudinalSection, longitudinal: MassHaulStationSource,
stationId: string | null, stationId: string | null,
): MassHaulPoint | null { ): MassHaulPoint | null {
if (!stationId) return null; if (!stationId) return null;
@@ -265,7 +265,8 @@ function appendVolumeCallout(
export function createMassHaulChart( export function createMassHaulChart(
series: MassHaulSeries[], series: MassHaulSeries[],
visibleKeys: ReadonlySet<string>, visibleKeys: ReadonlySet<string>,
longitudinal: LongitudinalSection, longitudinal: MassHaulStationSource,
axis: MassHaulAxis,
selectedStationId: string | null, selectedStationId: string | null,
stationInterval: number, stationInterval: number,
widthPx: number, widthPx: number,
@@ -286,11 +287,11 @@ export function createMassHaulChart(
svg.style.minWidth = `${minimumWidthPx}px`; svg.style.minWidth = `${minimumWidthPx}px`;
svg.append(svgElement("rect", { width: widthPx, height: heightPx, class: "b06-chart__bg" })); svg.append(svgElement("rect", { width: widthPx, height: heightPx, class: "b06-chart__bg" }));
// 종단면도와 완전히 같은 X 매핑 — 여백·최댓값 모두 종단 렌더러와 공유한다. // 종단면도와 완전히 같은 X 매핑 — 여백·최댓값 모두 호출한 쪽 종단 렌더러에서 받는다.
const maxChainage = longitudinalMaxChainage(longitudinal); const maxChainage = Math.max(axis.maxChainageM, 1);
const plotWidth = widthPx - LONG_PAD.left - LONG_PAD.right; const plotWidth = widthPx - axis.padLeft - axis.padRight;
const plotHeight = heightPx - MASS_PAD_TOP - MASS_PAD_BOTTOM; 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 { min, max } = volumeRange(series);
const span = Math.max(max - min, 1e-6); const span = Math.max(max - min, 1e-6);
@@ -303,14 +304,14 @@ export function createMassHaulChart(
const value = max - ratio * span; const value = max - ratio * span;
svg.append( svg.append(
svgElement("line", { svgElement("line", {
x1: LONG_PAD.left, x1: axis.padLeft,
y1: gridY, y1: gridY,
x2: widthPx - LONG_PAD.right, x2: widthPx - axis.padRight,
y2: gridY, y2: gridY,
class: "b06-chart__grid", class: "b06-chart__grid",
}), }),
svgText(formatVolume(value), { svgText(formatVolume(value), {
x: LONG_PAD.left - 9, x: axis.padLeft - 9,
y: gridY + 4, y: gridY + 4,
"text-anchor": "end", "text-anchor": "end",
class: "b06-chart__tick", class: "b06-chart__tick",
@@ -390,14 +391,14 @@ export function createMassHaulChart(
// Y 범위 계산(`volumeRange`)이 0을 항상 품으므로 이 선은 어떤 노선에서도 화면에 남는다. // Y 범위 계산(`volumeRange`)이 0을 항상 품으므로 이 선은 어떤 노선에서도 화면에 남는다.
svg.append( svg.append(
svgElement("line", { svgElement("line", {
x1: LONG_PAD.left, x1: axis.padLeft,
y1: zeroY, y1: zeroY,
x2: widthPx - LONG_PAD.right, x2: widthPx - axis.padRight,
y2: zeroY, y2: zeroY,
class: "b06-masshaul__zero", class: "b06-masshaul__zero",
}), }),
svgText("0", { svgText("0", {
x: LONG_PAD.left - 9, x: axis.padLeft - 9,
y: zeroY + 4, y: zeroY + 4,
"text-anchor": "end", "text-anchor": "end",
class: "b06-chart__tick b06-masshaul__zero-tick", class: "b06-chart__tick b06-masshaul__zero-tick",
@@ -420,8 +421,8 @@ export function createMassHaulChart(
appendBalanceLayer(svg, haulPlan, { appendBalanceLayer(svg, haulPlan, {
x, x,
y, y,
left: LONG_PAD.left, left: axis.padLeft,
right: widthPx - LONG_PAD.right, right: widthPx - axis.padRight,
top: MASS_PAD_TOP, top: MASS_PAD_TOP,
bottom: heightPx - MASS_PAD_BOTTOM, bottom: heightPx - MASS_PAD_BOTTOM,
curveYAt, curveYAt,
@@ -444,8 +445,8 @@ export function createMassHaulChart(
appendVolumeCallout(svg, focus, stationInterval, { appendVolumeCallout(svg, focus, stationInterval, {
anchorX: focusX, anchorX: focusX,
anchorY: y(focus.cumulative_volume_m3), anchorY: y(focus.cumulative_volume_m3),
left: LONG_PAD.left, left: axis.padLeft,
right: widthPx - LONG_PAD.right, right: widthPx - axis.padRight,
top: MASS_PAD_TOP, top: MASS_PAD_TOP,
bottom: heightPx - MASS_PAD_BOTTOM, bottom: heightPx - MASS_PAD_BOTTOM,
curveYAt, curveYAt,
@@ -454,9 +455,9 @@ export function createMassHaulChart(
svg.append( svg.append(
svgElement("line", { svgElement("line", {
x1: LONG_PAD.left, x1: axis.padLeft,
y1: MASS_PAD_TOP, y1: MASS_PAD_TOP,
x2: LONG_PAD.left, x2: axis.padLeft,
y2: heightPx - MASS_PAD_BOTTOM, y2: heightPx - MASS_PAD_BOTTOM,
class: "b06-chart__axis", class: "b06-chart__axis",
}), }),
+63
View File
@@ -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<K extends keyof SVGElementTagNameMap>(
tag: K,
attributes: Record<string, string | number> = {},
): 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<string, string | number>,
): SVGTextElement {
const text = svgElement("text", attributes);
text.textContent = value;
return text;
}
/** 측점 간격을 실제 측점 배열에서 되짚는다(최빈 간격). 라벨 표기의 분모다. */
export function inferStationInterval(stations: Array<{ chainage_m: number }>): number {
const counts = new Map<number, number>();
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)}`;
}