diff --git a/.gitignore b/.gitignore index 84f53985e..918db4041 100644 --- a/.gitignore +++ b/.gitignore @@ -73,10 +73,11 @@ config/spreadsheet_node/ # graphify 위키 산출물 — 창끼리 같은 위키를 찾게 **통째로** git 으로 나름 # (2026-09-09 사용자 확정 「출력물 전체를 공유해도 됨」). # 다만 아래 둘은 PC 마다 다른 값이라 나르면 안 됨 — 나르면 상대 PC 에서 어긋남. -docs/wiki/graphify-out/.graphify_python -docs/wiki/graphify-out/.graphify_incremental.json +# docs/wiki/graphify-out/.graphify_python +# docs/wiki/graphify-out/.graphify_incremental.json # 캐시는 **PC 마다 다시 만들어지는 파생물**이라 git 으로 나르지 않는다(2026-09-10 사용자 확정). # 까닭 — 코드 창에서 graphify 가 돌 때마다 이 폴더가 바뀌어, 위키 브랜치를 받을 때 # 「덮으면 네 것이 날아간다」로 **병합이 막혔다**. 4.3MB·443파일도 함께 가벼워진다. -docs/wiki/graphify-out/cache/ +#docs/wiki/graphify-out/cache/ +docs/ ~$* 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_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_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/M01_MasterData/M01_MasterData_Store_Make.py b/M01_MasterData/M01_MasterData_Store_Make.py index c5ab2df45..029ce0d35 100644 --- a/M01_MasterData/M01_MasterData_Store_Make.py +++ b/M01_MasterData/M01_MasterData_Store_Make.py @@ -108,11 +108,14 @@ _NUMBER = re.compile(r"(? 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("