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:
@@ -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 ?? [])];
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user