feat(B06): 유토곡선 운반 블록을 장비 경계현으로 띠 분할 + 도면 정합 5건
참고 도면의 토량배분도는 블록마다 수평선이 여러 개(A-A' 기선 / B-B' / C-C')이고 그 사이 띠마다 번호가 붙어 종단면 해칭 구간과 1:1로 대응한다. 하단 브래킷의 폭이 각 수평선의 길이와 같다. 즉 수평선 높이는 그 현의 길이가 장비 경계거리와 같아지는 높이이고, 세로축이 곧 토량이므로 두 수평선 사이 띠 두께가 그 장비의 운반토량이다. 블록 하나에 평균운반거리 하나 / 장비 하나이던 모델을 띠(HaulBand) 여럿으로 바꿨다. 경계 탐색이 단조가 아니라는 것을 무작위 검증이 잡았다. 가지치기로 블록을 하나로 묶어도 원래 폴리라인에 잔요철이 남아 높이를 올리는 도중 현이 잠깐 다시 길어진다. 순수 이분법은 엉뚱한 근으로 수렴했다(종무대 경계현 45m). 훑기 64칸 + 이분법 24회 2단계로 바꿔, 현이 상한을 넘는 가장 높은 자리 위를 경계로 잡는다. - 장비 경계 dozer 50 -> 70m (사용자 확정, 정의처는 config 한 곳 유지) - balloon 도형 = 운반수단: 종무대 육각 / 도쟈 원 / 덤프 사각, 사토는 언더바만 - 띠 면 클릭 시 balloon 강조, 다른 띠·빈 곳 클릭 시 해제 - 선타입: 기선 실선 / 경계현 파선 / 평균운반거리 점선 + 방향 화살촉 - 유토곡선 0선을 파선에서 실선으로 (파선은 경계현의 몫) - 암 경계선 오프셋 상한 0 (양수면 경계선이 지표면 위로 떠 토사층이 음수가 된다) - 종단도 구조물 라벨을 플롯 하단으로 옮기고 맨 마지막에 그려 가리지 않게 - 암 경계선 제어를 그래프 하단 행 가운데로 검증: 수동 4종 + 실측 26측점 + 무작위 100건에서 띠 합=Q, 띠 연속성, 경계현<=상한, 안분합, 보존 성립. 렌더 스모크 4개 크기 NaN 0건 + 도형·클릭 강조 확인. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -252,6 +252,9 @@ export function createLongitudinalProfile(
|
||||
}
|
||||
}
|
||||
|
||||
// 구조물 이름은 **맨 마지막에** 붙인다 — 측점 마커 안에 넣으면 뒤이어 그리는 지반선·
|
||||
// 계획선이 그 위를 덮는다(2026-08-02 사용자 지시: 오버레이 순서 최상위).
|
||||
const structureLabels: SVGTextElement[] = [];
|
||||
for (const station of data.stations) {
|
||||
const stationX = x(station.chainage_m);
|
||||
const selected = station.station_id === selectedStationId;
|
||||
@@ -293,13 +296,14 @@ export function createLongitudinalProfile(
|
||||
class: "b06-chart__station-label",
|
||||
}),
|
||||
);
|
||||
// 구조물(비정규) 측점: 구조물 이름을 측점선 상단에 표기해 위치를 식별할 수 있게 한다.
|
||||
// 그래프 이름표가 플롯 안 상단으로 들어왔으므로 그 아래로 내려 겹치지 않게 한다.
|
||||
// 구조물(비정규) 측점: 구조물 이름으로 위치를 식별할 수 있게 한다.
|
||||
// 플롯 **하단**(측점 라벨 바로 위)에 둔다 — 상단은 그래프 이름표와 절·성토 표기가
|
||||
// 이미 쓰고 있어 서로 겹쳤다(2026-08-02 사용자 지시).
|
||||
if (station.structure) {
|
||||
marker.append(
|
||||
structureLabels.push(
|
||||
svgText(station.structure, {
|
||||
x: stationX,
|
||||
y: LONG_PAD.top + 26,
|
||||
y: heightPx - LONG_PAD.bottom - 5,
|
||||
"text-anchor": "middle",
|
||||
class: "b06-chart__structure-label",
|
||||
}),
|
||||
@@ -357,6 +361,8 @@ export function createLongitudinalProfile(
|
||||
class: "b06-chart__axis-label",
|
||||
}),
|
||||
);
|
||||
// 구조물 이름표는 지반선·계획선·축까지 다 그린 **뒤에** 얹는다 — 무엇에도 가리지 않는다.
|
||||
if (structureLabels.length) svg.append(...structureLabels);
|
||||
wrapper.append(svg);
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
/* =============================================================================
|
||||
* B06_wf3_ProfileCross_UI_MassHaul_Balance.ts
|
||||
* 토량 분배(평형선) 계산 엔진 — 순수 함수, DOM 의존 없음.
|
||||
* 토량 분배(평형선 · 운반 블록 · 장비 띠) 계산 엔진 — 순수 함수, DOM 의존 없음.
|
||||
*
|
||||
* 누가토량 곡선(`_UI_MassHaul`)은 "얼마가 남는가"만 말한다. 이 파일은 그 곡선에서
|
||||
* **어디 흙을 어디로 얼마나 옮기는가**를 뽑아 낸다: 평형선 → 운반 블록 → 운반토량 `Q` →
|
||||
* 평균운반거리 `L` → 지반유형 안분 → 장비 선정.
|
||||
* **어디 흙을 어디로 얼마나 옮기는가**를 뽑아 낸다: 평형선 → 운반 블록 → 장비 띠 →
|
||||
* 띠별 운반토량 `Q` · 평균운반거리 `L` · 지반유형 안분.
|
||||
*
|
||||
* ── 작도 규칙(참고자료) ─────────────────────────────────────────────
|
||||
* <https://clouds-daily.tistory.com/249>
|
||||
* - 상승 = 절토, 하강 = 성토. 극대·극소점 = 절·성토 경계.
|
||||
* - 산 모양 = 좌→우 운반, 골 모양 = 우→좌 운반.
|
||||
* - 평형선(수평선)이 곡선을 자르는 두 점 사이는 절토량 = 성토량.
|
||||
* - 운반토량 `Q` = 평형선에서 곡선 정점까지의 종거.
|
||||
* - 평균운반거리 `L` = **종거의 1/2 지점을 지나는 수평선 B′C′의 길이**(반종거 수평선).
|
||||
* - 운반토량 = 평형선에서 곡선 정점까지의 종거.
|
||||
* - 평균운반거리 = **종거의 1/2 지점을 지나는 수평선 B′C′의 길이**(반종거 수평선).
|
||||
* - 배분 3원칙 = ① 운반거리 최소 ② 높은 곳 → 낮은 곳 ③ 토량을 모아 운반.
|
||||
*
|
||||
* ── 평형선을 어떻게 자동으로 잡는가 ─────────────────────────────────
|
||||
* 참고자료는 "가장 유리한 평형점을 도상에서 구한다"까지만 말한다. 이를 결정론으로 옮길 때
|
||||
* **0선 하나로 고정하면 안 된다** — 임도는 절토가 크게 우세해 사토가 전부 종점 한 덩어리로
|
||||
* 몰린다. 그래서 곡선을 왼쪽부터 훑으며 평형선 높이를 **계단으로 올린다**:
|
||||
* 참고자료는 "가장 유리한 평형점을 도상에서 구한다"까지만 말한다. **0선 하나로 고정하면
|
||||
* 안 된다** — 임도는 절토가 크게 우세해 사토가 전부 종점 한 덩어리로 몰린다. 그래서 곡선을
|
||||
* 왼쪽부터 훑으며 평형선 높이를 **계단으로 올린다**:
|
||||
*
|
||||
* 커서(곡선 위, 높이 = 현재 평형선)에서 다음 극값을 본다.
|
||||
* - 올라갔다가(절토) 다시 현재 높이 **아래**까지 내려오면 → 성토가 절토를 다 받는다.
|
||||
@@ -30,6 +30,18 @@
|
||||
* 이렇게 하면 ① 구간이 겹치거나 비지 않고 ② 사토·토취가 발생 지점에 남으며
|
||||
* ③ 단차의 합이 최종 누가토량과 정확히 일치한다.
|
||||
*
|
||||
* ── 블록 하나를 장비 띠로 가르는 이유 ───────────────────────────────
|
||||
* 참고 도면의 토량배분도에는 수평선이 블록마다 여러 개 그려져 있고(A–A′ / B–B′ / C–C′),
|
||||
* 그 사이 띠마다 번호가 붙어 종단면의 해칭 구간과 1:1로 대응한다. 하단 브래킷
|
||||
* (무대운반거리 / 도자 평균운반거리 / 덤프 평균운반거리)의 폭이 각 수평선의 길이와 같다.
|
||||
*
|
||||
* 즉 **수평선의 높이는 그 현(弦)의 길이가 장비 경계거리와 같아지는 높이**다. 누가토량
|
||||
* 그래프에서 세로축이 곧 토량이므로, 두 수평선 사이 띠의 두께가 그 장비가 옮기는 토량이다.
|
||||
* 현 길이 20m 되는 높이 위쪽 → 종무대
|
||||
* 거기서 70m 되는 높이까지 → 도쟈
|
||||
* 그 아래 ~ 평형선 → 덤프
|
||||
* 경계값 정의처는 `config_system.EARTHWORK_HAUL_EQUIPMENT_LIMITS_M` 한 곳뿐이다.
|
||||
*
|
||||
* ── 단위 ─────────────────────────────────────────────────────────
|
||||
* 곡선이 다짐상태 기준이므로 `Q`·EA/RR/BR 안분도 **다짐상태**다. 내역서용 자연상태 수량은
|
||||
* `MassHaulResult.cut_natural_m3` / 측점별 `cut_soil_m3`·`cut_rr_m3`·`cut_br_m3`가 따로 든다.
|
||||
@@ -38,7 +50,7 @@
|
||||
import type { EarthworkConversion, GroundType } from "./B06_wf3_ProfileCross_Api_Fetch";
|
||||
import type { MassHaulPoint, MassHaulResult } from "./B06_wf3_ProfileCross_UI_MassHaul";
|
||||
|
||||
/** 운반거리 상한 하나(㎥ 아님, m). `max_distance_m: null`이면 상한 없음(최종 후보). */
|
||||
/** 운반거리 상한 하나(m). `max_distance_m: null`이면 상한 없음(나머지를 전부 받는다). */
|
||||
export interface HaulEquipmentLimit {
|
||||
key: string;
|
||||
max_distance_m: number | null;
|
||||
@@ -47,35 +59,59 @@ export interface HaulEquipmentLimit {
|
||||
/** 운반 방향. 산 모양이면 좌→우(`forward`), 골 모양이면 우→좌(`backward`). */
|
||||
export type HaulDirection = "forward" | "backward";
|
||||
|
||||
/** 곡선 위 한 점(누가거리 m, 누가토량 ㎥). 렌더러가 그대로 좌표로 옮긴다. */
|
||||
export interface CurvePoint {
|
||||
m: number;
|
||||
v: number;
|
||||
}
|
||||
|
||||
/** 블록 하나를 장비 경계현으로 가른 띠. 띠 두께가 곧 그 장비의 운반토량이다. */
|
||||
export interface HaulBand {
|
||||
/** 전체 통번호(1부터). 도면의 ①②③에 해당한다. */
|
||||
index: number;
|
||||
/** config 경계로 고른 장비 키. 경계값을 못 받으면 null(프론트에 사본을 두지 않는다). */
|
||||
equipment: string | null;
|
||||
/** 띠의 평형선 쪽 / 정점 쪽 경계 높이(누가토량 ㎥). */
|
||||
level_base_m3: number;
|
||||
level_apex_m3: number;
|
||||
/** 이 띠의 운반토량(㎥, 다짐상태) = 두 경계의 차. */
|
||||
volume_m3: number;
|
||||
/** 평형선 쪽 경계현(= 이 장비의 경계거리와 길이가 같은 수평선)의 양 끝. */
|
||||
boundary_from_m: number;
|
||||
boundary_to_m: number;
|
||||
/** 평균운반거리 `L`(m) = 띠 중간 높이 현의 길이. */
|
||||
haul_distance_m: number;
|
||||
haul_from_m: number;
|
||||
haul_to_m: number;
|
||||
/** `Q`를 절토 구간의 지반유형 구성비로 안분한 값(㎥). 합 = `volume_m3`. */
|
||||
ea_m3: number;
|
||||
rr_m3: number;
|
||||
br_m3: number;
|
||||
/** 띠를 감싸는 다각형(곡선 두 변 + 위아래 현). 클릭 대상이자 해칭 면이다. */
|
||||
outline: CurvePoint[];
|
||||
}
|
||||
|
||||
export interface HaulBlock {
|
||||
/** 1부터. 도면 balloon의 블록 번호. */
|
||||
/** 1부터. */
|
||||
index: number;
|
||||
/** 이 블록의 평형선 높이(누가토량 축, ㎥). */
|
||||
base_m3: number;
|
||||
/** 평형선이 곡선을 자르는 두 점(누가거리 m). */
|
||||
from_m: number;
|
||||
to_m: number;
|
||||
/** 정점(극대/극소) 위치와 값 — balloon 지시선 앵커. */
|
||||
/** 정점(극대/극소) 위치와 값. */
|
||||
apex_m: number;
|
||||
apex_volume_m3: number;
|
||||
/** 운반토량 `Q`(㎥, 다짐상태) = 종거. */
|
||||
/** 블록 전체 운반토량 `Q`(㎥) = 종거. 띠 물량의 합과 같다. */
|
||||
volume_m3: number;
|
||||
/** 평균운반거리 `L`(m) = 반종거 수평선 길이. */
|
||||
haul_distance_m: number;
|
||||
/** 반종거 수평선의 양 끝(누가거리 m) — 렌더러가 그 선을 그대로 그린다. */
|
||||
haul_from_m: number;
|
||||
haul_to_m: number;
|
||||
direction: HaulDirection;
|
||||
/** `Q`를 절토 구간의 지반유형 구성비로 안분한 값(㎥, 다짐상태). 합 = `volume_m3`. */
|
||||
ea_m3: number;
|
||||
rr_m3: number;
|
||||
br_m3: number;
|
||||
/** config 경계로 고른 장비 키. 경계값을 못 받으면 null(프론트에 사본을 두지 않는다). */
|
||||
equipment: string | null;
|
||||
/** 장비 경계현으로 가른 띠. 정점 쪽(가까운 장비)부터 평형선 쪽 순으로 담긴다. */
|
||||
bands: HaulBand[];
|
||||
}
|
||||
|
||||
/** 받아 줄 짝이 없어 남은 토량. `spoil` = 사토(잉여), `borrow` = 토취(부족). */
|
||||
export interface HaulResidual {
|
||||
index: number;
|
||||
kind: "spoil" | "borrow";
|
||||
from_m: number;
|
||||
to_m: number;
|
||||
@@ -110,6 +146,11 @@ const EPSILON = 1e-9;
|
||||
*/
|
||||
const MIN_SWING_RATIO = 0.02;
|
||||
|
||||
/** 경계현 높이를 훑을 때 쪼개는 칸 수. 잔요철에 속지 않을 만큼 촘촘해야 한다. */
|
||||
const CHORD_SCAN_STEPS = 64;
|
||||
/** 훑어서 찾은 칸 안에서 좁히는 이분법 반복 수. 24회면 칸 폭의 1/1600만 남는다. */
|
||||
const CHORD_SOLVE_STEPS = 24;
|
||||
|
||||
function factorFor(conversion: EarthworkConversion, ground: GroundType): number {
|
||||
const factor = conversion?.[ground]?.compacted;
|
||||
return Number.isFinite(factor) && factor > 0 ? factor : 1;
|
||||
@@ -134,7 +175,7 @@ function valueAt(points: MassHaulPoint[], x: number): number {
|
||||
|
||||
/**
|
||||
* `[fromX, toX]`를 왼쪽부터 훑어 곡선이 `level`을 **처음** 지나는 x. 없으면 null.
|
||||
* 평형선·반종거 수평선의 교점이 전부 이 함수 하나로 나온다.
|
||||
* 평형선·경계현·반종거 수평선의 교점이 전부 이 함수 하나로 나온다.
|
||||
*/
|
||||
function crossFrom(
|
||||
points: MassHaulPoint[],
|
||||
@@ -204,7 +245,7 @@ interface CutMix {
|
||||
|
||||
/**
|
||||
* `[fromX, toX]` 절토 구간의 지반유형 구성(다짐상태 가중치). 구간이 측점 사이를 반만
|
||||
* 물면 그 비율만큼만 센다 — 평형선 교점은 측점에 딱 떨어지지 않는다.
|
||||
* 물면 그 비율만큼만 센다 — 경계현 교점은 측점에 딱 떨어지지 않는다.
|
||||
*/
|
||||
function cutMix(
|
||||
points: MassHaulPoint[],
|
||||
@@ -228,8 +269,8 @@ function cutMix(
|
||||
return mix;
|
||||
}
|
||||
|
||||
/** 구성비로 `Q`를 안분한다. 구간에 절토 기록이 전혀 없으면 지반유형 미상 = 토사로 둔다. */
|
||||
function apportion(mix: CutMix, volume: number): Pick<HaulBlock, "ea_m3" | "rr_m3" | "br_m3"> {
|
||||
/** 구성비로 물량을 안분한다. 구간에 절토 기록이 전혀 없으면 지반유형 미상 = 토사로 둔다. */
|
||||
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 {
|
||||
@@ -239,20 +280,96 @@ function apportion(mix: CutMix, volume: number): Pick<HaulBlock, "ea_m3" | "rr_m
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 평균운반거리에 맞는 장비를 고른다. 경계 정의처는 `config_system.EARTHWORK_HAUL_EQUIPMENT_LIMITS_M`
|
||||
* **한 곳뿐**이라, 값을 못 받았으면 프론트에 사본을 두지 않고 그냥 비운다.
|
||||
*/
|
||||
function pickEquipment(limits: HaulEquipmentLimit[] | undefined, distanceM: number): string | null {
|
||||
if (!limits?.length) return null;
|
||||
for (const limit of limits) {
|
||||
if (limit.max_distance_m === null || distanceM <= limit.max_distance_m) return limit.key;
|
||||
}
|
||||
return limits[limits.length - 1].key;
|
||||
/** 거리 짧은 장비부터. `max_distance_m: null`(상한 없음)은 항상 맨 뒤. */
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
/** 한 블록을 장비 띠로 가르는 데 필요한 기하 계산 묶음. */
|
||||
interface BlockGeometry {
|
||||
/** `ratio`(0 = 평형선, 1 = 정점)에 해당하는 누가토량 높이. */
|
||||
levelAt: (ratio: number) => number;
|
||||
/** 그 높이 수평선이 블록 안에서 곡선을 자르는 두 점. */
|
||||
chordAt: (ratio: number) => { from: number; to: number; length: number };
|
||||
}
|
||||
|
||||
function blockGeometry(
|
||||
points: MassHaulPoint[],
|
||||
fromM: number,
|
||||
toM: number,
|
||||
apexM: number,
|
||||
base: number,
|
||||
apex: number,
|
||||
): BlockGeometry {
|
||||
const levelAt = (ratio: number): number => base + (apex - base) * ratio;
|
||||
const chordAt = (ratio: number): { from: number; to: number; length: number } => {
|
||||
const level = levelAt(ratio);
|
||||
const from = crossFrom(points, fromM, apexM, level) ?? fromM;
|
||||
const to = crossFrom(points, apexM, toM, level) ?? toM;
|
||||
return { from, to, length: Math.max(to - from, 0) };
|
||||
};
|
||||
return { levelAt, chordAt };
|
||||
}
|
||||
|
||||
/**
|
||||
* 누가토량 곡선에서 토량 분배(평형선·운반 블록·사토/토취)를 뽑는다.
|
||||
* 현의 길이가 `target` **이하로 확정되는** 가장 낮은 높이를 찾는다(반환값은 `ratio`).
|
||||
*
|
||||
* 현 길이는 평형선(가장 김)에서 정점(0)으로 갈수록 줄지만 **단조가 아니다** — 가지치기로
|
||||
* 블록을 하나로 묶어도 그 안의 원래 폴리라인에는 잔요철이 남아, 높이를 올리는 도중 현이
|
||||
* 잠깐 다시 길어질 수 있다. 이분법만 쓰면 그 지점에서 엉뚱한 근으로 수렴한다(무작위 검증에서
|
||||
* 종무대 경계현이 45m로 잡히는 사례를 잡았다).
|
||||
*
|
||||
* 그래서 **훑기 → 이분법** 두 단계로 간다: 먼저 전 구간을 성기게 훑어 현이 `target`을 넘는
|
||||
* **가장 높은** 자리를 찾고, 그 바로 위 칸에서만 이분법으로 좁힌다. 이렇게 잡은 경계 위쪽은
|
||||
* 전부 `target` 이하가 보장된다 — "이 띠는 이 장비로 옮길 수 있다"가 성립해야 하기 때문이다.
|
||||
* 평형선에서 이미 `target` 이하면 그 장비가 블록 전체를 맡는다 → 0.
|
||||
*/
|
||||
function ratioForChord(geometry: BlockGeometry, target: number): number {
|
||||
let lastOver = -1;
|
||||
for (let step = 0; step <= CHORD_SCAN_STEPS; step += 1) {
|
||||
if (geometry.chordAt(step / CHORD_SCAN_STEPS).length > target) lastOver = step;
|
||||
}
|
||||
if (lastOver < 0) return 0;
|
||||
if (lastOver >= CHORD_SCAN_STEPS) return 1;
|
||||
let low = lastOver / CHORD_SCAN_STEPS;
|
||||
let high = (lastOver + 1) / CHORD_SCAN_STEPS;
|
||||
for (let step = 0; step < CHORD_SOLVE_STEPS; step += 1) {
|
||||
const middle = (low + high) / 2;
|
||||
if (geometry.chordAt(middle).length > target) low = middle;
|
||||
else high = middle;
|
||||
}
|
||||
return high;
|
||||
}
|
||||
|
||||
/** 띠를 감싸는 다각형 — 아래 현 → 왼쪽 곡선 → 위 현 → 오른쪽 곡선. */
|
||||
function bandOutline(
|
||||
points: MassHaulPoint[],
|
||||
low: { from: number; to: number },
|
||||
high: { from: number; to: number },
|
||||
lowLevel: number,
|
||||
highLevel: number,
|
||||
): CurvePoint[] {
|
||||
const between = (a: number, b: number): CurvePoint[] =>
|
||||
points
|
||||
.filter((point) => point.chainage_m > a && point.chainage_m < b)
|
||||
.map((point) => ({ m: point.chainage_m, v: point.cumulative_volume_m3 }));
|
||||
return [
|
||||
{ m: low.from, v: lowLevel },
|
||||
...between(low.from, high.from),
|
||||
{ m: high.from, v: highLevel },
|
||||
{ m: high.to, v: highLevel },
|
||||
...between(high.to, low.to).reverse(),
|
||||
{ m: low.to, v: lowLevel },
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 누가토량 곡선에서 토량 분배(평형선·운반 블록·장비 띠·사토/토취)를 뽑는다.
|
||||
* 블록도 잔량도 안 나오면(평탄한 곡선) null.
|
||||
*/
|
||||
export function computeHaulPlan(
|
||||
@@ -265,42 +382,76 @@ export function computeHaulPlan(
|
||||
const range = Math.max(result.max_cumulative_m3 - result.min_cumulative_m3, 0);
|
||||
const minSwing = Math.max(range * MIN_SWING_RATIO, 1);
|
||||
const extrema = pruneExtrema(points, extremaIndices(points), minSwing);
|
||||
const tiers = sortedLimits(limits);
|
||||
|
||||
const blocks: HaulBlock[] = [];
|
||||
const residuals: HaulResidual[] = [];
|
||||
const steps: BalanceStep[] = [];
|
||||
let bandCounter = 0;
|
||||
|
||||
const pushBlock = (
|
||||
fromM: number,
|
||||
toM: number,
|
||||
base: number,
|
||||
apexX: number,
|
||||
apexY: number,
|
||||
apexM: number,
|
||||
apex: number,
|
||||
direction: HaulDirection,
|
||||
cutFrom: number,
|
||||
cutTo: number,
|
||||
): void => {
|
||||
const volume = Math.abs(apexY - base);
|
||||
const volume = Math.abs(apex - base);
|
||||
if (!(volume > EPSILON) || !(toM > fromM)) return;
|
||||
// 반종거 수평선 — 종거의 절반 높이에서 곡선을 자른 두 점 사이가 평균운반거리다.
|
||||
const half = direction === "forward" ? base + volume / 2 : base - volume / 2;
|
||||
const haulFrom = crossFrom(points, fromM, apexX, half) ?? fromM;
|
||||
const haulTo = crossFrom(points, apexX, toM, half) ?? toM;
|
||||
const distance = Math.max(haulTo - haulFrom, 0);
|
||||
const geometry = blockGeometry(points, fromM, toM, apexM, base, apex);
|
||||
|
||||
// 정점 쪽(가까운 장비)부터 평형선 쪽으로 내려오며 띠를 자른다.
|
||||
const bands: HaulBand[] = [];
|
||||
let upper = 1;
|
||||
for (const tier of tiers) {
|
||||
if (upper <= EPSILON) break;
|
||||
const lower = tier.max_distance_m === null ? 0 : ratioForChord(geometry, tier.max_distance_m);
|
||||
if (upper - lower <= 1e-6) {
|
||||
upper = lower;
|
||||
continue;
|
||||
}
|
||||
const lowChord = geometry.chordAt(lower);
|
||||
const highChord = geometry.chordAt(upper);
|
||||
const haulChord = geometry.chordAt((lower + upper) / 2);
|
||||
// 절토 쪽 변에서 잘라 낸 구간이 이 띠가 퍼오는 흙이다(산이면 왼쪽 변, 골이면 오른쪽 변).
|
||||
const cutFrom = direction === "forward" ? lowChord.from : highChord.to;
|
||||
const cutTo = direction === "forward" ? highChord.from : lowChord.to;
|
||||
const bandVolume = (upper - lower) * volume;
|
||||
bandCounter += 1;
|
||||
bands.push({
|
||||
index: bandCounter,
|
||||
equipment: tier.key || null,
|
||||
level_base_m3: geometry.levelAt(lower),
|
||||
level_apex_m3: geometry.levelAt(upper),
|
||||
volume_m3: bandVolume,
|
||||
boundary_from_m: lowChord.from,
|
||||
boundary_to_m: lowChord.to,
|
||||
haul_distance_m: haulChord.length,
|
||||
haul_from_m: haulChord.from,
|
||||
haul_to_m: haulChord.to,
|
||||
outline: bandOutline(
|
||||
points,
|
||||
lowChord,
|
||||
highChord,
|
||||
geometry.levelAt(lower),
|
||||
geometry.levelAt(upper),
|
||||
),
|
||||
...apportion(cutMix(points, cutFrom, cutTo, result.conversion), bandVolume),
|
||||
});
|
||||
upper = lower;
|
||||
}
|
||||
|
||||
blocks.push({
|
||||
index: blocks.length + 1,
|
||||
base_m3: base,
|
||||
from_m: fromM,
|
||||
to_m: toM,
|
||||
apex_m: apexX,
|
||||
apex_volume_m3: apexY,
|
||||
apex_m: apexM,
|
||||
apex_volume_m3: apex,
|
||||
volume_m3: volume,
|
||||
haul_distance_m: distance,
|
||||
haul_from_m: haulFrom,
|
||||
haul_to_m: haulTo,
|
||||
direction,
|
||||
equipment: pickEquipment(limits, distance),
|
||||
...apportion(cutMix(points, cutFrom, cutTo, result.conversion), volume),
|
||||
bands,
|
||||
});
|
||||
steps.push({ from_m: fromM, to_m: toM, level_m3: base });
|
||||
};
|
||||
@@ -309,6 +460,7 @@ export function computeHaulPlan(
|
||||
const delta = to - from;
|
||||
if (Math.abs(delta) < EPSILON) return;
|
||||
residuals.push({
|
||||
index: residuals.length + 1,
|
||||
kind: delta > 0 ? "spoil" : "borrow",
|
||||
from_m: fromM,
|
||||
to_m: toM,
|
||||
@@ -353,7 +505,7 @@ export function computeHaulPlan(
|
||||
if (nextY <= level + EPSILON) {
|
||||
// 성토가 절토를 다 받는다 — 평형선 그대로, 곡선이 되돌아온 점에서 블록을 닫는다.
|
||||
const end = crossFrom(points, apexX, nextX, level) ?? nextX;
|
||||
pushBlock(cursor, end, level, apexX, apexY, "forward", cursor, apexX);
|
||||
pushBlock(cursor, end, level, apexX, apexY, "forward");
|
||||
cursor = end;
|
||||
k += 1;
|
||||
} else {
|
||||
@@ -361,7 +513,7 @@ export function computeHaulPlan(
|
||||
const start = crossFrom(points, cursor, apexX, nextY) ?? cursor;
|
||||
steps.push({ from_m: cursor, to_m: start, level_m3: level });
|
||||
pushResidual(cursor, start, level, nextY);
|
||||
pushBlock(start, nextX, nextY, apexX, apexY, "forward", start, apexX);
|
||||
pushBlock(start, nextX, nextY, apexX, apexY, "forward");
|
||||
level = nextY;
|
||||
cursor = nextX;
|
||||
k += 2;
|
||||
@@ -369,7 +521,7 @@ export function computeHaulPlan(
|
||||
} else if (nextY >= level - EPSILON) {
|
||||
// 골 — 오른쪽 절토가 왼쪽 성토를 채운다(우→좌 운반).
|
||||
const end = crossFrom(points, apexX, nextX, level) ?? nextX;
|
||||
pushBlock(cursor, end, level, apexX, apexY, "backward", apexX, end);
|
||||
pushBlock(cursor, end, level, apexX, apexY, "backward");
|
||||
cursor = end;
|
||||
k += 1;
|
||||
} else {
|
||||
@@ -377,7 +529,7 @@ export function computeHaulPlan(
|
||||
const start = crossFrom(points, cursor, apexX, nextY) ?? cursor;
|
||||
steps.push({ from_m: cursor, to_m: start, level_m3: level });
|
||||
pushResidual(cursor, start, level, nextY);
|
||||
pushBlock(start, nextX, nextY, apexX, apexY, "backward", apexX, nextX);
|
||||
pushBlock(start, nextX, nextY, apexX, apexY, "backward");
|
||||
level = nextY;
|
||||
cursor = nextX;
|
||||
k += 2;
|
||||
@@ -422,12 +574,17 @@ export function haulPlanPayload(plan: HaulPlan): Record<string, unknown> {
|
||||
to_m: round(block.to_m),
|
||||
base_m3: round(block.base_m3),
|
||||
volume_m3: round(block.volume_m3),
|
||||
haul_distance_m: round(block.haul_distance_m),
|
||||
direction: block.direction,
|
||||
equipment: block.equipment,
|
||||
ea_m3: round(block.ea_m3),
|
||||
rr_m3: round(block.rr_m3),
|
||||
br_m3: round(block.br_m3),
|
||||
// 장비 띠 — 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,
|
||||
|
||||
@@ -1,20 +1,26 @@
|
||||
/* =============================================================================
|
||||
* B06_wf3_ProfileCross_UI_MassHaul_Balance_View.ts
|
||||
* 토량 분배(평형선) SVG 렌더러 — 유토곡선 위에 겹치는 레이어.
|
||||
* 토량 분배(평형선 · 장비 띠) SVG 렌더러 — 유토곡선 위에 겹치는 레이어.
|
||||
*
|
||||
* 그리는 것은 넷이다:
|
||||
* 1. 계단형 평형선 — 블록마다 수평, 사토·토취가 나는 자리에서 계단으로 옮겨 간다.
|
||||
* 2. 종거 표시선 — 평형선에서 곡선 정점까지. 이 길이가 곧 운반토량 `Q`.
|
||||
* 3. 반종거 수평선 — 종거의 1/2 높이에서 곡선을 자른 두 점 사이. 이 길이가 `L`이고
|
||||
* 끝에 방향 화살촉을 단다(산 = 좌→우, 골 = 우→좌).
|
||||
* 4. 육각 balloon — `Q` / `L`·장비 / EA·RR·BR. 사토·토취는 단차 화살표 + 라벨.
|
||||
* 그리는 것:
|
||||
* 1. 계단형 평형선 — 블록마다 수평(실선). 사토·토취가 나는 자리에서 계단으로 옮겨 간다.
|
||||
* 2. 장비 띠 면 — 경계현 두 개와 곡선 두 변으로 둘러싸인 다각형. 클릭 대상이다.
|
||||
* 3. 경계현(파선) — 그 현의 길이가 장비 경계거리와 같아지는 높이의 수평선.
|
||||
* 4. 평균운반거리(점선 + 화살촉) — 띠 중간 높이의 현. 방향은 산=좌→우, 골=우→좌.
|
||||
* 5. balloon — 도형이 곧 운반수단이다: 종무대 육각 / 도쟈 원 / 덤프 사각.
|
||||
* 사토·토취는 도형 없이 **언더바만**(2026-08-02 사용자 지시).
|
||||
*
|
||||
* 좌표 변환(x/y)과 그릴 상자는 유토곡선 렌더러가 넘겨준다 — 축을 두 번 정의하지 않는다.
|
||||
* 계산은 전부 `_UI_MassHaul_Balance`가 끝내 두므로 여기서는 배치만 판단한다.
|
||||
* 700줄 제한 대응으로 `_UI_MassHaul_View`에서 분리했다.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { HaulBlock, HaulPlan, HaulResidual } from "./B06_wf3_ProfileCross_UI_MassHaul_Balance";
|
||||
import type {
|
||||
HaulBand,
|
||||
HaulBlock,
|
||||
HaulPlan,
|
||||
HaulResidual,
|
||||
} 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";
|
||||
|
||||
@@ -34,15 +40,23 @@ const EQUIPMENT_LABEL: Record<string, LocaleKey> = {
|
||||
dump_truck: "B06_MassHaul_Equip_Dump",
|
||||
};
|
||||
|
||||
/** balloon 도형 = 운반수단. 모르는 장비는 사각으로 떨어뜨린다. */
|
||||
type BalloonShape = "hexagon" | "ellipse" | "rect";
|
||||
const EQUIPMENT_SHAPE: Record<string, BalloonShape> = {
|
||||
free_haul: "hexagon",
|
||||
dozer: "ellipse",
|
||||
dump_truck: "rect",
|
||||
};
|
||||
|
||||
const LINE_HEIGHT = 11;
|
||||
const PAD_X = 8;
|
||||
const PAD_Y = 5;
|
||||
/** 육각형 좌우 꼭짓점이 파고드는 깊이(px). */
|
||||
const NOTCH = 8;
|
||||
/** balloon과 곡선 사이 최소 틈. */
|
||||
const GAP = 7;
|
||||
/** 이보다 좁은 블록에는 balloon을 달지 않는다 — 서로 겹쳐 도면을 못 읽는다. */
|
||||
const MIN_BLOCK_WIDTH_PX = 34;
|
||||
/** 이보다 좁은 띠에는 balloon을 달지 않는다 — 서로 겹쳐 도면을 못 읽는다. */
|
||||
const MIN_BAND_WIDTH_PX = 34;
|
||||
/** 이보다 얇은 띠에는 평균운반거리 선을 긋지 않는다(경계현과 붙어 한 줄로 보인다). */
|
||||
const MIN_BAND_THICKNESS_PX = 9;
|
||||
|
||||
/** SVG는 자동 크기가 없어 폭을 어림해야 한다. 한글은 라틴 글자보다 넓다. */
|
||||
function textWidth(value: string): number {
|
||||
@@ -66,9 +80,7 @@ function equipmentLabel(key: string | null): string {
|
||||
return locale ? L(locale) : key;
|
||||
}
|
||||
|
||||
/**
|
||||
* 육각형 경로. 좌우 끝이 뾰족한 도면용 balloon 모양이라 폭이 좁아도 꼬리를 달기 쉽다.
|
||||
*/
|
||||
/** 육각형 — 좌우 끝이 뾰족한 도면용 모양. */
|
||||
function hexagonPoints(cx: number, cy: number, width: number, height: number): string {
|
||||
const halfW = width / 2;
|
||||
const halfH = height / 2;
|
||||
@@ -83,6 +95,40 @@ function hexagonPoints(cx: number, cy: number, width: number, height: number): s
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
function balloonShape(
|
||||
shape: BalloonShape,
|
||||
cx: number,
|
||||
cy: number,
|
||||
width: number,
|
||||
height: number,
|
||||
): SVGElement {
|
||||
if (shape === "ellipse") {
|
||||
// 원형은 같은 글자를 담으려면 사각보다 커야 한다 — 모서리가 비기 때문이다.
|
||||
return svgElement("ellipse", {
|
||||
cx,
|
||||
cy,
|
||||
rx: width / 2 + 5,
|
||||
ry: height / 2 + 3,
|
||||
class: "b06-balance__balloon-shape",
|
||||
});
|
||||
}
|
||||
if (shape === "rect") {
|
||||
return svgElement("rect", {
|
||||
x: cx - width / 2,
|
||||
y: cy - height / 2,
|
||||
width,
|
||||
height,
|
||||
rx: 2,
|
||||
ry: 2,
|
||||
class: "b06-balance__balloon-shape",
|
||||
});
|
||||
}
|
||||
return svgElement("polygon", {
|
||||
points: hexagonPoints(cx, cy, width, height),
|
||||
class: "b06-balance__balloon-shape",
|
||||
});
|
||||
}
|
||||
|
||||
interface PlacedBox {
|
||||
left: number;
|
||||
right: number;
|
||||
@@ -95,8 +141,8 @@ function overlaps(a: PlacedBox, b: PlacedBox): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* 겹치지 않는 세로 자리를 찾는다. 선호 방향(위/아래)으로 한 칸씩 밀어 보고, 상자를 벗어나면
|
||||
* 반대쪽을 시도한다. 끝내 못 찾으면 처음 자리에 그대로 둔다 — 안 그리는 것보다 낫다.
|
||||
* 겹치지 않는 세로 자리를 찾는다. 선호 방향으로 한 칸씩 밀어 보고, 상자를 벗어나면 반대쪽을
|
||||
* 시도한다. 끝내 못 찾으면 처음 자리에 그대로 둔다 — 안 그리는 것보다 낫다.
|
||||
*/
|
||||
function findSlot(
|
||||
placed: PlacedBox[],
|
||||
@@ -125,7 +171,7 @@ function findSlot(
|
||||
return preferredY;
|
||||
}
|
||||
|
||||
/** 계단형 평형선 — 칸 사이는 수직선으로 이어 계단을 만든다. */
|
||||
/** 계단형 평형선(기선) — 칸 사이는 수직선으로 이어 계단을 만든다. */
|
||||
function appendBalanceLine(group: SVGGElement, plan: HaulPlan, box: BalanceLayerBox): void {
|
||||
const steps = [...plan.steps].sort((a, b) => a.from_m - b.from_m);
|
||||
let previous: { x: number; y: number } | null = null;
|
||||
@@ -149,72 +195,93 @@ function appendBalanceLine(group: SVGGElement, plan: HaulPlan, box: BalanceLayer
|
||||
}
|
||||
}
|
||||
|
||||
/** 종거(수직) + 반종거 수평선(방향 화살촉). 두 선이 곧 `Q`와 `L`의 눈금이다. */
|
||||
function appendMeasures(group: SVGGElement, block: HaulBlock, box: BalanceLayerBox): void {
|
||||
const apexX = box.x(block.apex_m);
|
||||
const baseY = box.y(block.base_m3);
|
||||
const apexY = box.y(block.apex_volume_m3);
|
||||
group.append(
|
||||
svgElement("line", {
|
||||
x1: apexX,
|
||||
y1: baseY,
|
||||
x2: apexX,
|
||||
y2: apexY,
|
||||
class: "b06-balance__ordinate",
|
||||
}),
|
||||
);
|
||||
|
||||
const half = box.y(
|
||||
block.direction === "forward"
|
||||
? block.base_m3 + block.volume_m3 / 2
|
||||
: block.base_m3 - block.volume_m3 / 2,
|
||||
);
|
||||
const from = box.x(block.haul_from_m);
|
||||
const to = box.x(block.haul_to_m);
|
||||
if (to - from < 2) return;
|
||||
const tipX = block.direction === "forward" ? to : from;
|
||||
const backX = block.direction === "forward" ? to - 6 : from + 6;
|
||||
group.append(
|
||||
svgElement("line", { x1: from, y1: half, x2: to, y2: half, class: "b06-balance__haul" }),
|
||||
svgElement("polygon", {
|
||||
points: `${tipX},${half} ${backX},${half - 3.5} ${backX},${half + 3.5}`,
|
||||
class: "b06-balance__haul-arrow",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** 블록 balloon. 자리가 좁으면 줄을 줄여서라도 `Q`는 반드시 남긴다. */
|
||||
function appendBlockBalloon(
|
||||
/** 띠 하나의 면·경계현·평균운반거리 선. 면은 클릭 대상이라 핸들을 돌려준다. */
|
||||
function appendBandGeometry(
|
||||
group: SVGGElement,
|
||||
block: HaulBlock,
|
||||
band: HaulBand,
|
||||
box: BalanceLayerBox,
|
||||
): SVGGElement {
|
||||
const bandGroup = svgElement("g", {
|
||||
class: `b06-balance__band b06-balance__band--${band.equipment ?? "unknown"}`,
|
||||
});
|
||||
bandGroup.append(
|
||||
svgElement("polygon", {
|
||||
points: band.outline.map((point) => `${box.x(point.m)},${box.y(point.v)}`).join(" "),
|
||||
class: "b06-balance__band-face",
|
||||
}),
|
||||
);
|
||||
|
||||
const baseY = box.y(band.level_base_m3);
|
||||
const apexY = box.y(band.level_apex_m3);
|
||||
// 평형선과 겹치는 아래 경계현은 다시 긋지 않는다 — 기선은 이미 실선으로 깔려 있다.
|
||||
if (Math.abs(band.level_base_m3 - block.base_m3) > 1e-6) {
|
||||
bandGroup.append(
|
||||
svgElement("line", {
|
||||
x1: box.x(band.boundary_from_m),
|
||||
y1: baseY,
|
||||
x2: box.x(band.boundary_to_m),
|
||||
y2: baseY,
|
||||
class: "b06-balance__chord",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// 평균운반거리 — 띠 중간 높이의 현. 띠가 얇으면 경계현과 붙어 버려 생략한다.
|
||||
const from = box.x(band.haul_from_m);
|
||||
const to = box.x(band.haul_to_m);
|
||||
if (Math.abs(baseY - apexY) >= MIN_BAND_THICKNESS_PX && to - from >= 3) {
|
||||
const midY = (baseY + apexY) / 2;
|
||||
const tipX = block.direction === "forward" ? to : from;
|
||||
const backX = block.direction === "forward" ? to - 6 : from + 6;
|
||||
bandGroup.append(
|
||||
svgElement("line", { x1: from, y1: midY, x2: to, y2: midY, class: "b06-balance__haul" }),
|
||||
svgElement("polygon", {
|
||||
points: `${tipX},${midY} ${backX},${midY - 3.5} ${backX},${midY + 3.5}`,
|
||||
class: "b06-balance__haul-arrow",
|
||||
}),
|
||||
);
|
||||
}
|
||||
group.append(bandGroup);
|
||||
return bandGroup;
|
||||
}
|
||||
|
||||
/** 띠 balloon. 도형이 운반수단이고, 자리가 좁으면 줄을 줄여서라도 물량은 남긴다. */
|
||||
function appendBandBalloon(
|
||||
group: SVGGElement,
|
||||
block: HaulBlock,
|
||||
band: HaulBand,
|
||||
box: BalanceLayerBox,
|
||||
placed: PlacedBox[],
|
||||
): void {
|
||||
const apexX = box.x(block.apex_m);
|
||||
const apexY = box.y(block.apex_volume_m3);
|
||||
const baseY = box.y(block.base_m3);
|
||||
if (box.x(block.to_m) - box.x(block.from_m) < MIN_BLOCK_WIDTH_PX) return;
|
||||
): SVGGElement | null {
|
||||
if (box.x(block.to_m) - box.x(block.from_m) < MIN_BAND_WIDTH_PX) return null;
|
||||
|
||||
const equipment = equipmentLabel(block.equipment);
|
||||
const equipment = equipmentLabel(band.equipment);
|
||||
const room = box.bottom - box.top;
|
||||
const lines = [`${block.index} · ${compactVolume(block.volume_m3)}㎥`];
|
||||
const lines = [`${band.index} · ${compactVolume(band.volume_m3)}㎥`];
|
||||
if (room >= 110) {
|
||||
lines.push(`L ${compactDistance(block.haul_distance_m)}m${equipment ? ` · ${equipment}` : ""}`);
|
||||
lines.push(`L ${compactDistance(band.haul_distance_m)}m${equipment ? ` · ${equipment}` : ""}`);
|
||||
}
|
||||
if (room >= 150) {
|
||||
lines.push(
|
||||
`EA ${compactVolume(block.ea_m3)} · RR ${compactVolume(block.rr_m3)} · BR ${compactVolume(block.br_m3)}`,
|
||||
`EA ${compactVolume(band.ea_m3)} · RR ${compactVolume(band.rr_m3)} · BR ${compactVolume(band.br_m3)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const width = Math.max(...lines.map(textWidth)) + PAD_X * 2 + NOTCH;
|
||||
const height = lines.length * LINE_HEIGHT + PAD_Y * 2;
|
||||
// 산이면 정점 위, 골이면 정점 아래 — 곡선 바깥이라 곡선을 가리지 않는다.
|
||||
const baseY = box.y(band.level_base_m3);
|
||||
const apexY = box.y(band.level_apex_m3);
|
||||
const upward = block.direction === "forward";
|
||||
const preferred = apexY + (upward ? -(height / 2 + GAP) : height / 2 + GAP);
|
||||
const clamped = Math.min(Math.max(preferred, box.top + height / 2), box.bottom - height / 2);
|
||||
const centerX = Math.min(Math.max(apexX, box.left + width / 2), box.right - width / 2);
|
||||
const centerY = findSlot(placed, centerX, clamped, width, height, box, upward);
|
||||
const preferred = Math.min(
|
||||
Math.max((baseY + apexY) / 2, box.top + height / 2),
|
||||
box.bottom - height / 2,
|
||||
);
|
||||
const centerX = Math.min(
|
||||
Math.max(box.x(block.apex_m), box.left + width / 2),
|
||||
box.right - width / 2,
|
||||
);
|
||||
const centerY = findSlot(placed, centerX, preferred, width, height, box, upward);
|
||||
placed.push({
|
||||
left: centerX - width / 2,
|
||||
right: centerX + width / 2,
|
||||
@@ -225,25 +292,21 @@ function appendBlockBalloon(
|
||||
const balloon = svgElement("g", { class: "b06-balance__balloon" });
|
||||
const title = svgElement("title");
|
||||
title.textContent =
|
||||
`${L("B06_MassHaul_Block")} ${block.index} · ` +
|
||||
`${L("B06_MassHaul_HaulVolume")} ${compactVolume(block.volume_m3)}㎥ · ` +
|
||||
`${L("B06_MassHaul_HaulDistance")} ${compactDistance(block.haul_distance_m)}m` +
|
||||
(equipment ? ` · ${equipment}` : "") +
|
||||
` · EA ${compactVolume(block.ea_m3)} / RR ${compactVolume(block.rr_m3)} / BR ${compactVolume(block.br_m3)}㎥`;
|
||||
// 지시선 — balloon이 밀려났을 때 어느 정점의 것인지 잇는다.
|
||||
`${L("B06_MassHaul_Block")} ${block.index} · ${equipment || "-"} · ` +
|
||||
`${L("B06_MassHaul_HaulVolume")} ${compactVolume(band.volume_m3)}㎥ · ` +
|
||||
`${L("B06_MassHaul_HaulDistance")} ${compactDistance(band.haul_distance_m)}m · ` +
|
||||
`EA ${compactVolume(band.ea_m3)} / RR ${compactVolume(band.rr_m3)} / BR ${compactVolume(band.br_m3)}㎥`;
|
||||
// 지시선 — balloon이 밀려났을 때 어느 띠의 것인지 잇는다.
|
||||
balloon.append(
|
||||
title,
|
||||
svgElement("line", {
|
||||
x1: apexX,
|
||||
y1: upward ? Math.min(apexY, baseY) : Math.max(apexY, baseY),
|
||||
x1: box.x(block.apex_m),
|
||||
y1: (baseY + apexY) / 2,
|
||||
x2: centerX,
|
||||
y2: centerY,
|
||||
class: "b06-balance__leader",
|
||||
}),
|
||||
svgElement("polygon", {
|
||||
points: hexagonPoints(centerX, centerY, width, height),
|
||||
class: "b06-balance__balloon-shape",
|
||||
}),
|
||||
balloonShape(EQUIPMENT_SHAPE[band.equipment ?? ""] ?? "rect", centerX, centerY, width, height),
|
||||
...lines.map((line, index) =>
|
||||
svgText(line, {
|
||||
x: centerX,
|
||||
@@ -254,9 +317,13 @@ function appendBlockBalloon(
|
||||
),
|
||||
);
|
||||
group.append(balloon);
|
||||
return balloon;
|
||||
}
|
||||
|
||||
/** 사토·토취 — 평형선 단차를 세로 양방향 화살표로 찍고 그 옆에 수량을 적는다. */
|
||||
/**
|
||||
* 사토·토취 — 평형선 단차를 세로 화살표로 찍고 수량은 **언더바만** 두른 라벨로 적는다
|
||||
* (2026-08-02 사용자 지시: 도형은 운반수단 표시 전용).
|
||||
*/
|
||||
function appendResidual(
|
||||
group: SVGGElement,
|
||||
residual: HaulResidual,
|
||||
@@ -285,11 +352,16 @@ function appendResidual(
|
||||
);
|
||||
// 좁은 그래프에서는 화살표만 남기고 수량은 툴팁으로 돌린다.
|
||||
if (!compact) {
|
||||
const textY = (y1 + y2) / 2 + 3;
|
||||
const width = textWidth(label);
|
||||
marker.append(
|
||||
svgText(label, {
|
||||
x: centerX + 5,
|
||||
y: (y1 + y2) / 2 + 3,
|
||||
class: "b06-balance__residual-text",
|
||||
svgText(label, { x: centerX + 5, y: textY, class: "b06-balance__residual-text" }),
|
||||
svgElement("line", {
|
||||
x1: centerX + 5,
|
||||
y1: textY + 3,
|
||||
x2: centerX + 5 + width,
|
||||
y2: textY + 3,
|
||||
class: "b06-balance__residual-underline",
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -299,16 +371,45 @@ function appendResidual(
|
||||
/**
|
||||
* 토량 분배 레이어를 유토곡선 SVG에 얹는다. 곡선·측점선 위, 선택 말풍선 아래에 놓아야
|
||||
* 읽는 순서가 맞으므로 호출 위치를 옮기지 말 것.
|
||||
*
|
||||
* 띠 면을 누르면 그 balloon이 강조된다. 다른 띠나 빈 곳을 누르면 풀린다
|
||||
* (2026-08-02 사용자 지시). 강조는 이 SVG 안에서만 사는 상태라 다시 그리면 초기화된다.
|
||||
*/
|
||||
export function appendBalanceLayer(svg: SVGSVGElement, plan: HaulPlan, box: BalanceLayerBox): void {
|
||||
const group = svgElement("g", { class: "b06-balance" });
|
||||
appendBalanceLine(group, plan, box);
|
||||
for (const block of plan.blocks) appendMeasures(group, block, box);
|
||||
|
||||
const compact = box.bottom - box.top < 110;
|
||||
for (const residual of plan.residuals) appendResidual(group, residual, box, compact);
|
||||
|
||||
const faces: SVGGElement[] = [];
|
||||
const balloons: Array<SVGGElement | null> = [];
|
||||
for (const block of plan.blocks) {
|
||||
for (const band of block.bands) faces.push(appendBandGeometry(group, block, band, box));
|
||||
}
|
||||
const placed: PlacedBox[] = [];
|
||||
for (const block of plan.blocks) appendBlockBalloon(group, block, box, placed);
|
||||
for (const block of plan.blocks) {
|
||||
for (const band of block.bands)
|
||||
balloons.push(appendBandBalloon(group, block, band, box, placed));
|
||||
}
|
||||
|
||||
const clearAll = (): void => {
|
||||
for (const face of faces) face.classList.remove("is-active");
|
||||
for (const balloon of balloons) balloon?.classList.remove("is-active");
|
||||
};
|
||||
faces.forEach((face, index) => {
|
||||
face.addEventListener("click", (event) => {
|
||||
// 카드·측점 선택까지 올라가면 그래프가 다시 그려져 방금 켠 강조가 사라진다.
|
||||
event.stopPropagation();
|
||||
const already = face.classList.contains("is-active");
|
||||
clearAll();
|
||||
if (already) return;
|
||||
face.classList.add("is-active");
|
||||
balloons[index]?.classList.add("is-active");
|
||||
});
|
||||
});
|
||||
// 띠 밖(그래프 빈 곳)을 누르면 강조를 푼다.
|
||||
svg.addEventListener("click", clearAll);
|
||||
svg.append(group);
|
||||
}
|
||||
|
||||
@@ -319,8 +420,9 @@ 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)}㎥`],
|
||||
[L("B06_MassHaul_BlockCount"), `${plan.blocks.length}`],
|
||||
[L("B06_MassHaul_BlockCount"), `${plan.blocks.length} / ${bands}`],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -235,6 +235,12 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
* 기본 오프셋·스텝은 context(config) 값으로 갱신된다. */
|
||||
let rockBoundaryDefault = -0.5;
|
||||
let rockBoundaryStep = 0.1;
|
||||
/**
|
||||
* 암 경계선 오프셋은 **0을 넘을 수 없다**(2026-08-02 사용자 지시). 오프셋은 지면선에서
|
||||
* 아래로 파고든 깊이라, 양수가 되면 경계선이 지표면 위로 떠올라 토사층이 음수가 된다.
|
||||
* DB에 옛 양수값이 남아 있어도 읽는 즉시 0으로 눌러 계산이 뒤집히지 않게 한다.
|
||||
*/
|
||||
const clampRockOffset = (value: number): number => Math.min(value, 0);
|
||||
const rockOffsets = new Map<string, number>();
|
||||
const rockKey = (chainageM: number): string => chainageM.toFixed(2);
|
||||
const rockSessionKey = (): string | null =>
|
||||
@@ -274,16 +280,18 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
return rockBoundaryDefault;
|
||||
},
|
||||
offsetFor: (section) =>
|
||||
rockOffsets.get(rockKey(section.chainage_m)) ??
|
||||
section.design?.rock_boundary_offset_m ??
|
||||
rockBoundaryDefault,
|
||||
clampRockOffset(
|
||||
rockOffsets.get(rockKey(section.chainage_m)) ??
|
||||
section.design?.rock_boundary_offset_m ??
|
||||
rockBoundaryDefault,
|
||||
),
|
||||
adjust: (chainageM, deltaM) => {
|
||||
const key = rockKey(chainageM);
|
||||
const stored = sectionDetail?.cross_sections.find(
|
||||
(section) => Math.abs(section.chainage_m - chainageM) < 0.01,
|
||||
)?.design?.rock_boundary_offset_m;
|
||||
const current = rockOffsets.get(key) ?? stored ?? rockBoundaryDefault;
|
||||
rockOffsets.set(key, Math.round((current + deltaM) * 100) / 100);
|
||||
const current = clampRockOffset(rockOffsets.get(key) ?? stored ?? rockBoundaryDefault);
|
||||
rockOffsets.set(key, clampRockOffset(Math.round((current + deltaM) * 100) / 100));
|
||||
persistRockOffsets();
|
||||
sectionView.refreshCard(chainageM);
|
||||
recomputeIfRock(chainageM); // 경계 이동 → 2단계 무릎·단면적 재계산
|
||||
|
||||
@@ -525,10 +525,13 @@
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* 암 경계선 제어(E-7) — 그래프 X축 제목 행 우측 맞춤. */
|
||||
/* 암 경계선 제어(E-7) — 그래프 영역 **하단 행 가운데**(2026-08-02 사용자 지시).
|
||||
우측 맞춤이던 것을 가운데로 옮겼다. `left: 50%` + 음수 translate가 폭을 모르고도
|
||||
가운데를 잡는 유일한 방법이다(절대 위치라 flex 정렬이 안 먹는다). */
|
||||
.b06-cross-card__rockb {
|
||||
position: absolute;
|
||||
right: var(--spacing-8);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
bottom: 2px;
|
||||
background: color-mix(in srgb, var(--color-surface) 82%, transparent);
|
||||
border-radius: var(--radius-inputs);
|
||||
|
||||
@@ -87,11 +87,12 @@
|
||||
fill: var(--color-success);
|
||||
}
|
||||
|
||||
/* 0선: 절토 우세와 성토 우세의 경계라 격자보다 진하게. 어떤 노선에서도 반드시 보인다. */
|
||||
/* 0선: 절토 우세와 성토 우세의 경계라 격자보다 진하게. 어떤 노선에서도 반드시 보인다.
|
||||
**실선**이다(2026-08-02 사용자 지시) — 도면에서 파선은 장비 경계현의 몫이라,
|
||||
0선까지 파선이면 둘이 구분되지 않는다. */
|
||||
.b06-masshaul__zero {
|
||||
stroke: var(--color-text-secondary);
|
||||
stroke-width: 1.2;
|
||||
stroke-dasharray: 4 3;
|
||||
}
|
||||
|
||||
.b06-masshaul__zero-tick {
|
||||
@@ -177,12 +178,41 @@
|
||||
* 사토는 위험색, 토취는 성공색. 곡선 위에 겹치므로 선을 얇게 두고 balloon만 불투명하다.
|
||||
* ========================================================================== */
|
||||
|
||||
/* 평형선 — 블록마다 수평. 곡선보다 얇지만 실선이라 격자와 구분된다. */
|
||||
/* 평형선(기선) — 블록마다 수평. **실선**이 기선의 표식이다(도면 A–A′). */
|
||||
.b06-balance__line {
|
||||
stroke: var(--color-warning);
|
||||
stroke-width: 1.6;
|
||||
}
|
||||
|
||||
/* 장비 경계현 — 그 현의 길이가 장비 경계거리와 같아지는 높이(도면 B–B′ / C–C′).
|
||||
기선과 구분되도록 **파선**으로 둔다. 선타입이 곧 의미다. */
|
||||
.b06-balance__chord {
|
||||
stroke: var(--color-warning);
|
||||
stroke-width: 1.2;
|
||||
stroke-dasharray: 6 4;
|
||||
}
|
||||
|
||||
/* 장비 띠 면 — 평소엔 클릭 대상으로만 남고, 눌렀을 때만 옅게 칠해진다. */
|
||||
.b06-balance__band-face {
|
||||
fill: transparent;
|
||||
stroke: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.b06-balance__band.is-active > .b06-balance__band-face {
|
||||
fill: color-mix(in srgb, var(--color-warning) 22%, transparent);
|
||||
}
|
||||
|
||||
/* 눌린 띠의 balloon은 테두리를 굵혀 어느 띠의 값인지 바로 짚이게 한다. */
|
||||
.b06-balance__balloon.is-active > .b06-balance__balloon-shape {
|
||||
fill: color-mix(in srgb, var(--color-warning) 20%, var(--color-surface-raised));
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.b06-balance__balloon.is-active > .b06-balance__leader {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* 계단의 수직 연결선 — 사토·토취가 나는 자리라 파선으로 "옮겨 갔음"을 드러낸다. */
|
||||
.b06-balance__riser {
|
||||
stroke: var(--color-warning);
|
||||
@@ -190,18 +220,12 @@
|
||||
stroke-dasharray: 3 2;
|
||||
}
|
||||
|
||||
/* 종거(평형선 → 정점) = 운반토량 Q의 눈금. */
|
||||
.b06-balance__ordinate {
|
||||
/* 평균운반거리 = 띠 중간 높이의 현. 경계현(파선)과 헷갈리지 않게 **점선**으로 가늘게.
|
||||
화살촉이 운반 방향이다(산 = 좌→우, 골 = 우→좌). */
|
||||
.b06-balance__haul {
|
||||
stroke: var(--color-warning);
|
||||
stroke-width: 1;
|
||||
stroke-dasharray: 2 3;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* 반종거 수평선 = 평균운반거리 L의 눈금. 화살촉이 운반 방향이다. */
|
||||
.b06-balance__haul {
|
||||
stroke: var(--color-warning);
|
||||
stroke-width: 1.6;
|
||||
}
|
||||
|
||||
.b06-balance__haul-arrow {
|
||||
@@ -216,7 +240,8 @@
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* 육각 balloon — 곡선 위에 놓이므로 배경을 거의 불투명하게 깐다. */
|
||||
/* balloon 도형 = 운반수단(종무대 육각 / 도쟈 원 / 덤프 사각). 곡선 위에 놓이므로
|
||||
배경을 거의 불투명하게 깐다. 도형별로 색을 나누지 않는다 — 모양이 이미 구분자다. */
|
||||
.b06-balance__balloon-shape {
|
||||
fill: color-mix(in srgb, var(--color-surface-raised) 94%, transparent);
|
||||
stroke: var(--color-warning);
|
||||
@@ -262,6 +287,17 @@
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* 사토·토취는 도형 없이 언더바만 두른다(2026-08-02 사용자 지시). */
|
||||
.b06-balance__residual--spoil .b06-balance__residual-underline {
|
||||
stroke: var(--color-danger);
|
||||
stroke-width: 1.2;
|
||||
}
|
||||
|
||||
.b06-balance__residual--borrow .b06-balance__residual-underline {
|
||||
stroke: var(--color-success);
|
||||
stroke-width: 1.2;
|
||||
}
|
||||
|
||||
/* 범례에서 분배 레이어는 곡선이 아니라 평형선 계열임을 스와치로 알린다. */
|
||||
.b06-masshaul__legend-item--balance .b06-masshaul__legend-swatch {
|
||||
border-top-color: var(--color-warning);
|
||||
|
||||
@@ -449,10 +449,12 @@ EARTHWORK_CONVERSION_FACTORS = {
|
||||
# 평균운반거리(m)로 운반장비를 고른다. 위에서부터 순서대로 검사해 `max_distance_m`
|
||||
# **이하**면 그 장비를 쓰고, 마지막 항목(None)이 나머지를 전부 받는다.
|
||||
#
|
||||
# 종무대(무대운반) ≤ 20m < 도쟈(불도저 압토) ≤ 50m < 덤프트럭
|
||||
# 종무대(무대운반) ≤ 20m < 도쟈(불도저 압토) ≤ 70m < 덤프트럭
|
||||
#
|
||||
# 2026-08-02 사용자 확정값. 참고 도면의 관측 운반거리(종무대 14~16m, 도쟈 20m,
|
||||
# 덤프 67.8m)와 모순되지 않는다. 현장·발주처 기준에 따라 달라질 수 있으므로
|
||||
# 2026-08-02 사용자 확정값(도쟈 50 → 70m). 유토곡선의 장비 경계현은 이 값을
|
||||
# **수평 현의 길이**로 읽는다 — 현 길이가 20m가 되는 높이 위쪽이 종무대 몫,
|
||||
# 70m가 되는 높이까지가 도쟈 몫, 그 아래가 덤프 몫이다.
|
||||
# 현장·발주처 기준에 따라 달라질 수 있으므로
|
||||
# **이 상수가 거리 경계의 유일한 정의처**이며 여기만 고치면 된다.
|
||||
#
|
||||
# key는 도면 표기와 다음과 같이 대응한다: free_haul=종무대, dozer=도쟈, dump_truck=덤프.
|
||||
@@ -461,7 +463,7 @@ EARTHWORK_CONVERSION_FACTORS = {
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
EARTHWORK_HAUL_EQUIPMENT_LIMITS_M = (
|
||||
("free_haul", 20.0),
|
||||
("dozer", 50.0),
|
||||
("dozer", 70.0),
|
||||
("dump_truck", None),
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user