From b1bdcb643d782ca197b46c004c453ee12e0200e2 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 15 Sep 2026 06:27:28 +0900 Subject: [PATCH] =?UTF-8?q?feat(b06):=20=EC=82=AC=EB=A9=B4=EA=B8=B8?= =?UTF-8?q?=EC=9D=B4=203m=20=EC=B4=88=EA=B3=BC=20=EC=86=8C=EB=8B=A8=20?= =?UTF-8?q?=EA=B2=80=ED=86=A0=20=EC=A0=95=EB=B3=B4=20=ED=95=9C=20=EC=A4=84?= =?UTF-8?q?=20=E2=80=94=20=EC=84=B1=ED=86=A0=EC=82=AC=EB=A9=B4=20=EA=B2=BD?= =?UTF-8?q?=EA=B3=A0=20=EB=B0=91=20=ED=9D=90=EB=A6=B0=20=EC=83=89=20=C2=B7?= =?UTF-8?q?=20=EC=A0=88=C2=B7=EC=84=B1=ED=86=A0=20=EB=91=98=20=EB=8B=A4=20?= =?UTF-8?q?=EC=86=8C=EB=8B=A8=20=EC=82=AC=EC=9D=B4=20=EA=B0=80=EC=9E=A5=20?= =?UTF-8?q?=EA=B8=B4=20=EB=8F=84=EB=A7=89=20=C2=B7=20=EB=B2=BD=20=EC=84=A0?= =?UTF-8?q?=20=EC=AA=BD=20=EB=BA=8C=20=C2=B7=20=EA=B2=BD=EA=B3=A0=20?= =?UTF-8?q?=EC=95=84=EB=8B=98(=EB=B3=84=ED=91=9C2=20=EC=B0=A8.(5)=20?= =?UTF-8?q?=E3=80=8C=EB=B6=95=EA=B4=B4=20=EC=9A=B0=EB=A0=A4=20=EC=A7=80?= =?UTF-8?q?=EC=97=AD=E3=80=8D=EC=9D=80=20=EC=9A=B0=EB=A6=AC=EA=B0=80=20?= =?UTF-8?q?=ED=8C=90=EC=A0=95=20=EB=AA=BB=20=ED=95=A8=20=C2=B7=20=EB=B8=8C?= =?UTF-8?q?=EB=A0=88=EC=9D=B8=20=E2=91=A2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq --- B06_Section/B06_Section_UI_Cross_Berm_Info.ts | 45 ++++++++ .../B06_Section_UI_Cross_FillSlope_Notice.ts | 58 ++++++++-- B06_Section/B06_Section_UI_Cross_Fit.ts | 63 ++++++++++ B06_Section/B06_Section_UI_Style_Cross.css | 5 + resources/tester/test_b06_berm_review_info.py | 108 ++++++++++++++++++ 5 files changed, 267 insertions(+), 12 deletions(-) create mode 100644 B06_Section/B06_Section_UI_Cross_Berm_Info.ts create mode 100644 resources/tester/test_b06_berm_review_info.py diff --git a/B06_Section/B06_Section_UI_Cross_Berm_Info.ts b/B06_Section/B06_Section_UI_Cross_Berm_Info.ts new file mode 100644 index 00000000..b7fad0ed --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_Berm_Info.ts @@ -0,0 +1,45 @@ +/* ============================================================================= + * B06_Section_UI_Cross_Berm_Info.ts + * 사면길이 3m 초과 — 소단 **검토 대상 정보** 판정(2026-09-15 브레인 ③) — 값만 가리고 그리지 않는다. + * + * 근거 — 산림자원법 시행규칙 별표2 Ⅰ.2.차.(5) 「절토·성토한 경사면이 붕괴 또는 밀려 내려갈 우려가 + * 있는 지역에는 사면길이 2~3미터마다 폭 50~100센티미터로 … 소단을 설치한다」. + * ⚠ **경고가 아님** — 「우려가 있는 지역」을 우리가 판정 못 함(성토사면 5m 는 「초과하는 경우」라 또렷해 + * 경고). 소단이 선 쪽은 길이가 소단 사이 가장 긴 도막으로 재져 저절로 빠진다. + * 벽이 선 쪽은 뺀다 — 성토사면 경고와 같은 규칙(`wallSides`). + * ========================================================================== */ + +import type { CrossSection } from "./B06_Section_Api_Fetch"; +import { + wallSides, + type FillSlopeSideLength, + type FillSlopeSideName, + type FillSlopeWarning, +} from "./B06_Section_UI_Cross_FillSlope_Warn"; + +/** 브레인 문구 그대로(2026-09-15) — 고치면 다시 받을 것. */ +export const BERM_INFO_TEXT = + "사면길이 3m 초과 — 소단 검토 대상(별표2 차.(5) · 붕괴 우려 지역 조건)"; + +/** 별표2 「사면길이 2~3미터마다」의 위 끝. */ +export const BERM_REVIEW_LENGTH_M = 3; + +/** 3m 를 **넘는** 사면(절·성토 · 벽 선 쪽 뺌)이 있는 측점만 — 측점 순서 그대로. */ +export function bermReviewStations( + sections: ReadonlyArray, + lengthsOf: (section: CrossSection) => Record, +): FillSlopeWarning[] { + const rows: FillSlopeWarning[] = []; + for (const section of sections) { + const lengths = lengthsOf(section); + const walls = wallSides(section); + const sides = (["left", "right"] as const) + .filter((side) => !walls.has(side)) + .flatMap((side) => { + const length = lengths[side]; + return length && length.lengthM > BERM_REVIEW_LENGTH_M + 1e-6 ? [{ side, ...length }] : []; + }); + if (sides.length) rows.push({ section, sides }); + } + return rows; +} diff --git a/B06_Section/B06_Section_UI_Cross_FillSlope_Notice.ts b/B06_Section/B06_Section_UI_Cross_FillSlope_Notice.ts index ff872893..6db3cdb6 100644 --- a/B06_Section/B06_Section_UI_Cross_FillSlope_Notice.ts +++ b/B06_Section/B06_Section_UI_Cross_FillSlope_Notice.ts @@ -3,36 +3,43 @@ * 성토사면 5m 초과 **경고 줄** — 횡단 카드 목록 위 요약 한 줄 + 펼치면 측점 목록 * (2026-09-14 브레인 승인 (나)). 판정은 `_Cross_FillSlope_Warn`, 여기는 그리기만. * 측점을 누르면 그 카드로 간다. 경고만 — 구조물을 세우거나 값을 바꾸지 않는다. + * ⭐ 2026-09-15 — 그 밑에 사면길이 3m 초과 **소단 검토 정보 한 줄**(경고 모양 아님 · 브레인 ③). * ========================================================================== */ import type { CrossSection } from "./B06_Section_Api_Fetch"; -import { fillSlopeLengths } from "./B06_Section_UI_Cross_Fit"; -import { FILL_SLOPE_WARN_TEXT, fillSlopeWarnings } from "./B06_Section_UI_Cross_FillSlope_Warn"; +import { BERM_INFO_TEXT, bermReviewStations } from "./B06_Section_UI_Cross_Berm_Info"; +import { fillSlopeLengths, slopeRunLengths } from "./B06_Section_UI_Cross_Fit"; +import { + FILL_SLOPE_WARN_TEXT, + fillSlopeWarnings, + type FillSlopeWarning, +} from "./B06_Section_UI_Cross_FillSlope_Warn"; import { L, stationLabel } from "./B06_Section_UI_Section_Common"; export interface FillSlopeNotice { - root: HTMLDetailsElement; + root: HTMLElement; /** 측점 설계가 바뀔 때마다 부른다 — 펼침 상태는 그대로 둔다. */ update: (sections: ReadonlyArray, stationInterval: number) => void; } -export function createFillSlopeNotice(onPick: (stationId: string) => void): FillSlopeNotice { +function noticeBlock(className: string): { + root: HTMLDetailsElement; + fill: (head: string, rows: FillSlopeWarning[], stationInterval: number) => void; +} { const root = document.createElement("details"); - root.className = "b06-section__notice"; + root.className = className; root.hidden = true; const summary = document.createElement("summary"); const list = document.createElement("div"); list.className = "b06-section__notice-list"; root.append(summary, list); - return { root, - update(sections, stationInterval) { - const warnings = fillSlopeWarnings(sections, fillSlopeLengths); - root.hidden = !warnings.length; - summary.textContent = `⚠ ${FILL_SLOPE_WARN_TEXT} · ${warnings.length}측점`; + fill(head, rows, stationInterval) { + root.hidden = !rows.length; + summary.textContent = `${head} · ${rows.length}측점`; list.replaceChildren( - ...warnings.map(({ section, sides }) => { + ...rows.map(({ section, sides }) => { const button = document.createElement("button"); button.type = "button"; const parts = sides.map(({ side, lengthM, open }) => { @@ -40,10 +47,37 @@ export function createFillSlopeNotice(onPick: (stationId: string) => void): Fill return `${label} ${open ? "≥" : ""}${lengthM.toFixed(2)}m`; }); button.textContent = `${stationLabel(section.chainage_m, stationInterval)} ${parts.join(" · ")}`; - button.addEventListener("click", () => onPick(section.station_id)); + button.dataset.stationId = section.station_id; return button; }), ); }, }; } + +export function createFillSlopeNotice(onPick: (stationId: string) => void): FillSlopeNotice { + const root = document.createElement("div"); + const warn = noticeBlock("b06-section__notice"); + const info = noticeBlock("b06-section__notice b06-section__notice--info"); + root.append(warn.root, info.root); + root.addEventListener("click", (event) => { + const id = (event.target as HTMLElement).closest("button")?.dataset.stationId; + if (id) onPick(id); + }); + + return { + root, + update(sections, stationInterval) { + warn.fill( + `⚠ ${FILL_SLOPE_WARN_TEXT}`, + fillSlopeWarnings(sections, fillSlopeLengths), + stationInterval, + ); + info.fill( + `ⓘ ${BERM_INFO_TEXT}`, + bermReviewStations(sections, slopeRunLengths), + stationInterval, + ); + }, + }; +} diff --git a/B06_Section/B06_Section_UI_Cross_Fit.ts b/B06_Section/B06_Section_UI_Cross_Fit.ts index c05d8f59..0a0f6779 100644 --- a/B06_Section/B06_Section_UI_Cross_Fit.ts +++ b/B06_Section/B06_Section_UI_Cross_Fit.ts @@ -146,6 +146,69 @@ export function fillSlopeLengths(section: CrossSection): { return lengths; } +/** + * 좌·우 사면(**절토·성토 둘 다**)에서 소단 사이 **가장 긴 도막**의 경사길이(m) — 소단 검토 정보용 + * (2026-09-15 브레인 ③). 구간·교차점은 `fillSlopeLengths` 와 같고, 절토는 2단 경사라 설계선 + * 도막마다 경사길이를 더한다. 평탄한 도막(소단)에서 끊는다. 사면이 없는 측은 null. + */ +export function slopeRunLengths(section: CrossSection): { + left: FillSlopeLength | null; + right: FillSlopeLength | null; +} { + const lengths: { left: FillSlopeLength | null; right: FillSlopeLength | null } = { + left: null, + right: null, + }; + const design = section.design; + if (!design) return lengths; + const groundAt = groundInterpolator(section.samples); + const designAt = designInterpolator(design.design_line); + const edges = design.road_edges; + if (!groundAt || !designAt || !edges) return lengths; + const lineOffsets = design.design_line.map((point) => point.offset_m); + if (lineOffsets.length < 2) return lengths; + const { protectMax, protectMin } = protectedSpan(design, edges); + // 소단(2°≈0.035)과 사면(1:0.3~2.0)을 가르는 기울기 — 가장 누운 사면 기울기의 절반. + const gradients = [design.fill_slope_ratio, design.cut_slope_ratio, design.soil_cut_slope_ratio] + .filter((ratio): ratio is number => typeof ratio === "number" && ratio > 0) + .map((ratio) => 1 / ratio); + const flatBelow = (gradients.length ? Math.min(...gradients) : 0.5) * 0.5; + for (const side of ["left", "right"] as const) { + const outward = side === "left" ? 1 : -1; + const start = side === "left" ? protectMax : protectMin; + const limit = side === "left" ? Math.max(...lineOffsets) : Math.min(...lineOffsets); + const meet = meetOffset(designAt, groundAt, start, limit, outward); + const end = Math.abs(meet) > Math.abs(limit) ? limit : meet; + const low = Math.min(start, end); + const high = Math.max(start, end); + let longest = 0; + let current = 0; + for (let index = 1; index < design.design_line.length; index += 1) { + const a = design.design_line[index - 1]; + const b = design.design_line[index]; + const run = Math.abs(b.offset_m - a.offset_m); + const from = Math.max(Math.min(a.offset_m, b.offset_m), low); + const to = Math.min(Math.max(a.offset_m, b.offset_m), high); + if (run <= 1e-9 || to - from <= 1e-9) continue; + const rise = Math.abs(b.elevation_m - a.elevation_m); + if (rise / run < flatBelow) { + longest = Math.max(longest, current); + current = 0; + continue; + } + current += ((to - from) / run) * Math.hypot(run, rise); + } + const lengthM = Math.max(longest, current); + if (lengthM > 0) { + lengths[side] = { + lengthM, + open: Math.abs(designAt(end) - groundAt(end)) > MEET_TOLERANCE_M, + }; + } + } + return lengths; +} + /** * 소단으로 끊긴 성토사면에서 **가장 긴 한 구간**의 경사길이(m). * diff --git a/B06_Section/B06_Section_UI_Style_Cross.css b/B06_Section/B06_Section_UI_Style_Cross.css index 90240bd6..e3b7602a 100644 --- a/B06_Section/B06_Section_UI_Style_Cross.css +++ b/B06_Section/B06_Section_UI_Style_Cross.css @@ -202,6 +202,11 @@ font-size: var(--text-caption); } +/* 소단 검토 — 정보 한 줄(경고 색 아님 · 2026-09-15 브레인 ③). */ +.b06-section__notice--info { + color: var(--color-text-secondary); +} + .b06-section__notice > summary { cursor: pointer; } diff --git a/resources/tester/test_b06_berm_review_info.py b/resources/tester/test_b06_berm_review_info.py new file mode 100644 index 00000000..e6f5c3d5 --- /dev/null +++ b/resources/tester/test_b06_berm_review_info.py @@ -0,0 +1,108 @@ +"""사면길이 3m 초과 — 소단 **검토 대상 정보 한 줄**(경고 아님 · 2026-09-15 브레인 ③). + + ① 법령 별표2 Ⅰ.2.차.(5) 「붕괴 또는 밀려 내려갈 **우려가 있는 지역**에는 사면길이 2~3m 마다」 — + 「우려 있음」을 우리가 판정 못 함 ⇒ 경고가 아니라 정보 + ② 3m 를 **넘는** 쪽만(3.00 은 아님) · 절토·성토 둘 다 · 원지반을 못 만난 하한값(≥)도 넘으면 들어감 + ③ 벽이 선 쪽은 뺌(성토사면 5m 경고와 같은 규칙 · `wallSides`) + ④ 문구는 브레인 문구 그대로 +TS 를 실제로 돌린다(파이썬 짝이 없는 화면 판정 — `test_b06_fill_slope_warn` 과 같은 방식). +""" + +from __future__ import annotations + +import json +import re +import subprocess +from pathlib import Path + +import pytest + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +TSC = PROJECT_ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" +FOLDER = PROJECT_ROOT / "B06_Section" +SOURCES = [ + FOLDER / "B06_Section_UI_Cross_Berm_Info.ts", + FOLDER / "B06_Section_UI_Cross_FillSlope_Warn.ts", + FOLDER / "B06_Section_UI_Cross_Culvert_Const.ts", +] + +_RUNNER = """ +import { readFileSync, writeFileSync } from "node:fs"; +import { BERM_INFO_TEXT, bermReviewStations } from "./B06_Section_UI_Cross_Berm_Info.js"; + +const [inputPath, outputPath] = process.argv.slice(2); +const cases = JSON.parse(readFileSync(inputPath, "utf8")); +const rows = bermReviewStations(cases.map((c) => c.section), (section) => + cases.find((c) => c.section.station_id === section.station_id).lengths, +); +writeFileSync(outputPath, JSON.stringify({ + text: BERM_INFO_TEXT, + rows: rows.map((w) => [w.section.station_id, w.sides.map((s) => s.side)]), +})); +""" + +LONG = {"lengthM": 4.0, "open": False} + +CASES = [ + {"section": {"station_id": "both"}, "lengths": {"left": LONG, "right": LONG}}, + { + "section": {"station_id": "edge"}, + "lengths": { + "left": {"lengthM": 3.0, "open": False}, + "right": {"lengthM": 2.5, "open": True}, + }, + }, + { + "section": {"station_id": "open_long"}, + "lengths": {"left": None, "right": {"lengthM": 3.4, "open": True}}, + }, + {"section": {"station_id": "ford", "ford": {}}, "lengths": {"left": LONG, "right": LONG}}, +] + + +def _run(tmp_path: Path) -> dict: + out = tmp_path / "js" + subprocess.run( # noqa: S603 — 고정 실행 파일 + [ + "node", + str(TSC), + *map(str, SOURCES), + "--outDir", + str(out), + "--module", + "esnext", + "--target", + "es2022", + "--moduleResolution", + "bundler", + "--ignoreConfig", + "--noCheck", + "--noResolve", + ], + cwd=str(PROJECT_ROOT), + check=True, + capture_output=True, + ) + for emitted in out.glob("*.js"): + text = emitted.read_text(encoding="utf-8") + emitted.write_text( + re.sub(r'(from "\./[^"]+?)(")', lambda m: m.group(1) + ".js" + m.group(2), text), + encoding="utf-8", + ) + (out / "runner.mjs").write_text(_RUNNER, encoding="utf-8") + payload, result = tmp_path / "input.json", tmp_path / "output.json" + payload.write_text(json.dumps(CASES, ensure_ascii=False), encoding="utf-8") + subprocess.run( # noqa: S603 + ["node", str(out / "runner.mjs"), str(payload), str(result)], + cwd=str(PROJECT_ROOT), + check=True, + capture_output=True, + ) + return json.loads(result.read_text(encoding="utf-8")) + + +@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치") +def test_3m_넘는_사면만_벽_선_쪽은_뺀다(tmp_path: Path) -> None: + got = _run(tmp_path) + assert dict(got["rows"]) == {"both": ["left", "right"], "open_long": ["right"]} + assert got["text"] == "사면길이 3m 초과 — 소단 검토 대상(별표2 차.(5) · 붕괴 우려 지역 조건)"