feat(B05): 48-3 · 48-4 구조물 배치 칸 = 구조물 정의대로 그림
- 정의 덧칸 얹기 — GET /m02/structure-definition 의 구조물군 이름 · 기준측점 · 동작 · 배치 · 규칙 · 그룹 형상을 종류에 얹음(못 받으면 옛 화면 그대로)
- 레지스트리 칸 — 배치(줄 · 칸 · 너비) 격자 · 차례 · 안 씀(enabled:false) · 규칙(선택지 · 보이기 · 기본값) 즉시 적용 · 규칙이 가린 칸은 저장 안 건드림
- 손코딩 시설 폼(동작 계곡통과시설) — 안 씀 · 선택지 · 규칙을 칸 표식으로 붙임 · 배치 · 이름은 손코딩 그대로
- 지난 정의 값 — 지금 정의에 없는 키 값을 읽기만 상자로
- 그룹 형상 칸 — 길이 · 높이 · 전 · 후 받기 켬끔 · 값은 옵션 {그룹 id}__{칸}
- 정의에서 뺀 선택지를 고른 옛 값은 그 값만 남겨 보임
- 시험 test_b05_structures_define 8 · 헬퍼 문맥 groupBoxes 로
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ
This commit is contained in:
@@ -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<string, DefinedSpot> | 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<StructureType[]> | null = null;
|
||||
*/
|
||||
export function fetchStructureTypes(includeDisabled = false): Promise<StructureType[]> {
|
||||
if (!typesCache) {
|
||||
// 구조물 정의(48) 덧칸 — 못 받으면 옛 화면 그대로(정의 없음 = 지금 차례 · 2열)
|
||||
const definition = requestJson<StructureDefinition>("/m02/structure-definition", {
|
||||
method: "GET",
|
||||
}).catch(() => null);
|
||||
typesCache = requestJson<StructureTypesResponse>("/projects/structure-types", {
|
||||
method: "GET",
|
||||
})
|
||||
.then((payload) => payload.types)
|
||||
.then(async (payload) => withDefinition(payload.types, await definition))
|
||||
.catch((error) => {
|
||||
typesCache = null; // 실패한 약속을 남겨 두면 다시 시도할 수 없다.
|
||||
throw error;
|
||||
|
||||
@@ -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 | number> };
|
||||
그러면: {
|
||||
옵션: string;
|
||||
/** 하위 칸 선택지를 이 목록으로 좁힘. */
|
||||
선택지?: string[];
|
||||
/** 하위 칸을 보이거나 가림. */
|
||||
보이기?: boolean;
|
||||
/** 상위 값이 바뀌어 이 줄이 걸리면 하위 칸 값을 이 값으로. */
|
||||
기본값?: string | number | null;
|
||||
};
|
||||
}
|
||||
|
||||
/** 배치 격자 한 칸 — 정의 `배치` 의 `{줄, 칸, 너비}`(1부터). */
|
||||
export interface DefinedSpot {
|
||||
줄?: number;
|
||||
칸?: number;
|
||||
너비?: number;
|
||||
}
|
||||
|
||||
export interface DefinedType {
|
||||
options: DefinedOption[];
|
||||
/** 종류 덧칸 — 기준 측점 칸 보임(기본 보임). */
|
||||
show_anchor?: boolean;
|
||||
/** 옵션 키 → 배치 칸. 비면 정의 옵션 차례대로 2열. */
|
||||
spots?: Record<string, DefinedSpot> | 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<T extends DefinedOption>(type: {
|
||||
options: T[];
|
||||
spots?: Record<string, DefinedSpot> | 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<string, DefinedSpot> | 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<string, string | number | undefined>,
|
||||
): Map<string, OptionState> {
|
||||
const states = new Map<string, OptionState>();
|
||||
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<string, unknown>): boolean =>
|
||||
rule.만약.값들.some((value) => same(value, values[rule.만약.옵션]));
|
||||
|
||||
/** 상위 값이 바뀌었을 때 하위 칸에 넣을 기본값 `{하위 키: 값}` — 걸린 줄 중 기본값이 있는 것만. */
|
||||
export function ruleDefaults(
|
||||
type: DefinedType,
|
||||
parent: string,
|
||||
values: Record<string, string | number | undefined>,
|
||||
): Record<string, string | number> {
|
||||
const out: Record<string, string | number> = {};
|
||||
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<string, string | number> | undefined,
|
||||
known: Iterable<string>,
|
||||
): 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<Record<ShapeName, { 받기?: boolean; 뜻?: string }>>;
|
||||
|
||||
/** 그룹 형상 값 저장 키 — 구조물 옵션에 `{그룹 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<string, string>;
|
||||
종류?: Record<string, { 기준측점?: boolean; 동작?: string[] }>;
|
||||
그룹?: Record<string, Array<{ id: string; 이름?: string; 형상?: DefinedShape }>>;
|
||||
배치?: Record<string, Record<string, DefinedSpot>>;
|
||||
규칙?: 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) })) }
|
||||
: {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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<HTMLElement>("[data-option-keys]")].find(
|
||||
(node) => node.dataset.optionKeys === key,
|
||||
) ?? null;
|
||||
const controlOf = (key: string): HTMLInputElement | HTMLSelectElement | null =>
|
||||
wrapOf(key)?.querySelector<HTMLInputElement | HTMLSelectElement>("select, input") ?? null;
|
||||
const values = (): Record<string, string> =>
|
||||
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<HTMLElement>("[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<HTMLOptionElement>("option[hidden]").forEach((o) => (o.hidden = false));
|
||||
paint();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<typeof createFacilityOptionsForm>;
|
||||
/** 「지난 정의 값」 읽기만 상자(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,
|
||||
|
||||
@@ -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<string, number>;
|
||||
/** 지금 종류 그룹의 형상 키 전부(받기 끈 칸 포함) — 저장 병합 · 지난 정의 값 판정. */
|
||||
shapeKeys: (type?: StructureType | null) => string[];
|
||||
/** 구조물 옵션에서 형상 값을 실음(폼 불러오기). */
|
||||
setShapes: (options: Record<string, string | number> | 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<string, string> = {};
|
||||
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<string, HTMLInputElement>();
|
||||
const rows = new Map<string, SummaryRow>();
|
||||
/** 옮긴 칸 · 제자리 표지 */
|
||||
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<string, number> = {};
|
||||
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(
|
||||
|
||||
@@ -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<string, string | number>): 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();
|
||||
|
||||
@@ -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<string, string | number> {
|
||||
// 서브폼이 담당하는 타입(독립 기슭막이)은 서브폼이 값을 읽는다.
|
||||
if (ctx.facilityFormKind(ctx.currentType())) return ctx.facilityOptions.readOptions();
|
||||
const values: Record<string, string | number> = {};
|
||||
// 서브폼이 담당하는 타입(독립 기슭막이)은 서브폼이 값을 읽는다. 그룹 형상 칸(48-3)은 둘 다 얹음.
|
||||
const shapes = ctx.groupBoxes.shapes();
|
||||
if (ctx.facilityFormKind(ctx.currentType()))
|
||||
return { ...ctx.facilityOptions.readOptions(), ...shapes };
|
||||
const values: Record<string, string | number> = { ...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,
|
||||
),
|
||||
};
|
||||
|
||||
@@ -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<string, string> =>
|
||||
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<string, string | number> | 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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/** 저장할 옵션 — 같은 종류를 고치는 중이면 폼이 안 그린 옛 칸을 이어 붙임.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user