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>
224 lines
8.9 KiB
TypeScript
224 lines
8.9 KiB
TypeScript
/* =============================================================================
|
|
* common_util_mass_haul_settle.ts
|
|
* 토량 분배의 **정산** 쪽 — 지반유형 안분 · 장비 선정 · 잉여/부족 상쇄 · 직렬화.
|
|
*
|
|
* `common_util_mass_haul_balance`가 700줄을 넘겨, 「곡선에서 블록·띠를 어떻게 뽑나」(그쪽)와
|
|
* 「뽑은 물량을 무엇으로 나누고 어디로 보내나」(여기)로 갈랐다.
|
|
*
|
|
* 의존 방향은 **한 쪽뿐**이다: Balance → Settle(함수), Settle → Balance(타입만).
|
|
* 타입 import는 실행 시 사라지므로 순환이 생기지 않는다.
|
|
* ========================================================================== */
|
|
|
|
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 "./common_util_mass_haul_balance";
|
|
import { EPSILON } from "./common_util_mass_haul_curve";
|
|
|
|
/** 환산계수가 비어 있거나 값이 이상하면 1.0으로 떨어뜨려 계산이 멈추지 않게 한다. */
|
|
function factorFor(conversion: EarthworkConversion, ground: GroundType): number {
|
|
const factor = conversion?.[ground]?.compacted;
|
|
return Number.isFinite(factor) && factor > 0 ? factor : 1;
|
|
}
|
|
|
|
export interface CutMix {
|
|
ea: number;
|
|
rr: number;
|
|
br: number;
|
|
}
|
|
|
|
/**
|
|
* `[fromX, toX]` 절토 구간의 지반유형 구성(다짐상태 가중치). 구간이 측점 사이를 반만
|
|
* 물면 그 비율만큼만 센다 — 경계현 교점은 측점에 딱 떨어지지 않는다.
|
|
*/
|
|
export 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;
|
|
}
|
|
|
|
/**
|
|
* `[fromX, toX]` 중 자연방토가 가능한 구간의 **길이 비율**(0~1).
|
|
* 구간마다 양 끝 측점이 둘 다 가능해야 그 구간을 인정한다(`natural_spoil`).
|
|
*/
|
|
export function naturalSpoilRatio(points: MassHaulPoint[], fromX: number, toX: number): number {
|
|
let total = 0;
|
|
let allowed = 0;
|
|
for (let index = 1; index < points.length; index += 1) {
|
|
const a = points[index - 1].chainage_m;
|
|
const b = points[index].chainage_m;
|
|
const overlap = Math.min(b, toX) - Math.max(a, fromX);
|
|
if (overlap <= 0) continue;
|
|
total += overlap;
|
|
if (points[index].natural_spoil) allowed += overlap;
|
|
}
|
|
return total > 0 ? allowed / total : 0;
|
|
}
|
|
|
|
/** 구성비로 물량을 안분한다. 구간에 절토 기록이 전혀 없으면 지반유형 미상 = 토사로 둔다. */
|
|
export function apportion(
|
|
mix: CutMix,
|
|
volume: number,
|
|
): Pick<HaulBand, "ea_m3" | "rr_m3" | "br_m3"> {
|
|
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,
|
|
};
|
|
}
|
|
|
|
/** 거리 짧은 장비부터. `max_distance_m: null`(상한 없음)은 항상 맨 뒤. */
|
|
export function sortedLimits(limits: HaulEquipmentLimit[] | undefined): HaulEquipmentLimit[] {
|
|
if (!limits?.length) return [{ key: "", max_distance_m: null }];
|
|
return [...limits].sort((a, b) => {
|
|
if (a.max_distance_m === null) return 1;
|
|
if (b.max_distance_m === null) return -1;
|
|
return a.max_distance_m - b.max_distance_m;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 운반거리에 맞는 장비를 고른다. 띠 분할은 경계현으로 하지만 **장거리 운반**은 거리가
|
|
* 먼저 정해지므로 이렇게 거꾸로 고른다. 경계 정의처는 config 한 곳뿐이라, 값을 못 받았으면
|
|
* 프론트에 사본을 두지 않고 그냥 비운다.
|
|
*/
|
|
export function pickEquipment(
|
|
limits: HaulEquipmentLimit[] | undefined,
|
|
distanceM: number,
|
|
): string | null {
|
|
if (!limits?.length) return null;
|
|
for (const limit of sortedLimits(limits)) {
|
|
if (limit.max_distance_m === null || distanceM <= limit.max_distance_m)
|
|
return limit.key || null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* 남은 잉여(사토)와 부족(토취)을 **거리가 가까운 짝부터** 맞물려 장거리 운반으로 바꾼다
|
|
* (배분 3원칙 ① 운반거리 최소). 맞물린 만큼 두 잔량에서 덜어 내고, 끝내 남는 쪽만
|
|
* 진짜 사토/토취로 남는다. 잉여가 우세한 노선이면 토취가 전부 사라진다.
|
|
*
|
|
* `residuals`를 그 자리에서 깎으므로 호출한 쪽은 0이 된 항목을 걸러야 한다.
|
|
*/
|
|
export function settleResiduals(
|
|
points: MassHaulPoint[],
|
|
residuals: HaulResidual[],
|
|
conversion: EarthworkConversion,
|
|
limits: HaulEquipmentLimit[] | undefined,
|
|
): HaulTransfer[] {
|
|
const center = (residual: HaulResidual): number => (residual.from_m + residual.to_m) / 2;
|
|
const level = (residual: HaulResidual): number =>
|
|
(residual.level_from_m3 + residual.level_to_m3) / 2;
|
|
const transfers: HaulTransfer[] = [];
|
|
const pairs: Array<{ spoil: HaulResidual; borrow: HaulResidual; distance: number }> = [];
|
|
for (const spoil of residuals.filter((entry) => entry.kind === "spoil")) {
|
|
for (const borrow of residuals.filter((entry) => entry.kind === "borrow")) {
|
|
pairs.push({ spoil, borrow, distance: Math.abs(center(spoil) - center(borrow)) });
|
|
}
|
|
}
|
|
pairs.sort((a, b) => a.distance - b.distance);
|
|
|
|
for (const pair of pairs) {
|
|
const volume = Math.min(pair.spoil.volume_m3, pair.borrow.volume_m3);
|
|
if (!(volume > EPSILON)) continue;
|
|
// 잔량이 깎인 만큼 지반유형 안분도 같은 비율로 줄인다 — 남은 사토의 구성비는 그대로다.
|
|
const ratio = pair.spoil.volume_m3 > 0 ? 1 - volume / pair.spoil.volume_m3 : 0;
|
|
pair.spoil.ea_m3 *= ratio;
|
|
pair.spoil.rr_m3 *= ratio;
|
|
pair.spoil.br_m3 *= ratio;
|
|
pair.spoil.natural_m3 *= ratio;
|
|
pair.spoil.volume_m3 -= volume;
|
|
pair.borrow.volume_m3 -= volume;
|
|
// 퍼오는 쪽이 절토 구간이므로 지반유형은 사토 구간에서 읽는다.
|
|
const mix = cutMix(points, pair.spoil.from_m, pair.spoil.to_m, conversion);
|
|
transfers.push({
|
|
index: transfers.length + 1,
|
|
from_m: center(pair.spoil),
|
|
to_m: center(pair.borrow),
|
|
volume_m3: volume,
|
|
haul_distance_m: pair.distance,
|
|
level_m3: (level(pair.spoil) + level(pair.borrow)) / 2,
|
|
equipment: pickEquipment(limits, pair.distance),
|
|
...apportion(mix, volume),
|
|
});
|
|
}
|
|
return transfers;
|
|
}
|
|
|
|
/** 확정 저장·B08 인계용 직렬화. 값은 소수 둘째 자리에서 끊는다. */
|
|
export function haulPlanPayload(plan: HaulPlan): Record<string, unknown> {
|
|
const round = (value: number): number => Math.round(value * 100) / 100;
|
|
return {
|
|
spoil_m3: round(plan.spoil_m3),
|
|
natural_spoil_m3: round(plan.natural_spoil_m3),
|
|
borrow_m3: round(plan.borrow_m3),
|
|
hauled_m3: round(plan.hauled_m3),
|
|
transferred_m3: round(plan.transferred_m3),
|
|
fill_total_m3: round(plan.fill_total_m3),
|
|
// 떨어진 구간끼리의 장거리 운반 — B08 내역서가 별도 운반 항목으로 세운다.
|
|
transfers: plan.transfers.map((transfer) => ({
|
|
index: transfer.index,
|
|
from_m: round(transfer.from_m),
|
|
to_m: round(transfer.to_m),
|
|
volume_m3: round(transfer.volume_m3),
|
|
haul_distance_m: round(transfer.haul_distance_m),
|
|
equipment: transfer.equipment,
|
|
ea_m3: round(transfer.ea_m3),
|
|
rr_m3: round(transfer.rr_m3),
|
|
br_m3: round(transfer.br_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),
|
|
direction: block.direction,
|
|
// 장비 띠 — B08 내역서는 이 단위로 운반 항목을 세운다.
|
|
bands: block.bands.map((band) => ({
|
|
index: band.index,
|
|
equipment: band.equipment,
|
|
volume_m3: round(band.volume_m3),
|
|
haul_distance_m: round(band.haul_distance_m),
|
|
ea_m3: round(band.ea_m3),
|
|
rr_m3: round(band.rr_m3),
|
|
br_m3: round(band.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),
|
|
natural_m3: round(residual.natural_m3),
|
|
ea_m3: round(residual.ea_m3),
|
|
rr_m3: round(residual.rr_m3),
|
|
br_m3: round(residual.br_m3),
|
|
})),
|
|
};
|
|
}
|