feat(B06): 세월교 좌측 폼을 배관 측점과 같은 양식으로 맞춘다

사용자 지시 3건(2026-08-30).

1) 횡단도에서 세월교 측벽을 고르면 좌측 폼의 그 측 칸이 선다 — 배관 기슭막이가
   쓰던 경로를 그대로 탄다. `SideSlots`에 `wingInlet`·`wingOutlet`을 더하고
   `slotOf()`가 배관 칸 → 독립 기슭막이 칸 → 날개벽 칸 순으로 지금 서 있는 쪽을
   고른다. 세월교 조정창은 `dataset.side`·`dataset.panelTitle`을 세워 어느 칸을
   가리키는지 알린다. 벽 선택 뒤 `refreshAdjustSlots()`도 배관과 같게 붙였다.

2) 월류 폭·높이·수량·개략 단면 결과를 관경 바로 다음으로 옮겼다(바닥 경사도 같은
   묶음에 둔다 — 물넘이포장 전용이라 세월교에서는 숨는다). 개략 단면 안내문은
   읽는 글이라 10px·opacity 0.75로 줄였다.

3) 세월교·날개벽 숫자 칸을 기슭막이·집수정과 같은 [-][값][+] 묶음으로 바꿨다
   (월류 폭·높이·바닥 경사·수량·날개 높이·길이·각도). `stepper()`에 표시 자릿수
   인자를 더해 련·각도는 정수로 보인다("1.0련"·"45.0°" 방지). 기본값 1이라 기존
   호출부는 그대로다.

자체검증(공용 브라우저 5174, 측점 7+9.7 세월교):
- tsc --noEmit 통과.
- 유입 측벽 선택 → `날개벽(유입) (세월교)` 칸 강조, 유출 측벽 → `날개벽(유출)
  (세월교)`. 한 번에 한 칸만 선다.
- 폼 항목 순서 실측: 관종 > 관경 > 월류 폭 > 월류 높이 > 수량 > 날개벽(유입) …
  안내문 font-size 10px, 보이는 스텝 묶음 9개.
- 수량 + → 폼 "2"·조정창 "2련", 각도 + → 폼 "50"·조정창 "50°" (소수점 없음).
- 회귀: 배관 측점 기슭막이 선택 → `유출구 (기슭막이(유출))` 강조 + 높이·길이·
  기준측점 전/후 행 이식 정상.
