feat(B05): 노면공 시작 · 종료 측점 · 길이 출력 · 기준 · 전/후 없음(87-1)

- 노면공 명세 byRange: 위치 줄 = 시작 · 종료 측점만(기준 측점 칸 없음 · 알약 = 시점) · 상세 내용 = 포장사양 → 길이(시작~종료 출력) → 높이
- 포장사양 선택지 콘크리트 · 아스콘 · 쇄석 · 높이 기본 0.20 m
- 저장 꼴: placement interval · start_m · end_m · options.pave_spec · height_m · 옛 length_m · before_m · after_m 은 [수정] 저장 때 뺌
- 구조물정의 판 20(판_19 이력) · 노면공 길이 · 전 · 후 옵션 빼고 height_m 기본값
- 시험: test_52_10 맞춤

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ
This commit is contained in:
2026-10-08 12:03:38 +09:00
co-authored by Claude Sonnet 5.5
parent e25b9ddf2f
commit fc36f0ae4e
11 changed files with 3879 additions and 31 deletions
+12 -5
View File
@@ -2,9 +2,10 @@
* design_structure_road.ts
* 노면공 › 콘크리트 포장 명세 — 사용자 구조물 표 08(`docs/raw/구조물정의_표/08_사토장_토취장_노면공_기타.md`).
*
* 기준 측점(패널 위치 칸) + 필수 컨테이너 「상세 내용」 한 벌 = 포장사양 고르기 · 높이(m) 손 입력.
* 구간 길이 · 전 · 후는 정의 옵션 칸 그대로(구간 배치) · 표에 없는 옛 상세 칸은 화면에서 빼고 값은 남김.
* 저장 = structures.json `options` — 새 키 `pave_spec` · `height_m`.
* 시작 · 종료 측점(패널 위치 줄 · 기준 측점 · 전 · 후 · 길이 입력 없음) + 필수 컨테이너 「상세 내용」 한 벌 =
* 포장사양 고르기 → 길이(m · 시작~종료 출력) → 높이(m · 두께 손 입력). 표에 없는 옛 상세 칸은 화면에서 빼고 값은 남김.
* 저장 = structures.json `options` — 새 키 `pave_spec` · `height_m` · `widening_off`(확폭 끈 구간 · 87-3) ·
* 옛 `length_m` · `before_m` · `after_m` 은 저장 때 뺌(`dropped`).
* 기타(52-14) = 기준 측점 + 상세 종류 이름(정의 옵션 칸 `name`) + 선택 컨테이너 「상세 내용」(비고 글) ·
* 저장 키 `etc_detail_{n}_note` · 잇기 `getcdetail{n}`.
* import 없음 — 시험이 이 파일만 옮겨 돌림.
@@ -12,8 +13,11 @@
import type { SpecChoice, StructureSpec } from "./design_structure_spec";
/** 포장사양 선택지 — 표의 나머지 값은 사용자 답 뒤 여기에만 더함. */
export const PAVE_SPEC_CHOICES: SpecChoice[] = [{ value: "콘크리트", label: "콘크리트" }];
/** 포장사양 선택지 — 근거 별표2 · 별표3 · 임도기술교본 3장. */
export const PAVE_SPEC_CHOICES: SpecChoice[] = ["콘크리트", "아스콘", "쇄석"].map((v) => ({
value: v,
label: v,
}));
export const PAVEMENT_SPEC: StructureSpec = {
typeId: "pavement_concrete",
@@ -31,6 +35,7 @@ export const PAVEMENT_SPEC: StructureSpec = {
key: "pave_spec",
choices: PAVE_SPEC_CHOICES,
},
{ id: "length", label: "길이 (m)", kind: "auto" },
{ id: "height", label: "높이 (m)", kind: "number", key: "height_m", step: 0.05 },
],
},
@@ -44,6 +49,8 @@ export const PAVEMENT_SPEC: StructureSpec = {
"separation_sheet",
"wire_mesh",
],
dropped: ["length_m", "before_m", "after_m"],
byRange: true,
};
export const ETC_SPEC: StructureSpec = {
@@ -69,6 +69,10 @@ export interface StructureSpec {
containers: SpecContainer[];
/** 명세가 화면에서 뺀 옛 키 — 값은 그대로 남고 「지난 정의 값」으로 보임. */
retired: string[];
/** 저장 때 지우는 옛 키 — 화면에도 안 그림(옛 항목을 [수정]하면 빠짐). */
dropped?: string[];
/** 범위 직접 입력형 — 시작 · 종료 측점만 받고 기준 측점 칸 없음(알약 = 시점 · 길이 · 전 · 후 입력 없음). */
byRange?: boolean;
}
/** 글 항목 길이 상한(자) — 칸 · 저장이 함께 자름. */
@@ -161,6 +161,22 @@ export function buildStructuresForm(options: {
optionRow,
{ get: groupBoxes.get, pick: groupBoxes.pick, clear: groupBoxes.clear, set: groupBoxes.setOne },
options.onFacilityChange,
// 노면공 길이 칸(87-1) = 시작~종료 출력 — 못 셈이면 빈 글(「—」)
(_c, item) => {
if (item.id !== "length") return "";
const step = options.getInterval();
const start = startFields.read(step, false);
const end = endFields.read(step, false);
return start !== null && end !== null && end > start
? String(Number((end - start).toFixed(2)))
: "";
},
);
// 시작 · 종료를 고치면 길이 출력을 다시 그림
[startFields, endFields].forEach((fields) =>
[fields.station, fields.remainder].forEach((input) =>
input.addEventListener("change", () => structureSpec.refresh()),
),
);
groupBoxes.watch(structureSpec.paintLinks);
const staleBox = document.createElement("fieldset");
@@ -56,6 +56,7 @@ import {
DEFAULT_INTERVAL_LENGTH_M,
groupOptions,
hasComputedRange,
isRangeOnly,
typeOptions,
readBeforeOption,
readLengthOption,
@@ -238,7 +239,7 @@ export function createStructuresSection(
// [?] 설명서가 먼저 여는 항목 = `구조물_<종류 이름>.md`(54-2 · 종류 안내 글은 그 파일로 옮김)
root.dataset.helpTopic = type ? `구조물_${type.name}` : "";
// 정의가 기준 측점을 끈 종류(48-3) — 기준 측점이 곧 위치인 종류(점형 · 범위 계산)는 늘 보임
anchorFields.wrap.hidden = isInterval && !showsAnchor(type!);
anchorFields.wrap.hidden = isInterval && (!showsAnchor(type!) || isRangeOnly(type));
anchorFields.wrap.querySelector("span")!.textContent = "기준 측점"; // 빈 칸 안내 없음(54-43)
syncRangeDisplay();
}
@@ -314,7 +314,10 @@ export async function commit(ctx: StructuresCommitContext, live = false): Promis
return;
}
// 기준 측점(마킹 위치) — 비우면 시작 측점. 시작~종료 밖은 서버도 거절한다.
anchor = ctx.anchorFields.read(step, false) ?? start;
// 범위 직접 입력형(노면공 · 87-1) = 기준 칸 없음 → 늘 시점.
anchor = STRUCTURE_SPECS[type.type_id]?.byRange
? start
: (ctx.anchorFields.read(step, false) ?? start);
if (anchor < start || anchor > end) {
ctx.anchorFields.station.classList.add("is-invalid");
ctx.anchorFields.station.focus();
@@ -20,6 +20,7 @@ import {
} from "./B05_Profile_Api_Structures";
import type { DesignSelection, ResolvedSelection } from "../A00_Common/design/design_selection";
import type { GroupItems, SummaryItem } from "../common_util/common_util_summary_item";
import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe";
/** 구조물군 표시 이름. 리스트 기호(A~G)만으로는 무엇인지 알기 어렵다.
* 우클릭 메뉴(종단그래프·배수유역도)도 같은 이름을 쓴다(2026-08-18 일원화). */
@@ -209,6 +210,10 @@ export function readBeforeOption(inputs: OptionEntry[]): number {
return length !== null && before > length ? length : before;
}
/** 시작 · 종료 측점만 받는 명세 종류인가(노면공 · 87-1) — 기준 측점 칸 없음 · 길이 출력. */
export const isRangeOnly = (type: { type_id: string } | null): boolean =>
!!type && !!STRUCTURE_SPECS[type.type_id]?.byRange;
/** 구간을 직접 입력하지 않는 타입인가 — C군 사면안정 5종처럼 B05 길이 옵션이
* 있으면 시작·종료 입력 칸을 없애고 기준 측점 + 길이로 범위를 계산해 보여만
* 준다(2026-08-19 사용자 지시). */
@@ -217,6 +222,7 @@ export function hasComputedRange(type: StructureType | null): boolean {
!!type &&
!type.managed_by &&
type.placement === "interval" &&
!isRangeOnly(type) &&
type.options.some((option) => option.key === "length_m" && isB05Option(option))
);
}
@@ -12,6 +12,7 @@ import type { StructureType } from "./B05_Profile_Api_Structures";
import {
createSpecForm,
type SpecForm,
type SpecFormHooks,
type SpecLinks,
} from "../A00_Common/design/design_structure_form";
import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe";
@@ -29,8 +30,9 @@ export function createStructureSpecForm(
optionRow: HTMLElement,
links: SpecLinks,
onChange: () => void,
auto?: SpecFormHooks["auto"],
): SpecForm {
const form = createSpecForm(links, { onChange });
const form = createSpecForm(links, { onChange, auto });
form.root.hidden = true;
forms.set(optionRow, form);
const lock = (): void => {
@@ -49,7 +51,7 @@ export function createStructureSpecForm(
/** 정의 옵션 칸으로 안 그리는 키 — 명세 키 · 명세가 뺀 옛 키. */
export function specOwnedKeys(type: StructureType | null): Set<string> {
const spec = structureSpecOf(type);
return new Set(spec ? [...specKeys(spec).keys, ...spec.retired] : []);
return new Set(spec ? [...specKeys(spec).keys, ...spec.retired, ...(spec.dropped ?? [])] : []);
}
/** 종류 · 값으로 명세 폼을 다시 그림(명세 없는 종류면 가림). */
@@ -85,5 +87,5 @@ export function specRenderedKeys(
if (!spec) return [];
const { keys, prefixes } = specKeys(spec);
const own = Object.keys(previous ?? {}).filter((key) => prefixes.some((p) => key.startsWith(p)));
return [...keys, ...own];
return [...keys, ...own, ...(spec.dropped ?? [])];
}
+7 -18
View File
@@ -1,5 +1,5 @@
{
"판": 19,
"판": 20,
"schema_version": 1,
"comment": "B05 구조물 타입 레지스트리 정본. 근거·옵션 상세는 PLAN.md 구조물 리스트와 resources/knowledge/technical_info/01_임도 참조. 2026-08-17 선택지 재편: B군(종단배수)·F군(생태/녹화)·G군(노면공)은 enabled:false 보존 — B05 선택지에서 빠지고 B06 개별 횡단도 옵션으로 재사용한다. 바닥막이(bed_sill)·생태연못(eco_pond)은 임도 미사용 확정으로 삭제. 배관·BOX암거·물넘이·세월교는 pipe_points.json 정본 관리(managed_by) — structures.json에 저장하지 않으며, 배관 부속(유형/집수정/기슭막이/돌붙임)·날개벽 옵션 정의는 B06/B07 필수 승격의 선언 근거다. ★기본값 원칙(2026-08-16 크로스체크 2차): default를 두는 것은 ①법정이 등급과 무관하게 못박은 단일 수치 ②사용자가 개발 단계에서 확정한 값 ③도메인 수치가 아닌 표시용 문자열, 이 셋뿐이다. 재료·형식 같은 설계자 판단 선택지와 미확정 제원은 default 없이 required로 두어 사용자가 직접 고르게 한다 (pipe_diameter_mm 1000 = 별표2 원칙값+사용자 확정 — 화이트리스트 등재). BOX암거 본체 2.0×2.0·날개벽 유입/유출 있음·짧은쪽 높이 1m·길이 2m·각도 45°는 2026-08-17 사용자 확정값이다 — 본체 규격은 실무 관측 규격(2.0×2.0·3.0×3.0)을 폼에서 프리셋 select로 받고 필요 시 사용자 지정으로 자유 입력하되, 저장 키는 body_width_m·body_height_m 숫자 그대로다(프리셋 전용 키를 만들지 않는다). 이 값들은 B05 서브폼이 실제로 받으므로 phase를 detail로 두지 않는다. ★보호공 개편(2026-08-17 사용자 지시): 구 `*_pitching`(있음/없음) + `*_pitching_finish`(찰/메) 두 축을 `*_protection` 한 축(돌붙임(찰)·돌붙임(메)·도수로)으로 합쳤다 — 도수로·산비탈수로(B4)는 유출부에서 돌붙임 대신 쓰는 공종이라 별도 구조물이 아니라 보호공 선택지로 받는다. \"없음\"은 삭제(물이 흐르는 자리라 보호공 필수). 치수는 배타 — 돌붙임이면 `*_protection_area_m2`(10㎡), 도수로면 `*_protection_width_m`(1m, 지식DB 수치 근거 없음·사용자 확정, 실무 확인 후 개선). 구 저장분은 `common_util_drainage_pipes.parse_pipe_points`가 새 키로 옮긴다.",
"구조물군": {
@@ -3177,25 +3177,14 @@
],
"options": [
{
"key": "length_m",
"label": "길이",
"key": "height_m",
"label": "높이",
"input": "number",
"unit": "m",
"default": 10
},
{
"key": "before_m",
"label": "기준측점 전",
"input": "number",
"unit": "m",
"default": 5
},
{
"key": "after_m",
"label": "기준측점 후",
"input": "number",
"unit": "m",
"default": 5
"default": 0.2,
"required": false,
"phase": "detail",
"default_basis": "사용자 확정 10-8 · 임도기술교본 3-2 「두께 0.2m」 기준 · 명세 칸(상세 내용)이 받음"
},
{
"key": "thickness_cm",
File diff suppressed because it is too large Load Diff
@@ -46,11 +46,14 @@ if (process.argv[2] === "road") {
same: spec === road.PAVEMENT_SPEC,
containers: spec.containers.map((c) => [c.id, c.title, c.required, c.link]),
choices: road.PAVE_SPEC_CHOICES.map((c) => c.value),
items: spec.containers[0].items.map((i) => [i.id, i.label, i.kind]),
state,
edited: s.writeOptions(spec, edited),
cleared: s.writeOptions(spec, cleared),
keys: s.specKeys(spec),
retired: spec.retired,
dropped: spec.dropped,
byRange: spec.byRange,
}),
);
process.exit(0);
@@ -36,7 +36,15 @@ def test_명세_등록_필수_컨테이너_하나(out):
def test_포장사양_선택지는_명세_한_곳(out):
assert out["choices"] == ["콘크리트"]
assert out["choices"] == ["콘크리트", "아스콘", "쇄석"]
def test_상세내용_차례_포장사양_길이_출력_높이(out):
assert out["items"] == [
["spec", "포장사양", "select"],
["length", "길이 (m)", "auto"],
["height", "높이 (m)", "number"],
]
def test_저장_키_왕복_옛_칸은_명세_밖(out):
@@ -44,7 +52,10 @@ def test_저장_키_왕복_옛_칸은_명세_밖(out):
assert out["edited"] == {"pave_spec": "콘크리트", "height_m": 0.35}
assert out["cleared"] == {"pave_spec": "콘크리트"}
assert out["keys"] == {"keys": ["pave_spec", "height_m"], "prefixes": []}
assert "length_m" not in out["retired"] # 구간 길이 · 전 · 후는 정의 칸 그대로
# 길이 · 기준측점 전/후는 입력 없음 — 시작 · 종료만 받고 옛 값은 저장 때 뺌(87-1)
assert out["byRange"] is True
assert out["dropped"] == ["length_m", "before_m", "after_m"]
assert "length_m" not in out["retired"]
assert set(out["retired"]) == {
"thickness_cm",
"width_m",
@@ -62,4 +73,7 @@ def test_기타_상세_종류_이름_글_칸():
name = next(o for o in etc["options"] if o["key"] == "name")
assert name["label"] == "상세 종류 이름" and name["input"] == "text"
pave = next(t for t in doc["types"] if t["type_id"] == "pavement_concrete")
assert [o["key"] for o in pave["options"]][:3] == ["length_m", "before_m", "after_m"]
keys = [o["key"] for o in pave["options"]]
assert not {"length_m", "before_m", "after_m"} & set(keys) # 87-1 시작 · 종료 직접 입력
height = next(o for o in pave["options"] if o["key"] == "height_m")
assert height["default"] == 0.2