feat(B05): C군 사면안정 5종 범위 입력 개편 — 기준 측점+길이 계산 표시

- 레지스트리: C군 5종(옹벽·돌쌓기 찰/메·흙막이·큰돌쌓기)에 길이 옵션 신설,
  높이와 함께 B05 필수 입력(phase b05)으로 전환. 순서 = 높이 → 길이.
- 패널: 구간 직접 입력(시작·종료) 삭제, 기준 측점 위 '측점 범위' 표시 행 신설
  — 기준+길이로 "시작 ~ 종료" 실시간 계산, 저장 시 시작=기준·종료=기준+길이.
- 구간 직입 시절 저장분 호환: 폼에 열면 길이를 시작~종료에서 자동 역산.
- 검증: pytest 111 통과(tmp/tests 정책·저장소 테스트 갱신), tsc, 헤드 브라우저
  저장 왕복(60~90·len=30·h=2.5) 확인.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 18:04:03 +09:00
co-authored by Claude Fable 5
parent 7df9ae622a
commit 84a73a3c09
3 changed files with 158 additions and 27 deletions
+66 -21
View File
@@ -512,15 +512,6 @@
},
"drawing_views": ["profile", "cross_section", "detail", "quantity"],
"options": [
{
"key": "form",
"label": "형식",
"input": "select",
"choices": ["중력식", "반중력식", "캔틸레버식", "부벽식"],
"default": null,
"required": true,
"phase": "detail"
},
{
"key": "height_m",
"label": "높이",
@@ -528,6 +519,24 @@
"unit": "m",
"default": null,
"required": true,
"phase": "b05"
},
{
"key": "length_m",
"label": "길이",
"input": "number",
"unit": "m",
"default": null,
"required": true,
"phase": "b05"
},
{
"key": "form",
"label": "형식",
"input": "select",
"choices": ["중력식", "반중력식", "캔틸레버식", "부벽식"],
"default": null,
"required": true,
"phase": "detail"
}
]
@@ -550,7 +559,16 @@
"unit": "m",
"default": null,
"required": true,
"phase": "detail"
"phase": "b05"
},
{
"key": "length_m",
"label": "길이",
"input": "number",
"unit": "m",
"default": null,
"required": true,
"phase": "b05"
},
{
"key": "back_len_cm",
@@ -581,7 +599,16 @@
"unit": "m",
"default": null,
"required": true,
"phase": "detail"
"phase": "b05"
},
{
"key": "length_m",
"label": "길이",
"input": "number",
"unit": "m",
"default": null,
"required": true,
"phase": "b05"
},
{
"key": "back_len_cm",
@@ -605,15 +632,6 @@
},
"drawing_views": ["profile", "cross_section", "quantity"],
"options": [
{
"key": "form",
"label": "형식",
"input": "select",
"choices": ["콘크리트", "돌(찰)", "돌(메)", "블록쌓기", "돌망태", "흙포대", "통나무쌓기"],
"default": null,
"required": true,
"phase": "detail"
},
{
"key": "height_m",
"label": "높이",
@@ -621,6 +639,24 @@
"unit": "m",
"default": null,
"required": true,
"phase": "b05"
},
{
"key": "length_m",
"label": "길이",
"input": "number",
"unit": "m",
"default": null,
"required": true,
"phase": "b05"
},
{
"key": "form",
"label": "형식",
"input": "select",
"choices": ["콘크리트", "돌(찰)", "돌(메)", "블록쌓기", "돌망태", "흙포대", "통나무쌓기"],
"default": null,
"required": true,
"phase": "detail"
}
]
@@ -643,7 +679,16 @@
"unit": "m",
"default": null,
"required": true,
"phase": "detail"
"phase": "b05"
},
{
"key": "length_m",
"label": "길이",
"input": "number",
"unit": "m",
"default": null,
"required": true,
"phase": "b05"
},
{
"key": "stone_cm",
+84 -6
View File
@@ -28,6 +28,7 @@ import {
} from "./B05_Profile_UI_Drainage_Facility";
import { field, numberInput, select, stationFields } from "./B05_Profile_UI_Structures_Fields";
import { renderStructureList } from "./B05_Profile_UI_Structures_List";
import { formatStation } from "./B05_Profile_Util_Station";
/** 구조물군 표시 이름. 리스트 기호(A~G)만으로는 무엇인지 알기 어렵다.
* 우클릭 메뉴(종단그래프·배수유역도)도 같은 이름을 쓴다(2026-08-18 일원화). */
@@ -128,10 +129,18 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
memoField.type = "text";
memoField.placeholder = "메모(선택)";
// 측점 범위(계산 표시) — C군 사면안정처럼 길이 옵션으로 구간이 정해지는 타입은
// 시작·종료를 직접 입력하지 않고 기준 측점 + 길이로 계산해 보여만 준다
// (2026-08-19 사용자 지시). 표기는 하단 구조물 목록과 같은 시작 ~ 종료 측점.
const rangeValue = document.createElement("span");
rangeValue.className = "b05-structure__range-value";
const rangeWrap = field("측점 범위", rangeValue);
// 측점은 3행 — 한 행이 [라벨][측점번호][잔여거리](2026-08-17 사용자 지시 2).
// 범위 계산 타입은 [측점 범위][기준 측점]만 보인다(범위 행이 기준 측점 위).
const positionRow = document.createElement("div");
positionRow.className = "b05-structure__position-row";
positionRow.append(startFields.wrap, anchorFields.wrap, endFields.wrap);
positionRow.append(rangeWrap, startFields.wrap, anchorFields.wrap, endFields.wrap);
// 기본 인터페이스는 2열 격자(2026-08-17 사용자 지시 1).
const typeRow = document.createElement("div");
@@ -157,6 +166,40 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
return type.type_id === "revetment" ? "revetment" : null;
}
/** 구간을 직접 입력하지 않는 타입인가 — C군 사면안정 5종처럼 B05 길이 옵션이
* 있으면 시작·종료 입력 칸을 없애고 기준 측점 + 길이로 범위를 계산해 보여만
* 준다(2026-08-19 사용자 지시). */
function hasComputedRange(type: StructureType | null): boolean {
return (
!!type &&
!type.managed_by &&
type.placement === "interval" &&
type.options.some((option) => option.key === "length_m" && isB05Option(option))
);
}
/** 길이 옵션(length_m) 값(m). 비었거나 0 이하면 null — 범위를 계산할 수 없다. */
function readLengthM(): number | null {
const entry = optionInputs.find((input) => input.key === "length_m");
if (!entry || entry.isEmpty()) return null;
const length = Number(entry.read());
return Number.isFinite(length) && length > 0 ? length : null;
}
/** 측점 범위 표시 갱신 — 기준 측점 + 길이가 다 있으면 "시작 ~ 종료", 아니면 "—". */
function syncRangeDisplay(): void {
const computed = hasComputedRange(currentType());
rangeWrap.hidden = !computed;
if (!computed) return;
const step = interval();
const anchor = anchorFields.read(step, false);
const length = readLengthM();
rangeValue.textContent =
anchor !== null && length !== null
? `${formatStation(anchor, step)} ~ ${formatStation(anchor + length, step)}`
: "—";
}
const primary = document.createElement("button");
primary.type = "button";
primary.className = "b05-route__irregular-btn is-primary";
@@ -223,11 +266,13 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
/** 배치형태·관리 주체에 맞춰 위치 칸을 바꾼다. 계곡 통과 시설(배수관 등)은 점형이라
* 기준 측점 하나만 받는다(2026-08-17 사용자 지시 2 — 시작·종료 불필요).
* 범위 계산 타입(C군 등)도 기준 측점만 받고 범위는 표시 행이 대신한다(2026-08-19).
* 종류 미선택(빈값)이면 입력 칸 전체를 숨긴다 — 리셋 직후 상태. */
function syncPlacementFields(): void {
const type = currentType();
const managed = !!type?.managed_by;
const isInterval = !!type && !managed && type.placement === "interval";
const isInterval =
!!type && !managed && type.placement === "interval" && !hasComputedRange(type);
positionRow.hidden = !type;
positionDivider.hidden = !type;
startFields.wrap.hidden = !isInterval;
@@ -236,6 +281,7 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
anchorFields.wrap.querySelector("span")!.textContent = isInterval
? "기준 측점 (비우면 시작)"
: "기준 측점";
syncRangeDisplay();
}
/** 종류에 맞춰 부속 옵션 서브폼을 켠다(계곡 통과 시설·독립 기슭막이, 값은 인자로). */
@@ -247,12 +293,13 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
syncOptionLock();
}
/** 위치(측점)가 채워졌는지 — 상세 옵션을 여는 조건(2026-08-18 입력 순서 가이드). */
/** 위치(측점)가 채워졌는지 — 상세 옵션을 여는 조건(2026-08-18 입력 순서 가이드).
* 범위 계산 타입은 기준 측점 하나가 곧 위치다(2026-08-19). */
function hasPosition(): boolean {
const type = currentType();
if (!type) return false;
const step = interval();
if (!type.managed_by && type.placement === "interval") {
if (!type.managed_by && type.placement === "interval" && !hasComputedRange(type)) {
return startFields.read(step, false) !== null && endFields.read(step, false) !== null;
}
return anchorFields.read(step, false) !== null;
@@ -298,6 +345,7 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
function handleStationInput(): void {
if (!hasPosition()) positionConfirmed = false;
syncOptionLock();
syncRangeDisplay();
}
/** 위치 입력 확정 — 측점번호+잔여거리를 다 받고 입력군을 빠져나온 순간.
@@ -306,6 +354,7 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
positionConfirmed = hasPosition();
commitTempPipe();
syncOptionLock();
syncRangeDisplay();
}
/** 타입의 옵션 스키마대로 입력 칸을 다시 그린다 — 상세(detail) 옵션은 B06/B07
@@ -349,8 +398,14 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
read: () => (option.input === "number" ? Number(input.value) || 0 : input.value),
isEmpty: () => input.value.trim() === "",
});
// 길이가 바뀌면 측점 범위 표시가 즉시 따라온다(범위 계산 타입, 2026-08-19).
if (option.key === "length_m") {
input.addEventListener("input", syncRangeDisplay);
input.addEventListener("change", syncRangeDisplay);
}
});
syncOptionLock();
syncRangeDisplay();
}
/** 고른 구조물군의 타입을 종류 목록에 채운다 — A군은 계곡 통과 시설(managed_by)도
@@ -450,6 +505,14 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
endFields.write(target.end_m ?? null, step);
memoField.value = target.memo ?? "";
renderOptionFields(target.options);
// 구간 직접 입력 시절에 저장된 항목 호환 — 길이 옵션이 비어 있으면 저장된
// 시작~종료 구간 길이로 채워 범위 표시·재저장이 이어지게 한다(2026-08-19).
if (target.start_m != null && target.end_m != null && target.end_m > target.start_m) {
const lengthEntry = optionInputs.find((entry) => entry.key === "length_m");
if (lengthEntry && lengthEntry.isEmpty()) {
lengthEntry.input.value = (target.end_m - target.start_m).toFixed(1);
}
}
syncFacilityForm(target.options);
} else {
anchorFields.write(null, step);
@@ -560,7 +623,21 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
let anchor: number | null;
let start: number | null = null;
let end: number | null = null;
if (placement === "interval") {
if (placement === "interval" && hasComputedRange(type)) {
// 범위 계산 타입(C군 등) — 시작 = 기준 측점, 종료 = 기준 + 길이(2026-08-19).
anchor = anchorFields.read(step, true);
if (anchor === null) {
anchorFields.station.focus();
return;
}
const length = readLengthM();
if (length === null) {
optionInputs.find((entry) => entry.key === "length_m")?.input.focus();
return;
}
start = anchor;
end = anchor + length;
} else if (placement === "interval") {
start = startFields.read(step, true);
end = endFields.read(step, true);
if (start === null || end === null || end <= start) {
@@ -792,7 +869,8 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
positionConfirmed = true; // 우클릭은 측점이 확정돼 들어온다 — 옵션도 바로 연다.
const placement = placementOf(typeId);
const step = interval();
const isInterval = !type.managed_by && placement === "interval";
// 범위 계산 타입은 시작·종료를 쓰지 않는다 — 길이 옵션이 정한다(2026-08-19).
const isInterval = !type.managed_by && placement === "interval" && !hasComputedRange(type);
anchorFields.write(chainageM, step);
startFields.write(isInterval ? chainageM : null, step);
endFields.write(isInterval ? chainageM + DEFAULT_INTERVAL_LENGTH_M : null, step);
@@ -222,6 +222,14 @@
background: rgba(224, 91, 91, 0.08);
}
/* 측점 범위(계산 표시) — C군 등 길이 옵션 기반 구간 타입. 입력 칸이 아니라 기준
* 측점 + 길이로 계산된 "시작 ~ 종료"를 보여만 준다(2026-08-19 사용자 지시). */
.b05-structure__position-row > .b05-route__field > .b05-structure__range-value {
flex: 1;
min-width: 0;
font-weight: 600;
}
/* 측점번호 + 잔여거리 두 칸 입력(2026-08-17 통합 — 구 비정규 측점 UI와 같은 방식). */
.b05-structure__station-pair {
display: flex;