diff --git a/A00_Common/design/design_structure_form.ts b/A00_Common/design/design_structure_form.ts new file mode 100644 index 000000000..e6df8ec91 --- /dev/null +++ b/A00_Common/design/design_structure_form.ts @@ -0,0 +1,253 @@ +/* ============================================================================= + * design_structure_form.ts + * 구조물 명세(`design_structure_spec`) 그리기 — B05 · B06 「구조물 배치」 칸이 같은 노드를 씀. + * + * · 필수 컨테이너 = 늘 보임(한 벌) · 선택 컨테이너 = [추가] 로 인스턴스를 늘리고 인스턴스마다 [빼기] + * · 항목: 고르기(상위 값에 따른 선택지 · 저장 키) · 숫자(맞물림 = 길이 · 전 · 후) · 자동(읽기만) + * · 인스턴스마다 「구조물 조합」 잇기 줄(`buildSummaryRow` · 고르기 모달 = 패널의 그룹 [고르기] 길 그대로) — + * 값은 패널 `group_items` 에 `linkOf` 키로 둠(빼면 뒤 인스턴스 잇기를 한 칸씩 당김) + * · 값은 폼이 쥠 — 저장은 패널이 `read()` 로 읽어 정본 자리(옵션)에 씀 + * · 칸 표식 = `data-spec-item="{컨테이너}.{항목}"`(정의 규칙 · 그룹 상자가 안 건드리게 옵션 키 표식 안 씀) + * ========================================================================== */ + +import { + branchOf, + choiceLabel, + choicesOf, + containerTitle, + itemLabel, + linkOf, + readState, + solveSplit, + writeOptions, + type SpecContainer, + type SpecItem, + type SpecNames, + type SpecState, + type SplitRole, + type StructureSpec, +} from "./design_structure_spec"; +import { + group, + grid, + labeled, + numberInput, + stepper, +} from "../../B05_Profile/B05_Profile_UI_Drainage_Facility_Fields"; +import { buildSummaryRow } from "../../B05_Profile/B05_Profile_UI_Structures_Summary_Row"; +import type { GroupItems, SummaryItem } from "../../common_util/common_util_summary_item"; + +/** 잇기 값 자리 — 패널 그룹 값(`group_items`)을 그대로 씀. */ +export interface SpecLinks { + get: () => GroupItems; + pick: (key: string) => void; + /** [해제] — 패널이 풀고 반영. */ + clear: (key: string) => void; + /** 값만 옮김(빼기 뒤 당김) — 반영은 뒤이은 폼 변경 알림이 함. */ + set: (key: string, item: SummaryItem | null) => void; +} + +export interface SpecFormHooks { + /** 자동 항목 값 글(빈 글 = 「—」). */ + auto?: (c: SpecContainer, item: SpecItem, state: SpecState) => string; + /** 숫자 칸 빈 칸 안내. */ + hint?: (c: SpecContainer, item: SpecItem, state: SpecState) => string; + onChange?: (c: SpecContainer, item: SpecItem) => void; +} + +export interface SpecForm { + root: HTMLElement; + /** 명세 · 저장 값으로 다시 그림. `names` = 정의 이름 바꿈 · `groupNames` = 정의 그룹 이름. */ + write: ( + spec: StructureSpec, + options: Record, + names?: SpecNames, + groupNames?: Record, + ) => void; + read: () => Record; + state: () => SpecState; + /** 한 항목 값을 바꿔 그림(B06 조정창 맞춤 등) — change 알림은 안 함. */ + setValue: (containerId: string, itemId: string, value: string) => void; + /** 필수 컨테이너 끝에 붙일 자리(B06 조정 행). */ + slot: (containerId: string) => HTMLElement; + /** 잇기 값이 바뀌었을 때 줄을 다시 그림. */ + paintLinks: () => void; + refresh: () => void; +} + +export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = {}): SpecForm { + const root = document.createElement("div"); + root.className = "b05-structure__spec"; + let spec: StructureSpec | null = null; + let state: SpecState = {}; + let names: SpecNames = {}; + let groupNames: Record = {}; + const recent = new Map(); + const slots = new Map(); + const rows: Array<{ key: string; set: (item: SummaryItem | null) => void }> = []; + type Instance = Record; + + const slotOf = (id: string): HTMLElement => { + let slot = slots.get(id); + if (!slot) { + slot = document.createElement("div"); + slot.className = "b05-structure__adjust-slot"; + slots.set(id, slot); + } + return slot; + }; + + const control = (c: SpecContainer, item: SpecItem, values: Instance): HTMLElement => { + if (item.kind === "select") { + const select = document.createElement("select"); + const choices = choicesOf(item, values); + select.replaceChildren( + new Option("— 안 정함 —", ""), + ...choices.map((ch) => new Option(choiceLabel(c, item, ch, names), ch.value)), + ); + const now = values[item.id] ?? ""; + // 선택지 밖 옛 값은 그 값만 남겨 보임(지운 값을 몰래 바꾸지 않음) + if (now && !choices.some((ch) => ch.value === now)) select.add(new Option(now, now)); + select.value = now; + return select; + } + const input = numberInput(String(item.step ?? 0.1), "0", hooks.hint?.(c, item, state) ?? ""); + if (item.kind === "auto") { + input.type = "text"; + input.readOnly = true; + input.value = hooks.auto?.(c, item, state) || "—"; + input.title = "자동 계산 — 고치지 않음"; + return input; + } + input.value = values[item.id] ?? ""; + return stepper(input, item.step ?? 0.1); + }; + + const onEdit = (c: SpecContainer, item: SpecItem, values: Instance, raw: string): void => { + values[item.id] = raw; + if (item.split) { + const order = [item.split, ...(recent.get(values) ?? []).filter((r) => r !== item.split)]; + recent.set(values, order); + const role = (r: SplitRole) => c.items.find((i) => i.split === r)!; + const solved = solveSplit( + { + length: values[role("length").id], + before: values[role("before").id], + after: values[role("after").id], + }, + order, + ); + for (const r of ["length", "before", "after"] as SplitRole[]) + values[role(r).id] = solved[r] ?? ""; + } + // 위 항목이 바뀌면 갈래(선택지 · 저장 키)가 달라짐 — 선택지 밖 값은 비움 + for (const child of c.items.filter((i) => i.by?.item === item.id)) { + const allowed = choicesOf(child, values); + if (child.kind === "select" && !allowed.some((ch) => ch.value === values[child.id])) + values[child.id] = ""; + } + paint(); + hooks.onChange?.(c, item); + }; + + const instanceBox = (c: SpecContainer, values: Instance, index: number): HTMLElement => { + const title = containerTitle(c, names, groupNames[c.link]); + const box = group(c.required ? title : `${title} ${index + 1}`); + box.root.dataset.specContainer = c.id; + const fields = c.items + .filter((item) => item.kind === "auto" || !item.by || branchOf(item, values)) + .map((item) => { + const input = control(c, item, values); + const wrap = labeled(itemLabel(c, item, names), input); + wrap.dataset.specItem = `${c.id}.${item.id}`; + const target = input.querySelector?.("input, select") ?? input; + target.addEventListener("change", () => + onEdit(c, item, values, (target as HTMLInputElement | HTMLSelectElement).value), + ); + return wrap; + }); + box.body.append(grid(...fields)); + if (c.required) box.body.append(slotOf(c.id)); + if (links) { + const key = linkOf(c, index + 1); + const row = buildSummaryRow(); + row.root.querySelector(".b05-structure__summary-label")!.textContent = "구조물 조합"; + row.root.dataset.specLink = key; + row.set(links.get()[key] ?? null); + row.bind({ pick: () => links.pick(key), clear: () => links.clear(key) }); + rows.push({ key, set: row.set }); + box.body.append(row.root); + } + if (!c.required) { + const remove = document.createElement("button"); + remove.type = "button"; + remove.className = "b05-route__irregular-btn"; + remove.textContent = "빼기"; + remove.dataset.specRemove = c.id; + remove.addEventListener("click", () => { + const list = state[c.id]; + // 잇기는 인스턴스 차례를 따름 — 뒤 것을 한 칸씩 당기고 마지막 키는 풂 + if (links) { + const now = links.get(); + for (let n = index + 1; n < list.length; n++) + links.set(linkOf(c, n), now[linkOf(c, n + 1)] ?? null); + links.set(linkOf(c, list.length), null); + } + list.splice(index, 1); + paint(); + hooks.onChange?.(c, c.items[0]); + }); + box.root.querySelector("legend")!.append(" ", remove); + } + return box.root; + }; + + function paint(): void { + rows.length = 0; + const boxes: HTMLElement[] = []; + for (const c of spec?.containers ?? []) { + const list = (state[c.id] ??= c.required ? [{}] : []); + list.forEach((values, index) => boxes.push(instanceBox(c, values, index))); + if (c.required) continue; + const add = document.createElement("button"); + add.type = "button"; + add.className = "b05-route__irregular-btn b05-structure__spec-add"; + add.textContent = `+ ${containerTitle(c, names, groupNames[c.link])}`; + add.title = "추가"; + add.dataset.specAdd = c.id; + add.addEventListener("click", () => { + list.push({}); + paint(); + hooks.onChange?.(c, c.items[0]); + }); + boxes.push(add); + } + root.replaceChildren(...boxes); + } + + return { + root, + write(next, options, nextNames = {}, nextGroups = {}) { + spec = next; + names = nextNames; + groupNames = nextGroups; + state = readState(next, options); + recent.clear(); + paint(); + }, + read: () => (spec ? writeOptions(spec, state) : {}), + state: () => state, + setValue(containerId, itemId, value) { + const values = state[containerId]?.[0]; + if (!values) return; + values[itemId] = value; + paint(); + }, + slot: slotOf, + paintLinks() { + const now = links?.get() ?? {}; + rows.forEach((row) => row.set(now[row.key] ?? null)); + }, + refresh: paint, + }; +} diff --git a/A00_Common/design/design_structure_pipe.ts b/A00_Common/design/design_structure_pipe.ts new file mode 100644 index 000000000..c72f84c0b --- /dev/null +++ b/A00_Common/design/design_structure_pipe.ts @@ -0,0 +1,187 @@ +/* ============================================================================= + * design_structure_pipe.ts + * 횡단배수 › 배관 명세 — 사용자 구조물 표 01(`docs/raw/구조물정의_표/01_횡단배수_배관.md`) 그대로. + * + * 컨테이너 다섯: 관(필수) · 관 보호공(유입)(필수) · 관 보호공(유출)(필수) · 흙막이 추가(선택 · 여러 개) · + * 도수로/돌붙임(선택 · 여러 개). 구조 · 정본 = 이 명세 · 이름만 정의(M02)에서 바꿈. + * 저장 키 = pipe_points.json 옛 키 그대로(B06 횡단 · B07 · B08 이 읽는 자리) — 흙막이 값은 옛 「기슭막이」 + * (`inlet_type` · `*_revet_*`)에 둠. 새 키 = 집수정 높이 · 선택 컨테이너(`soil_add_{n}_*` · `chute_add_{n}_*`). + * 정함(PLAN 52): 유출 형태 = 흙막이 6가지 · 도수로/돌붙임 = 구조만 · 길이 = 전 + 후 맞물림. + * 계산(관 길이 · 높이 안내)은 `design_structure_pipe_calc`. + * import 없음 — 시험이 이 파일만 옮겨 돌림. + * ========================================================================== */ + +import type { SpecChoice, SpecItem, StructureSpec } from "./design_structure_spec"; + +const SOIL = "기슭막이"; // 저장 값(옛 키 그대로) — 화면 이름은 흙막이 +const BASIN = "집수정"; +const SOIL_FORMS: SpecChoice[] = [ + { value: "돌쌓기(찰)", label: "돌 쌓기(찰)" }, + { value: "돌쌓기(메)", label: "돌 쌓기(메)" }, + { value: "콘크리트", label: "콘크리트" }, + { value: "돌망태", label: "돌망태" }, + { value: "통나무·목재틀", label: "통나무/목재틀" }, + { value: "바자", label: "바자" }, +]; +const BASIN_FORMS: SpecChoice[] = [ + { value: "I", label: "I형" }, + { value: "L", label: "L형" }, + { value: "U", label: "ㄷ형" }, +]; + +/** 형태 · 길이 · 높이 · 전 · 후 — 필수 보호공은 구조 갈래마다 옛 키 · 선택 흙막이는 인스턴스 키. */ +function guardItems(keys?: Record>): SpecItem[] { + const by = (field: string, choices?: Record) => + keys + ? { + item: "structure", + map: Object.fromEntries( + Object.entries(keys).map(([structure, k]) => [ + structure, + { key: k[field], choices: choices?.[structure] }, + ]), + ), + } + : undefined; + return [ + { + id: "form", + label: "형태", + kind: "select", + choices: SOIL_FORMS, + by: by("form", { [BASIN]: BASIN_FORMS, [SOIL]: SOIL_FORMS }), + }, + { id: "length", label: "길이 (m)", kind: "number", split: "length", step: 1, by: by("length") }, + { id: "height", label: "높이 (m)", kind: "number", step: 0.1, by: by("height") }, + { + id: "before", + label: "기준측점 전 (m)", + kind: "number", + split: "before", + step: 0.5, + by: by("before"), + }, + { + id: "after", + label: "기준측점 후 (m)", + kind: "number", + split: "after", + step: 0.5, + by: by("after"), + }, + ]; +} + +const revetKeys = (side: "inlet" | "outlet"): Record => ({ + form: `${side}_revet_form`, + length: `${side}_revet_length_m`, + height: `${side}_revet_height_m`, + before: `${side}_revet_before_m`, + after: `${side}_revet_after_m`, +}); + +export const PIPE_SPEC: StructureSpec = { + typeId: "pipe", + containers: [ + { + id: "pipe", + title: "관", + required: true, + link: "grv6lny", // 정의 그룹 「관」(46장) — 집계표 잇기 자리 그대로 + items: [ + { + id: "diameter", + label: "관경", + kind: "select", + key: "pipe_diameter_mm", + asNumber: true, + choices: ["800", "1000", "1200", "1500"].map((v) => ({ value: v, label: v })), + }, + { id: "length", label: "관 길이 (m)", kind: "auto" }, + ], + }, + { + id: "inlet", + title: "관 보호공 (유입)", + required: true, + link: "gnbf9m9", + items: [ + { + id: "structure", + label: "구조", + kind: "select", + key: "inlet_type", + choices: [ + { value: BASIN, label: "집수정" }, + { value: SOIL, label: "흙막이", also: { inlet_structure: "revet" } }, + ], + }, + ...guardItems({ + [BASIN]: { + form: "inlet_structure", + length: "inlet_basin_length_m", + height: "inlet_basin_height_m", + before: "inlet_basin_before_m", + after: "inlet_basin_after_m", + }, + [SOIL]: revetKeys("inlet"), + }), + ], + }, + { + id: "outlet", + title: "관 보호공 (유출)", + required: true, + link: "g9lq9uk", + items: [ + { + id: "structure", + label: "구조", + kind: "select", + key: "outlet_type", + choices: [{ value: SOIL, label: "흙막이" }], + }, + ...guardItems({ [SOIL]: revetKeys("outlet") }), + ], + }, + { + id: "soil_add", + title: "흙막이 추가", + required: false, + prefix: "soil_add", + link: "gsoiladd", + items: [ + { + id: "structure", + label: "구조", + kind: "select", + choices: [{ value: SOIL, label: "흙막이" }], + }, + ...guardItems(), + ], + }, + { + id: "chute_add", + title: "도수로/돌붙임", + required: false, + prefix: "chute_add", + link: "gchuteadd", + items: [ + { + id: "structure", + label: "구조", + kind: "select", + choices: [ + { value: "도수로", label: "도수로" }, + { value: "돌붙임", label: "돌붙임" }, + ], + }, + ], + }, + ], + // 표 01 에 없는 옛 칸 — 화면에서 빼고 값은 남김(지난 정의 값) + retired: ["pipe_kind", "wing_wall_type", "inlet_basin_form", "inlet_basin_material"], +}; + +/** 명세로 그리는 종류 — 종류 키 → 명세(표가 오는 대로 더함). */ +export const STRUCTURE_SPECS: Readonly> = { pipe: PIPE_SPEC }; diff --git a/A00_Common/design/design_structure_pipe_calc.ts b/A00_Common/design/design_structure_pipe_calc.ts new file mode 100644 index 000000000..d6432aabe --- /dev/null +++ b/A00_Common/design/design_structure_pipe_calc.ts @@ -0,0 +1,42 @@ +/* ============================================================================= + * design_structure_pipe_calc.ts + * 배관 명세 계산 — 관 길이(자동) · 보호공 높이 안내. 계산 자리 = ② TS 를 서버 Node 로: + * 관 길이는 횡단 기하 `computeStoredLayouts`(B06 · 서버 Node 진입점이 같은 함수로 `pipe_length_m` 를 + * 셈 · 시험 test_b06_pipe_length) 그대로 — 여기서 새로 셈하지 않음 · 화면에 보이기만(저장 안 함). + * 높이 = 손 입력 · 횡단도 조정 높이는 B06 `withSpecDefaults` 가 같은 키(`*_revet_height_m`)에 실음 · + * 빈 칸 안내 = 관경 기준 최소 높이(`pipeWallMinHeightM` ↔ 파이썬 `pipe_wall_min_height_m` 짝). + * ========================================================================== */ + +import { pipeWallMinHeightM } from "../../common_util/common_util_culvert_sets"; +import type { CrossSection } from "../../B06_Section/B06_Section_Api_Fetch"; +import { computeStoredLayouts } from "../../B06_Section/B06_Section_Structure_Layouts"; +import { PIPE_MATCH_M } from "./design_selection"; + +/** 횡단 측점 목록을 대는 곳 — 횡단 보기가 자료를 받으면 걸어 둠(종단만 연 동안은 없음). */ +let sectionsSource: (() => readonly CrossSection[] | null) | null = null; +export function setPipeSectionsSource(source: () => readonly CrossSection[] | null): void { + sectionsSource = source; +} + +/** 관 길이(m) — 그 관의 주인 측점(관 자리에서 가장 가까운 측점) 횡단 기하 값 · 못 세우면 저장된 + * `pipe_length_m` · 그것도 없으면 null. */ +export function pipeLengthM(chainageM: number | null): number | null { + const sections = sectionsSource?.() ?? null; + if (chainageM === null || !sections?.length) return null; + const holders = sections.filter( + (s) => Math.abs((s.culvert?.chainage_m ?? Infinity) - chainageM) < PIPE_MATCH_M, + ); + if (!holders.length) return null; + const owner = holders.reduce((a, b) => + Math.abs(b.chainage_m - chainageM) < Math.abs(a.chainage_m - chainageM) ? b : a, + ); + const drawn = computeStoredLayouts(owner, sections)?.culvert?.pipe?.lengthM; + const length = typeof drawn === "number" && drawn > 0 ? drawn : owner.design?.pipe_length_m; + return typeof length === "number" && length > 0 ? length : null; +} + +/** 흙막이 높이 빈 칸 안내 — 관경 기준 최소 높이(엔진이 빈 칸을 이 값으로 그림). */ +export function guardHeightHint(diameterMm: string): string { + const d = Number(diameterMm || "1000") / 1000; + return `비우면 관경 기준 ${pipeWallMinHeightM(d)}`; +} diff --git a/A00_Common/design/design_structure_spec.ts b/A00_Common/design/design_structure_spec.ts new file mode 100644 index 000000000..506858418 --- /dev/null +++ b/A00_Common/design/design_structure_spec.ts @@ -0,0 +1,239 @@ +/* ============================================================================= + * design_structure_spec.ts + * 구조물 명세 공용 틀(PLAN 52) — 화면 요소 없는 순수 규칙. 사용자 구조물 표 한 장 = 명세 한 벌. + * + * · 종류 = 기본 옵션(기준 측점 No + 거리 · 패널 위치 칸이 받음) + 컨테이너[] + 항목[] + * · 컨테이너 = 제목 · 필수(늘 한 벌) / 선택([추가] · [빼기] · 여러 개) + * · 항목 꼴 = 고르기 · 숫자 · 자동(계산이 채움 · 저장 안 함) · 맞물림(길이 = 전 + 후) + * · 상위 값에 따른 선택지 · 저장 키 = `by`(같은 컨테이너 안 상위 항목 값 → 갈래) + * · 저장 = 정본 자리 옵션 그대로(관이면 pipe_points.json `options`) — 필수 컨테이너는 항목 `key` · + * 선택 컨테이너는 인스턴스마다 `{prefix}_{n}_{항목}`(n 은 1부터) + * · 구조물 조합 잇기(`group_items`) 키 = 필수는 `link`(정의 그룹 id) · 선택은 `{link}{n}` + * · 이름 = 정의 `종류.{종류}.이름` 이 있으면 그것(M02 바꿈) · 그룹 이름 · 없으면 명세 글 + * import 없음 — 시험이 이 파일만 옮겨 돌림. + * ========================================================================== */ + +export interface SpecChoice { + value: string; + label: string; + /** 이 값을 고르면 함께 적는 옵션(하류가 읽는 옛 칸 · 예: 흙막이 = inlet_structure revet). */ + also?: Record; +} + +/** 상위 값 하나에 걸린 갈래 — 저장 키 · 선택지. */ +export interface SpecBranch { + key: string; + choices?: SpecChoice[]; +} + +export type SplitRole = "length" | "before" | "after"; + +export interface SpecItem { + id: string; + label: string; + kind: "select" | "number" | "auto"; + /** 저장 키(필수 컨테이너 · `by` 없을 때). */ + key?: string; + choices?: SpecChoice[]; + /** 같은 컨테이너 상위 항목 값 → 갈래. 갈래가 없으면(상위 비었음) 칸을 가림. */ + by?: { item: string; map: Record }; + /** 길이 = 전 + 후 맞물림 몫. */ + split?: SplitRole; + /** 고르기 값을 수로 저장(관경 등 옛 저장이 수). */ + asNumber?: boolean; + step?: number; + /** 빈 칸 안내 — 함수면 지금 값으로 셈. */ + hint?: string | ((values: Record) => string); +} + +export interface SpecContainer { + id: string; + title: string; + required: boolean; + /** 선택 컨테이너 저장 키 머리 — `{prefix}_{n}_{항목}`. */ + prefix?: string; + /** 구조물 조합 잇기 키 — 필수는 정의 그룹 id 그대로 · 선택은 뒤에 n. */ + link: string; + items: SpecItem[]; +} + +export interface StructureSpec { + typeId: string; + containers: SpecContainer[]; + /** 명세가 화면에서 뺀 옛 키 — 값은 그대로 남고 「지난 정의 값」으로 보임. */ + retired: string[]; +} + +/** 컨테이너 인스턴스 한 벌 값 `{항목 id: 글}`. */ +export type Instance = Record; +export type SpecState = Record; + +/** 항목의 지금 갈래 — `by` 없으면 항목 자체. 상위가 비었으면 null(가림 · 저장 안 함). */ +export function branchOf(item: SpecItem, values: Instance): SpecBranch | null { + if (!item.by) + return item.key || item.choices ? { key: item.key ?? "", choices: item.choices } : null; + return item.by.map[values[item.by.item] ?? ""] ?? null; +} + +const instanceKey = (c: SpecContainer, n: number, item: SpecItem): string => + `${c.prefix}_${n}_${item.id}`; + +/** 항목 저장 키 — 자동 항목 · 갈래 없음 = null. */ +export function keyOf( + c: SpecContainer, + item: SpecItem, + n: number, + values: Instance, +): string | null { + if (item.kind === "auto") return null; + if (!c.required) return instanceKey(c, n, item); + return branchOf(item, values)?.key || null; +} + +/** 지금 고를 수 있는 값 — 갈래 선택지가 앞섬. */ +export function choicesOf(item: SpecItem, values: Instance): SpecChoice[] { + return branchOf(item, values)?.choices ?? item.choices ?? []; +} + +/** 인스턴스 잇기 키 — 필수 = 그룹 id · 선택 = `{link}{n}`. */ +export const linkOf = (c: SpecContainer, n: number): string => + c.required ? c.link : `${c.link}${n}`; + +const text = (value: unknown): string => + value === undefined || value === null ? "" : String(value); + +/** 옵션 → 컨테이너 값. 필수는 한 벌(상위 항목 먼저 읽어 갈래 키를 앎) · 선택은 저장된 n 차례대로. */ +export function readState( + spec: StructureSpec, + options: Record, +): SpecState { + const state: SpecState = {}; + for (const c of spec.containers) { + if (c.required) { + const values: Instance = {}; + const plain = c.items.filter((item) => !item.by); + for (const item of [...plain, ...c.items.filter((item) => item.by)]) { + const key = keyOf(c, item, 1, values); + if (key) values[item.id] = text(options[key]); + } + state[c.id] = [values]; + continue; + } + const head = new RegExp(`^${c.prefix}_(\\d+)_`); + const ns = [ + ...new Set( + Object.keys(options) + .map((k) => head.exec(k)?.[1]) + .filter((n): n is string => !!n) + .map(Number), + ), + ].sort((a, b) => a - b); + state[c.id] = ns.map((n) => + Object.fromEntries(c.items.map((item) => [item.id, text(options[instanceKey(c, n, item)])])), + ); + } + return state; +} + +/** 컨테이너 값 → 옵션(빈 칸 뺌 · 숫자 칸은 수 · 선택 컨테이너는 차례대로 1 부터 다시 매김). */ +export function writeOptions( + spec: StructureSpec, + state: SpecState, +): Record { + const out: Record = {}; + for (const c of spec.containers) { + (state[c.id] ?? []).forEach((values, index) => { + for (const item of c.items) { + const key = keyOf(c, item, index + 1, values); + const raw = (values[item.id] ?? "").trim(); + if (!key || raw === "") continue; + if (item.kind === "number" || item.asNumber) { + const n = Number(raw); + if (Number.isFinite(n)) out[key] = Math.round(n * 100) / 100; + continue; + } + out[key] = raw; + if (c.required) + Object.assign(out, choicesOf(item, values).find((ch) => ch.value === raw)?.also); + } + }); + } + return out; +} + +/** 명세가 다루는 키 전부(필수 갈래 · also 포함) + 선택 컨테이너 머리 — 지난 정의 값 판정 · 저장 병합. */ +export function specKeys(spec: StructureSpec): { keys: string[]; prefixes: string[] } { + const keys = new Set(); + const prefixes: string[] = []; + for (const c of spec.containers) { + if (!c.required) { + prefixes.push(`${c.prefix}_`); + continue; + } + for (const item of c.items) { + const branches = item.by ? Object.values(item.by.map) : [branchOf(item, {})]; + for (const branch of branches) { + if (branch?.key) keys.add(branch.key); + for (const ch of branch?.choices ?? item.choices ?? []) + Object.keys(ch.also ?? {}).forEach((k) => keys.add(k)); + } + } + } + return { keys: [...keys], prefixes }; +} + +/** + * 길이 = 전 + 후 맞물림 — 마지막에 고친 둘을 지키고 나머지 하나를 셈(정함 52). + * `recent` = 고친 차례(최근이 앞) · 둘이 안 차면 길이 → 전 → 후 차례로 채움. 지킬 둘 중 빈 칸이 있으면 안 셈. + */ +export function solveSplit( + values: Partial>, + recent: SplitRole[], +): Partial> { + const keep = [...new Set([...recent, "length", "before", "after"] as SplitRole[])].slice(0, 2); + const num = (role: SplitRole): number | null => { + const raw = (values[role] ?? "").trim(); + const n = Number(raw); + return raw !== "" && Number.isFinite(n) ? n : null; + }; + const [a, b] = keep.map(num); + if (a === null || b === null) return values; + const free = (["length", "before", "after"] as SplitRole[]).find((r) => !keep.includes(r))!; + const got = { length: num("length"), before: num("before"), after: num("after") }; + const result = + free === "length" + ? (got.before ?? 0) + (got.after ?? 0) + : Math.max((got.length ?? 0) - (free === "before" ? (got.after ?? 0) : (got.before ?? 0)), 0); + return { ...values, [free]: String(Math.round(result * 100) / 100) }; +} + +/** 이름 바꿈 표(정의 `종류.{종류}.이름`) — 키 `{컨테이너}` · `{컨테이너}.{항목}` · `{컨테이너}.{항목}={값}`. */ +export type SpecNames = Record; + +export const containerTitle = (c: SpecContainer, names: SpecNames, groupName?: string): string => + names[c.id] ?? groupName ?? c.title; +export const itemLabel = (c: SpecContainer, item: SpecItem, names: SpecNames): string => + names[`${c.id}.${item.id}`] ?? item.label; +export const choiceLabel = ( + c: SpecContainer, + item: SpecItem, + ch: SpecChoice, + names: SpecNames, +): string => names[`${c.id}.${item.id}=${ch.value}`] ?? ch.label; + +/** 명세의 이름 키 전부(정의 파일에 올릴 표) — `{키: 명세 글}`. */ +export function nameTable(spec: StructureSpec): SpecNames { + const out: SpecNames = {}; + for (const c of spec.containers) { + if (!c.required) out[c.id] = c.title; // 필수 컨테이너 이름 = 정의 그룹 이름(M02 그룹 표에서 바꿈) + for (const item of c.items) { + out[`${c.id}.${item.id}`] = item.label; + const all = [ + ...(item.choices ?? []), + ...Object.values(item.by?.map ?? {}).flatMap((b) => b.choices ?? []), + ]; + for (const ch of all) out[`${c.id}.${item.id}=${ch.value}`] = ch.label; + } + } + return out; +} diff --git a/A00_Common/design/design_view_cross.ts b/A00_Common/design/design_view_cross.ts index 32f187eb0..a2174fee4 100644 --- a/A00_Common/design/design_view_cross.ts +++ b/A00_Common/design/design_view_cross.ts @@ -73,6 +73,7 @@ import { createStandardSlot, } from "../../B06_Section/B06_Section_UI_Page_Panel"; import type { DesignPageHost } from "./design_page"; +import { setPipeSectionsSource } from "./design_structure_pipe_calc"; import { bindDesignView, bindPipeOptions, @@ -94,6 +95,8 @@ export async function mountCrossView( const projectId = shared.projectId || null; let currentRouteId: number | null = null; let sectionDetail: SectionDetailResponse | null = null; + // 배관 명세 관 길이(자동) = 이 횡단 자료의 기하(PLAN 52-1) + setPipeSectionsSource(() => sectionDetail?.cross_sections ?? null); let stationInterval: number | undefined; // 표준 횡단면 설정 패널 자리. context 로드 후 config 기본값으로 채운다. diff --git a/A00_Common/spreadsheet/spreadsheet_links.css b/A00_Common/spreadsheet/spreadsheet_links.css index 01a82b2c9..30694cd8e 100644 --- a/A00_Common/spreadsheet/spreadsheet_links.css +++ b/A00_Common/spreadsheet/spreadsheet_links.css @@ -195,3 +195,72 @@ transform: translate(-100%, 0); pointer-events: none; } + +/* 칸을 골랐을 때 그 칸의 알약 — 굵은 테두리 + 바깥 고리(칸이 바뀐 첫 그림만 한 번 반짝) */ +.ss-link.is-here { + outline: 2px solid var(--color-accent); + outline-offset: 2px; + font-weight: 600; +} + +.ss-link.is-pop { + animation: ss-link-pop 1.2s ease-out 1 both; +} + +@keyframes ss-link-pop { + 0%, + 40%, + 80% { + box-shadow: 0 0 0 4px color-mix(in srgb, var(--color-accent) 55%, transparent); + } + 20%, + 60%, + 100% { + box-shadow: 0 0 0 0 transparent; + } +} + +/* 일위대가에 없는 알약 — ⚠ + 점선 붉은 테두리 + [×] */ +.ss-link.is-orphan { + border: 1px dashed var(--color-danger); + color: var(--color-danger); +} + +.ss-link__x { + margin-left: 2px; + padding: 0 4px; + border-radius: 50%; + font-weight: 700; + cursor: pointer; +} + +.ss-link__x:hover { + background: color-mix(in srgb, var(--color-danger) 20%, transparent); +} + +.ss-link-cell.is-orphan { + outline: 1px dashed var(--color-danger); + outline-offset: -2px; +} + +/* 보낸 범위(도면 표) — 점선 테두리(채움 없음) + 왼위 이름표 */ +.ss-link-range { + position: absolute; + z-index: 3; + box-sizing: border-box; + border: 2px dashed var(--color-accent); + pointer-events: none; +} + +.ss-link-range__tag { + position: absolute; + left: -2px; + bottom: 100%; + padding: 0 4px; + border-radius: 3px 3px 0 0; + background: var(--color-accent); + color: var(--color-canvas); + font-size: 10px; + line-height: 14px; + white-space: nowrap; +} diff --git a/A00_Common/spreadsheet/spreadsheet_links.ts b/A00_Common/spreadsheet/spreadsheet_links.ts index 978a9c8b4..7525085a4 100644 --- a/A00_Common/spreadsheet/spreadsheet_links.ts +++ b/A00_Common/spreadsheet/spreadsheet_links.ts @@ -15,7 +15,7 @@ import "./spreadsheet_links.css"; import { createMapContextMenu } from "@ui/ui_template_context_menu"; import { el, showToast } from "@ui/ui_template_elements"; -import { parseA1, quoteSheet, toA1 } from "./spreadsheet_address"; +import { inRange, parseA1, parseRange, quoteSheet, toA1 } from "./spreadsheet_address"; import { applyCommand } from "./spreadsheet_commands"; import { askShare } from "./spreadsheet_link_ask"; import { linkHues } from "./spreadsheet_link_color"; @@ -53,6 +53,11 @@ export interface LinkOptions { onItemsChange?: (항목: LinkItem[]) => void; /** 자재DB 찾기(빈 글 = 처음 목록) — 자재 모드만 */ 자재찾기?: (글: string) => Promise; + /** 입력 모드 — 호스트가 항목 목록을 믿을 수 있게 받았음(받기 실패 · 빈 목록 구분) · true 일 때만 + * 목록에 없는 연결 알약에 ⚠ + [×] */ + 확정?: boolean; + /** 다른 곳으로 보낸 칸 범위 표시(예: 도면 표) — 시트 id · 범위 · 이름표 · 마우스 올림 글 */ + 표시범위?: { 시트: string; 범위: string; 이름: string; 설명?: string }[]; } export interface Links extends PartHandle { @@ -74,6 +79,8 @@ const TEXT = { fail: "자재 목록을 못 받음", picked: "칸을 고른 뒤 새 알약을 누르면 연결", dup: "이미 목록에 있는 자재", + orphan: "일위대가에 없는 알약 — 연결을 풀면 [저장] 때 남음", + orphanX: "연결 풀기", }; type RowCol = Extract; @@ -146,6 +153,8 @@ export function mountLinks( let dragging: LinkItem | null = null; const dropMark = el("div", { className: "ss-link-drop" }); let pick: HTMLElement | null = null; + /** 마지막으로 강조를 판정한 칸(시트 id!칸) — 바뀔 때만 반짝 · 스크롤 */ + let lastAt = ""; /** 키 → 연결(시트 · 줄) */ function found(): Map { @@ -204,6 +213,8 @@ export function mountLinks( item: LinkItem, hit: { sheet: Sheet; link: CellLink } | undefined, hue: number | undefined, + orphan: boolean, + here: string, ) { const where = hit ? hit.sheet.id === ctx.selection.시트 @@ -211,15 +222,30 @@ export function mountLinks( : `${quoteSheet(hit.sheet.이름)}!${hit.link.칸}` : ""; const b = el("button", { - className: hit ? "ss-link is-linked" : "ss-link", - attrs: { type: "button", title: hit ? TEXT.linked : TEXT.free, "data-key": item.키 }, + className: `ss-link${hit ? " is-linked" : ""}${orphan ? " is-orphan" : ""}${ + here ? ` is-here${here === "new" ? " is-pop" : ""}` : "" + }`, + attrs: { + type: "button", + title: orphan ? TEXT.orphan : hit ? TEXT.linked : TEXT.free, + "data-key": item.키, + }, children: [ - el("span", { text: item.이름 }), + el("span", { text: orphan ? `⚠ ${item.이름}` : item.이름 }), ...(item.단위 ? [el("small", { text: item.단위 })] : []), ...(where ? [el("code", { text: where })] : []), ], }); if (hue !== undefined) b.style.setProperty("--ss-link-h", String(hue)); + if (orphan && !ctx.readOnly) { + const x = el("span", { + className: "ss-link__x", + text: "×", + attrs: { role: "button", title: TEXT.orphanX }, + }); + x.addEventListener("click", (e) => (e.stopPropagation(), unlink(), back())); + b.append(x); + } b.draggable = !ctx.readOnly; b.addEventListener("dragstart", (e) => { dragging = item; @@ -233,13 +259,13 @@ export function mountLinks( // 초점은 격자에 둠 — 끌 수 있는 알약은 막으면 끌기가 안 돼 그대로(누른 뒤 `back` 이 격자로 돌림) if (!b.draggable) b.addEventListener("mousedown", (e) => e.preventDefault()); b.addEventListener("click", () => (hit ? goTo(hit.sheet, hit.link.칸) : void link(item))); - const unlink = (): void => { + function unlink(): void { if (hit) put( hit.sheet, (hit.sheet.연결 ?? []).filter((l) => l.키 !== item.키), ); - }; + } b.addEventListener("contextmenu", (e) => { e.preventDefault(); const inList = opts?.모드 === "자재" && !item.고정 && items.some((i) => i.키 === item.키); @@ -307,7 +333,17 @@ export function mountLinks( : { 키: link.키, 이름: link.이름 ?? link.키, 단위: link.단위 }, ); const hues = linkHues(map.keys()); - const pills = shown.map((item) => pill(item, map.get(item.키), hues.get(item.키))); + const listed = new Set(items.map((i) => i.키)); + const sure = opts.모드 === "입력" && opts.확정 === true; + // 칸을 고르면 그 칸에 이은 알약 강조 — 칸이 바뀐 첫 그림만 한 번 반짝("new") + const at = `${ctx.selection.시트}!${toA1(ctx.selection.활성.r, ctx.selection.활성.c)}`; + const fresh = at !== lastAt; + lastAt = at; + const pills = shown.map((item) => { + const hit = map.get(item.키); + const here = hit && `${hit.sheet.id}!${hit.link.칸}` === at ? (fresh ? "new" : "same") : ""; + return pill(item, hit, hues.get(item.키), sure && !!hit && !listed.has(item.키), here); + }); if (opts.모드 === "자재" && opts.자재찾기 && !ctx.readOnly) { const more = el("button", { className: "ss-link ss-link--add", @@ -318,6 +354,8 @@ export function mountLinks( pills.push(more); } bar.replaceChildren(...pills); + if (fresh) + bar.querySelector(".is-here")?.scrollIntoView?.({ block: "nearest", inline: "nearest" }); } /** 이은 칸 — 알약 색 옅은 배경 + 왼아래 작은 세모 · 칸이 든 판 층에 얹어 스크롤을 따라감 */ @@ -325,6 +363,8 @@ export function mountLinks( for (const d of dots) d.remove(); dots = []; const hues = linkHues(found().keys()); + const listed = new Set(items.map((i) => i.키)); + const sure = opts?.모드 === "입력" && opts.확정 === true; // 칸마다 이은 알약(이은 차례) — 색 = 먼저 이은 알약 · 둘 넘으면 수 const byCell = new Map(); for (const l of ctx.sheet().연결 ?? []) byCell.set(l.칸, [...(byCell.get(l.칸) ?? []), l]); @@ -340,6 +380,7 @@ export function mountLinks( dot.style.left = `${box.x}px`; dot.style.top = `${box.y + box.h - 7}px`; const marks = [tint, dot]; + if (sure && !listed.has(first.키)) tint.classList.add("is-orphan"); if (list.length > 1) { const count = el("div", { className: "ss-link-count", text: String(list.length) }); count.style.left = `${box.x + box.w}px`; @@ -350,6 +391,22 @@ export function mountLinks( layer.append(...marks); dots.push(...marks); } + // 보낸 범위(도면 표 등) — 채움 없는 점선 테두리 + 왼위 이름표(알약 색 채움과 안 겹침) + for (const mark of opts?.표시범위 ?? []) { + const g = mark.시트 === ctx.sheet().id ? parseRange(mark.범위) : null; + if (!g) continue; + const { layer, box } = slot(ctx, g.r0, g.c0); + const end = slot(ctx, g.r1, g.c1).box; + if (!box.w || !box.h) continue; + const frame = el("div", { + className: "ss-link-range", + attrs: { "data-range": mark.범위 }, + children: [el("span", { className: "ss-link-range__tag", text: mark.이름 })], + }); + frame.style.cssText = `left:${box.x}px;top:${box.y}px;width:${Math.max(box.w, end.x + end.w - box.x)}px;height:${Math.max(box.h, end.y + end.h - box.y)}px`; + layer.append(frame); + dots.push(frame); + } } /** 칸에 마우스를 올리면 이은 알약 이름들(격자 title) — 이은 칸이 아니면 지움 */ @@ -360,6 +417,11 @@ export function mountLinks( const names = a1 ? (ctx.sheet().연결 ?? []).filter((l) => l.칸 === a1).map((l) => l.이름 ?? l.키) : []; + if (hit && hit.r >= 0 && hit.c >= 0) + for (const m of opts?.표시범위 ?? []) { + const g = m.시트 === ctx.sheet().id ? parseRange(m.범위) : null; + if (g && inRange(g, hit.r, hit.c)) names.push(m.설명 ?? m.이름); + } if (names.length) ctx.grid.root.title = names.join(" · "); else if (tipped) ctx.grid.root.removeAttribute("title"); tipped = names.length > 0; diff --git a/B05_Profile/B05_Profile_Api_Structures.ts b/B05_Profile/B05_Profile_Api_Structures.ts index 02146289b..c3fb9948f 100644 --- a/B05_Profile/B05_Profile_Api_Structures.ts +++ b/B05_Profile/B05_Profile_Api_Structures.ts @@ -14,6 +14,13 @@ import { readState, writeState } from "../A00_Common/b_page_state"; import type { GroupItems, SummaryItem } from "../common_util/common_util_summary_item"; import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend"; +import { + type DefinedRule, + type DefinedShape, + type DefinedSpot, + type StructureDefinition, + withDefinition, +} from "./B05_Profile_UI_Structures_Define"; /** 배치형태 — 점형(측점 1개) / 구간형(시~종점) / 부지형(위치+면적). */ export type StructurePlacement = "point" | "interval" | "site"; @@ -27,7 +34,7 @@ export interface StructureOptionField { default: string | number | null; /** 미확정 항목(기본값 없음) — 사용자가 값을 넣어야 저장된다. */ required?: boolean; - /** 거짓이면 폼에 **회색으로** 그려지고 못 고른다 — 칸은 남기되 잠그는 자리. */ + /** 거짓 = 「안 씀」(48 정의 틀 3장) — 폼에 안 그림 · 옛 값은 그대로 남음. */ enabled?: boolean; /** 입력 시점 — B05는 유무·종류·위치만 받고 상세 치수(detail)는 B06/B07에서 받는다 * (2026-08-17 사용자 확정). detail이면 required여도 B05 폼에 그리지 않는다. */ @@ -79,6 +86,18 @@ export interface StructureType { enabled: boolean; /** 옵션 그룹(M02 PLAN 46) — 폼의 보조 컨테이너(제목 = name) · 안의 옵션 키 · 빈 그룹도 됨. */ groups?: StructureOptionGroup[]; + /** 구조물군 이름(48 정의 `구조물군`) — 없으면 화면 기본 이름. */ + group_name?: string; + /** 기준 측점 칸 보임(48 정의 `종류.기준측점`) — false 일 때만 가림. */ + show_anchor?: boolean; + /** 손코딩 특수 동작 이름(48 정의 `종류.동작`) — 그 동작이 붙은 칸 · 폼을 그대로 살림. */ + behaviors?: string[]; + /** 옵션 키 → 배치 칸(48 정의 `배치`). */ + spots?: Record | null; + /** 상위 → 하위 규칙(48 정의 `규칙` 중 이 종류 것). */ + rules?: DefinedRule[] | null; + /** 구조물 명세 이름 바꿈(52 정의 `종류.이름` · `{컨테이너}[.{항목}[={값}]]: 이름`). */ + spec_names?: Record; } /** 옵션 그룹 한 개 — 서버가 M02 「구조물 옵션 보이기」 그룹 표로 채움. */ @@ -86,6 +105,8 @@ export interface StructureOptionGroup { id: string; name: string; options: string[]; + /** 그룹이 받을 형상 칸(48 정의 그룹 `형상`) — 길이 · 높이 · 전 · 후 받기 켬끔 + 뜻. */ + shape?: DefinedShape; } export interface StructureInstance { @@ -181,10 +202,14 @@ let typesCache: Promise | null = null; */ export function fetchStructureTypes(includeDisabled = false): Promise { if (!typesCache) { + // 구조물 정의(48) 덧칸 — 못 받으면 옛 화면 그대로(정의 없음 = 지금 차례 · 2열) + const definition = requestJson("/m02/structure-definition", { + method: "GET", + }).catch(() => null); typesCache = requestJson("/projects/structure-types", { method: "GET", }) - .then((payload) => payload.types) + .then(async (payload) => withDefinition(payload.types, await definition)) .catch((error) => { typesCache = null; // 실패한 약속을 남겨 두면 다시 시도할 수 없다. throw error; diff --git a/B05_Profile/B05_Profile_Structures_Repository.py b/B05_Profile/B05_Profile_Structures_Repository.py index 273874bef..bc2ad31ed 100644 --- a/B05_Profile/B05_Profile_Structures_Repository.py +++ b/B05_Profile/B05_Profile_Structures_Repository.py @@ -12,7 +12,11 @@ import os import uuid from typing import Any, Iterable -from B05_Profile.B05_Profile_Structures_Schema import StructureInstance, structure_type_map +from B05_Profile.B05_Profile_Structures_Schema import ( + StructureInstance, + definition_version, + structure_type_map, +) from common_util.common_util_json import atomic_write_json from M02_MasterTemplete.StructOptions.M02_SO_Options import hidden_keys @@ -125,6 +129,8 @@ def save_structures( "revision": revision, "structures": [item.model_dump(mode="json") for item in items], "migrated_legacy": sorted(history), + # 저장 때 구조물 정의 판(PLAN 48-1) — 뒤에 정의가 바뀌어도 이 값이 어느 정의로 적혔는지 앎 + "definition_version": definition_version(), } atomic_write_json(structures_file_path(project_root), payload) return revision @@ -192,12 +198,15 @@ def _validate_options(item: StructureInstance, definition, hidden: set[str] = fr for key, value in item.options.items(): option = allowed.get(key) if option is None: - raise ValueError(f"{definition.name}에 정의되지 않은 옵션입니다: {key}") + # 지금 정의에 없는 키 = 지난 정의 값 — 버리지 않고 그대로 남김(PLAN 48-0 ② · 오류 없음) + continue if option.input == "number": if isinstance(value, bool) or not isinstance(value, (int, float)): raise ValueError(f"{definition.name}의 {option.label}은(는) 숫자여야 합니다.") if not (value == value and abs(value) != float("inf")) or value < 0: raise ValueError(f"{definition.name}의 {option.label}은(는) 0 이상이어야 합니다.") + elif option.input == "bool" and not isinstance(value, bool): + raise ValueError(f"{definition.name}의 {option.label}은(는) 예 · 아니오여야 합니다.") elif option.input == "select" and option.choices and str(value) not in option.choices: raise ValueError(f"{definition.name}의 {option.label} 값이 선택지에 없습니다: {value}") for option in definition.options: diff --git a/B05_Profile/B05_Profile_Structures_Schema.py b/B05_Profile/B05_Profile_Structures_Schema.py index b98fe87e4..3c37ca035 100644 --- a/B05_Profile/B05_Profile_Structures_Schema.py +++ b/B05_Profile/B05_Profile_Structures_Schema.py @@ -1,13 +1,15 @@ """B05 구조물 타입 레지스트리·인스턴스 검증 모델. -구조물 타입 정의는 코드가 아니라 데이터(`B05_Profile_Structure_Types.json`)다. 리스트가 -바뀌어도 코드를 고치지 않도록 분리했고, 프론트는 `GET /structure-types`로 같은 정본을 받는다. +구조물 타입 정의는 코드가 아니라 데이터(M02 마스터 `resources/master_template/구조물정의.json` +`types` · PLAN 48-1)다. 리스트가 바뀌어도 코드를 고치지 않도록 분리했고, 프론트는 +`GET /structure-types`로 같은 정본을 받는다. 정의 틀 = `docs/raw/M02_구조물옵션정의/정의_틀.md`. 인스턴스 정본은 `B05_Profile/route/structures.json` **하나뿐**이다(2026-08-16 사용자 확정). DB에는 참조 메타만 남긴다 — 두 곳에 같은 값을 두면 어긋난 쪽이 화면에 뜬다(관 매설 지점의 "복원 유령" 전례). """ +import copy import json import os from functools import lru_cache @@ -21,8 +23,12 @@ from common_util.common_util_summary_item import parse_group_items, parse_summar STRUCTURE_PLACEMENTS = ("point", "interval", "site") STRUCTURE_SIDES = ("left", "right", "center", "cross") -_REGISTRY_PATH = os.path.join( - os.path.dirname(os.path.abspath(__file__)), "B05_Profile_Structure_Types.json" +# 시험은 사본으로 바꿈 — 읽기 캐시는 (경로 · 고친 시각 · 크기)로 갈려 저장 즉시 새로 읽음 +DEFINITION_PATH = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "resources", + "master_template", + "구조물정의.json", ) Placement = Literal["point", "interval", "site"] @@ -35,7 +41,7 @@ class StructureOptionField(BaseModel): key: str label: str - input: Literal["select", "number", "text"] + input: Literal["select", "number", "text", "bool"] choices: list[str] = Field(default_factory=list) unit: str | None = None default: Any = None @@ -99,14 +105,37 @@ class StructureType(BaseModel): enabled: bool = True -@lru_cache(maxsize=1) -def load_structure_types() -> tuple[StructureType, ...]: - """레지스트리 정본을 읽어 타입 목록을 돌려준다(프로세스 수명 동안 1회 로드).""" - with open(_REGISTRY_PATH, encoding="utf-8") as handle: - payload = json.load(handle) +def _stamp() -> tuple[str, int, int]: + stat = os.stat(DEFINITION_PATH) + return DEFINITION_PATH, stat.st_mtime_ns, stat.st_size + + +@lru_cache(maxsize=4) +def _read_definition(path: str, _mtime: int, _size: int) -> dict[str, Any]: + with open(path, encoding="utf-8") as handle: + return json.load(handle) + + +@lru_cache(maxsize=4) +def _read_types(path: str, mtime: int, size: int) -> tuple[StructureType, ...]: + payload = _read_definition(path, mtime, size) return tuple(StructureType.model_validate(item) for item in payload["types"]) +def load_definition() -> dict[str, Any]: + """구조물 정의 한 벌(사본) — `{판, types, 표시, 그룹, 종류, 배치, 규칙, 동작, …}`.""" + return copy.deepcopy(_read_definition(*_stamp())) + + +def definition_version() -> int: + return int(_read_definition(*_stamp()).get("판", 1)) + + +def load_structure_types() -> tuple[StructureType, ...]: + """정의 정본을 읽어 타입 목록을 돌려준다(파일이 바뀔 때만 다시 읽음).""" + return _read_types(*_stamp()) + + def structure_type_map() -> dict[str, StructureType]: return {item.type_id: item for item in load_structure_types()} @@ -132,8 +161,7 @@ def station_planting_labels() -> set[str]: def registry_schema_version() -> int: - with open(_REGISTRY_PATH, encoding="utf-8") as handle: - return int(json.load(handle).get("schema_version", 1)) + return int(_read_definition(*_stamp()).get("schema_version", 1)) class StructureInstance(BaseModel): diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts index ee870d18b..e7371d120 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts @@ -7,10 +7,9 @@ * 시설 종류는 구조물 배치의 "종류" 드롭다운이, 위치(시작·기준·종료 측점)는 위치 * 칸이 담당하므로 여기에는 부속 옵션 필드만 있다. * - * 배관 부속(2026-08-17 UI 개편): 관종·관경(기본 1000) 한 행 + 유입구·유출구 - * 소그룹. 유입구는 집수정/기슭막이 택일이고, 유출구는 기슭막이뿐이다(현재 다른 - * 선택지 없음). 기슭막이 조각은 형태·길이·높이·바닥 보호공을 한 벌로 묶어 - * 유입·유출·독립 구조물(D4 기슭막이)이 **같은 인터페이스**를 쓴다. + * 배관 부속(PLAN 52-1): 사용자 구조물 표 01 명세 폼(`A00_Common/design/design_structure_pipe`) + * — 관 · 관 보호공(유입 · 유출) · 흙막이 추가 · 도수로/돌붙임 컨테이너. 저장 키는 옛 키 그대로 · + * 옛 관종 · 날개벽 · 집수정 형식 칸은 화면에서 빠지고 값은 남음(지난 정의 값). * * 보호공(2026-08-17 사용자 지시): 구 "돌붙임 있음/없음 + 표면처리(찰/메)" 두 축을 * `돌붙임(찰)·돌붙임(메)·도수로` 한 축으로 합쳤다. 도수로·산비탈수로(B4)는 유출부에서 @@ -38,26 +37,23 @@ import type { import { BOX_SIZE_CUSTOM, BOX_SIZE_PRESETS, - createBasinSplitFields, - createRevetmentFields, createRevetSideGroup, createWingFields, grid, group, - INLET_KINDS, INLET_REVET_KEYS, - inletKindOf, type InletStructureKind, labeled, numberInput, optionalSelect, OUTLET_REVET_KEYS, putNumber, - stepper, WING_IN_KEYS, WING_OUT_KEYS, } from "./B05_Profile_UI_Drainage_Facility_Fields"; -import { pipeWallMinHeightM } from "../common_util/common_util_culvert_sets"; +import { createSpecForm, type SpecLinks } from "../A00_Common/design/design_structure_form"; +import { PIPE_SPEC } from "../A00_Common/design/design_structure_pipe"; +import { guardHeightHint, pipeLengthM } from "../A00_Common/design/design_structure_pipe_calc"; import { hasLinks, linkFields, @@ -206,7 +202,17 @@ export interface FacilityOptionsForm { options?: Record, /** 담당 유역의 설계유량(㎥/s) — 물넘이·세월교 개략 단면의 입력. 없으면 안내만. */ designFlow?: number | null, + /** 종류 정의 — 배관 명세 이름 바꿈(`spec_names`) · 그룹 이름(PLAN 52). */ + definition?: { + spec_names?: Record; + groups?: Array<{ id: string; name: string }>; + } | null, ) => void; + /** 배관 명세 컨테이너 「구조물 조합」 잇기 자리 — 폼이 패널 그룹 값에 이음(PLAN 52-1). */ + bindLinks: (links: SpecLinks) => void; + paintLinks: () => void; + /** 고른 관 측점 — 관 길이(자동) 계산에 씀. */ + setChainage: (chainageM: number | null) => void; /** 현재 필드 값을 시설 옵션으로 읽는다(빈 값 생략). 숨긴 칸은 폼이 받은 옛 값 그대로. */ readOptions: () => Record; /** 「구조물 옵션 보이기」에서 끈 레지스트리 옵션 키 — 그 칸을 가린다(값은 옛 값 보존). @@ -244,87 +250,56 @@ export function createFacilityOptionsForm( root.className = "b05-drainage__facility"; root.hidden = true; - // ── 배관 본체 — 관종 > 관경 한 행(2026-08-17 사용자 지시 4) ────────────── - // 기본값(관종 파형강관 · 관경 1000 · 등록부)은 시설이 생길 때 칸에 실제 값으로 들어옴(PLAN 46-7). + // ── 관종 · 관경 한 행 — 세월교 몫(배관은 아래 명세 폼 · 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)), ); - // 관보호공 날개벽 — **개소당 원단위**가 형식마다 붙박이라 고르는 칸이 있어야 물량이 선다 - // (2026-09-09, 계획서 4-13 · 소광리 원본 탭 다섯). 비우면 날개벽을 안 센다. - const wingWall = optionalSelect("안 놓음", ["A-TYPE", "C-TYPE", "A-TYPE+집수정"]); const pipeRow = grid( labeled("관종", pipeMaterial, "pipe_kind"), labeled("관경", pipeDiameter, "pipe_diameter_mm"), ); - const wingRow = grid(labeled("관보호공 날개벽", wingWall, "wing_wall_type")); - // ── 유입구 — 집수정 또는 기슭막이 택일(사용자 지시 5~9) ───────────────── - const inletGroup = group("유입구"); - // 「안 정함」이면 기슭막이 칸을 보이되 값은 안 실음. - const inletType = document.createElement("select"); - inletType.replaceChildren( - new Option("— 안 정함 —", ""), - ...INLET_KINDS.map((kind) => new Option(kind.label, kind.label)), - ); - const basinForm = optionalSelect("선택", [ - "동물이동형", - "□형(기본형)", - "돌집수정 ㄴ형", - "돌집수정 ㄷ형", - ]); - // 집수정도 재질·표면처리를 형태 한 축으로 합친다(사용자 지시 1). - const basinMaterial = optionalSelect("선택", [ - "콘크리트", - "조립식 주철맨홀", - "돌쌓기(찰)", - "돌쌓기(메)", - ]); - const basinMaterialField = labeled("형태", basinMaterial, "inlet_basin_material"); - // 집수정 종방향 길이 — 3D 예상형상용, 전후 동일 배분·기본 2m(2026-08-23 사용자). - const basinLength = numberInput("0.1", "0"); - // 기준측점 전 · 후 — 옛 횡단 조정창에만 있던 칸을 폼으로(계획서 24-4a). - const basinSplit = createBasinSplitFields(basinLength); - basinSplit.onChange(() => emit()); - const basinRows = [ - grid( - labeled("형식", basinForm, "inlet_basin_form"), - labeled("길이 (m)", stepper(basinLength, 1), "inlet_basin_length_m"), - ), - basinSplit.row, - ]; - const height = (): string => - String(pipeWallMinHeightM(Number(pipeDiameter.value || "1000") / 1000)); - const inletRevet = createRevetmentFields(INLET_REVET_KEYS, { height }); - // 첫 행 = [구조][형태] — 구조에 따라 집수정 형태와 기슭막이 형태가 갈아 끼워진다 - // (2026-08-17 사용자 지시 3). - // 조정창에서 옮겨 오는 행(단 수·옵션)이 붙을 자리(2026-08-29 사용자 지시 4). - // 비어 있으면 아무 자리도 차지하지 않는다. - const inletSlot = document.createElement("div"); - inletSlot.className = "b05-structure__adjust-slot"; - const outletSlot = inletSlot.cloneNode() as HTMLDivElement; - inletGroup.body.append( - grid( - labeled("구조", inletType, ["inlet_type", "inlet_structure"]), - basinMaterialField, - inletRevet.formField, - ), - ...basinRows, - ...inletRevet.rows, - inletSlot, - ); - - // ── 유출구 — 구조 선택지는 기슭막이뿐이지만 양식을 맞춘다(사용자 지시 2·4) ── - const outletGroup = group("유출구"); - const outletType = optionalSelect("— 안 정함 —", ["기슭막이"]); - const outletRevet = createRevetmentFields(OUTLET_REVET_KEYS, { height }); - outletGroup.body.append( - grid(labeled("구조", outletType, "outlet_type"), outletRevet.formField), - ...outletRevet.rows, - outletSlot, + // ── 배관 = 사용자 구조물 표 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), + }, + { + 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(); + }, + }, ); + // 조정창에서 옮겨 오는 행(단 수·옵션)이 붙을 자리 — 유입 · 유출 컨테이너 끝(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"; + }; // ── 추가 기슭막이(다단) — 횡단도에서 다단 벽을 고를 때만 뜬다(2026-08-30 사용자 // 지시 4). 유입구·유출구와 같은 양식이되, 값은 조정창 사본의 행이 그대로 옮겨 온다 @@ -425,13 +400,11 @@ export function createFacilityOptionsForm( root.append( saveNote, pipeRow, - wingRow, + pipeForm.root, ford.widthRow, ford.slopeRow, ford.countRow, ford.summary, - inletGroup.root, - outletGroup.root, extraGroup.root, revetTopRow, revetInlet.root, @@ -448,12 +421,8 @@ export function createFacilityOptionsForm( if (current === null) return; const isPipe = current === "pipe"; const isBox = current === "box_culvert"; - // 관종·관경은 배수관과 세월교가 공유한다. - pipeRow.hidden = !isPipe && current !== "ford_bridge"; - // 관보호공 날개벽 원단위는 **배수관 Ø800·Ø1000** 것뿐이다 — 다른 시설에서는 숨긴다. - wingRow.hidden = !isPipe; - inletGroup.root.hidden = !isPipe; - outletGroup.root.hidden = !isPipe; + pipeRow.hidden = current !== "ford_bridge"; + pipeForm.root.hidden = !isPipe; boxWrap.hidden = !isBox; // 날개벽은 BOX암거와 세월교가 같은 옵션 한 벌을 쓴다(2026-08-25 사용자 확정). const hasWing = isBox || current === "ford_bridge"; @@ -482,17 +451,6 @@ export function createFacilityOptionsForm( wingInFields.syncVisibility(); wingOutFields.syncVisibility(); } - if (isPipe) { - // 유입구 구조에 따라 집수정 칸과 기슭막이 칸을 갈아 끼운다(사용자 지시 6~8). - // 병합 목록이라 판정은 라벨이 아니라 매핑된 정본 종류로 한다(2026-08-29). - const kind = inletKindOf(inletType.value); - const basin = kind.type === "집수정"; - const revet = kind.type === "기슭막이"; - basinRows.forEach((row) => (row.hidden = !basin)); - basinMaterialField.hidden = !basin; - inletRevet.formField.hidden = !revet; - inletRevet.rows.forEach((row) => (row.hidden = !revet)); - } } function emit(): void { @@ -500,11 +458,6 @@ export function createFacilityOptionsForm( callbacks.onChange?.(); } - [inletType, outletType, wingWall, basinForm, basinMaterial, basinLength].forEach((input) => - input.addEventListener("change", emit), - ); - inletRevet.onChange(emit); - outletRevet.onChange(emit); revetInlet.fields.onChange(emit); revetOutlet.fields.onChange(emit); revetSide.addEventListener("change", emit); @@ -521,9 +474,6 @@ export function createFacilityOptionsForm( /** 유입구 구조 변경 수신자(B06 유입측 형식 제어). B05는 비워 둔다. */ let inletStructureHandler: ((value: InletStructureKind) => void) | null = null; - inletType.addEventListener("change", () => - inletStructureHandler?.(inletKindOf(inletType.value).structure), - ); return { root, @@ -538,23 +488,31 @@ export function createFacilityOptionsForm( revetSideLabels = labels; syncVisibility(); }, - inletStructure: () => inletKindOf(inletType.value).structure, + inletStructure: inletStructureOf, setInletStructure(value) { - // 지금 고른 정본 종류(기슭막이/집수정)는 지키고 형식만 맞춘다. - const type = inletKindOf(inletType.value).type; - const hit = - INLET_KINDS.find((kind) => kind.type === type && kind.structure === value) ?? - INLET_KINDS.find((kind) => kind.structure === value); - if (hit) inletType.value = hit.label; + // 조정창 형식 → 유입 보호공 구조 · 형태(자동 = 고른 구조는 지키고 형태만 비움) + if (value === "revet") 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(); + }, + paintLinks: () => pipeForm.paintLinks(), + setChainage(chainageM) { + chainage = chainageM; + if (current === "pipe") pipeForm.refresh(); + }, onInletStructureChange(handler) { inletStructureHandler = handler; }, setHiddenOptions(facility, registryKeys) { hiddenOptions.set(facility, registryKeys); }, - setFacility(facility, options = {}, designFlow = null) { + setFacility(facility, options = {}, designFlow = null, definition = null) { current = facility; hiddenOptions.remember(options); ford.setDesignFlow(designFlow ?? null); @@ -567,25 +525,10 @@ export function createFacilityOptionsForm( // 받은 값만 칸에 — 없으면 「안 정함」(새 시설은 부르는 쪽이 등록부 기본값을 실어 줌 · 46-7). pipeMaterial.value = text("pipe_kind"); pipeDiameter.value = text("pipe_diameter_mm"); - // 병합 목록: 저장된 정본 둘(inlet_type·inlet_structure)로 라벨을 되찾는다. - const savedType = text("inlet_type"); - const savedStructure = text("inlet_structure"); - inletType.value = savedType - ? ( - INLET_KINDS.find( - (kind) => - kind.type === savedType && (!savedStructure || kind.structure === savedStructure), - ) ?? inletKindOf(savedType) - ).label - : ""; - outletType.value = text("outlet_type"); - wingWall.value = text("wing_wall_type"); - basinForm.value = text("inlet_basin_form"); - basinMaterial.value = text("inlet_basin_material"); - basinLength.value = text("inlet_basin_length_m"); - basinSplit.write(options); - inletRevet.write(options); - outletRevet.write(options); + if (facility === "pipe") { + const groups = Object.fromEntries((definition?.groups ?? []).map((g) => [g.id, g.name])); + pipeForm.write(PIPE_SPEC, options, definition?.spec_names ?? {}, groups); + } // 저장된 폭·높이가 프리셋과 맞으면 그 프리셋을, 아니면 "사용자 지정"을 고른다. // 값이 없으면 「안 정함」. const savedWidth = Number.parseFloat(text("body_width_m")); @@ -625,24 +568,7 @@ export function createFacilityOptionsForm( if (pipeMaterial.value) options.pipe_kind = pipeMaterial.value; }; if (current === "pipe") { - putPipe(); - const inletKind = inletKindOf(inletType.value); - if (wingWall.value) options.wing_wall_type = wingWall.value; - // B06 유입측 형식(조정창과 같은 값) — 병합 드롭다운이 함께 정한다(2026-08-29). - if (inletType.value) { - options.inlet_type = inletKind.type; - options.inlet_structure = inletKind.structure; - } - if (inletKind.type === "집수정") { - if (basinForm.value) options.inlet_basin_form = basinForm.value; - if (basinMaterial.value) options.inlet_basin_material = basinMaterial.value; - putNumber(options, "inlet_basin_length_m", basinLength.value); - basinSplit.read(options); - } else { - inletRevet.read(options); - } - if (outletType.value) options.outlet_type = outletType.value; - outletRevet.read(options); + Object.assign(options, pipeForm.read()); // 명세 컨테이너 값 = 옛 키 그대로(PLAN 52-1) } else if (current === "box_culvert") { // 프리셋을 골랐든 사용자 지정을 적었든 정본은 폭·높이 두 칸이다. putNumber(options, "body_width_m", boxWidth.value); diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts index 9a33297c5..4e6afd657 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts @@ -26,15 +26,14 @@ const wingKeys = (side: "in" | "out"): string[] => [ /** 시설 종류별로 **폼이 적는 칸** — `createFacilityOptionsForm().readOptions()` 가 쓰는 키 전부. */ export const FORM_OPTION_KEYS: Readonly> = { + // 배관 = 표 01 명세 키(PLAN 52-1 · `design_structure_pipe`) — 옛 관종 · 날개벽 · 집수정 형식은 폼에서 + // 빠져 이어 붙임(지난 정의 값) · 선택 컨테이너 키는 `FORM_OPTION_PREFIXES` pipe: [ "pipe_diameter_mm", - "pipe_kind", - "wing_wall_type", "inlet_type", "inlet_structure", - "inlet_basin_form", - "inlet_basin_material", "inlet_basin_length_m", + "inlet_basin_height_m", "inlet_basin_before_m", "inlet_basin_after_m", ...revetKeys("inlet"), @@ -55,6 +54,11 @@ export const FORM_OPTION_KEYS: Readonly> = { revetment: ["side", ...revetKeys("inlet"), ...revetKeys("outlet")], }; +/** 폼이 인스턴스 번호를 붙여 적는 키 머리(명세 선택 컨테이너 · `{머리}{n}_{항목}`) — 빼면 지워짐. */ +export const FORM_OPTION_PREFIXES: Readonly> = { + pipe: ["soil_add_", "chute_add_"], +}; + /** 독립 기슭막이 레지스트리 키 → 폼은 유입·유출 두 채널 키로 적는다. */ const REVETMENT_FIELDS = ["form", "length_m", "height_m", "before_m", "after_m"]; @@ -95,7 +99,10 @@ export function mergeFacilityOptions( ): T { if (!previous || previous.facility !== next.facility) return next; const managed = new Set(FORM_OPTION_KEYS[next.facility] ?? []); - const kept = Object.entries(previous.options ?? {}).filter(([key]) => !managed.has(key)); + const heads = FORM_OPTION_PREFIXES[next.facility] ?? []; + const kept = Object.entries(previous.options ?? {}).filter( + ([key]) => !managed.has(key) && !heads.some((head) => key.startsWith(head)), + ); const options = { ...Object.fromEntries(kept), ...(next.options ?? {}) }; const summary = next.summary_item === undefined && previous.summary_item !== undefined diff --git a/B05_Profile/B05_Profile_UI_Structures_Define.ts b/B05_Profile/B05_Profile_UI_Structures_Define.ts new file mode 100644 index 000000000..4c5ecf599 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Structures_Define.ts @@ -0,0 +1,217 @@ +/* ============================================================================= + * B05_Profile_UI_Structures_Define.ts + * 구조물 정의 → 「구조물 배치」 칸 모양 (PLAN 48-3 · 48-4) — 화면 요소 없는 순수 규칙. + * + * 정의(`resources/master_template/구조물정의.json` · GET /structure-types 가 실어 줌)를 읽어 + * 칸 차례 · 격자 · 안 씀 · 규칙(상위 값 → 하위 선택지 · 보이기 · 기본값)을 낸다. 틀 = + * `docs/raw/M02_구조물옵션정의/정의_틀.md`. 선택지를 줄이면(숨김) 옛 값은 그 값만 남겨 보임. + * 그리기는 `_Structures_Panel_Options`(레지스트리 칸) · `_Structures_Define_Form`(시설 폼 칸). + * 값에 따른 계산 · 연동 제어는 여기 없음(48-5). + * import 없음 — 시험이 이 파일만 옮겨 돌림. + * ========================================================================== */ + +/** 정의가 옵션 하나에 실어 주는 몫 — 칸 그리기가 쓰는 것만. */ +export interface DefinedOption { + key: string; + label: string; + input: string; + choices: string[]; + default: string | number | null; + visible?: boolean; + /** 거짓 = 「안 씀」(정의 틀 3장) — 칸을 안 그림 · 옛 값은 그대로 남음. */ + enabled?: boolean; +} + +/** 상위 → 하위 규칙 한 줄(정의 `규칙`) — 상위 칸 값이 `만약.값들` 중 하나면 하위 칸에 `그러면` 을 씀. */ +export interface DefinedRule { + 종류?: string; + 만약: { 옵션: string; 값들: Array }; + 그러면: { + 옵션: string; + /** 하위 칸 선택지를 이 목록으로 좁힘. */ + 선택지?: string[]; + /** 하위 칸을 보이거나 가림. */ + 보이기?: boolean; + /** 상위 값이 바뀌어 이 줄이 걸리면 하위 칸 값을 이 값으로. */ + 기본값?: string | number | null; + }; +} + +/** 배치 격자 한 칸 — 정의 `배치` 의 `{줄, 칸, 너비}`(1부터). */ +export interface DefinedSpot { + 줄?: number; + 칸?: number; + 너비?: number; +} + +export interface DefinedType { + options: DefinedOption[]; + /** 종류 덧칸 — 기준 측점 칸 보임(기본 보임). */ + show_anchor?: boolean; + /** 옵션 키 → 배치 칸. 비면 정의 옵션 차례대로 2열. */ + spots?: Record | null; + rules?: DefinedRule[] | null; +} + +/** 칸 하나의 지금 모양 — 규칙을 건 뒤. */ +export interface OptionState { + visible: boolean; + choices: string[]; +} + +const whole = (n: unknown): number | null => + typeof n === "number" && Number.isInteger(n) && n >= 1 ? n : null; + +/** 그릴 옵션 차례 — 배치가 있으면 (줄, 칸) 차례 · 없는 칸은 뒤에 정의 차례로. 안 씀 · 숨김은 뺌. */ +export function orderedOptions(type: { + options: T[]; + spots?: Record | null; +}): T[] { + const spots = type.spots ?? {}; + const rank = (key: string): [number, number] => { + const spot = spots[key]; + return [whole(spot?.줄) ?? Infinity, whole(spot?.칸) ?? Infinity]; + }; + return type.options + .map((option, index) => ({ option, index, at: rank(option.key) })) + .filter(({ option }) => option.enabled !== false && option.visible !== false) + .sort((a, b) => a.at[0] - b.at[0] || a.at[1] - b.at[1] || a.index - b.index) + .map(({ option }) => option); +} + +/** 한 줄 칸 수 — 배치가 쓰는 가장 오른쪽 칸(1 ~ 6) · 배치 없으면 2. */ +export function columnsOf(type: { spots?: Record | null }): number { + let widest = 0; + for (const spot of Object.values(type.spots ?? {})) { + const col = whole(spot.칸) ?? 1; + widest = Math.max(widest, col + (whole(spot.너비) ?? 1) - 1); + } + return widest ? Math.min(Math.max(widest, 2), 6) : 2; +} + +/** 칸 하나의 CSS 격자 자리 — 배치 없으면 null(흐름대로). */ +export function gridPlace( + spot: DefinedSpot | undefined, + columns: number, +): { row: string; column: string } | null { + if (!spot) return null; + const row = whole(spot.줄); + const col = whole(spot.칸); + const span = Math.min(whole(spot.너비) ?? 1, columns); + return { + row: row ? String(row) : "", + column: col ? `${Math.min(col, columns)} / span ${span}` : `span ${span}`, + }; +} + +/** 기준 측점 칸을 보일지 — 정의가 끈 때만 가림. */ +export function showsAnchor(type: { show_anchor?: boolean }): boolean { + return type.show_anchor !== false; +} + +const same = (a: unknown, b: unknown): boolean => String(a ?? "") === String(b ?? ""); + +/** 규칙을 지금 값에 건 칸 모양 — 걸린 줄의 선택지 · 보이기가 정의 기본을 덮음(뒤 줄이 이김). */ +export function ruleStates( + type: DefinedType, + values: Record, +): Map { + const states = new Map(); + for (const option of type.options) + states.set(option.key, { visible: true, choices: [...option.choices] }); + for (const rule of type.rules ?? []) { + const state = states.get(rule.그러면.옵션); + // 가려진 상위 칸에 매인 줄은 안 걸림 — 가림이 사슬로 이어짐(정의 차례대로 적용) + if (!state || states.get(rule.만약.옵션)?.visible === false) continue; + if (!hits(rule, values)) continue; + const pick = rule.그러면.선택지; + if (pick) state.choices = pick.filter((c) => state.choices.includes(c)); + if (typeof rule.그러면.보이기 === "boolean") state.visible = rule.그러면.보이기; + } + return states; +} + +const hits = (rule: DefinedRule, values: Record): boolean => + rule.만약.값들.some((value) => same(value, values[rule.만약.옵션])); + +/** 상위 값이 바뀌었을 때 하위 칸에 넣을 기본값 `{하위 키: 값}` — 걸린 줄 중 기본값이 있는 것만. */ +export function ruleDefaults( + type: DefinedType, + parent: string, + values: Record, +): Record { + const out: Record = {}; + for (const rule of type.rules ?? []) { + const value = rule.그러면.기본값; + if (rule.만약.옵션 !== parent || value === undefined || value === null) continue; + if (hits(rule, values)) out[rule.그러면.옵션] = value; + } + return out; +} + +/** 지금 정의에 없는 키의 값 — 「지난 정의 값」 읽기만 칸으로 보임. `known` = 폼이 아는 키 전부. */ +export function staleValues( + values: Record | undefined, + known: Iterable, +): Array<[string, string | number]> { + const keys = new Set(known); + return Object.entries(values ?? {}).filter(([key]) => !keys.has(key)); +} + +/** 그룹 형상 칸(정의 틀 4장) — 길이 · 높이 · 기준 측점 전 · 후 마다 받기 켬끔 + 뜻. */ +export type ShapeName = "길이" | "높이" | "전" | "후"; +export const SHAPE_FIELDS: ReadonlyArray<{ name: ShapeName; suffix: string; label: string }> = [ + { name: "길이", suffix: "length_m", label: "길이 (m)" }, + { name: "높이", suffix: "height_m", label: "높이 (m)" }, + { name: "전", suffix: "before_m", label: "기준측점 전 (m)" }, + { name: "후", suffix: "after_m", label: "기준측점 후 (m)" }, +]; +export type DefinedShape = Partial>; + +/** 그룹 형상 값 저장 키 — 구조물 옵션에 `{그룹 id}__{칸}` 으로 둠. */ +export const shapeKey = (groupId: string, suffix: string): string => `${groupId}__${suffix}`; + +/** 그룹이 받는 형상 칸 — 받기 켠 것만 정해진 차례로. */ +export function shapeFields( + shape: DefinedShape | undefined, +): Array<{ suffix: string; label: string; meaning: string }> { + return SHAPE_FIELDS.filter((f) => shape?.[f.name]?.받기 === true).map((f) => ({ + suffix: f.suffix, + label: f.label, + meaning: shape?.[f.name]?.뜻 ?? "", + })); +} + +/** 정의 한 벌(GET /m02/structure-definition) 중 화면이 쓰는 칸. */ +export interface StructureDefinition { + 판?: number; + 구조물군?: Record; + 종류?: Record }>; + 그룹?: Record>; + 배치?: Record>; + 규칙?: DefinedRule[]; +} + +/** 종류 목록에 정의 덧칸을 얹음 — 구조물군 이름 · 기준 측점 · 동작 · 배치 · 규칙 · 그룹 형상. + * 정의를 못 받았으면(null) 그대로 — 지금 화면과 같음. */ +export function withDefinition< + T extends { type_id: string; group: string; groups?: Array<{ id: string }> }, +>(types: T[], definition: StructureDefinition | null): T[] { + if (!definition) return types; + return types.map((type) => { + const kind = definition.종류?.[type.type_id] ?? {}; + const shapes = new Map((definition.그룹?.[type.type_id] ?? []).map((g) => [g.id, g.형상])); + return { + ...type, + group_name: definition.구조물군?.[type.group], + show_anchor: kind.기준측점, + behaviors: kind.동작 ?? [], + spec_names: kind.이름 ?? {}, + spots: definition.배치?.[type.type_id] ?? null, + rules: (definition.규칙 ?? []).filter((rule) => rule.종류 === type.type_id), + ...(type.groups + ? { groups: type.groups.map((g) => ({ ...g, shape: shapes.get(g.id) })) } + : {}), + }; + }); +} diff --git a/B05_Profile/B05_Profile_UI_Structures_Define_Form.ts b/B05_Profile/B05_Profile_UI_Structures_Define_Form.ts new file mode 100644 index 000000000..e3e44d790 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Structures_Define_Form.ts @@ -0,0 +1,85 @@ +/* ============================================================================= + * B05_Profile_UI_Structures_Define_Form.ts + * 손코딩 시설 폼(동작 「계곡통과시설」)에 정의를 붙임(PLAN 48-3 · 48-4) — 숨긴 선택지 · 규칙. + * + * · 칸은 `labeled(..., 옵션키)` 가 붙인 `data-option-keys` 표식으로 찾음(키 하나인 칸만) · 찾는 자리 = + * 폼 본문 전체(그룹 상자로 옮겨 간 칸까지). + * · 칸 배치 · 이름은 손코딩 그대로(동작이 그림) — 안 씀은 끈 옵션과 같은 길(`_Drainage_Facility_Hidden`). + * · 선택지 = 정의 · 규칙 선택지 — 새 선택지는 보기를 더하고 밖 보기는 `hidden` 으로만 가림(옛 값은 그대로). + * 여러 키를 함께 적는 칸(유입구 구조 · 본체 규격)은 키 하나 표식이 아니라 안 건드림. + * · 규칙이 가린 칸 = `is-rule-hidden`(끈 옵션 표식 `is-option-hidden` 과 따로). + * ========================================================================== */ + +import type { StructureType } from "./B05_Profile_Api_Structures"; +import { ruleDefaults, ruleStates } from "./B05_Profile_UI_Structures_Define"; +import { isOptionVisible } from "./B05_Profile_UI_Structures_Summary"; + +/** 시설 폼이 가릴 옵션 키 — 「구조물 옵션 보이기」에서 끈 것 + 정의 「안 씀」. */ +export function hiddenOptionKeys(type: StructureType | null): string[] { + return (type?.options ?? []).filter((o) => !isOptionVisible(o)).map((o) => o.key); +} + +export interface FacilityDefinition { + /** 지금 종류 정의로 칸 모양을 맞춤 — 시설 폼을 채운 뒤마다 부름. */ + apply: (type: StructureType | null) => void; +} + +export function createFacilityDefinition(root: HTMLElement): FacilityDefinition { + let type: StructureType | null = null; + const wrapOf = (key: string): HTMLElement | null => + [...root.querySelectorAll("[data-option-keys]")].find( + (node) => node.dataset.optionKeys === key, + ) ?? null; + const controlOf = (key: string): HTMLInputElement | HTMLSelectElement | null => + wrapOf(key)?.querySelector("select, input") ?? null; + const values = (): Record => + Object.fromEntries((type?.options ?? []).map((o) => [o.key, controlOf(o.key)?.value ?? ""])); + + const paint = (): void => { + if (!type) return; + const states = ruleStates(type, values()); + for (const option of type.options) { + const wrap = wrapOf(option.key); + const state = states.get(option.key); + if (!wrap || !state) continue; + wrap.classList.toggle("is-rule-hidden", !state.visible); + const control = controlOf(option.key); + if (!(control instanceof HTMLSelectElement)) continue; + // 정의에 새로 든 선택지는 보기를 더하고, 정의 · 규칙 밖 보기는 가림(빈 보기 · 지금 값은 그대로) + const have = new Set([...control.options].map((item) => item.value)); + for (const choice of state.choices) + if (!have.has(choice)) control.add(new Option(choice, choice)); + for (const item of control.options) { + item.hidden = + !!item.value && !state.choices.includes(item.value) && item.value !== control.value; + } + } + }; + + // 상위 칸이 바뀌면 걸린 규칙의 기본값을 하위 칸에 넣고, 그 칸의 change 로 폼 반영(시설 폼 emit)을 잇는다. + root.addEventListener("change", (event) => { + if (!type) return; + const key = (event.target as HTMLElement).closest("[data-option-keys]")?.dataset + .optionKeys; + if (key) { + for (const [child, value] of Object.entries(ruleDefaults(type, key, values()))) { + const control = controlOf(child); + if (!control || control.value === String(value)) continue; + control.value = String(value); + control.dispatchEvent(new Event("change", { bubbles: true })); + } + } + paint(); + }); + + return { + apply(next) { + type = next; + root + .querySelectorAll(".is-rule-hidden") + .forEach((node) => node.classList.remove("is-rule-hidden")); + root.querySelectorAll("option[hidden]").forEach((o) => (o.hidden = false)); + paint(); + }, + }; +} diff --git a/B05_Profile/B05_Profile_UI_Structures_Fields.ts b/B05_Profile/B05_Profile_UI_Structures_Fields.ts index 2581b79ee..ed31f4ce4 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Fields.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Fields.ts @@ -74,7 +74,12 @@ export function optionControl( const tip = option.not_in_table ?? option.empty_means ?? ""; if (option.input === "select") { const blank = option.required ? "— 선택 —" : "— 안 정함 —"; - const element = select([["", blank], ...option.choices.map((c) => [c, c] as [string, string])]); + // 정의에서 뺀 선택지(48)를 고른 옛 값 — 그 값만 남겨 보임(값을 잃지 않음) + const kept = value && !option.choices.includes(value) ? [value] : []; + const element = select([ + ["", blank], + ...[...option.choices, ...kept].map((c) => [c, c] as [string, string]), + ]); element.value = value; if (tip) element.title = tip; return element; diff --git a/B05_Profile/B05_Profile_UI_Structures_Form.ts b/B05_Profile/B05_Profile_UI_Structures_Form.ts index 980e75b36..c0e28f3fc 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Form.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Form.ts @@ -24,6 +24,10 @@ import { import { createFacilityOptionsForm } from "./B05_Profile_UI_Drainage_Facility"; import { buildSummaryRow, type SummaryRow } from "./B05_Profile_UI_Structures_Summary_Row"; import { createGroupBoxes, type GroupBoxes } from "./B05_Profile_UI_Structures_Groups"; +import { + createFacilityDefinition, + type FacilityDefinition, +} from "./B05_Profile_UI_Structures_Define_Form"; export interface StructuresFormElements { root: HTMLElement; @@ -46,6 +50,10 @@ export interface StructuresFormElements { /** 버튼 묶음 — 패널이 그 앞에 메모 칸을 끼워 넣는다. */ actions: HTMLElement; facilityOptions: ReturnType; + /** 「지난 정의 값」 읽기만 상자(48-3) — 지금 정의에 없는 키의 값. */ + staleBox: HTMLElement; + /** 시설 폼(손코딩)에 정의 붙이기(48-3 · 48-4) — 숨긴 선택지 · 규칙. */ + facilityDefinition: FacilityDefinition; primary: HTMLButtonElement; removeButton: HTMLButtonElement; resetButton: HTMLButtonElement; @@ -148,15 +156,27 @@ export function buildStructuresForm(options: { const summary = buildSummaryRow(); summary.root.hidden = true; const groupBoxes = createGroupBoxes(); + // 배관 명세 컨테이너 「구조물 조합」 잇기 = 그룹 값(`group_items`)과 같은 자리 · 같은 [고르기] 길(PLAN 52-1) + facilityOptions.bindLinks({ + get: groupBoxes.get, + pick: groupBoxes.pick, + clear: groupBoxes.clear, + set: groupBoxes.setOne, + }); + groupBoxes.watch(facilityOptions.paintLinks); + const staleBox = document.createElement("fieldset"); + staleBox.className = "b05-structure__group b05-structure__stale"; + staleBox.hidden = true; body.append( typeRow, ownerNote, positionRow, + summary.root, // 종류 단위 「구조물 조합」 잇기 — 기준 측점 바로 아래(PLAN 52-1 · 컨테이너 없는 종류) positionDivider, optionRow, facilityOptions.root, - summary.root, + staleBox, groupBoxes.root, actions, ); @@ -178,6 +198,8 @@ export function buildStructuresForm(options: { optionRow, actions, facilityOptions, + staleBox, + facilityDefinition: createFacilityDefinition(body), primary, removeButton, resetButton, diff --git a/B05_Profile/B05_Profile_UI_Structures_Groups.ts b/B05_Profile/B05_Profile_UI_Structures_Groups.ts index 1f7fce79d..f41119d99 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Groups.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Groups.ts @@ -10,6 +10,8 @@ * 독립 기슭막이 채널 키는 `hiddenFormKeys` 로 넓힘). * · 그룹 연결 값(`{그룹 id: 참조}`)은 정의가 사라진 그룹 것도 그대로 쥠(값 보존 · 경고 한 줄). * · 저장은 패널이 한다(`group_items` 칸) — 여기는 값만 쥠. + * · 그룹 형상 칸(48 정의 그룹 `형상` · 길이 · 높이 · 전 · 후 받기 켬끔) — 값은 구조물 옵션 + * `{그룹 id}__{칸}` 으로 저장(패널이 옵션에 얹음) · 계산은 안 함(48-5). * ========================================================================== */ import { openItemPicker } from "@ui/ui_template_item_picker"; @@ -17,6 +19,10 @@ import { pipeStructureId } from "@ui/ui_template_item_picker_project"; import type { StructureType } from "./B05_Profile_Api_Structures"; import { hiddenFormKeys } from "./B05_Profile_UI_Drainage_Facility_Merge"; import { buildSummaryRow, type SummaryRow } from "./B05_Profile_UI_Structures_Summary_Row"; +import { isOptionVisible } from "./B05_Profile_UI_Structures_Summary"; +import { SHAPE_FIELDS, shapeFields, shapeKey } from "./B05_Profile_UI_Structures_Define"; +import { field, numberInput } from "./B05_Profile_UI_Structures_Fields"; +import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe"; import { GROUP_JOIN, type GroupItems, @@ -33,8 +39,25 @@ export interface GroupBoxes { set: (items: GroupItems) => void; /** 한 그룹 값 — null = 해제. */ setOne: (groupId: string, item: SummaryItem | null) => void; - /** [고르기] · [해제] 배선 — 패널이 한 번 부른다. */ - bind: (handlers: { pick: (groupId: string) => void; clear: (groupId: string) => void }) => void; + /** [고르기] · [해제] · 형상 칸 고침 배선 — 패널이 한 번 부른다. */ + bind: (handlers: GroupHandlers) => void; + /** 형상 칸 값 `{그룹 id}__{칸}: 수` — 빈 칸은 뺌. 옵션에 얹어 저장. */ + shapes: () => Record; + /** 지금 종류 그룹의 형상 키 전부(받기 끈 칸 포함) — 저장 병합 · 지난 정의 값 판정. */ + shapeKeys: (type?: StructureType | null) => string[]; + /** 구조물 옵션에서 형상 값을 실음(폼 불러오기). */ + setShapes: (options: Record | undefined) => void; + /** 명세 컨테이너 잇기 줄(PLAN 52-1)이 쓰는 [고르기] · [해제] — 그룹 상자와 같은 길. */ + pick: (groupId: string) => void; + clear: (groupId: string) => void; + /** 값이 바뀔 때마다 부름(명세 컨테이너 잇기 줄 다시 그림). */ + watch: (listener: () => void) => void; +} + +interface GroupHandlers { + pick: (groupId: string) => void; + clear: (groupId: string) => void; + change?: () => void; } export function createGroupBoxes(): GroupBoxes { @@ -43,7 +66,14 @@ export function createGroupBoxes(): GroupBoxes { root.dataset.b05Groups = ""; root.hidden = true; let items: GroupItems = {}; - let handlers: { pick: (id: string) => void; clear: (id: string) => void } | null = null; + let handlers: GroupHandlers | null = null; + let shapes: Record = {}; + let current: StructureType | null = null; + const listeners: Array<() => void> = []; + const changed = (): void => listeners.forEach((listener) => listener()); + const keysOf = (type: StructureType | null | undefined): string[] => + (type?.groups ?? []).flatMap((g) => SHAPE_FIELDS.map((f) => shapeKey(g.id, f.suffix))); + const shapeInputs = new Map(); const rows = new Map(); /** 옮긴 칸 · 제자리 표지 */ const moved: Array<{ node: HTMLElement; mark: Comment }> = []; @@ -83,13 +113,16 @@ export function createGroupBoxes(): GroupBoxes { render(type, scopes, facility) { restore(); rows.clear(); - const groups = type?.groups ?? []; + shapeInputs.clear(); + current = type; + // 명세 종류(PLAN 52 · 배관)는 컨테이너가 잇기 줄을 가짐 — 그룹 상자를 안 세움 + const groups = type && STRUCTURE_SPECS[type.type_id] ? [] : (type?.groups ?? []); const boxes: HTMLElement[] = []; bodies = []; // 「구조물 옵션 보이기」에서 끈 칸 — 시설 폼 밖으로 옮긴 칸도 같은 규칙으로 가림(Hidden 과 같은 식) const off = hiddenFormKeys( facility, - (type?.options ?? []).filter((o) => o.visible === false).map((o) => o.key), + (type?.options ?? []).filter((o) => !isOptionVisible(o)).map((o) => o.key), ); for (const group of groups) { const keys = hiddenFormKeys(facility, group.options); @@ -133,6 +166,19 @@ export function createGroupBoxes(): GroupBoxes { } } } + for (const shape of shapeFields(group.shape)) { + const key = shapeKey(group.id, shape.suffix); + const input = numberInput("0.1", "0"); + input.value = shapes[key] ?? ""; + input.dataset.shapeKey = key; + input.title = shape.meaning; + input.addEventListener("change", () => { + shapes = { ...shapes, [key]: input.value.trim() }; + handlers?.change?.(); + }); + shapeInputs.set(key, input); + body.append(field(shape.label, input)); + } if (!body.childElementCount) { const none = document.createElement("p"); none.className = "b05-structure__owner-note"; @@ -150,7 +196,10 @@ export function createGroupBoxes(): GroupBoxes { boxes.push(box); bodies.push(body); } - const stale = type ? paintStale(new Set(groups.map((g) => g.id))) : null; + const stale = + type && !STRUCTURE_SPECS[type.type_id] + ? paintStale(new Set(groups.map((g) => g.id))) + : null; root.replaceChildren(...boxes, ...(stale ? [stale] : [])); root.hidden = !type || (!boxes.length && !stale); // 잠금은 계곡 통과 시설 서브폼(`inert`)을 따름 — 레지스트리 칸은 칸마다 disabled 라 그대로 @@ -167,6 +216,7 @@ export function createGroupBoxes(): GroupBoxes { set(next) { items = { ...next }; for (const [id, row] of rows) row.set(items[id] ?? null); + changed(); }, setOne(groupId, item) { if (item) items = { ...items, [groupId]: item }; @@ -175,10 +225,31 @@ export function createGroupBoxes(): GroupBoxes { items = rest; } rows.get(groupId)?.set(item); + changed(); }, + pick: (groupId) => handlers?.pick(groupId), + clear: (groupId) => handlers?.clear(groupId), + watch: (listener) => listeners.push(listener), bind(next) { handlers = next; }, + shapes() { + const out: Record = {}; + for (const key of shapeInputs.keys()) { + const text = (shapes[key] ?? "").trim(); + if (text !== "" && Number.isFinite(Number(text))) out[key] = Number(text); + } + return out; + }, + shapeKeys: (type) => keysOf(type === undefined ? current : type), + setShapes(options) { + shapes = Object.fromEntries( + Object.entries(options ?? {}) + .filter(([key]) => key.includes(GROUP_JOIN)) + .map(([key, value]) => [key, String(value)]), + ); + for (const [key, input] of shapeInputs) input.value = shapes[key] ?? ""; + }, }; } @@ -193,6 +264,8 @@ export function bindSummaryPickers(opts: { editingId: () => string | null; selectedPipeChainage: () => number | null; commitSummary: () => void; + /** 형상 칸 고침 반영 — 고치는 중이면 곧바로(새 항목은 [추가]가 확정). */ + liveCommit: () => void; }): void { const base = (): string | null => { const pipe = opts.selectedPipeChainage(); @@ -214,6 +287,7 @@ export function bindSummaryPickers(opts: { }, }); opts.groups.bind({ + change: opts.liveCommit, pick: (groupId) => { const id = base(); void pick( diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index 149903454..7f1c2ffe0 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -23,7 +23,11 @@ import { 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 { renderOptionFields as renderOptionFieldsInto } from "./B05_Profile_UI_Structures_Panel_Options"; +import { + hasSpecContainers, + paintStale, + renderOptionFields as renderOptionFieldsInto, +} from "./B05_Profile_UI_Structures_Panel_Options"; import { bindSummaryPickers } from "./B05_Profile_UI_Structures_Groups"; import { buildStructuresForm } from "./B05_Profile_UI_Structures_Form"; import { @@ -38,7 +42,8 @@ import { loadPipeForm as loadPipeFormInto, } from "./B05_Profile_UI_Structures_Panel_Commit"; import { renderStructureList } from "./B05_Profile_UI_Structures_List"; -import { isOptionVisible } from "./B05_Profile_UI_Structures_Summary"; +import { showsAnchor } from "./B05_Profile_UI_Structures_Define"; +import { hiddenOptionKeys } from "./B05_Profile_UI_Structures_Define_Form"; import { stationLabel as formatStation } from "../common_util/common_util_station"; import { PIPE_MATCH_M, resolveSelection } from "../A00_Common/design/design_selection"; @@ -52,11 +57,9 @@ import { hasComputedRange, readBeforeOption, readLengthOption, -} from "./B05_Profile_UI_Structures_Panel_Types"; -import type { - PipeFacilityItem, - StructuresCallbacks, - StructuresSection, + type PipeFacilityItem, + type StructuresCallbacks, + type StructuresSection, } from "./B05_Profile_UI_Structures_Panel_Types"; export function createStructuresSection( @@ -86,6 +89,8 @@ export function createStructuresSection( optionRow, actions, facilityOptions, + staleBox, + facilityDefinition, primary, removeButton, resetButton, @@ -222,7 +227,7 @@ export function createStructuresSection( !!type && !managed && type.placement === "interval" && !hasComputedRange(type); positionRow.hidden = !type; positionDivider.hidden = !type; - summary.root.hidden = !type; + summary.root.hidden = !type || hasSpecContainers(type); // 컨테이너 종류는 컨테이너마다 잇기(52-1) startFields.wrap.hidden = !isInterval; endFields.wrap.hidden = !isInterval; primary.disabled = !type; @@ -232,6 +237,8 @@ export function createStructuresSection( ? `${type.design_owner}에서 관리 — 여기서 넣어도 제원·수량은 그쪽 값을 씁니다.` : ""; ownerNote.hidden = !type?.design_owner; + // 정의가 기준 측점을 끈 종류(48-3) — 기준 측점이 곧 위치인 종류(점형 · 범위 계산)는 늘 보임 + anchorFields.wrap.hidden = isInterval && !showsAnchor(type!); anchorFields.wrap.querySelector("span")!.textContent = isInterval ? "기준 측점 (비우면 시작)" : "기준 측점"; @@ -244,13 +251,14 @@ export function createStructuresSection( designFlow: number | null = null, ): void { const kind = facilityFormKind(currentType()); - facilityOptions.setFacility(kind, options ?? newDefaults(), designFlow); + facilityOptions.setChainage(selectedPipeChainage ?? tempPipeChainage); // 관 길이(자동 · 52-1) + facilityOptions.setFacility(kind, options ?? newDefaults(), designFlow, currentType()); + if (kind) + paintStale(staleBox, currentType(), options, kind, groupBoxes.shapeKeys(currentType())); // 「구조물 옵션 보이기」에서 끈 옵션은 시설 폼에서도 가림(값은 옛 값 그대로 저장) - facilityOptions.setHiddenOptions( - kind, - (currentType()?.options ?? []).filter((o) => !isOptionVisible(o)).map((o) => o.key), - ); + facilityOptions.setHiddenOptions(kind, hiddenOptionKeys(currentType())); regroup(); + facilityDefinition.apply(kind ? currentType() : null); // 숨긴 선택지 · 규칙(48-3 · 48-4) syncOptionLock(); } @@ -321,6 +329,8 @@ export function createStructuresSection( /** 타입의 옵션 스키마대로 입력 칸을 다시 그린다 — 그리는 규칙은 `_Structures_Panel_Options`. */ function renderOptionFields(values?: Record): void { renderOptionFieldsInto(optionContext, values ?? newDefaults()); + if (!facilityFormKind(currentType())) + paintStale(staleBox, currentType(), values, null, groupBoxes.shapeKeys(currentType())); regroup(); } @@ -506,8 +516,7 @@ export function createStructuresSection( }, summary: () => summary.get(), setSummary: (value) => summary.set(value), - groupItems: () => groupBoxes.get(), - setGroupItems: (value) => groupBoxes.set(value), + groupBoxes, }; bindStructuresEvents(panelContext); // 집계 항목(본체 · 옵션 그룹) — [고르기]는 21-5 모달 · 배선은 `_Structures_Groups` @@ -518,6 +527,7 @@ export function createStructuresSection( editingId: () => editingId, selectedPipeChainage: () => selectedPipeChainage, commitSummary, + liveCommit, }); body.insertBefore(field("메모", memoField), actions); syncButtons(); @@ -530,11 +540,11 @@ export function createStructuresSection( hasSelection: () => editingId !== null || selectedPipeChainage !== null, setTypes(next) { types = next; - const groups = [...new Set(next.map((t) => t.group))]; - // 첫 항목은 빈칸 — 진입·리셋 상태에서 군·종류를 강제로 고르게 두지 않는다. + const groups = new Map(next.map((t) => [t.group, t.group_name] as const)); + // 첫 항목은 빈칸 — 진입·리셋 상태에서 군·종류를 강제로 고르게 두지 않는다. 이름은 정의(48) 먼저. groupSelect.replaceChildren( new Option("— 선택 —", ""), - ...groups.map((group) => new Option(GROUP_LABELS[group] ?? group, group)), + ...[...groups].map(([g, name]) => new Option(name ?? GROUP_LABELS[g] ?? g, g)), ); groupSelect.value = ""; syncTypeOptions(); diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts index 86196870c..e24155203 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts @@ -18,6 +18,7 @@ import type { FacilityAttributes, FacilityOptionsForm } from "./B05_Profile_UI_D import type { StationFields } from "./B05_Profile_UI_Structures_Fields"; import { splitPavementRange } from "./B05_Profile_UI_Structures_Pavement"; import { mergeStructureOptions } from "./B05_Profile_UI_Structures_Summary"; +import type { GroupBoxes } from "./B05_Profile_UI_Structures_Groups"; import { readGroupItems, readSummaryItem, @@ -75,13 +76,13 @@ export interface StructuresCommitContext { summary: () => SummaryItem | null; setSummary: (value: SummaryItem | null) => void; /** 옵션 그룹 보조 컨테이너마다 고른 항목(PLAN 46-3) — 저장은 `group_items` 칸. */ - groupItems: () => GroupItems; - setGroupItems: (value: GroupItems) => void; + /** 옵션 그룹 상자 — 집계 항목(46-3) · 형상 칸 값(48-3). */ + groupBoxes: GroupBoxes; } /** 저장할 그룹 연결 — 고른 것이 있을 때만 칸을 실음(빈 묶음 = 칸 없음). */ function groupField(ctx: StructuresCommitContext): { group_items?: GroupItems } { - const items = ctx.groupItems(); + const items = ctx.groupBoxes.get(); return Object.keys(items).length ? { group_items: items } : {}; } @@ -105,7 +106,8 @@ export function loadForm(ctx: StructuresCommitContext, target: StructureInstance ctx.memoField.value = target.memo ?? ""; ctx.renderOptionFields(target.options); ctx.setSummary(readSummaryItem(target.summary_item)); - ctx.setGroupItems(readGroupItems(target.group_items)); + ctx.groupBoxes.set(readGroupItems(target.group_items)); + ctx.groupBoxes.setShapes(target.options); // 구간 직접 입력 시절에 저장된 항목 호환 — 길이·전길이 옵션이 없으면 저장된 // 시작~종료·기준점에서 역산해 채워 범위 표시·재저장이 이어지게 한다(2026-08-19). if (target.start_m != null && target.end_m != null && target.end_m > target.start_m) { @@ -134,7 +136,8 @@ export function loadForm(ctx: StructuresCommitContext, target: StructureInstance ctx.memoField.value = ""; ctx.renderOptionFields(); ctx.setSummary(null); - ctx.setGroupItems({}); + ctx.groupBoxes.set({}); + ctx.groupBoxes.setShapes({}); ctx.syncFacilityForm(); } [ctx.anchorFields, ctx.startFields, ctx.endFields].forEach((fields) => fields.clearInvalid()); @@ -182,7 +185,8 @@ export function loadPipeForm(ctx: StructuresCommitContext, pipe: PipeFacilityIte ctx.memoField.value = ""; ctx.syncFacilityForm(pipe.options ?? {}, pipe.design_flow_m3s ?? null); ctx.setSummary(readSummaryItem(pipe.summary_item)); - ctx.setGroupItems(readGroupItems(pipe.group_items)); + ctx.groupBoxes.set(readGroupItems(pipe.group_items)); + ctx.groupBoxes.setShapes(pipe.options); [ctx.anchorFields, ctx.startFields, ctx.endFields].forEach((fields) => fields.clearInvalid()); ctx.syncPlacementFields(); ctx.syncButtons(); @@ -191,9 +195,11 @@ export function loadPipeForm(ctx: StructuresCommitContext, pipe: PipeFacilityIte } export function readOptions(ctx: StructuresCommitContext): Record { - // 서브폼이 담당하는 타입(독립 기슭막이)은 서브폼이 값을 읽는다. - if (ctx.facilityFormKind(ctx.currentType())) return ctx.facilityOptions.readOptions(); - const values: Record = {}; + // 서브폼이 담당하는 타입(독립 기슭막이)은 서브폼이 값을 읽는다. 그룹 형상 칸(48-3)은 둘 다 얹음. + const shapes = ctx.groupBoxes.shapes(); + if (ctx.facilityFormKind(ctx.currentType())) + return { ...ctx.facilityOptions.readOptions(), ...shapes }; + const values: Record = { ...shapes }; ctx.optionInputs.forEach((input) => { // 빈 칸은 아예 넣지 않는다 — 빈 숫자를 0으로 저장하면 "0으로 확정"과 구분이 안 된다. if (input.isEmpty()) return; @@ -241,7 +247,7 @@ export async function commit(ctx: StructuresCommitContext, live = false): Promis } // 배수관 등 계곡 통과 시설은 점형 — 기준 측점 하나뿐이다(2026-08-17 지시 2). const attributes: FacilityAttributes = { facility: type.type_id as PipeFacility }; - const options = ctx.facilityOptions.readOptions(); + const options = readOptions(ctx); if (Object.keys(options).length) attributes.options = options; // 집계 항목 — 미지정(null)도 그대로 실어 옛 지정을 풀 수 있게 한다. attributes.summary_item = ctx.summary(); @@ -363,7 +369,7 @@ export async function commit(ctx: StructuresCommitContext, live = false): Promis options: mergeStructureOptions( previous, type.type_id, - ctx.optionInputs.map((entry) => entry.key), + [...ctx.optionInputs.map((entry) => entry.key), ...ctx.groupBoxes.shapeKeys()], records[0].options, ), }; diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts index f557893d3..97032d397 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts @@ -11,7 +11,21 @@ import { isB05Option, type StructureType } from "./B05_Profile_Api_Structures"; import type { PipeFacility } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { field, optionControl } from "./B05_Profile_UI_Structures_Fields"; -import { isOptionVisible } from "./B05_Profile_UI_Structures_Summary"; +import { + columnsOf, + gridPlace, + orderedOptions, + ruleDefaults, + ruleStates, + staleValues, +} from "./B05_Profile_UI_Structures_Define"; +import { FORM_OPTION_KEYS } from "./B05_Profile_UI_Drainage_Facility_Merge"; +import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe"; +import { specKeys } from "../A00_Common/design/design_structure_spec"; + +/** 구조물 명세 컨테이너로 그리는 종류인지 — 그때 종류 단위 「구조물 조합」 줄은 가림(PLAN 52-1). */ +export const hasSpecContainers = (type: StructureType): boolean => + !!STRUCTURE_SPECS[type.type_id]?.containers.length; /** 패널이 쥔 값 중 옵션 그리기에 필요한 것들. 이름은 분리 전 지역변수와 같다. */ export interface OptionRenderContext { @@ -46,39 +60,48 @@ export function renderOptionFields( optionInputs.length = 0; optionRow.replaceChildren(); // 서브폼이 담당하는 타입(계곡 통과 시설·독립 기슭막이)은 여기서 그리지 않는다. + // 차례 · 안 씀 · 숨김은 정의대로(48-3 · `_Structures_Define`). const shown = type && !ctx.facilityFormKind(type) - ? type.options.filter((o) => (ctx.includeDetail || isB05Option(o)) && isOptionVisible(o)) + ? orderedOptions(type).filter((o) => ctx.includeDetail || isB05Option(o)) : []; - if (!shown.length) { + optionRow.style.gridTemplateColumns = ""; + if (!type || !shown.length) { optionRow.hidden = true; ctx.syncOptionLock(); return; } optionRow.hidden = false; + const columns = columnsOf(type); + if (columns !== 2) optionRow.style.gridTemplateColumns = `repeat(${columns}, minmax(0, 1fr))`; + // 규칙(48-4)이 가린 칸은 `optionInputs` 에서 뺌 — 필수 검사 · 저장이 안 봄(옛 값은 병합이 지킴) + const all: Array<{ entry: (typeof optionInputs)[number]; wrap: HTMLElement }> = []; shown.forEach((option) => { // 받은 값만 칸에 — 새 구조물은 부르는 쪽(패널)이 등록부 기본값을 `values` 로 실어 줌(PLAN 46-7). + // 보기는 정의 전부로 세워 옛 값을 잃지 않고, 숨김 · 규칙 선택지는 `applyRules` 가 좁힘. const input = optionControl(option, values[option.key]); const label = `${option.unit ? `${option.label} (${option.unit})` : option.label}${option.not_in_table ? " · 표에 안 쓰임" : ""}`; - // `enabled:false` 는 칸을 남기고 잠그기만 한다 — 값은 기본값이 그대로 저장된다 - // (고를 수 없는 칸이라 제안이 아니라 프로그램 값). - const locked = option.enabled === false; - if (locked && !input.value) input.value = String(option.default ?? ""); - if (locked) { - input.disabled = true; - input.classList.add("is-locked"); - input.title = "지금은 고를 수 없는 항목입니다."; - } const wrap = field(label, input, option.default_basis); wrap.dataset.optionKeys = option.key; // 옵션 그룹 상자가 이 표식으로 칸을 옮김(PLAN 46-3) + // 배치 격자(48-3) — 정의 `배치` 의 줄 · 칸 · 너비 · 없으면 흐름대로 + const place = gridPlace(type.spots?.[option.key], columns); + wrap.style.gridRow = place?.row ?? ""; + wrap.style.gridColumn = place?.column ?? ""; optionRow.append(wrap); - input.addEventListener("change", () => ctx.liveCommit()); - optionInputs.push({ - key: option.key, - required: !locked && !!option.required, - input, - read: () => (option.input === "number" ? Number(input.value) || 0 : input.value), - isEmpty: () => input.value.trim() === "", + // 상위 값이 바뀌면 하위 기본값 · 선택지 · 보이기를 먼저 맞추고 반영(48-4) + input.addEventListener("change", () => { + applyRules(option.key); + ctx.liveCommit(); + }); + all.push({ + wrap, + entry: { + key: option.key, + required: !!option.required, + input, + read: () => (option.input === "number" ? Number(input.value) || 0 : input.value), + isEmpty: () => input.value.trim() === "", + }, }); // 전면 기울기는 품셈 표준경사 표(0.20~0.50) 밖 값도 받는다 — 실무 도면이 S0.7·S0.8 을 // 쓴다(2026-09-09 실무 DWG 확인). **막지 않고 알리기만** 한다: 막으면 실물이 안 들어간다. @@ -110,6 +133,80 @@ export function renderOptionFields( input.addEventListener("change", () => refresh(true)); } }); + /** 규칙 걸기 — `parent` 가 바뀐 칸이면 그 칸에 매인 기본값부터 넣음. */ + function applyRules(parent: string | null): void { + const now = (): Record => + Object.fromEntries(all.map(({ entry }) => [entry.key, entry.input.value])); + if (parent) { + for (const [key, value] of Object.entries(ruleDefaults(type!, parent, now()))) { + const hit = all.find(({ entry }) => entry.key === key); + if (hit) hit.entry.input.value = String(value); + } + } + const next = ruleStates(type!, now()); + optionInputs.length = 0; + for (const { entry, wrap } of all) { + const state = next.get(entry.key); + const off = state?.visible === false; + wrap.classList.toggle("is-rule-hidden", off); // 그룹 상자로 옮겨 가도 같은 표식 + if (!off) optionInputs.push(entry); + if (state && entry.input.tagName.toLowerCase() === "select") + syncChoices(entry.input as HTMLSelectElement, state.choices); + } + } + applyRules(null); ctx.syncOptionLock(); ctx.syncRangeDisplay(); } + +/** 고르기 칸 보기를 규칙 선택지로 — 빈 보기는 남기고, 지금 값이 목록 밖이면 그 값만 남겨 보임. */ +function syncChoices(element: HTMLSelectElement, choices: string[]): void { + const now = [...element.children].slice(1).map((o) => (o as HTMLOptionElement).value); + const keep = element.value && !choices.includes(element.value) ? [element.value] : []; + const want = [...choices, ...keep]; + if (now.length === want.length && now.every((v, i) => v === want[i])) return; + const value = element.value; + const blank = element.children[0]; + element.replaceChildren(blank, ...want.map((c) => new Option(c, c))); + element.value = value; +} + +/** 「지난 정의 값」 상자(48-3) — 지금 정의에 없는 키의 값을 읽기만 칸으로 보임(오류 없음 · 저장은 + * 병합이 옛 값 그대로 지킴). 아는 키 = 정의 옵션 전부(안 씀 포함) + 시설 폼이 적는 칸. */ +export function paintStale( + box: HTMLElement, + type: StructureType | null, + values: Record | undefined, + facility: PipeFacility | null, + /** 그룹 형상 키(`{그룹 id}__{칸}`) — 정의된 그룹 몫이라 지난 값이 아님. */ + shapeKeys: string[] = [], +): void { + // 명세 종류(52) — 명세가 뺀 옛 키(retired)는 지난 정의 값 · 선택 컨테이너 키(머리)는 앎 + const spec = type ? STRUCTURE_SPECS[type.type_id] : undefined; + const own = spec ? specKeys(spec) : { keys: [], prefixes: [] }; + const known = [ + ...shapeKeys, + ...(type?.options ?? []).map((o) => o.key), + ...(facility ? (FORM_OPTION_KEYS[facility] ?? []) : []), + ...own.keys, + ].filter((key) => !spec?.retired.includes(key)); + const stale = type + ? staleValues(values, known).filter(([key]) => !own.prefixes.some((p) => key.startsWith(p))) + : []; + box.replaceChildren(); + box.hidden = !stale.length; + if (!stale.length) return; + const legend = document.createElement("legend"); + legend.textContent = "지난 정의 값"; + const body = document.createElement("div"); + body.className = "b05-structure__grid"; + for (const [key, value] of stale) { + const input = document.createElement("input"); + input.type = "text"; + input.readOnly = true; + input.value = String(value); + input.title = "지금 정의에 없는 칸 — 읽기만 · 계산에 안 쓰임"; + body.append(field(key, input)); + } + box.append(legend, body); +} diff --git a/B05_Profile/B05_Profile_UI_Structures_Summary.ts b/B05_Profile/B05_Profile_UI_Structures_Summary.ts index 9503cd1af..9e29b082b 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Summary.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Summary.ts @@ -8,9 +8,9 @@ * import 없음 — 시험이 이 파일만 옮겨 돌림. * ========================================================================== */ -/** 옵션 보이기 — 서버가 채운 `visible`(21-8)이 false 일 때만 숨김. 없으면 보임. */ -export function isOptionVisible(option: { visible?: boolean }): boolean { - return option.visible !== false; +/** 옵션 보이기 — 서버가 채운 `visible`(21-8)이 false 이거나 정의가 「안 씀」(`enabled:false` · 48)일 때 숨김. */ +export function isOptionVisible(option: { visible?: boolean; enabled?: boolean }): boolean { + return option.visible !== false && option.enabled !== false; } /** 저장할 옵션 — 같은 종류를 고치는 중이면 폼이 안 그린 옛 칸을 이어 붙임. diff --git a/B05_Profile/B05_Profile_UI_Style_Structures.css b/B05_Profile/B05_Profile_UI_Style_Structures.css index 57e713798..bb609526d 100644 --- a/B05_Profile/B05_Profile_UI_Style_Structures.css +++ b/B05_Profile/B05_Profile_UI_Style_Structures.css @@ -515,3 +515,32 @@ .b05-structure__optgroup > .b05-structure__grid.is-locked { opacity: 0.5; } + +/* 구조물 정의 규칙(PLAN 48-4)이 가린 칸 — 그룹 상자로 옮겨 가도 같은 표식 */ +.b05-structure-section .is-rule-hidden { + display: none; +} + +/* 「지난 정의 값」 읽기만 상자(PLAN 48-3) — 지금 정의에 없는 키의 값 */ +.b05-structure__stale input[readonly] { + opacity: 0.7; +} + +/* 구조물 명세 컨테이너(PLAN 52-1) — 상자 차례 · 긴 잇기 이름이 상자를 밀어 넓히지 않게(fieldset 최소 폭) */ +.b05-structure__spec { + display: flex; + flex-direction: column; + gap: 6px; +} + +.b05-structure__spec[hidden] { + display: none; +} + +.b05-structure__spec .b05-structure__group { + min-width: 0; +} + +.b05-structure__spec-add { + align-self: flex-start; +} diff --git a/B06_Section/B06_Section_Engine_Culvert.py b/B06_Section/B06_Section_Engine_Culvert.py index ce97262f9..87fb59003 100644 --- a/B06_Section/B06_Section_Engine_Culvert.py +++ b/B06_Section/B06_Section_Engine_Culvert.py @@ -327,7 +327,8 @@ def _wing_spec(values: dict[str, Any], defaults: dict[str, Any], side: str) -> d height = _number( values.get(f"{prefix}_height_m"), _number(defaults.get(f"{prefix}_height_m"), 0.0) ) - installed = str(install or "").strip() != "없음" + # BOX암거 유입·유출 구조(표 02) — 없음 · 날개벽 · 흙막이 · 옛 값 있음 = 날개벽. 날개벽만 세움. + installed = str(install or "").strip() not in ("없음", "흙막이") extend = (length or 0.0) * math.cos(math.radians(angle or 0.0)) if installed else 0.0 return { "installed": installed, diff --git a/M01_MasterData/M01_MasterData_Store_Cache.py b/M01_MasterData/M01_MasterData_Store_Cache.py index cca474f97..ac1be8ad8 100644 --- a/M01_MasterData/M01_MasterData_Store_Cache.py +++ b/M01_MasterData/M01_MasterData_Store_Cache.py @@ -221,19 +221,21 @@ def check_saved(files: dict, changed: list[str], book: dict, narrowed=None) -> l def _labor_files() -> list[tuple[str, Path]]: - """모든 층(마스터 · 회사 · 개인)의 일위대가 문서 `labor/<열>.json` — (어디, 파일).""" + """모든 층(마스터 · 회사 · 개인)의 일위대가 `labor/<열>.json` · 단가승격 `promote/<키>.json` + · 사전조합 `precombo/<키>.json`(51-11) — (어디, 파일).""" from M02_MasterTemplete import M02_Template_Layers as layers # M02 가 M01 을 부름 — 늦게 - root = layers.storage_root() - found = [("마스터", p) for p in sorted((layers.system_dir() / "labor").glob("*.json"))] - found += [(f"회사 {p.parts[-4]}", p) for p in sorted(root.glob("*/templates/labor/*.json"))] - personal = sorted(root.glob("*/*/templates/labor/*.json")) - return found + [(f"개인 {p.parts[-5]}/{p.parts[-4]}", p) for p in personal] + return [ + (f"{what} {where}", path) + for kind, what in (("labor", "일위대가"), ("promote", "단가승격"), ("precombo", "사전조합")) + for where, path in layers.layer_files(kind) + ] def labor_errors(whole) -> list[str]: - """일위대가 문서가 담은 로직이 마스터에 없음(39-1d) — 로직 지우기를 막음 · 어디서 쓰는지 알림. - 전부터 깨진 것은 `save` 가 고치기 전 검사와 견주어 뺌.""" + """일위대가 · 단가승격 · 사전조합 문서가 담은 로직이 마스터에 없음(39-1d · 50-3) — + 로직 지우기를 막음 · 어디서 쓰는지 알림 · 참조 줄(로직 없음)은 건너뜀. 전부터 깨진 것은 + `save` 가 고치기 전 검사와 견주어 뺌.""" out = [] for where, path in _labor_files(): try: @@ -243,10 +245,12 @@ def labor_errors(whole) -> list[str]: name = (doc.get("일위대가번") if isinstance(doc, dict) else None) or path.stem for one in (doc.get("담은로직") if isinstance(doc, dict) else None) or []: key = str(one.get("로직") or "") if isinstance(one, dict) else "" + if not key: + continue try: whole.logic(key) except cm.mf.FormulaError: - out.append(f"일위대가 {where} 「{name}」 이 로직 「{key}」 을 씀 — 거기서 먼저 뺌") + out.append(f"{where} 「{name}」 이 로직 「{key}」 을 씀 — 거기서 먼저 뺌") return out diff --git a/M01_MasterData/M01_MasterData_Store_Make.py b/M01_MasterData/M01_MasterData_Store_Make.py index c5ab2df45..13762011a 100644 --- a/M01_MasterData/M01_MasterData_Store_Make.py +++ b/M01_MasterData/M01_MasterData_Store_Make.py @@ -104,30 +104,88 @@ def sections(book: str, division: str = "", chapter: str = "", q: str = "", limi # ── 로직 원문(출처 글 → 품셈 본문 절) ────────────────────────────────── -_NUMBER = re.compile(r"(?~`])") # 마크다운 이스케이프 `\.` `\-` … +_COMMENT = re.compile(r"") # 글 안 주석(글자층 표시) — 글자만 남김 +_CIRCLED = "①②③④⑤⑥⑦⑧⑨⑩⑪⑫⑬⑭⑮⑯⑰⑱⑲⑳" + + +def _plain(text: str) -> str: + """원문 md 기호 벗김 — 주석 · 이스케이프 · 인용 머리 `> `.""" + text = _ESCAPE.sub(r"\1", _COMMENT.sub("", text)).strip() + return text[1:].strip() if text.startswith(">") else text def _blocks(lines: list[str]) -> list[dict]: - """md 줄 → 화면 조각 — 제목 {h} · 표 {table} · 글 {p} · 주석 줄은 뺌.""" + """md 줄 → 화면 조각 — 제목 {h} · 표 {table} · 글 {p} · 주석 줄은 뺌 · + 빈 줄 · 주석이 표를 끊음(붙은 표 둘을 한 표로 안 합침) · `
` = 줄바꿈.""" out: list[dict] = [] + joinable = False for line in lines: - text = line.strip() + text = re.sub(r"", "\n", line.strip()) if not text or text.startswith("