diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts index fd2f8cae..e3ce91d8 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts @@ -237,6 +237,15 @@ export interface CrossDesign { }; design_elevation_m: number; cut_area_m2: number; + /** + * 절토 내역(합 = `cut_area_m2`). 지표면~암반 경계선이 토사, 그 아래가 암이다. + * 경계선을 올리내리면 두 값이 함께 바뀌고 유토곡선도 따라 움직인다. + * 구 데이터에는 없으므로 optional — 없으면 `cut_area_m2` 전량을 지반유형으로 본다. + */ + cut_soil_area_m2?: number; + cut_rock_area_m2?: number; + /** 암반부에 적용할 지반유형(`ripping_rock`/`blasting_rock`). 토사 측점은 null. */ + cut_rock_kind?: GroundType | null; fill_area_m2: number; ditch_area_m2: number; design_line: Array<{ offset_m: number; elevation_m: number }>; diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Engine_Design.py b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Engine_Design.py index 4feab76a..bcb6937c 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Engine_Design.py +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Engine_Design.py @@ -136,6 +136,45 @@ def _trapezoid_areas(offsets: list[float], diffs: list[float]) -> tuple[float, f return cut_area, fill_area +def _split_cut_areas( + offsets: list[float], diffs: list[float], soil_depth_m: float +) -> tuple[float, float]: + """절토 면적을 암반 경계선 기준으로 (토사, 암반)으로 나눈다. + + 암반 경계선은 지반선 평행 복사(`지반고 + rock_boundary_offset_m`)이므로 토사층 두께 + `t0`가 절토 구간 전체에서 균일하다. 따라서 오프셋별 절토 종거 `d = 지반고 - 설계고`에 + 대해 토사분은 `min(max(d, 0), t0)`, 암반분은 `max(d - t0, 0)`이며 두 값의 합은 항상 + `max(d, 0)`이라 `_trapezoid_areas`의 절토 면적과 정확히 일치한다. + + 두 함수 모두 `d = 0`과 `d = t0`에서 꺾이므로 그 교차점을 구간 분할점으로 넣어야 + 사다리꼴 적분이 근사가 아닌 정확값이 된다. + """ + t0 = max(float(soil_depth_m), 0.0) + soil_area = 0.0 + rock_area = 0.0 + for index in range(1, len(offsets)): + x0, x1 = offsets[index - 1], offsets[index] + d0, d1 = diffs[index - 1], diffs[index] + width = x1 - x0 + if width <= 0: + continue + ratios = [0.0, 1.0] + for level in (0.0, t0): + if (d0 - level) * (d1 - level) < 0: + ratios.append((level - d0) / (d1 - d0)) + ratios.sort() + for step in range(1, len(ratios)): + ratio_a, ratio_b = ratios[step - 1], ratios[step] + span = width * (ratio_b - ratio_a) + if span <= 0: + continue + d_a = d0 + (d1 - d0) * ratio_a + d_b = d0 + (d1 - d0) * ratio_b + soil_area += (min(max(d_a, 0.0), t0) + min(max(d_b, 0.0), t0)) / 2.0 * span + rock_area += (max(d_a - t0, 0.0) + max(d_b - t0, 0.0)) / 2.0 * span + return soil_area, rock_area + + def _ground_interpolator(valid: list[tuple[float, float]]): """정렬된 (offset, 지반고) 샘플의 선형 보간 함수를 만든다(범위 밖 끝값 클램프).""" @@ -501,6 +540,22 @@ def compute_cross_design( # 측구 굴착은 설계선에 포함돼 절토 면적에 자연 반영된다(별도 가산 없음 — 이중계상 방지). cut_area, fill_area = _trapezoid_areas(offsets, diffs) + # 절토면적 토사/암반 분리 — 지표면~암반 경계선이 토사, 그 아래가 암이다. 경계선 위치가 + # 곧 유토곡선 EA/RR/BR 비율을 만들므로, 사용자가 경계선을 올리내리면 이 값이 함께 바뀐다. + # 토사 지반은 암반 경계선 자체가 없어 전량 토사, 암 지반인데 경계선 값이 없으면(구 데이터) + # 분리 근거가 없으므로 전량 암으로 둔다(기존 단일 지반유형 환산과 같은 결과). + if preset_key != "rock": + cut_soil_area, cut_rock_area = cut_area, 0.0 + cut_rock_kind: str | None = None + elif rock_boundary_offset_m is None: + cut_soil_area, cut_rock_area = 0.0, cut_area + cut_rock_kind = ground_type + else: + cut_soil_area, cut_rock_area = _split_cut_areas( + offsets, diffs, abs(float(rock_boundary_offset_m)) + ) + cut_rock_kind = ground_type + # 측구 공칭 단면적(수량 산출 참고용): 일반=사다리꼴, L형=직각삼각형 근사. if not geometry.has_ditch: ditch_area = 0.0 @@ -575,6 +630,10 @@ def compute_cross_design( }, "design_elevation_m": round(float(design_elevation_m), 4), "cut_area_m2": round(cut_area, 4), + # 절토 내역(합=cut_area_m2). 유토곡선이 지반유형별 환산계수를 물리는 단위다. + "cut_soil_area_m2": round(cut_soil_area, 4), + "cut_rock_area_m2": round(cut_rock_area, 4), + "cut_rock_kind": cut_rock_kind, "fill_area_m2": round(fill_area, 4), "ditch_area_m2": round(ditch_area, 4), "design_line": design_line, diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_Areas.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_Areas.ts new file mode 100644 index 00000000..f2fb5d92 --- /dev/null +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_Areas.ts @@ -0,0 +1,250 @@ +/* ============================================================================= + * B06_wf3_ProfileCross_UI_Cross_Areas.ts + * 횡단 절·성토 면적 readout과 면적 영역 하이라이트 밴드. + * + * 절토는 암반 경계선(지면선 평행 복사)을 기준으로 위=토사, 아래=암반으로 갈린다. 경계선 + * 위치가 곧 유토곡선 EA/RR/BR 비율을 만들므로, 사용자가 경계선을 올리내리면 여기 표시되는 + * 면적과 유토곡선이 함께 움직인다. 면적값 산출의 정본은 백엔드 + * (`B06_wf3_ProfileCross_Engine_Design._split_cut_areas`)이고, 이 모듈은 **같은 규칙으로 + * 영역만 다시 그린다**(수치를 여기서 다시 계산해 표시하지 않는다 — 이중 정의 금지). + * + * 하이라이트 규칙 (2026-08-02 사용자 지시): + * - 선택된 횡단도에서만 동작한다. 다른 측점을 고르면 카드가 다시 그려지며 자동 해제된다. + * - 값 칩 또는 면적 영역을 누르면 그 영역만 강조하고, 다시 누르면 해제한다. + * + * 700줄 제한 대응으로 `_UI_Cross_Design`(588줄)에서 면적 readout을 이 파일로 옮겨 왔다. + * ========================================================================== */ + +import type { CrossDesign, SectionSample } from "./B06_wf3_ProfileCross_Api_Fetch"; +import { L, svgElement } from "./B06_wf3_ProfileCross_UI_Section_Common"; + +/** 강조 대상. `cut_total`은 토사·암반 두 밴드를 함께 켠다. */ +export type CrossAreaKey = "cut_soil" | "cut_rock" | "cut_total" | "fill"; + +export type AreaHighlightSetter = (key: CrossAreaKey | null) => void; + +/** 두께가 0 이하인 구간을 잘라내는 판정 여유(㎡ 아닌 m 단위 종거). */ +const EPSILON = 1e-9; + +function interpolator(points: Array<{ offset: number; value: number }>) { + return (offset: number): number => { + if (offset <= points[0].offset) return points[0].value; + const last = points[points.length - 1]; + if (offset >= last.offset) return last.value; + for (let index = 1; index < points.length; index += 1) { + if (offset > points[index].offset) continue; + const a = points[index - 1]; + const b = points[index]; + const span = b.offset - a.offset; + if (span <= 0) return b.value; + return a.value + (b.value - a.value) * ((offset - a.offset) / span); + } + return last.value; + }; +} + +/** + * `top > bottom`인 구간만 잘라 폴리곤 점 문자열을 만든다. + * 두께가 0이 되는 지점을 끼워 넣어 밴드 끝이 지면선·설계선 교점에서 정확히 닫히게 한다. + */ +function bandPolygons( + xs: number[], + top: number[], + bottom: number[], + px: (offset: number) => number, + py: (elevation: number) => number, +): string[] { + const polygons: string[] = []; + let upper: string[] = []; + let lower: string[] = []; + const flush = (): void => { + if (upper.length >= 2) polygons.push([...upper, ...lower.reverse()].join(" ")); + upper = []; + lower = []; + }; + const add = (offset: number, high: number, low: number): void => { + upper.push(`${px(offset)},${py(high)}`); + lower.push(`${px(offset)},${py(low)}`); + }; + const crossing = (index: number): void => { + const gapA = top[index - 1] - bottom[index - 1]; + const gapB = top[index] - bottom[index]; + if (gapA === gapB) return; + const ratio = gapA / (gapA - gapB); + const offset = xs[index - 1] + (xs[index] - xs[index - 1]) * ratio; + const level = top[index - 1] + (top[index] - top[index - 1]) * ratio; + add(offset, level, level); + }; + for (let index = 0; index < xs.length; index += 1) { + const gap = top[index] - bottom[index]; + const previousGap = index > 0 ? top[index - 1] - bottom[index - 1] : 0; + if (gap > EPSILON) { + if (index > 0 && previousGap <= EPSILON) crossing(index); + add(xs[index], top[index], bottom[index]); + } else if (index > 0 && previousGap > EPSILON) { + crossing(index); + flush(); + } + } + flush(); + return polygons; +} + +/** 토사층 두께(m). 토사 측점은 암반 경계선이 없어 null, 경계선 값이 없는 암 측점은 0(전량 암). */ +function soilDepth(design: CrossDesign): number | null { + if (!design.cut_rock_kind) return null; + return Math.abs(design.rock_boundary_offset_m ?? 0); +} + +/** + * 절토(토사)·절토(암반)·성토 영역을 SVG에 깔고, 강조 토글 함수를 돌려준다. + * 밴드는 평소 투명에 가깝게 두되 클릭 대상은 되도록 남겨 "면적을 눌러도 강조"가 성립한다. + */ +export function appendCrossAreaBands( + svg: SVGSVGElement, + design: CrossDesign, + groundSamples: SectionSample[], + x: (offset: number) => number, + toDisplayY: (elevation: number) => number, + onSelect: (key: CrossAreaKey) => void, +): AreaHighlightSetter { + const ground = groundSamples + .filter((sample) => sample.valid !== false && Number.isFinite(sample.elevation_m ?? NaN)) + .map((sample) => ({ offset: sample.offset_m ?? 0, value: sample.elevation_m as number })) + .sort((a, b) => a.offset - b.offset); + const line = (design.design_line ?? []) + .filter((point) => Number.isFinite(point.offset_m) && Number.isFinite(point.elevation_m)) + .map((point) => ({ offset: point.offset_m, value: point.elevation_m })) + .sort((a, b) => a.offset - b.offset); + if (ground.length < 2 || line.length < 2) return () => undefined; + + const groundAt = interpolator(ground); + const designAt = interpolator(line); + // 설계선이 덮는 범위 밖에는 절·성토가 정의되지 않는다 — 두 범위의 교집합만 그린다. + const minOffset = Math.max(ground[0].offset, line[0].offset); + const maxOffset = Math.min(ground[ground.length - 1].offset, line[line.length - 1].offset); + if (!(maxOffset > minOffset)) return () => undefined; + const xs = [ + ...new Set( + [ + ...ground.map((point) => point.offset), + ...line.map((point) => point.offset), + minOffset, + maxOffset, + ] + .filter((offset) => offset >= minOffset && offset <= maxOffset) + .map((offset) => Math.round(offset * 1e6) / 1e6), + ), + ].sort((a, b) => a - b); + + const groundLevels = xs.map(groundAt); + const designLevels = xs.map(designAt); + const depth = soilDepth(design); + const rockLevels = depth === null ? null : groundLevels.map((level) => level - depth); + + const bands: Array<{ key: CrossAreaKey; top: number[]; bottom: number[] }> = [ + { key: "fill", top: designLevels, bottom: groundLevels }, + ]; + if (rockLevels) { + bands.push({ + key: "cut_soil", + top: groundLevels, + bottom: designLevels.map((level, index) => Math.max(level, rockLevels[index])), + }); + bands.push({ key: "cut_rock", top: rockLevels, bottom: designLevels }); + } else { + bands.push({ key: "cut_soil", top: groundLevels, bottom: designLevels }); + } + + const groups = new Map(); + for (const band of bands) { + const polygons = bandPolygons(xs, band.top, band.bottom, x, toDisplayY); + if (!polygons.length) continue; + const group = svgElement("g", { class: `b06-chart__area b06-chart__area--${band.key}` }); + for (const points of polygons) group.append(svgElement("polygon", { points })); + group.addEventListener("click", () => onSelect(band.key)); + groups.set(band.key, group); + svg.append(group); + } + + return (key) => { + for (const [bandKey, group] of groups) { + const active = + key === bandKey || + (key === "cut_total" && (bandKey === "cut_soil" || bandKey === "cut_rock")); + group.classList.toggle("is-active", active); + } + }; +} + +/** + * 절·성토 면적값 오버레이(E-4) — 그래프 중상단에 표시한다. + * `onSelect`가 오면 칩이 강조 토글 버튼이 된다(선택된 카드에서만 넘어온다). + */ +export function buildAreaReadout( + design: CrossDesign | undefined, + onSelect?: (key: CrossAreaKey) => void, +): { root: HTMLElement; setActive: AreaHighlightSetter } { + const readout = document.createElement("div"); + readout.className = "b06-cross-card__areas"; + if (!design) { + const unset = document.createElement("span"); + unset.className = "b06-design__area b06-design__area--unset"; + unset.textContent = L("B06_Design_Unset"); + readout.append(unset); + return { root: readout, setActive: () => undefined }; + } + + // 토사/암반 칩은 암 측점에서만 의미가 있다(토사 측점은 절토계와 같은 값이라 중복 표기). + const entries: Array<{ key: CrossAreaKey; label: string; value: number }> = []; + if (design.cut_rock_kind) { + entries.push( + { + key: "cut_soil", + label: L("B06_Design_Cut_Soil_Area"), + value: design.cut_soil_area_m2 ?? 0, + }, + { + key: "cut_rock", + label: L("B06_Design_Cut_Rock_Area"), + value: design.cut_rock_area_m2 ?? 0, + }, + ); + } + entries.push( + { key: "cut_total", label: L("B06_Design_Cut_Area"), value: design.cut_area_m2 }, + { key: "fill", label: L("B06_Design_Fill_Area"), value: design.fill_area_m2 }, + ); + + const chips = new Map(); + for (const entry of entries) { + const text = `${entry.label} ${entry.value.toFixed(2)}㎡`; + if (!onSelect) { + const chip = document.createElement("span"); + chip.className = `b06-design__area b06-design__area--${entry.key}`; + chip.textContent = text; + readout.append(chip); + continue; + } + const chip = document.createElement("button"); + chip.type = "button"; + chip.className = `b06-design__area b06-design__area--${entry.key}`; + chip.textContent = text; + chip.title = L("B06_Design_Area_Highlight"); + chip.setAttribute("aria-pressed", "false"); + chip.addEventListener("click", () => onSelect(entry.key)); + chips.set(entry.key, chip); + readout.append(chip); + } + + return { + root: readout, + setActive: (key) => { + for (const [chipKey, chip] of chips) { + const active = chipKey === key; + chip.classList.toggle("is-active", active); + chip.setAttribute("aria-pressed", String(active)); + } + }, + }; +} diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_Design.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_Design.ts index aea39003..4b57411f 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_Design.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_Design.ts @@ -432,27 +432,6 @@ export function buildDesignControls( return { bar, groundSegment }; } -/** 절·성토 면적값 오버레이(E-4) — 그래프 중상단에 배경색과 함께 표시한다. */ -export function buildAreaReadout(design: CrossDesign | undefined): HTMLElement { - const readout = document.createElement("div"); - readout.className = "b06-cross-card__areas"; - if (design) { - const cut = document.createElement("span"); - cut.className = "b06-design__area b06-design__area--cut"; - cut.textContent = `${L("B06_Design_Cut_Area")} ${design.cut_area_m2.toFixed(2)}㎡`; - const fill = document.createElement("span"); - fill.className = "b06-design__area b06-design__area--fill"; - fill.textContent = `${L("B06_Design_Fill_Area")} ${design.fill_area_m2.toFixed(2)}㎡`; - readout.append(cut, fill); - } else { - const unset = document.createElement("span"); - unset.className = "b06-design__area b06-design__area--unset"; - unset.textContent = L("B06_Design_Unset"); - readout.append(unset); - } - return readout; -} - /** * 횡단 SVG에 표준단면 설계선을 겹쳐 그린다. 지면선과 겹치는 구간(사면이 지반을 추종하는 * 부분)만 점선으로 그려 뒤에 깔린 지표선이 비쳐 보이게 하고, 나머지는 실선으로 둔다. diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_View.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_View.ts index badc0a0c..51319352 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_View.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_View.ts @@ -8,11 +8,16 @@ * ========================================================================== */ import type { CrossSection, SectionSample } from "./B06_wf3_ProfileCross_Api_Fetch"; +import { + appendCrossAreaBands, + type AreaHighlightSetter, + buildAreaReadout, + type CrossAreaKey, +} from "./B06_wf3_ProfileCross_UI_Cross_Areas"; import { appendCrossDesignOverlay, appendPavementOverlay, appendRockBoundaryOverlay, - buildAreaReadout, buildDesignControls, buildRockBoundaryControl, sectionModeLabel, @@ -369,9 +374,30 @@ export function createCrossSectionCard( svg.append(svgElement("polyline", { points, class: "b06-chart__cross-profile" })), ); + // 면적 강조는 선택된 카드에서만 붙인다 — 다른 측점을 고르면 카드가 다시 그려지며 해제된다. + let activeArea: CrossAreaKey | null = null; + let setBandActive: AreaHighlightSetter = () => undefined; + let setChipActive: AreaHighlightSetter = () => undefined; + const toggleArea = (key: CrossAreaKey): void => { + activeArea = activeArea === key ? null : key; + setBandActive(activeArea); + setChipActive(activeArea); + }; + if (section.design) { const toDisplayY = (elevation: number): number => y(elevationMid + (elevation - elevationMid) * exaggeration); + // 면적 밴드는 지면선 위·설계선 아래에 깔아 선이 밴드에 가리지 않게 한다. + if (selected) { + setBandActive = appendCrossAreaBands( + svg, + section.design, + sourceSamples, + x, + toDisplayY, + toggleArea, + ); + } // 포장층 → 설계선 → 암 경계선 순으로 겹쳐, 설계선이 포장 박스 위에 오게 한다. appendPavementOverlay(svg, section.design, x, toDisplayY); appendCrossDesignOverlay(svg, section.design, x, toDisplayY, sourceSamples); @@ -449,8 +475,13 @@ export function createCrossSectionCard( const chartWrap = document.createElement("div"); chartWrap.className = "b06-cross-card__chart-wrap"; attachZoomPan(svg, widthPx, heightPx); - // 절·성토 면적값은 그래프 중상단 오버레이로 표시(E-4). - chartWrap.append(svg, buildAreaReadout(section.design)); + // 절·성토 면적값은 그래프 중상단 오버레이로 표시(E-4). 선택된 카드의 칩은 강조 토글이 된다. + const readout = buildAreaReadout( + section.design, + selected && section.design ? toggleArea : undefined, + ); + setChipActive = readout.setActive; + chartWrap.append(svg, readout.root); // 암 경계선 제어는 그래프 X축 제목 행 우측에 배치(E-7). 암 지반에서만. if (rockBoundary && section.design?.geometry_preset === "rock") { const rockControl = buildRockBoundaryControl(section, rockBoundary); diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul.ts index 68cbf8db..614c4c06 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul.ts @@ -19,17 +19,21 @@ * 편절·편성이 지배적이므로 두 기준이 크게 어긋날 수 있다. 어느 쪽을 정식 기준으로 삼을지 * 사용자가 그래프를 보고 판단할 수 있게, 두 기준을 같은 축에 겹쳐 그린다. * - * ── 지반유형 처리를 3가지로 내는 이유 ──────────────────────────────── + * ── 절토를 토사/암반으로 나눠 환산하는 이유 ────────────────────────── * 같은 기준 2.다.(4)(다)는 "절토부분은 토사·암반으로 구분하되, 암반부분은 추정선으로 - * 기입한다"고 정한다. 그러나 현재 `compute_cross_design()`은 측점당 절토 단면적을 - * **하나만** 내므로 암반 경계선 위아래를 나누지 못하고, 측점 전체가 단일 지반유형으로 - * 환산된다(미결사항 1번). 분리 로직이 서기 전까지 환산 방식이 결과를 얼마나 흔드는지 - * 눈으로 확인할 수 있도록 세 가지를 모두 계산해 둔다. + * 기입한다"고 정한다. 절토면적은 계수로 나누는 것이 아니라 **횡단도 기하로 갈린다** — + * 지표면~암반 경계선이 토사, 그 아래가 암이다. 엔진(`compute_cross_design()`)이 그 경계로 + * 나눈 `cut_soil_area_m2` / `cut_rock_area_m2`를 각각 자기 환산계수로 곱해 합산한다. + * 참고 도면(3공구)의 작업별 EA:RR 비율이 34:66~71:29로 흩어지는 것이 이 구조의 근거다. + * + * 지반유형 처리 3종(`ground_type`/`soil_only`/`natural`)은 환산 방식이 결과를 얼마나 + * 흔드는지 눈으로 대조하기 위한 비교군으로 남겨 둔다. * * 분석 근거: docs/raw/2026-08-02_유토곡선_3공구_분석.md * ========================================================================== */ import type { + CrossDesign, CrossSection, EarthworkConversion, GroundType, @@ -43,7 +47,7 @@ export interface GroundVolumes { blasting_rock: number; } -/** 측점 하나의 유토곡선 좌표. */ +/** 측점 하나의 유토곡선 좌표. 구간 물량은 **직전 측점부터 이 측점까지**의 몫이다(첫 측점은 0). */ export interface MassHaulPoint { station_id: string; chainage_m: number; @@ -51,6 +55,13 @@ export interface MassHaulPoint { net_volume_m3: number; /** 시점부터의 누가토량(㎥). */ cumulative_volume_m3: number; + /** 구간 절토량(자연상태) — 토사분/암반분. 측점 선택 시 물량 표기에 그대로 쓴다. */ + cut_soil_m3: number; + cut_rock_m3: number; + /** 구간 절토량(환산 후) — 곡선에 실제로 들어간 값. */ + cut_compacted_m3: number; + /** 구간 성토량(다짐상태 = 설계 물량). */ + fill_m3: number; } export interface MassHaulResult { @@ -138,13 +149,41 @@ function normalizeGround(value: GroundType | undefined): GroundType { return value && GROUND_TYPES.includes(value) ? value : "soil"; } -/** 적분 입력 한 점. 기준이 달라도 여기까지 오면 같은 코드를 탄다. */ +/** + * 적분 입력 한 점. 기준이 달라도 여기까지 오면 같은 코드를 탄다. + * 절토는 암반 경계선으로 이미 갈린 상태로 들어온다(토사분 / 암반분). + */ interface AreaSample { station_id: string; chainage_m: number; - cut_area_m2: number; + cut_soil_area_m2: number; + cut_rock_area_m2: number; + /** 암반분에 물릴 지반유형. 토사 측점은 null(암반분 0). */ + rock_kind: GroundType | null; fill_area_m2: number; - ground: GroundType; +} + +/** + * 엔진이 낸 절토 분리값을 읽는다. 구 데이터(분리 필드 없음)는 절토 전량을 측점 + * 지반유형으로 돌려 **기존 단일 지반유형 환산과 같은 결과**를 유지한다. + */ +function splitCut(design: CrossDesign | undefined): { + soil: number; + rock: number; + rock_kind: GroundType | null; +} { + const ground = normalizeGround(design?.ground_type); + const total = finiteArea(design?.cut_area_m2); + if (design?.cut_soil_area_m2 === undefined || design?.cut_rock_area_m2 === undefined) { + return ground === "soil" + ? { soil: total, rock: 0, rock_kind: null } + : { soil: 0, rock: total, rock_kind: ground }; + } + return { + soil: finiteArea(design.cut_soil_area_m2), + rock: finiteArea(design.cut_rock_area_m2), + rock_kind: design.cut_rock_kind ?? (ground === "soil" ? null : ground), + }; } /** @@ -167,6 +206,10 @@ function integrate( chainage_m: samples[0].chainage_m, net_volume_m3: 0, cumulative_volume_m3: 0, + cut_soil_m3: 0, + cut_rock_m3: 0, + cut_compacted_m3: 0, + fill_m3: 0, }, ]; const cutNatural = emptyVolumes(); @@ -184,13 +227,23 @@ function integrate( if (!(spanM > 0)) continue; const halfSpan = spanM / 2; - // 절토: 양 끝 점이 각자 자기 지반유형으로 절반씩 가져간다. + // 절토: 양 끝 점이 각자 절반씩 가져가고, 각 점 안에서 토사분·암반분이 따로 환산된다. let segmentCut = 0; + let segmentCutSoil = 0; + let segmentCutRock = 0; for (const end of [previous, current]) { - const naturalM3 = end.cut_area_m2 * halfSpan; - if (naturalM3 <= 0) continue; - cutNatural[end.ground] += naturalM3; - segmentCut += naturalM3 * factorFor(conversion, end.ground, groundMode); + const soilM3 = end.cut_soil_area_m2 * halfSpan; + const rockM3 = end.cut_rock_area_m2 * halfSpan; + if (soilM3 > 0) { + cutNatural.soil += soilM3; + segmentCutSoil += soilM3; + segmentCut += soilM3 * factorFor(conversion, "soil", groundMode); + } + if (rockM3 > 0 && end.rock_kind) { + cutNatural[end.rock_kind] += rockM3; + segmentCutRock += rockM3; + segmentCut += rockM3 * factorFor(conversion, end.rock_kind, groundMode); + } } // 성토: 설계 성토량이 곧 다짐상태 물량이라 환산하지 않는다. @@ -207,6 +260,10 @@ function integrate( chainage_m: current.chainage_m, net_volume_m3: net, cumulative_volume_m3: cumulative, + cut_soil_m3: segmentCutSoil, + cut_rock_m3: segmentCutRock, + cut_compacted_m3: segmentCut, + fill_m3: segmentFill, }); } @@ -235,13 +292,17 @@ function designedSections(crossSections: CrossSection[]): CrossSection[] { /** 횡단 기준 — 엔진이 낸 실측 단면적을 그대로 쓴다. */ function crossAreaSamples(sections: CrossSection[]): AreaSample[] { - return sections.map((section) => ({ - station_id: section.station_id, - chainage_m: section.chainage_m, - cut_area_m2: finiteArea(section.design?.cut_area_m2), - fill_area_m2: finiteArea(section.design?.fill_area_m2), - ground: normalizeGround(section.design?.ground_type), - })); + return sections.map((section) => { + const cut = splitCut(section.design); + return { + station_id: section.station_id, + chainage_m: section.chainage_m, + cut_soil_area_m2: cut.soil, + cut_rock_area_m2: cut.rock, + rock_kind: cut.rock_kind, + fill_area_m2: finiteArea(section.design?.fill_area_m2), + }; + }); } /** @@ -251,6 +312,9 @@ function crossAreaSamples(sections: CrossSection[]): AreaSample[] { * (`B05_wf2_Route_UI_Profile_Alignment.ts`가 이미 쓰는 부호 규약과 같다). * 노반폭과 지반유형은 종단 샘플에 없으므로 **가장 가까운 설계 측점**에서 가져온다. * 설계된 측점이 하나도 없으면 노반폭을 알 수 없어 곡선을 만들지 않는다. + * + * 개략 단면적에는 암반 경계선이 없으므로, **가장 가까운 측점의 토사:암반 면적비**를 그대로 + * 물려 두 기준이 같은 환산을 타게 한다(비율을 못 구하면 전량 토사로 둔다). */ function longitudinalAreaSamples( longitudinal: LongitudinalSection, @@ -276,12 +340,17 @@ function longitudinalAreaSamples( const nearest = sections[cursor]; const width = finiteArea(nearest.design?.roadbed_width_m); const height = sample.difference_m; + const cutArea = height < 0 ? -height * width : 0; + const cut = splitCut(nearest.design); + const total = cut.soil + cut.rock; + const rockRatio = total > 0 ? cut.rock / total : 0; return { station_id: `${nearest.station_id}@${index}`, chainage_m: sample.chainage_m, - cut_area_m2: height < 0 ? -height * width : 0, + cut_soil_area_m2: cutArea * (1 - rockRatio), + cut_rock_area_m2: cutArea * rockRatio, + rock_kind: cut.rock_kind, fill_area_m2: height > 0 ? height * width : 0, - ground: normalizeGround(nearest.design?.ground_type), }; }); } @@ -341,6 +410,10 @@ export function massHaulPayload(result: MassHaulResult): Record chainage_m: round(point.chainage_m), net_volume_m3: round(point.net_volume_m3), cumulative_volume_m3: round(point.cumulative_volume_m3), + // 구간 절토 내역(자연상태) — B08 내역서가 EA/RR/BR로 되받는 단위. + cut_soil_m3: round(point.cut_soil_m3), + cut_rock_m3: round(point.cut_rock_m3), + fill_m3: round(point.fill_m3), })), }; } diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_View.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_View.ts index ce43dbb6..0c987517 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_View.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_View.ts @@ -19,6 +19,7 @@ import type { LongitudinalSection } from "./B06_wf3_ProfileCross_Api_Fetch"; import type { MassHaulBasis, MassHaulGroundMode, + MassHaulPoint, MassHaulSeries, } from "./B06_wf3_ProfileCross_UI_MassHaul"; import type { LocaleKey } from "@ui/ui_template_locale"; @@ -81,6 +82,32 @@ function volumeRange(series: MassHaulSeries[]): { min: number; max: number } { return { min: rawMin - padding, max: rawMax + padding }; } +/** + * 선택된 측점의 곡선 점을 찾는다. + * + * 측점 id로 직접 맞추지 않고 **누가거리로 맞춘다** — 종단 기준 곡선의 점 id는 종단 샘플 + * 인덱스가 붙은 파생값(`${station_id}@${index}`)이라 측점 id와 형태가 다르기 때문이다. + */ +export function massHaulPointAt( + series: MassHaulSeries, + longitudinal: LongitudinalSection, + stationId: string | null, +): MassHaulPoint | null { + if (!stationId) return null; + const station = longitudinal.stations.find((entry) => entry.station_id === stationId); + if (!station) return null; + let best: MassHaulPoint | null = null; + let bestGap = Number.POSITIVE_INFINITY; + for (const point of series.result.points) { + const gap = Math.abs(point.chainage_m - station.chainage_m); + if (gap < bestGap) { + bestGap = gap; + best = point; + } + } + return bestGap <= 0.5 ? best : null; +} + export function createMassHaulChart( series: MassHaulSeries[], visibleKeys: ReadonlySet, @@ -213,6 +240,19 @@ export function createMassHaulChart( ); } + // 선택 측점 강조 — 첫 표시 곡선 위에 점을 찍어 아래 물량 표기 줄과 시선을 잇는다. + const focus = banded ? massHaulPointAt(banded, longitudinal, selectedStationId) : null; + if (focus) { + svg.append( + svgElement("circle", { + cx: x(focus.chainage_m), + cy: y(focus.cumulative_volume_m3), + r: 4, + class: "b06-masshaul__focus", + }), + ); + } + svg.append( svgElement("line", { x1: LONG_PAD.left, @@ -274,6 +314,43 @@ export function createMassHaulLegend( return legend; } +/** + * 선택 측점의 **구간 물량** 표기 줄 (2026-08-02 사용자 지시). + * 횡단도를 고르든 종단도 측점을 고르든 같은 선택 경로를 타므로 여기 한 곳만 그리면 된다. + * 물량은 "직전 측점 → 이 측점" 구간 몫이며, 횡단도 카드는 면적(㎡)만 표시한다(역할 분리). + */ +export function createMassHaulStationInfo( + series: MassHaulSeries, + longitudinal: LongitudinalSection, + selectedStationId: string | null, + stationInterval: number, +): HTMLElement | null { + const point = massHaulPointAt(series, longitudinal, selectedStationId); + if (!point) return null; + const row = document.createElement("div"); + row.className = "b06-masshaul__station"; + const chips: Array<[string, string]> = [ + [L("B06_MassHaul_Station_Segment"), stationLabel(point.chainage_m, stationInterval)], + [L("B06_Design_Cut_Soil_Area"), `${formatVolume(point.cut_soil_m3)}㎥`], + [L("B06_Design_Cut_Rock_Area"), `${formatVolume(point.cut_rock_m3)}㎥`], + [L("B06_MassHaul_CutCompacted"), `${formatVolume(point.cut_compacted_m3)}㎥`], + [L("B06_MassHaul_Fill"), `${formatVolume(point.fill_m3)}㎥`], + [L("B06_MassHaul_Station_Net"), `${formatVolume(point.net_volume_m3)}㎥`], + [L("B06_MassHaul_Station_Cumulative"), `${formatVolume(point.cumulative_volume_m3)}㎥`], + ]; + for (const [label, value] of chips) { + const chip = document.createElement("span"); + chip.className = "b06-masshaul__chip"; + const name = document.createElement("em"); + name.textContent = label; + const amount = document.createElement("strong"); + amount.textContent = value; + chip.append(name, amount); + row.append(chip); + } + return row; +} + /** * 곡선 아래 총괄 요약 줄. 절토는 자연상태(내역 기준)와 환산 후(곡선 기준)를 함께 적는다. * 곡선이 여러 개라 수치가 어느 곡선의 것인지 끝에 반드시 밝힌다. diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts index ca309b5b..d1a86c2a 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts @@ -37,6 +37,7 @@ import { import { createMassHaulChart, createMassHaulLegend, + createMassHaulStationInfo, createMassHaulSummary, MASS_HAUL_HEIGHT, MASS_HAUL_MIN_HEIGHT, @@ -58,8 +59,11 @@ import { export type { CrossDesignChange, DesignChangeHandler, RockBoundaryControl }; -/** 헤더·요약줄·테두리가 먹는 세로 공간. 패널 높이에서 이만큼 빼야 그래프 몫이 된다. */ -const PANEL_CHROME_PX = 96; +/** + * 헤더·요약줄·선택측점 물량줄·테두리가 먹는 세로 공간. 패널 높이에서 이만큼 빼야 그래프 몫이 된다. + * (`.b06-section__panel-body`가 `overflow: hidden`이라 이 값이 모자라면 아래 줄이 잘린다.) + */ +const PANEL_CHROME_PX = 128; /** 손대지 않았을 때의 패널 높이 — 이 값이 축소 비례의 기준(H₀)이 된다. */ const BASE_PANEL_HEIGHT = LONG_HEIGHT + MASS_HAUL_HEIGHT + PANEL_CHROME_PX; const MIN_LONG_HEIGHT = 110; @@ -309,12 +313,20 @@ export function createSectionView( chartWrap.replaceChildren(...nodes); panelTitle.textContent = `${L("B06_Profile_View_Longitudinal")} · ${L("B06_MassHaul_Title")}`; - const summary = panel.querySelector(".b06-masshaul__summary"); - summary?.remove(); + panel.querySelector(".b06-masshaul__summary")?.remove(); + panel.querySelector(".b06-masshaul__station")?.remove(); // 요약 수치는 켜 둔 곡선 중 첫 번째 것 — 곡선이 여러 개라 어느 것인지 요약 끝에 밝힌다. const summarySeries = series.find((entry) => visibleSeries.has(entry.key)); if (summarySeries) { panelCount.textContent = ""; + // 선택 측점의 구간 물량은 요약 위에 붙인다(횡단도는 면적만, 물량은 여기서 본다). + const stationInfo = createMassHaulStationInfo( + summarySeries, + detail.longitudinal, + selectedStationId, + cachedStationInterval, + ); + if (stationInfo) panelBody.append(stationInfo); panelBody.append(createMassHaulSummary(summarySeries)); } else { panelCount.textContent = L(series.length ? "B06_MassHaul_AllHidden" : "B06_MassHaul_Empty"); diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_Cross.css b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_Cross.css index d0755c10..9e0e943d 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_Cross.css +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_Cross.css @@ -515,10 +515,20 @@ pointer-events: none; } -.b06-design__area--cut { +.b06-design__area--cut, +.b06-design__area--cut_total { color: rgb(220 38 38); } +/* 절토 내역 — 토사(지표면~암반 경계선)와 암반(경계선 아래)을 색으로 갈라 둔다. */ +.b06-design__area--cut_soil { + color: rgb(217 119 6); +} + +.b06-design__area--cut_rock { + color: rgb(71 85 105); +} + .b06-design__area--fill { color: rgb(37 99 235); } @@ -527,6 +537,50 @@ color: var(--color-text-muted); } +/* 선택된 카드에서만 칩이 버튼이 된다 — 부모가 pointer-events: none이라 여기서 되살린다. */ +button.b06-design__area { + padding: 0 2px; + border: none; + border-radius: var(--radius-inputs); + background: none; + font-family: inherit; + font-size: inherit; + cursor: pointer; + pointer-events: auto; +} + +/* 켠 칩은 글자색을 유지한 채 같은 색으로 옅게 칠하고 테두리를 둘러 대비를 살린다. */ +button.b06-design__area.is-active { + background: color-mix(in srgb, currentcolor 18%, transparent); + box-shadow: inset 0 0 0 1px currentcolor; + font-weight: var(--font-weight-medium); +} + +/* 면적 강조 밴드 — 평소엔 클릭 대상으로만 남고, 켜질 때만 채워진다. */ +.b06-chart__area > polygon { + fill: transparent; + stroke: none; + cursor: pointer; +} + +.b06-chart__area--cut_soil.is-active > polygon { + fill: color-mix(in srgb, rgb(217 119 6) 28%, transparent); + stroke: rgb(217 119 6); + stroke-width: 1.2; +} + +.b06-chart__area--cut_rock.is-active > polygon { + fill: color-mix(in srgb, rgb(71 85 105) 28%, transparent); + stroke: rgb(71 85 105); + stroke-width: 1.2; +} + +.b06-chart__area--fill.is-active > polygon { + fill: color-mix(in srgb, rgb(37 99 235) 28%, transparent); + stroke: rgb(37 99 235); + stroke-width: 1.2; +} + /* 횡단 표준단면 설계선 오버레이. 기본은 실선. */ .b06-chart__design-cross { fill: none; diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_MassHaul.css b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_MassHaul.css index e3634cff..d244fb2a 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_MassHaul.css +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_MassHaul.css @@ -140,3 +140,23 @@ margin-inline-start: auto; color: var(--color-text-muted); } + +/* 선택 측점의 구간 물량 줄 — 총괄 요약과 구분되게 배경을 옅게 깐다. */ +.b06-masshaul__station { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-8) var(--spacing-16); + padding: var(--spacing-8) var(--spacing-16); + border-top: 1px solid var(--color-border); + background: color-mix(in srgb, var(--color-royal-amethyst) 7%, transparent); + color: var(--color-text-secondary); + font-size: var(--text-caption); +} + +/* 선택 측점이 곡선의 어디인지 찍는 점 — 물량 줄과 시선을 잇는다. */ +.b06-masshaul__focus { + fill: var(--color-royal-amethyst); + stroke: var(--color-surface); + stroke-width: 1.5; +} diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index 5f85f2c7..c19b7c90 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -240,6 +240,10 @@ export const ui_locales_b2 = { B06_MassHaul_Legend_Title: ["표시할 유토곡선", "Visible mass haul curves"], B06_MassHaul_Legend_Show: ["표시", "Show"], B06_MassHaul_Legend_Hide: ["숨김", "Hide"], + /* 선택 측점의 구간 물량 — 직전 측점부터 이 측점까지의 몫이다. */ + B06_MassHaul_Station_Segment: ["구간 물량", "Segment volume"], + B06_MassHaul_Station_Net: ["순토량", "Net volume"], + B06_MassHaul_Station_Cumulative: ["누가토량", "Cumulative"], /* --- B06 측점 표준횡단 설계 지정 --- */ B06_Design_Ground_Legend: ["지반유형", "Ground type"], @@ -255,6 +259,10 @@ export const ui_locales_b2 = { B06_Design_Ditch_Left: ["좌", "Left"], B06_Design_Ditch_Right: ["우", "Right"], B06_Design_Cut_Area: ["절토", "Cut"], + /* 절토 내역 — 지표면~암반 경계선이 토사, 그 아래가 암반(합=절토). */ + B06_Design_Cut_Soil_Area: ["절토(토사)", "Cut (soil)"], + B06_Design_Cut_Rock_Area: ["절토(암반)", "Cut (rock)"], + B06_Design_Area_Highlight: ["누르면 해당 면적을 강조합니다", "Click to highlight this area"], B06_Design_Fill_Area: ["성토", "Fill"], B06_Design_Unset: ["미지정", "Not set"], B06_Design_DitchType_Legend: ["측구형식", "Ditch type"],