Files
Aislo/B05_wf2_Route/B05_wf2_Route_UI_Profile_Disposal.ts
T
eomsangdonandClaude Opus 5 6c90c26eef 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>
2026-08-03 19:06:46 +09:00

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,
})),
};
},
};
}