merge: main_laptop_1 665ccf777 — 52-7 충돌 = 양쪽 다 살림

- Panel_Options = 메인 hidesTypeSummary 세 조건(명세 · 정의 그룹 · 종단배수) + 가드레일 solveSplit · 옛 hasSpecContainers 걷음
- 구조물정의.json = 대피로 · 가드레일 · 맹암거 그룹 다 · 판 9
- 이력 판_6 = 메인 것(바탕 판 6 그대로) · 네 판_6 은 판_8 로
- 52-8 숨김(군 단위)과 대피로 목록 빼기(종류 단위)는 자리가 달라 둘 다 둠
- 시험 수 = B05 선택지 28 · 정의 종류 42

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
This commit is contained in:
2026-10-06 00:00:55 +09:00
co-authored by Claude Opus 5.5
34 changed files with 8467 additions and 134 deletions
+67 -57
View File
@@ -53,6 +53,8 @@ import {
} from "./B05_Profile_UI_Drainage_Facility_Fields";
import { createSpecForm, type SpecLinks } from "../A00_Common/design/design_structure_form";
import { PIPE_SPEC } from "../A00_Common/design/design_structure_pipe";
import { FORD_SPEC, withLegacyGuards } from "../A00_Common/design/design_structure_ford";
import { fordHeightHint, fordHeightM } from "../A00_Common/design/design_structure_ford_calc";
import { guardHeightHint, pipeLengthM } from "../A00_Common/design/design_structure_pipe_calc";
import {
hasLinks,
@@ -250,45 +252,31 @@ export function createFacilityOptionsForm(
root.className = "b05-drainage__facility";
root.hidden = true;
// ── 관종 · 관경 한 행 — 세월교 몫(배관은 아래 명세 폼 · PLAN 52-1) ─────────
const pipeMaterial = optionalSelect("— 안 정함 —", ["흄관", "VR관", "파형강관"]);
const pipeDiameter = document.createElement("select");
pipeDiameter.replaceChildren(
new Option("— 안 정함 —", ""),
...["800", "1000", "1200", "1500"].map((size) => new Option(`Ø${size}`, size)),
);
const pipeRow = grid(
labeled("관종", pipeMaterial, "pipe_kind"),
labeled("관경", pipeDiameter, "pipe_diameter_mm"),
);
// ── 배관 = 사용자 구조물 표 01 명세(PLAN 52-1) — 컨테이너 다섯 · 옛 관종 · 날개벽 칸은 뺌(지난 정의 값) ──
let linkSource: SpecLinks | null = null;
let chainage: number | null = null;
const pipeForm = createSpecForm(
{
get: () => linkSource?.get() ?? {},
pick: (key) => linkSource?.pick(key),
clear: (key) => linkSource?.clear(key),
set: (key, item) => linkSource?.set(key, item),
const specLinks: SpecLinks = {
get: () => linkSource?.get() ?? {},
pick: (key) => linkSource?.pick(key),
clear: (key) => linkSource?.clear(key),
set: (key, item) => linkSource?.set(key, item),
};
const pipeForm = createSpecForm(specLinks, {
auto: (_c, item) => {
if (item.id !== "length") return "";
const length = pipeLengthM(chainage);
return length === null ? "횡단 설계 뒤 셈" : length.toFixed(2);
},
{
auto: (_c, item) => {
if (item.id !== "length") return "";
const length = pipeLengthM(chainage);
return length === null ? "횡단 설계 뒤 셈" : length.toFixed(2);
},
hint: (c, item, state) => {
const soil = c.id !== "inlet" || state.inlet?.[0]?.structure === "기슭막이";
return item.id === "height" && soil ? guardHeightHint(state.pipe?.[0]?.diameter ?? "") : "";
},
onChange: (c, item) => {
if (c.id === "inlet" && (item.id === "structure" || item.id === "form"))
inletStructureHandler?.(inletStructureOf());
emit();
},
hint: (c, item, state) => {
const soil = c.id !== "inlet" || state.inlet?.[0]?.structure === "기슭막이";
return item.id === "height" && soil ? guardHeightHint(state.pipe?.[0]?.diameter ?? "") : "";
},
);
onChange: (c, item) => {
if (c.id === "inlet" && (item.id === "structure" || item.id === "form"))
inletStructureHandler?.(inletStructureOf());
emit();
},
});
// 조정창에서 옮겨 오는 행(단 수·옵션)이 붙을 자리 — 유입 · 유출 컨테이너 끝(2026-08-29 지시 4)
const inletSlot = pipeForm.slot("inlet");
const outletSlot = pipeForm.slot("outlet");
@@ -301,6 +289,26 @@ export function createFacilityOptionsForm(
: "auto";
};
// ── 세월교 = 사용자 구조물 표 04 명세(PLAN 52-4) — 세월교 · 유입 구조 · 유출 구조 ──
// 월류 높이 = 월류 폭과 연동(폭을 바꾸면 필요 수심으로 채움 · 그 아래는 안 받음 · `design_structure_ford_calc`)
let fordFlow: number | null = null;
const fordNeed = (): number | null =>
fordHeightM(fordFlow, Number(fordForm.state().ford?.[0]?.width ?? ""));
const fordForm = createSpecForm(specLinks, {
hint: (c, item, state) =>
c.id === "ford" && item.id === "height"
? fordHeightHint(fordFlow, Number(state.ford?.[0]?.width ?? ""))
: "",
onChange: (c, item) => {
const need = c.id === "ford" ? fordNeed() : null;
const height = fordForm.state().ford?.[0]?.height ?? "";
const low = height !== "" && !(Number(height) >= (need ?? 0));
if (need !== null && (item.id === "width" || (item.id === "height" && low)))
fordForm.setValue("ford", "height", need.toFixed(2));
emit();
},
});
// ── 추가 기슭막이(다단) — 횡단도에서 다단 벽을 고를 때만 뜬다(2026-08-30 사용자
// 지시 4). 유입구·유출구와 같은 양식이되, 값은 조정창 사본의 행이 그대로 옮겨 온다
// (다단 벽은 폼에 자기 칸이 없다 — 높이·형태·구간값이 전부 조정 채널에 있다).
@@ -370,6 +378,8 @@ export function createFacilityOptionsForm(
);
const wingInFields = createWingFields("날개벽(유입)", WING_IN_KEYS);
const wingOutFields = createWingFields("날개벽(유출)", WING_OUT_KEYS);
const wingInHome = wingInFields.slot.parentElement!;
const wingOutHome = wingOutFields.slot.parentElement!;
const hiddenOptions = createHiddenOptions(root);
@@ -399,8 +409,8 @@ export function createFacilityOptionsForm(
// 월류 폭·높이 → 바닥 경사 → 수량 → 개략 단면 결과. 다른 시설에서는 전부 숨는다.
root.append(
saveNote,
pipeRow,
pipeForm.root,
fordForm.root,
ford.widthRow,
ford.slopeRow,
ford.countRow,
@@ -421,15 +431,18 @@ export function createFacilityOptionsForm(
if (current === null) return;
const isPipe = current === "pipe";
const isBox = current === "box_culvert";
pipeRow.hidden = current !== "ford_bridge";
const isBridge = current === "ford_bridge";
pipeForm.root.hidden = !isPipe;
fordForm.root.hidden = !isBridge;
boxWrap.hidden = !isBox;
// 날개벽은 BOX암거와 세월교가 같은 옵션 한 벌을 쓴다(2026-08-25 사용자 확정).
const hasWing = isBox || current === "ford_bridge";
// 날개벽 칸 = BOX암거 · 세월교는 유입 · 유출 구조 컨테이너(52-4) — B06 조정 행 자리만 그 컨테이너 끝으로
const hasWing = isBox;
wingInFields.root.hidden = !hasWing;
wingOutFields.root.hidden = !hasWing;
const isFord = current === "ford_pavement" || current === "ford_bridge";
ford.countRow.hidden = current !== "ford_bridge";
(isBridge ? fordForm.slot("inlet") : wingInHome).append(wingInFields.slot);
(isBridge ? fordForm.slot("outlet") : wingOutHome).append(wingOutFields.slot);
const isFord = current === "ford_pavement";
ford.countRow.hidden = true;
ford.widthRow.hidden = !isFord;
// 바닥 경사는 물넘이포장만 쓴다 — 세월교는 구체 위 노면이라 파임이 없다.
ford.slopeRow.hidden = current !== "ford_pavement";
@@ -462,8 +475,6 @@ export function createFacilityOptionsForm(
revetOutlet.fields.onChange(emit);
revetSide.addEventListener("change", emit);
[
pipeMaterial,
pipeDiameter,
boxSize,
boxWidth,
boxHeight,
@@ -500,8 +511,12 @@ export function createFacilityOptionsForm(
bindLinks(links) {
linkSource = links;
pipeForm.paintLinks();
fordForm.paintLinks();
},
paintLinks: () => {
pipeForm.paintLinks();
fordForm.paintLinks();
},
paintLinks: () => pipeForm.paintLinks(),
setChainage(chainageM) {
chainage = chainageM;
if (current === "pipe") pipeForm.refresh();
@@ -516,6 +531,7 @@ export function createFacilityOptionsForm(
current = facility;
hiddenOptions.remember(options);
ford.setDesignFlow(designFlow ?? null);
fordFlow = designFlow ?? null;
if (facility === null) {
syncVisibility();
return;
@@ -523,12 +539,11 @@ export function createFacilityOptionsForm(
const text = (key: string): string =>
options[key] !== undefined ? String(options[key]) : "";
// 받은 값만 칸에 — 없으면 「안 정함」(새 시설은 부르는 쪽이 등록부 기본값을 실어 줌 · 46-7).
pipeMaterial.value = text("pipe_kind");
pipeDiameter.value = text("pipe_diameter_mm");
if (facility === "pipe") {
const groups = Object.fromEntries((definition?.groups ?? []).map((g) => [g.id, g.name]));
const groups = Object.fromEntries((definition?.groups ?? []).map((g) => [g.id, g.name]));
if (facility === "pipe")
pipeForm.write(PIPE_SPEC, options, definition?.spec_names ?? {}, groups);
}
if (facility === "ford_bridge")
fordForm.write(FORD_SPEC, withLegacyGuards(options), definition?.spec_names ?? {}, groups);
// 저장된 폭·높이가 프리셋과 맞으면 그 프리셋을, 아니면 "사용자 지정"을 고른다.
// 값이 없으면 「안 정함」.
const savedWidth = Number.parseFloat(text("body_width_m"));
@@ -549,7 +564,7 @@ export function createFacilityOptionsForm(
boxHeight.value = hasSaved ? String(savedHeight) : "";
wingInFields.write(options);
wingOutFields.write(options);
if (facility === "ford_pavement" || facility === "ford_bridge") ford.write(facility, options);
if (facility === "ford_pavement") ford.write(facility, options);
revetSide.value = text("side");
const spread = legacyRevetOptions(options);
revetInlet.fields.write(spread);
@@ -558,15 +573,13 @@ export function createFacilityOptionsForm(
},
setDesignFlow(designFlow) {
ford.setDesignFlow(designFlow ?? null);
fordFlow = designFlow ?? null;
if (current === "ford_bridge") fordForm.refresh(); // 높이 안내
syncVisibility();
},
readOptions() {
const options: Record<string, string | number> = {};
// 고른 칸만 싣는다 — 「안 정함」은 빈 값(기본값을 몰래 확정으로 안 바꿈).
const putPipe = (): void => {
if (pipeDiameter.value) options.pipe_diameter_mm = Number(pipeDiameter.value);
if (pipeMaterial.value) options.pipe_kind = pipeMaterial.value;
};
if (current === "pipe") {
Object.assign(options, pipeForm.read()); // 명세 컨테이너 값 = 옛 키 그대로(PLAN 52-1)
} else if (current === "box_culvert") {
@@ -578,10 +591,7 @@ export function createFacilityOptionsForm(
} else if (current === "ford_pavement") {
ford.read(current, options);
} else if (current === "ford_bridge") {
putPipe();
ford.read(current, options);
wingInFields.read(options);
wingOutFields.read(options);
Object.assign(options, fordForm.read()); // 표 04 명세 값 · 옛 관종 · 날개벽 치수는 병합이 이어 붙임(52-4)
} else if (current === "revetment") {
if (revetSide.value) options.side = revetSide.value;
// 단 수는 폼이 갖지 않는다 — 횡단 설계 patch(extra_wall_counts)가 정본이고
@@ -42,14 +42,16 @@ export const FORM_OPTION_KEYS: Readonly<Record<string, readonly string[]>> = {
],
box_culvert: ["body_width_m", "body_height_m", ...wingKeys("in"), ...wingKeys("out")],
ford_pavement: ["ford_width_m", "ford_height_m", "ford_slope_pct", "thickness_cm", "length_m"],
// 세월교 = 표 04 명세 키(PLAN 52-4 · `design_structure_ford`) — 옛 관종 · 날개벽 치수는 폼에서 빠져 이어 붙임
ford_bridge: [
"pipe_kind",
"pipe_diameter_mm",
"pipe_count",
"ford_width_m",
"ford_height_m",
...wingKeys("in"),
...wingKeys("out"),
"inlet_type",
"outlet_type",
"wing_in",
"wing_out",
],
revetment: ["side", ...revetKeys("inlet"), ...revetKeys("outlet")],
};
@@ -17,6 +17,7 @@ import {
} from "./B05_Profile_UI_IrregularStations";
import { structureAnchorM, type StructureInstance } from "./B05_Profile_Api_Structures";
import { GROUP_LABELS } from "./B05_Profile_UI_Structures_Panel";
import { HIDDEN_PICK_GROUPS } from "./B05_Profile_UI_Structures_Panel_Types";
/** 우클릭 메뉴용 최소 타입 정보 — 사이드 「구조물 배치」 종류 목록과 같은 원천. */
export interface StructureMenuType {
@@ -32,7 +33,9 @@ export function structureGroupMenuItems(
types: ReadonlyArray<StructureMenuType>,
onPick: (typeId: string) => void,
): MapContextMenuItem[] {
const groups = [...new Set(types.map((type) => type.group))];
const groups = [...new Set(types.map((type) => type.group))].filter(
(group) => !HIDDEN_PICK_GROUPS.has(group),
);
return groups.map((group) => ({
label: GROUP_LABELS[group] ?? group,
children: types
@@ -78,7 +78,9 @@ export function createFacilityDefinition(root: HTMLElement): FacilityDefinition
root
.querySelectorAll(".is-rule-hidden")
.forEach((node) => node.classList.remove("is-rule-hidden"));
root.querySelectorAll<HTMLOptionElement>("option[hidden]").forEach((o) => (o.hidden = false));
root
.querySelectorAll<HTMLOptionElement>("option[hidden]:not([data-pick-hidden])")
.forEach((o) => (o.hidden = false));
paint();
},
};
@@ -24,7 +24,7 @@ import type { PipeFacility } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import { type FacilityAttributes } from "./B05_Profile_UI_Drainage_Facility";
import { field, optionDefaults } from "./B05_Profile_UI_Structures_Fields";
import {
hasSpecContainers,
hidesTypeSummary,
paintStale,
renderOptionFields as renderOptionFieldsInto,
} from "./B05_Profile_UI_Structures_Panel_Options";
@@ -54,6 +54,7 @@ export type { PipeFacilityItem, StructuresSection } from "./B05_Profile_UI_Struc
import {
DEFAULT_INTERVAL_LENGTH_M,
GROUP_LABELS,
groupOption,
hasComputedRange,
readBeforeOption,
readLengthOption,
@@ -227,7 +228,7 @@ export function createStructuresSection(
!!type && !managed && type.placement === "interval" && !hasComputedRange(type);
positionRow.hidden = !type;
positionDivider.hidden = !type;
summary.root.hidden = !type || hasSpecContainers(type); // 컨테이너 종류는 컨테이너마다 잇기(52-1)
summary.root.hidden = !type || hidesTypeSummary(type); // 컨테이너 종류는 컨테이너마다 잇기(52-1)
startFields.wrap.hidden = !isInterval;
endFields.wrap.hidden = !isInterval;
primary.disabled = !type;
@@ -544,7 +545,7 @@ export function createStructuresSection(
// 첫 항목은 빈칸 — 진입·리셋 상태에서 군·종류를 강제로 고르게 두지 않는다. 이름은 정의(48) 먼저.
groupSelect.replaceChildren(
new Option("— 선택 —", ""),
...[...groups].map(([g, name]) => new Option(name ?? GROUP_LABELS[g] ?? g, g)),
...[...groups].map(([g, name]) => groupOption(g, name ?? GROUP_LABELS[g] ?? g)),
);
groupSelect.value = "";
syncTypeOptions();
@@ -23,9 +23,10 @@ import { FORM_OPTION_KEYS } from "./B05_Profile_UI_Drainage_Facility_Merge";
import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe";
import { solveSplit, specKeys, type SplitRole } from "../A00_Common/design/design_structure_spec";
/** 컨테이너로 그리는 종류인지(명세 · 정의 그룹 상자) — 그때 종류 단위 「구조물 조합」 줄은 가림(PLAN 52-1 · 52-7). */
export const hasSpecContainers = (type: StructureType): boolean =>
!!STRUCTURE_SPECS[type.type_id]?.containers.length || !!type.groups?.length;
/** 종류 단위 「구조물 조합」 줄을 가릴 종류인지 — 구조물 명세 컨테이너 · 정의 그룹 상자(그룹마다 잇기 줄)로
* 그리는 종류(PLAN 52-1 · 52-6b · 52-7) · 종단배수(B군 · 조합 잇기 없음 · 표 07). */
export const hidesTypeSummary = (type: StructureType): boolean =>
!!STRUCTURE_SPECS[type.type_id]?.containers.length || !!type.groups?.length || type.group === "B";
/** 길이 = 전 + 후 를 배관 맞물림(`solveSplit` · 마지막에 고친 둘을 지킴)으로 푸는 종류(표 07 가드레일). */
const SOLVE_SPLIT = new Set(["guardrail"]);
@@ -34,6 +34,18 @@ export const GROUP_LABELS: Record<string, string> = {
호환: "기타",
};
/** 새로 놓을 때 고르기 목록(군 드롭다운 · 우클릭 메뉴)에서 뺄 군 — 종단배수·계류사방(표 07).
* 옛 값 · 도면 · 집계는 그대로이고 데이터도 지우지 않는다. */
export const HIDDEN_PICK_GROUPS: ReadonlySet<string> = new Set(["B", "D"]);
/** 군 드롭다운 항목 — 뺄 군은 가림(옛 항목을 열 때는 값으로 그대로 고를 수 있음). */
export function groupOption(group: string, name: string): HTMLOptionElement {
const option = new Option(name, group);
option.hidden = HIDDEN_PICK_GROUPS.has(group);
if (option.hidden) option.dataset.pickHidden = ""; // 정의 폼의 「가림 풀기」가 건드리지 않게
return option;
}
/** 구간형 신규 추가 시 기본 구간 길이(m). 사용자가 종점을 바로 고칠 수 있게 짧게 잡는다. */
export const DEFAULT_INTERVAL_LENGTH_M = 15;