feat(b06): 사면길이 3m 초과 소단 검토 정보 한 줄 — 성토사면 경고 밑 흐린 색 · 절·성토 둘 다 소단 사이 가장 긴 도막 · 벽 선 쪽 뺌 · 경고 아님(별표2 차.(5) 「붕괴 우려 지역」은 우리가 판정 못 함 · 브레인 ③)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
This commit is contained in:
@@ -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<CrossSection>,
|
||||
lengthsOf: (section: CrossSection) => Record<FillSlopeSideName, FillSlopeSideLength | null>,
|
||||
): 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;
|
||||
}
|
||||
@@ -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<CrossSection>, 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,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
*
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) · 붕괴 우려 지역 조건)"
|
||||
Reference in New Issue
Block a user