Files
Aislo/common_util/common_util_mass_haul_settle.ts
T
eomsangdonandClaude Opus 5 fceec5fb24 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>
2026-08-03 18:35:02 +09:00

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),
})),
};
}