feat(B06): 횡단도에 사토장 성토선·말풍선 표시

교본 6장 3절이 운반처리 위치를 평면도·횡단도에 표시하도록 요구함.

- 긴 파선+점(9 3 2 3) · 사토장 색 — **터파기 짧은 점선과 선 종류를 가름**
- 이름표 「유용토운반작업장 ○㎡」 를 평상 위에 얹음
- 말풍선에 단면·폭·구간 용량·못 담은 몫·잘림 경고와 **근거 두 줄**
  (「폭은 노면 끝에서 잼」·「교본 6장 3절이 표시를 요구함」)
- 지반을 못 만나 잘린 사토장은 붉게 — 사면 미폐합 경고와 같은 결
- 계획선을 만져도 사토장이 안 사라지게 저장분 폭·구간값을 이어 붙임
  (폭은 구간 용량에서 서버가 정한 값이라 브라우저가 다시 풀지 않음)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-09 11:29:57 +09:00
co-authored by Claude Opus 5
parent 3a5a13b488
commit 490566eb4d
5 changed files with 167 additions and 1 deletions
+19
View File
@@ -453,6 +453,25 @@ export interface CrossDesign {
/** 암반부에 적용할 지반유형(`ripping_rock`/`blasting_rock`). 토사 측점은 null. */
cut_rock_kind?: GroundType | null;
fill_area_m2: number;
/**
* 사토장(유용토운반작업장) 몫 — **`fill_area_m2` 와 합치지 않는다**(2026-09-09 확정 ㉠).
* 노면 끝 바깥은 노선 성토가 아니라 사토장 성토라, 받는 쪽이 갈라 볼 수 있어야 한다.
* 사토장이 없는 측점은 전부 0·null 이다(구 데이터에는 아예 없어 optional).
*/
spoil_fill_area_m2?: number;
spoil_fill_side?: "left" | "right" | null;
spoil_fill_width_m?: number;
spoil_fill_max_width_m?: number;
spoil_fill_line?: Array<{ offset_m: number; elevation_m: number }>;
spoil_fill_unclosed?: boolean;
/** 사토장이 대신 차지해 노선 성토에서 뺀 몫(㎡) — 되짚기용. 합계에 또 넣지 말 것. */
spoil_fill_replaced_fill_m2?: number;
/** 구간(구조물) 단위 값 — 측점마다 같은 값이 실린다. 말풍선·수량이 되짚는 데 쓴다. */
spoil_fill_capacity_m3?: number;
spoil_fill_placed_m3?: number;
spoil_fill_unplaced_m3?: number;
spoil_fill_structure_id?: string | null;
spoil_fill_extra_distance_m?: number | null;
/** 성토측 자연 지반 경사(rise/run). 자연방토 판정 입력. 성토측이 없으면 null. */
fill_ground_slope?: number | null;
/**
+30 -1
View File
@@ -89,7 +89,23 @@ export const USER_TOUCHED_KEYS = [
] as const;
/** 다시 계산해도 살려 두는 값 — 위 사용자 값에 **상태를 나르는 둘**을 더한 것. */
const PRESERVED_KEYS = ["status", "pavement_suggested", ...USER_TOUCHED_KEYS] as const;
// 사토장 구간값 — **브라우저가 못 만드는 값**이라 이어 붙인다. 구간 전체를 봐야 나오는
// 값이고(용량 배분) 정본은 [저장] 때 서버가 다시 낸다. 안 이으면 계획선을 만지는 순간
// 말풍선에서 「구간 용량 …」이 사라진다.
const SPOIL_KEYS = [
"spoil_fill_capacity_m3",
"spoil_fill_placed_m3",
"spoil_fill_unplaced_m3",
"spoil_fill_structure_id",
"spoil_fill_extra_distance_m",
] as const;
const PRESERVED_KEYS = [
"status",
"pavement_suggested",
...USER_TOUCHED_KEYS,
...SPOIL_KEYS,
] as const;
function preserveUserFields(
next: NonNullable<CrossSection["design"]>,
@@ -281,6 +297,19 @@ function refreshLocally(input: CrossRefreshInput): number[] | null {
? section.curve_outer_side
: null,
curveWideningM: section.curve_widening_m ?? null,
// 사토장 — **저장분 폭을 그대로 잇는다**. 폭은 구간 용량에서 서버가 정한 값이라
// 브라우저가 다시 풀지 않는다(다시 풀면 그 측점만 폭이 달라져 작업장 모양이 깨진다).
// 이어 붙이지 않으면 계획선을 만질 때마다 사토장이 그림에서 사라진다.
spoilFill:
typeof design.spoil_fill_width_m === "number" &&
design.spoil_fill_width_m > 0 &&
(design.spoil_fill_side === "left" || design.spoil_fill_side === "right")
? {
side: design.spoil_fill_side,
widthM: design.spoil_fill_width_m,
slopeRatioN: null,
}
: null,
},
);
} catch {
@@ -0,0 +1,91 @@
/* =============================================================================
* B06_Section_UI_Cross_SpoilFill.ts
* 횡단도에 **사토장(유용토운반작업장) 성토선**을 얹는다.
*
* 왜 있나 — 임도기술교본 6장 3절이 **운반처리 위치를 평면도와 횡단도에 표시**하도록
* 요구한다(지식DB `01_임도/02_상세설계/유용토운반작업장.md` §2). 수량을 세기 전에
* 도면에 그것이 보여야 한다.
*
* ⚠ **터파기 파선과 선 종류를 가른다** — 터파기는 짧은 점선, 사토장은 긴 파선+점.
* 같은 파선으로 두면 도면에서 둘을 구별할 수 없다(2026-09-09 네 창 확정).
* ⚠ 값은 설계 결과(`spoil_fill_*`)에서 온다 — 여기서 **다시 세지 않는다**.
* ========================================================================== */
const SVG_NS = "http://www.w3.org/2000/svg";
/** 설계 결과에서 사토장 그리기에 쓰는 값만 추려 받는다. */
export interface SpoilFillDrawing {
spoil_fill_line?: Array<{ offset_m: number; elevation_m: number }> | null;
spoil_fill_area_m2?: number | null;
spoil_fill_width_m?: number | null;
spoil_fill_unclosed?: boolean | null;
spoil_fill_capacity_m3?: number | null;
spoil_fill_placed_m3?: number | null;
spoil_fill_unplaced_m3?: number | null;
}
/** 말풍선 문구 — 무엇이 얼마나 쌓였는지와 **근거**를 함께 적는다. */
export function spoilFillTooltip(design: SpoilFillDrawing): string {
const area = Number(design.spoil_fill_area_m2 ?? 0);
const width = Number(design.spoil_fill_width_m ?? 0);
const lines = [
`유용토운반작업장(구 사토장) · 단면 ${area.toFixed(2)}㎡ · 폭 ${width.toFixed(2)}m`,
];
const capacity = design.spoil_fill_capacity_m3;
if (typeof capacity === "number" && capacity > 0) {
const placed = Number(design.spoil_fill_placed_m3 ?? 0);
lines.push(`구간 용량 ${capacity.toFixed(1)}㎥ 중 ${placed.toFixed(1)}㎥ 담김`);
const unplaced = Number(design.spoil_fill_unplaced_m3 ?? 0);
if (unplaced > 0) {
lines.push(`${unplaced.toFixed(1)}㎥ 는 못 담음 — 지반 자료가 있는 데까지만 넓힘`);
}
}
if (design.spoil_fill_unclosed) {
lines.push("⚠ 비탈이 원지반을 못 만나 잘림 — 지반 자료 범위를 넘어감");
}
lines.push("폭은 노면 끝(노견이 시작하는 자리)에서 잼 — 그 구간 노견도 이 성토 안에 듦");
lines.push("교본 6장 3절이 평면도·횡단도 표시를 요구함");
return lines.join("\n");
}
/**
* 사토장 성토선을 그린다. 선이 없으면 아무것도 하지 않는다.
* 돌려주는 값은 그린 폴리라인(없으면 `null`) — 부르는 쪽이 강조에 쓸 수 있다.
*/
export function appendSpoilFillOverlay(
layer: SVGElement,
design: SpoilFillDrawing | null | undefined,
x: (offset: number) => number,
toDisplayY: (elevation: number) => number,
): SVGElement | null {
const line = design?.spoil_fill_line;
if (!design || !Array.isArray(line) || line.length < 2) return null;
const polyline = document.createElementNS(SVG_NS, "polyline");
polyline.setAttribute(
"points",
line.map((point) => `${x(point.offset_m)},${toDisplayY(point.elevation_m)}`).join(" "),
);
polyline.setAttribute(
"class",
design.spoil_fill_unclosed ? "b06-chart__spoil-fill is-unclosed" : "b06-chart__spoil-fill",
);
const title = document.createElementNS(SVG_NS, "title");
title.textContent = spoilFillTooltip(design);
polyline.append(title);
layer.append(polyline);
// 이름표 — 평상 한가운데 위에 얹는다. 선만 있으면 그것이 무엇인지 도면에서 모른다.
const first = line[0];
const last = line[line.length - 1];
const label = document.createElementNS(SVG_NS, "text");
label.setAttribute("x", String((x(first.offset_m) + x(last.offset_m)) / 2));
label.setAttribute("y", String(toDisplayY(Math.max(first.elevation_m, last.elevation_m)) - 4));
label.setAttribute("text-anchor", "middle");
label.setAttribute("class", "b06-chart__spoil-fill-label");
label.textContent = `유용토운반작업장 ${Number(design.spoil_fill_area_m2 ?? 0).toFixed(2)}`;
const labelTitle = document.createElementNS(SVG_NS, "title");
labelTitle.textContent = spoilFillTooltip(design);
label.append(labelTitle);
layer.append(label);
return polyline;
}
+4
View File
@@ -19,6 +19,7 @@ import {
appendFordSurfaceDropPlan,
} from "./B06_Section_UI_Cross_Ford_Pavement";
import { culvertWallsStandAt } from "./B06_Section_UI_Cross_Culvert_Wire";
import { appendSpoilFillOverlay } from "./B06_Section_UI_Cross_SpoilFill";
import { appendRevetmentOverlay, computeRevetmentLayout } from "./B06_Section_UI_Cross_Revetment";
import {
appendCrossDesignOverlay,
@@ -467,6 +468,9 @@ export function createCrossSectionCard(
// 같은 트림 값을 쓰므로 "그림은 이런데 수량은 저렇다"가 생기지 않는다.
applyStructureAreas(section, designTrim);
appendCrossDesignOverlay(plotLayer, section.design, x, toDisplayY, drawSamples, designTrim);
// 사토장(유용토운반작업장) — 교본 6장 3절이 횡단도 표시를 요구한다. 값은 설계 결과에서
// 그대로 오고 여기서 다시 세지 않는다. 선 종류는 터파기 파선과 갈라 둔다.
appendSpoilFillOverlay(plotLayer, section.design, x, toDisplayY);
// 암 경계선 = 지면선 복사 + 오프셋(계획선 기준 아님).
if (rockBoundary && section.design.geometry_preset === "rock") {
appendRockBoundaryOverlay(
@@ -388,3 +388,26 @@
stroke-dasharray: 4 3;
stroke-width: 1;
}
/* 사토장(유용토운반작업장) — **터파기 파선과 선 종류를 가른다**(2026-09-09).
터파기는 짧은 점선(4 3), 사토장은 **긴 파선 + 점**(9 3 2 3)이라 한눈에 갈린다.
색은 등록부 사토장 색(#8c9a2e)을 그대로 쓴다. */
.b06-chart__spoil-fill {
fill: none;
stroke: #8c9a2e;
stroke-dasharray: 9 3 2 3;
stroke-width: 1.4;
}
.b06-chart__spoil-fill-label {
fill: #6d7a20;
font-size: 10px;
paint-order: stroke;
stroke: rgba(255, 255, 255, 0.9);
stroke-width: 3;
}
/* 지반을 못 만나 잘린 사토장 — 사면 미폐합 경고와 같은 결로 붉게 알린다. */
.b06-chart__spoil-fill.is-unclosed {
stroke: #c0392b;
}