- 조작값은 원래대로(수량 1·각도 45) 되돌려 놓았다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-30 12:05:22 +09:00
co-authored by Claude Opus 5
parent 03840ff3d6
commit e78feab5ed
6 changed files with 68 additions and 18 deletions
@@ -187,6 +187,10 @@ export interface FacilityOptionsForm {
/** 독립 기슭막이 좌·우 칸의 이식 자리 — 배관 유입구·유출구 자리와 같은 몫이다. */ /** 독립 기슭막이 좌·우 칸의 이식 자리 — 배관 유입구·유출구 자리와 같은 몫이다. */
revetInletSlot: HTMLElement; revetInletSlot: HTMLElement;
revetOutletSlot: HTMLElement; revetOutletSlot: HTMLElement;
/** 세월교·BOX암거 날개벽 칸의 이식 자리 — 세월교 유입·유출 측벽을 고르면 이 칸이
* 선다(2026-08-30 사용자 지시 1: 배관 측점·기슭막이와 같게). */
wingInSlot: HTMLElement;
wingOutSlot: HTMLElement;
/** 유입구 "구조"에 합쳐진 B06 형식 값(2026-08-29 지시 5). B06이 조정창 값과 맞춘다. */ /** 유입구 "구조"에 합쳐진 B06 형식 값(2026-08-29 지시 5). B06이 조정창 값과 맞춘다. */
inletStructure: () => InletStructureKind; inletStructure: () => InletStructureKind;
setInletStructure: (value: InletStructureKind) => void; setInletStructure: (value: InletStructureKind) => void;
@@ -356,7 +360,9 @@ export function createFacilityOptionsForm(
// ── 세월교 — 구체 내 배관은 배수관과 같은 관종·관경 칸을 쓰고(2026-08-17 사용자 // ── 세월교 — 구체 내 배관은 배수관과 같은 관종·관경 칸을 쓰고(2026-08-17 사용자
// 지시) 수량만 따로 받는다. // 지시) 수량만 따로 받는다.
const fordCount = numberInput("1", "1", "련"); const fordCount = numberInput("1", "1", "련");
const fordRow = grid(labeled("수량 (련)", fordCount)); // 숫자 칸은 기슭막이·집수정과 같은 [-][값][+] 묶음(2026-08-30 사용자 지시 3).
// 련은 정수라 소수 자릿수를 두지 않는다.
const fordRow = grid(labeled("수량 (련)", stepper(fordCount, 1, 0)));
// ── 물넘이·세월교 개략 단면 — 월류 폭 + 월류 높이 한 행(2026-08-18 사용자 지시). // ── 물넘이·세월교 개략 단면 — 월류 폭 + 월류 높이 한 행(2026-08-18 사용자 지시).
// 폭 기본값은 세월교 10m·물넘이 포장 5m(사용자 확정 — 지식DB 폭 수치 근거 없음). // 폭 기본값은 세월교 10m·물넘이 포장 5m(사용자 확정 — 지식DB 폭 수치 근거 없음).
@@ -365,14 +371,14 @@ export function createFacilityOptionsForm(
const fordWidth = numberInput("0.1"); const fordWidth = numberInput("0.1");
const fordHeight = numberInput("0.01"); const fordHeight = numberInput("0.01");
const fordWidthRow = grid( const fordWidthRow = grid(
labeled("월류 폭 (m)", fordWidth), labeled("월류 폭 (m)", stepper(fordWidth, 0.1)),
labeled("월류 높이 (m)", fordHeight), labeled("월류 높이 (m)", stepper(fordHeight, 0.1)),
); );
// 물넘이 바닥은 유입(상류)이 높고 유출이 낮게 기운다(2026-08-28 사용자 확정). // 물넘이 바닥은 유입(상류)이 높고 유출이 낮게 기운다(2026-08-28 사용자 확정).
// 비우면 그 측점의 **노면 횡단경사**를 그대로 쓴다 — 횡단도가 판단한다. // 비우면 그 측점의 **노면 횡단경사**를 그대로 쓴다 — 횡단도가 판단한다.
const fordSlope = numberInput("0.1"); const fordSlope = numberInput("0.1");
fordSlope.placeholder = "노면 기울기"; fordSlope.placeholder = "노면 기울기";
const fordSlopeRow = grid(labeled("바닥 경사 유입→유출 (%)", fordSlope)); const fordSlopeRow = grid(labeled("바닥 경사 유입→유출 (%)", stepper(fordSlope, 0.1)));
const fordSummary = document.createElement("p"); const fordSummary = document.createElement("p");
fordSummary.className = "b05-drainage__facility-note"; fordSummary.className = "b05-drainage__facility-note";
/** 담당 유역 설계유량(㎥/s) — 개략 단면의 입력. 유역이 없으면 null. */ /** 담당 유역 설계유량(㎥/s) — 개략 단면의 입력. 유역이 없으면 null. */
@@ -423,8 +429,14 @@ export function createFacilityOptionsForm(
emit(); emit();
}); });
// 세월교·물넘이 항목은 **관종·관경 바로 다음**에 둔다(2026-08-30 사용자 지시 2) —
// 월류 폭·높이 → 바닥 경사 → 수량 → 개략 단면 결과. 다른 시설에서는 전부 숨는다.
root.append( root.append(
pipeRow, pipeRow,
fordWidthRow,
fordSlopeRow,
fordRow,
fordSummary,
inletGroup.root, inletGroup.root,
outletGroup.root, outletGroup.root,
extraGroup.root, extraGroup.root,
@@ -434,10 +446,6 @@ export function createFacilityOptionsForm(
boxWrap, boxWrap,
wingInFields.root, wingInFields.root,
wingOutFields.root, wingOutFields.root,
fordWidthRow,
fordSlopeRow,
fordRow,
fordSummary,
); );
let current: PipeFacility | null = null; let current: PipeFacility | null = null;
@@ -537,6 +545,8 @@ export function createFacilityOptionsForm(
extraSlot, extraSlot,
revetInletSlot: revetInlet.slot, revetInletSlot: revetInlet.slot,
revetOutletSlot: revetOutlet.slot, revetOutletSlot: revetOutlet.slot,
wingInSlot: wingInFields.slot,
wingOutSlot: wingOutFields.slot,
setRevetSideLabels(labels) { setRevetSideLabels(labels) {
revetSideLabels = labels; revetSideLabels = labels;
syncVisibility(); syncVisibility();
@@ -84,7 +84,9 @@ export function numberInput(step: string, min = "0", placeholder = ""): HTMLInpu
/** 스텝 묶음 입력에 붙일 일련번호 — 라벨이 가리킬 대상을 명시하는 데 쓴다. */ /** 스텝 묶음 입력에 붙일 일련번호 — 라벨이 가리킬 대상을 명시하는 데 쓴다. */
let stepperSeq = 0; let stepperSeq = 0;
export function stepper(input: HTMLInputElement, stepM: number): HTMLElement { /** `decimals` — 표시 자릿수. 련·각도처럼 정수로 세는 칸은 0을 준다(2026-08-30 사용자:
* 세월교 상세도 같은 양식으로 맞추되 "1.0련"·"45.0°"로 보이면 안 된다). */
export function stepper(input: HTMLInputElement, stepM: number, decimals = 1): HTMLElement {
const wrap = document.createElement("div"); const wrap = document.createElement("div");
wrap.className = "b05-structure__stepper"; wrap.className = "b05-structure__stepper";
// 라벨이 이 입력을 가리키게 id를 붙인다. 없으면 <label>의 암묵 대상이 묶음의 // 라벨이 이 입력을 가리키게 id를 붙인다. 없으면 <label>의 암묵 대상이 묶음의
@@ -95,11 +97,12 @@ export function stepper(input: HTMLInputElement, stepM: number): HTMLElement {
const current = Number.parseFloat(input.value); const current = Number.parseFloat(input.value);
const min = Number.parseFloat(input.min); const min = Number.parseFloat(input.min);
const floor = Number.isFinite(min) ? min : 0; const floor = Number.isFinite(min) ? min : 0;
const grain = 10 ** decimals;
const next = Math.max( const next = Math.max(
floor, floor,
Math.round(((Number.isFinite(current) ? current : 0) + delta) * 10) / 10, Math.round(((Number.isFinite(current) ? current : 0) + delta) * grain) / grain,
); );
input.value = next.toFixed(1); input.value = next.toFixed(decimals);
input.dispatchEvent(new Event("input", { bubbles: true })); input.dispatchEvent(new Event("input", { bubbles: true }));
input.dispatchEvent(new Event("change", { bubbles: true })); input.dispatchEvent(new Event("change", { bubbles: true }));
}; };
@@ -385,6 +388,9 @@ const WING_DEFAULTS = { install: "있음", height: "1", length: "2", angle: "45"
interface WingFields { interface WingFields {
root: HTMLFieldSetElement; root: HTMLFieldSetElement;
/** 횡단도에서 그 측 날개벽·측벽을 고를 때 칸을 세우는 자리(B06 전용, 2026-08-30).
* 배관 유입구·유출구 칸의 `adjust-slot`과 같은 몫이다. */
slot: HTMLElement;
inputs: Array<HTMLInputElement | HTMLSelectElement>; inputs: Array<HTMLInputElement | HTMLSelectElement>;
syncVisibility: () => void; syncVisibility: () => void;
write: (options: Record<string, string | number>) => void; write: (options: Record<string, string | number>) => void;
@@ -403,10 +409,16 @@ export function createWingFields(title: string, keys: WingKeys): WingFields {
const angle = numberInput("1"); const angle = numberInput("1");
angle.value = WING_DEFAULTS.angle; angle.value = WING_DEFAULTS.angle;
const heightField = labeled("짧은쪽 높이 (m)", height); // 숫자 칸은 기슭막이·집수정과 같은 [-][값][+] 묶음으로 맞춘다(2026-08-30 사용자).
const dimsRow = grid(labeled("이 (m)", length), labeled("각도 (°)", angle)); const heightField = labeled("짧은쪽 높이 (m)", stepper(height, 0.1));
const dimsRow = grid(
labeled("길이 (m)", stepper(length, 0.1)),
labeled("각도 (°)", stepper(angle, 5, 0)),
);
const box = group(title); const box = group(title);
box.body.append(grid(labeled("설치", install), heightField), dimsRow); const slot = document.createElement("div");
slot.className = "b05-structure__adjust-slot";
box.body.append(grid(labeled("설치", install), heightField), dimsRow, slot);
function syncVisibility(): void { function syncVisibility(): void {
const off = install.value === "없음"; const off = install.value === "없음";
@@ -419,6 +431,7 @@ export function createWingFields(title: string, keys: WingKeys): WingFields {
return { return {
root: box.root, root: box.root,
slot,
inputs: [install, height, length, angle], inputs: [install, height, length, angle],
syncVisibility, syncVisibility,
write(options) { write(options) {
@@ -344,6 +344,15 @@
font-size: var(--text-caption); font-size: var(--text-caption);
} }
/* 물넘이·세월교 개략 단면 결과 — 읽어 보는 안내문이라 입력 칸보다 작게 둔다
(2026-08-30 사용자: 글자가 너무 크다). */
.b05-drainage__facility-note {
margin: 0;
font-size: 10px;
line-height: 1.4;
opacity: 0.75;
}
/* 스텝 묶음과 그 안의 버튼·숫자칸도 같은 높이로 선다. */ /* 스텝 묶음과 그 안의 버튼·숫자칸도 같은 높이로 선다. */
.b05-structure__stepper { .b05-structure__stepper {
height: var(--b05-control-h); height: var(--b05-control-h);
+12 -4
View File
@@ -22,6 +22,10 @@ interface SideSlots {
/** 독립 기슭막이 좌·우 칸 — 배관 유입구·유출구 칸이 숨어 있을 때 이쪽으로 간다. */ /** 독립 기슭막이 좌·우 칸 — 배관 유입구·유출구 칸이 숨어 있을 때 이쪽으로 간다. */
revetInlet: HTMLElement; revetInlet: HTMLElement;
revetOutlet: HTMLElement; revetOutlet: HTMLElement;
/** 세월교 날개벽(유입·유출) 칸 — 세월교 측점에서는 배관·독립 칸이 모두 숨어 있어
* 이쪽이 그 측의 자리다(2026-08-30 사용자 지시 1). */
wingInlet: HTMLElement;
wingOutlet: HTMLElement;
} }
let sideSlots: SideSlots | null = null; let sideSlots: SideSlots | null = null;
@@ -51,12 +55,14 @@ function slotUsable(target: HTMLElement | undefined): target is HTMLElement {
return !!group && !group.hidden; return !!group && !group.hidden;
} }
/** 배관이면 유입구·유출구 칸, 독립 기슭막이면 좌·우 칸 — 지금 서 있는 쪽을 준다. */ /** 배관이면 유입구·유출구 칸, 독립 기슭막이면 좌·우 칸, 세월교면 날개벽 칸 —
* 지금 서 있는 쪽을 준다(같은 측의 자리가 시설 종류마다 다른 칸에 있다). */
function slotOf(side: "inlet" | "outlet"): HTMLElement { function slotOf(side: "inlet" | "outlet"): HTMLElement {
const slots = sideSlots!; const slots = sideSlots!;
return slotUsable(slots[side]) const revet = side === "inlet" ? slots.revetInlet : slots.revetOutlet;
? slots[side] const wing = side === "inlet" ? slots.wingInlet : slots.wingOutlet;
: slots[side === "inlet" ? "revetInlet" : "revetOutlet"]; if (slotUsable(slots[side])) return slots[side];
return slotUsable(revet) ? revet : wing;
} }
/** 옮겨 온 행과 그 **제자리 표식** — 되돌릴 때 원래 순서로 꽂기 위해 짝으로 둔다. /** 옮겨 온 행과 그 **제자리 표식** — 되돌릴 때 원래 순서로 꽂기 위해 짝으로 둔다.
@@ -85,6 +91,8 @@ function clearSideSlots(): void {
sideSlots?.extra, sideSlots?.extra,
sideSlots?.revetInlet, sideSlots?.revetInlet,
sideSlots?.revetOutlet, sideSlots?.revetOutlet,
sideSlots?.wingInlet,
sideSlots?.wingOutlet,
]) { ]) {
target?.replaceChildren(); target?.replaceChildren();
const group = target?.closest("fieldset"); const group = target?.closest("fieldset");
@@ -265,6 +265,11 @@ export function buildFordPanel(deps: FordPanelDeps, opts?: { dock?: boolean }):
show(role) { show(role) {
current = role; current = role;
root.classList.toggle("is-hidden", role === null); root.classList.toggle("is-hidden", role === null);
// 좌측 [횡단 조정]에 어느 칸을 세울지 알린다 — 세월교는 그 측 날개벽 칸이다
// (배관 조정창이 유입구·유출구 칸을 가리키는 것과 같은 규약, 2026-08-30 지시 1).
root.dataset.side = role ?? "";
// 칸 이름이 이미 "날개벽(유입)"이라 괄호에는 구조물 이름만 붙인다.
root.dataset.panelTitle = role === null ? "" : "세월교";
arrows.detach(); arrows.detach();
if (role !== null) arrows.attach(); if (role !== null) arrows.attach();
render(); render();
@@ -266,6 +266,8 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
extra: section.facility.extraSlot, extra: section.facility.extraSlot,
revetInlet: section.facility.revetInletSlot, revetInlet: section.facility.revetInletSlot,
revetOutlet: section.facility.revetOutletSlot, revetOutlet: section.facility.revetOutletSlot,
wingInlet: section.facility.wingInSlot,
wingOutlet: section.facility.wingOutSlot,
}); });
async function load(): Promise<void> { async function load(): Promise<void> {
@@ -441,6 +443,9 @@ export function wireStructureSelection(
markWallSelecting(); markWallSelecting();
origFord(chainageM, role); origFord(chainageM, role);
panel.showPipeAt(role ? chainageM : null); panel.showPipeAt(role ? chainageM : null);
// 폼이 이 시설로 갈아 끼워진 뒤에 이식을 다시 돌린다 — 날개벽 칸이 그제야 선다
// (배관 기슭막이와 같은 순서, 2026-08-30 사용자 지시 1).
refreshAdjustSlots();
}; };
const origBox = stationControls.box.select; const origBox = stationControls.box.select;
stationControls.box.select = (chainageM, role) => { stationControls.box.select = (chainageM, role) => {