diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul.ts index 9e5f2b02..7aafdd8e 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul.ts @@ -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), })), }; diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Balance.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Balance.ts index b4909346..5b44516f 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Balance.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Balance.ts @@ -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([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 { const round = (value: number): number => Math.round(value * 100) / 100; @@ -568,6 +595,20 @@ export function haulPlanPayload(plan: HaulPlan): Record { 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), diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Balance_View.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Balance_View.ts index e72a8690..62bcffd2 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Balance_View.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Balance_View.ts @@ -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 = { @@ -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; } diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Curve.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Curve.ts new file mode 100644 index 00000000..4b05b256 --- /dev/null +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Curve.ts @@ -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([i]); + if (partner > 0 && partner < list.length - 1) drop.add(partner); + list = list.filter((_, position) => !drop.has(position)); + changed = true; + break; + } + } + return list; +} diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_View.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_View.ts index 815a0276..38565f26 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_View.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_View.ts @@ -392,6 +392,7 @@ export function createMassHaulChart( top: MASS_PAD_TOP, bottom: heightPx - MASS_PAD_BOTTOM, curveYAt, + stationInterval, }); } diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_MassHaul.css b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_MassHaul.css index 30a3fbb6..2befd11c 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_MassHaul.css +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_MassHaul.css @@ -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; diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index fb343856..33fc5927 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -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"],