feat(B05/B06): 유토곡선 횡단 기준 통일·자동선정 삭제·계획선 R 연결 외 9건

사용자 피드백 반영(2026-08-03 2차).

유토곡선 일원화
- B05 유토곡선을 B06과 같은 계산(computeMassHaulSeries: 횡단 정식 + 종단 개략
  비교)으로 전환. 상세 조회가 미지정 측점에 기본 설계를 즉석 계산해 얹으므로
  B05 진입 시점에 이미 정식 곡선 재료가 있다. 표시 토글 sessionStorage 키와
  balloon 위치 scope를 B06과 공유해 두 화면이 같은 그림을 유지한다.
- B05 전용 개략 엔진(computeLongitudinalMassHaul) 삭제.
- 유토곡선 펼침 시 종단 그래프:곡선 세로 1:1 분할.

사토장·토취장 자동 선정 삭제
- 임도에는 토취장이 없고 부족분은 설계자가 계획선을 고쳐 맞춘다는 실무 판단에
  따라 4옵션(비용/지형안정성/계곡부/임내공간) 엔진·API·UI·config·저장 훅 전부
  제거. massHaulPayload/createMassHaulLegend 시그니처 원복.

기본 지반 변경
- 기본 설계 프리뷰·확정 기본값을 토사에서 리핑암 + 예상 암반 경계 0.5m로 변경.
  산지 절토는 표토 아래 암이 일반적이라 전량 토사 가정은 물량이 낙관적이다.
  발파암은 B06에서 측점별 수정.

계획선 R·선 연결 (배관 구조물 자리)
- 계획선 샘플이 고정 격자로만 평가되어 격자 사이 변화점(배관 측점 승격분)의
  모서리를 잘라먹던 결함 수정 — 샘플 집합에 PVI·BVC·EVC를 합집합으로 포함
  (프론트 buildAlignment + 서버 build_alignment 동일 규칙). 면적 가중치도
  합쳐진 격자로 재계산. 37.3m 변화점 + R=150 수치 검증 통과.

표시 개선
- EP(종점) 잔량 라벨: 곡선 끝점에 "EP {누가토량}㎥" 불투명 판(B05·B06 공통).
- 유토곡선 0선을 붉은 굵은 실선(2.5px)으로, 0 눈금값도 적색.
- 3D 뷰 [측점 가로선] 우측 [측점 라벨] 토글 신설(기본 꺼짐) — 구조물 측점만
  "측점번호 구조물명" 스프라이트 표시.

typecheck·vite build·ruff·B03 테스트 통과. 실서버 스모크로 리핑암 기본 프리뷰와
disposal-sites 제거 확인.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-03 19:54:37 +09:00
co-authored by Claude Opus 5
parent 6c90c26eef
commit f4f56069b0
25 changed files with 286 additions and 1534 deletions
+21 -17
View File
@@ -95,16 +95,34 @@
/* 0선: 절토 우세와 성토 우세의 경계라 격자보다 진하게. 어떤 노선에서도 반드시 보인다.
**실선**이다(2026-08-02 사용자 지시) — 도면에서 파선은 장비 경계현의 몫이라,
0선까지 파선이면 둘이 구분되지 않는다. */
/* 0선은 절·성 우세를 가르는 기준선 — 붉은 굵은 실선으로 다른 격자와 확실히 구분한다
(2026-08-03 사용자 지시). */
.b06-masshaul__zero {
stroke: var(--color-text-secondary);
stroke-width: 1.2;
stroke: var(--color-danger, #d32f2f);
stroke-width: 2.5;
}
.b06-masshaul__zero-tick {
fill: var(--color-text-secondary);
fill: var(--color-danger, #d32f2f);
font-weight: var(--font-weight-medium);
}
/* EP(종점) 잔량 라벨 — 곡선 위에 놓이므로 불투명 판을 깐다. */
.b06-masshaul__ep-dot {
fill: var(--color-danger, #d32f2f);
}
.b06-masshaul__ep-plate {
fill: var(--color-surface);
stroke: var(--color-border);
}
.b06-masshaul__ep-label {
fill: var(--color-text-body);
font-size: 11px;
font-weight: 600;
}
/* 그래프 이름표 배경 — 이름이 곡선 위에 놓이므로 불투명하게 깐다. */
.b06-masshaul__name-plate {
fill: var(--color-surface);
@@ -353,17 +371,3 @@
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;
}
-59
View File
@@ -392,61 +392,6 @@ 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 인계에 쓰는 **정식 곡선**이라 시그니처를 그대로 유지한다.
@@ -493,16 +438,12 @@ 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: {
-126
View File
@@ -1,126 +0,0 @@
/* =============================================================================
* 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 };
}
+31 -14
View File
@@ -415,6 +415,37 @@ export function createMassHaulChart(
);
}
// EP(종점) 잔량 라벨 — 곡선 끝점의 누가토량이 곧 노선 전체의 잉여(+)/부족(−)이라
// 눈금을 되짚지 않아도 읽히게 값 자체를 적는다(2026-08-03 사용자 지시). 단위 ㎥ 명기.
if (banded) {
const last = banded.result.points[banded.result.points.length - 1];
const epX = x(last.chainage_m);
const epY = y(last.cumulative_volume_m3);
const text = `EP ${formatVolume(last.cumulative_volume_m3)}㎥`;
const labelWidth = textWidth(text) + 10;
// 종점은 오른쪽 끝이라 라벨을 왼쪽으로 눕히고, 위아래는 플롯 안으로 클램프한다.
const labelX = Math.max(epX - 8, axis.padLeft + labelWidth);
const labelY = Math.min(Math.max(epY - 8, MASS_PAD_TOP + 12), heightPx - MASS_PAD_BOTTOM - 4);
svg.append(
svgElement("circle", { cx: epX, cy: epY, r: 3.5, class: "b06-masshaul__ep-dot" }),
svgElement("rect", {
x: labelX - labelWidth,
y: labelY - 12,
width: labelWidth,
height: 16,
rx: 4,
ry: 4,
class: "b06-masshaul__ep-plate",
}),
svgText(text, {
x: labelX - 5,
y: labelY,
"text-anchor": "end",
class: "b06-masshaul__ep-label",
}),
);
}
// 토량 분배 레이어 — 곡선 **위**, 선택 말풍선 **아래**. 순서를 바꾸면 평형선이 곡선을
// 가리거나(위로 올리면) 선택 말풍선이 balloon에 묻힌다(아래로 내리면).
if (haulPlan) {
@@ -508,11 +539,6 @@ 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";
@@ -544,15 +570,6 @@ 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");