- 구조물정의 판 16(판_15 이력): 유입 구조 기본 집수정 · 유출 형태 찰 · 유입 · 유출 높이 1.5 · 집수정 높이 1.5 새 칸 · 사면안정 흙막이 형태 찰 · 높이 1.5 - 엔진 짝(파이썬 · TS): 집수정 높이 빈 값 = 정의 기본값 - 횡단 기하: 자동 집수정 모양 = L형(DEFAULT_BASIN_SHAPE 한 곳) · ㄴ·ㄷ형을 못 세우는 지형만 I형 - 명세 기본값 칸(default) — 추가 흙막이 새 상자 = 찰 · 10 · 1.5 · 5 · 5 실제 값 · 이미 있는 상자 값 그대로 - B05 폼: 빈 숫자 칸 「값 (기본)」 · 빈 유입 구조 = 집수정 (기본) · 집수정 모양 L형 (기본) · 추가 흙막이 상자에도 [연동][경사] - [연동][경사] 단추 안 여백 줄임 · 한 줄(높이 31.7 그대로) - 시험: 기본값 바뀐 기대값 맞춤(culvert_set · structure_span · new_structure_defaults) 화면 확인(sub5_검증): 새 배관 = 관경 1000 · 집수정 · L형 (기본) · 1.5 · 유출 흙막이 찰 10 · 1.5 · 5 · 5 · 단 0 / 36+0 단 0→1 새 흙막이 1 = 찰 10 · 1.5 · 5 · 5 · 연동 · 경사 켬 / 사면안정 새 흙막이 = 찰 10 · 1.5 · 5 · 5 / 단추 여섯 31.7 한 줄 · 세션 캐시 비워 되돌림 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ
697 lines
34 KiB
TypeScript
697 lines
34 KiB
TypeScript
/* =============================================================================
|
|
* B05_Profile_UI_Drainage_Facility.ts
|
|
* 계곡 통과 시설(배관/BOX암거/물넘이포장/세월교)의 부속 옵션 서브폼 + 시설 보관소.
|
|
*
|
|
* 자동 배관이든 수동 배관이든 같은 구조물이다 — 별도 편집 UI를 두지 않고 사이드
|
|
* 「구조물 배치」 폼의 옵션 자리에 이 서브폼이 끼워진다(2026-08-17 사용자 지시).
|
|
* 시설 종류는 구조물 배치의 "종류" 드롭다운이, 위치(시작·기준·종료 측점)는 위치
|
|
* 칸이 담당하므로 여기에는 부속 옵션 필드만 있다.
|
|
*
|
|
* 배관 부속(PLAN 52-1): 사용자 구조물 표 01 명세 폼(`A00_Common/design/design_structure_pipe`)
|
|
* — 관 · 관 보호공(유입 · 유출) · 흙막이 추가 · 도수로/돌붙임 컨테이너. 저장 키는 옛 키 그대로 ·
|
|
* 옛 관종 · 날개벽 · 집수정 형식 칸은 화면에서 빠지고 값은 남음(지난 정의 값).
|
|
*
|
|
* 보호공(2026-08-17 사용자 지시): 구 "돌붙임 있음/없음 + 표면처리(찰/메)" 두 축을
|
|
* `돌붙임(찰)·돌붙임(메)·도수로` 한 축으로 합쳤다. 도수로·산비탈수로(B4)는 유출부에서
|
|
* 돌붙임 대신 쓰는 공종이라 별도 구조물이 아니라 이 선택지로 받는다. 치수는 돌붙임이면
|
|
* 면적(㎡), 도수로면 폭(m)이며 둘은 배타다.
|
|
* 유입·유출 길이가 정해지면 위치 칸(시작·종료 측점)을 기준 − 유입 ~ 기준 + 유출로
|
|
* 자동 채운다. 형식·치수 제원은 B06/B07 필수 승격 대상이라 보이되 비필수다.
|
|
*
|
|
* BOX암거 = 사용자 구조물 표 02 명세(PLAN 52-2 · `design_structure_box`) — 본체 규격 ·
|
|
* 유입 · 유출 구조. 저장 키는 body_width_m·body_height_m 숫자 · wing_in·wing_out 그대로.
|
|
* 날개벽 조각(createWingFields)은 숨김 — B06 조정 행 자리만 BOX · 세월교 명세 유입 · 유출 칸으로 옮겨 붙음.
|
|
*
|
|
* 짝: 시설 속성 붙이기 `carryIndices` ↔ 서버 `common_util/common_util_drainage_pipes.py`
|
|
* `carry_indices` — 같은 규칙(허용 오차 안 · 한 원본 한 번) · 거울 시험 test_b05_facility_carry.
|
|
* ========================================================================== */
|
|
|
|
import type {
|
|
DetailPipeInput,
|
|
PipeFacility,
|
|
PipeSource,
|
|
} from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
|
|
|
import {
|
|
createRevetSideGroup,
|
|
createWingFields,
|
|
grid,
|
|
group,
|
|
INLET_REVET_KEYS,
|
|
type InletStructureKind,
|
|
labeled,
|
|
numberInput,
|
|
optionalSelect,
|
|
OUTLET_REVET_KEYS,
|
|
stepper,
|
|
WING_IN_KEYS,
|
|
WING_OUT_KEYS,
|
|
} from "./B05_Profile_UI_Drainage_Facility_Fields";
|
|
import { revetChoiceRow } from "./B05_Profile_UI_Drainage_Revet_Choice";
|
|
import { createSpecForm, type SpecLinks } from "../A00_Common/design/design_structure_form";
|
|
import { branchOf } from "../A00_Common/design/design_structure_spec";
|
|
import { PIPE_SPEC } from "../A00_Common/design/design_structure_pipe";
|
|
import {
|
|
BOX_SPEC,
|
|
bodyOf,
|
|
bodyOptions,
|
|
boxOptionsIn,
|
|
} from "../A00_Common/design/design_structure_box";
|
|
import { FORD_SPEC, withLegacyGuards } from "../A00_Common/design/design_structure_ford";
|
|
import { fordHeightHint, fordHeightM } from "../A00_Common/design/design_structure_ford_calc";
|
|
import {
|
|
DEFAULT_BASIN_SHAPE,
|
|
pipeDrawn,
|
|
pipeExtraCount,
|
|
pipeLengthM,
|
|
setPipeExtraCount,
|
|
} from "../A00_Common/design/design_structure_pipe_calc";
|
|
import {
|
|
hasLinks,
|
|
linkFields,
|
|
type GroupItems,
|
|
type SummaryItem,
|
|
} from "../common_util/common_util_summary_item";
|
|
import { FORD_PAVEMENT_SPEC } from "../A00_Common/design/design_structure_ford_pavement";
|
|
import { createHiddenOptions } from "./B05_Profile_UI_Drainage_Facility_Hidden";
|
|
|
|
export type { InletStructureKind }; // 목록은 700줄 한계로 `_Fields` 로 옮김 — 부르던 곳은 그대로
|
|
|
|
/** 시설 확장 정보 한 건 — 관 지점(chainage)에 얹힌다. 배관(pipe)도 부속 옵션을
|
|
* 가지면 저장한다. */
|
|
export interface FacilityAttributes {
|
|
facility: PipeFacility;
|
|
start_m?: number;
|
|
end_m?: number;
|
|
options?: Record<string, string | number>;
|
|
/** 이 관이 뜻하는 구조물 집계표 항목(21-1 · options 밖) — null/없음 = 미지정. */
|
|
summary_item?: SummaryItem | null;
|
|
/** 옵션 그룹마다 고른 집계 항목(PLAN 46-3) — 없음 = 그룹 미지정. */
|
|
group_items?: GroupItems | null;
|
|
}
|
|
|
|
/** 확장 정보가 실제로 있는지 — 배관이면서 아무 부속도 없으면 보관할 이유가 없다. */
|
|
function hasExtras(attributes: FacilityAttributes): boolean {
|
|
if (hasLinks(attributes)) return true; // 집계 항목(본체 · 그룹)만 지정한 맨 배관도 보관한다
|
|
if (attributes.facility !== "pipe") return true;
|
|
if (attributes.start_m !== undefined || attributes.end_m !== undefined) return true;
|
|
return !!attributes.options && Object.keys(attributes.options).length > 0;
|
|
}
|
|
|
|
/** 속성을 붙이는 허용 오차(m) — 측점 반올림(0.01m)을 덮되 이웃 관에는 안 닿는 폭.
|
|
* 서버 `common_util_drainage_pipes.CARRY_TOLERANCE_M` 와 짝. */
|
|
export const CARRY_TOLERANCE_M = 0.05;
|
|
|
|
/** 관 측점마다 속성을 가져올 원본 번호(못 붙이면 null) — 허용 오차 안 가장 가까운 원본 ·
|
|
* 한 원본은 한 번만. 서버 `carry_indices` 와 짝(거울 시험 test_b05_facility_carry). */
|
|
export function carryIndices(
|
|
targets: readonly number[],
|
|
sources: readonly number[],
|
|
): Array<number | null> {
|
|
const free = new Set(sources.keys());
|
|
return targets.map((target) => {
|
|
let best: number | null = null;
|
|
for (const index of free) {
|
|
const gap = Math.abs(sources[index] - target);
|
|
if (gap <= CARRY_TOLERANCE_M && (best === null || gap < Math.abs(sources[best] - target)))
|
|
best = index;
|
|
}
|
|
if (best !== null) free.delete(best);
|
|
return best;
|
|
});
|
|
}
|
|
|
|
/** 재계산 왕복에서 시설 정보를 잃지 않게 측점으로 되붙이는 보관소 — 붙이기는 `carryIndices`
|
|
* (서버 `carry_facility_attributes` 와 같은 규칙) · 관을 옮기면 `move` · `follow` 가 속성을 옮김. */
|
|
export function createFacilityStore() {
|
|
let entries: Array<{ chainage: number; attributes: FacilityAttributes }> = [];
|
|
|
|
return {
|
|
/** 서버 응답으로 전체를 다시 채운다 — 응답이 정본이다. */
|
|
replaceFromResponse(
|
|
points: ReadonlyArray<{
|
|
chainage_m: number;
|
|
facility?: PipeFacility;
|
|
start_m?: number;
|
|
end_m?: number;
|
|
options?: Record<string, string | number>;
|
|
summary_item?: SummaryItem | null;
|
|
group_items?: GroupItems | null;
|
|
}>,
|
|
): void {
|
|
entries = points
|
|
.map((point) => {
|
|
// 응답이 정본이다 — 서버는 미지정이면 칸을 안 싣는다(`as_dict`). 예전엔 칸이 없으면 화면이 쥔
|
|
// 값을 이어받았는데, 횡단 보기에서 [해제]한 초안이 응답으로 돌아올 때 옛 지정이 되살아났다.
|
|
return {
|
|
chainage: point.chainage_m,
|
|
attributes: {
|
|
facility: (point.facility ?? "pipe") as PipeFacility,
|
|
start_m: point.start_m,
|
|
end_m: point.end_m,
|
|
options: point.options,
|
|
...linkFields(point),
|
|
} as FacilityAttributes,
|
|
};
|
|
})
|
|
.filter((entry) => hasExtras(entry.attributes));
|
|
},
|
|
/** 한 지점의 시설을 바꾼다(맨 배관으로 되돌리면 엔트리 삭제). */
|
|
set(chainageM: number, attributes: FacilityAttributes | null): void {
|
|
entries = entries.filter((entry) => Math.abs(entry.chainage - chainageM) > 0.005);
|
|
if (attributes && hasExtras(attributes)) {
|
|
entries.push({ chainage: chainageM, attributes });
|
|
}
|
|
},
|
|
get(chainageM: number): FacilityAttributes | null {
|
|
const hit = entries.find((entry) => Math.abs(entry.chainage - chainageM) <= 0.005);
|
|
return hit ? hit.attributes : null;
|
|
},
|
|
/** 옮긴 관의 속성을 새 측점으로 옮긴다(지도에서 끌기). */
|
|
move(fromM: number, toM: number): void {
|
|
const hit = entries.find((entry) => Math.abs(entry.chainage - fromM) <= 0.005);
|
|
if (hit) hit.chainage = toM;
|
|
},
|
|
/** 관 목록이 통째로 바뀔 때(되돌리기 · 구조물 폼) — 사라진 관 하나 · 새 관 하나면 옮긴 것으로
|
|
* 보고 속성을 따라 옮김. 둘 이상이 한꺼번에 바뀌면 옮기지 않음(제자리 관만 속성 유지). */
|
|
follow(before: readonly number[], after: readonly number[]): void {
|
|
const near = (list: readonly number[], value: number): boolean =>
|
|
list.some((other) => Math.abs(other - value) <= CARRY_TOLERANCE_M);
|
|
const gone = before.filter((value) => !near(after, value));
|
|
const added = after.filter((value) => !near(before, value));
|
|
if (gone.length === 1 && added.length === 1) this.move(gone[0], added[0]);
|
|
},
|
|
/** 요청 목록에 시설 정보를 되붙인다 — 허용 오차 안의 원본만 · 못 붙인 관은 속성 없음. */
|
|
attach(points: ReadonlyArray<{ chainage_m: number; source: PipeSource }>): DetailPipeInput[] {
|
|
const picks = carryIndices(
|
|
points.map((point) => point.chainage_m),
|
|
entries.map((entry) => entry.chainage),
|
|
);
|
|
return points.map((point, index) => {
|
|
const pick = picks[index];
|
|
if (pick === null) return { ...point };
|
|
const { summary_item: _s, group_items: _g, ...rest } = entries[pick].attributes;
|
|
// 미지정(null)은 칸을 안 싣는다 — 서버가 모르는 칸을 거절하는 동안에도 계산이 안 깨짐.
|
|
return { ...point, ...rest, ...linkFields(entries[pick].attributes) };
|
|
});
|
|
},
|
|
};
|
|
}
|
|
|
|
export type FacilityStore = ReturnType<typeof createFacilityStore>;
|
|
|
|
interface FacilityOptionsFormCallbacks {
|
|
/** 아무 옵션이나 바뀌었을 때 — 패널이 저장 흐름(수정 확정)을 안내하는 데 쓴다. */
|
|
onChange?: () => void;
|
|
}
|
|
|
|
export interface FacilityOptionsForm {
|
|
root: HTMLElement;
|
|
/** 시설 종류에 맞는 옵션 필드를 보여주고 값을 채운다. null이면 통째로 숨긴다.
|
|
* 독립 기슭막이(D4)는 레지스트리 옵션 방식으로 옮겨 서브폼 대상이 아니다(2026-08-19). */
|
|
setFacility: (
|
|
facility: PipeFacility | null,
|
|
options?: Record<string, string | number>,
|
|
/** 담당 유역의 설계유량(㎥/s) — 물넘이·세월교 개략 단면의 입력. 없으면 안내만. */
|
|
designFlow?: number | null,
|
|
/** 종류 정의 — 배관 명세 이름 바꿈(`spec_names`) · 그룹 이름(PLAN 52). */
|
|
definition?: {
|
|
spec_names?: Record<string, string>;
|
|
groups?: Array<{ id: string; name: string }>;
|
|
/** 옵션 정의 기본값 — 빈 고르기 칸이 그림 값을 보임(54-13). */
|
|
options?: Array<{ key: string; default: unknown }>;
|
|
} | null,
|
|
) => void;
|
|
/** 배관 명세 컨테이너 「구조물 조합」 잇기 자리 — 폼이 패널 그룹 값에 이음(PLAN 52-1). */
|
|
bindLinks: (links: SpecLinks) => void;
|
|
paintLinks: () => void;
|
|
/** 고른 관 측점 — 관 길이(자동) 계산에 씀. */
|
|
setChainage: (chainageM: number | null) => void;
|
|
/** 횡단 「추가 기슭막이(단)」이 바뀜 — 배관 흙막이 컨테이너 수를 다시 맞춤(54-1 · B06 이 부름). */
|
|
recount: () => void;
|
|
/** 현재 필드 값을 시설 옵션으로 읽는다(빈 값 생략). 숨긴 칸은 폼이 받은 옛 값 그대로. */
|
|
readOptions: () => Record<string, string | number>;
|
|
/** 「구조물 옵션 보이기」에서 끈 레지스트리 옵션 키 — 그 칸을 가린다(값은 옛 값 보존).
|
|
* `setFacility` 뒤에 부른다. */
|
|
setHiddenOptions: (facility: PipeFacility | null, registryKeys: Iterable<string>) => void;
|
|
/** 설계유량만 갱신한다 — 필드 값은 건드리지 않는다. 임시 배치(2026-08-18)의
|
|
* 유역 재계산 결과를 입력 중인 폼에 살짝 반영하는 용도. */
|
|
setDesignFlow: (designFlow: number | null) => void;
|
|
/** 조정창에서 옮겨 오는 행(단 수·옵션)이 붙을 자리 — B06만 채운다(2026-08-29 지시 4). */
|
|
inletSlot: HTMLElement;
|
|
outletSlot: HTMLElement;
|
|
/** 다단(추가) 기슭막이 행이 옮겨 붙는 자리 — 비면 그룹째 숨는다(2026-08-30 지시 4). */
|
|
extraSlot: HTMLElement;
|
|
/** 독립 기슭막이 좌·우 칸의 이식 자리 — 배관 유입구·유출구 자리와 같은 몫이다. */
|
|
revetInletSlot: HTMLElement;
|
|
revetOutletSlot: HTMLElement;
|
|
/** 세월교·BOX암거 날개벽 칸의 이식 자리 — 세월교 유입·유출 측벽을 고르면 이 칸이
|
|
* 선다(2026-08-30 사용자 지시 1: 배관 측점·기슭막이와 같게). */
|
|
wingInSlot: HTMLElement;
|
|
wingOutSlot: HTMLElement;
|
|
/** 유입구 "구조"에 합쳐진 B06 형식 값(2026-08-29 지시 5). B06이 조정창 값과 맞춘다. */
|
|
inletStructure: () => InletStructureKind;
|
|
setInletStructure: (value: InletStructureKind) => void;
|
|
/** 독립 기슭막이 좌·우 칸의 이름표를 그 측점 방향으로 맞춘다(B06 전용). */
|
|
setRevetSideLabels: (labels: { inlet: "좌" | "우"; outlet: "좌" | "우" }) => void;
|
|
/** 구조가 바뀔 때 B06 유입측 형식 제어에 알린다(B05는 등록하지 않는다). */
|
|
onInletStructureChange: (handler: (value: InletStructureKind) => void) => void;
|
|
}
|
|
|
|
/** 구조물 배치 폼의 옵션 자리에 끼워지는 계곡 통과 시설 부속 옵션 서브폼. */
|
|
export function createFacilityOptionsForm(
|
|
callbacks: FacilityOptionsFormCallbacks = {},
|
|
): FacilityOptionsForm {
|
|
const root = document.createElement("div");
|
|
root.className = "b05-drainage__facility";
|
|
root.hidden = true;
|
|
|
|
// ── 배관 = 사용자 구조물 표 01 명세(PLAN 52-1) — 컨테이너 다섯 · 옛 관종 · 날개벽 칸은 뺌(지난 정의 값) ──
|
|
let linkSource: SpecLinks | null = null;
|
|
let chainage: number | null = null;
|
|
const specLinks: SpecLinks = {
|
|
get: () => linkSource?.get() ?? {},
|
|
pick: (key) => linkSource?.pick(key),
|
|
clear: (key) => linkSource?.clear(key),
|
|
set: (key, item) => linkSource?.set(key, item),
|
|
};
|
|
// 그림이 쓰는 값(54-13) — 빈 고르기 칸 = 정의 기본값(엔진 · 횡단도가 그 값으로 그림) · 보여 주기만
|
|
let defaults: Record<string, unknown> = {};
|
|
const byDefault = (key: string | undefined): { value: string; tag: string } | null => {
|
|
const value = key ? defaults[key] : null;
|
|
return value === null || value === undefined || value === ""
|
|
? null
|
|
: { value: String(value), tag: "기본" };
|
|
};
|
|
/** 빈 날개벽 설치 = 정의 기본 설치(옛 「있음」 · 빈 값 = 날개벽 · 엔진 `_wing_spec`). */
|
|
const wingDefault = (key: string): { value: string; tag: string } => {
|
|
const value = String(defaults[key] ?? "");
|
|
return { value: ["없음", "흙막이"].includes(value) ? value : "날개벽", tag: "기본" };
|
|
};
|
|
// 유입 「자동(규칙)」 = 기슭막이 + 유입 형식 auto(옛 값 · 정의 기본) — 그림은 지형 규칙이 집수정 · 흙막이를
|
|
// 정함. 사용자가 유입 구조를 고를 때까지 저장도 그대로(흙막이 고름의 형식 revet 을 안 적음)
|
|
let inletAuto: { structure?: string | number } | null = null;
|
|
const inletIsAuto = (): boolean =>
|
|
!!inletAuto && pipeForm.state().inlet?.[0]?.structure === "기슭막이";
|
|
// 그림 계산은 한 번 그릴 동안 한 번만(칸마다 부름)
|
|
let drawnMemo: ReturnType<typeof pipeDrawn> | undefined;
|
|
const drawnNow = (): ReturnType<typeof pipeDrawn> => {
|
|
if (drawnMemo === undefined) {
|
|
drawnMemo = pipeDrawn(chainage);
|
|
queueMicrotask(() => (drawnMemo = undefined));
|
|
}
|
|
return drawnMemo;
|
|
};
|
|
// 유출 「추가 흙막이(단)」(54-27) — 값 = 횡단 조정 단 수(같은 저장 자리) · 흙막이 상자 수가 따라감
|
|
const tiersInput = numberInput("1", "0", "횡단 설계 뒤");
|
|
tiersInput.max = "9";
|
|
const tiersRow = labeled("추가 흙막이(단)", stepper(tiersInput, 1, 0));
|
|
tiersRow.dataset.specItem = "outlet.tiers";
|
|
tiersRow
|
|
.querySelectorAll("button")
|
|
.forEach((b, i) => (b.title = `1단 ${i ? "늘리기" : "줄이기"}`));
|
|
const paintTiers = (values: Record<string, string>): HTMLElement => {
|
|
const count = pipeExtraCount(chainage);
|
|
tiersInput.value = count === null ? "" : String(count);
|
|
tiersInput.disabled = count === null;
|
|
tiersRow.hidden = !!values.structure && values.structure !== "기슭막이";
|
|
return tiersRow;
|
|
};
|
|
tiersInput.addEventListener("change", () => {
|
|
const count = Math.max(0, Math.min(9, Math.round(Number(tiersInput.value) || 0)));
|
|
// 지형이 못 세운 단은 횡단 기하가 자름 — 끝난 뒤 세운 만큼으로 다시 그림
|
|
void setPipeExtraCount(chainage, count).then(() => pipeForm.recount());
|
|
});
|
|
const inletChoice = revetChoiceRow(
|
|
() => chainage,
|
|
() => pipeForm.refresh(),
|
|
);
|
|
const outletChoice = revetChoiceRow(
|
|
() => chainage,
|
|
() => pipeForm.refresh(),
|
|
);
|
|
const pipeForm = createSpecForm(specLinks, {
|
|
// 관 보호공 [연동] · [경사](54-30) — 유입 · 유출 상자 각각 한 줄(같은 값) · 집수정이면 숨김
|
|
fields: (c, values) =>
|
|
c.id === "outlet"
|
|
? [paintTiers(values), outletChoice.paint(false)]
|
|
: c.id === "inlet"
|
|
? [inletChoice.paint(values.structure === "집수정")]
|
|
: c.id === "soil_add" // 추가 흙막이 상자도 같은 줄(54-36 · 상자마다 한 줄 · 같은 값)
|
|
? [
|
|
revetChoiceRow(
|
|
() => chainage,
|
|
() => pipeForm.refresh(),
|
|
).paint(false),
|
|
]
|
|
: [],
|
|
shown: (c, item, values, index) => {
|
|
if (c.id === "inlet" && item.id === "structure" && inletIsAuto()) {
|
|
const drawn = drawnNow();
|
|
return drawn
|
|
? { value: drawn.basin ? "집수정" : "기슭막이", tag: "자동" }
|
|
: { value: "", tag: "자동(지형 규칙)" };
|
|
}
|
|
if (values[item.id]) return null;
|
|
if (item.id !== "form") return byDefault(branchOf(item, values)?.key);
|
|
const basin = c.id === "inlet" && values.structure === "집수정";
|
|
const drawn = drawnNow();
|
|
const form = basin
|
|
? (drawn?.basin ?? DEFAULT_BASIN_SHAPE)
|
|
: c.id === "soil_add"
|
|
? drawn?.extras[index]
|
|
: drawn?.forms[c.id];
|
|
return form ? { value: form, tag: "기본" } : null;
|
|
},
|
|
auto: (_c, item) => {
|
|
if (item.id !== "length") return "";
|
|
const length = pipeLengthM(chainage);
|
|
return length === null ? "횡단 설계 뒤 셈" : String(Math.round(length));
|
|
},
|
|
// 흙막이 개수 = 유출 「추가 기슭막이(단)」(54-1)
|
|
count: (c) => (c.id === "soil_add" ? pipeExtraCount(chainage) : undefined),
|
|
hint: (c, item, state) => {
|
|
if (item.default) return ""; // 명세 기본값 안내는 폼이 그림(추가 흙막이 · 54-36)
|
|
// 빈 칸 = 정의 기본값 「값 (기본)」(54-36) — 구조가 비었으면 기본 구조의 칸
|
|
const raw = state[c.id]?.[0] ?? {};
|
|
const top = c.items.find((i) => i.id === "structure");
|
|
const values =
|
|
raw.structure || !top ? raw : { ...raw, structure: byDefault(top.key)?.value ?? "" };
|
|
const fallback = item.kind === "number" ? byDefault(branchOf(item, values)?.key) : null;
|
|
return fallback ? `${fallback.value} (기본)` : "";
|
|
},
|
|
onChange: (c, item) => {
|
|
if (c.id === "inlet" && item.id === "structure") inletAuto = null;
|
|
if (c.id === "inlet" && (item.id === "structure" || item.id === "form"))
|
|
inletStructureHandler?.(inletStructureOf());
|
|
emit();
|
|
// 고른 뒤 그림이 바뀜 — 자동 표시를 새 그림으로 다시 그림(54-13)
|
|
if (c.id === "inlet") {
|
|
drawnMemo = undefined;
|
|
pipeForm.refresh();
|
|
}
|
|
},
|
|
});
|
|
// 조정창에서 옮겨 오는 행(단 수·옵션)이 붙을 자리 — 유입 · 유출 컨테이너 끝(2026-08-29 지시 4)
|
|
const inletSlot = pipeForm.slot("inlet");
|
|
const outletSlot = pipeForm.slot("outlet");
|
|
/** 유입 보호공 구조 → B06 유입측 형식 값(조정창과 같은 값). */
|
|
const inletStructureOf = (): InletStructureKind => {
|
|
const inlet = pipeForm.state().inlet?.[0] ?? {};
|
|
if (inlet.structure === "기슭막이") return "revet";
|
|
return inlet.structure === "집수정" && ["I", "L", "U"].includes(inlet.form ?? "")
|
|
? (inlet.form as InletStructureKind)
|
|
: "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, {
|
|
shown: (c, item, values) => {
|
|
if (values[item.id]) return null;
|
|
if (c.id !== "ford") return wingDefault(c.id === "inlet" ? "wing_in" : "wing_out");
|
|
return item.id === "diameter" ? byDefault("pipe_diameter_mm") : null;
|
|
},
|
|
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). 유입구·유출구와 같은 양식이되, 값은 조정창 사본의 행이 그대로 옮겨 온다
|
|
// (다단 벽은 폼에 자기 칸이 없다 — 높이·형태·구간값이 전부 조정 채널에 있다).
|
|
const extraGroup = group("추가 기슭막이");
|
|
const extraSlot = document.createElement("div");
|
|
extraSlot.className = "b05-structure__adjust-slot";
|
|
extraGroup.body.append(extraSlot);
|
|
extraGroup.root.hidden = true;
|
|
|
|
// ── 독립 기슭막이(2026-08-28 이관: 배관처럼 pipe_points 관리) ─────────────
|
|
// 설치 측은 **컨테이너 밖**(배관의 관종·관경과 같은 층), 벽 제원은 좌·우 두 칸으로
|
|
// 나눈다(2026-08-30 사용자). 설치 측이 한쪽이면 그 칸만 뜬다.
|
|
// 좌·우가 저장 채널(유입/유출) 중 어느 쪽인지는 측점 지형이 정하므로 이름표만
|
|
// 바꿔 단다(`setRevetSideLabels`) — 값은 언제나 그 채널 키로 오간다.
|
|
const revetSide = optionalSelect("— 안 정함 —", ["양쪽", "좌", "우"]);
|
|
const revetTopRow = grid(labeled("설치 측", revetSide, "side"));
|
|
const revetInlet = createRevetSideGroup(INLET_REVET_KEYS);
|
|
const revetOutlet = createRevetSideGroup(OUTLET_REVET_KEYS);
|
|
/** 좌·우 이름표 — B06이 그 측점의 벽 방향을 알려 주면 바뀐다. 기본은 유입=좌. */
|
|
let revetSideLabels: { inlet: "좌" | "우"; outlet: "좌" | "우" } = {
|
|
inlet: "좌",
|
|
outlet: "우",
|
|
};
|
|
/** 옛 전용 키(`form`·`length_m`…)와 한 벌 저장분을 **양쪽 칸**으로 펼쳐 읽는다. */
|
|
const legacyRevetOptions = (
|
|
options: Record<string, string | number>,
|
|
): Record<string, string | number> => {
|
|
const merged = { ...options };
|
|
const pairs: Array<[string, string, string]> = [
|
|
["outlet_revet_form", "inlet_revet_form", "form"],
|
|
["outlet_revet_length_m", "inlet_revet_length_m", "length_m"],
|
|
["outlet_revet_height_m", "inlet_revet_height_m", "height_m"],
|
|
["outlet_revet_before_m", "inlet_revet_before_m", "before_m"],
|
|
["outlet_revet_after_m", "inlet_revet_after_m", "after_m"],
|
|
];
|
|
for (const [outletKey, inletKey, legacy] of pairs) {
|
|
if (merged[outletKey] === undefined && options[legacy] !== undefined) {
|
|
merged[outletKey] = options[legacy];
|
|
}
|
|
// 한쪽만 저장된 옛 자료 — 반대쪽 칸도 같은 값으로 세운다.
|
|
if (merged[inletKey] === undefined && merged[outletKey] !== undefined) {
|
|
merged[inletKey] = merged[outletKey];
|
|
}
|
|
if (merged[outletKey] === undefined && merged[inletKey] !== undefined) {
|
|
merged[outletKey] = merged[inletKey];
|
|
}
|
|
}
|
|
return merged;
|
|
};
|
|
|
|
// ── BOX암거 = 사용자 구조물 표 02 명세(PLAN 52-2) — 본체 · 유입 구조 · 유출 구조 ──
|
|
const boxForm = createSpecForm(specLinks, {
|
|
onChange: () => emit(),
|
|
shown: (c, item, values) => {
|
|
if (values[item.id]) return null;
|
|
if (c.id !== "body") return wingDefault(c.id === "inlet" ? "wing_in" : "wing_out");
|
|
const size = bodyOf({
|
|
body_width_m: Number(defaults.body_width_m ?? 2),
|
|
body_height_m: Number(defaults.body_height_m ?? 2),
|
|
}).size;
|
|
return item.id === "size" && size ? { value: size, tag: "기본" } : null;
|
|
},
|
|
});
|
|
// 날개벽 칸 — 세월교 · BOX 가 같은 조각을 쓰던 자리 · 조정 행은 명세 유입 · 유출 칸으로 옮겨 붙음
|
|
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);
|
|
|
|
// ── 물넘이 포장 = 사용자 구조물 표 03 명세(PLAN 52-3) — 경사각도 · 도수로/돌붙임 ·
|
|
// 옛 월류 폭 · 높이 · 바닥 경사 · 두께 · 길이 칸은 뺌(지난 정의 값) ──
|
|
const paveForm = createSpecForm(specLinks, { onChange: () => emit() });
|
|
|
|
// 10-A ⑲ 저장 규칙 글은 [?] 설명서 `구조물_<시설>.md` 로 옮김(54-2) — 병합은 `_Drainage_Facility_Merge`.
|
|
|
|
// 세월교·물넘이 항목은 **관종·관경 바로 다음**에 둔다(2026-08-30 사용자 지시 2) —
|
|
// 월류 폭·높이 → 바닥 경사 → 수량 → 개략 단면 결과. 다른 시설에서는 전부 숨는다.
|
|
root.append(
|
|
pipeForm.root,
|
|
fordForm.root,
|
|
paveForm.root,
|
|
extraGroup.root,
|
|
revetTopRow,
|
|
revetInlet.root,
|
|
revetOutlet.root,
|
|
boxForm.root,
|
|
wingInFields.root,
|
|
wingOutFields.root,
|
|
);
|
|
|
|
let current: PipeFacility | null = null;
|
|
|
|
function syncVisibility(): void {
|
|
root.hidden = current === null;
|
|
if (current === null) return;
|
|
const isPipe = current === "pipe";
|
|
const isBox = current === "box_culvert";
|
|
const isBridge = current === "ford_bridge";
|
|
pipeForm.root.hidden = !isPipe;
|
|
boxForm.root.hidden = !isBox;
|
|
fordForm.root.hidden = !isBridge;
|
|
// 날개벽 칸 = 옛 자리 — BOX(52-2) · 세월교(52-4)는 명세 유입 · 유출 구조 컨테이너 · B06 조정 행 자리만 그 컨테이너 끝으로
|
|
const spec = isBox ? boxForm : isBridge ? fordForm : null;
|
|
wingInFields.root.hidden = true;
|
|
wingOutFields.root.hidden = true;
|
|
(spec ? spec.slot("inlet") : wingInHome).append(wingInFields.slot);
|
|
(spec ? spec.slot("outlet") : wingOutHome).append(wingOutFields.slot);
|
|
paveForm.root.hidden = current !== "ford_pavement";
|
|
const isRevet = current === "revetment";
|
|
revetTopRow.hidden = !isRevet;
|
|
// 설치 측이 한쪽이면 그쪽 칸만 남긴다 — 이름표는 측점이 정한 좌·우다(안 정함 = 양쪽처럼 보임).
|
|
const side = revetSide.value || "양쪽";
|
|
revetInlet.root.hidden = !isRevet || (side !== "양쪽" && side !== revetSideLabels.inlet);
|
|
revetOutlet.root.hidden = !isRevet || (side !== "양쪽" && side !== revetSideLabels.outlet);
|
|
revetInlet.legend.textContent = `기슭막이 (${revetSideLabels.inlet})`;
|
|
revetOutlet.legend.textContent = `기슭막이 (${revetSideLabels.outlet})`;
|
|
// 추가 기슭막이 칸은 **내용이 있을 때만** 선다 — 고른 다단 벽의 행이 여기 오면 뜨고,
|
|
// 선택이 풀리면 스스로 사라진다.
|
|
extraGroup.root.hidden = extraSlot.childElementCount === 0;
|
|
}
|
|
|
|
function emit(): void {
|
|
syncVisibility();
|
|
callbacks.onChange?.();
|
|
}
|
|
|
|
revetInlet.fields.onChange(emit);
|
|
revetOutlet.fields.onChange(emit);
|
|
revetSide.addEventListener("change", emit);
|
|
[...wingInFields.inputs, ...wingOutFields.inputs].forEach((input) =>
|
|
input.addEventListener("change", emit),
|
|
);
|
|
|
|
/** 유입구 구조 변경 수신자(B06 유입측 형식 제어). B05는 비워 둔다. */
|
|
let inletStructureHandler: ((value: InletStructureKind) => void) | null = null;
|
|
|
|
return {
|
|
root,
|
|
inletSlot,
|
|
outletSlot,
|
|
extraSlot,
|
|
revetInletSlot: revetInlet.slot,
|
|
revetOutletSlot: revetOutlet.slot,
|
|
wingInSlot: wingInFields.slot,
|
|
wingOutSlot: wingOutFields.slot,
|
|
setRevetSideLabels(labels) {
|
|
revetSideLabels = labels;
|
|
syncVisibility();
|
|
},
|
|
inletStructure: inletStructureOf,
|
|
setInletStructure(value) {
|
|
// 조정창 형식 → 유입 보호공 구조 · 형태(자동 = 고른 구조는 지키고 형태만 비움)
|
|
if (value === "revet") {
|
|
inletAuto = null;
|
|
pipeForm.setValue("inlet", "structure", "기슭막이");
|
|
} else if (value !== "auto") pipeForm.setValue("inlet", "structure", "집수정");
|
|
if (value !== "revet" && pipeForm.state().inlet?.[0]?.structure === "집수정")
|
|
pipeForm.setValue("inlet", "form", value === "auto" ? "" : value);
|
|
syncVisibility();
|
|
},
|
|
bindLinks(links) {
|
|
linkSource = links;
|
|
pipeForm.paintLinks();
|
|
boxForm.paintLinks();
|
|
fordForm.paintLinks();
|
|
paveForm.paintLinks();
|
|
},
|
|
paintLinks: () => {
|
|
pipeForm.paintLinks();
|
|
boxForm.paintLinks();
|
|
fordForm.paintLinks();
|
|
paveForm.paintLinks();
|
|
},
|
|
setChainage(chainageM) {
|
|
chainage = chainageM;
|
|
if (current === "pipe") pipeForm.refresh();
|
|
},
|
|
recount() {
|
|
if (current === "pipe") pipeForm.recount();
|
|
},
|
|
onInletStructureChange(handler) {
|
|
inletStructureHandler = handler;
|
|
},
|
|
setHiddenOptions(facility, registryKeys) {
|
|
hiddenOptions.set(facility, registryKeys);
|
|
},
|
|
setFacility(facility, options = {}, designFlow = null, definition = null) {
|
|
current = facility;
|
|
hiddenOptions.remember(options);
|
|
fordFlow = designFlow ?? null;
|
|
if (facility === null) {
|
|
syncVisibility();
|
|
return;
|
|
}
|
|
const text = (key: string): string =>
|
|
options[key] !== undefined ? String(options[key]) : "";
|
|
// 받은 값만 칸에 — 없으면 「안 정함」(새 시설은 부르는 쪽이 등록부 기본값을 실어 줌 · 46-7).
|
|
const groups = Object.fromEntries((definition?.groups ?? []).map((g) => [g.id, g.name]));
|
|
const names = definition?.spec_names ?? {};
|
|
defaults = Object.fromEntries((definition?.options ?? []).map((o) => [o.key, o.default]));
|
|
if (facility === "pipe") {
|
|
// 정의 파일의 옛 이름 「흙막이 추가」 — 명세 이름 「흙막이」가 앞섬(54-1)
|
|
const { soil_add: _old, ...pipeNames } = names;
|
|
const kind = options.inlet_structure;
|
|
inletAuto =
|
|
options.inlet_type === "기슭막이" &&
|
|
!["revet", "I", "L", "U"].includes(String(kind ?? ""))
|
|
? { structure: kind }
|
|
: null;
|
|
pipeForm.write(PIPE_SPEC, options, pipeNames, groups);
|
|
}
|
|
if (facility === "box_culvert") {
|
|
// 본체 규격은 저장 안 함 — 폭 · 높이로 되찾음 · 옛 설치 「있음」 = 날개벽
|
|
boxForm.write(BOX_SPEC, boxOptionsIn(options), names, groups);
|
|
for (const [id, value] of Object.entries(bodyOf(options)))
|
|
boxForm.setValue("body", id, value);
|
|
}
|
|
if (facility === "ford_bridge")
|
|
fordForm.write(FORD_SPEC, withLegacyGuards(options), names, groups);
|
|
if (facility === "ford_pavement") paveForm.write(FORD_PAVEMENT_SPEC, options, names, groups);
|
|
wingInFields.write(options);
|
|
wingOutFields.write(options);
|
|
revetSide.value = text("side");
|
|
const spread = legacyRevetOptions(options);
|
|
revetInlet.fields.write(spread);
|
|
revetOutlet.fields.write(spread);
|
|
syncVisibility();
|
|
},
|
|
setDesignFlow(designFlow) {
|
|
fordFlow = designFlow ?? null;
|
|
if (current === "ford_bridge") fordForm.refresh(); // 높이 안내
|
|
syncVisibility();
|
|
},
|
|
readOptions() {
|
|
const options: Record<string, string | number> = {};
|
|
// 고른 칸만 싣는다 — 「안 정함」은 빈 값(기본값을 몰래 확정으로 안 바꿈).
|
|
if (current === "pipe") {
|
|
Object.assign(options, pipeForm.read()); // 명세 컨테이너 값 = 옛 키 그대로(PLAN 52-1)
|
|
// 자동(규칙)은 고를 때까지 그대로 — 보여 주기만으로 파일이 안 바뀜(54-13)
|
|
if (inletIsAuto()) {
|
|
if (inletAuto!.structure === undefined) delete options.inlet_structure;
|
|
else options.inlet_structure = inletAuto!.structure;
|
|
}
|
|
} else if (current === "box_culvert") {
|
|
// 정본은 폭 · 높이 두 칸 — 규격이면 규격 수 · 사용자 입력이면 명세 숫자 칸(PLAN 52-2)
|
|
Object.assign(options, boxForm.read(), bodyOptions(boxForm.state().body?.[0]));
|
|
} else if (current === "ford_pavement") {
|
|
Object.assign(options, paveForm.read()); // 표 03 명세 값 · 옛 월류 · 경사 · 두께 · 길이는 병합이 이어 붙임(52-3)
|
|
} else if (current === "ford_bridge") {
|
|
Object.assign(options, fordForm.read()); // 표 04 명세 값 · 옛 관종 · 날개벽 치수는 병합이 이어 붙임(52-4)
|
|
} else if (current === "revetment") {
|
|
if (revetSide.value) options.side = revetSide.value;
|
|
// 단 수는 폼이 갖지 않는다 — 횡단 설계 patch(extra_wall_counts)가 정본이고
|
|
// 조작은 [추가 기슭막이(단)] 행이 한다(2026-08-30 사용자: 중복이라 삭제).
|
|
// 좌·우가 각자 값을 갖는다 — 저장 채널은 유입/유출 키 그대로다(2026-08-30).
|
|
revetInlet.fields.read(options);
|
|
revetOutlet.fields.read(options);
|
|
}
|
|
hiddenOptions.restore(options); // 숨긴 칸은 폼이 받은 옛 값 그대로
|
|
return options;
|
|
},
|
|
};
|
|
}
|