fix(B06): 유토곡선 포물선 보간 + 잉여·부족 장거리 상쇄
참고 도면(기번3, 측점별 누가토량 81점 + balloon 43개)과 대조해 오류 2건 확인·수정. [1] 측점 사이는 직선이 아니라 포물선이다. 단면적이 선형으로 변하면 그 적분인 누가토량은 2차식이 된다. 도면에서 측점간격 20m 구간의 반종거가 20/√2 = 14.14m로 12번 반복해 찍히는 것이 근거(직선이면 10m). 직선으로 재던 평균운반거리가 약 40% 짧게 나와 20m 경계 판정이 어긋나고 있었다. MassHaulPoint에 다짐환산 순단면적을 실어 구간마다 V(t) = V0 + a0t + (a1-a0)t²/(2Δ)를 세운다. V(Δ) = V0 + (a0+a1)Δ/2라 곡선 자체는 그대로고 현 길이만 바뀐다. 두 단면적의 평균이 구간 평균과 어긋나면 포물선이 끝점을 벗어나 수평선 교점을 못 찾고 블록이 통째로 늘어난다(도면 곡선 검증에서 성토 1455㎥가 한 블록에 묻혔다). 두 값을 같은 양만큼 옮겨 끝값을 강제로 맞춘다. 곡률은 보존되므로 포물선 모양은 그대로다. [2] 남은 사토와 토취를 거리 가까운 짝부터 맞물려 장거리 운반으로 바꾼다. 도면 곡선은 0선 아래로 한 번도 내려가지 않는데 기존 로직은 토취 1435㎥를 만들어 냈다. 도면의 덤프 L=935/1008m가 이 장거리 운반이고, 배분 3원칙 ①(거리 최소) ③(모아서 운반)이다. - 사토 표기에 측점(M.N) 추가 - 도면은 사토에 운반거리를 안 매긴다 - 검산 칩: 운반(띠) + 장거리 운반 + 토취 = 총 성토량 - 장거리 운반선은 1점쇄선 + 화살촉 (네 번째 선타입) - 700줄 초과로 곡선 기하를 _UI_MassHaul_Curve.ts로 분리 도면 곡선 재현: 토취 1435 -> 0, 사토 2606 -> 1171(= 최종 누가토량), 운반계 오차 -10.1% -> -0.80%, 종무대 L 0.2~13.2 -> 10.51~18.01m(도면 8.39~15.98). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -72,6 +72,15 @@ export interface MassHaulPoint {
|
||||
cut_compacted_m3: number;
|
||||
/** 구간 성토량(다짐상태 = 설계 물량). */
|
||||
fill_m3: number;
|
||||
/**
|
||||
* 이 측점의 **순단면적**(㎡, 다짐환산) = 절토환산단면적 − 성토단면적.
|
||||
*
|
||||
* 유토곡선은 측점 사이에서 직선이 아니라 **포물선**이다 — 단면적이 선형으로 변하면
|
||||
* 그 적분인 누가토량은 2차식이 된다. 평균운반거리(반종거 현)를 직선으로 재면 참고 도면 대비
|
||||
* 약 40% 짧게 나와 장비 경계 판정이 어긋난다(도면의 20m 구간 반종거가 20/√2 = 14.14m).
|
||||
* 이 값이 있어야 `_UI_MassHaul_Balance`가 구간마다 2차식을 세울 수 있다.
|
||||
*/
|
||||
net_area_m2: number;
|
||||
}
|
||||
|
||||
export interface MassHaulResult {
|
||||
@@ -176,6 +185,18 @@ function splitCut(design: CrossDesign | undefined): {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 측점 하나의 순단면적(다짐환산 절토 − 성토). 구간 토량 `(a₀ + a₁)Δ/2`가 평균단면법과
|
||||
* 정확히 같으므로, 이 값으로 세운 2차식은 지금 곡선을 그대로 통과한다.
|
||||
*/
|
||||
function sampleNetArea(sample: AreaSample, conversion: EarthworkConversion): number {
|
||||
const soil = sample.cut_soil_area_m2 * factorFor(conversion, "soil");
|
||||
const rock = sample.rock_kind
|
||||
? sample.cut_rock_area_m2 * factorFor(conversion, sample.rock_kind)
|
||||
: 0;
|
||||
return soil + rock - sample.fill_area_m2;
|
||||
}
|
||||
|
||||
/**
|
||||
* 단면적 시퀀스를 누가토량으로 적분한다.
|
||||
*
|
||||
@@ -198,6 +219,7 @@ function integrate(samples: AreaSample[], conversion: EarthworkConversion): Mass
|
||||
cut_br_m3: 0,
|
||||
cut_compacted_m3: 0,
|
||||
fill_m3: 0,
|
||||
net_area_m2: sampleNetArea(samples[0], conversion),
|
||||
},
|
||||
];
|
||||
const cutNatural = emptyVolumes();
|
||||
@@ -256,6 +278,7 @@ function integrate(samples: AreaSample[], conversion: EarthworkConversion): Mass
|
||||
cut_br_m3: segmentCutBlasting,
|
||||
cut_compacted_m3: segmentCut,
|
||||
fill_m3: segmentFill,
|
||||
net_area_m2: sampleNetArea(current, conversion),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -413,6 +436,7 @@ export function massHaulPayload(
|
||||
cut_rock_m3: round(point.cut_rock_m3),
|
||||
cut_rr_m3: round(point.cut_rr_m3),
|
||||
cut_br_m3: round(point.cut_br_m3),
|
||||
net_area_m2: round(point.net_area_m2),
|
||||
fill_m3: round(point.fill_m3),
|
||||
})),
|
||||
};
|
||||
|
||||
@@ -49,6 +49,12 @@
|
||||
|
||||
import type { EarthworkConversion, GroundType } from "./B06_wf3_ProfileCross_Api_Fetch";
|
||||
import type { MassHaulPoint, MassHaulResult } from "./B06_wf3_ProfileCross_UI_MassHaul";
|
||||
import {
|
||||
crossFrom,
|
||||
EPSILON,
|
||||
extremaIndices,
|
||||
pruneExtrema,
|
||||
} from "./B06_wf3_ProfileCross_UI_MassHaul_Curve";
|
||||
|
||||
/** 운반거리 상한 하나(m). `max_distance_m: null`이면 상한 없음(나머지를 전부 받는다). */
|
||||
export interface HaulEquipmentLimit {
|
||||
@@ -121,6 +127,29 @@ export interface HaulResidual {
|
||||
level_to_m3: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 떨어진 잉여 구간 → 부족 구간 **장거리 운반**. 참고 도면의 덤프 운반 중 L이 900~1000m대인
|
||||
* 것들이 이것으로, 한 산의 현으로는 설명되지 않는다(노선 연장보다 긴 현은 없다).
|
||||
*
|
||||
* 배분 3원칙 ③ "토량을 모아 한 가지 방법으로 운반"이 바로 이 항목이다. 이것이 없으면
|
||||
* 절토가 우세한 노선인데도 국지 부족이 그대로 토취로 계상돼, 사지 않아도 될 흙을 사게 된다.
|
||||
*/
|
||||
export interface HaulTransfer {
|
||||
index: number;
|
||||
/** 퍼오는 자리 / 채우는 자리(누가거리 m, 각 잔량 구간의 중앙). */
|
||||
from_m: number;
|
||||
to_m: number;
|
||||
volume_m3: number;
|
||||
/** 두 자리 사이 거리 = 이 운반의 `L`. */
|
||||
haul_distance_m: number;
|
||||
/** 연결 수평선을 그릴 높이(누가토량 ㎥). */
|
||||
level_m3: number;
|
||||
equipment: string | null;
|
||||
ea_m3: number;
|
||||
rr_m3: number;
|
||||
br_m3: number;
|
||||
}
|
||||
|
||||
/** 계단형 평형선 한 칸. 렌더러가 칸 사이를 수직선으로 이어 계단을 만든다. */
|
||||
export interface BalanceStep {
|
||||
from_m: number;
|
||||
@@ -131,15 +160,22 @@ export interface BalanceStep {
|
||||
export interface HaulPlan {
|
||||
blocks: HaulBlock[];
|
||||
residuals: HaulResidual[];
|
||||
transfers: HaulTransfer[];
|
||||
steps: BalanceStep[];
|
||||
spoil_m3: number;
|
||||
borrow_m3: number;
|
||||
/** 운반 블록 토량 합계(㎥) — 실제로 장비가 옮기는 양. */
|
||||
/** 블록 안에서 옮기는 양(㎥). */
|
||||
hauled_m3: number;
|
||||
/** 떨어진 구간끼리 장거리로 옮기는 양(㎥). */
|
||||
transferred_m3: number;
|
||||
/**
|
||||
* 총 성토량(㎥) — **검산 기준값**. 성토는 전부 운반으로 채워지므로
|
||||
* `hauled_m3 + transferred_m3`가 이 값과 같아야 한다.
|
||||
* 참고 도면에서 balloon 43개 합이 총 성토량과 소수점까지 일치하는 것이 근거다.
|
||||
*/
|
||||
fill_total_m3: number;
|
||||
}
|
||||
|
||||
const EPSILON = 1e-9;
|
||||
|
||||
/**
|
||||
* 이보다 작은 진동은 블록으로 세지 않는다. 측점 하나짜리 요철까지 블록을 만들면 balloon이
|
||||
* 수십 개 깔려 도면을 못 읽는다. 곡선 진폭 대비 비율이라 노선 규모에 자동으로 맞는다.
|
||||
@@ -156,87 +192,6 @@ function factorFor(conversion: EarthworkConversion, ground: GroundType): number
|
||||
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<number>([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;
|
||||
@@ -290,6 +245,20 @@ function sortedLimits(limits: HaulEquipmentLimit[] | undefined): HaulEquipmentLi
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 운반거리에 맞는 장비를 고른다. 띠 분할은 경계현으로 하지만 **장거리 운반**은 거리가
|
||||
* 먼저 정해지므로 이렇게 거꾸로 고른다. 경계 정의처는 config 한 곳뿐이라, 값을 못 받았으면
|
||||
* 프론트에 사본을 두지 않고 그냥 비운다.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/** 한 블록을 장비 띠로 가르는 데 필요한 기하 계산 묶음. */
|
||||
interface BlockGeometry {
|
||||
/** `ratio`(0 = 평형선, 1 = 정점)에 해당하는 누가토량 높이. */
|
||||
@@ -545,22 +514,80 @@ export function computeHaulPlan(
|
||||
}
|
||||
|
||||
if (!blocks.length && !residuals.length) return null;
|
||||
|
||||
const transfers = settleResiduals(points, residuals, result.conversion, limits);
|
||||
const settled = residuals.filter((residual) => residual.volume_m3 > EPSILON);
|
||||
settled.forEach((residual, index) => {
|
||||
residual.index = index + 1;
|
||||
});
|
||||
|
||||
let spoil = 0;
|
||||
let borrow = 0;
|
||||
for (const residual of residuals) {
|
||||
for (const residual of settled) {
|
||||
if (residual.kind === "spoil") spoil += residual.volume_m3;
|
||||
else borrow += residual.volume_m3;
|
||||
}
|
||||
let fillTotal = 0;
|
||||
for (const point of points) fillTotal += point.fill_m3;
|
||||
return {
|
||||
blocks,
|
||||
residuals,
|
||||
residuals: settled,
|
||||
transfers,
|
||||
steps,
|
||||
spoil_m3: spoil,
|
||||
borrow_m3: borrow,
|
||||
hauled_m3: blocks.reduce((sum, block) => sum + block.volume_m3, 0),
|
||||
transferred_m3: transfers.reduce((sum, entry) => sum + entry.volume_m3, 0),
|
||||
fill_total_m3: fillTotal,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 남은 잉여(사토)와 부족(토취)을 **거리가 가까운 짝부터** 맞물려 장거리 운반으로 바꾼다
|
||||
* (배분 3원칙 ① 운반거리 최소). 맞물린 만큼 두 잔량에서 덜어 내고, 끝내 남는 쪽만
|
||||
* 진짜 사토/토취로 남는다. 잉여가 우세한 노선이면 토취가 전부 사라진다.
|
||||
*
|
||||
* `residuals`를 그 자리에서 깎으므로 호출한 쪽은 0이 된 항목을 걸러야 한다.
|
||||
*/
|
||||
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;
|
||||
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;
|
||||
@@ -568,6 +595,20 @@ export function haulPlanPayload(plan: HaulPlan): Record<string, unknown> {
|
||||
spoil_m3: round(plan.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),
|
||||
|
||||
@@ -20,9 +20,10 @@ import type {
|
||||
HaulBlock,
|
||||
HaulPlan,
|
||||
HaulResidual,
|
||||
HaulTransfer,
|
||||
} 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";
|
||||
import { L, stationLabel, svgElement, svgText } from "./B06_wf3_ProfileCross_UI_Section_Common";
|
||||
|
||||
/** 유토곡선 렌더러가 넘겨주는 좌표계와 그릴 수 있는 상자(px). */
|
||||
export interface BalanceLayerBox {
|
||||
@@ -37,6 +38,8 @@ export interface BalanceLayerBox {
|
||||
* balloon을 곡선과 겹치지 않는 자리에 놓기 위해 유토곡선 렌더러가 넘겨준다.
|
||||
*/
|
||||
curveYAt?: (fromPx: number, toPx: number) => { top: number; bottom: number } | null;
|
||||
/** 사토·토취 발생 위치를 측점으로 적기 위한 측점 간격(m). */
|
||||
stationInterval?: number;
|
||||
}
|
||||
|
||||
const EQUIPMENT_LABEL: Record<string, LocaleKey> = {
|
||||
@@ -471,7 +474,13 @@ function appendResidual(
|
||||
);
|
||||
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)}㎥`;
|
||||
// 도면은 사토 balloon에 거리(L)가 아니라 **측점**(M.N)을 적는다 — 사토는 운반이 아니라
|
||||
// 그 자리 처리라 운반거리를 매기지 않기 때문이다(2026-08-02 도면 분석).
|
||||
const at = (residual.from_m + residual.to_m) / 2;
|
||||
const station = box.stationInterval ? ` · ${stationLabel(at, box.stationInterval)}` : "";
|
||||
const label =
|
||||
`${L(residual.kind === "spoil" ? "B06_MassHaul_Surplus" : "B06_MassHaul_Shortage")} ` +
|
||||
`${compactVolume(residual.volume_m3)}㎥${station}`;
|
||||
const marker = svgElement("g", {
|
||||
class: `b06-balance__residual b06-balance__residual--${residual.kind}`,
|
||||
});
|
||||
@@ -503,6 +512,53 @@ function appendResidual(
|
||||
group.append(marker);
|
||||
}
|
||||
|
||||
/**
|
||||
* 떨어진 잉여 → 부족 **장거리 운반**. 한 산의 현이 아니라 두 지역을 잇는 선이라
|
||||
* 1점쇄선 + 양 끝 화살촉으로 구분한다. 길이가 곧 그 운반의 `L`이다.
|
||||
*/
|
||||
function appendTransfer(
|
||||
group: SVGGElement,
|
||||
transfer: HaulTransfer,
|
||||
box: BalanceLayerBox,
|
||||
compact: boolean,
|
||||
): void {
|
||||
const fromX = box.x(transfer.from_m);
|
||||
const toX = box.x(transfer.to_m);
|
||||
const y = Math.min(Math.max(box.y(transfer.level_m3), box.top + 4), box.bottom - 4);
|
||||
const marker = svgElement("g", { class: "b06-balance__transfer" });
|
||||
const title = svgElement("title");
|
||||
const equipment = equipmentLabel(transfer.equipment);
|
||||
title.textContent =
|
||||
`${L("B06_MassHaul_Transfer")} ${transfer.index} · ${equipment || "-"} · ` +
|
||||
`${L("B06_MassHaul_HaulVolume")} ${compactVolume(transfer.volume_m3)}㎥ · ` +
|
||||
`${L("B06_MassHaul_HaulDistance")} ${compactDistance(transfer.haul_distance_m)}m · ` +
|
||||
`EA ${compactVolume(transfer.ea_m3)} / RR ${compactVolume(transfer.rr_m3)} / BR ${compactVolume(transfer.br_m3)}㎥`;
|
||||
const tip = toX >= fromX ? toX : toX;
|
||||
const back = toX >= fromX ? toX - 7 : toX + 7;
|
||||
marker.append(
|
||||
title,
|
||||
svgElement("line", { x1: fromX, y1: y, x2: toX, y2: y, class: "b06-balance__transfer-line" }),
|
||||
svgElement("polygon", {
|
||||
points: `${tip},${y} ${back},${y - 4} ${back},${y + 4}`,
|
||||
class: "b06-balance__transfer-head",
|
||||
}),
|
||||
);
|
||||
if (!compact) {
|
||||
marker.append(
|
||||
svgText(
|
||||
`${compactVolume(transfer.volume_m3)}㎥ · ${compactDistance(transfer.haul_distance_m)}m${equipment ? ` · ${equipment}` : ""}`,
|
||||
{
|
||||
x: (fromX + toX) / 2,
|
||||
y: y - 4,
|
||||
"text-anchor": "middle",
|
||||
class: "b06-balance__transfer-text",
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
group.append(marker);
|
||||
}
|
||||
|
||||
/**
|
||||
* 토량 분배 레이어를 유토곡선 SVG에 얹는다. 곡선·측점선 위, 선택 말풍선 아래에 놓아야
|
||||
* 읽는 순서가 맞으므로 호출 위치를 옮기지 말 것.
|
||||
@@ -515,6 +571,7 @@ export function appendBalanceLayer(svg: SVGSVGElement, plan: HaulPlan, box: Bala
|
||||
appendBalanceLine(group, plan, box);
|
||||
|
||||
const compact = box.bottom - box.top < 110;
|
||||
for (const transfer of plan.transfers) appendTransfer(group, transfer, box, compact);
|
||||
for (const residual of plan.residuals) appendResidual(group, residual, box, compact);
|
||||
|
||||
const faces: SVGGElement[] = [];
|
||||
@@ -560,8 +617,25 @@ export function appendBalanceLayer(svg: SVGSVGElement, plan: HaulPlan, box: Bala
|
||||
export function haulPlanChips(plan: HaulPlan | null): Array<[string, string]> {
|
||||
if (!plan) return [];
|
||||
const bands = plan.blocks.reduce((sum, block) => sum + block.bands.length, 0);
|
||||
return [
|
||||
[L("B06_MassHaul_HaulTotal"), `${compactVolume(plan.hauled_m3)}㎥`],
|
||||
const moved = plan.hauled_m3 + plan.transferred_m3;
|
||||
// 검산 — 성토는 **절토를 옮겨 온 것 + 사 온 것(토취)**으로만 채워진다:
|
||||
// 운반(띠) + 장거리 운반 + 토취 = 총 성토량
|
||||
// 참고 도면에서 balloon 43개 합이 총 성토량과 소수점까지 일치하는 것이 근거다
|
||||
// (그 노선은 절토 우세라 토취가 0이었다).
|
||||
const gap = moved + plan.borrow_m3 - plan.fill_total_m3;
|
||||
const ratio = plan.fill_total_m3 > 0 ? Math.abs(gap) / plan.fill_total_m3 : 0;
|
||||
const chips: Array<[string, string]> = [
|
||||
[L("B06_MassHaul_HaulTotal"), `${compactVolume(moved)}㎥`],
|
||||
[L("B06_MassHaul_BlockCount"), `${plan.blocks.length} / ${bands}`],
|
||||
];
|
||||
if (plan.transfers.length) {
|
||||
chips.push([L("B06_MassHaul_Transfer"), `${compactVolume(plan.transferred_m3)}㎥`]);
|
||||
}
|
||||
chips.push([
|
||||
L("B06_MassHaul_Check"),
|
||||
ratio <= 0.005
|
||||
? L("B06_MassHaul_Check_Ok")
|
||||
: `${gap > 0 ? "+" : "−"}${compactVolume(Math.abs(gap))}㎥`,
|
||||
]);
|
||||
return chips;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
/* =============================================================================
|
||||
* B06_wf3_ProfileCross_UI_MassHaul_Curve.ts
|
||||
* 누가토량 곡선의 기하 — 포물선 보간 · 수평선 교점 · 극값 추출.
|
||||
*
|
||||
* 유토곡선은 측점 사이에서 **직선이 아니라 포물선**이다. 측점별 단면적이 선형으로 변하면
|
||||
* 그 적분인 누가토량은 2차식이 되기 때문이다. 이걸 직선으로 보면 평균운반거리(반종거 현)가
|
||||
* 참고 도면 대비 약 40% 짧게 나와 장비 경계 판정이 어긋난다 — 도면에서 측점간격 20m 구간의
|
||||
* 반종거가 `20/√2 = 14.14m`로 12번 반복해 찍히는 것이 근거다(직선이면 10m).
|
||||
*
|
||||
* 토량 분배 계산(`_UI_MassHaul_Balance`)이 700줄을 넘겨 곡선 기하만 여기로 떼어냈다.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { MassHaulPoint } from "./B06_wf3_ProfileCross_UI_MassHaul";
|
||||
|
||||
export const EPSILON = 1e-9;
|
||||
|
||||
/**
|
||||
* 구간 하나의 2차식 계수. 측점 사이에서 단면적이 선형으로 변하므로 누가토량은
|
||||
* `V(t) = V₀ + a₀·t + (a₁ − a₀)·t² / (2Δ)` (t = 구간 시작으로부터의 거리)
|
||||
* 이다. `V(Δ) = V₀ + (a₀ + a₁)Δ/2`라 평균단면법 결과와 정확히 같다.
|
||||
*
|
||||
* 순단면적이 없는 데이터(옛 payload·종단 기준 개략값)는 구간 평균 단면적을 양 끝에 같이
|
||||
* 넣어 자동으로 직선으로 떨어진다.
|
||||
*/
|
||||
export interface Segment {
|
||||
x0: number;
|
||||
span: number;
|
||||
v0: number;
|
||||
a0: number;
|
||||
curvature: number;
|
||||
}
|
||||
|
||||
export function segmentOf(points: MassHaulPoint[], index: number): Segment | null {
|
||||
const a = points[index - 1];
|
||||
const b = points[index];
|
||||
const span = b.chainage_m - a.chainage_m;
|
||||
if (!(span > 0)) return null;
|
||||
const average = (b.cumulative_volume_m3 - a.cumulative_volume_m3) / span;
|
||||
const start = Number.isFinite(a.net_area_m2) ? a.net_area_m2 : average;
|
||||
const end = Number.isFinite(b.net_area_m2) ? b.net_area_m2 : average;
|
||||
// 포물선이 **구간 끝값을 정확히 통과하도록** 두 단면적을 같은 양만큼 옮긴다.
|
||||
// `V(Δ) = V₀ + (a₀+a₁)Δ/2`이므로 평균이 어긋나면 끝값이 어긋나고, 그러면 수평선 교점을
|
||||
// 못 찾아 블록이 엉뚱하게 길어진다(도면 곡선 검증에서 성토 1455㎥가 한 블록에 묻혔다).
|
||||
// 곡률 `(a₁−a₀)`는 그대로라 포물선 모양은 보존된다.
|
||||
const shift = average - (start + end) / 2;
|
||||
return {
|
||||
x0: a.chainage_m,
|
||||
span,
|
||||
v0: a.cumulative_volume_m3,
|
||||
a0: start + shift,
|
||||
curvature: (end - start) / (2 * span),
|
||||
};
|
||||
}
|
||||
|
||||
export function evaluate(segment: Segment, t: number): number {
|
||||
return segment.v0 + segment.a0 * t + segment.curvature * t * t;
|
||||
}
|
||||
|
||||
/** 구간 2차식이 `[tLo, tHi]` 안에서 `level`을 처음 지나는 t. 없으면 null. */
|
||||
function solveSegment(segment: Segment, level: number, tLo: number, tHi: number): number | null {
|
||||
const c = segment.v0 - level;
|
||||
const roots: number[] = [];
|
||||
if (Math.abs(segment.curvature) < 1e-12) {
|
||||
if (Math.abs(segment.a0) > EPSILON) roots.push(-c / segment.a0);
|
||||
} else {
|
||||
const discriminant = segment.a0 * segment.a0 - 4 * segment.curvature * c;
|
||||
if (discriminant < 0) return null;
|
||||
const root = Math.sqrt(discriminant);
|
||||
roots.push((-segment.a0 - root) / (2 * segment.curvature));
|
||||
roots.push((-segment.a0 + root) / (2 * segment.curvature));
|
||||
}
|
||||
const inside = roots
|
||||
.filter((t) => t >= tLo - 1e-9 && t <= tHi + 1e-9)
|
||||
.sort((left, right) => left - right);
|
||||
return inside.length ? Math.min(Math.max(inside[0], tLo), tHi) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* `[fromX, toX]`를 왼쪽부터 훑어 곡선이 `level`을 **처음** 지나는 x. 없으면 null.
|
||||
* 평형선·경계현·반종거 수평선의 교점이 전부 이 함수 하나로 나온다.
|
||||
*/
|
||||
export 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 segment = segmentOf(points, index);
|
||||
if (!segment) continue;
|
||||
const x0 = Math.max(segment.x0, fromX);
|
||||
const x1 = Math.min(segment.x0 + segment.span, toX);
|
||||
if (!(x1 > x0)) continue;
|
||||
const t = solveSegment(segment, level, x0 - segment.x0, x1 - segment.x0);
|
||||
if (t !== null) return segment.x0 + t;
|
||||
// 구간 끝에서 부호가 바뀌었는데 2차 근을 못 찾으면(수치 한계) 직선으로 떨어뜨린다.
|
||||
// 교점을 놓치면 블록 경계가 통째로 밀리므로 여기서 반드시 하나는 돌려줘야 한다.
|
||||
const y0 = evaluate(segment, x0 - segment.x0);
|
||||
const y1 = evaluate(segment, x1 - segment.x0);
|
||||
if ((y0 - level) * (y1 - level) <= 0) {
|
||||
if (Math.abs(y1 - y0) < EPSILON) return x0;
|
||||
return x0 + ((level - y0) / (y1 - y0)) * (x1 - x0);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 곡선의 극값 인덱스 수열(양 끝점 포함). 기울기 부호가 바뀌는 자리가 극값이다. */
|
||||
export 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` 미만인 극값은 **짝이 되는 이웃과 함께** 지운다 —
|
||||
* 하나만 지우면 극대·극소 교대가 깨져 뒤 계산이 방향을 잃는다. 양 끝점은 남긴다.
|
||||
*/
|
||||
export 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<number>([i]);
|
||||
if (partner > 0 && partner < list.length - 1) drop.add(partner);
|
||||
list = list.filter((_, position) => !drop.has(position));
|
||||
changed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return list;
|
||||
}
|
||||
@@ -392,6 +392,7 @@ export function createMassHaulChart(
|
||||
top: MASS_PAD_TOP,
|
||||
bottom: heightPx - MASS_PAD_BOTTOM,
|
||||
curveYAt,
|
||||
stationInterval,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -263,6 +263,25 @@
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* 장거리 운반선 — 한 산의 현이 아니라 떨어진 두 지역을 잇는 선이라 **1점쇄선**으로 구분한다
|
||||
(기선 실선 / 경계현 파선 / 평균운반거리 점선과 겹치지 않는 네 번째 선타입). */
|
||||
.b06-balance__transfer-line {
|
||||
stroke: var(--color-accent);
|
||||
stroke-width: 1.4;
|
||||
stroke-dasharray: 9 3 2 3;
|
||||
}
|
||||
|
||||
.b06-balance__transfer-head {
|
||||
fill: var(--color-accent);
|
||||
stroke: none;
|
||||
}
|
||||
|
||||
.b06-balance__transfer-text {
|
||||
fill: var(--color-accent);
|
||||
font-size: 9px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* 사토·토취 단차. 남는 흙(사토)과 모자란 흙(토취)을 색으로 갈라 둔다. */
|
||||
.b06-balance__step-arrow {
|
||||
stroke-width: 1.4;
|
||||
|
||||
@@ -248,6 +248,11 @@ export const ui_locales_b2 = {
|
||||
B06_MassHaul_Equip_FreeHaul: ["종무대", "Free haul"],
|
||||
B06_MassHaul_Equip_Dozer: ["도쟈", "Dozer"],
|
||||
B06_MassHaul_Equip_Dump: ["덤프", "Dump truck"],
|
||||
/* 떨어진 잉여 구간 → 부족 구간 장거리 운반. 배분 3원칙 ③(토량을 모아 운반). */
|
||||
B06_MassHaul_Transfer: ["장거리 운반", "Long haul"],
|
||||
/* 검산 — 성토는 전부 운반으로 채워지므로 운반 합계 = 총 성토량이어야 한다. */
|
||||
B06_MassHaul_Check: ["운반·성토 검산", "Haul vs fill check"],
|
||||
B06_MassHaul_Check_Ok: ["일치", "Balanced"],
|
||||
|
||||
/* --- B06 측점 표준횡단 설계 지정 --- */
|
||||
B06_Design_Ground_Legend: ["지반유형", "Ground type"],
|
||||
|
||||
Reference in New Issue
Block a user