/* ============================================================================= * B06_Section_UI_Cross_Areas.ts * 횡단 절·성토 면적 영역 하이라이트 밴드(카드 좌상 면적표는 걷음 · 값은 상세 모달 표 · 379-2). * * 절토는 암반 경계선(지면선 평행 복사)을 기준으로 위=토사, 아래=암반으로 갈린다. 경계선 * 위치가 곧 유토곡선 EA/RR/BR 비율을 만들므로, 사용자가 경계선을 올리내리면 여기 표시되는 * 면적과 유토곡선이 함께 움직인다. 면적값 산출의 정본은 백엔드 * (`B06_Section_Engine_Design._split_cut_areas`)이고, 이 모듈은 **같은 규칙으로 * 영역만 다시 그린다**(수치를 여기서 다시 계산해 표시하지 않는다 — 이중 정의 금지). * * 하이라이트 규칙 (2026-08-02 사용자 지시): * - 선택된 횡단도에서만 동작한다. 다른 측점을 고르면 카드가 다시 그려지며 자동 해제된다. * - 값 칩 또는 면적 영역을 누르면 그 영역만 강조하고, 다시 누르면 해제한다. * * 700줄 제한 대응으로 `_UI_Cross_Design`(588줄)에서 면적 readout을 이 파일로 옮겨 왔다. * ========================================================================== */ import type { CrossDesign, SectionSample } from "./B06_Section_Api_Fetch"; import { svgElement } from "./B06_Section_UI_Section_Common"; /** * 강조 대상 = 면적표의 칸. `cut_total`은 토사·암반 두 밴드를 함께 켠다. * `cut_rr`/`cut_br`은 표에서는 늘 따로 서지만 그림에서는 암반 밴드 **하나**를 가리키므로, * 그 측점의 암종과 맞는 쪽을 눌렀을 때만 밴드가 켜진다(값이 0인 쪽은 눌려도 그림 변화 없음). */ export type CrossAreaKey = "cut_soil" | "cut_rr" | "cut_br" | "cut_total" | "fill"; /** 그림에 실제로 깔리는 밴드. 표의 칸(`CrossAreaKey`)보다 적다. */ type BandKey = "cut_soil" | "cut_rock" | "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; }; } /** 밴드 조각 하나. `from`/`to`는 그 조각이 덮는 편거리(offset) 범위다. */ interface BandPiece { points: string; from: number; to: number; } /** * 성토 밴드는 **중심선에서 바깥으로 나가다 지면과 처음 만나는 곳까지**만 유효하다. * 그 너머에서 지면이 다시 설계선 아래로 내려가 생기는 조각은 성토부가 아니므로 버린다 * (2026-08-02 사용자 지시). 좌·우 각각 중심선에 가장 가까운 조각만 남긴다. */ function keepInnermost(pieces: BandPiece[]): BandPiece[] { let left: BandPiece | null = null; let right: BandPiece | null = null; const kept: BandPiece[] = []; for (const piece of pieces) { if (piece.from <= 0 && piece.to >= 0) { // 중심선을 물고 있는 조각은 좌우 어느 쪽으로도 첫 조각이라 그대로 둔다. kept.push(piece); continue; } if (piece.to < 0) { if (!left || piece.to > left.to) left = piece; } else if (!right || piece.from < right.from) right = piece; } if (left) kept.push(left); if (right) kept.push(right); return kept; } /** * `top > bottom`인 구간만 잘라 폴리곤 점 문자열을 만든다. * 두께가 0이 되는 지점을 끼워 넣어 밴드 끝이 지면선·설계선 교점에서 정확히 닫히게 한다. */ function bandPolygons( xs: number[], top: number[], bottom: number[], px: (offset: number) => number, py: (elevation: number) => number, ): BandPiece[] { const polygons: BandPiece[] = []; let upper: string[] = []; let lower: string[] = []; let from = 0; let to = 0; const flush = (): void => { if (upper.length >= 2) polygons.push({ points: [...upper, ...lower.reverse()].join(" "), from, to }); upper = []; lower = []; }; const add = (offset: number, high: number, low: number): void => { if (!upper.length) from = offset; to = offset; 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; } /** * 그 측점 절토의 암종. 분리 필드가 없는 **구 데이터**는 지반유형을 그대로 쓴다 — * 면적표(상세 모달)는 그 규칙으로 RR/BR에 값을 넣는데 여기서만 null로 보면 * 암반 밴드가 아예 안 만들어져 **표에는 값이 있는데 그림이 안 켜진다**(2026-08-02 사용자 지적). */ function rockKindOf(design: CrossDesign): CrossDesign["cut_rock_kind"] { if (design.cut_rock_kind) return design.cut_rock_kind; return design.ground_type === "soil" ? null : design.ground_type; } /** 토사층 두께(m). 토사 측점은 암반 경계선이 없어 null, 분리 근거가 없는 암 측점은 0(전량 암). */ function soilDepth(design: CrossDesign): number | null { if (!rockKindOf(design)) return null; // 구 데이터(분리 면적 없음)는 경계선을 신뢰할 수 없으므로 전량 암으로 본다 — 표와 같은 규칙. if (design.cut_soil_area_m2 === undefined || design.cut_rock_area_m2 === undefined) return 0; return Math.abs(design.rock_boundary_offset_m ?? 0); } /** * 절토(토사)·절토(암반)·성토 영역을 SVG에 깔고, 강조 토글 함수를 돌려준다. * 밴드는 평소 투명에 가깝게 두되 클릭 대상은 되도록 남겨 "면적을 눌러도 강조"가 성립한다. */ export function appendCrossAreaBands( svg: SVGElement, 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: BandKey; 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 rockCell: CrossAreaKey = rockKindOf(design) === "blasting_rock" ? "cut_br" : "cut_rr"; const groups = new Map(); for (const band of bands) { let pieces = bandPolygons(xs, band.top, band.bottom, x, toDisplayY); if (band.key === "fill") pieces = keepInnermost(pieces); if (!pieces.length) continue; // 암반 밴드는 암종까지 클래스에 실어 색을 RR/BR로 갈라 준다 — 표 글자색과 짝이 맞아야 // 어느 숫자를 켰는지 색으로 바로 읽힌다. const variant = band.key === "cut_rock" ? ` b06-chart__area--${rockCell}` : ""; const group = svgElement("g", { class: `b06-chart__area b06-chart__area--${band.key}${variant}`, }); for (const piece of pieces) group.append(svgElement("polygon", { points: piece.points })); group.addEventListener("click", (event) => { // 카드까지 올라가면 측점 선택이 다시 걸려 카드가 통째로 다시 그려진다 — 방금 켠 강조가 사라진다. event.stopPropagation(); onSelect(band.key === "cut_rock" ? rockCell : band.key); }); groups.set(band.key, group); svg.append(group); } return (key) => { for (const [bandKey, group] of groups) { const active = key === "cut_total" ? bandKey === "cut_soil" || bandKey === "cut_rock" : bandKey === "cut_rock" ? key === rockCell : key === bandKey; group.classList.toggle("is-active", active); } }; }