diff --git a/B06_Section/B06_Section_UI_Cross_FillSlope_Notice.ts b/B06_Section/B06_Section_UI_Cross_FillSlope_Notice.ts new file mode 100644 index 00000000..ff872893 --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_FillSlope_Notice.ts @@ -0,0 +1,49 @@ +/* ============================================================================= + * B06_Section_UI_Cross_FillSlope_Notice.ts + * 성토사면 5m 초과 **경고 줄** — 횡단 카드 목록 위 요약 한 줄 + 펼치면 측점 목록 + * (2026-09-14 브레인 승인 (나)). 판정은 `_Cross_FillSlope_Warn`, 여기는 그리기만. + * 측점을 누르면 그 카드로 간다. 경고만 — 구조물을 세우거나 값을 바꾸지 않는다. + * ========================================================================== */ + +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 { L, stationLabel } from "./B06_Section_UI_Section_Common"; + +export interface FillSlopeNotice { + root: HTMLDetailsElement; + /** 측점 설계가 바뀔 때마다 부른다 — 펼침 상태는 그대로 둔다. */ + update: (sections: ReadonlyArray, stationInterval: number) => void; +} + +export function createFillSlopeNotice(onPick: (stationId: string) => void): FillSlopeNotice { + const root = document.createElement("details"); + root.className = "b06-section__notice"; + 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}측점`; + list.replaceChildren( + ...warnings.map(({ section, sides }) => { + const button = document.createElement("button"); + button.type = "button"; + const parts = sides.map(({ side, lengthM, open }) => { + const label = L(side === "left" ? "B06_Design_Ditch_Left" : "B06_Design_Ditch_Right"); + return `${label} ${open ? "≥" : ""}${lengthM.toFixed(2)}m`; + }); + button.textContent = `${stationLabel(section.chainage_m, stationInterval)} ${parts.join(" · ")}`; + button.addEventListener("click", () => onPick(section.station_id)); + return button; + }), + ); + }, + }; +} diff --git a/B06_Section/B06_Section_UI_Cross_FillSlope_Warn.ts b/B06_Section/B06_Section_UI_Cross_FillSlope_Warn.ts new file mode 100644 index 00000000..30ba1c77 --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_FillSlope_Warn.ts @@ -0,0 +1,88 @@ +/* ============================================================================= + * B06_Section_UI_Cross_FillSlope_Warn.ts + * 성토사면 길이 5m 초과 **경고** 판정(2026-09-14 브레인 승인 (나)) — 값만 가리고 그리지 않는다. + * + * 근거 — 산림자원법 시행규칙 별표2 Ⅰ.2.차.(3).(나) · 임도설치 규정 별표7 2.차.(3).(나) + * 「성토사면 길이 5m 초과 시 옹벽·석축」. 실무 표본(오솔길 W열 성토면 거리)에서도 흔해 + * (영월 63% · 봉화 49%) **경고까지만** — 구조물을 자동으로 세우지 않는다(설계자 판단). + * + * 벽이 선 쪽은 뺀다 — 기슭막이·옹벽이 사면을 끊은 쪽은 이미 조치된 자리다. **좌·우를 갈라** + * 한쪽에만 벽이 서면 반대쪽은 그대로 경고한다. + * 길이는 `fillSlopeLengths`(카드 머리 「성토사면」 칸과 같은 값)를 받아 쓴다 — 여기서 다시 재지 않는다. + * ========================================================================== */ + +import type { CrossSection } from "./B06_Section_Api_Fetch"; +import { FILL_SLOPE_MAX_LENGTH_M } from "./B06_Section_UI_Cross_Culvert_Const"; + +/** 브레인 승인 문구 그대로(2026-09-14) — 고치면 승인을 다시 받을 것. */ +export const FILL_SLOPE_WARN_TEXT = + "성토사면 길이 5m 초과 — 법령상 옹벽·석축 설치 대상 (산림자원법 시행규칙 별표2 Ⅰ.2.차.(3).(나) · 임도설치 규정 별표7 2.차.(3).(나)) ※ 실무 표본에서도 흔함(영월 63% · 봉화 49%) — 설치 여부는 설계자 판단"; + +export type FillSlopeSideName = "left" | "right"; + +export interface FillSlopeSideLength { + lengthM: number; + /** 원지반을 못 만나 거기까지만 잰 하한값. */ + open: boolean; +} + +export interface FillSlopeWarning { + section: CrossSection; + sides: Array<{ side: FillSlopeSideName } & FillSlopeSideLength>; +} + +/** 벽이 서서 성토사면을 끊는 쪽 — 배관 기슭막이 · 독립 기슭막이 · 세월교·BOX암거 측벽. */ +export function wallSides(section: CrossSection): Set { + const sides = new Set(); + if (section.ford || section.box) return new Set(["left", "right"]); + const culvert = section.culvert; + if (culvert?.hidden_pipe) { + // 독립 기슭막이 설치 측 — 좌 = +offset(`restrictToSide`) · 양쪽·미지정은 둘 다. + if (culvert.side !== "우") sides.add("left"); + if (culvert.side !== "좌") sides.add("right"); + } else if (culvert) { + // 유입 = 상단측(미상이면 좌) · 집수정은 벽이 아니다. + const inlet: FillSlopeSideName = (section.uphill_side ?? "left") === "left" ? "left" : "right"; + if (culvert.inlet.structure !== "집수정") sides.add(inlet); + if (culvert.outlet.structure !== "집수정") sides.add(inlet === "left" ? "right" : "left"); + } + const revetment = section.revetment; + if (revetment) { + // 설치 측이 비면 성토가 나는 쪽(`computeRevetmentLayout` 과 같은 규칙). + const mode = section.design?.section_mode; + const side = + revetment.side === "우" + ? "right" + : revetment.side === "좌" + ? "left" + : mode === "left_cut" + ? "right" + : mode === "right_cut" || mode === "both_fill" + ? "left" + : null; + if (side) sides.add(side); + } + return sides; +} + +/** 5m 를 **넘는** 성토사면(벽 선 쪽 뺌)이 있는 측점만 — 측점 순서 그대로. */ +export function fillSlopeWarnings( + sections: ReadonlyArray, + lengthsOf: (section: CrossSection) => Record, +): FillSlopeWarning[] { + const warnings: 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 > FILL_SLOPE_MAX_LENGTH_M + 1e-6 + ? [{ side, ...length }] + : []; + }); + if (sides.length) warnings.push({ section, sides }); + } + return warnings; +} diff --git a/B06_Section/B06_Section_UI_Section_View.ts b/B06_Section/B06_Section_UI_Section_View.ts index 3fbfdff2..37b2e7a0 100644 --- a/B06_Section/B06_Section_UI_Section_View.ts +++ b/B06_Section/B06_Section_UI_Section_View.ts @@ -20,7 +20,6 @@ import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; import type { CrossSection, EarthworkConversion, - HaulEquipmentLimit, SectionDetailResponse, } from "./B06_Section_Api_Fetch"; import type { RockBoundaryControl } from "./B06_Section_UI_Cross_Design"; @@ -81,53 +80,10 @@ import { inferStationInterval, L, } from "./B06_Section_UI_Section_Common"; +import type { SectionViewController } from "./B06_Section_UI_Section_View_Types"; +import { createFillSlopeNotice } from "./B06_Section_UI_Cross_FillSlope_Notice"; -export type { CrossDesignChange, DesignChangeHandler, RockBoundaryControl }; - -/** - * 상태 행·요약줄·하단 접기 손잡이·테두리가 먹는 세로 공간의 **어림값**. - * - * 평소에는 쓰지 않는다 — 그래프 몫은 `chartWrap`을 직접 재서 정한다(어림값이 실제보다 크면 - * 유토곡선 아래에 빈 공간이 남는다). 화면에 붙기 전이라 잴 수 없는 첫 렌더에서만 쓰는 - * 출발값이고, 최소 패널 높이 계산의 기준이기도 하다. - */ -export interface SectionViewController { - root: HTMLElement; - render: ( - detail: SectionDetailResponse, - verticalExaggeration: number, - crossHalfWidth?: number, - stationInterval?: number, - earthworkConversion?: EarthworkConversion, - haulEquipmentLimits?: HaulEquipmentLimit[], - /** balloon 위치 캐시를 가르는 키(프로젝트+경로). 노선이 다르면 위치가 섞이면 안 된다. */ - balloonScope?: string, - /** 자연방토 판정 경사(config). 못 받으면 자연방토 없음으로 본다. */ - naturalSpoilMinSlope?: number, - ) => void; - /** 측점 하나의 카드만 새로 만들어 교체한다 (전체 재렌더 없이 설계 변경 반영). */ - refreshCard: (chainageM: number) => void; - /** 여러 측점을 한꺼번에 교체한다 — 상단 패널은 **마지막에 한 번만** 다시 그린다. */ - refreshCards: (chainages: ReadonlyArray) => void; - /** 좌측 구조물 목록에서 고른 측점 카드를 선택하고 화면에 드러낸다 — 재클릭 토글 없음 - * (2026-08-29 B05/B06 일원화: 목록 클릭 → 해당 카드 스크롤·강조). */ - focusStation: (stationId: string) => void; - /** 카드(측점) 선택이 바뀔 때 알림 — 좌측 「구조물 배치」 폼이 그 측점 구조물을 - * 올린다(2026-08-29 일원화). null = 선택 해제. */ - setStationSelectListener: (listener: (stationId: string | null) => void) => void; - clear: () => void; - /** 종단 아래 구조물 알약 레인에 쓸 목록 — B05 와 같은 부품·같은 표기(2026-09-07 사용자 - * 지시 4 「구조물 표시 통일」). 좌측 「구조물 배치」가 목록을 받을 때마다 넘겨준다. */ - setStructureMarks: ( - structures: ReadonlyArray, - types: ReadonlyArray, - ) => void; - /** 종단 그래프 우클릭으로 구조물을 넣고 빼는 길(2026-09-12 B05·B06 일원화). */ - setStructureEdit: (edit: SectionStructureEdit | null) => void; - /** 계획선 편집 ▲/▼ — 그릴 때마다 불러 선형·편집 함수를 받는다(null = 버튼 없음). */ - setGradeEdit: (provider: (() => LongitudinalPanelInput["grade"]) | null) => void; - dispose: () => void; -} +export type { CrossDesignChange, DesignChangeHandler, RockBoundaryControl, SectionViewController }; export function createSectionView( onDesignChange?: DesignChangeHandler, @@ -236,6 +192,10 @@ export function createSectionView( }, }); panel.append(panelResizer.root); + // 성토사면 5m 초과 경고 줄(2026-09-14 브레인 (나)) — 패널과 카드 사이 · 측점 누르면 그 카드로. + const fillSlopeNotice = createFillSlopeNotice((id) => + selectedStationId === id ? revealCard(id, "smooth") : selectStation(id, true), + ); /** * 패널 **어디에서든** 굴린 휠은 페이지가 아니라 그래프를 좌우로 민다(2026-08-02 사용자 지시). @@ -489,6 +449,7 @@ export function createSectionView( // 보던 자리가 맨 앞으로 튀면 못 쓴다. 위치를 잡아 뒀다 되돌린다. const keepScrollLeft = chartWrap.scrollLeft; const detail = currentDetail; + fillSlopeNotice.update(detail.cross_sections, cachedStationInterval); // 카드 갱신도 여기를 거침 // 그래프 몫은 **추정하지 않고 잰다**. `chartWrap`은 `flex: 1 / min-height: 0`이라 높이가 // 내용이 아니라 패널에서 정해지므로, 재서 쓰면 되먹임 없이 한 번에 수렴한다. // 화면에 붙기 전(detached)에는 잴 수 없으니 그때만 `PANEL_CHROME_PX` 추정치로 시작한다. @@ -599,7 +560,7 @@ export function createSectionView( } // panel은 이미 root의 자식이라 replaceChildren이 떼었다 붙이면서 스크롤을 잃는다. const keepScrollLeft = chartWrap.scrollLeft; - root.replaceChildren(panel, grid); + root.replaceChildren(panel, fillSlopeNotice.root, grid); chartWrap.scrollLeft = keepScrollLeft; }; diff --git a/B06_Section/B06_Section_UI_Section_View_Types.ts b/B06_Section/B06_Section_UI_Section_View_Types.ts new file mode 100644 index 00000000..cc2c1c77 --- /dev/null +++ b/B06_Section/B06_Section_UI_Section_View_Types.ts @@ -0,0 +1,59 @@ +/* ============================================================================= + * B06_Section_UI_Section_View_Types.ts + * `createSectionView` 가 돌려주는 조종기 모양 — 700줄 제한으로 뷰 본체에서 떼어 냄(2026-09-14). + * 부르는 쪽은 종전대로 `_UI_Section_View` 에서 가져간다(거기서 다시 내보냄). + * ========================================================================== */ + +import type { + EarthworkConversion, + HaulEquipmentLimit, + SectionDetailResponse, +} from "./B06_Section_Api_Fetch"; +import type { SectionStructureEdit } from "./B06_Section_UI_Section_View_Menu"; +import type { LongitudinalPanelInput } from "./B06_Section_UI_Section_View_Draw"; +import type { StructureInstance, StructureType } from "../B05_Profile/B05_Profile_Api_Structures"; + +/** + * 상태 행·요약줄·하단 접기 손잡이·테두리가 먹는 세로 공간의 **어림값**. + * + * 평소에는 쓰지 않는다 — 그래프 몫은 `chartWrap`을 직접 재서 정한다(어림값이 실제보다 크면 + * 유토곡선 아래에 빈 공간이 남는다). 화면에 붙기 전이라 잴 수 없는 첫 렌더에서만 쓰는 + * 출발값이고, 최소 패널 높이 계산의 기준이기도 하다. + */ +export interface SectionViewController { + root: HTMLElement; + render: ( + detail: SectionDetailResponse, + verticalExaggeration: number, + crossHalfWidth?: number, + stationInterval?: number, + earthworkConversion?: EarthworkConversion, + haulEquipmentLimits?: HaulEquipmentLimit[], + /** balloon 위치 캐시를 가르는 키(프로젝트+경로). 노선이 다르면 위치가 섞이면 안 된다. */ + balloonScope?: string, + /** 자연방토 판정 경사(config). 못 받으면 자연방토 없음으로 본다. */ + naturalSpoilMinSlope?: number, + ) => void; + /** 측점 하나의 카드만 새로 만들어 교체한다 (전체 재렌더 없이 설계 변경 반영). */ + refreshCard: (chainageM: number) => void; + /** 여러 측점을 한꺼번에 교체한다 — 상단 패널은 **마지막에 한 번만** 다시 그린다. */ + refreshCards: (chainages: ReadonlyArray) => void; + /** 좌측 구조물 목록에서 고른 측점 카드를 선택하고 화면에 드러낸다 — 재클릭 토글 없음 + * (2026-08-29 B05/B06 일원화: 목록 클릭 → 해당 카드 스크롤·강조). */ + focusStation: (stationId: string) => void; + /** 카드(측점) 선택이 바뀔 때 알림 — 좌측 「구조물 배치」 폼이 그 측점 구조물을 + * 올린다(2026-08-29 일원화). null = 선택 해제. */ + setStationSelectListener: (listener: (stationId: string | null) => void) => void; + clear: () => void; + /** 종단 아래 구조물 알약 레인에 쓸 목록 — B05 와 같은 부품·같은 표기(2026-09-07 사용자 + * 지시 4 「구조물 표시 통일」). 좌측 「구조물 배치」가 목록을 받을 때마다 넘겨준다. */ + setStructureMarks: ( + structures: ReadonlyArray, + types: ReadonlyArray, + ) => void; + /** 종단 그래프 우클릭으로 구조물을 넣고 빼는 길(2026-09-12 B05·B06 일원화). */ + setStructureEdit: (edit: SectionStructureEdit | null) => void; + /** 계획선 편집 ▲/▼ — 그릴 때마다 불러 선형·편집 함수를 받는다(null = 버튼 없음). */ + setGradeEdit: (provider: (() => LongitudinalPanelInput["grade"]) | null) => void; + dispose: () => void; +} diff --git a/B06_Section/B06_Section_UI_Style_Cross.css b/B06_Section/B06_Section_UI_Style_Cross.css index b2b89cd1..90240bd6 100644 --- a/B06_Section/B06_Section_UI_Style_Cross.css +++ b/B06_Section/B06_Section_UI_Style_Cross.css @@ -195,6 +195,29 @@ gap: var(--spacing-16); } +/* 성토사면 5m 초과 경고 줄(2026-09-14) — 요약 한 줄, 펼치면 측점 단추 목록. */ +.b06-section__notice { + margin-bottom: var(--spacing-8); + color: var(--color-warning); + font-size: var(--text-caption); +} + +.b06-section__notice > summary { + cursor: pointer; +} + +.b06-section__notice-list { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-4); + padding-top: var(--spacing-4); +} + +.b06-section__notice-list > button { + font-size: var(--text-caption); + white-space: nowrap; +} + .b06-cross-card { cursor: pointer; transition: diff --git a/resources/tester/test_b06_fill_slope_warn.py b/resources/tester/test_b06_fill_slope_warn.py new file mode 100644 index 00000000..b9180946 --- /dev/null +++ b/resources/tester/test_b06_fill_slope_warn.py @@ -0,0 +1,155 @@ +"""성토사면 길이 5m 초과 경고 — 벽 선 쪽만 빼고(좌·우 갈라) 경고만 (2026-09-14 브레인 (나)). + + ① 5m 를 **넘는** 쪽만 — 5.00 은 아님 · 원지반을 못 만난 하한값(≥)도 5m 이하면 아님 + ② 벽이 선 쪽은 뺀다 — 배관 유입(상단측)·유출(반대측) 기슭막이, 집수정은 벽 아님 + ③ 한쪽에만 벽 → 반대쪽은 그대로 경고 · 독립 기슭막이 설치 측(좌/우/양쪽) + ④ 세월교·BOX암거는 양쪽 측벽 + ⑤ 문구는 브레인 승인 그대로 + +TS 를 실제로 돌린다(파이썬 짝이 없는 화면 판정). +""" + +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" +SOURCE = PROJECT_ROOT / "B06_Section" / "B06_Section_UI_Cross_FillSlope_Warn.ts" + +_RUNNER = """ +import { readFileSync, writeFileSync } from "node:fs"; +import { FILL_SLOPE_WARN_TEXT, fillSlopeWarnings } from "./B06_Section_UI_Cross_FillSlope_Warn.js"; + +const [inputPath, outputPath] = process.argv.slice(2); +const cases = JSON.parse(readFileSync(inputPath, "utf8")); +const warnings = fillSlopeWarnings(cases.map((c) => c.section), (section) => + cases.find((c) => c.section.station_id === section.station_id).lengths, +); +writeFileSync(outputPath, JSON.stringify({ + text: FILL_SLOPE_WARN_TEXT, + warnings: warnings.map((w) => [w.section.station_id, w.sides.map((s) => s.side)]), +})); +""" + +LONG = {"lengthM": 7.0, "open": False} +BOTH = {"left": LONG, "right": LONG} + + +def _culvert(inlet: str = "기슭막이", outlet: str = "기슭막이", **extra: object) -> dict: + return {"inlet": {"structure": inlet}, "outlet": {"structure": outlet}, **extra} + + +CASES = [ + {"section": {"station_id": "plain"}, "lengths": BOTH}, + { + "section": {"station_id": "edge"}, + "lengths": { + "left": {"lengthM": 5.0, "open": False}, + "right": {"lengthM": 4.2, "open": True}, + }, + }, + # 상단측 좌 → 유입(좌) 기슭막이 · 유출(우) 기슭막이 — 양쪽 다 벽. + { + "section": {"station_id": "pipe", "uphill_side": "left", "culvert": _culvert()}, + "lengths": BOTH, + }, + # 유입이 집수정 → 좌(유입측)는 벽 없음 → 좌만 경고. + { + "section": {"station_id": "basin", "uphill_side": "left", "culvert": _culvert("집수정")}, + "lengths": BOTH, + }, + # 상단측 우 → 유출은 좌 · 유출이 집수정이면 좌만 경고. + { + "section": { + "station_id": "right_up", + "uphill_side": "right", + "culvert": _culvert(outlet="집수정"), + }, + "lengths": BOTH, + }, + { + "section": {"station_id": "own_left", "culvert": _culvert(hidden_pipe=True, side="좌")}, + "lengths": BOTH, + }, + { + "section": { + "station_id": "revet_auto", + "revetment": {"side": None}, + "design": {"section_mode": "left_cut"}, + }, + "lengths": {"left": None, "right": LONG}, + }, + {"section": {"station_id": "ford", "ford": {}}, "lengths": BOTH}, +] + + +def _run(tmp_path: Path) -> dict: + out = tmp_path / "js" + subprocess.run( # noqa: S603 — 고정 실행 파일 + [ + "node", + str(TSC), + str(SOURCE), + # 실행에 드는 import 는 이것 하나 — 나머지는 타입 import 라 지워진다. + str(SOURCE.with_name("B06_Section_UI_Cross_Culvert_Const.ts")), + "--outDir", + str(out), + "--module", + "esnext", + "--target", + "es2022", + "--moduleResolution", + "bundler", + "--ignoreConfig", + # 타입 줄기가 별칭(@util 등)으로 번져 단독 컴파일로는 못 푼다 — 검사는 typecheck 몫. + "--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_벽_선_쪽만_빼고_5m_넘는_쪽을_경고한다(tmp_path: Path) -> None: + got = dict(_run(tmp_path)["warnings"]) + assert got == { + "plain": ["left", "right"], + "basin": ["left"], + "right_up": ["left"], + "own_left": ["right"], + } + # 5.00 · 하한값 4.2(≥) · 양쪽 벽 · 자동 설치 측 벽 · 세월교는 경고 없음. + assert not {"edge", "pipe", "revet_auto", "ford"} & set(got) + + +@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치") +def test_문구는_승인된_그대로(tmp_path: Path) -> None: + assert _run(tmp_path)["text"] == ( + "성토사면 길이 5m 초과 — 법령상 옹벽·석축 설치 대상 " + "(산림자원법 시행규칙 별표2 Ⅰ.2.차.(3).(나) · 임도설치 규정 별표7 2.차.(3).(나)) " + "※ 실무 표본에서도 흔함(영월 63% · 봉화 49%) — 설치 여부는 설계자 판단" + )