diff --git a/B06_Section/B06_Section_Api_Types.ts b/B06_Section/B06_Section_Api_Types.ts index 7fbca2a6..05323078 100644 --- a/B06_Section/B06_Section_Api_Types.ts +++ b/B06_Section/B06_Section_Api_Types.ts @@ -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; /** diff --git a/B06_Section/B06_Section_Cross_Refresh.ts b/B06_Section/B06_Section_Cross_Refresh.ts index 09951ca5..875525b3 100644 --- a/B06_Section/B06_Section_Cross_Refresh.ts +++ b/B06_Section/B06_Section_Cross_Refresh.ts @@ -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, @@ -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 { diff --git a/B06_Section/B06_Section_UI_Cross_SpoilFill.ts b/B06_Section/B06_Section_UI_Cross_SpoilFill.ts new file mode 100644 index 00000000..ae88da48 --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_SpoilFill.ts @@ -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; +} diff --git a/B06_Section/B06_Section_UI_Cross_View.ts b/B06_Section/B06_Section_UI_Cross_View.ts index 061fd070..734a9037 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -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( diff --git a/B06_Section/B06_Section_UI_Style_Cross_Areas.css b/B06_Section/B06_Section_UI_Style_Cross_Areas.css index 535a53c9..36151f4f 100644 --- a/B06_Section/B06_Section_UI_Style_Cross_Areas.css +++ b/B06_Section/B06_Section_UI_Style_Cross_Areas.css @@ -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; +}