절·성토 균형은 종단 시공계획고로 정해지고 사토장 위치도 계획고를 다시 끌어야 정리되므로, 계획용 유토곡선과 부지 선정을 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>
369 lines
14 KiB
TypeScript
369 lines
14 KiB
TypeScript
/* =============================================================================
|
|
* B05_wf2_Route_UI_Profile_Disposal.ts
|
|
* 사토장·토취장 자동 선정 — 4옵션 토글과 결과 목록, 사용자 정의 편집.
|
|
*
|
|
* 토글은 계획 유토곡선 범례 줄의 [토량 분배]와 [도형 위치 초기화] **사이**에 구분기호와
|
|
* 함께 들어간다(2026-08-03 사용자 지시). 하나를 켜면 나머지는 꺼지는 라디오 방식이다.
|
|
*
|
|
* ── 서버가 계산하는 것 / 화면이 계산하는 것 ──────────────────────────
|
|
* 후보 부지 탐색은 DEM 격자를 훑어야 해서 서버 몫이다(`/route/disposal-sites`).
|
|
* 화면은 유토곡선이 이미 낸 잔량(사토·토취 구간과 토량)을 그대로 실어 보낸다 —
|
|
* 같은 곡선을 서버에서 다시 적분하면 계획선을 끌 때마다 화면과 어긋난다.
|
|
*
|
|
* ── 사용자 정의 ─────────────────────────────────────────────────────
|
|
* 자동 선정값은 출발점일 뿐이라 위치(측점)와 용량을 손으로 고칠 수 있다. 고치면 그 부지에
|
|
* "사용자 정의" 배지가 붙고 운반거리를 다시 잰다. [사용자 정의 삭제]로 자동값을 되돌린다.
|
|
* ========================================================================== */
|
|
|
|
import type { DisposalOption, DisposalSite, DisposalZone } from "./B05_wf2_Route_Api_Fetch";
|
|
import { computeDisposalSites } from "./B05_wf2_Route_Api_Fetch";
|
|
import type { HaulPlan } from "@util/common_util_mass_haul_balance";
|
|
import { showToast } from "@ui/ui_template_elements";
|
|
|
|
/** 선택한 기준을 세션 동안 유지한다(패널 펼침·범례 토글과 같은 수명). */
|
|
const OPTION_KEY = "b05-route-disposal-option";
|
|
|
|
const OPTION_LABELS: Array<{ key: DisposalOption; label: string; tip: string }> = [
|
|
{ key: "cost", label: "비용", tip: "운반거리가 짧고 트럭이 오르막을 타지 않는 자리를 고릅니다." },
|
|
{
|
|
key: "stability",
|
|
label: "지형안정성",
|
|
tip: "평탄지·완경사지를 고르고 계곡부·하천 주변은 유실 위험으로 제외합니다.",
|
|
},
|
|
{
|
|
key: "valley",
|
|
label: "계곡부",
|
|
tip: "배관(암거) 지점과 연계해 계곡부를 메우는 실무 방식으로 고릅니다.",
|
|
},
|
|
{
|
|
key: "clearing",
|
|
label: "임내 공간",
|
|
tip: "라이다 식생 높이가 낮은 공터·벌채지를 우선합니다.",
|
|
},
|
|
];
|
|
|
|
/** 화면이 들고 있는 부지 하나 — 서버 자동값 위에 사용자 수정분을 얹은 상태. */
|
|
interface EditableSite extends DisposalSite {
|
|
/** 사용자가 위치나 용량을 고쳤는가. 배지 표시와 복원 대상 판정에 쓴다. */
|
|
custom: boolean;
|
|
/** 자동 선정 당시 값 — [사용자 정의 삭제]가 여기로 되돌린다. */
|
|
auto: { chainage_m: number; assigned_m3: number; haul_distance_m: number };
|
|
}
|
|
|
|
export interface RouteDisposalController {
|
|
/** 범례 줄에 끼워 넣을 토글 4개. */
|
|
toggles: HTMLElement[];
|
|
/** 선정 결과 목록 — 유토곡선 요약 막대 아래에 붙인다. */
|
|
panel: HTMLElement;
|
|
setRoute(projectId: string, routeId: number | null): void;
|
|
/** 유토곡선이 다시 계산될 때마다 호출한다. 잔량이 바뀌면 결과를 다시 받는다. */
|
|
update(plan: HaulPlan | null): void;
|
|
/** 확정 저장용 직렬화. 기준을 안 골랐으면 null. */
|
|
payload(): Record<string, unknown> | null;
|
|
}
|
|
|
|
/** 잔량 목록을 서버가 받는 구간 형태로 옮긴다. */
|
|
function zonesOf(plan: HaulPlan | null, kind: "spoil" | "borrow"): DisposalZone[] {
|
|
return (plan?.residuals ?? [])
|
|
.filter((residual) => residual.kind === kind)
|
|
.map((residual) => ({
|
|
from_m: residual.from_m,
|
|
to_m: residual.to_m,
|
|
// 사토는 자연방토로 빠지는 몫을 뺀 나머지만 실어 내면 된다.
|
|
volume_m3:
|
|
kind === "spoil"
|
|
? Math.max(residual.volume_m3 - residual.natural_m3, 0)
|
|
: residual.volume_m3,
|
|
}))
|
|
.filter((zone) => zone.volume_m3 > 0);
|
|
}
|
|
|
|
function totalOf(zones: DisposalZone[]): number {
|
|
return zones.reduce((sum, zone) => sum + zone.volume_m3, 0);
|
|
}
|
|
|
|
/** 잔량이 실질적으로 바뀌었는지 — 같은 값으로 서버를 다시 부르지 않기 위한 지문. */
|
|
function signatureOf(
|
|
option: DisposalOption | null,
|
|
spoil: DisposalZone[],
|
|
borrow: DisposalZone[],
|
|
): string {
|
|
const round = (value: number): number => Math.round(value * 10) / 10;
|
|
const part = (zones: DisposalZone[]): string =>
|
|
zones
|
|
.map((zone) => `${round(zone.from_m)}:${round(zone.to_m)}:${round(zone.volume_m3)}`)
|
|
.join(",");
|
|
return `${option ?? "-"}|${part(spoil)}|${part(borrow)}`;
|
|
}
|
|
|
|
function metricRow(label: string, value: string): HTMLElement {
|
|
const item = document.createElement("span");
|
|
item.className = "b05-disposal__metric";
|
|
const caption = document.createElement("em");
|
|
caption.textContent = label;
|
|
const amount = document.createElement("strong");
|
|
amount.textContent = value;
|
|
item.append(caption, amount);
|
|
return item;
|
|
}
|
|
|
|
export function createRouteDisposalPanel(): RouteDisposalController {
|
|
const panel = document.createElement("div");
|
|
panel.className = "b05-disposal";
|
|
panel.hidden = true;
|
|
|
|
let projectId = "";
|
|
let routeId: number | null = null;
|
|
let option: DisposalOption | null =
|
|
(sessionStorage.getItem(OPTION_KEY) as DisposalOption) || null;
|
|
let sites: EditableSite[] = [];
|
|
let signature = "";
|
|
let pending = false;
|
|
let lastPlan: HaulPlan | null = null;
|
|
|
|
const toggles = OPTION_LABELS.map(({ key, label, tip }) => {
|
|
const button = document.createElement("button");
|
|
button.type = "button";
|
|
button.className = "b06-masshaul__legend-item b05-disposal__toggle";
|
|
button.textContent = label;
|
|
button.title = tip;
|
|
button.addEventListener("click", () => select(key));
|
|
return button;
|
|
});
|
|
|
|
function syncToggles(): void {
|
|
toggles.forEach((button, index) => {
|
|
const active = OPTION_LABELS[index].key === option;
|
|
button.classList.toggle("is-off", !active);
|
|
button.setAttribute("aria-pressed", String(active));
|
|
// 하나를 고르면 나머지는 비활성 — 두 기준을 섞은 결과는 근거가 설명되지 않는다.
|
|
button.disabled = pending || (option !== null && !active);
|
|
});
|
|
}
|
|
|
|
function select(next: DisposalOption): void {
|
|
option = option === next ? null : next;
|
|
if (option) sessionStorage.setItem(OPTION_KEY, option);
|
|
else sessionStorage.removeItem(OPTION_KEY);
|
|
sites = [];
|
|
signature = "";
|
|
syncToggles();
|
|
void refresh();
|
|
}
|
|
|
|
async function refresh(): Promise<void> {
|
|
if (!option || !routeId || !projectId) {
|
|
panel.hidden = true;
|
|
panel.replaceChildren();
|
|
return;
|
|
}
|
|
const spoilZones = zonesOf(lastPlan, "spoil");
|
|
const borrowZones = zonesOf(lastPlan, "borrow");
|
|
const next = signatureOf(option, spoilZones, borrowZones);
|
|
if (next === signature) {
|
|
render();
|
|
return;
|
|
}
|
|
if (!spoilZones.length && !borrowZones.length) {
|
|
signature = next;
|
|
sites = [];
|
|
render("절·성토가 균형을 이뤄 사토장·토취장이 필요하지 않습니다.");
|
|
return;
|
|
}
|
|
pending = true;
|
|
syncToggles();
|
|
render("후보지를 계산하는 중…");
|
|
try {
|
|
const response = await computeDisposalSites(projectId, routeId, option, {
|
|
surplus_m3: totalOf(spoilZones),
|
|
shortage_m3: totalOf(borrowZones),
|
|
surplus_zones: spoilZones,
|
|
shortage_zones: borrowZones,
|
|
});
|
|
signature = next;
|
|
sites = [...response.spoil, ...response.borrow].map((site) => ({
|
|
...site,
|
|
custom: false,
|
|
auto: {
|
|
chainage_m: site.chainage_m,
|
|
assigned_m3: site.assigned_m3,
|
|
haul_distance_m: site.haul_distance_m,
|
|
},
|
|
}));
|
|
// 후보가 모자라 남은 토량은 반드시 알린다 — 조용히 빠지면 계획이 완성된 줄 안다.
|
|
const leftover =
|
|
response.unassigned_spoil_m3 > 0 || response.unassigned_borrow_m3 > 0
|
|
? `후보지가 부족해 사토 ${response.unassigned_spoil_m3.toLocaleString()}㎥ · ` +
|
|
`토취 ${response.unassigned_borrow_m3.toLocaleString()}㎥가 배정되지 않았습니다.`
|
|
: undefined;
|
|
render(sites.length ? leftover : "조건에 맞는 후보지를 찾지 못했습니다.");
|
|
} catch (error) {
|
|
sites = [];
|
|
render(error instanceof Error ? error.message : "후보지를 계산하지 못했습니다.");
|
|
} finally {
|
|
pending = false;
|
|
syncToggles();
|
|
}
|
|
}
|
|
|
|
/** 사용자가 위치를 옮기면 운반거리는 잔량 구간 중심까지의 거리로 다시 잰다. */
|
|
function recomputeHaul(site: EditableSite): void {
|
|
const zones = zonesOf(lastPlan, site.kind);
|
|
const total = totalOf(zones);
|
|
if (total <= 0) return;
|
|
const center =
|
|
zones.reduce((sum, zone) => sum + zone.volume_m3 * 0.5 * (zone.from_m + zone.to_m), 0) /
|
|
total;
|
|
site.haul_distance_m =
|
|
Math.round((Math.abs(site.chainage_m - center) + Math.abs(site.offset_m)) * 10) / 10;
|
|
}
|
|
|
|
function editRow(site: EditableSite): HTMLElement {
|
|
const row = document.createElement("div");
|
|
row.className = "b05-disposal__edit";
|
|
const add = (label: string, value: number, apply: (next: number) => void): void => {
|
|
const wrap = document.createElement("label");
|
|
wrap.className = "b05-disposal__field";
|
|
const caption = document.createElement("span");
|
|
caption.textContent = label;
|
|
const input = document.createElement("input");
|
|
input.type = "number";
|
|
input.step = "0.1";
|
|
input.min = "0";
|
|
input.value = String(value);
|
|
input.addEventListener("change", () => {
|
|
const parsed = Number(input.value);
|
|
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
input.value = String(value);
|
|
showToast("0 이상의 숫자를 입력하세요.", "error");
|
|
return;
|
|
}
|
|
apply(parsed);
|
|
site.custom = true;
|
|
recomputeHaul(site);
|
|
render();
|
|
});
|
|
wrap.append(caption, input);
|
|
row.append(wrap);
|
|
};
|
|
add("측점(m)", site.chainage_m, (next) => {
|
|
site.chainage_m = next;
|
|
});
|
|
add("용량(㎥)", site.assigned_m3, (next) => {
|
|
site.assigned_m3 = next;
|
|
});
|
|
if (site.custom) {
|
|
const reset = document.createElement("button");
|
|
reset.type = "button";
|
|
reset.className = "b05-disposal__reset";
|
|
reset.textContent = "사용자 정의 삭제";
|
|
reset.title = "이 부지를 자동 선정값으로 되돌립니다.";
|
|
reset.addEventListener("click", () => {
|
|
site.chainage_m = site.auto.chainage_m;
|
|
site.assigned_m3 = site.auto.assigned_m3;
|
|
site.haul_distance_m = site.auto.haul_distance_m;
|
|
site.custom = false;
|
|
render();
|
|
});
|
|
row.append(reset);
|
|
}
|
|
return row;
|
|
}
|
|
|
|
function siteCard(site: EditableSite): HTMLElement {
|
|
const card = document.createElement("div");
|
|
card.className = `b05-disposal__site b05-disposal__site--${site.kind}`;
|
|
const head = document.createElement("div");
|
|
head.className = "b05-disposal__head";
|
|
const title = document.createElement("strong");
|
|
title.textContent = `${site.kind === "spoil" ? "사토장" : "토취장"} ${site.id.split("-")[1]}`;
|
|
head.append(title);
|
|
if (site.custom) {
|
|
const badge = document.createElement("span");
|
|
badge.className = "b05-disposal__badge";
|
|
badge.textContent = "사용자 정의";
|
|
head.append(badge);
|
|
}
|
|
const metrics = document.createElement("div");
|
|
metrics.className = "b05-disposal__metrics";
|
|
metrics.append(
|
|
metricRow(
|
|
"위치",
|
|
`${site.chainage_m.toFixed(1)}m ${site.side === "left" ? "좌" : "우"}${Math.abs(site.offset_m).toFixed(0)}m`,
|
|
),
|
|
metricRow(
|
|
"배정",
|
|
`${site.assigned_m3.toLocaleString()}㎥ / 수용 ${site.capacity_m3.toLocaleString()}㎥`,
|
|
),
|
|
metricRow("운반거리", `${site.haul_distance_m.toFixed(0)}m`),
|
|
metricRow("지반경사", `${(site.mean_slope * 100).toFixed(0)}%`),
|
|
);
|
|
// 옵션마다 사용자가 확인해야 할 근거값이 다르다 — 고른 기준에 해당하는 것만 덧붙인다.
|
|
if (option === "cost") {
|
|
metrics.append(
|
|
metricRow("낙차", `${site.drop_m.toFixed(1)}m ${site.drop_m > 0 ? "(하향)" : "(상향)"}`),
|
|
);
|
|
} else if (option === "stability" && site.valley_distance_m !== null) {
|
|
metrics.append(metricRow("계곡 이격", `${site.valley_distance_m.toFixed(0)}m`));
|
|
} else if (option === "valley" && site.valley_distance_m !== null) {
|
|
metrics.append(metricRow("계곡 축까지", `${site.valley_distance_m.toFixed(0)}m`));
|
|
} else if (option === "clearing" && site.canopy_m !== null) {
|
|
metrics.append(metricRow("수고", `${site.canopy_m.toFixed(1)}m`));
|
|
}
|
|
card.append(head, metrics, editRow(site));
|
|
return card;
|
|
}
|
|
|
|
function render(note?: string): void {
|
|
panel.hidden = !option;
|
|
panel.replaceChildren();
|
|
if (!option) return;
|
|
if (note) {
|
|
const message = document.createElement("span");
|
|
message.className = "b05-disposal__note";
|
|
message.textContent = note;
|
|
panel.append(message);
|
|
}
|
|
for (const site of sites) panel.append(siteCard(site));
|
|
}
|
|
|
|
syncToggles();
|
|
|
|
return {
|
|
toggles,
|
|
panel,
|
|
setRoute(nextProjectId, nextRouteId) {
|
|
if (projectId === nextProjectId && routeId === nextRouteId) return;
|
|
projectId = nextProjectId;
|
|
routeId = nextRouteId;
|
|
signature = "";
|
|
sites = [];
|
|
void refresh();
|
|
},
|
|
update(plan) {
|
|
lastPlan = plan;
|
|
void refresh();
|
|
},
|
|
payload() {
|
|
if (!option) return null;
|
|
return {
|
|
option,
|
|
sites: sites.map((site) => ({
|
|
id: site.id,
|
|
kind: site.kind,
|
|
chainage_m: site.chainage_m,
|
|
offset_m: site.offset_m,
|
|
side: site.side,
|
|
x: site.x,
|
|
y: site.y,
|
|
elevation_m: site.elevation_m,
|
|
capacity_m3: site.capacity_m3,
|
|
assigned_m3: site.assigned_m3,
|
|
haul_distance_m: site.haul_distance_m,
|
|
custom: site.custom,
|
|
})),
|
|
};
|
|
},
|
|
};
|
|
}
|