feat(B05): 계획 유토곡선 이관 + 사토장·토취장 4옵션 자동 선정
절·성토 균형은 종단 시공계획고로 정해지고 사토장 위치도 계획고를 다시 끌어야 정리되므로, 계획용 유토곡선과 부지 선정을 B05로 옮겼다. B06은 실측 단면적으로 낸 정식 곡선과 확정·B08 인계를 맡는다. B05 계획 유토곡선 - _UI_Profile_MassHaul.ts: 종단면 패널 안 2차 하단 슬라이드. 펼치면 12행 도면 테이블 자리를 곡선이 대신 차지한다. 곡선 SVG를 종단 그래프와 같은 가로 스크롤러 안 형제로 넣고 MassHaulAxis에 LONG_PAD + originOffset을 넘겨 X축을 종단 chainageMapper와 일치시켰다. - computeLongitudinalMassHaul(): 횡단 설계가 없는 계획 단계용 개략 엔진. 표준횡단 노반폭을 전 구간 공통으로 물린다. 종단 기반 토량은 국내 기준상 노선계획 개산용 이므로(2026-08-03 조사) 표준단면까지 씌워 정밀화하지 않는다. 사토장·토취장 자동 선정 - B05_wf2_Route_Engine_Disposal.py + POST /route/disposal-sites: 노선 corridor DEM 격자를 훑어 후보 부지를 추리고 옵션별 점수로 정렬한다. 기준 4가지 — 비용(최단거리+하향 운반), 지형안정성(완경사·계곡 이격), 계곡부(계곡 축 매립), 임내 공간(라이다 수고 기반 공터). 기준값 정의처는 DISPOSAL_SITE_CRITERIA. - _UI_Profile_Disposal.ts: 범례 줄의 [토량 분배]와 [도형 위치 초기화] 사이에 구분기호와 함께 라디오 토글 4개. 부지 카드에 위치·용량·운반거리와 기준별 근거값, 측점·용량 사용자 정의 편집과 복원. 후보 부족으로 남은 토량은 경고로 알린다. - 확정 시 종단 정본의 disposal_sites에 심는다(기준 미선택이면 저장분 삭제). B06 연동 - common_util_mass_haul_sites.ts: 저장된 부지를 정식 곡선의 잔량 위치 기준으로 운반거리를 다시 재 표시하고 mass_haul.disposal_sites로 B08에 넘긴다. 미구현(계획서에 남김): 토취장 토질 판정(지반유형이 B06 산출물), 계곡부 암거 연장. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -353,3 +353,17 @@
|
||||
border-top-color: var(--color-warning);
|
||||
border-top-style: dashed;
|
||||
}
|
||||
|
||||
/* 범례 줄 안에서 컨트롤 묶음을 가르는 세로 구분기호(사토장·토취장 토글 양옆). */
|
||||
.b06-masshaul__legend-divider {
|
||||
width: 1px;
|
||||
height: 16px;
|
||||
flex: none;
|
||||
background: var(--color-border);
|
||||
}
|
||||
|
||||
/* 사토장·토취장 줄 — 요약 줄과 같은 양식이되 위 줄과 이어 붙은 것으로 읽히게 경계를 지운다. */
|
||||
.b06-masshaul__summary--sites {
|
||||
border-top: none;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
@@ -392,6 +392,61 @@ function longitudinalAreaSamples(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 횡단 설계가 아직 없을 때 쓰는 종단 개략 곡선의 입력.
|
||||
*
|
||||
* B05 노선 설계 단계에는 측점별 횡단 설계(`CrossDesign`)가 없다 — 그건 B06에서 만든다.
|
||||
* 그래서 노반폭을 표준횡단 설정(`road_width_m + shoulder_left_m + shoulder_right_m`)에서
|
||||
* 한 값으로 받아 전 구간에 같게 물린다. 계획선을 끌어 옮기며 절·성토 균형을 잡는
|
||||
* 용도라 이 정도 개략값이면 충분하고, 정식 물량은 B06 횡단 기준 곡선이 낸다.
|
||||
*/
|
||||
export interface LongitudinalMassHaulOptions {
|
||||
/** 전 구간 공통 노반폭(m). */
|
||||
roadbedWidthM: number;
|
||||
/**
|
||||
* 절토 전량에 물릴 지반유형. B05는 암반 경계선을 모르므로 기본값은 토사이며,
|
||||
* 그만큼 다짐환산이 보수적으로(작게) 잡힌다.
|
||||
*/
|
||||
groundType?: GroundType;
|
||||
}
|
||||
|
||||
/**
|
||||
* 횡단 설계 없이 종단 계획선만으로 개략 유토곡선을 만든다(B05 계획 단계).
|
||||
*
|
||||
* `difference_m = 계획고 − 지반고`이므로 음수면 절토, 양수면 성토다. 단면적은 그 높이차에
|
||||
* 노반폭을 곱한 사각형으로 어림한다 — 비탈면 몫이 빠져 실제보다 작게 나오지만, 국내
|
||||
* 설계기준이 종단 기반 토량을 노선계획 단계의 **개산용**으로만 두고 별도 산식을 정하지
|
||||
* 않으므로(2026-08-03 조사) 표준단면까지 씌워 정밀화하지는 않는다.
|
||||
* 자연방토는 성토측 지반 경사를 알 수 없어 전부 불가로 본다(보수적).
|
||||
*/
|
||||
export function computeLongitudinalMassHaul(
|
||||
longitudinal: MassHaulLongitudinal,
|
||||
conversion: EarthworkConversion,
|
||||
options: LongitudinalMassHaulOptions,
|
||||
): MassHaulResult | null {
|
||||
const profile = longitudinal.design_profiles?.[0];
|
||||
const width = finiteArea(options.roadbedWidthM);
|
||||
if (!profile || !(width > 0)) return null;
|
||||
const ground = normalizeGround(options.groundType);
|
||||
const samples = profile.samples
|
||||
.filter((sample) => Number.isFinite(sample.chainage_m) && Number.isFinite(sample.difference_m))
|
||||
.sort((a, b) => a.chainage_m - b.chainage_m);
|
||||
const areas: AreaSample[] = samples.map((sample, index) => {
|
||||
const height = sample.difference_m;
|
||||
const cutArea = height < 0 ? -height * width : 0;
|
||||
return {
|
||||
station_id: `plan@${index}`,
|
||||
chainage_m: sample.chainage_m,
|
||||
cut_soil_area_m2: ground === "soil" ? cutArea : 0,
|
||||
cut_rock_area_m2: ground === "soil" ? 0 : cutArea,
|
||||
rock_kind: ground === "soil" ? null : ground,
|
||||
fill_area_m2: height > 0 ? height * width : 0,
|
||||
natural_spoil: false,
|
||||
};
|
||||
});
|
||||
return integrate(areas, conversion);
|
||||
}
|
||||
|
||||
/**
|
||||
* 측점별 설계 결과에서 유토곡선을 계산한다(횡단 기준·지반유형별 환산).
|
||||
* 확정 저장과 B08 인계에 쓰는 **정식 곡선**이라 시그니처를 그대로 유지한다.
|
||||
@@ -438,12 +493,16 @@ export function massHaulPayload(
|
||||
result: MassHaulResult,
|
||||
haulPlan?: HaulPlan | null,
|
||||
balloonOffsets?: Record<string, [number, number]>,
|
||||
/** B05가 고른 사토장·토취장 — 정식 곡선 기준으로 운반거리를 다시 잰 형태로 받는다. */
|
||||
disposalSites?: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const round = (value: number): number => Math.round(value * 100) / 100;
|
||||
return {
|
||||
basis: "compacted",
|
||||
conversion: result.conversion,
|
||||
...(haulPlan ? { haul_plan: haulPlanPayload(haulPlan) } : {}),
|
||||
// B08 내역서가 사토·토취 운반 항목을 세우는 입력. 선정이 없으면 키 자체가 빠진다.
|
||||
...(disposalSites ? { disposal_sites: disposalSites } : {}),
|
||||
// 사용자가 끌어 옮긴 balloon 위치 — 비어 있어도 보낸다(초기화가 저장에 반영돼야 한다).
|
||||
...(balloonOffsets ? { balloon_offsets: balloonOffsets } : {}),
|
||||
cut_natural_m3: {
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
/* =============================================================================
|
||||
* common_util_mass_haul_sites.ts
|
||||
* 사토장·토취장 선정 결과를 유토곡선 옆에 표시하고 운반거리를 다시 재는 공용 모듈.
|
||||
*
|
||||
* 부지를 **고르는** 일은 B05 계획 단계에서 끝난다(계획선을 바꿔야 정리되는 문제라서다).
|
||||
* B06은 그 결과를 그대로 받아, 실측 단면적으로 낸 **정식 곡선**의 잔량 위치를 기준으로
|
||||
* 운반거리를 다시 재 표시하고 B08 내역서로 넘긴다 — 계획 곡선과 정식 곡선은 잔량이
|
||||
* 놓이는 자리가 다르므로, B05가 적어 둔 거리를 그대로 쓰면 물량과 어긋난다.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { HaulPlan } from "./common_util_mass_haul_balance";
|
||||
|
||||
/** B05가 종단 정본(`longitudinal.disposal_sites`)에 심어 둔 부지 하나. */
|
||||
export interface DisposalSiteRecord {
|
||||
id: string;
|
||||
kind: "spoil" | "borrow";
|
||||
chainage_m: number;
|
||||
offset_m: number;
|
||||
side: "left" | "right";
|
||||
x: number;
|
||||
y: number;
|
||||
elevation_m: number;
|
||||
capacity_m3: number;
|
||||
assigned_m3: number;
|
||||
/** B05 계획 곡선 기준 운반거리(m). 표시·저장에는 아래 재계산값을 쓴다. */
|
||||
haul_distance_m: number;
|
||||
/** 사용자가 위치나 용량을 손으로 고친 부지인가. */
|
||||
custom: boolean;
|
||||
}
|
||||
|
||||
export interface DisposalSitesSnapshot {
|
||||
/** 선정 기준 — `cost` | `stability` | `valley` | `clearing`. */
|
||||
option: string;
|
||||
sites: DisposalSiteRecord[];
|
||||
}
|
||||
|
||||
const OPTION_LABEL: Record<string, string> = {
|
||||
cost: "비용",
|
||||
stability: "지형안정성",
|
||||
valley: "계곡부",
|
||||
clearing: "임내 공간",
|
||||
};
|
||||
|
||||
/** 잔량이 몰려 있는 대표 위치(m). 없으면 null. */
|
||||
function residualCenter(plan: HaulPlan | null, kind: "spoil" | "borrow"): number | null {
|
||||
const residuals = (plan?.residuals ?? []).filter((residual) => residual.kind === kind);
|
||||
const total = residuals.reduce(
|
||||
(sum, residual) =>
|
||||
sum +
|
||||
(kind === "spoil"
|
||||
? Math.max(residual.volume_m3 - residual.natural_m3, 0)
|
||||
: residual.volume_m3),
|
||||
0,
|
||||
);
|
||||
if (total <= 0) return null;
|
||||
const weighted = residuals.reduce((sum, residual) => {
|
||||
const volume =
|
||||
kind === "spoil" ? Math.max(residual.volume_m3 - residual.natural_m3, 0) : residual.volume_m3;
|
||||
return sum + volume * 0.5 * (residual.from_m + residual.to_m);
|
||||
}, 0);
|
||||
return weighted / total;
|
||||
}
|
||||
|
||||
/** 정식 곡선의 잔량 위치를 기준으로 각 부지의 운반거리를 다시 잰 목록. */
|
||||
export function resolveDisposalSites(
|
||||
snapshot: DisposalSitesSnapshot | null | undefined,
|
||||
plan: HaulPlan | null,
|
||||
): DisposalSiteRecord[] {
|
||||
if (!snapshot?.sites?.length) return [];
|
||||
const centers = {
|
||||
spoil: residualCenter(plan, "spoil"),
|
||||
borrow: residualCenter(plan, "borrow"),
|
||||
};
|
||||
return snapshot.sites.map((site) => {
|
||||
const center = centers[site.kind];
|
||||
if (center === null) return site;
|
||||
const distance = Math.abs(site.chainage_m - center) + Math.abs(site.offset_m);
|
||||
return { ...site, haul_distance_m: Math.round(distance * 10) / 10 };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 곡선 아래 요약 줄 다음에 붙는 사토장·토취장 줄. 선정 결과가 없으면 null.
|
||||
* 표시 수치는 정식 곡선 기준으로 다시 잰 값이라 B05 화면과 거리가 다를 수 있다.
|
||||
*/
|
||||
export function createDisposalSummary(
|
||||
snapshot: DisposalSitesSnapshot | null | undefined,
|
||||
plan: HaulPlan | null,
|
||||
): HTMLElement | null {
|
||||
const sites = resolveDisposalSites(snapshot, plan);
|
||||
if (!sites.length) return null;
|
||||
const row = document.createElement("div");
|
||||
row.className = "b06-masshaul__summary b06-masshaul__summary--sites";
|
||||
const basis = document.createElement("span");
|
||||
basis.className = "b06-masshaul__basis";
|
||||
basis.textContent = `선정 기준 ${OPTION_LABEL[snapshot?.option ?? ""] ?? snapshot?.option ?? "-"}`;
|
||||
row.append(basis);
|
||||
for (const site of sites) {
|
||||
const chip = document.createElement("span");
|
||||
chip.className = "b06-masshaul__chip";
|
||||
const name = document.createElement("em");
|
||||
name.textContent = `${site.kind === "spoil" ? "사토장" : "토취장"}${site.custom ? "*" : ""}`;
|
||||
const value = document.createElement("strong");
|
||||
value.textContent =
|
||||
`${site.chainage_m.toFixed(1)}m ${site.side === "left" ? "좌" : "우"}` +
|
||||
`${Math.abs(site.offset_m).toFixed(0)}m · ${site.assigned_m3.toLocaleString()}㎥ · ` +
|
||||
`L=${site.haul_distance_m.toFixed(0)}m`;
|
||||
chip.append(name, value);
|
||||
row.append(chip);
|
||||
}
|
||||
const note = document.createElement("span");
|
||||
note.className = "b06-masshaul__chip";
|
||||
note.textContent = "* 사용자 정의";
|
||||
row.append(note);
|
||||
return row;
|
||||
}
|
||||
|
||||
/** 확정 저장·B08 인계용 직렬화. 운반거리는 정식 곡선 기준으로 다시 잰 값을 싣는다. */
|
||||
export function disposalSitesPayload(
|
||||
snapshot: DisposalSitesSnapshot | null | undefined,
|
||||
plan: HaulPlan | null,
|
||||
): Record<string, unknown> | undefined {
|
||||
const sites = resolveDisposalSites(snapshot, plan);
|
||||
if (!sites.length) return undefined;
|
||||
return { option: snapshot?.option, sites };
|
||||
}
|
||||
@@ -508,6 +508,11 @@ export function createMassHaulLegend(
|
||||
visibleKeys: ReadonlySet<string>,
|
||||
onToggle: (key: string) => void,
|
||||
onResetBalloons?: () => void,
|
||||
/**
|
||||
* 토량 분배 토글과 도형 위치 초기화 버튼 **사이**에 끼워 넣을 컨트롤(양쪽에 구분기호가 선다).
|
||||
* B05 사토장·토취장 선정 토글이 이 자리에 들어간다(2026-08-03 사용자 지시).
|
||||
*/
|
||||
extras?: HTMLElement[],
|
||||
): HTMLElement {
|
||||
const legend = document.createElement("div");
|
||||
legend.className = "b06-masshaul__legend";
|
||||
@@ -539,6 +544,15 @@ export function createMassHaulLegend(
|
||||
L("B06_MassHaul_Balance_Layer"),
|
||||
"b06-masshaul__legend-item b06-masshaul__legend-item--balance",
|
||||
);
|
||||
if (extras?.length) {
|
||||
const divider = (): HTMLElement => {
|
||||
const line = document.createElement("span");
|
||||
line.className = "b06-masshaul__legend-divider";
|
||||
line.setAttribute("aria-hidden", "true");
|
||||
return line;
|
||||
};
|
||||
legend.append(divider(), ...extras, divider());
|
||||
}
|
||||
// 끌어 옮긴 balloon을 한 번에 자동 배치로 되돌린다(프론트 캐시 + 다음 확정 시 영구저장소).
|
||||
if (onResetBalloons && visibleKeys.has(MASS_HAUL_BALANCE_KEY)) {
|
||||
const reset = document.createElement("button");
|
||||
|
||||
Reference in New Issue
Block a user