From 44c6136b9112877f0e2d77910fad357853a0d010 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 16:00:45 +0900 Subject: [PATCH 1/2] auto: 2026-10-05 16:00 (ESD_LAPTOP) --- docs/raw/사용자요구/2026-10-05_수정요구.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/raw/사용자요구/2026-10-05_수정요구.md b/docs/raw/사용자요구/2026-10-05_수정요구.md index eeadef7f7..9569cdcc8 100644 --- a/docs/raw/사용자요구/2026-10-05_수정요구.md +++ b/docs/raw/사용자요구/2026-10-05_수정요구.md @@ -48,3 +48,23 @@ 4. 구조물 조합의 단가산출 상세화면에도 원문보기 우측 패널이 있어야함. 우측 패널은 단가산출 로직을 선택할 경우에 원문을 보여주는 기능이며 패널을 접었다 펼수 있어야함. 5. 원문 패널 추가에 따라 현재 단가산출 로직 항목을 선택하면 옵션창이 보이나 선택하면 하이라이트를 하고 원문을 보일수 있게 함. 그리고 옵션설정에 대한 조정은 올리기, 내리기, 빼기 버튼의 이름을 위로, 아래로, 삭제로 변경하고 아래로 와 삭제 사이에 신규 버튼(이름 : 옵션)을 추가하여 선택할 수 있게 변경. 버튼 사이간격에 대해 템플릿화하여 적용. 6. 구조물 조합의 면적산출 우측패널에 별도의 도면 패널 추가(기능은 4번 요청 확인) + +## 구조물 옵션 정의 페이지 (10-5 · 원문 그대로) + +이제부터 다시 계획서를 세울 예정임. +1. 목표 : 현재 구조물 옵션 보이기의 컨테이너 항목을 기준으로 프로젝트 내부에 사용하는 구조물의 옵션 항목 및 제어를 설정하는 페이지를 궁성 +2. 상세 내용 + - 현재 M02의 구조물 옵션보이기 항목으로 구조물군과 구조물 종류를 설정하고 이름을 변경할 수 있게 반영 + - 구조물 종류를 선택시 우측에 해당항목의 일위대가 그룹을 지정하고 변수를 편입할수 있게 반영(기반영), 추가로 배치 순서를 조절할 수 있게, 행열의 크기 제어도 같이 + - 구조물 종류의 특정 옵션의 옵션 값 항목을 지정할 수 있게 반영 + - 구조물 종류의 최상위에 기본값인 기준 측점을 표현할건지에 대한 기능 + - 구조물의 길이, 높이, 기준측점 전/후 값에 대한 옵션 사용여부 및 어떤걸 제어하는지에 대한 결정 + - 구조물 옵션과 값들의 이름 변경기능 + - 옵션들은 상위에 따라 하위 옵션을 변경할 수 있게 반영 +3. 현재 B05와 B06의 방대한 변경이 필요하니 순차적으로 진행, 가자먼저 작업할 내용은 B05와 B06에 좌측 패널의 구조물 배치 컨테이너에 보일 항목을 반만들수 있게 반영하고 선택에 따라 변경되는 변수 값과 제어는 별도로 작업 할 예정. 일단 방향성에 대해 검토해보고 대화부터 하자. 내가 원하는 방향은 결국 내가 프로그래머가 아니라서 나중에 항목을 추가하거나 옵션을 변경하였을때 프론트엔드에 기능을 구현하고 변경하고 싶은것임. + +## 원문 보기 패널 (10-5 · 원문 그대로) + +계획서는 작성해주고 빠르게 작업해줄 내용이 M01과 M02에서 원문보기가 패널 양식을 사용하지 않고 있음. 추가로 M01페이지의 원문보기에서 md파일을 보기가 깨짐이 있음. M02는 정상. M01의 경우 흐름도와 원분은 우측 패널로 구현되어있음. 좌측 패널과 동일하게 접었다 펴는 양식 제대로 확인할 ㄱ서. + +그리고 우측 패널의 내용은 메인 화면의 스크롤과 별개로 동작하게 반영 From fc54a385df238a745806a0369d6360957410448a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 16:15:37 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(B05):=2048-3=20=C2=B7=2048-4=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=EB=B0=B0=EC=B9=98=20=EC=B9=B8=20?= =?UTF-8?q?=3D=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=EC=A0=95=EC=9D=98=EB=8C=80?= =?UTF-8?q?=EB=A1=9C=20=EA=B7=B8=EB=A6=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 정의 덧칸 얹기 — GET /m02/structure-definition 의 구조물군 이름 · 기준측점 · 동작 · 배치 · 규칙 · 그룹 형상을 종류에 얹음(못 받으면 옛 화면 그대로) - 레지스트리 칸 — 배치(줄 · 칸 · 너비) 격자 · 차례 · 안 씀(enabled:false) · 규칙(선택지 · 보이기 · 기본값) 즉시 적용 · 규칙이 가린 칸은 저장 안 건드림 - 손코딩 시설 폼(동작 계곡통과시설) — 안 씀 · 선택지 · 규칙을 칸 표식으로 붙임 · 배치 · 이름은 손코딩 그대로 - 지난 정의 값 — 지금 정의에 없는 키 값을 읽기만 상자로 - 그룹 형상 칸 — 길이 · 높이 · 전 · 후 받기 켬끔 · 값은 옵션 {그룹 id}__{칸} - 정의에서 뺀 선택지를 고른 옛 값은 그 값만 남겨 보임 - 시험 test_b05_structures_define 8 · 헬퍼 문맥 groupBoxes 로 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ --- B05_Profile/B05_Profile_Api_Structures.ts | 27 ++- .../B05_Profile_UI_Structures_Define.ts | 216 ++++++++++++++++++ .../B05_Profile_UI_Structures_Define_Form.ts | 85 +++++++ .../B05_Profile_UI_Structures_Fields.ts | 7 +- B05_Profile/B05_Profile_UI_Structures_Form.ts | 14 ++ .../B05_Profile_UI_Structures_Groups.ts | 65 +++++- .../B05_Profile_UI_Structures_Panel.ts | 32 ++- .../B05_Profile_UI_Structures_Panel_Commit.ts | 28 ++- ...B05_Profile_UI_Structures_Panel_Options.ts | 123 ++++++++-- .../B05_Profile_UI_Structures_Summary.ts | 6 +- .../B05_Profile_UI_Style_Structures.css | 10 + .../tester/helper_b05_structures_define.cjs | 99 ++++++++ .../helper_b05_structures_form_summary.cjs | 10 +- .../tester/test_b05_structures_define.py | 80 +++++++ 14 files changed, 749 insertions(+), 53 deletions(-) create mode 100644 B05_Profile/B05_Profile_UI_Structures_Define.ts create mode 100644 B05_Profile/B05_Profile_UI_Structures_Define_Form.ts create mode 100644 resources/tester/helper_b05_structures_define.cjs create mode 100644 resources/tester/test_b05_structures_define.py diff --git a/B05_Profile/B05_Profile_Api_Structures.ts b/B05_Profile/B05_Profile_Api_Structures.ts index 02146289b..81e5c579d 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,16 @@ 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; } /** 옵션 그룹 한 개 — 서버가 M02 「구조물 옵션 보이기」 그룹 표로 채움. */ @@ -86,6 +103,8 @@ export interface StructureOptionGroup { id: string; name: string; options: string[]; + /** 그룹이 받을 형상 칸(48 정의 그룹 `형상`) — 길이 · 높이 · 전 · 후 받기 켬끔 + 뜻. */ + shape?: DefinedShape; } export interface StructureInstance { @@ -181,10 +200,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_UI_Structures_Define.ts b/B05_Profile/B05_Profile_UI_Structures_Define.ts new file mode 100644 index 000000000..4b1b22660 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Structures_Define.ts @@ -0,0 +1,216 @@ +/* ============================================================================= + * 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.동작 ?? [], + 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..e4a177b27 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,6 +156,9 @@ export function buildStructuresForm(options: { const summary = buildSummaryRow(); summary.root.hidden = true; const groupBoxes = createGroupBoxes(); + const staleBox = document.createElement("fieldset"); + staleBox.className = "b05-structure__group b05-structure__stale"; + staleBox.hidden = true; body.append( typeRow, @@ -156,6 +167,7 @@ export function buildStructuresForm(options: { positionDivider, optionRow, facilityOptions.root, + staleBox, summary.root, groupBoxes.root, actions, @@ -178,6 +190,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..ea74cd5ad 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,9 @@ 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 { GROUP_JOIN, type GroupItems, @@ -33,8 +38,20 @@ 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; +} + +interface GroupHandlers { + pick: (groupId: string) => void; + clear: (groupId: string) => void; + change?: () => void; } export function createGroupBoxes(): GroupBoxes { @@ -43,7 +60,12 @@ 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 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 +105,15 @@ export function createGroupBoxes(): GroupBoxes { render(type, scopes, facility) { restore(); rows.clear(); + shapeInputs.clear(); + current = type; const groups = 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 +157,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"; @@ -179,6 +216,23 @@ export function createGroupBoxes(): GroupBoxes { 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 +247,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 +270,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..5731e515d 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -23,7 +23,10 @@ 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 { + 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 +41,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"; @@ -86,6 +90,8 @@ export function createStructuresSection( optionRow, actions, facilityOptions, + staleBox, + facilityDefinition, primary, removeButton, resetButton, @@ -232,6 +238,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 ? "기준 측점 (비우면 시작)" : "기준 측점"; @@ -245,12 +253,12 @@ export function createStructuresSection( ): void { const kind = facilityFormKind(currentType()); facilityOptions.setFacility(kind, options ?? newDefaults(), designFlow); + 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..5a30bf77c 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts @@ -11,7 +11,15 @@ 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"; /** 패널이 쥔 값 중 옵션 그리기에 필요한 것들. 이름은 분리 전 지역변수와 같다. */ export interface OptionRenderContext { @@ -46,39 +54,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 +127,74 @@ 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 { + const known = [ + ...shapeKeys, + ...(type?.options ?? []).map((o) => o.key), + ...(facility ? (FORM_OPTION_KEYS[facility] ?? []) : []), + ]; + const stale = type ? staleValues(values, known) : []; + 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..6b140043f 100644 --- a/B05_Profile/B05_Profile_UI_Style_Structures.css +++ b/B05_Profile/B05_Profile_UI_Style_Structures.css @@ -515,3 +515,13 @@ .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; +} diff --git a/resources/tester/helper_b05_structures_define.cjs b/resources/tester/helper_b05_structures_define.cjs new file mode 100644 index 000000000..c4249162b --- /dev/null +++ b/resources/tester/helper_b05_structures_define.cjs @@ -0,0 +1,99 @@ +/* 구조물 정의 → 칸 모양 단위검증 헬퍼(PLAN 48-3 · 48-4) — `B05_Profile_UI_Structures_Define.ts` 를 + * 그 자리에서 트랜스파일해 Node 로 돌림(import 없는 파일). 결과는 표준출력에 JSON 한 벌. + * 판 1 정의(`resources/master_template/구조물정의.json`)도 읽어 지금 화면과 같은지(차례 · 2열) 봄. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const src = fs.readFileSync( + path.join(ROOT, "B05_Profile", "B05_Profile_UI_Structures_Define.ts"), + "utf8", +); +const js = ts.transpileModule(src, { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, +}).outputText; +const box = { exports: {} }; +new Function("exports", "module", "require", js)(box.exports, box, require); +const d = box.exports; + +const opt = (key, extra = {}) => ({ + key, + label: key, + input: "select", + choices: ["a", "b", "c"], + default: null, + ...extra, +}); +const type = { + options: [ + opt("p"), + opt("q"), + opt("r", { enabled: false }), + opt("s", { visible: false }), + opt("t"), + ], + spots: { t: { 줄: 1, 칸: 1, 너비: 2 }, p: { 줄: 2, 칸: 2 } }, + rules: [ + { + 종류: "x", + 만약: { 옵션: "p", 값들: ["a"] }, + 그러면: { 옵션: "q", 선택지: ["b", "c"], 기본값: "c" }, + }, + { 종류: "x", 만약: { 옵션: "p", 값들: ["b"] }, 그러면: { 옵션: "q", 보이기: false } }, + { 종류: "x", 만약: { 옵션: "q", 값들: ["c"] }, 그러면: { 옵션: "t", 보이기: false } }, + ], +}; +const plain = (states) => Object.fromEntries([...states].map(([k, v]) => [k, v])); + +const def = JSON.parse( + fs.readFileSync(path.join(ROOT, "resources", "master_template", "구조물정의.json"), "utf8"), +); +const merged = d.withDefinition( + def.types.map((t) => ({ ...t, groups: (def.그룹[t.type_id] ?? []).map((g) => ({ id: g.id })) })), + def, +); +const v1 = merged.map((t) => ({ + id: t.type_id, + same: + JSON.stringify(d.orderedOptions(t).map((o) => o.key)) === + JSON.stringify(t.options.filter((o) => o.enabled !== false).map((o) => o.key)), + columns: d.columnsOf(t), + anchor: d.showsAnchor(t), +})); + +const out = { + order: d.orderedOptions(type).map((o) => o.key), + columns: d.columnsOf(type), + columnsNone: d.columnsOf({}), + placeT: d.gridPlace(type.spots.t, 2), + placeNone: d.gridPlace(undefined, 2), + stateA: plain(d.ruleStates(type, { p: "a", q: "c" })), + stateB: plain(d.ruleStates(type, { p: "b", q: "c" })), + defaultsA: d.ruleDefaults(type, "p", { p: "a" }), + defaultsB: d.ruleDefaults(type, "p", { p: "b" }), + stale: d.staleValues({ p: "a", old_key: 3, g1__length_m: 2 }, ["p", "g1__length_m"]), + shapes: d.shapeFields({ + 길이: { 받기: true, 뜻: "연장" }, + 높이: { 받기: false }, + 후: { 받기: true }, + }), + shapeKey: d.shapeKey("g1", "length_m"), + withNull: d.withDefinition([{ type_id: "a", group: "A" }], null), + withDef: d.withDefinition([{ type_id: "a", group: "A", groups: [{ id: "g1" }] }], { + 구조물군: { A: "가 군" }, + 종류: { a: { 기준측점: false, 동작: ["계곡통과시설"] } }, + 그룹: { a: [{ id: "g1", 형상: { 길이: { 받기: true } } }] }, + 배치: { a: { k: { 줄: 1, 칸: 1 } } }, + 규칙: [ + { ...type.rules[0], 종류: "a" }, + { ...type.rules[1], 종류: "b" }, + ], + }), + v1Types: v1.length, + v1Same: v1.every((t) => t.same), + v1Columns: [...new Set(v1.map((t) => t.columns))], + v1Anchor: v1.every((t) => t.anchor), +}; +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/helper_b05_structures_form_summary.cjs b/resources/tester/helper_b05_structures_form_summary.cjs index f5e9f6ebb..0a548359e 100644 --- a/resources/tester/helper_b05_structures_form_summary.cjs +++ b/resources/tester/helper_b05_structures_form_summary.cjs @@ -355,8 +355,14 @@ async function saveWith({ setTempPipeChainage() {}, summary: () => summaryValue, setSummary: (value) => (summaryValue = value), - groupItems: () => ({}), // 옵션 그룹(PLAN 46-3) — 이 시험은 그룹 없음 - setGroupItems() {}, + // 옵션 그룹(PLAN 46-3) · 형상 칸(48-3) — 이 시험은 그룹 없음 + groupBoxes: { + get: () => ({}), + set() {}, + shapes: () => ({}), + shapeKeys: () => [], + setShapes() {}, + }, }; if (summaryOnly) { const done = commitModule.commitSummaryOnly(ctx); diff --git a/resources/tester/test_b05_structures_define.py b/resources/tester/test_b05_structures_define.py new file mode 100644 index 000000000..1cb497663 --- /dev/null +++ b/resources/tester/test_b05_structures_define.py @@ -0,0 +1,80 @@ +# -*- coding: utf-8 -*- +"""B05/B06 「구조물 배치」 칸 = 구조물 정의대로 — PLAN 48-3 · 48-4 (2026-10-05). + +정의(`구조물정의.json`)가 주는 차례 · 배치 격자 · 안 씀(`enabled:false`) · 규칙(상위 값 → 하위 +선택지 · 보이기 · 기본값) · 지난 정의 값 · 그룹 형상 칸 · 정의 덧칸 얹기를 본다. +판 1 정의로는 지금 화면과 같음(차례 그대로 · 2열 · 기준 측점 보임). +순수 규칙은 Node 헬퍼(`helper_b05_structures_define.cjs`)가 돌림 · 화면은 ORCA `ui_checks/48-3_구조물배치_정의그리기.js`. +""" + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) + + +@pytest.fixture(scope="module") +def out(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_b05_structures_define.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_차례는_배치_줄_칸_뒤는_정의_차례_안씀_숨김_뺌(out): + assert out["order"] == ["t", "p", "q"] + + +def test_격자_칸수와_자리(out): + assert out["columns"] == 2 + assert out["columnsNone"] == 2 + assert out["placeT"] == {"row": "1", "column": "1 / span 2"} + assert out["placeNone"] is None + + +def test_규칙_선택지_보이기_사슬(out): + assert out["stateA"]["q"]["choices"] == ["b", "c"] + assert out["stateA"]["t"]["visible"] is False # q=c → t 가림 + assert out["stateB"]["q"]["visible"] is False # p=b → q 가림 + assert out["stateB"]["t"]["visible"] is True # 가려진 q 에 매인 줄은 안 걸림 + + +def test_규칙_기본값(out): + assert out["defaultsA"] == {"q": "c"} + assert out["defaultsB"] == {} + + +def test_지난_정의_값(out): + assert out["stale"] == [["old_key", 3]] + + +def test_그룹_형상_칸(out): + assert [s["suffix"] for s in out["shapes"]] == ["length_m", "after_m"] + assert out["shapes"][0]["meaning"] == "연장" + assert out["shapeKey"] == "g1__length_m" + + +def test_정의_덧칸_얹기(out): + assert out["withNull"] == [{"type_id": "a", "group": "A"}] + t = out["withDef"][0] + assert t["group_name"] == "가 군" + assert t["show_anchor"] is False + assert t["behaviors"] == ["계곡통과시설"] + assert t["spots"] == {"k": {"줄": 1, "칸": 1}} + assert [r["종류"] for r in t["rules"]] == ["a"] + assert t["groups"][0]["shape"] == {"길이": {"받기": True}} + + +def test_판1_정의는_지금_화면과_같음(out): + assert out["v1Types"] == 37 + assert out["v1Same"] is True + assert out["v1Columns"] == [2] + assert out["v1Anchor"] is True