Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_4
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;
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -108,11 +108,14 @@ _NUMBER = re.compile(r"(?<![\d-])\d+(?:-\d+)+(?![\d-])")
|
||||
|
||||
|
||||
def _blocks(lines: list[str]) -> list[dict]:
|
||||
"""md 줄 → 화면 조각 — 제목 {h} · 표 {table} · 글 {p} · 주석 줄은 뺌."""
|
||||
"""md 줄 → 화면 조각 — 제목 {h} · 표 {table} · 글 {p} · 주석 줄은 뺌 ·
|
||||
빈 줄 · 주석이 표를 끊음(붙은 표 둘을 한 표로 안 합침) · `<br>` = 줄바꿈."""
|
||||
out: list[dict] = []
|
||||
joinable = False
|
||||
for line in lines:
|
||||
text = line.strip()
|
||||
text = re.sub(r"<br\s*/?>", "\n", line.strip())
|
||||
if not text or text.startswith("<!--"):
|
||||
joinable = False
|
||||
continue
|
||||
if text.startswith("#"):
|
||||
out.append({"h": len(text) - len(text.lstrip("#")), "text": text.lstrip("#").strip()})
|
||||
@@ -120,11 +123,14 @@ def _blocks(lines: list[str]) -> list[dict]:
|
||||
cells = [c.strip() for c in text.strip("|").split("|")]
|
||||
if all(re.fullmatch(r":?-{3,}:?", c) for c in cells):
|
||||
continue # 머리 · 몸 가름 줄
|
||||
if not out or "table" not in out[-1]:
|
||||
if not joinable:
|
||||
out.append({"table": []})
|
||||
out[-1]["table"].append(cells)
|
||||
joinable = True
|
||||
continue
|
||||
else:
|
||||
out.append({"p": text})
|
||||
joinable = False
|
||||
return out
|
||||
|
||||
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
background: none;
|
||||
}
|
||||
|
||||
/* 원문 꼴 한 벌(M01 · M02 같음) — 둘레 글자 크기(M01 14)를 안 따르고 본문 크기 */
|
||||
.m01lab-src {
|
||||
font-size: var(--text-body);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
@@ -26,8 +28,6 @@
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
min-width: 0;
|
||||
max-height: 80vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.m01lab-src__head {
|
||||
@@ -51,6 +51,7 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 칸 안 줄바꿈(원문 <br>)만 살리고 저절로 접지 않음 */
|
||||
.m01lab-src__table td {
|
||||
white-space: nowrap;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
@@ -130,11 +130,11 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
openOnExpand(summary, async () => {
|
||||
if (!isPicked("table", STRUCTURE_TABLE) && (await opt.confirmLeave())) opt.onOpen(null);
|
||||
});
|
||||
// 구조물 옵션 보이기 · 도면 양식은 시스템 층(마스터 템플릿)에만 · 옵션 보이기(그룹)는 시스템 관리자만(PLAN 46)
|
||||
// 구조물 옵션 정의 · 도면 양식은 시스템 층(마스터 템플릿)에만 · 정의 고쳐 씀은 시스템 관리자만 · 다른 역할은 읽기만(PLAN 48-2)
|
||||
const extras =
|
||||
layer === "system"
|
||||
? {
|
||||
옵션: canEdit(layer, opt.isAdmin) ? section(options.title, options.body) : null,
|
||||
옵션: section(options.title, options.body),
|
||||
도면: section(L("M02_KindDrawing"), bodies.get("drawing")!, ...actionRow("drawing")),
|
||||
}
|
||||
: null;
|
||||
|
||||
@@ -1,56 +1,29 @@
|
||||
/* =============================================================================
|
||||
* M02_SO_Api.ts
|
||||
* 구조물 옵션 보이기 · 그룹 서버 호출 — 서버 = `M02_SO_Router.py`
|
||||
* (마스터 템플릿 `구조물옵션표시.json` · `구조물옵션그룹.json`)
|
||||
* 구조물 정의 서버 호출 — 서버 = `M02_SO_Router.py`(마스터 템플릿 `구조물정의.json` 한 벌 · PLAN 48)
|
||||
* ========================================================================== */
|
||||
|
||||
import { call } from "../M02_MasterTemplete_Api_Fetch";
|
||||
import type { StructDef } from "./M02_SO_Def_Model";
|
||||
|
||||
export interface OptionKind {
|
||||
type_id: string;
|
||||
group: string;
|
||||
name: string;
|
||||
/** 거짓이면 B05 선택지에 안 나오는 종류 */
|
||||
enabled: boolean;
|
||||
options: { key: string; label: string; phase: "b05" | "detail"; required: boolean }[];
|
||||
/* --- 구조물 정의(PLAN 48 · 마스터 템플릿 `구조물정의.json` · 판 번호 · 이력) --- */
|
||||
|
||||
/** 지난 판 한 줄 */
|
||||
export interface DefVersion {
|
||||
판: number;
|
||||
저장일: string;
|
||||
}
|
||||
|
||||
/** 판 · 표시(숨긴 것만 `{종류: {옵션: false}}`) · 종류(옵션이 있는 것과 그 옵션) */
|
||||
export interface OptionsDoc {
|
||||
판: string;
|
||||
표시: Record<string, Record<string, false>>;
|
||||
종류: OptionKind[];
|
||||
}
|
||||
/** 정의 한 벌 — 읽기 = 로그인 누구나 */
|
||||
export const fetchStructureDef = (): Promise<StructDef> => call("/structure-definition");
|
||||
|
||||
export const fetchStructureOptions = (): Promise<OptionsDoc> =>
|
||||
call("/layers/system/structure-options"); // 읽기 = 로그인 누구나 · 고쳐 씀은 시스템 관리자
|
||||
/** 통째로 고쳐 씀(시스템 관리자만) — 읽어 간 판이 지금 판과 다르면 StaleError · 돌려받는 것 = 판 +1 정의 */
|
||||
export const saveStructureDef = (판: number, 정의: StructDef): Promise<StructDef> =>
|
||||
call("/structure-definition", { method: "PUT", body: JSON.stringify({ 판, 정의 }) });
|
||||
|
||||
/** 고쳐 씀 — 숨긴 것만 보냄 · 낡은 판이면 StaleError · 돌려받는 것은 새 문서 */
|
||||
export const saveStructureOptions = (판: string, 표시: OptionsDoc["표시"]): Promise<OptionsDoc> =>
|
||||
call("/structure-options", { method: "PUT", body: JSON.stringify({ 판, 표시 }) });
|
||||
/** 지난 판 목록(`구조물정의_이력/판_N.json`) */
|
||||
export const fetchStructureDefHistory = (): Promise<DefVersion[]> =>
|
||||
call("/structure-definition/history");
|
||||
|
||||
/* --- 옵션 그룹(PLAN 46-2 · 마스터 템플릿 `구조물옵션그룹.json` · 시스템 관리자만) --- */
|
||||
|
||||
/** 그룹 하나 = 프로젝트 폼의 보조 컨테이너 하나(제목 = 이름) · 옵션 0 도 됨 */
|
||||
export interface OptionGroupDef {
|
||||
id: string;
|
||||
이름: string;
|
||||
옵션: string[];
|
||||
}
|
||||
|
||||
/** 판 · 그룹(`{종류: 그룹[]}`) · 종류(레지스트리 전부 — 옵션 없는 종류도) */
|
||||
export interface GroupsDoc {
|
||||
판: string;
|
||||
그룹: Record<string, OptionGroupDef[]>;
|
||||
종류: OptionKind[];
|
||||
}
|
||||
|
||||
export const fetchStructureOptionGroups = (): Promise<GroupsDoc> =>
|
||||
call("/structure-option-groups");
|
||||
|
||||
/** 고쳐 씀 — 낡은 판이면 StaleError · 돌려받는 것은 새 문서 */
|
||||
export const saveStructureOptionGroups = (
|
||||
판: string,
|
||||
그룹: GroupsDoc["그룹"],
|
||||
): Promise<GroupsDoc> =>
|
||||
call("/structure-option-groups", { method: "PUT", body: JSON.stringify({ 판, 그룹 }) });
|
||||
export const fetchStructureDefAt = (판: number): Promise<StructDef> =>
|
||||
call(`/structure-definition/history/${판}`);
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
/* =============================================================================
|
||||
* M02_SO_Def.ts
|
||||
* 구조물 정의 편집 화면(PLAN 48-2 · 메인 칸) — 왼쪽 구조물군 · 종류 목록(이름 바꿈 · 순서 · 안 씀) ·
|
||||
* 오른쪽 고른 종류(이름 · 안 씀 · 기준 측점 표시 · 옵션 · 배치 격자 · 그룹 · 규칙) ·
|
||||
* 아래 [저장](판 +1 · 시스템 관리자만) · 지난 판 목록(누르면 그 판을 읽기만으로 봄).
|
||||
* 다른 역할은 읽기만. 고칠 사본은 저장 전까지 서버에 안 감 · 정본 = `구조물정의.json`(48-1).
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, createTag, el, showToast } from "@ui/ui_template_elements";
|
||||
import { StaleError } from "../M02_MasterTemplete_Api_Fetch";
|
||||
import {
|
||||
fetchStructureDef,
|
||||
fetchStructureDefAt,
|
||||
fetchStructureDefHistory,
|
||||
saveStructureDef,
|
||||
type DefVersion,
|
||||
} from "./M02_SO_Api";
|
||||
import { buildLayoutBlock } from "./M02_SO_Def_Layout";
|
||||
import {
|
||||
baseShown,
|
||||
clone,
|
||||
problems,
|
||||
stepFamily,
|
||||
stepType,
|
||||
type StructDef,
|
||||
} from "./M02_SO_Def_Model";
|
||||
import { buildOptionsBlock } from "./M02_SO_Def_Options";
|
||||
import { buildRulesBlock } from "./M02_SO_Def_Rules";
|
||||
import { checkBox, textBox } from "./M02_SO_Def_Ui";
|
||||
import { buildGroupBlock } from "./M02_SO_Groups";
|
||||
import { to } from "./M02_SO_Text";
|
||||
|
||||
export interface DefScreenOptions {
|
||||
/** 시스템 관리자 — 아니면 읽기만 */
|
||||
isAdmin: boolean;
|
||||
/** 메인 칸에 화면을 띄움 — 고침 표시 손잡이를 돌려받음 */
|
||||
show: (label: string, node: HTMLElement) => (dirty: boolean) => void;
|
||||
}
|
||||
|
||||
const why = (error: unknown): string => (error instanceof Error ? error.message : "");
|
||||
|
||||
/** 빈 묶음 걷어냄 — 그리기 중 만든 빈 그룹 목록 · 배치 · 종류 덧칸 */
|
||||
function tidy(def: StructDef): StructDef {
|
||||
const out = clone(def);
|
||||
for (const part of [out.그룹, out.배치, out.종류, out.표시] as Record<string, unknown>[])
|
||||
for (const [k, v] of Object.entries(part))
|
||||
if (Array.isArray(v) ? !v.length : !Object.keys(v as object).length) delete part[k];
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 작은 단추 */
|
||||
function smallButton(
|
||||
label: string,
|
||||
run: () => void,
|
||||
off = false,
|
||||
variant: "ghost" | "danger" = "ghost",
|
||||
): HTMLButtonElement {
|
||||
const b = createButton({ label, variant, size: "sm" });
|
||||
b.disabled = off;
|
||||
b.addEventListener("click", run);
|
||||
return b;
|
||||
}
|
||||
|
||||
/** 화면을 받아 띄움 — 못 받으면 알림만 · 띄웠으면 참 */
|
||||
export async function openDefScreen(opt: DefScreenOptions): Promise<boolean> {
|
||||
let def: StructDef;
|
||||
try {
|
||||
def = await fetchStructureDef();
|
||||
} catch (error) {
|
||||
showToast(to("Load_Failed", why(error)), "error");
|
||||
return false;
|
||||
}
|
||||
drawDef(def, opt);
|
||||
return true;
|
||||
}
|
||||
|
||||
function drawDef(first: StructDef, opt: DefScreenOptions): void {
|
||||
/** 지금 판(서버) · 고칠 사본 · 보는 지난 판(있으면 읽기만) */
|
||||
let base = first;
|
||||
let work = clone(first);
|
||||
let past: StructDef | null = null;
|
||||
let dirty = false;
|
||||
let current: string | null = null;
|
||||
const open = new Set<string>(); // 선택지를 펼친 옵션
|
||||
let versions: DefVersion[] | null = null;
|
||||
|
||||
const shown = (): StructDef => past ?? work;
|
||||
const canEdit = (): boolean => opt.isAdmin && !past;
|
||||
const kindList = el("div", { className: "m02-opts__kinds", attrs: { "data-sod-kinds": "" } });
|
||||
const detail = el("div", { className: "m02-opts__list", attrs: { "data-sod-detail": "" } });
|
||||
const banner = el("div", { className: "m02-sod__banner", attrs: { "data-sod-banner": "" } });
|
||||
const issues = el("div", { className: "m02-sod__issues", attrs: { "data-sod-issues": "" } });
|
||||
const historyBox = el("div", {
|
||||
className: "m02-sod__history",
|
||||
attrs: { "data-sod-history": "" },
|
||||
});
|
||||
|
||||
const changed = (): void => {
|
||||
dirty = true;
|
||||
sync();
|
||||
paint();
|
||||
};
|
||||
|
||||
const paintKinds = (): void => {
|
||||
const d = shown();
|
||||
const edit = canEdit();
|
||||
const parts: HTMLElement[] = [];
|
||||
const families = Object.keys(d.구조물군);
|
||||
families.forEach((g, gi) => {
|
||||
parts.push(
|
||||
el("div", {
|
||||
className: "m02-sod__family",
|
||||
attrs: { "data-sod-family": g },
|
||||
children: [
|
||||
textBox(d.구조물군[g], (v) => (d.구조물군[g] = v), changed, {
|
||||
edit,
|
||||
attr: "data-sod-family-name",
|
||||
}),
|
||||
...(edit
|
||||
? [
|
||||
smallButton(to("GroupUp"), () => stepFamily(d, g, -1) && changed(), gi === 0),
|
||||
smallButton(
|
||||
to("GroupDown"),
|
||||
() => stepFamily(d, g, 1) && changed(),
|
||||
gi === families.length - 1,
|
||||
),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
}),
|
||||
);
|
||||
const kinds = d.types.filter((t) => t.group === g);
|
||||
kinds.forEach((t, ti) => {
|
||||
const off = t.enabled === false;
|
||||
const groups = d.그룹[t.type_id]?.length ?? 0;
|
||||
const b = el("button", {
|
||||
className: `m02-opts__kind${t.type_id === current ? " is-active" : ""}${off ? " is-off" : ""}`,
|
||||
attrs: { type: "button", "data-sod-kind": t.type_id },
|
||||
children: [
|
||||
el("span", { text: t.name }),
|
||||
...(groups ? [createTag(to("GroupTag", String(groups)), "accent")] : []),
|
||||
...(off ? [createTag(to("Off"), "neutral")] : []),
|
||||
],
|
||||
});
|
||||
b.addEventListener("click", () => {
|
||||
current = t.type_id;
|
||||
paint();
|
||||
});
|
||||
parts.push(
|
||||
el("div", {
|
||||
className: "m02-sod__kindrow",
|
||||
children: [
|
||||
b,
|
||||
...(edit
|
||||
? [
|
||||
smallButton(
|
||||
to("GroupUp"),
|
||||
() => stepType(d, t.type_id, -1) && changed(),
|
||||
ti === 0,
|
||||
),
|
||||
smallButton(
|
||||
to("GroupDown"),
|
||||
() => stepType(d, t.type_id, 1) && changed(),
|
||||
ti === kinds.length - 1,
|
||||
),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
kindList.replaceChildren(...parts);
|
||||
};
|
||||
|
||||
const paintDetail = (): void => {
|
||||
const d = shown();
|
||||
const type = d.types.find((t) => t.type_id === current);
|
||||
if (!type) {
|
||||
detail.replaceChildren(el("p", { className: "m02-side__empty", text: to("Pick") }));
|
||||
return;
|
||||
}
|
||||
const edit = canEdit();
|
||||
const extra = (): NonNullable<StructDef["종류"][string]> => (d.종류[type.type_id] ??= {});
|
||||
detail.replaceChildren(
|
||||
el("div", {
|
||||
className: "m02-sod__kindhead",
|
||||
children: [
|
||||
textBox(type.name, (v) => (type.name = v), changed, {
|
||||
edit,
|
||||
attr: "data-sod-kind-name",
|
||||
cls: "m02-sod__name",
|
||||
}),
|
||||
el("small", { className: "m02-opts__key", text: type.type_id }),
|
||||
checkBox(to("Unused"), type.enabled === false, (v) => ((type.enabled = !v), changed()), {
|
||||
edit,
|
||||
attr: "data-sod-kind-off",
|
||||
}),
|
||||
checkBox(
|
||||
to("BaseStationShow"),
|
||||
baseShown(d, type.type_id),
|
||||
(v) => {
|
||||
if (v) delete extra().기준측점;
|
||||
else extra().기준측점 = false;
|
||||
changed();
|
||||
},
|
||||
{ edit, attr: "data-sod-base" },
|
||||
),
|
||||
...(d.종류[type.type_id]?.동작 ?? []).map((a) => {
|
||||
const tag = createTag(a, "neutral");
|
||||
tag.title = d.동작[a] ?? "";
|
||||
return tag;
|
||||
}),
|
||||
],
|
||||
}),
|
||||
buildOptionsBlock({ def: d, type, edit, open, changed }),
|
||||
buildLayoutBlock({ def: d, type, edit, changed }),
|
||||
buildGroupBlock({ list: (d.그룹[type.type_id] ??= []), edit, changed }),
|
||||
buildRulesBlock({ def: d, type, edit, changed }),
|
||||
);
|
||||
};
|
||||
|
||||
const paintFoot = (): void => {
|
||||
banner.replaceChildren(
|
||||
...(past
|
||||
? [
|
||||
el("span", { text: to("PastView", String(past.판)) }),
|
||||
smallButton(to("PastBack"), () => ((past = null), paint(), paintHistory())),
|
||||
]
|
||||
: [
|
||||
el("span", { text: to("Version", String(base.판)) }),
|
||||
...(opt.isAdmin ? [] : [createTag(to("ReadOnly"), "neutral")]),
|
||||
]),
|
||||
);
|
||||
banner.classList.toggle("is-past", !!past);
|
||||
const list = past ? [] : problems(work);
|
||||
issues.replaceChildren(...list.map((t) => el("p", { className: "m02-sod__bad", text: t })));
|
||||
save.disabled = !opt.isAdmin || !!past || !dirty || list.length > 0;
|
||||
};
|
||||
|
||||
const paint = (): void => {
|
||||
paintKinds();
|
||||
paintDetail();
|
||||
paintFoot();
|
||||
};
|
||||
|
||||
const reload = async (): Promise<void> => {
|
||||
try {
|
||||
base = await fetchStructureDef();
|
||||
work = clone(base);
|
||||
dirty = false;
|
||||
sync();
|
||||
} catch (again) {
|
||||
showToast(to("Load_Failed", why(again)), "error");
|
||||
}
|
||||
};
|
||||
|
||||
const save = createButton({ label: to("Save"), variant: "filled" });
|
||||
save.setAttribute("data-sod-save", "");
|
||||
save.addEventListener("click", async () => {
|
||||
if (problems(work).length) return;
|
||||
save.disabled = true; // 연달아 눌러도 한 번만
|
||||
try {
|
||||
base = await saveStructureDef(base.판, tidy(work));
|
||||
work = clone(base);
|
||||
dirty = false;
|
||||
versions = null;
|
||||
sync();
|
||||
showToast(to("SavedVersion", String(base.판)), "success");
|
||||
} catch (error) {
|
||||
if (error instanceof StaleError) {
|
||||
showToast(to("Stale"), "error");
|
||||
await reload();
|
||||
} else showToast(to("Failed", why(error)), "error");
|
||||
} finally {
|
||||
paint();
|
||||
paintHistory();
|
||||
}
|
||||
});
|
||||
|
||||
/** 지난 판 목록 — 펼칠 때 받음 · 누르면 그 판을 읽기만으로 */
|
||||
const paintHistory = (): void => {
|
||||
if (!versions) return historyBox.replaceChildren();
|
||||
historyBox.replaceChildren(
|
||||
...(versions.length
|
||||
? versions.map((v) => {
|
||||
const b = createButton({
|
||||
label: to("PastItem", `${v.판} · ${v.저장일}`),
|
||||
variant: past?.판 === v.판 ? "filled" : "ghost",
|
||||
size: "sm",
|
||||
});
|
||||
b.setAttribute("data-sod-past", String(v.판));
|
||||
b.addEventListener("click", async () => {
|
||||
try {
|
||||
past = await fetchStructureDefAt(v.판);
|
||||
} catch (error) {
|
||||
return showToast(to("Load_Failed", why(error)), "error");
|
||||
}
|
||||
paint();
|
||||
paintHistory();
|
||||
});
|
||||
return b;
|
||||
})
|
||||
: [el("p", { className: "m02-side__empty", text: to("PastNone") })]),
|
||||
);
|
||||
};
|
||||
const history = createButton({ label: to("PastList"), variant: "ghost" });
|
||||
history.setAttribute("data-sod-history-toggle", "");
|
||||
history.addEventListener("click", async () => {
|
||||
if (versions) {
|
||||
versions = null;
|
||||
return paintHistory();
|
||||
}
|
||||
try {
|
||||
versions = (await fetchStructureDefHistory()).sort((a, b) => b.판 - a.판);
|
||||
} catch (error) {
|
||||
return showToast(to("Load_Failed", why(error)), "error");
|
||||
}
|
||||
paintHistory();
|
||||
});
|
||||
|
||||
const node = el("div", {
|
||||
className: "m02-opts m02-sod",
|
||||
children: [
|
||||
el("p", { className: "m02-side__empty", text: to("DefNote") }),
|
||||
banner,
|
||||
el("div", { className: "m02-opts__panes", children: [kindList, detail] }),
|
||||
issues,
|
||||
el("div", {
|
||||
className: "m02-side__row",
|
||||
children: [...(opt.isAdmin ? [save] : []), history],
|
||||
}),
|
||||
historyBox,
|
||||
],
|
||||
});
|
||||
const setDirty = opt.show(to("Screen"), node);
|
||||
const sync = (): void => setDirty(dirty);
|
||||
paint();
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/* =============================================================================
|
||||
* M02_SO_Def_Layout.ts
|
||||
* 구조물 정의 편집 화면(PLAN 48-2) — 고른 종류의 **배치 격자**(B05 · B06 좌측 구조물 칸 = 두 칸)
|
||||
* 옵션마다 줄 · 칸 · 너비 + 미리보기(기준 측점은 맨 위 한 줄 · 겹치면 빨강).
|
||||
* 자리를 안 정한 옵션은 차례대로 빈 칸을 채움 · 숫자를 고치면 그 옵션 자리만 정의 `배치` 에 남음.
|
||||
* ========================================================================== */
|
||||
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import {
|
||||
baseShown,
|
||||
GRID_COLS,
|
||||
live,
|
||||
overlaps,
|
||||
placeAll,
|
||||
type Spot,
|
||||
type StructDef,
|
||||
type TypeDef,
|
||||
} from "./M02_SO_Def_Model";
|
||||
import { head, numberBox } from "./M02_SO_Def_Ui";
|
||||
import { to } from "./M02_SO_Text";
|
||||
|
||||
export interface LayoutBlockOptions {
|
||||
def: StructDef;
|
||||
type: TypeDef;
|
||||
edit: boolean;
|
||||
changed: () => void;
|
||||
}
|
||||
|
||||
const ROW_MAX = 60;
|
||||
|
||||
/** 미리보기 — 두 칸 격자 · 옵션마다 상자 */
|
||||
function preview(def: StructDef, type: TypeDef, cells: Map<string, Spot>): HTMLElement {
|
||||
const bad = new Set(overlaps(cells));
|
||||
const base = baseShown(def, type.type_id);
|
||||
const offset = base ? 1 : 0;
|
||||
const boxes: HTMLElement[] = [];
|
||||
if (base)
|
||||
boxes.push(
|
||||
el("div", {
|
||||
className: "m02-sod__cell is-base",
|
||||
text: to("BaseStation"),
|
||||
attrs: { style: `grid-row: 1; grid-column: 1 / span ${GRID_COLS}` },
|
||||
}),
|
||||
);
|
||||
for (const o of live(type.options)) {
|
||||
const c = cells.get(o.key)!;
|
||||
boxes.push(
|
||||
el("div", {
|
||||
className: `m02-sod__cell${bad.has(o.key) ? " is-bad" : ""}`,
|
||||
text: o.label,
|
||||
attrs: {
|
||||
"data-sod-cell": o.key,
|
||||
title: o.label,
|
||||
style: `grid-row: ${c.줄 + offset}; grid-column: ${c.칸} / span ${c.너비}`,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
return el("div", {
|
||||
className: "m02-sod__grid",
|
||||
attrs: {
|
||||
"data-sod-preview": "",
|
||||
style: `grid-template-columns: repeat(${GRID_COLS}, minmax(0, 1fr))`,
|
||||
},
|
||||
children: boxes,
|
||||
});
|
||||
}
|
||||
|
||||
export function buildLayoutBlock(opt: LayoutBlockOptions): HTMLElement {
|
||||
const { def, type, edit, changed } = opt;
|
||||
const cells = placeAll(type.options, def.배치[type.type_id]);
|
||||
const rows = live(type.options).map((o) => {
|
||||
const c = cells.get(o.key)!;
|
||||
const fix = (patch: Partial<Spot>): void => {
|
||||
(def.배치[type.type_id] ??= {})[o.key] = { ...c, ...patch };
|
||||
changed();
|
||||
};
|
||||
return el("div", {
|
||||
className: "m02-sod__place",
|
||||
attrs: { "data-sod-place-row": o.key },
|
||||
children: [
|
||||
el("span", { className: "m02-sod__placename", text: o.label }),
|
||||
el("small", { text: to("GridRow") }),
|
||||
numberBox(c.줄, (n) => fix({ 줄: n }), {
|
||||
edit,
|
||||
min: 1,
|
||||
max: ROW_MAX,
|
||||
attr: "data-sod-row",
|
||||
}),
|
||||
el("small", { text: to("GridCol") }),
|
||||
numberBox(c.칸, (n) => fix({ 칸: n }), {
|
||||
edit,
|
||||
min: 1,
|
||||
max: GRID_COLS,
|
||||
attr: "data-sod-col",
|
||||
}),
|
||||
el("small", { text: to("GridWidth") }),
|
||||
numberBox(c.너비, (n) => fix({ 너비: n }), {
|
||||
edit,
|
||||
min: 1,
|
||||
max: GRID_COLS,
|
||||
attr: "data-sod-width",
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
return el("div", {
|
||||
className: "m02-sod__block",
|
||||
children: [
|
||||
...head(to("LayoutHead"), to("LayoutNote")),
|
||||
el("div", {
|
||||
className: "m02-sod__layout",
|
||||
children: [
|
||||
el("div", { className: "m02-sod__places", children: rows }),
|
||||
preview(def, type, cells),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
/* =============================================================================
|
||||
* M02_SO_Def_Model.ts
|
||||
* 구조물 정의 편집 화면(PLAN 48-2)의 자료 꼴 · 순수 손질 함수 — 화면 그리기 없음.
|
||||
* 정본 = 마스터 템플릿 `구조물정의.json`(48-1 · 틀 `docs/raw/M02_구조물옵션정의/정의_틀.md`).
|
||||
* 키는 만들 때 자동 · 안 바뀜 · 이름 자유 · 지움 = 「안 씀」(`enabled: false`).
|
||||
* 선택지는 글 그대로가 값 — 이름을 바꾸면 이 정의 안의 기본값 · 규칙도 같이 바꿈.
|
||||
* ========================================================================== */
|
||||
|
||||
export type OptionForm = "select" | "number" | "text" | "bool";
|
||||
|
||||
export interface Spot {
|
||||
줄: number;
|
||||
칸: number;
|
||||
너비: number;
|
||||
}
|
||||
|
||||
export interface OptionDef {
|
||||
key: string;
|
||||
label: string;
|
||||
input: OptionForm;
|
||||
choices?: string[];
|
||||
unit?: string | null;
|
||||
default?: unknown;
|
||||
enabled?: boolean;
|
||||
phase?: "b05" | "detail";
|
||||
[more: string]: unknown;
|
||||
}
|
||||
|
||||
export interface TypeDef {
|
||||
type_id: string;
|
||||
group: string;
|
||||
name: string;
|
||||
options: OptionDef[];
|
||||
enabled?: boolean;
|
||||
[more: string]: unknown;
|
||||
}
|
||||
|
||||
export type ShapeKey = "길이" | "높이" | "전" | "후";
|
||||
export const SHAPE_KEYS: readonly ShapeKey[] = ["길이", "높이", "전", "후"];
|
||||
|
||||
export interface GroupDef {
|
||||
id: string;
|
||||
이름: string;
|
||||
옵션: string[];
|
||||
형상?: Partial<Record<ShapeKey, { 받기: boolean; 뜻: string }>>;
|
||||
}
|
||||
|
||||
/** 만약 상위 옵션 = 값들 중 하나 → 하위 옵션의 선택지 · 보이기 · 기본값(하나 이상) */
|
||||
export interface RuleDef {
|
||||
종류: string;
|
||||
만약: { 옵션: string; 값들: string[] };
|
||||
그러면: { 옵션: string; 선택지?: string[]; 보이기?: boolean; 기본값?: unknown };
|
||||
}
|
||||
|
||||
export interface StructDef {
|
||||
판: number;
|
||||
구조물군: Record<string, string>;
|
||||
동작: Record<string, string>;
|
||||
types: TypeDef[];
|
||||
종류: Record<string, { 기준측점?: boolean; 동작?: string[] }>;
|
||||
표시: Record<string, Record<string, false>>;
|
||||
그룹: Record<string, GroupDef[]>;
|
||||
배치: Record<string, Record<string, Spot>>;
|
||||
규칙: RuleDef[];
|
||||
[more: string]: unknown;
|
||||
}
|
||||
|
||||
export const NAME_MAX = 40;
|
||||
/** B05 · B06 좌측 구조물 칸 = 두 칸 격자 */
|
||||
export const GRID_COLS = 2;
|
||||
|
||||
/** 깊은 사본 — 고칠 사본을 서버 문서와 떼어 놓음 */
|
||||
export const clone = <T>(value: T): T => structuredClone(value);
|
||||
|
||||
/** 새 키 — 머리글 + 영문 소문자 · 숫자 6자 · 이미 있는 키와 안 겹침 */
|
||||
export function newKey(prefix: string, taken: Iterable<string>): string {
|
||||
const used = new Set(taken);
|
||||
for (;;) {
|
||||
const key = `${prefix}${Math.random().toString(36).slice(2, 8)}`;
|
||||
if (key.length > prefix.length + 3 && !used.has(key)) return key;
|
||||
}
|
||||
}
|
||||
|
||||
/** 목록 안에서 한 칸 옮김 — 끝이면 그대로 · 옮겼으면 참 */
|
||||
export function step<T>(list: T[], index: number, delta: number): boolean {
|
||||
const at = index + delta;
|
||||
if (index < 0 || at < 0 || at >= list.length) return false;
|
||||
[list[index], list[at]] = [list[at], list[index]];
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 쓰는 것만 — 「안 씀」(enabled 거짓) 뺌 */
|
||||
export const live = <T extends { enabled?: boolean }>(list: readonly T[]): T[] =>
|
||||
list.filter((x) => x.enabled !== false);
|
||||
|
||||
/** 기준 측점 칸 보이기 — 없으면 보임 */
|
||||
export const baseShown = (def: StructDef, typeId: string): boolean =>
|
||||
def.종류[typeId]?.기준측점 !== false;
|
||||
|
||||
/** 이름 검사 — 1~40자 · 같은 목록 안에서 안 겹침 · 틀린 이름들 */
|
||||
export function badNames(names: readonly string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const bad: string[] = [];
|
||||
for (const raw of names) {
|
||||
const name = raw.trim();
|
||||
if (!name || name.length > NAME_MAX || seen.has(name)) bad.push(name || "(빈 이름)");
|
||||
seen.add(name);
|
||||
}
|
||||
return bad;
|
||||
}
|
||||
|
||||
/** 구조물군 순서 바꿈 — `{키: 이름}` 묶음의 키 차례 */
|
||||
export function stepFamily(def: StructDef, key: string, delta: number): boolean {
|
||||
const entries = Object.entries(def.구조물군);
|
||||
if (
|
||||
!step(
|
||||
entries,
|
||||
entries.findIndex(([k]) => k === key),
|
||||
delta,
|
||||
)
|
||||
)
|
||||
return false;
|
||||
def.구조물군 = Object.fromEntries(entries);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 종류 순서 바꿈 — 같은 군 안 앞뒤 종류와 `types` 자리를 바꿈 */
|
||||
export function stepType(def: StructDef, typeId: string, delta: number): boolean {
|
||||
const t = def.types.find((x) => x.type_id === typeId);
|
||||
if (!t) return false;
|
||||
const same = def.types.filter((x) => x.group === t.group);
|
||||
const other = same[same.indexOf(t) + delta];
|
||||
if (!other) return false;
|
||||
const i = def.types.indexOf(t);
|
||||
const j = def.types.indexOf(other);
|
||||
[def.types[i], def.types[j]] = [def.types[j], def.types[i]];
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 선택지 이름 바꿈 — 그 옵션 기본값 · 이 종류 규칙의 값들 · 선택지 · 기본값도 같이 */
|
||||
export function renameChoice(
|
||||
def: StructDef,
|
||||
typeId: string,
|
||||
o: OptionDef,
|
||||
from: string,
|
||||
to: string,
|
||||
): void {
|
||||
if (from === to || !o.choices) return;
|
||||
o.choices = o.choices.map((c) => (c === from ? to : c));
|
||||
if (o.default === from) o.default = to;
|
||||
for (const r of def.규칙) {
|
||||
if (r.종류 !== typeId) continue;
|
||||
if (r.만약.옵션 === o.key) r.만약.값들 = r.만약.값들.map((v) => (v === from ? to : v));
|
||||
if (r.그러면.옵션 === o.key) {
|
||||
if (r.그러면.선택지) r.그러면.선택지 = r.그러면.선택지.map((v) => (v === from ? to : v));
|
||||
if (r.그러면.기본값 === from) r.그러면.기본값 = to;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 선택지 뺌 — 기본값 · 규칙에서도 뺌(규칙 값들이 비면 그 규칙은 검사에서 걸림) */
|
||||
export function dropChoice(def: StructDef, typeId: string, o: OptionDef, value: string): void {
|
||||
o.choices = (o.choices ?? []).filter((c) => c !== value);
|
||||
if (o.default === value) o.default = null;
|
||||
for (const r of def.규칙) {
|
||||
if (r.종류 !== typeId) continue;
|
||||
if (r.만약.옵션 === o.key) r.만약.값들 = r.만약.값들.filter((v) => v !== value);
|
||||
if (r.그러면.옵션 === o.key) {
|
||||
if (r.그러면.선택지) r.그러면.선택지 = r.그러면.선택지.filter((v) => v !== value);
|
||||
if (r.그러면.기본값 === value) delete r.그러면.기본값;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 배치 격자 — 정한 자리 먼저 · 안 정한 옵션은 차례대로 빈 칸 채움 · 안 씀은 빠짐 */
|
||||
export function placeAll(
|
||||
options: readonly OptionDef[],
|
||||
spots: Record<string, Spot> = {},
|
||||
cols = GRID_COLS,
|
||||
): Map<string, Spot> {
|
||||
const out = new Map<string, Spot>();
|
||||
const taken = new Set<string>();
|
||||
const mark = (c: Spot): void => {
|
||||
for (let i = 0; i < c.너비; i += 1) taken.add(`${c.줄}:${c.칸 + i}`);
|
||||
};
|
||||
const shown = live(options);
|
||||
for (const o of shown) {
|
||||
const s = spots[o.key];
|
||||
if (!s) continue;
|
||||
const width = Math.max(1, Math.min(cols, s.너비 || 1));
|
||||
const cell = { 줄: s.줄, 칸: Math.min(s.칸, cols - width + 1), 너비: width };
|
||||
out.set(o.key, cell);
|
||||
mark(cell);
|
||||
}
|
||||
let row = 1;
|
||||
let col = 1;
|
||||
for (const o of shown) {
|
||||
if (out.has(o.key)) continue;
|
||||
while (taken.has(`${row}:${col}`)) {
|
||||
col += 1;
|
||||
if (col > cols) ((row += 1), (col = 1));
|
||||
}
|
||||
const cell = { 줄: row, 칸: col, 너비: 1 };
|
||||
out.set(o.key, cell);
|
||||
mark(cell);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 겹친 칸 — 두 옵션이 같은 줄 · 칸을 쓰면 그 키들 */
|
||||
export function overlaps(cells: Map<string, Spot>): string[] {
|
||||
const owner = new Map<string, string>();
|
||||
const bad = new Set<string>();
|
||||
for (const [key, c] of cells)
|
||||
for (let i = 0; i < c.너비; i += 1) {
|
||||
const at = `${c.줄}:${c.칸 + i}`;
|
||||
const other = owner.get(at);
|
||||
if (other) bad.add(other).add(key);
|
||||
else owner.set(at, key);
|
||||
}
|
||||
return [...bad];
|
||||
}
|
||||
|
||||
/** 규칙 한 줄이 틀렸나 — 서버 검사(`M02_SO_Definition._rule_errors`)와 같은 뜻 */
|
||||
export function ruleBad(def: StructDef, r: RuleDef): boolean {
|
||||
const t = def.types.find((x) => x.type_id === r.종류);
|
||||
const src = t?.options.find((o) => o.key === r.만약.옵션);
|
||||
const dst = t?.options.find((o) => o.key === r.그러면.옵션);
|
||||
if (!src || !dst || src === dst || !r.만약.값들.length) return true;
|
||||
if (src.input === "select" && !r.만약.값들.every((v) => src.choices?.includes(v))) return true;
|
||||
const { 선택지, 보이기, 기본값 } = r.그러면;
|
||||
if (선택지 === undefined && 보이기 === undefined && 기본값 === undefined) return true;
|
||||
let allowed = dst.choices ?? [];
|
||||
if (선택지 !== undefined) {
|
||||
if (dst.input !== "select" || !선택지.every((v) => allowed.includes(v))) return true;
|
||||
allowed = 선택지;
|
||||
}
|
||||
return 기본값 !== undefined && dst.input === "select" && !allowed.includes(String(기본값));
|
||||
}
|
||||
|
||||
/** 저장 전 검사 — 화면에 띄울 까닭 글들(빈 목록 = 저장 됨) */
|
||||
export function problems(def: StructDef): string[] {
|
||||
const out: string[] = [];
|
||||
const fam = badNames(Object.values(def.구조물군));
|
||||
if (fam.length) out.push(`구조물군 이름: ${fam.join(", ")}`);
|
||||
for (const g of Object.keys(def.구조물군)) {
|
||||
const bad = badNames(def.types.filter((t) => t.group === g).map((t) => t.name));
|
||||
if (bad.length) out.push(`${def.구조물군[g]} 종류 이름: ${bad.join(", ")}`);
|
||||
}
|
||||
for (const t of def.types) {
|
||||
const opt = badNames(t.options.map((o) => o.label));
|
||||
if (opt.length) out.push(`${t.name} 옵션 이름: ${opt.join(", ")}`);
|
||||
for (const o of t.options) {
|
||||
if (o.input !== "select") continue;
|
||||
const ch = badNames(o.choices ?? []);
|
||||
if (ch.length) out.push(`${t.name}.${o.label} 선택지: ${ch.join(", ")}`);
|
||||
if (!o.choices?.length) out.push(`${t.name}.${o.label} 고르기 꼴은 선택지가 있어야 함`);
|
||||
}
|
||||
const grp = badNames((def.그룹[t.type_id] ?? []).map((g) => g.이름));
|
||||
if (grp.length) out.push(`${t.name} 그룹 이름: ${grp.join(", ")}`);
|
||||
const lap = overlaps(placeAll(t.options, def.배치[t.type_id]));
|
||||
if (lap.length) out.push(`${t.name} 배치 겹침: ${lap.join(", ")}`);
|
||||
}
|
||||
def.규칙.forEach((r, i) => {
|
||||
if (ruleBad(def, r)) out.push(`규칙 ${i + 1} 이 틀림`);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
/* =============================================================================
|
||||
* M02_SO_Def_Options.ts
|
||||
* 구조물 정의 편집 화면(PLAN 48-2) — 고른 종류의 **옵션 목록**
|
||||
* (이름 · 꼴 · 단위 · 기본값 · 보이기 · 들어갈 자리 · 안 씀 · 순서 · [옵션 더함]) +
|
||||
* 고르기 꼴의 **선택지 목록**(이름 바꿈 · 빼기 · 순서 · [선택지 더함]).
|
||||
* 옵션 키는 더할 때 자동 · 안 바뀜 · 선택지는 글이 곧 값(이름 바꿈 = 기본값 · 규칙도 같이) ·
|
||||
* 저장은 화면 [저장](`M02_SO_Def.ts`).
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, createTag, el, showConfirmDialog } from "@ui/ui_template_elements";
|
||||
import {
|
||||
dropChoice,
|
||||
newKey,
|
||||
renameChoice,
|
||||
type OptionDef,
|
||||
type OptionForm,
|
||||
type StructDef,
|
||||
type TypeDef,
|
||||
} from "./M02_SO_Def_Model";
|
||||
import { checkBox, head, moveButtons, pickBox, textBox } from "./M02_SO_Def_Ui";
|
||||
import { buildPlaceSelect } from "./M02_SO_Groups";
|
||||
import { to } from "./M02_SO_Text";
|
||||
|
||||
export interface OptionsBlockOptions {
|
||||
def: StructDef;
|
||||
type: TypeDef;
|
||||
edit: boolean;
|
||||
/** 선택지를 펼친 옵션 키 — 다시 그려도 남음 */
|
||||
open: Set<string>;
|
||||
changed: () => void;
|
||||
}
|
||||
|
||||
const FORMS: readonly OptionForm[] = ["select", "number", "text", "bool"];
|
||||
const FORM_TEXT = {
|
||||
select: "FormSelect",
|
||||
number: "FormNumber",
|
||||
text: "FormText",
|
||||
bool: "FormBool",
|
||||
} as const;
|
||||
|
||||
/** 기본값 칸 — 꼴에 맞춤(고르기 = 선택지 · 예/아니오 · 숫자 · 글) · 비우면 기본값 없음 */
|
||||
function defaultBox(o: OptionDef, edit: boolean, changed: () => void): HTMLElement {
|
||||
const attr = "data-sod-default";
|
||||
const now = o.default == null ? "" : String(o.default);
|
||||
if (o.input === "select" || o.input === "bool") {
|
||||
const pairs: [string, string][] =
|
||||
o.input === "select"
|
||||
? (o.choices ?? []).map((c) => [c, c])
|
||||
: [
|
||||
["true", to("Yes")],
|
||||
["false", to("No")],
|
||||
];
|
||||
return pickBox(
|
||||
[["", to("NoDefault")], ...pairs],
|
||||
now,
|
||||
(v) => {
|
||||
o.default = v === "" ? null : o.input === "bool" ? v === "true" : v;
|
||||
changed();
|
||||
},
|
||||
{ edit, attr, cls: "m02-sod__default" },
|
||||
);
|
||||
}
|
||||
return textBox(
|
||||
now,
|
||||
(v) => {
|
||||
const n = Number(v);
|
||||
o.default = v.trim() === "" ? null : o.input === "number" && Number.isFinite(n) ? n : v;
|
||||
},
|
||||
changed,
|
||||
{ edit, attr, cls: "m02-sod__default", place: to("NoDefault") },
|
||||
);
|
||||
}
|
||||
|
||||
/** 선택지 목록 — 이름(떠날 때 바꿈) · 빼기 · 순서 · [선택지 더함] */
|
||||
function choicesBlock(opt: OptionsBlockOptions, o: OptionDef): HTMLElement {
|
||||
const { def, type, edit, changed } = opt;
|
||||
const list = (o.choices ??= []);
|
||||
const rows = list.map((c) => {
|
||||
let next = c;
|
||||
const del = createButton({ label: to("ChoiceDrop"), variant: "danger", size: "sm" });
|
||||
del.addEventListener("click", async () => {
|
||||
if (!(await showConfirmDialog(to("ChoiceDropAsk", c), to("ChoiceDrop")))) return;
|
||||
dropChoice(def, type.type_id, o, c);
|
||||
changed();
|
||||
});
|
||||
return el("div", {
|
||||
className: "m02-sod__choice",
|
||||
attrs: { "data-sod-choice": c },
|
||||
children: [
|
||||
textBox(
|
||||
c,
|
||||
(v) => (next = v.trim()),
|
||||
() => {
|
||||
if (next && next !== c && !list.includes(next))
|
||||
renameChoice(def, type.type_id, o, c, next);
|
||||
changed();
|
||||
},
|
||||
{ edit, attr: "data-sod-choice-name" },
|
||||
),
|
||||
...moveButtons(list, c, changed, edit),
|
||||
...(edit ? [del] : []),
|
||||
],
|
||||
});
|
||||
});
|
||||
const add = createButton({ label: to("ChoiceAdd"), variant: "ghost", size: "sm" });
|
||||
add.setAttribute("data-sod-choice-add", "");
|
||||
add.addEventListener("click", () => {
|
||||
let n = list.length + 1;
|
||||
while (list.includes(to("ChoiceNew", String(n)))) n += 1;
|
||||
list.push(to("ChoiceNew", String(n)));
|
||||
changed();
|
||||
});
|
||||
return el("div", {
|
||||
className: "m02-sod__choices",
|
||||
children: [
|
||||
el("p", { className: "m02-side__empty", text: to("ChoiceNote") }),
|
||||
...(rows.length ? rows : [el("p", { className: "m02-sod__bad", text: to("ChoiceNone") })]),
|
||||
...(edit ? [add] : []),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/** 옵션 한 줄(+ 펼친 선택지) */
|
||||
function optionRow(opt: OptionsBlockOptions, o: OptionDef): HTMLElement {
|
||||
const { def, type, edit, open, changed } = opt;
|
||||
const off = o.enabled === false;
|
||||
const hidden = def.표시[type.type_id]?.[o.key] === false;
|
||||
const form = pickBox(
|
||||
FORMS.map((f) => [f, to(FORM_TEXT[f])] as const),
|
||||
o.input,
|
||||
(v) => {
|
||||
o.input = v as OptionForm;
|
||||
o.default = null; // 꼴이 바뀌면 옛 기본값은 뜻이 없음
|
||||
if (o.input === "select") open.add(o.key);
|
||||
changed();
|
||||
},
|
||||
{ edit, attr: "data-sod-form", cls: "m02-sod__form" },
|
||||
);
|
||||
const choices =
|
||||
o.input === "select"
|
||||
? createButton({
|
||||
label: to("Choices", String(o.choices?.length ?? 0)),
|
||||
variant: open.has(o.key) ? "filled" : "ghost",
|
||||
size: "sm",
|
||||
})
|
||||
: null;
|
||||
choices?.setAttribute("data-sod-choices", o.key);
|
||||
choices?.addEventListener("click", () => {
|
||||
if (open.has(o.key)) open.delete(o.key);
|
||||
else open.add(o.key);
|
||||
changed();
|
||||
});
|
||||
const show = checkBox(
|
||||
to("Shown"),
|
||||
!hidden,
|
||||
(v) => {
|
||||
const row = (def.표시[type.type_id] ??= {});
|
||||
if (v) delete row[o.key];
|
||||
else row[o.key] = false;
|
||||
if (!Object.keys(row).length) delete def.표시[type.type_id];
|
||||
changed();
|
||||
},
|
||||
{ edit, attr: "data-sod-shown" },
|
||||
);
|
||||
const row = el("div", {
|
||||
className: `m02-sod__option${off ? " is-off" : ""}`,
|
||||
attrs: { "data-sod-option": o.key },
|
||||
children: [
|
||||
textBox(o.label, (v) => (o.label = v), changed, {
|
||||
edit,
|
||||
attr: "data-sod-name",
|
||||
cls: "m02-sod__name",
|
||||
}),
|
||||
el("small", { className: "m02-opts__key", text: o.key }),
|
||||
...(o.phase === "detail" ? [createTag(to("Detail"), "accent")] : []),
|
||||
form,
|
||||
textBox(o.unit ?? "", (v) => (o.unit = v.trim() || null), changed, {
|
||||
edit,
|
||||
attr: "data-sod-unit",
|
||||
cls: "m02-sod__unit",
|
||||
place: to("Unit"),
|
||||
max: 12,
|
||||
}),
|
||||
defaultBox(o, edit, changed),
|
||||
show,
|
||||
buildPlaceSelect((def.그룹[type.type_id] ??= []), o.key, edit, changed),
|
||||
checkBox(to("Unused"), off, (v) => ((o.enabled = !v), changed()), {
|
||||
edit,
|
||||
attr: "data-sod-off",
|
||||
}),
|
||||
...moveButtons(type.options, o, changed, edit),
|
||||
...(choices ? [choices] : []),
|
||||
],
|
||||
});
|
||||
return o.input === "select" && open.has(o.key)
|
||||
? el("div", { className: "m02-sod__optwrap", children: [row, choicesBlock(opt, o)] })
|
||||
: row;
|
||||
}
|
||||
|
||||
/** 옵션 목록 칸 — 제목 · 안내 · 옵션 줄 · [옵션 더함] */
|
||||
export function buildOptionsBlock(opt: OptionsBlockOptions): HTMLElement {
|
||||
const { type, edit, changed } = opt;
|
||||
const add = createButton({ label: to("OptionAdd"), variant: "ghost", size: "sm" });
|
||||
add.setAttribute("data-sod-option-add", "");
|
||||
add.addEventListener("click", () => {
|
||||
const n = type.options.length + 1;
|
||||
const o: OptionDef = {
|
||||
key: newKey(
|
||||
"opt_",
|
||||
type.options.map((x) => x.key),
|
||||
),
|
||||
label: to("OptionNew", String(n)),
|
||||
input: "select",
|
||||
choices: [to("ChoiceNew", "1")],
|
||||
default: null,
|
||||
};
|
||||
type.options.push(o);
|
||||
opt.open.add(o.key);
|
||||
changed();
|
||||
});
|
||||
return el("div", {
|
||||
className: "m02-sod__block",
|
||||
children: [
|
||||
...head(to("OptionHead"), to("OptionNote")),
|
||||
...(type.options.length
|
||||
? type.options.map((o) => optionRow(opt, o))
|
||||
: [el("p", { className: "m02-side__empty", text: to("NoOptionKind") })]),
|
||||
...(edit ? [el("div", { className: "m02-side__row", children: [add] })] : []),
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
/* =============================================================================
|
||||
* M02_SO_Def_Rules.ts
|
||||
* 구조물 정의 편집 화면(PLAN 48-2) — 고른 종류의 **규칙 표**(정의 `규칙` 중 그 종류 것만)
|
||||
* 「만약 상위 옵션 = 값들 중 하나 → 하위 옵션의 선택지 · 보이기 · 기본값」 한 줄씩 · [규칙 더함] · 지우기.
|
||||
* 「그대로」 칸은 정의에 안 남김 · 셋 다 그대로면 틀린 줄(빨강) · 적용은 B05 · B06(48-4).
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import {
|
||||
live,
|
||||
ruleBad,
|
||||
type OptionDef,
|
||||
type RuleDef,
|
||||
type StructDef,
|
||||
type TypeDef,
|
||||
} from "./M02_SO_Def_Model";
|
||||
import { head, pickBox } from "./M02_SO_Def_Ui";
|
||||
import { to } from "./M02_SO_Text";
|
||||
|
||||
export interface RulesBlockOptions {
|
||||
def: StructDef;
|
||||
type: TypeDef;
|
||||
edit: boolean;
|
||||
changed: () => void;
|
||||
}
|
||||
|
||||
const bool = (): [string, string][] => [
|
||||
["true", to("Yes")],
|
||||
["false", to("No")],
|
||||
];
|
||||
|
||||
/** 여럿 고르기 — 체크한 것만 차례대로 */
|
||||
function multiPick(
|
||||
all: readonly string[],
|
||||
picked: readonly string[],
|
||||
set: (v: string[]) => void,
|
||||
edit: boolean,
|
||||
attr: string,
|
||||
): HTMLElement {
|
||||
const on = new Set(picked);
|
||||
return el("div", {
|
||||
className: "m02-sod__rulechoices",
|
||||
attrs: { [attr]: "" },
|
||||
children: all.map((c) => {
|
||||
const box = el("input", { attrs: { type: "checkbox", value: c } }) as HTMLInputElement;
|
||||
box.checked = on.has(c);
|
||||
box.disabled = !edit;
|
||||
box.addEventListener("change", () => {
|
||||
if (box.checked) on.add(c);
|
||||
else on.delete(c);
|
||||
set(all.filter((x) => on.has(x)));
|
||||
});
|
||||
return el("label", { className: "m02-sod__check", children: [box, el("span", { text: c })] });
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/** 글 칸 — 쉼표로 여럿 */
|
||||
function listText(
|
||||
value: string[],
|
||||
set: (v: string[]) => void,
|
||||
edit: boolean,
|
||||
attr: string,
|
||||
): HTMLElement {
|
||||
const box = el("input", {
|
||||
className: "ui-input ui-input--sm m02-sod__rulepick",
|
||||
attrs: { type: "text", [attr]: "", placeholder: to("RuleValues") },
|
||||
}) as HTMLInputElement;
|
||||
box.value = value.join(", ");
|
||||
box.disabled = !edit;
|
||||
box.addEventListener("change", () =>
|
||||
set(
|
||||
box.value
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
),
|
||||
);
|
||||
return box;
|
||||
}
|
||||
|
||||
/** 만약 값들 — 고르기 = 선택지 체크 · 예/아니오 = 하나 · 그 밖 = 쉼표 글 */
|
||||
function whenValues(
|
||||
src: OptionDef | undefined,
|
||||
rule: RuleDef,
|
||||
edit: boolean,
|
||||
changed: () => void,
|
||||
): HTMLElement {
|
||||
const set = (v: string[]): void => ((rule.만약.값들 = v), changed());
|
||||
if (src?.input === "select")
|
||||
return multiPick(src.choices ?? [], rule.만약.값들, set, edit, "data-sod-rule-value");
|
||||
if (src?.input === "bool")
|
||||
return pickBox(bool(), rule.만약.값들[0] ?? "true", (v) => set([v]), {
|
||||
edit,
|
||||
attr: "data-sod-rule-value",
|
||||
cls: "m02-sod__rulepick",
|
||||
});
|
||||
return listText(rule.만약.값들, set, edit, "data-sod-rule-value");
|
||||
}
|
||||
|
||||
/** 그러면 기본값 — 「그대로」 + (좁힌) 선택지 · 예/아니오 · 글 */
|
||||
function thenDefault(
|
||||
dst: OptionDef | undefined,
|
||||
rule: RuleDef,
|
||||
edit: boolean,
|
||||
changed: () => void,
|
||||
): HTMLElement {
|
||||
const then = rule.그러면;
|
||||
const now = then.기본값 === undefined ? "" : String(then.기본값);
|
||||
const set = (v: string): void => {
|
||||
if (v === "") delete then.기본값;
|
||||
else
|
||||
then.기본값 =
|
||||
dst?.input === "bool"
|
||||
? v === "true"
|
||||
: dst?.input === "number" && Number.isFinite(Number(v))
|
||||
? Number(v)
|
||||
: v;
|
||||
changed();
|
||||
};
|
||||
const pairs: [string, string][] =
|
||||
dst?.input === "select"
|
||||
? (then.선택지 ?? dst.choices ?? []).map((c) => [c, c])
|
||||
: dst?.input === "bool"
|
||||
? bool()
|
||||
: [];
|
||||
if (pairs.length || (dst?.input !== "number" && dst?.input !== "text"))
|
||||
return pickBox([["", to("KeepDefault")], ...pairs], now, set, {
|
||||
edit,
|
||||
attr: "data-sod-rule-default",
|
||||
cls: "m02-sod__rulepick",
|
||||
});
|
||||
const box = el("input", {
|
||||
className: "ui-input ui-input--sm m02-sod__rulepick",
|
||||
attrs: { type: "text", "data-sod-rule-default": "", placeholder: to("KeepDefault") },
|
||||
}) as HTMLInputElement;
|
||||
box.value = now;
|
||||
box.disabled = !edit;
|
||||
box.addEventListener("change", () => set(box.value.trim()));
|
||||
return box;
|
||||
}
|
||||
|
||||
function ruleRow(opt: RulesBlockOptions, rule: RuleDef, index: number): HTMLElement {
|
||||
const { def, type, edit, changed } = opt;
|
||||
const byKey = new Map(type.options.map((o) => [o.key, o]));
|
||||
const names = live(type.options).map((o) => [o.key, o.label] as [string, string]);
|
||||
const src = byKey.get(rule.만약.옵션);
|
||||
const dst = byKey.get(rule.그러면.옵션);
|
||||
const then = rule.그러면;
|
||||
const del = createButton({ label: to("GroupDel"), variant: "danger", size: "sm" });
|
||||
del.addEventListener("click", () => (def.규칙.splice(def.규칙.indexOf(rule), 1), changed()));
|
||||
const show = then.보이기 === undefined ? "" : then.보이기 ? "1" : "0";
|
||||
return el("div", {
|
||||
className: `m02-sod__rule${ruleBad(def, rule) ? " is-bad" : ""}`,
|
||||
attrs: { "data-sod-rule": String(index) },
|
||||
children: [
|
||||
el("small", { text: to("RuleIf") }),
|
||||
pickBox(names, rule.만약.옵션, (v) => ((rule.만약 = { 옵션: v, 값들: [] }), changed()), {
|
||||
edit,
|
||||
attr: "data-sod-rule-top",
|
||||
cls: "m02-sod__rulepick",
|
||||
}),
|
||||
el("small", { text: "=" }),
|
||||
whenValues(src, rule, edit, changed),
|
||||
el("small", { text: to("RuleThen") }),
|
||||
pickBox(names, then.옵션, (v) => ((rule.그러면 = { 옵션: v }), changed()), {
|
||||
edit,
|
||||
attr: "data-sod-rule-target",
|
||||
cls: "m02-sod__rulepick",
|
||||
}),
|
||||
...(dst?.input === "select"
|
||||
? [
|
||||
el("small", { text: to("RuleChoices") }),
|
||||
multiPick(
|
||||
dst.choices ?? [],
|
||||
then.선택지 ?? [],
|
||||
(v) => {
|
||||
if (v.length) then.선택지 = v;
|
||||
else delete then.선택지;
|
||||
changed();
|
||||
},
|
||||
edit,
|
||||
"data-sod-rule-choice",
|
||||
),
|
||||
]
|
||||
: []),
|
||||
pickBox(
|
||||
[
|
||||
["", to("Keep")],
|
||||
["1", to("Show")],
|
||||
["0", to("Hide")],
|
||||
],
|
||||
show,
|
||||
(v) => {
|
||||
if (v === "") delete then.보이기;
|
||||
else then.보이기 = v === "1";
|
||||
changed();
|
||||
},
|
||||
{ edit, attr: "data-sod-rule-show", cls: "m02-sod__rulepick" },
|
||||
),
|
||||
thenDefault(dst, rule, edit, changed),
|
||||
...(edit ? [del] : []),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
export function buildRulesBlock(opt: RulesBlockOptions): HTMLElement {
|
||||
const { def, type, edit, changed } = opt;
|
||||
const mine = def.규칙.filter((r) => r.종류 === type.type_id);
|
||||
const opts = live(type.options);
|
||||
const add = createButton({ label: to("RuleAdd"), variant: "ghost", size: "sm" });
|
||||
add.setAttribute("data-sod-rule-add", "");
|
||||
add.disabled = opts.length < 2;
|
||||
add.addEventListener("click", () => {
|
||||
const top = opts.find((o) => o.input === "select") ?? opts[0];
|
||||
const target = opts.find((o) => o.key !== top.key)!;
|
||||
def.규칙.push({
|
||||
종류: type.type_id,
|
||||
만약: { 옵션: top.key, 값들: top.choices?.length ? [top.choices[0]] : [] },
|
||||
그러면: { 옵션: target.key, 보이기: false },
|
||||
});
|
||||
changed();
|
||||
});
|
||||
return el("div", {
|
||||
className: "m02-sod__block",
|
||||
children: [
|
||||
...head(to("RuleHead"), to("RuleNote")),
|
||||
...(mine.length
|
||||
? mine.map((r) => ruleRow(opt, r, def.규칙.indexOf(r)))
|
||||
: [el("p", { className: "m02-side__empty", text: to("RuleNone") })]),
|
||||
...(edit ? [el("div", { className: "m02-side__row", children: [add] })] : []),
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/* =============================================================================
|
||||
* M02_SO_Def_Ui.ts
|
||||
* 구조물 정의 편집 화면(PLAN 48-2)의 작은 입력 부품 — 글 · 숫자 · 체크 · 고르기 · 위아래 단추.
|
||||
* 값은 받은 손잡이로 사본만 고침 · 글은 칸을 떠날 때(`change`) 다시 그림(초점 안 끊김).
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import { step } from "./M02_SO_Def_Model";
|
||||
import { to } from "./M02_SO_Text";
|
||||
|
||||
/** 글 칸 — 글자마다 `set` · 떠날 때 `done` */
|
||||
export function textBox(
|
||||
value: string,
|
||||
set: (v: string) => void,
|
||||
done: () => void,
|
||||
opt: { edit: boolean; attr?: string; place?: string; cls?: string; max?: number },
|
||||
): HTMLInputElement {
|
||||
const box = el("input", {
|
||||
className: `ui-input ui-input--sm ${opt.cls ?? ""}`,
|
||||
attrs: {
|
||||
type: "text",
|
||||
maxlength: String(opt.max ?? 40),
|
||||
...(opt.attr ? { [opt.attr]: "" } : {}),
|
||||
},
|
||||
}) as HTMLInputElement;
|
||||
box.value = value;
|
||||
box.placeholder = opt.place ?? "";
|
||||
box.disabled = !opt.edit;
|
||||
box.addEventListener("input", () => {
|
||||
set(box.value);
|
||||
box.classList.toggle("is-invalid", !box.value.trim() && !opt.place);
|
||||
});
|
||||
box.addEventListener("change", done);
|
||||
return box;
|
||||
}
|
||||
|
||||
/** 숫자 칸 — 범위 밖 · 빈 칸은 무름 */
|
||||
export function numberBox(
|
||||
value: number,
|
||||
set: (v: number) => void,
|
||||
opt: { edit: boolean; min: number; max: number; attr?: string },
|
||||
): HTMLInputElement {
|
||||
const box = el("input", {
|
||||
className: "ui-input ui-input--sm m02-sod__num",
|
||||
attrs: {
|
||||
type: "number",
|
||||
min: String(opt.min),
|
||||
max: String(opt.max),
|
||||
...(opt.attr ? { [opt.attr]: "" } : {}),
|
||||
},
|
||||
}) as HTMLInputElement;
|
||||
box.value = String(value);
|
||||
box.disabled = !opt.edit;
|
||||
box.addEventListener("change", () => {
|
||||
const n = Math.round(Number(box.value));
|
||||
if (box.value === "" || !Number.isFinite(n) || n < opt.min || n > opt.max) {
|
||||
box.value = String(value);
|
||||
return;
|
||||
}
|
||||
set(n);
|
||||
});
|
||||
return box;
|
||||
}
|
||||
|
||||
/** 체크 + 글 */
|
||||
export function checkBox(
|
||||
label: string,
|
||||
on: boolean,
|
||||
set: (v: boolean) => void,
|
||||
opt: { edit: boolean; attr?: string },
|
||||
): HTMLLabelElement {
|
||||
const box = el("input", {
|
||||
attrs: { type: "checkbox", ...(opt.attr ? { [opt.attr]: "" } : {}) },
|
||||
}) as HTMLInputElement;
|
||||
box.checked = on;
|
||||
box.disabled = !opt.edit;
|
||||
box.addEventListener("change", () => set(box.checked));
|
||||
return el("label", { className: "m02-sod__check", children: [box, el("span", { text: label })] });
|
||||
}
|
||||
|
||||
/** 고르기 — [값, 글] 짝 */
|
||||
export function pickBox(
|
||||
pairs: readonly (readonly [string, string])[],
|
||||
value: string,
|
||||
set: (v: string) => void,
|
||||
opt: { edit: boolean; attr?: string; cls?: string },
|
||||
): HTMLSelectElement {
|
||||
const box = el("select", {
|
||||
className: `ui-input ui-input--sm ${opt.cls ?? ""}`,
|
||||
attrs: opt.attr ? { [opt.attr]: "" } : {},
|
||||
}) as HTMLSelectElement;
|
||||
box.append(...pairs.map(([v, t]) => new Option(t, v)));
|
||||
box.value = value;
|
||||
box.disabled = !opt.edit;
|
||||
box.addEventListener("change", () => set(box.value));
|
||||
return box;
|
||||
}
|
||||
|
||||
/** 위로 · 아래로 단추 둘 — 고치는 사람만 · 옮겼으면 `changed` */
|
||||
export function moveButtons<T>(
|
||||
list: T[],
|
||||
item: T,
|
||||
changed: () => void,
|
||||
edit: boolean,
|
||||
): HTMLElement[] {
|
||||
if (!edit) return [];
|
||||
const index = list.indexOf(item);
|
||||
const make = (label: string, delta: number, off: boolean): HTMLButtonElement => {
|
||||
const b = createButton({ label, variant: "ghost", size: "sm" });
|
||||
b.disabled = off;
|
||||
b.addEventListener("click", () => step(list, list.indexOf(item), delta) && changed());
|
||||
return b;
|
||||
};
|
||||
return [make(to("GroupUp"), -1, index <= 0), make(to("GroupDown"), 1, index >= list.length - 1)];
|
||||
}
|
||||
|
||||
/** 절 제목 + 안내 한 줄 */
|
||||
export const head = (title: string, note?: string): HTMLElement[] => [
|
||||
el("h4", { className: "m02-opts__group", text: title }),
|
||||
...(note ? [el("p", { className: "m02-side__empty", text: note })] : []),
|
||||
];
|
||||
@@ -0,0 +1,217 @@
|
||||
"""M02 구조물 정의 한 벌(PLAN 48-1) — 정본 `resources/master_template/구조물정의.json`.
|
||||
|
||||
틀 = `docs/raw/M02_구조물옵션정의/정의_틀.md`. 읽기 = 로그인 누구나 · 쓰기 = 시스템 관리자만.
|
||||
판 = 정수 · 저장마다 +1 · 앞 판은 `구조물정의_이력/판_N.json` 으로 남김(지난 정의 값 읽기용).
|
||||
검사 = 칸 꼴 · 키 겹침 · 표시 · 그룹 · 배치 · 규칙 대상 있음 — 틀리면 422 · 낡은 판 409.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from pydantic import ValidationError
|
||||
|
||||
from B05_Profile import B05_Profile_Structures_Schema as schema
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
|
||||
FILE = "구조물정의.json"
|
||||
HISTORY = "구조물정의_이력"
|
||||
TOP = {"판", "schema_version", "comment", "구조물군", "동작", "types"}
|
||||
SECTIONS = {"종류": dict, "표시": dict, "그룹": dict, "배치": dict, "규칙": list}
|
||||
SPOT = ("줄", "칸", "너비")
|
||||
|
||||
|
||||
def read() -> dict[str, Any]:
|
||||
return schema.load_definition()
|
||||
|
||||
|
||||
def history_path(version: int) -> Path:
|
||||
return Path(schema.DEFINITION_PATH).parent / HISTORY / f"판_{version}.json"
|
||||
|
||||
|
||||
def history() -> list[dict[str, Any]]:
|
||||
"""지난 판 목록 `[{판, 저장일}]` — 이력 `판_N.json` · 저장일 = 파일 고친 때(분까지)."""
|
||||
folder = history_path(1).parent
|
||||
rows = []
|
||||
for path in folder.glob("판_*.json") if folder.is_dir() else []:
|
||||
number = path.stem.removeprefix("판_")
|
||||
if number.isdigit():
|
||||
when = datetime.fromtimestamp(path.stat().st_mtime).strftime("%Y-%m-%d %H:%M")
|
||||
rows.append({"판": int(number), "저장일": when})
|
||||
return sorted(rows, key=lambda r: r["판"], reverse=True)
|
||||
|
||||
|
||||
def read_version(version: int) -> dict[str, Any]:
|
||||
"""지난 판 한 벌(읽기만) — 없으면 404."""
|
||||
path = history_path(version)
|
||||
if not path.is_file():
|
||||
raise store.StoreError(404, f"판 {version} 이력이 없음")
|
||||
return json.loads(path.read_text("utf-8"))
|
||||
|
||||
|
||||
def _type_errors(doc: dict[str, Any]) -> tuple[list[str], dict[str, dict[str, Any]]]:
|
||||
"""(틀린 것 · 종류 → {옵션 키: 옵션}) — 종류 꼴 · 키 겹침 · 구조물군 있음."""
|
||||
errors: list[str] = []
|
||||
keys: dict[str, dict[str, Any]] = {}
|
||||
groups = doc.get("구조물군")
|
||||
if not isinstance(groups, dict):
|
||||
errors.append("구조물군은 {키: 이름} 묶음이어야 함")
|
||||
groups = {}
|
||||
for row in doc.get("types") if isinstance(doc.get("types"), list) else []:
|
||||
try:
|
||||
t = schema.StructureType.model_validate(row)
|
||||
except ValidationError as e:
|
||||
errors.append(
|
||||
f"종류 「{row.get('type_id') if isinstance(row, dict) else row}」 꼴 — {e}"
|
||||
)
|
||||
continue
|
||||
if t.type_id in keys:
|
||||
errors.append(f"종류 키 「{t.type_id}」 겹침")
|
||||
if t.group not in groups:
|
||||
errors.append(f"「{t.type_id}」 구조물군 「{t.group}」 이 구조물군에 없음")
|
||||
opts: dict[str, Any] = {}
|
||||
for o in t.options:
|
||||
if o.key in opts:
|
||||
errors.append(f"「{t.type_id}」 옵션 키 「{o.key}」 겹침")
|
||||
if o.input == "select" and not o.choices:
|
||||
errors.append(f"「{t.type_id}.{o.key}」 고르기 꼴은 선택지가 있어야 함")
|
||||
opts[o.key] = o
|
||||
keys[t.type_id] = opts
|
||||
if not isinstance(doc.get("types"), list) or not keys:
|
||||
errors.append("types 는 종류 목록이어야 함")
|
||||
return errors, keys
|
||||
|
||||
|
||||
def _kind_errors(doc: dict[str, Any], keys: dict[str, dict[str, Any]]) -> list[str]:
|
||||
"""종류마다 덧칸 — `{기준측점: 참거짓, 동작: [동작 이름]}`."""
|
||||
errors: list[str] = []
|
||||
acts = doc.get("동작") if isinstance(doc.get("동작"), dict) else {}
|
||||
for tid, row in doc["종류"].items():
|
||||
if tid not in keys or not isinstance(row, dict):
|
||||
errors.append(f"종류 덧칸 「{tid}」 — 없는 종류이거나 묶음 아님")
|
||||
continue
|
||||
for name, value in row.items():
|
||||
if name == "기준측점" and isinstance(value, bool):
|
||||
continue
|
||||
if name == "동작" and isinstance(value, list) and all(a in acts for a in value):
|
||||
continue
|
||||
errors.append(
|
||||
f"종류 덧칸 「{tid}.{name}」 이 틀림(기준측점 참거짓 · 동작 = 동작 이름들)"
|
||||
)
|
||||
return errors
|
||||
|
||||
|
||||
def _spot_errors(doc: dict[str, Any], keys: dict[str, dict[str, Any]]) -> list[str]:
|
||||
"""배치 — `{종류: {옵션 키: {줄, 칸, 너비}}}` · 1 이상 정수."""
|
||||
errors: list[str] = []
|
||||
for tid, row in doc["배치"].items():
|
||||
if tid not in keys or not isinstance(row, dict):
|
||||
errors.append(f"배치 「{tid}」 — 없는 종류이거나 묶음 아님")
|
||||
continue
|
||||
for key, spot in row.items():
|
||||
ok = isinstance(spot, dict) and set(spot) <= set(SPOT)
|
||||
ok = ok and all(type(v) is int and v >= 1 for v in spot.values())
|
||||
if key not in keys[tid] or not ok:
|
||||
errors.append(
|
||||
f"배치 「{tid}.{key}」 — 없는 옵션이거나 줄 · 칸 · 너비가 1 이상 정수 아님"
|
||||
)
|
||||
return errors
|
||||
|
||||
|
||||
def _rule_errors(doc: dict[str, Any], keys: dict[str, dict[str, Any]]) -> list[str]:
|
||||
"""규칙 — `{종류, 만약: {옵션, 값들}, 그러면: {옵션, 선택지? · 보이기? · 기본값?}}`."""
|
||||
errors: list[str] = []
|
||||
for i, rule in enumerate(doc["규칙"], 1):
|
||||
where = f"규칙 {i}"
|
||||
if not isinstance(rule, dict) or rule.get("종류") not in keys:
|
||||
errors.append(f"{where} — 없는 종류")
|
||||
continue
|
||||
opts = keys[rule["종류"]]
|
||||
when, then = rule.get("만약"), rule.get("그러면")
|
||||
if not isinstance(when, dict) or not isinstance(then, dict):
|
||||
errors.append(f"{where} — 만약 · 그러면은 묶음이어야 함")
|
||||
continue
|
||||
src, dst = opts.get(when.get("옵션")), opts.get(then.get("옵션"))
|
||||
if src is None or dst is None or src is dst:
|
||||
errors.append(f"{where} — 만약 · 그러면 옵션이 없거나 같음")
|
||||
continue
|
||||
values = when.get("값들")
|
||||
if not isinstance(values, list) or not values:
|
||||
errors.append(f"{where} — 값들이 비었음")
|
||||
elif src.input == "select" and not set(map(str, values)) <= set(src.choices):
|
||||
errors.append(f"{where} — 값들이 「{src.key}」 선택지에 없음")
|
||||
effects = set(then) - {"옵션"}
|
||||
if not effects or not effects <= {"선택지", "보이기", "기본값"}:
|
||||
errors.append(f"{where} — 그러면은 선택지 · 보이기 · 기본값 중 하나 이상")
|
||||
allowed = dst.choices
|
||||
if "선택지" in then:
|
||||
pick = then["선택지"]
|
||||
if dst.input != "select" or not isinstance(pick, list) or not set(pick) <= set(allowed):
|
||||
errors.append(f"{where} — 선택지는 「{dst.key}」 고르기 선택지 안에서만")
|
||||
else:
|
||||
allowed = pick
|
||||
if "보이기" in then and not isinstance(then["보이기"], bool):
|
||||
errors.append(f"{where} — 보이기는 참거짓")
|
||||
if "기본값" in then and dst.input == "select" and str(then["기본값"]) not in allowed:
|
||||
errors.append(f"{where} — 기본값이 「{dst.key}」 선택지에 없음")
|
||||
return errors
|
||||
|
||||
|
||||
def check(doc: Any) -> list[str]:
|
||||
"""정의 한 벌 검사 — 틀린 것 목록(빈 목록 = 통과)."""
|
||||
from M02_MasterTemplete.StructOptions import M02_SO_Groups as groups
|
||||
from M02_MasterTemplete.StructOptions import M02_SO_Options as options
|
||||
|
||||
if not isinstance(doc, dict):
|
||||
return ["정의는 묶음이어야 함"]
|
||||
errors = [f"모르는 칸 「{k}」" for k in doc if k not in TOP and k not in SECTIONS]
|
||||
errors += [
|
||||
f"「{k}」 꼴이 틀림" for k, kind in SECTIONS.items() if not isinstance(doc.get(k), kind)
|
||||
]
|
||||
if not isinstance(doc.get("동작"), dict):
|
||||
errors.append("동작은 {이름: 뜻} 묶음이어야 함")
|
||||
if errors:
|
||||
return errors
|
||||
found, keys = _type_errors(doc)
|
||||
errors += found
|
||||
plain = {tid: set(opts) for tid, opts in keys.items()}
|
||||
for fn, section in ((options._clean, "표시"), (groups._clean, "그룹")):
|
||||
try:
|
||||
fn(doc[section], plain)
|
||||
except store.StoreError as e:
|
||||
errors += e.detail["errors"] if isinstance(e.detail, dict) else [str(e.detail)]
|
||||
return errors + _kind_errors(doc, keys) + _spot_errors(doc, keys) + _rule_errors(doc, keys)
|
||||
|
||||
|
||||
def _commit(change: Callable[[dict[str, Any]], dict[str, Any]], version: Any) -> dict[str, Any]:
|
||||
"""판이 같을 때만 고쳐 씀 — 앞 판은 이력으로 · 새 판 = 앞 판 + 1."""
|
||||
path = Path(schema.DEFINITION_PATH)
|
||||
with store._LOCK:
|
||||
current = read()
|
||||
have = int(current.get("판", 1))
|
||||
if str(version) != str(have):
|
||||
raise store.StoreError(409, {"stale": [FILE], "판": have})
|
||||
doc = {**change(current), "판": have}
|
||||
errors = check(doc)
|
||||
if errors:
|
||||
raise store.StoreError(422, {"errors": errors})
|
||||
atomic_write_json(history_path(have), current)
|
||||
atomic_write_json(path, {**doc, "판": have + 1})
|
||||
return read()
|
||||
|
||||
|
||||
def save(doc: Any, version: Any) -> dict[str, Any]:
|
||||
"""정의 통째로 고쳐 씀(시스템 관리자) — 돌려주는 것 = 새 정의."""
|
||||
if not isinstance(doc, dict):
|
||||
raise store.StoreError(400, "정의는 묶음이어야 함")
|
||||
return _commit(lambda _: dict(doc), version)
|
||||
|
||||
|
||||
def save_sections(changes: dict[str, Any], version: Any) -> dict[str, Any]:
|
||||
"""정의 몇 칸만 고쳐 씀(옵션 보이기 · 그룹 화면)."""
|
||||
return _commit(lambda current: {**current, **changes}, version)
|
||||
@@ -1,45 +1,34 @@
|
||||
"""M02 구조물 옵션 그룹 — 구조물 종류 안에 이름 붙은 묶음(PLAN 46-2).
|
||||
|
||||
정본 = `resources/master_template/구조물옵션그룹.json` =
|
||||
`{type_id: [{"id": "g1a2b3c", "이름": "유입구 기슭막이", "옵션": ["inlet_revet_form", …]}]}`
|
||||
— 없는 종류 = 그룹 없음(파일이 없어도 됨) · 빈 그룹(옵션 0)도 그룹.
|
||||
정본 = 구조물 정의 한 벌(`M02_SO_Definition` · PLAN 48-1)의 「그룹」 칸 =
|
||||
`{type_id: [{"id": "g1a2b3c", "이름": "유입구 기슭막이", "옵션": [...], "형상"?: {...}}]}`
|
||||
— 없는 종류 = 그룹 없음 · 빈 그룹(옵션 0)도 그룹 · 형상 = 길이 · 높이 · 전 · 후 받기 + 뜻.
|
||||
|
||||
그룹 하나 = 프로젝트 B05 · B06 폼의 보조 컨테이너 하나(제목 = 이름) — 그 안에서 구조물 조합
|
||||
항목 하나를 지정(구조물 기록 `group_items[그룹 id]`). 그룹에 안 넣은 옵션 = 본체 칸(`summary_item`).
|
||||
옵션 하나는 한 종류 안에서 그룹 하나에만. 시스템 공통 · 고쳐 씀은 시스템 관리자(길이 막음).
|
||||
판 = 파일 sha256 앞 16자(`store.version_of`) · 낡은 판으로 쓰면 409.
|
||||
판 = 정의 판(글) · 낡은 판으로 쓰면 409 · 저장마다 정의 판 +1 · 이력.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from B05_Profile.B05_Profile_Structures_Schema import load_structure_types
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from B05_Profile.B05_Profile_Structures_Schema import load_definition, load_structure_types
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
|
||||
FILE = "구조물옵션그룹.json"
|
||||
GROUP_ID = re.compile(r"^g[0-9a-z]{1,16}$")
|
||||
NAME_MAX = 40
|
||||
SHAPE = ("길이", "높이", "전", "후") # 그룹이 받을 형상 칸 — `{칸: {받기: 참거짓, 뜻: 글}}`
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _load() -> tuple[dict[str, Any], str]:
|
||||
"""(그룹 표 · 판) — 파일이 없으면 빈 표 · 판 빈 글."""
|
||||
path = store.FOLDER / FILE
|
||||
if not path.is_file():
|
||||
return {}, ""
|
||||
raw = path.read_bytes()
|
||||
try:
|
||||
doc = json.loads(raw)
|
||||
except ValueError as e:
|
||||
raise store.StoreError(500, f"{FILE} 을 못 읽음 — {e}") from e
|
||||
if not isinstance(doc, dict):
|
||||
raise store.StoreError(500, f"{FILE} 은 종류별 묶음이어야 함")
|
||||
return doc, store.version_of(raw)
|
||||
"""(그룹 표 · 판) — 정의 한 벌(`M02_SO_Definition`)의 「그룹」 칸 · 판 = 정의 판 글."""
|
||||
doc = load_definition()
|
||||
return doc.get("그룹") or {}, str(doc.get("판", 1))
|
||||
|
||||
|
||||
def groups_of() -> dict[str, list[dict[str, Any]]]:
|
||||
@@ -83,11 +72,23 @@ def listing() -> dict[str, Any]:
|
||||
return {"판": version, "그룹": doc, "종류": kinds}
|
||||
|
||||
|
||||
def _clean(groups: Any) -> dict[str, list[dict[str, Any]]]:
|
||||
"""받은 표 검사 — 없는 종류 · 키 · 겹친 id · 이름 · 옵션 두 그룹은 422 · 빈 종류는 뺌."""
|
||||
def _shape_ok(shape: Any) -> bool:
|
||||
return isinstance(shape, dict) and all(
|
||||
k in SHAPE
|
||||
and isinstance(v, dict)
|
||||
and isinstance(v.get("받기"), bool)
|
||||
and isinstance(v.get("뜻", ""), str)
|
||||
and set(v) <= {"받기", "뜻"}
|
||||
for k, v in shape.items()
|
||||
)
|
||||
|
||||
|
||||
def _clean(groups: Any, keys: dict[str, set[str]] | None = None) -> dict[str, list[dict[str, Any]]]:
|
||||
"""받은 표 검사 — 없는 종류 · 키 · 겹친 id · 이름 · 옵션 두 그룹 · 틀린 형상 422."""
|
||||
if not isinstance(groups, dict):
|
||||
raise store.StoreError(400, "그룹은 종류별 묶음이어야 함")
|
||||
keys = {t.type_id: {o.key for o in t.options} for t in load_structure_types()}
|
||||
if keys is None:
|
||||
keys = {t.type_id: {o.key for o in t.options} for t in load_structure_types()}
|
||||
errors: list[str] = []
|
||||
out: dict[str, list[dict[str, Any]]] = {}
|
||||
for type_id, rows in groups.items():
|
||||
@@ -126,9 +127,14 @@ def _clean(groups: Any) -> dict[str, list[dict[str, Any]]]:
|
||||
)
|
||||
else:
|
||||
owner[key] = name
|
||||
if "형상" in row and not _shape_ok(row["형상"]):
|
||||
errors.append(
|
||||
f"「{type_id}.{name}」 형상은 {{길이 · 높이 · 전 · 후: {{받기, 뜻}}}}"
|
||||
)
|
||||
ids.add(gid)
|
||||
names.add(name)
|
||||
clean.append({"id": gid, "이름": name, "옵션": [k for k in opts if k in keys[type_id]]})
|
||||
kept = {"id": gid, "이름": name, "옵션": [k for k in opts if k in keys[type_id]]}
|
||||
clean.append({**kept, "형상": row["형상"]} if "형상" in row else kept)
|
||||
if clean:
|
||||
out[type_id] = clean
|
||||
if errors:
|
||||
@@ -138,11 +144,7 @@ def _clean(groups: Any) -> dict[str, list[dict[str, Any]]]:
|
||||
|
||||
def save(groups: Any, version: str) -> dict[str, Any]:
|
||||
"""고쳐 씀 — 판이 같을 때만. 돌려주는 것 = 새 `listing()`."""
|
||||
fixed = _clean(groups)
|
||||
path = store.FOLDER / FILE
|
||||
with store._LOCK:
|
||||
have = store.version_of(path.read_bytes()) if path.is_file() else ""
|
||||
if have != (version or ""):
|
||||
raise store.StoreError(409, {"stale": [FILE], "판": have})
|
||||
atomic_write_json(path, fixed)
|
||||
from M02_MasterTemplete.StructOptions.M02_SO_Definition import save_sections
|
||||
|
||||
save_sections({"그룹": _clean(groups)}, version)
|
||||
return listing()
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
/* =============================================================================
|
||||
* M02_SO_Groups.ts
|
||||
* 「구조물 옵션 보이기」 화면의 **옵션 그룹** 부품(PLAN 46-2) — 고른 종류의 그룹 목록
|
||||
* (이름 · 위로 · 아래로 · 지우기 · [그룹 추가]) · 옵션 줄마다 「들어갈 자리」 고르기(본체 · 그룹).
|
||||
* 값은 화면이 쥔 고칠 사본(종류 → 그룹[])에만 — 저장은 화면 [저장](`M02_SO_Options.ts`).
|
||||
* 구조물 정의 화면의 **옵션 그룹** 부품(PLAN 46-2 · 48-2) — 고른 종류의 그룹 목록
|
||||
* (이름 · 위로 · 아래로 · 지우기 · 형상 칸 길이 · 높이 · 전 · 후 받기 켬끔과 뜻 · [그룹 추가]) ·
|
||||
* 옵션 줄마다 「들어갈 자리」 고르기(본체 · 그룹).
|
||||
* 값은 화면이 쥔 고칠 사본에만 — 저장은 화면 [저장](`M02_SO_Def.ts`).
|
||||
* 그룹 하나 = 프로젝트 B05 · B06 폼의 보조 컨테이너 하나 · 옵션이 없어도 됨(서버 `M02_SO_Groups.py`).
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el, showConfirmDialog } from "@ui/ui_template_elements";
|
||||
import type { OptionGroupDef } from "./M02_SO_Api";
|
||||
import { SHAPE_KEYS, type GroupDef } from "./M02_SO_Def_Model";
|
||||
import { checkBox, textBox } from "./M02_SO_Def_Ui";
|
||||
import { to } from "./M02_SO_Text";
|
||||
|
||||
const NAME_MAX = 40;
|
||||
|
||||
/** 새 그룹 id — 서버 규칙 `g[0-9a-z]{1,16}` · 같은 종류 안에서 안 겹치게 */
|
||||
export function newGroupId(list: readonly OptionGroupDef[]): string {
|
||||
export function newGroupId(list: readonly GroupDef[]): string {
|
||||
const taken = new Set(list.map((g) => g.id));
|
||||
for (;;) {
|
||||
const id = `g${Math.random().toString(36).slice(2, 8)}`;
|
||||
@@ -22,35 +24,49 @@ export function newGroupId(list: readonly OptionGroupDef[]): string {
|
||||
}
|
||||
|
||||
/** 그 옵션이 든 그룹 id — 없으면 null(본체) */
|
||||
export function groupOf(list: readonly OptionGroupDef[], key: string): string | null {
|
||||
export function groupOf(list: readonly GroupDef[], key: string): string | null {
|
||||
return list.find((g) => g.옵션.includes(key))?.id ?? null;
|
||||
}
|
||||
|
||||
/** 옵션을 그룹(null = 본체)으로 옮김 — 한 옵션은 한 그룹에만 */
|
||||
export function moveOption(list: OptionGroupDef[], key: string, gid: string | null): void {
|
||||
export function moveOption(list: GroupDef[], key: string, gid: string | null): void {
|
||||
for (const g of list) g.옵션 = g.옵션.filter((k) => k !== key);
|
||||
const target = gid ? list.find((g) => g.id === gid) : null;
|
||||
if (target) target.옵션.push(key);
|
||||
}
|
||||
|
||||
/** 이름이 다 괜찮은가 — 1~40자 · 같은 종류 안에서 안 겹침(서버도 같은 검사) */
|
||||
export function namesOk(groups: Map<string, OptionGroupDef[]>): boolean {
|
||||
for (const list of groups.values()) {
|
||||
const names = list.map((g) => g.이름.trim());
|
||||
if (names.some((n) => !n || n.length > NAME_MAX)) return false;
|
||||
if (new Set(names).size !== names.length) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export interface GroupBlockOptions {
|
||||
/** 고른 종류의 그룹(고칠 사본 — 이 부품이 직접 고침) */
|
||||
list: OptionGroupDef[];
|
||||
list: GroupDef[];
|
||||
edit: boolean;
|
||||
/** 고쳤음 — 화면이 고침 표시 · 다시 그리기 */
|
||||
changed: () => void;
|
||||
}
|
||||
|
||||
/** 그룹 형상 줄 — 칸마다 받기 체크 + 뜻(무엇을 정하는지 한 줄) */
|
||||
function shapeLine(group: GroupDef, edit: boolean, changed: () => void): HTMLElement {
|
||||
const shape = (group.형상 ??= {});
|
||||
return el("div", {
|
||||
className: "m02-sog__shape",
|
||||
attrs: { "data-sog-shape": group.id },
|
||||
children: SHAPE_KEYS.map((k) => {
|
||||
const f = shape[k] ?? { 받기: false, 뜻: "" };
|
||||
const on = checkBox(
|
||||
to(`Shape_${k}` as const),
|
||||
f.받기,
|
||||
(v) => ((shape[k] = { ...f, 받기: v }), changed()),
|
||||
{ edit, attr: `data-sog-shape-${SHAPE_KEYS.indexOf(k)}` },
|
||||
);
|
||||
const mean = textBox(f.뜻, (v) => (shape[k] = { ...(shape[k] ?? f), 뜻: v }), changed, {
|
||||
edit: edit && f.받기,
|
||||
cls: "m02-sog__mean",
|
||||
place: to("ShapeMean"),
|
||||
});
|
||||
return el("div", { className: "m02-sog__shapecell", children: [on, mean] });
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/** 그룹 목록 칸 — 제목 · 안내 · 그룹 줄 · [그룹 추가] */
|
||||
export function buildGroupBlock(opt: GroupBlockOptions): HTMLElement {
|
||||
const { list } = opt;
|
||||
@@ -87,7 +103,7 @@ export function buildGroupBlock(opt: GroupBlockOptions): HTMLElement {
|
||||
list.splice(list.indexOf(group), 1);
|
||||
opt.changed();
|
||||
});
|
||||
return el("div", {
|
||||
const line = el("div", {
|
||||
className: "m02-sog__row",
|
||||
attrs: { "data-sog-group": group.id },
|
||||
children: [
|
||||
@@ -99,6 +115,10 @@ export function buildGroupBlock(opt: GroupBlockOptions): HTMLElement {
|
||||
...(opt.edit ? [up, down, del] : []),
|
||||
],
|
||||
});
|
||||
return el("div", {
|
||||
className: "m02-sog__item",
|
||||
children: [line, shapeLine(group, opt.edit, opt.changed)],
|
||||
});
|
||||
});
|
||||
const add = createButton({ label: to("GroupAdd"), variant: "ghost", size: "sm" });
|
||||
add.setAttribute("data-sog-add", "");
|
||||
@@ -121,7 +141,7 @@ export function buildGroupBlock(opt: GroupBlockOptions): HTMLElement {
|
||||
|
||||
/** 옵션 줄의 「들어갈 자리」 고르기 — 본체 · 그룹 이름들 */
|
||||
export function buildPlaceSelect(
|
||||
list: OptionGroupDef[],
|
||||
list: GroupDef[],
|
||||
key: string,
|
||||
edit: boolean,
|
||||
changed: () => void,
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
"""M02 구조물 옵션 보이기 — 구조물 종류마다 B05/B06 폼에 보일 옵션을 사용자가 체크.
|
||||
|
||||
정본 = `resources/master_template/구조물옵션표시.json` = `{type_id: {option_key: false}}`
|
||||
— 없는 종류 · 키는 보임(파일이 없어도 모두 보임).
|
||||
정본 = 구조물 정의(`M02_SO_Definition` · PLAN 48-1) 「표시」 칸 = `{type_id: {option_key: false}}`
|
||||
— 없는 종류 · 키는 보임.
|
||||
저장은 **숨김(false)만** 남김 — 「모두 보임」 = `{}`.
|
||||
옵션 정의 · 옛 값은 그대로 — 폼이 그리는 것만 갈림(`GET /api/projects/structure-types` `visible`).
|
||||
판 = 파일 sha256 앞 16자(`store.version_of`) · 낡은 판으로 쓰면 409.
|
||||
판 = 정의 판(글) · 낡은 판으로 쓰면 409 · 저장마다 정의 판 +1 · 이력.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
@@ -19,14 +18,13 @@ from B05_Profile.B05_Profile_Structures_Schema import (
|
||||
StructureOptionField,
|
||||
StructureType,
|
||||
StructureTypesResponse,
|
||||
load_definition,
|
||||
load_structure_types,
|
||||
registry_schema_version,
|
||||
)
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete.StructOptions.M02_SO_Groups import groups_of
|
||||
|
||||
FILE = "구조물옵션표시.json"
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -56,18 +54,9 @@ class TypesWithVisible(StructureTypesResponse):
|
||||
|
||||
|
||||
def _load() -> tuple[dict[str, Any], str]:
|
||||
"""(숨김 표 · 판) — 파일이 없으면 빈 표 · 판 빈 글."""
|
||||
path = store.FOLDER / FILE
|
||||
if not path.is_file():
|
||||
return {}, ""
|
||||
raw = path.read_bytes()
|
||||
try:
|
||||
doc = json.loads(raw)
|
||||
except ValueError as e:
|
||||
raise store.StoreError(500, f"{FILE} 을 못 읽음 — {e}") from e
|
||||
if not isinstance(doc, dict):
|
||||
raise store.StoreError(500, f"{FILE} 은 종류별 묶음이어야 함")
|
||||
return doc, store.version_of(raw)
|
||||
"""(표시 표 · 판) — 정의 한 벌(`M02_SO_Definition`)의 「표시」 칸 · 판 = 정의 판 글."""
|
||||
doc = load_definition()
|
||||
return doc.get("표시") or {}, str(doc.get("판", 1))
|
||||
|
||||
|
||||
def _shown(doc: dict[str, Any], type_id: str, key: str) -> bool:
|
||||
@@ -138,11 +127,12 @@ def listing() -> dict[str, Any]:
|
||||
return {"판": version, "표시": hidden, "종류": kinds}
|
||||
|
||||
|
||||
def _clean(shown: Any) -> dict[str, dict[str, bool]]:
|
||||
def _clean(shown: Any, keys: dict[str, set[str]] | None = None) -> dict[str, dict[str, bool]]:
|
||||
"""받은 표 검사 → 숨김만 남김. 없는 종류 · 키 · 참거짓 아닌 값은 422."""
|
||||
if not isinstance(shown, dict):
|
||||
raise store.StoreError(400, "표시는 종류별 묶음이어야 함")
|
||||
keys = {t.type_id: {o.key for o in t.options} for t in load_structure_types()}
|
||||
if keys is None:
|
||||
keys = {t.type_id: {o.key for o in t.options} for t in load_structure_types()}
|
||||
errors: list[str] = []
|
||||
out: dict[str, dict[str, bool]] = {}
|
||||
for type_id, row in shown.items():
|
||||
@@ -165,11 +155,7 @@ def _clean(shown: Any) -> dict[str, dict[str, bool]]:
|
||||
|
||||
def save(shown: Any, version: str) -> dict[str, Any]:
|
||||
"""고쳐 씀 — 판이 같을 때만. 돌려주는 것 = 새 `listing()`."""
|
||||
fixed = _clean(shown)
|
||||
path = store.FOLDER / FILE
|
||||
with store._LOCK:
|
||||
have = store.version_of(path.read_bytes()) if path.is_file() else ""
|
||||
if have != (version or ""):
|
||||
raise store.StoreError(409, {"stale": [FILE], "판": have})
|
||||
atomic_write_json(path, fixed)
|
||||
from M02_MasterTemplete.StructOptions.M02_SO_Definition import save_sections
|
||||
|
||||
save_sections({"표시": _clean(shown)}, version)
|
||||
return listing()
|
||||
|
||||
@@ -1,25 +1,12 @@
|
||||
/* =============================================================================
|
||||
* M02_SO_Options.ts
|
||||
* 좌측 「구조물 옵션 보이기」 줄(구조물 집계 양식 아래 별도 · 시스템 관리자만) — 누르면 메인 칸에
|
||||
* 종류 목록(군별) → 고른 종류의 **그룹**(PLAN 46-2 · `M02_SO_Groups.ts`) + 옵션 줄
|
||||
* (보이기 체크 · 라벨 · 키 작은 글 · B06 전용 · 필수 표시 · 들어갈 자리) · [저장] · [모두 보임].
|
||||
* 체크 안 한 옵션은 B05 · B06 구조물 폼에서 숨음(정의 · 값은 그대로).
|
||||
* 서버 = `M02_SO_Options.py`(`구조물옵션표시.json` · 숨긴 것만) · `M02_SO_Groups.py`(`구조물옵션그룹.json`).
|
||||
* 좌측 「구조물 옵션 정의」 줄(구조물 집계 양식 아래 별도 · 시스템 층) — 누르면 메인 칸에
|
||||
* 정의 편집 화면(PLAN 48-2 · `M02_SO_Def.ts`) · 고쳐 씀은 시스템 관리자만 · 다른 역할은 읽기만.
|
||||
* 옛 「보이기 · 그룹」 화면(46장)은 정의 화면의 옵션 줄 「보이기」 · 그룹 칸으로 들어감.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, createTag, el, showToast } from "@ui/ui_template_elements";
|
||||
import { StaleError } from "../M02_MasterTemplete_Api_Fetch";
|
||||
import {
|
||||
fetchStructureOptionGroups,
|
||||
fetchStructureOptions,
|
||||
saveStructureOptionGroups,
|
||||
saveStructureOptions,
|
||||
type GroupsDoc,
|
||||
type OptionGroupDef,
|
||||
type OptionKind,
|
||||
type OptionsDoc,
|
||||
} from "./M02_SO_Api";
|
||||
import { buildGroupBlock, buildPlaceSelect, namesOk } from "./M02_SO_Groups";
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { openDefScreen } from "./M02_SO_Def";
|
||||
import "./M02_SO_Style.css";
|
||||
import { to } from "./M02_SO_Text";
|
||||
|
||||
@@ -40,8 +27,6 @@ export interface OptionsSection {
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
const why = (error: unknown): string => (error instanceof Error ? error.message : "");
|
||||
|
||||
export function buildOptionsSection(opt: OptionsSectionOptions): OptionsSection {
|
||||
const row = el("button", {
|
||||
className: "m02-side__item ui-sidebar-row",
|
||||
@@ -49,7 +34,6 @@ export function buildOptionsSection(opt: OptionsSectionOptions): OptionsSection
|
||||
attrs: { type: "button", "data-options-row": "" },
|
||||
});
|
||||
const body = el("div", { className: "m02-side__items ui-sidebar-list", children: [row] });
|
||||
row.addEventListener("click", () => void open());
|
||||
|
||||
/** 이 화면이 열려 있는 동안 참 — 다른 것이 열리면 `clear` 가 내림 */
|
||||
let opened = false;
|
||||
@@ -57,241 +41,12 @@ export function buildOptionsSection(opt: OptionsSectionOptions): OptionsSection
|
||||
opened = on;
|
||||
row.classList.toggle("is-active", on);
|
||||
};
|
||||
|
||||
async function open(): Promise<void> {
|
||||
row.addEventListener("click", async () => {
|
||||
if (opened || !(await opt.confirmLeave())) return;
|
||||
let doc: OptionsDoc;
|
||||
let groups: GroupsDoc;
|
||||
try {
|
||||
[doc, groups] = await Promise.all([fetchStructureOptions(), fetchStructureOptionGroups()]);
|
||||
} catch (error) {
|
||||
return showToast(to("Load_Failed", why(error)), "error");
|
||||
}
|
||||
draw(doc, groups);
|
||||
}
|
||||
|
||||
/** 메인 칸 화면 — `doc.표시` · `groups.그룹` 을 고칠 사본(저장 전까지 서버에 안 감)으로 쥠 */
|
||||
function draw(doc: OptionsDoc, groupsDoc: GroupsDoc): void {
|
||||
let version = doc.판;
|
||||
let groupVersion = groupsDoc.판;
|
||||
const edit = opt.isAdmin;
|
||||
/** 숨긴 옵션 — 종류 → 키 집합 */
|
||||
const hidden = new Map<string, Set<string>>();
|
||||
const load = (src: OptionsDoc["표시"]): void => {
|
||||
hidden.clear();
|
||||
for (const [id, keys] of Object.entries(src)) hidden.set(id, new Set(Object.keys(keys)));
|
||||
};
|
||||
/** 그룹 — 종류 → 그룹[](고칠 사본) */
|
||||
const groups = new Map<string, OptionGroupDef[]>();
|
||||
const loadGroups = (src: GroupsDoc["그룹"]): void => {
|
||||
groups.clear();
|
||||
for (const [id, list] of Object.entries(src))
|
||||
groups.set(
|
||||
id,
|
||||
list.map((g) => ({ ...g, 옵션: [...g.옵션] })),
|
||||
);
|
||||
};
|
||||
load(doc.표시);
|
||||
loadGroups(groupsDoc.그룹);
|
||||
/** 종류 = 레지스트리 전부(옵션 없는 종류도 그룹을 둘 수 있음) */
|
||||
let kinds = groupsDoc.종류;
|
||||
let current: OptionKind | null = null;
|
||||
let shownDirty = false;
|
||||
let groupDirty = false;
|
||||
|
||||
const kindList = el("div", { className: "m02-opts__kinds" });
|
||||
const optionList = el("div", { className: "m02-opts__list" });
|
||||
const off = (id: string): number => hidden.get(id)?.size ?? 0;
|
||||
const groupList = (id: string): OptionGroupDef[] => {
|
||||
if (!groups.has(id)) groups.set(id, []);
|
||||
return groups.get(id)!;
|
||||
};
|
||||
|
||||
const paintKinds = (): void => {
|
||||
const parts: HTMLElement[] = [];
|
||||
let group = "";
|
||||
for (const k of kinds) {
|
||||
if (k.group !== group) {
|
||||
group = k.group;
|
||||
parts.push(
|
||||
el("h4", {
|
||||
className: "m02-opts__group",
|
||||
text: group.length === 1 ? to("Group", group) : group,
|
||||
}),
|
||||
);
|
||||
}
|
||||
const n = off(k.type_id);
|
||||
const g = groups.get(k.type_id)?.length ?? 0;
|
||||
const b = el("button", {
|
||||
className: `m02-opts__kind${k.type_id === current?.type_id ? " is-active" : ""}`,
|
||||
attrs: { type: "button", "data-options-kind": k.type_id },
|
||||
children: [
|
||||
el("span", { text: k.name }),
|
||||
...(g ? [createTag(to("GroupTag", String(g)), "accent")] : []),
|
||||
...(n ? [createTag(to("Hidden", String(n)), "warning")] : []),
|
||||
...(k.enabled ? [] : [createTag(to("Off"), "neutral")]),
|
||||
],
|
||||
});
|
||||
b.addEventListener("click", () => {
|
||||
current = k;
|
||||
paintKinds();
|
||||
paintOptions();
|
||||
});
|
||||
parts.push(b);
|
||||
}
|
||||
kindList.replaceChildren(...parts);
|
||||
};
|
||||
|
||||
/** 그룹을 고쳤음 — 고침 표시 · 종류 목록(그룹 수) · 오른쪽 다시 그림 */
|
||||
const groupsChanged = (): void => {
|
||||
groupDirty = true;
|
||||
sync();
|
||||
paintKinds();
|
||||
paintOptions();
|
||||
};
|
||||
|
||||
const paintOptions = (): void => {
|
||||
if (!current) {
|
||||
optionList.replaceChildren(el("p", { className: "m02-side__empty", text: to("Pick") }));
|
||||
return;
|
||||
}
|
||||
const kind = current;
|
||||
const list = groupList(kind.type_id);
|
||||
const rows = kind.options.map((o) => {
|
||||
const box = el("input", {
|
||||
attrs: { type: "checkbox", "data-options-key": o.key },
|
||||
}) as HTMLInputElement;
|
||||
box.checked = !hidden.get(kind.type_id)?.has(o.key);
|
||||
box.disabled = !edit;
|
||||
box.addEventListener("change", () => {
|
||||
const set = hidden.get(kind.type_id) ?? new Set<string>();
|
||||
if (box.checked) set.delete(o.key);
|
||||
else set.add(o.key);
|
||||
hidden.set(kind.type_id, set);
|
||||
shownDirty = true;
|
||||
sync();
|
||||
paintKinds();
|
||||
});
|
||||
const tags: HTMLElement[] = [];
|
||||
if (o.phase === "detail") tags.push(createTag(to("Detail"), "accent"));
|
||||
if (o.required && o.phase === "b05") {
|
||||
const tag = createTag(to("Required"), "danger");
|
||||
tag.title = to("RequiredTip");
|
||||
tags.push(tag);
|
||||
}
|
||||
return el("div", {
|
||||
className: "m02-opts__option",
|
||||
children: [
|
||||
el("label", {
|
||||
className: "m02-opts__check",
|
||||
children: [
|
||||
box,
|
||||
el("span", { text: o.label }),
|
||||
el("small", { className: "m02-opts__key", text: o.key }),
|
||||
...tags,
|
||||
],
|
||||
}),
|
||||
buildPlaceSelect(list, o.key, edit, groupsChanged),
|
||||
],
|
||||
});
|
||||
});
|
||||
optionList.replaceChildren(
|
||||
el("h3", { className: "m02-options__head", text: kind.name }),
|
||||
buildGroupBlock({ list, edit, changed: groupsChanged }),
|
||||
el("h4", { className: "m02-opts__group", text: to("OptionHead") }),
|
||||
...(rows.length
|
||||
? rows
|
||||
: [el("p", { className: "m02-side__empty", text: to("NoOptionKind") })]),
|
||||
);
|
||||
};
|
||||
|
||||
const save = createButton({ label: to("Save"), variant: "filled" });
|
||||
const all = createButton({ label: to("ShowAll"), variant: "ghost" });
|
||||
all.addEventListener("click", () => {
|
||||
if (![...hidden.values()].some((s) => s.size)) return;
|
||||
hidden.clear();
|
||||
shownDirty = true;
|
||||
sync();
|
||||
paintKinds();
|
||||
paintOptions();
|
||||
});
|
||||
|
||||
/** 다시 받기 — 낡은 판으로 막혔을 때 둘 다 새로 */
|
||||
const reload = async (): Promise<void> => {
|
||||
try {
|
||||
const [fresh, freshGroups] = await Promise.all([
|
||||
fetchStructureOptions(),
|
||||
fetchStructureOptionGroups(),
|
||||
]);
|
||||
version = fresh.판;
|
||||
groupVersion = freshGroups.판;
|
||||
kinds = freshGroups.종류;
|
||||
load(fresh.표시);
|
||||
loadGroups(freshGroups.그룹);
|
||||
shownDirty = groupDirty = false;
|
||||
sync();
|
||||
current = kinds.find((k) => k.type_id === current?.type_id) ?? null;
|
||||
paintKinds();
|
||||
paintOptions();
|
||||
} catch (again) {
|
||||
showToast(to("Load_Failed", why(again)), "error");
|
||||
}
|
||||
};
|
||||
|
||||
save.addEventListener("click", async () => {
|
||||
if (groupDirty && !namesOk(groups)) return showToast(to("NameBad"), "error");
|
||||
save.disabled = true; // 연달아 눌러도 한 번만
|
||||
try {
|
||||
if (shownDirty) {
|
||||
const 표시: OptionsDoc["표시"] = {};
|
||||
for (const [id, keys] of hidden)
|
||||
if (keys.size) 표시[id] = Object.fromEntries([...keys].map((k) => [k, false as const]));
|
||||
const done = await saveStructureOptions(version, 표시);
|
||||
version = done.판;
|
||||
load(done.표시);
|
||||
shownDirty = false;
|
||||
}
|
||||
if (groupDirty) {
|
||||
const 그룹: GroupsDoc["그룹"] = {};
|
||||
for (const [id, list] of groups)
|
||||
if (list.length) 그룹[id] = list.map((g) => ({ ...g, 이름: g.이름.trim() }));
|
||||
const done = await saveStructureOptionGroups(groupVersion, 그룹);
|
||||
groupVersion = done.판;
|
||||
loadGroups(done.그룹);
|
||||
groupDirty = false;
|
||||
}
|
||||
sync();
|
||||
showToast(to("Saved"), "success");
|
||||
paintKinds();
|
||||
paintOptions();
|
||||
} catch (error) {
|
||||
if (error instanceof StaleError) {
|
||||
showToast(to("Stale"), "error");
|
||||
await reload();
|
||||
} else showToast(to("Failed", why(error)), "error");
|
||||
} finally {
|
||||
save.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
const node = el("div", {
|
||||
className: "m02-opts",
|
||||
children: [
|
||||
el("p", { className: "m02-side__empty", text: to("Note") }),
|
||||
el("div", {
|
||||
className: "m02-opts__panes",
|
||||
children: [kindList, optionList],
|
||||
}),
|
||||
...(edit ? [el("div", { className: "m02-side__row", children: [save, all] })] : []),
|
||||
],
|
||||
});
|
||||
const setDirty = opt.show(to("Screen"), node);
|
||||
const sync = (): void => setDirty(shownDirty || groupDirty);
|
||||
paintKinds();
|
||||
paintOptions();
|
||||
if (!(await openDefScreen({ isAdmin: opt.isAdmin, show: opt.show }))) return;
|
||||
opt.onOpen();
|
||||
mark(true);
|
||||
}
|
||||
});
|
||||
|
||||
return { title: to("Title"), body, clear: () => mark(false) };
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""M02 구조물 옵션 보이기 · 그룹 길 — 정본 `M02_SO_Options.py` · `M02_SO_Groups.py`.
|
||||
"""M02 구조물 옵션 보이기 · 그룹 · 정의 길 — 정본 `M02_SO_Definition.py`(PLAN 48-1) 한 벌.
|
||||
|
||||
`router` = 고쳐 씀 · 관리 읽기(`main.py` 가 `system_admin_only` 로 붙임) ·
|
||||
`read_router` = 읽기만(로그인 누구나 · `main.py` 가 `protected` 로 붙임).
|
||||
`read_router` = 읽기만(로그인 누구나 · `main.py` 가 `protected` 로 붙임) · 정의 쓰기는 여기서
|
||||
시스템 관리자만(`access.require(write=True)` · 아니면 403).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -14,6 +15,7 @@ from pydantic import BaseModel
|
||||
from common_util.common_util_auth import verify_session
|
||||
from M02_MasterTemplete import M02_Layer_Access as access
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete.StructOptions import M02_SO_Definition as definition
|
||||
from M02_MasterTemplete.StructOptions import M02_SO_Groups as groups
|
||||
from M02_MasterTemplete.StructOptions import M02_SO_Options as options
|
||||
|
||||
@@ -31,6 +33,11 @@ class GroupsBody(BaseModel):
|
||||
그룹: dict[str, Any] # {종류: [{id, 이름, 옵션[]}]}
|
||||
|
||||
|
||||
class DefinitionBody(BaseModel):
|
||||
판: int # 읽어 간 정의 판 — 어긋나면 409
|
||||
정의: dict[str, Any] # 정의 한 벌(틀 = docs/raw/M02_구조물옵션정의/정의_틀.md)
|
||||
|
||||
|
||||
def _call(fn, *args):
|
||||
with store.at(store.FOLDER):
|
||||
try:
|
||||
@@ -68,3 +75,31 @@ def get_structure_options_read(session=Depends(verify_session)) -> dict:
|
||||
"""구조물 옵션 보이기 읽기 — 로그인 누구나(마스터 한 벌 · 고쳐 씀은 시스템 관리자만)."""
|
||||
access.require(session, "system", None)
|
||||
return _call(options.listing)
|
||||
|
||||
|
||||
@read_router.get("/structure-definition")
|
||||
def get_structure_definition(session=Depends(verify_session)) -> dict:
|
||||
"""구조물 정의 한 벌(판 · 구조물군 · 동작 · types · 종류 · 표시 · 그룹 · 배치 · 규칙)."""
|
||||
access.require(session, "system", None)
|
||||
return _call(definition.read)
|
||||
|
||||
|
||||
@read_router.get("/structure-definition/history")
|
||||
def get_structure_definition_history(session=Depends(verify_session)) -> list[dict]:
|
||||
"""지난 판 목록 `[{판, 저장일}]`(새 판부터) — 로그인 누구나(PLAN 48-2)."""
|
||||
access.require(session, "system", None)
|
||||
return _call(definition.history)
|
||||
|
||||
|
||||
@read_router.get("/structure-definition/history/{version}")
|
||||
def get_structure_definition_at(version: int, session=Depends(verify_session)) -> dict:
|
||||
"""지난 판 한 벌(읽기만) — 없으면 404."""
|
||||
access.require(session, "system", None)
|
||||
return _call(definition.read_version, version)
|
||||
|
||||
|
||||
@read_router.put("/structure-definition")
|
||||
def put_structure_definition(body: DefinitionBody, session=Depends(verify_session)) -> dict:
|
||||
"""정의 고쳐 씀 — 시스템 관리자만(403) · 낡은 판 409 · 검사 422 · 판 +1 · 앞 판은 이력."""
|
||||
access.require(session, "system", None, write=True)
|
||||
return _call(definition.save, body.정의, body.판)
|
||||
|
||||
@@ -112,3 +112,223 @@
|
||||
color: var(--color-text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* 구조물 정의 편집 화면(PLAN 48-2) — 판 띠 · 종류 머리 · 옵션 · 선택지 · 배치 격자 · 규칙 · 지난 판 */
|
||||
.m02-sod {
|
||||
max-width: 80rem;
|
||||
}
|
||||
|
||||
.m02-sod__banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.m02-sod__banner.is-past {
|
||||
padding: var(--spacing-4) var(--spacing-8);
|
||||
border: 1px solid var(--color-accent);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-mist-violet);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.m02-sod__family,
|
||||
.m02-sod__kindrow,
|
||||
.m02-sod__kindhead,
|
||||
.m02-sod__option,
|
||||
.m02-sod__choice,
|
||||
.m02-sod__place,
|
||||
.m02-sod__rule {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.m02-sod__family {
|
||||
margin-top: var(--spacing-8);
|
||||
}
|
||||
|
||||
.m02-sod__family > .ui-input {
|
||||
flex: 1 1 8rem;
|
||||
min-width: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.m02-sod__kindrow {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.m02-sod__kindrow > .m02-opts__kind {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m02-sod__kindrow > button:not(.m02-opts__kind),
|
||||
.m02-sod__family > button {
|
||||
flex: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.m02-sod__block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
padding-top: var(--spacing-8);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.m02-sod__option {
|
||||
padding: var(--spacing-4) 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.is-off > .ui-input,
|
||||
.m02-opts__kind.is-off > span:first-child {
|
||||
color: var(--color-text-muted);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.m02-sod__name {
|
||||
flex: 1 1 10rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m02-sod__form,
|
||||
.m02-sod__default {
|
||||
width: 8rem;
|
||||
}
|
||||
|
||||
.m02-sod__unit,
|
||||
.m02-sod__num {
|
||||
width: 4.5rem;
|
||||
}
|
||||
|
||||
.m02-sod__check {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.m02-sod__choices {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
margin: 0 0 var(--spacing-8) var(--spacing-24);
|
||||
padding-left: var(--spacing-8);
|
||||
border-left: 2px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* 배치 격자 — 왼쪽 칸 숫자 · 오른쪽 미리보기 */
|
||||
.m02-sod__layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(12rem, 22rem);
|
||||
gap: var(--spacing-16);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.m02-sod__place {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.m02-sod__placename {
|
||||
flex: 1 1 6rem;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.m02-sod__place > .m02-sod__num {
|
||||
flex: none;
|
||||
width: 4rem;
|
||||
}
|
||||
|
||||
.m02-sod__grid {
|
||||
display: grid;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-8);
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.m02-sod__cell {
|
||||
overflow: hidden;
|
||||
padding: var(--spacing-4);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-raised);
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.m02-sod__cell.is-base {
|
||||
background: var(--color-mist-violet);
|
||||
}
|
||||
|
||||
.m02-sod__cell.is-bad,
|
||||
.m02-sod__rule.is-bad {
|
||||
border-color: var(--color-danger);
|
||||
}
|
||||
|
||||
.m02-sod__rule {
|
||||
padding: var(--spacing-4);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.m02-sod__rulepick {
|
||||
width: 9rem;
|
||||
}
|
||||
|
||||
.m02-sod__rulechoices {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
/* 그룹 형상 칸 — 길이 · 높이 · 전 · 후 받기 + 뜻 */
|
||||
.m02-sog__item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
padding-bottom: var(--spacing-4);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.m02-sog__shape {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr));
|
||||
gap: var(--spacing-4) var(--spacing-12);
|
||||
}
|
||||
|
||||
.m02-sog__shapecell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.m02-sog__mean {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m02-sod__issues:empty,
|
||||
.m02-sod__history:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.m02-sod__history {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.m02-sod__bad {
|
||||
margin: 0;
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
@@ -6,13 +6,12 @@
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
|
||||
const TEXT = {
|
||||
Title: ["구조물 옵션 보이기", "Structure options shown"],
|
||||
Row: ["종류마다 그룹 · 보일 옵션 정하기", "Groups and shown options per type"],
|
||||
Screen: ["구조물 옵션 보이기 · 그룹", "Structure options · groups"],
|
||||
Note: [
|
||||
"체크한 옵션만 B05 · B06 구조물 폼에 보임 — 옵션 정의 · 이미 넣은 값은 그대로 · 처음은 모두 보임",
|
||||
"Only checked options appear in the B05 · B06 forms — definitions and stored values stay",
|
||||
Title: ["구조물 옵션 정의", "Structure option definition"],
|
||||
Row: [
|
||||
"구조물군 · 종류 · 옵션 · 배치 · 그룹 · 규칙",
|
||||
"Families · types · options · layout · groups · rules",
|
||||
],
|
||||
Screen: ["구조물 옵션 정의", "Structure option definition"],
|
||||
GroupHead: ["그룹", "Groups"],
|
||||
GroupNote: [
|
||||
"그룹 하나 = 프로젝트 B05 · B06 폼의 보조 컨테이너 하나(제목 = 그룹 이름) · 그 안에서 구조물 조합 항목을 따로 지정 · 옵션이 없어도 연결됨 · 그룹에 안 넣은 옵션은 본체",
|
||||
@@ -35,35 +34,92 @@ const TEXT = {
|
||||
],
|
||||
GroupTag: ["그룹 {value}", "{value} groups"],
|
||||
Main: ["본체", "Main body"],
|
||||
OptionHead: ["옵션 — 보이기 · 들어갈 자리", "Options — shown · container"],
|
||||
OptionHead: ["옵션", "Options"],
|
||||
OptionNote: [
|
||||
"이름 · 꼴 · 단위 · 기본값 · 들어갈 자리(본체 · 그룹) · 안 씀 · 순서 — 키(작은 글)는 안 바뀜 · 고르기 꼴은 [선택지]로 목록을 고침",
|
||||
"Name · form · unit · default · container · unused · order — keys never change",
|
||||
],
|
||||
OptionAdd: ["옵션 더함", "Add option"],
|
||||
OptionNew: ["새 옵션 {value}", "New option {value}"],
|
||||
FormSelect: ["고르기", "Select"],
|
||||
FormNumber: ["숫자", "Number"],
|
||||
FormText: ["글", "Text"],
|
||||
FormBool: ["예/아니오", "Yes/No"],
|
||||
Yes: ["예", "Yes"],
|
||||
No: ["아니오", "No"],
|
||||
Unit: ["단위", "Unit"],
|
||||
NoDefault: ["기본값 없음", "No default"],
|
||||
Unused: ["안 씀", "Unused"],
|
||||
Choices: ["선택지 {value}", "{value} choices"],
|
||||
ChoiceAdd: ["선택지 더함", "Add choice"],
|
||||
ChoiceNew: ["새 선택지 {value}", "New choice {value}"],
|
||||
ChoiceNone: ["선택지 없음 — 고르기 꼴은 하나 이상", "No choices — select needs at least one"],
|
||||
ChoiceNote: [
|
||||
"선택지 글이 곧 저장 값 — 이름을 바꾸면 기본값 · 규칙도 같이 바뀜 · 이미 저장한 프로젝트 값은 「지난 정의 값」으로 남음",
|
||||
"Choice text is the stored value — renaming updates defaults and rules here",
|
||||
],
|
||||
ChoiceDrop: ["빼기", "Remove"],
|
||||
ChoiceDropAsk: [
|
||||
"선택지 「{value}」 를 뺌 — 기본값 · 규칙에서도 빠짐 · 이미 저장한 프로젝트 값은 「지난 정의 값」으로 남음",
|
||||
"Remove choice “{value}” — also from defaults and rules",
|
||||
],
|
||||
Shown: ["보이기", "Shown"],
|
||||
RuleValues: ["값(쉼표로 여럿)", "Values (comma separated)"],
|
||||
RuleChoices: ["선택지", "Choices"],
|
||||
LayoutHead: ["배치 격자", "Layout grid"],
|
||||
LayoutNote: [
|
||||
"B05 · B06 좌측 구조물 칸(두 칸)의 배치 — 옵션마다 줄 · 칸 · 너비 · 기준 측점은 맨 위 한 줄 · 겹치면 빨강 · 안 정한 옵션은 차례대로",
|
||||
"Left panel layout (two columns) — row · column · width per option · overlaps in red",
|
||||
],
|
||||
GridRow: ["줄", "Row"],
|
||||
GridCol: ["칸", "Col"],
|
||||
GridWidth: ["너비", "Width"],
|
||||
BaseStation: ["기준 측점", "Base station"],
|
||||
BaseStationShow: ["기준 측점 표시", "Show base station"],
|
||||
Shape_길이: ["길이", "Length"],
|
||||
Shape_높이: ["높이", "Height"],
|
||||
Shape_전: ["기준 측점 전", "Before"],
|
||||
Shape_후: ["기준 측점 후", "After"],
|
||||
ShapeMean: ["무엇을 정하는지", "What it controls"],
|
||||
RuleHead: ["규칙", "Rules"],
|
||||
RuleNote: [
|
||||
"만약 상위 옵션 = 값 → 하위 옵션의 선택지 · 보이기 · 기본값 — 「그대로」 칸은 손대지 않음 · 틀린 줄은 빨강",
|
||||
"If parent = value → child choices · visibility · default",
|
||||
],
|
||||
RuleNone: ["규칙 없음", "No rules"],
|
||||
RuleAdd: ["규칙 더함", "Add rule"],
|
||||
RuleIf: ["만약", "If"],
|
||||
RuleThen: ["→", "→"],
|
||||
Keep: ["보이기 그대로", "Keep visibility"],
|
||||
Show: ["보임", "Show"],
|
||||
Hide: ["숨김", "Hide"],
|
||||
KeepDefault: ["기본값 그대로", "Keep default"],
|
||||
DefNote: [
|
||||
"구조물군 · 종류 · 옵션 · 선택지 · 배치 · 그룹 · 규칙 정의 — [저장]하면 판이 하나 오르고 B05 · B06 에 바로 적용 · 지난 판은 이력으로 남음",
|
||||
"Structure definition — Save bumps the version; past versions stay in history",
|
||||
],
|
||||
Version: ["판 {value}", "Version {value}"],
|
||||
ReadOnly: ["읽기만", "Read only"],
|
||||
SavedVersion: ["저장함 — 판 {value}", "Saved — version {value}"],
|
||||
PastList: ["지난 판", "Past versions"],
|
||||
PastItem: ["판 {value}", "v{value}"],
|
||||
PastNone: ["지난 판 없음", "No past versions"],
|
||||
PastView: ["판 {value} 보는 중 — 읽기만", "Viewing version {value} — read only"],
|
||||
PastBack: ["지금 판으로", "Back to current"],
|
||||
NoOptionKind: [
|
||||
"이 종류는 옵션이 없음 — 그룹만 만들 수 있음(그룹마다 조합 연결)",
|
||||
"No options — groups only (each links a combo)",
|
||||
],
|
||||
NameBad: [
|
||||
"그룹 이름은 1~40자 · 같은 종류 안에서 겹치면 안 됨",
|
||||
"Group names: 1–40 chars, unique",
|
||||
],
|
||||
Group: ["{value}군", "Group {value}"],
|
||||
Hidden: ["숨김 {value}", "{value} hidden"],
|
||||
Off: ["안 쓰는 종류", "Not offered"],
|
||||
NoOption: ["옵션 없음", "No options"],
|
||||
Pick: ["왼쪽에서 구조물 종류를 고름", "Pick a structure type on the left"],
|
||||
Detail: ["B06 전용", "B06 only"],
|
||||
Required: ["필수", "Required"],
|
||||
RequiredTip: [
|
||||
"필수 옵션 — 숨기면 폼에 안 나오고 필수로도 안 침(저장이 안 막힘)",
|
||||
"Required — when hidden it is not shown and not enforced",
|
||||
],
|
||||
Save: ["저장", "Save"],
|
||||
ShowAll: ["모두 보임", "Show all"],
|
||||
Saved: ["저장함", "Saved"],
|
||||
Stale: [
|
||||
"그 사이 옵션 표시가 바뀜 — 새로 받았으니 다시 고쳐 주세요",
|
||||
"그 사이 정의가 바뀜 — 새로 받았으니 다시 고쳐 주세요",
|
||||
"Changed meanwhile — reloaded, please edit again",
|
||||
],
|
||||
Load_Failed: ["옵션 표시를 못 받음 — {value}", "Could not load option display — {value}"],
|
||||
Failed: ["옵션 표시를 못 저장함 — {value}", "Could not save option display — {value}"],
|
||||
Load_Failed: ["구조물 정의를 못 받음 — {value}", "Could not load the definition — {value}"],
|
||||
Failed: ["구조물 정의를 못 저장함 — {value}", "Could not save the definition — {value}"],
|
||||
} as const satisfies Record<string, readonly [string, string]>;
|
||||
|
||||
export type OptionsTextKey = keyof typeof TEXT;
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
{}
|
||||
@@ -1 +0,0 @@
|
||||
{}
|
||||
+730
-130
File diff suppressed because it is too large
Load Diff
@@ -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,112 @@
|
||||
/* M02 구조물 정의 편집 화면 자료 손질(`M02_SO_Def_Model.ts` · PLAN 48-2) 단위검증 헬퍼 —
|
||||
* TS 를 그 자리에서 트랜스파일해 Node 로 돌리고 결과를 JSON 으로 냄(pytest 가 판정). */
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const source = fs.readFileSync(
|
||||
path.join(__dirname, "..", "..", "M02_MasterTemplete", "StructOptions", "M02_SO_Def_Model.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const js = ts.transpileModule(source, {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
new Function("exports", "module", "require", js)(box.exports, box, require);
|
||||
const m = box.exports;
|
||||
|
||||
const sel = (key, choices) => ({ key, label: key, input: "select", choices: [...choices] });
|
||||
const type = () => ({
|
||||
type_id: "k1",
|
||||
group: "A",
|
||||
name: "배수관",
|
||||
options: [sel("a", ["x", "y"]), sel("b", ["p", "q"]), { key: "c", label: "c", input: "number" }],
|
||||
});
|
||||
const defOf = (over = {}) => ({
|
||||
판: 3,
|
||||
구조물군: { A: "A군", B: "B군" },
|
||||
동작: {},
|
||||
types: [
|
||||
type(),
|
||||
{ ...type(), type_id: "k2", name: "둘째" },
|
||||
{ ...type(), type_id: "k3", group: "B", name: "셋째" },
|
||||
],
|
||||
종류: {},
|
||||
표시: {},
|
||||
그룹: {},
|
||||
배치: {},
|
||||
규칙: [],
|
||||
...over,
|
||||
});
|
||||
const cells = (t, spots) => Object.fromEntries(m.placeAll(t.options, spots));
|
||||
|
||||
const out = {};
|
||||
// 자리 안 정한 옵션 = 차례대로 채움 · 두 칸이면 셋째는 둘째 줄
|
||||
out.auto = cells(type());
|
||||
// 정한 자리 먼저 · 남은 것은 빈 칸으로 · 너비는 칸 수를 안 넘음
|
||||
out.fixed = cells(type(), { c: { 줄: 1, 칸: 1, 너비: 5 } });
|
||||
// 안 씀은 격자에서 빠짐
|
||||
const off = type();
|
||||
off.options[0].enabled = false;
|
||||
out.off = cells(off);
|
||||
// 겹침
|
||||
out.overlap = m
|
||||
.overlaps(
|
||||
m.placeAll(type().options, { a: { 줄: 1, 칸: 1, 너비: 2 }, b: { 줄: 1, 칸: 2, 너비: 1 } }),
|
||||
)
|
||||
.sort();
|
||||
// 규칙 — 맞는 줄 · 없는 값 · 자기 자신 · 없는 하위 선택지 · 그러면 비었음 · 고르기 아닌 상위
|
||||
const rd = defOf();
|
||||
const R = (만약, 그러면) => ({ 종류: "k1", 만약, 그러면 });
|
||||
out.rules = [
|
||||
R({ 옵션: "a", 값들: ["x"] }, { 옵션: "b", 선택지: ["p"], 기본값: "p" }),
|
||||
R({ 옵션: "a", 값들: ["zz"] }, { 옵션: "b", 보이기: false }),
|
||||
R({ 옵션: "a", 값들: ["x"] }, { 옵션: "a", 보이기: false }),
|
||||
R({ 옵션: "a", 값들: ["y"] }, { 옵션: "b", 선택지: ["nope"] }),
|
||||
R({ 옵션: "a", 값들: ["x"] }, { 옵션: "b" }),
|
||||
R({ 옵션: "c", 값들: ["3"] }, { 옵션: "b", 보이기: false }),
|
||||
R({ 옵션: "a", 값들: ["x"] }, { 옵션: "b", 선택지: ["p"], 기본값: "q" }),
|
||||
].map((r) => m.ruleBad(rd, r));
|
||||
// 이름 — 빈 이름 · 겹침
|
||||
out.names = m.badNames(["가", " ", "가"]);
|
||||
// 저장 전 검사 — 깨끗한 정의는 빈 목록 · 겹침 · 이름 틀림 · 빈 선택지는 까닭
|
||||
out.clean = m.problems(defOf());
|
||||
const broken = defOf({
|
||||
배치: { k1: { a: { 줄: 1, 칸: 1, 너비: 2 }, b: { 줄: 1, 칸: 2, 너비: 1 } } },
|
||||
});
|
||||
broken.types[0].options[1].label = "a";
|
||||
broken.types[1].options[0].choices = [];
|
||||
out.broken = m.problems(broken);
|
||||
// 선택지 이름 바꿈 · 뺌 — 기본값 · 규칙 같이
|
||||
const rn = defOf({
|
||||
규칙: [R({ 옵션: "a", 값들: ["x", "y"] }, { 옵션: "b", 선택지: ["p"], 기본값: "p" })],
|
||||
});
|
||||
const a = rn.types[0].options[0];
|
||||
const b = rn.types[0].options[1];
|
||||
a.default = "x";
|
||||
m.renameChoice(rn, "k1", a, "x", "엑스");
|
||||
m.renameChoice(rn, "k1", b, "p", "피");
|
||||
out.rename = [
|
||||
a.choices,
|
||||
a.default,
|
||||
rn.규칙[0].만약.값들,
|
||||
rn.규칙[0].그러면.선택지,
|
||||
rn.규칙[0].그러면.기본값,
|
||||
];
|
||||
m.dropChoice(rn, "k1", b, "피");
|
||||
out.drop = [b.choices, rn.규칙[0].그러면.선택지, "기본값" in rn.규칙[0].그러면];
|
||||
// 순서 — 구조물군 키 차례 · 종류는 같은 군 안에서만
|
||||
const od = defOf();
|
||||
out.family = [m.stepFamily(od, "A", -1), m.stepFamily(od, "A", 1), Object.keys(od.구조물군)];
|
||||
out.type = [m.stepType(od, "k2", 1), m.stepType(od, "k1", 1), od.types.map((t) => t.type_id)];
|
||||
out.base = [
|
||||
m.baseShown(defOf(), "k1"),
|
||||
m.baseShown(defOf({ 종류: { k1: { 기준측점: false } } }), "k1"),
|
||||
];
|
||||
// 목록 순서 바꿈 · 끝은 그대로
|
||||
const list = [1, 2, 3];
|
||||
out.step = [m.step(list, 0, -1), m.step(list, 0, 1), list];
|
||||
// 새 키 — 머리글 · 안 겹침 · 영문 소문자 숫자
|
||||
const k = m.newKey("opt_", ["opt_aaaaaa"]);
|
||||
out.newKey = [/^opt_[0-9a-z]{4,}$/.test(k), k !== "opt_aaaaaa"];
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
@@ -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
|
||||
@@ -192,10 +192,11 @@ def test_placement_mismatching_registry_is_rejected(project_root):
|
||||
save_structures(project_root, [wall_as_point], base_revision=0)
|
||||
|
||||
|
||||
def test_undefined_option_key_is_rejected(project_root):
|
||||
bad = _point(options={"form": "돌", "no_such_option": 1})
|
||||
with pytest.raises(ValueError, match="옵션"):
|
||||
save_structures(project_root, [bad], base_revision=0)
|
||||
def test_undefined_option_key_is_kept(project_root):
|
||||
"""지금 정의에 없는 키 = 지난 정의 값 — 오류 없이 그대로 남김(PLAN 48-1)."""
|
||||
old = _point(options={"form": "돌", "no_such_option": 1})
|
||||
assert save_structures(project_root, [old], base_revision=0) == 1
|
||||
assert load_structures(project_root)[1][0].options["no_such_option"] == 1
|
||||
|
||||
|
||||
def test_negative_number_option_is_rejected(project_root):
|
||||
|
||||
@@ -18,7 +18,7 @@ ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
def _box_options() -> dict[str, dict]:
|
||||
registry = json.loads(
|
||||
(ROOT / "B05_Profile" / "B05_Profile_Structure_Types.json").read_text(encoding="utf-8")
|
||||
(ROOT / "resources" / "master_template" / "구조물정의.json").read_text(encoding="utf-8")
|
||||
)
|
||||
types = registry if isinstance(registry, list) else registry.get("types") or []
|
||||
box = next(t for t in types if t["type_id"] == "box_culvert")
|
||||
|
||||
@@ -32,7 +32,7 @@ QUANTITY_ONLY = {
|
||||
|
||||
def _pipe_option_keys() -> list[str]:
|
||||
payload = json.loads(
|
||||
(ROOT / "B05_Profile" / "B05_Profile_Structure_Types.json").read_text(encoding="utf-8")
|
||||
(ROOT / "resources" / "master_template" / "구조물정의.json").read_text(encoding="utf-8")
|
||||
)
|
||||
types = payload if isinstance(payload, list) else payload["types"]
|
||||
pipe = next(item for item in types if item["type_id"] == "pipe")
|
||||
|
||||
@@ -20,7 +20,7 @@ from B08_Quantity.B08_Quantity_Engine_UnitQuantity import expand, stone_masonry
|
||||
from common_util.common_util_structure_face_role import structure_face_role_of # noqa: E402
|
||||
|
||||
REGISTRY = json.loads(
|
||||
(ROOT / "B05_Profile" / "B05_Profile_Structure_Types.json").read_text(encoding="utf-8")
|
||||
(ROOT / "resources" / "master_template" / "구조물정의.json").read_text(encoding="utf-8")
|
||||
)
|
||||
FIELDS = (ROOT / "B05_Profile" / "B05_Profile_UI_Structures_Fields.ts").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ KNOWN_ABSENT: dict[str, str] = {}
|
||||
|
||||
def _registry_keys() -> set[str]:
|
||||
payload = json.loads(
|
||||
(ROOT / "B05_Profile" / "B05_Profile_Structure_Types.json").read_text(encoding="utf-8")
|
||||
(ROOT / "resources" / "master_template" / "구조물정의.json").read_text(encoding="utf-8")
|
||||
)
|
||||
types = payload if isinstance(payload, list) else (payload.get("types") or [])
|
||||
return {option["key"] for item in types for option in item.get("options") or []}
|
||||
|
||||
@@ -90,7 +90,7 @@ def test_사방_종류_이름이_레지스트리와_맞을것() -> None:
|
||||
import json
|
||||
|
||||
registry = json.loads(
|
||||
(ROOT / "B05_Profile" / "B05_Profile_Structure_Types.json").read_text(encoding="utf-8")
|
||||
(ROOT / "resources" / "master_template" / "구조물정의.json").read_text(encoding="utf-8")
|
||||
)
|
||||
types = registry if isinstance(registry, list) else (registry.get("types") or [])
|
||||
known = {t["type_id"] for t in types}
|
||||
|
||||
@@ -68,7 +68,7 @@ def test_바닥막이는_제곱미터당() -> None:
|
||||
|
||||
def test_개거_L형수로는_등록부_글자로_선다() -> None:
|
||||
registry = json.loads(
|
||||
(ROOT / "B05_Profile" / "B05_Profile_Structure_Types.json").read_text(encoding="utf-8")
|
||||
(ROOT / "resources" / "master_template" / "구조물정의.json").read_text(encoding="utf-8")
|
||||
)
|
||||
choices = next(
|
||||
option["choices"]
|
||||
|
||||
@@ -29,7 +29,7 @@ from B08_Quantity.B08_Quantity_Engine_UnitQuantity import ( # noqa: E402
|
||||
|
||||
def _registry_choices() -> list[str]:
|
||||
payload = json.loads(
|
||||
(ROOT / "B05_Profile" / "B05_Profile_Structure_Types.json").read_text(encoding="utf-8")
|
||||
(ROOT / "resources" / "master_template" / "구조물정의.json").read_text(encoding="utf-8")
|
||||
)
|
||||
types = payload if isinstance(payload, list) else payload["types"]
|
||||
pipe = next(item for item in types if item["type_id"] == "pipe")
|
||||
|
||||
@@ -53,3 +53,11 @@ def test_route() -> None:
|
||||
app.include_router(router_module.router)
|
||||
got = TestClient(app).get("/api/m01/logic/source", params={"src": "산림품셈 3-1"}).json()
|
||||
assert got["found"][0]["matched"] == "3-1"
|
||||
|
||||
|
||||
def test_tables_split_and_br() -> None:
|
||||
"""빈 줄 · 주석이 붙은 표 둘을 가름(49-2) · 칸 안 `<br>` = 줄바꿈."""
|
||||
one = make.source("건설품셈 공통 6-1-1")["found"][0]
|
||||
tables = [b["table"] for b in one["blocks"] if "table" in b]
|
||||
assert [len(t[0]) for t in tables] == [7, 2]
|
||||
assert tables[0][2][0] == "인력운반\n타설"
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
"""M02 구조물 정의 편집 화면 자료 손질(`M02_SO_Def_Model.ts` · PLAN 48-2) 단위검증.
|
||||
|
||||
프론트에 JS 테스트 러너가 없어 Node 헬퍼(`helper_m02_so_def_model.cjs`)가 TS 를 트랜스파일해 돌리고
|
||||
pytest 는 그 결과(JSON)를 판정.
|
||||
|
||||
못박는 것
|
||||
- 배치 격자: 안 정한 옵션은 차례대로 · 정한 자리 먼저 · 너비 ≤ 칸 수 · 안 씀 빠짐 · 겹침 찾음.
|
||||
- 규칙: 없는 값 · 자기 자신 · 없는 하위 선택지 · 그러면 비었음 · 좁힌 선택지 밖 기본값은 틀린 줄
|
||||
(서버 `M02_SO_Definition._rule_errors` 와 같은 뜻) · 고르기 아닌 상위는 값 검사 안 함.
|
||||
- 저장 전 검사: 깨끗한 정의 = 빈 목록 · 이름 겹침 · 배치 겹침 · 빈 선택지 = 까닭 글.
|
||||
- 선택지 이름 바꿈 · 뺌은 그 옵션 기본값 · 같은 종류 규칙도 같이 고침.
|
||||
- 순서: 구조물군 = 키 차례 · 종류 = 같은 군 안에서만.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def r() -> dict:
|
||||
proc = subprocess.run(
|
||||
["node", str(HERE / "helper_m02_so_def_model.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_배치_자동_채움(r) -> None:
|
||||
assert r["auto"] == {
|
||||
"a": {"줄": 1, "칸": 1, "너비": 1},
|
||||
"b": {"줄": 1, "칸": 2, "너비": 1},
|
||||
"c": {"줄": 2, "칸": 1, "너비": 1},
|
||||
}
|
||||
|
||||
|
||||
def test_배치_정한_자리_먼저_너비는_칸수까지(r) -> None:
|
||||
assert r["fixed"]["c"] == {"줄": 1, "칸": 1, "너비": 2}
|
||||
assert r["fixed"]["a"] == {"줄": 2, "칸": 1, "너비": 1}
|
||||
assert r["fixed"]["b"] == {"줄": 2, "칸": 2, "너비": 1}
|
||||
|
||||
|
||||
def test_안씀은_격자에서_빠짐(r) -> None:
|
||||
assert set(r["off"]) == {"b", "c"}
|
||||
assert r["off"]["b"] == {"줄": 1, "칸": 1, "너비": 1}
|
||||
|
||||
|
||||
def test_겹침(r) -> None:
|
||||
assert r["overlap"] == ["a", "b"]
|
||||
|
||||
|
||||
def test_규칙_틀린_줄(r) -> None:
|
||||
assert r["rules"] == [False, True, True, True, True, False, True]
|
||||
|
||||
|
||||
def test_이름_검사(r) -> None:
|
||||
assert r["names"] == ["(빈 이름)", "가"]
|
||||
|
||||
|
||||
def test_저장_전_검사(r) -> None:
|
||||
assert r["clean"] == []
|
||||
assert any("옵션 이름" in t for t in r["broken"])
|
||||
assert any("배치 겹침" in t for t in r["broken"])
|
||||
assert any("선택지가 있어야" in t for t in r["broken"])
|
||||
|
||||
|
||||
def test_선택지_이름_바꿈_뺌은_기본값_규칙도_같이(r) -> None:
|
||||
assert r["rename"] == [["엑스", "y"], "엑스", ["엑스", "y"], ["피"], "피"]
|
||||
assert r["drop"] == [["q"], [], False]
|
||||
|
||||
|
||||
def test_순서_기준측점_새키(r) -> None:
|
||||
assert r["family"] == [False, True, ["B", "A"]]
|
||||
assert r["type"] == [False, True, ["k2", "k1", "k3"]]
|
||||
assert r["base"] == [True, False]
|
||||
assert r["step"] == [False, True, [2, 1, 3]]
|
||||
assert r["newKey"] == [True, True]
|
||||
@@ -0,0 +1,123 @@
|
||||
"""M02 구조물 정의 편집 화면(PLAN 48-2) — 지난 판 길 · 화면이 보내는 꼴을 서버가 받음.
|
||||
|
||||
못박는 것
|
||||
- 지난 판 목록 `[{판, 저장일}]` 새 판부터 · 한 벌 읽기 · 없는 판 404 · 읽기는 누구나.
|
||||
- 화면이 고친 정의(이름 · 순서 · 안 씀 · 기준측점 · 선택지 이름 · 배치 · 그룹 형상 · 규칙)를
|
||||
그대로 PUT 하면 판 +1 · 같은 값으로 다시 읽힘.
|
||||
- 시스템 관리자 아니면 PUT 403(화면은 읽기만).
|
||||
- 마스터 정본은 안 씀(사본).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from B05_Profile import B05_Profile_Structures_Schema as schema
|
||||
from common_util.common_util_auth import verify_session
|
||||
from M02_MasterTemplete.StructOptions.M02_SO_Router import read_router
|
||||
|
||||
URL = "/api/m02/structure-definition"
|
||||
ADMIN = {"role": "SYSTEM_ADMIN", "user_id": 1}
|
||||
USER = {"role": "USER", "user_id": 2, "company_id": 1}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def copy(tmp_path: Path, monkeypatch) -> Path:
|
||||
path = tmp_path / "구조물정의.json"
|
||||
path.write_text(Path(schema.DEFINITION_PATH).read_text("utf-8"), "utf-8")
|
||||
monkeypatch.setattr(schema, "DEFINITION_PATH", str(path))
|
||||
return path
|
||||
|
||||
|
||||
def _client(session: dict) -> TestClient:
|
||||
app = FastAPI()
|
||||
app.include_router(read_router)
|
||||
app.dependency_overrides[verify_session] = lambda: session
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _screen_edit(doc: dict) -> dict:
|
||||
"""화면이 하는 고침 한 벌 — `M02_SO_Def*.ts` 가 만드는 꼴 그대로."""
|
||||
pipe = next(t for t in doc["types"] if t["type_id"] == "pipe")
|
||||
pipe["name"] = "배수관(고침)"
|
||||
kind = next(o for o in pipe["options"] if o["key"] == "pipe_kind")
|
||||
kind["label"] = "관 종류"
|
||||
kind["choices"] = ["흄관(고침)" if c == "흄관" else c for c in kind["choices"]]
|
||||
pipe["options"].append(
|
||||
{
|
||||
"key": "opt_ab12cd",
|
||||
"label": "새 옵션 1",
|
||||
"input": "select",
|
||||
"choices": ["새 선택지 1", "새 선택지 2"],
|
||||
"default": None,
|
||||
}
|
||||
)
|
||||
pipe["options"][1]["enabled"] = False # 안 씀
|
||||
first = list(doc["구조물군"])
|
||||
doc["구조물군"] = {k: doc["구조물군"][k] for k in [first[1], first[0], *first[2:]]}
|
||||
doc["종류"].setdefault("pipe", {})["기준측점"] = False
|
||||
doc["배치"]["pipe"] = {"pipe_kind": {"줄": 1, "칸": 1, "너비": 2}}
|
||||
doc["표시"]["pipe"] = {"inlet_type": False}
|
||||
doc["그룹"]["pipe"][1]["형상"] = {
|
||||
"길이": {"받기": True, "뜻": "유입 보호공 연장"},
|
||||
"높이": {"받기": False, "뜻": ""},
|
||||
}
|
||||
doc["규칙"] = [
|
||||
{
|
||||
"종류": "pipe",
|
||||
"만약": {"옵션": "pipe_kind", "값들": ["흄관(고침)"]},
|
||||
"그러면": {"옵션": "opt_ab12cd", "선택지": ["새 선택지 2"], "기본값": "새 선택지 2"},
|
||||
},
|
||||
{
|
||||
"종류": "pipe",
|
||||
"만약": {"옵션": "inlet_type", "값들": ["집수정"]},
|
||||
"그러면": {"옵션": "opt_ab12cd", "보이기": False},
|
||||
},
|
||||
]
|
||||
return doc
|
||||
|
||||
|
||||
def test_화면_고침을_그대로_받아_판_더하기_1_지난_판_길(copy) -> None:
|
||||
admin = _client(ADMIN)
|
||||
doc = admin.get(URL).json()
|
||||
assert admin.get(f"{URL}/history").json() == []
|
||||
sent = _screen_edit(json.loads(json.dumps(doc)))
|
||||
res = admin.put(URL, json={"판": doc["판"], "정의": sent})
|
||||
assert res.status_code == 200, res.text
|
||||
got = res.json()
|
||||
assert got["판"] == doc["판"] + 1
|
||||
for key in ("구조물군", "종류", "배치", "표시", "그룹", "규칙", "types"):
|
||||
assert got[key] == sent[key], key
|
||||
assert list(got["구조물군"]) == list(sent["구조물군"]) # 순서 그대로
|
||||
rows = admin.get(f"{URL}/history").json()
|
||||
assert [r["판"] for r in rows] == [doc["판"]] and len(rows[0]["저장일"]) == 16
|
||||
old = admin.get(f"{URL}/history/{doc['판']}").json()
|
||||
assert old == doc
|
||||
assert admin.get(f"{URL}/history/999").status_code == 404
|
||||
|
||||
|
||||
def test_지난_판은_누구나_읽고_쓰기는_시스템_관리자만(copy) -> None:
|
||||
user = _client(USER)
|
||||
doc = user.get(URL).json()
|
||||
assert user.get(f"{URL}/history").status_code == 200
|
||||
assert user.put(URL, json={"판": doc["판"], "정의": _screen_edit(doc)}).status_code == 403
|
||||
assert json.loads(copy.read_text("utf-8"))["판"] == doc["판"]
|
||||
|
||||
|
||||
def test_화면이_못_거른_틀린_규칙은_422_안_씀(copy) -> None:
|
||||
admin = _client(ADMIN)
|
||||
doc = admin.get(URL).json()
|
||||
doc["규칙"] = [
|
||||
{
|
||||
"종류": "pipe",
|
||||
"만약": {"옵션": "pipe_kind", "값들": ["없음"]},
|
||||
"그러면": {"옵션": "inlet_type"},
|
||||
}
|
||||
]
|
||||
assert admin.put(URL, json={"판": doc["판"], "정의": doc}).status_code == 422
|
||||
assert json.loads(copy.read_text("utf-8"))["판"] == doc["판"]
|
||||
@@ -0,0 +1,158 @@
|
||||
"""M02 구조물 정의 한 벌(`/api/m02/structure-definition` · PLAN 48-1).
|
||||
|
||||
못박는 것
|
||||
- 판 1 = 옛 레지스트리 그대로 옮김(37종 · 옵션 235 · 키 그대로) · 검사 통과.
|
||||
- 읽기 = 로그인 누구나 · 쓰기 = 시스템 관리자만(403).
|
||||
- 저장마다 판 +1 · 앞 판은 `구조물정의_이력/판_N.json` · 낡은 판 409 · 틀린 정의 422(안 씀).
|
||||
- 검사 = 키 겹침 · 꼴 · 규칙 대상 있음 · 그룹 형상.
|
||||
- 저장한 정의를 `/structure-types` 가 바로 읽음 · 프로젝트 저장에 정의 판 · 없는 키 값 보존.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from B05_Profile import B05_Profile_Structures_Schema as schema
|
||||
from B05_Profile.B05_Profile_Structures_Repository import load_structures, save_structures
|
||||
from B05_Profile.B05_Profile_Structures_Router import router as b05_router
|
||||
from B05_Profile.B05_Profile_Structures_Schema import StructureInstance, load_structure_types
|
||||
from common_util.common_util_auth import verify_session
|
||||
from M02_MasterTemplete.StructOptions import M02_SO_Definition as definition
|
||||
from M02_MasterTemplete.StructOptions.M02_SO_Router import read_router
|
||||
|
||||
URL = "/api/m02/structure-definition"
|
||||
TYPES = "/api/projects/structure-types"
|
||||
ADMIN = {"role": "SYSTEM_ADMIN", "user_id": 1}
|
||||
USER = {"role": "USER", "user_id": 2, "company_id": 1}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def copy(tmp_path: Path, monkeypatch) -> Path:
|
||||
"""정의 사본 — 마스터 정본은 안 씀."""
|
||||
path = tmp_path / "구조물정의.json"
|
||||
path.write_text(Path(schema.DEFINITION_PATH).read_text("utf-8"), "utf-8")
|
||||
monkeypatch.setattr(schema, "DEFINITION_PATH", str(path))
|
||||
return path
|
||||
|
||||
|
||||
def _client(session: dict) -> TestClient:
|
||||
app = FastAPI()
|
||||
app.include_router(read_router)
|
||||
app.include_router(b05_router)
|
||||
app.dependency_overrides[verify_session] = lambda: session
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_판1_은_옛_레지스트리_그대로_검사_통과() -> None:
|
||||
doc = definition.read()
|
||||
assert doc["판"] == 1 and len(doc["types"]) == 37
|
||||
assert sum(len(t["options"]) for t in doc["types"]) == 235
|
||||
assert len(load_structure_types()) == 37
|
||||
assert sum(len(t.options) for t in load_structure_types()) == 235
|
||||
assert definition.check(doc) == []
|
||||
assert [g["이름"] for g in doc["그룹"]["pipe"]] == ["관", "관 보호공(유입)", "관 보호공(유출)"]
|
||||
assert {t["group"] for t in doc["types"]} <= set(doc["구조물군"])
|
||||
|
||||
|
||||
def test_읽기는_누구나_쓰기는_시스템_관리자만(copy) -> None:
|
||||
user = _client(USER)
|
||||
got = user.get(URL)
|
||||
assert got.status_code == 200 and got.json()["판"] == 1
|
||||
assert user.put(URL, json={"판": 1, "정의": got.json()}).status_code == 403
|
||||
assert json.loads(copy.read_text("utf-8"))["판"] == 1 # 안 씀
|
||||
|
||||
|
||||
def test_저장마다_판_더하기_1_앞_판은_이력_structure_types_가_바로_읽음(copy) -> None:
|
||||
admin = _client(ADMIN)
|
||||
doc = admin.get(URL).json()
|
||||
tid = doc["types"][0]["type_id"]
|
||||
doc["types"][0]["name"] = "이름 바꿈"
|
||||
res = admin.put(URL, json={"판": 1, "정의": doc})
|
||||
assert res.status_code == 200, res.text
|
||||
assert res.json()["판"] == 2
|
||||
old = json.loads((copy.parent / "구조물정의_이력" / "판_1.json").read_text("utf-8"))
|
||||
assert old["판"] == 1 and old["types"][0]["name"] != "이름 바꿈"
|
||||
seen = {t["type_id"]: t for t in admin.get(TYPES).json()["types"]}
|
||||
assert seen[tid]["name"] == "이름 바꿈" and len(seen) == 37
|
||||
assert admin.put(URL, json={"판": 1, "정의": doc}).status_code == 409 # 낡은 판
|
||||
assert admin.put(URL, json={"판": 2, "정의": res.json()}).json()["판"] == 3
|
||||
assert (copy.parent / "구조물정의_이력" / "판_2.json").is_file()
|
||||
|
||||
|
||||
def _rule(doc: dict) -> tuple[str, dict, dict]:
|
||||
"""고르기 옵션 둘 이상 가진 종류 — (종류 키, 상위 옵션, 하위 옵션)."""
|
||||
for t in doc["types"]:
|
||||
picks = [o for o in t["options"] if o["input"] == "select"]
|
||||
if len(picks) >= 2:
|
||||
return t["type_id"], picks[0], picks[1]
|
||||
raise AssertionError("고르기 둘인 종류 없음")
|
||||
|
||||
|
||||
def test_검사_키_겹침_꼴_규칙_대상_형상(copy) -> None:
|
||||
admin = _client(ADMIN)
|
||||
base = admin.get(URL).json()
|
||||
tid, up, down = _rule(base)
|
||||
good = {
|
||||
"종류": tid,
|
||||
"만약": {"옵션": up["key"], "값들": [up["choices"][0]]},
|
||||
"그러면": {"옵션": down["key"], "선택지": down["choices"][:1], "보이기": True},
|
||||
}
|
||||
assert definition.check({**base, "규칙": [good]}) == []
|
||||
|
||||
def bad(**change) -> dict:
|
||||
return json.loads(json.dumps({**base, **change}))
|
||||
|
||||
dup = bad()
|
||||
dup["types"].append(dup["types"][0]) # 종류 키 겹침
|
||||
opt_dup = bad()
|
||||
opt_dup["types"][0]["options"].append(opt_dup["types"][0]["options"][0]) # 옵션 키 겹침
|
||||
shape = bad()
|
||||
shape["types"][0]["options"][0]["input"] = "날짜" # 없는 꼴
|
||||
cases = [
|
||||
dup,
|
||||
opt_dup,
|
||||
shape,
|
||||
bad(규칙=[{**good, "그러면": {**good["그러면"], "옵션": "없는키"}}]), # 대상 없음
|
||||
bad(규칙=[{**good, "종류": "없는종류"}]),
|
||||
bad(규칙=[{**good, "만약": {"옵션": up["key"], "값들": ["없는값"]}}]),
|
||||
bad(규칙=[{**good, "그러면": {"옵션": down["key"], "선택지": ["없는값"]}}]),
|
||||
bad(규칙=[{**good, "그러면": {"옵션": down["key"]}}]), # 효과 없음
|
||||
bad(배치={tid: {up["key"]: {"줄": 0, "칸": 1}}}),
|
||||
bad(배치={tid: {"없는키": {"줄": 1}}}),
|
||||
bad(종류={tid: {"동작": ["없는동작"]}}),
|
||||
bad(종류={"없는종류": {"기준측점": False}}),
|
||||
bad(표시={tid: {"없는키": False}}),
|
||||
bad(
|
||||
그룹={"pipe": [{"id": "g1", "이름": "가", "옵션": [], "형상": {"폭": {"받기": True}}}]}
|
||||
),
|
||||
bad(모르는칸=1),
|
||||
]
|
||||
for i, doc in enumerate(cases):
|
||||
res = admin.put(URL, json={"판": 1, "정의": doc})
|
||||
assert res.status_code == 422, (i, res.text)
|
||||
assert json.loads(copy.read_text("utf-8"))["판"] == 1 # 거절되면 안 씀
|
||||
assert not (copy.parent / "구조물정의_이력").exists()
|
||||
shaped = bad(종류={tid: {"기준측점": False}})
|
||||
shaped["그룹"]["pipe"][0]["형상"] = {"길이": {"받기": True, "뜻": "관 길이"}}
|
||||
assert admin.put(URL, json={"판": 1, "정의": shaped}).status_code == 200
|
||||
|
||||
|
||||
def test_프로젝트_저장에_정의_판_없는_키_값은_남김(copy, tmp_path) -> None:
|
||||
root = str(tmp_path / "project")
|
||||
item = StructureInstance.model_validate(
|
||||
{
|
||||
"type_id": "mirror",
|
||||
"placement": "point",
|
||||
"chainage_m": 10.0,
|
||||
"options": {"지난정의키": "옛값"},
|
||||
}
|
||||
)
|
||||
assert save_structures(root, [item], base_revision=0) == 1
|
||||
raw = json.loads(Path(root, "B05_Profile", "route", "structures.json").read_text("utf-8"))
|
||||
assert raw["definition_version"] == 1
|
||||
assert load_structures(root)[1][0].options["지난정의키"] == "옛값"
|
||||
@@ -1,12 +1,11 @@
|
||||
"""M02 구조물 옵션 그룹(`/api/m02/structure-option-groups` · `구조물옵션그룹.json` · PLAN 46-2).
|
||||
"""M02 구조물 옵션 그룹(`/api/m02/structure-option-groups` · 정의 「그룹」 칸 · PLAN 46-2).
|
||||
|
||||
못박는 것
|
||||
- 시드 = `{}` · 파일이 없으면 그룹 없음 · `/structure-types` 종류마다 `groups: []`.
|
||||
- 「그룹」 이 `{}` 면 그룹 없음 · `/structure-types` 종류마다 `groups: []`.
|
||||
- 저장한 그룹이 `/structure-types` `groups` 에 그대로(차례 · 이름 · 옵션) · 빈 그룹도 나감.
|
||||
- 옵션 없는 종류도 그룹을 둘 수 있음(관리 목록 `종류` 는 레지스트리 전부).
|
||||
- 없는 종류 · 키 · id 틀림 · 겹침 · 이름 빔 · 겹침 · 옵션 두 그룹은 422 · 낡은 판 409 ·
|
||||
거절되면 안 씀.
|
||||
- 파일이 깨져도 폼 응답은 그룹 없음(죽지 않음) · 관리 화면은 500.
|
||||
- 좌측 「구조물 옵션 보이기」 는 시스템 관리자만(화면 코드).
|
||||
"""
|
||||
|
||||
@@ -19,10 +18,10 @@ import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from B05_Profile import B05_Profile_Structures_Schema as schema
|
||||
from B05_Profile.B05_Profile_Structures_Router import router as b05_router
|
||||
from B05_Profile.B05_Profile_Structures_Schema import load_structure_types
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete.StructOptions import M02_SO_Groups as groups
|
||||
from M02_MasterTemplete.StructOptions.M02_SO_Router import router as m02_router
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
@@ -32,7 +31,12 @@ TYPES = "/api/projects/structure-types"
|
||||
|
||||
@pytest.fixture
|
||||
def client(tmp_path: Path, monkeypatch) -> TestClient:
|
||||
"""정의 사본(표시 · 그룹 빈 칸 · 판 1)으로 — 마스터 정본은 안 씀(PLAN 48-1)."""
|
||||
monkeypatch.setattr(store, "FOLDER", tmp_path)
|
||||
doc = json.loads(Path(schema.DEFINITION_PATH).read_text("utf-8"))
|
||||
copy = tmp_path / "구조물정의.json"
|
||||
copy.write_text(json.dumps({**doc, "판": 1, "표시": {}, "그룹": {}}), "utf-8")
|
||||
monkeypatch.setattr(schema, "DEFINITION_PATH", str(copy))
|
||||
app = FastAPI()
|
||||
app.include_router(m02_router)
|
||||
app.include_router(b05_router)
|
||||
@@ -48,11 +52,9 @@ def _groups(client: TestClient) -> dict[str, list[dict]]:
|
||||
return {t["type_id"]: t["groups"] for t in client.get(TYPES).json()["types"]}
|
||||
|
||||
|
||||
def test_시드는_빈_표_없으면_그룹_없음(client) -> None:
|
||||
seed = ROOT / "resources/master_template/구조물옵션그룹.json"
|
||||
assert json.loads(seed.read_text("utf-8")) == {}
|
||||
def test_빈_그룹_칸은_그룹_없음(client) -> None:
|
||||
got = client.get(URL).json()
|
||||
assert got["판"] == "" and got["그룹"] == {}
|
||||
assert got["판"] == "1" and got["그룹"] == {}
|
||||
assert len(got["종류"]) == len(load_structure_types()) # 옵션 없는 종류도
|
||||
assert all(v == [] for v in _groups(client).values())
|
||||
|
||||
@@ -67,7 +69,7 @@ def test_저장한_그룹이_폼_응답에_차례대로_빈_그룹도(client) ->
|
||||
],
|
||||
empty: [{"id": "gx", "이름": "부속", "옵션": []}],
|
||||
}
|
||||
res = client.put(URL, json={"판": "", "그룹": body})
|
||||
res = client.put(URL, json={"판": "1", "그룹": body})
|
||||
assert res.status_code == 200, res.text
|
||||
assert res.json()["그룹"][tid][1]["이름"] == "빈 그룹" # 앞뒤 빈칸 지움
|
||||
seen = _groups(client)
|
||||
@@ -92,22 +94,18 @@ def test_틀린_그룹은_422_낡은_판은_409(client) -> None:
|
||||
{tid: [ok, {"id": "g2", "이름": "나", "옵션": [a]}]}, # 옵션 두 그룹
|
||||
{tid: {"g1": ok}},
|
||||
):
|
||||
assert client.put(URL, json={"판": "", "그룹": bad}).status_code == 422, bad
|
||||
assert not (store.FOLDER / groups.FILE).exists() # 거절되면 안 씀
|
||||
v1 = client.put(URL, json={"판": "", "그룹": {tid: [ok]}}).json()["판"]
|
||||
assert client.put(URL, json={"판": "1", "그룹": bad}).status_code == 422, bad
|
||||
assert json.loads(Path(schema.DEFINITION_PATH).read_text("utf-8"))["판"] == 1 # 거절되면 안 씀
|
||||
v1 = client.put(URL, json={"판": "1", "그룹": {tid: [ok]}}).json()["판"]
|
||||
assert client.put(URL, json={"판": "낡음", "그룹": {}}).status_code == 409
|
||||
res = client.put(URL, json={"판": v1, "그룹": {}})
|
||||
assert res.status_code == 200 and res.json()["그룹"] == {}
|
||||
|
||||
|
||||
def test_파일이_깨져도_폼_응답은_그룹_없음(client) -> None:
|
||||
(store.FOLDER / groups.FILE).write_text("{깨짐", encoding="utf-8")
|
||||
assert all(v == [] for v in _groups(client).values())
|
||||
assert client.get(URL).status_code == 500
|
||||
|
||||
|
||||
def test_좌측_옵션_보이기는_시스템_관리자만() -> None:
|
||||
def test_좌측_정의_줄은_시스템_층에_늘_고쳐_씀은_시스템_관리자만() -> None:
|
||||
"""PLAN 48-2 — 다른 역할은 읽기만(화면이 칸을 잠금 · 서버 PUT 403)."""
|
||||
side = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts").read_text("utf-8")
|
||||
assert "옵션: canEdit(layer, opt.isAdmin) ? section(options.title, options.body) : null" in side
|
||||
ui = (ROOT / "M02_MasterTemplete/StructOptions/M02_SO_Options.ts").read_text("utf-8")
|
||||
assert "buildGroupBlock(" in ui and "buildPlaceSelect(" in ui
|
||||
assert "옵션: section(options.title, options.body)," in side
|
||||
folder = ROOT / "M02_MasterTemplete/StructOptions"
|
||||
assert "buildGroupBlock(" in (folder / "M02_SO_Def.ts").read_text("utf-8")
|
||||
assert "buildPlaceSelect(" in (folder / "M02_SO_Def_Options.ts").read_text("utf-8")
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
"""M02 구조물 옵션 보이기(`/api/m02/structure-options` · 정본 `구조물옵션표시.json`).
|
||||
"""M02 구조물 옵션 보이기(`/api/m02/structure-options` · 정의 「표시」 칸 · PLAN 48-1).
|
||||
|
||||
못박는 것
|
||||
- 파일이 없거나 `{}` 면 `GET /api/projects/structure-types` 옵션이 모두 visible.
|
||||
- 「표시」 가 `{}` 면 `GET /api/projects/structure-types` 옵션이 모두 visible.
|
||||
- 저장한 숨김이 그 응답 visible 에 반영 · 다른 종류 · 옵션은 그대로 · 옵션 정의는 안 바뀜.
|
||||
- 저장은 숨김(false)만 남김 · [모두 보임] = `{}` · 없는 종류 · 키 · 참거짓 아님 422 · 낡은 판 409.
|
||||
- 파일이 깨져도 폼 응답은 모두 보임(죽지 않음).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -16,10 +15,10 @@ import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from B05_Profile import B05_Profile_Structures_Schema as schema
|
||||
from B05_Profile.B05_Profile_Structures_Router import router as b05_router
|
||||
from B05_Profile.B05_Profile_Structures_Schema import load_structure_types
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete.StructOptions import M02_SO_Options as opt
|
||||
from M02_MasterTemplete.StructOptions.M02_SO_Router import router as m02_router
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
@@ -33,7 +32,12 @@ def structure_type(type_id):
|
||||
|
||||
@pytest.fixture
|
||||
def client(tmp_path: Path, monkeypatch) -> TestClient:
|
||||
"""정의 사본(표시 · 그룹 빈 칸 · 판 1)으로 — 마스터 정본은 안 씀(PLAN 48-1)."""
|
||||
monkeypatch.setattr(store, "FOLDER", tmp_path)
|
||||
doc = json.loads(Path(schema.DEFINITION_PATH).read_text("utf-8"))
|
||||
copy = tmp_path / "구조물정의.json"
|
||||
copy.write_text(json.dumps({**doc, "판": 1, "표시": {}, "그룹": {}}), "utf-8")
|
||||
monkeypatch.setattr(schema, "DEFINITION_PATH", str(copy))
|
||||
app = FastAPI()
|
||||
app.include_router(m02_router)
|
||||
app.include_router(b05_router)
|
||||
@@ -51,13 +55,9 @@ def _visible(client: TestClient) -> dict[str, dict[str, bool]]:
|
||||
return {t["type_id"]: {o["key"]: o["visible"] for o in t["options"]} for t in types}
|
||||
|
||||
|
||||
def test_시드_파일은_빈_표이고_없어도_모두_보임(client) -> None:
|
||||
assert (
|
||||
json.loads((ROOT / "resources/master_template/구조물옵션표시.json").read_text("utf-8"))
|
||||
== {}
|
||||
)
|
||||
def test_빈_표시는_모두_보임(client) -> None:
|
||||
got = client.get(URL).json()
|
||||
assert got["판"] == "" and got["표시"] == {} # 파일 없음
|
||||
assert got["판"] == "1" and got["표시"] == {}
|
||||
assert all(all(v.values()) for v in _visible(client).values())
|
||||
assert client.get(TYPES).json()["schema_version"] == 1
|
||||
|
||||
@@ -65,7 +65,7 @@ def test_시드_파일은_빈_표이고_없어도_모두_보임(client) -> None:
|
||||
def test_숨김을_저장하면_폼_응답에_반영_정의는_그대로(client) -> None:
|
||||
tid, off, on = _pick()
|
||||
before = client.get(TYPES).json()["types"]
|
||||
res = client.put(URL, json={"판": "", "표시": {tid: {off: False, on: True}}})
|
||||
res = client.put(URL, json={"판": "1", "표시": {tid: {off: False, on: True}}})
|
||||
assert res.status_code == 200, res.text
|
||||
assert res.json()["표시"] == {tid: {off: False}} # 숨김만 남김
|
||||
seen = _visible(client)
|
||||
@@ -83,10 +83,10 @@ def test_숨김을_저장하면_폼_응답에_반영_정의는_그대로(client)
|
||||
|
||||
def test_모두_보임은_빈_표_판은_저장마다_바뀜(client) -> None:
|
||||
tid, off, _ = _pick()
|
||||
v1 = client.put(URL, json={"판": "", "표시": {tid: {off: False}}}).json()["판"]
|
||||
v1 = client.put(URL, json={"판": "1", "표시": {tid: {off: False}}}).json()["판"]
|
||||
res = client.put(URL, json={"판": v1, "표시": {}})
|
||||
assert res.status_code == 200 and res.json()["표시"] == {} and res.json()["판"] != v1
|
||||
assert json.loads((store.FOLDER / opt.FILE).read_text("utf-8")) == {}
|
||||
assert json.loads(Path(schema.DEFINITION_PATH).read_text("utf-8"))["표시"] == {}
|
||||
assert all(all(v.values()) for v in _visible(client).values())
|
||||
|
||||
|
||||
@@ -98,21 +98,15 @@ def test_없는_종류_키_참거짓_아님은_422_낡은_판은_409(client) ->
|
||||
{tid: {off: "no"}},
|
||||
{tid: [off]},
|
||||
):
|
||||
assert client.put(URL, json={"판": "", "표시": bad}).status_code == 422, bad
|
||||
assert client.put(URL, json={"판": "", "표시": []}).status_code == 422 # 묶음 아님(요청 모양)
|
||||
assert not (store.FOLDER / opt.FILE).exists() # 거절되면 안 씀
|
||||
v1 = client.put(URL, json={"판": "", "표시": {tid: {off: False}}}).json()["판"]
|
||||
assert client.put(URL, json={"판": "1", "표시": bad}).status_code == 422, bad
|
||||
assert client.put(URL, json={"판": "1", "표시": []}).status_code == 422 # 묶음 아님(요청 모양)
|
||||
assert json.loads(Path(schema.DEFINITION_PATH).read_text("utf-8"))["판"] == 1 # 거절되면 안 씀
|
||||
v1 = client.put(URL, json={"판": "1", "표시": {tid: {off: False}}}).json()["판"]
|
||||
assert client.put(URL, json={"판": "낡음", "표시": {}}).status_code == 409
|
||||
assert client.put(URL, json={"판": "", "표시": {}}).status_code == 409 # 처음 판으로 또 쓰기
|
||||
assert client.put(URL, json={"판": "1", "표시": {}}).status_code == 409 # 처음 판으로 또 쓰기
|
||||
assert client.get(URL).json()["판"] == v1
|
||||
|
||||
|
||||
def test_파일이_깨져도_폼_응답은_모두_보임(client) -> None:
|
||||
(store.FOLDER / opt.FILE).write_text("{깨짐", encoding="utf-8")
|
||||
assert all(all(v.values()) for v in _visible(client).values())
|
||||
assert client.get(URL).status_code == 500 # 관리 화면은 까닭을 그대로 올림
|
||||
|
||||
|
||||
def test_좌측_줄은_구조물_집계_양식_아래_한_줄_같은_한_곳에서_지움() -> None:
|
||||
side = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts").read_text("utf-8")
|
||||
assert "section(options.title, options.body)" in side
|
||||
@@ -129,12 +123,10 @@ def test_좌측_줄은_구조물_집계_양식_아래_한_줄_같은_한_곳에
|
||||
assert "matset" not in side
|
||||
paint = side.split("function paint(): void {")[1].split("\n }\n")[0]
|
||||
assert "if (opt.getOpen()) options.clear();" in paint # 양식을 열면 이 줄 고름 지움
|
||||
# 화면은 정의 한 벌로 읽고 씀(PLAN 48-2) · 보이기 체크는 정의 화면 옵션 줄
|
||||
api = (ROOT / "M02_MasterTemplete/StructOptions/M02_SO_Api.ts").read_text("utf-8")
|
||||
assert (
|
||||
'call("/layers/system/structure-options")' in api
|
||||
and 'call("/structure-options", { method: "PUT"' in api
|
||||
)
|
||||
ui = (ROOT / "M02_MasterTemplete/StructOptions/M02_SO_Options.ts").read_text("utf-8")
|
||||
assert 'call("/structure-definition")' in api
|
||||
ui = (ROOT / "M02_MasterTemplete/StructOptions/M02_SO_Def_Options.ts").read_text("utf-8")
|
||||
assert 'o.phase === "detail"' in ui # B06 전용 표시
|
||||
|
||||
|
||||
@@ -165,7 +157,7 @@ def test_숨긴_옵션은_필수_아님_저장이_안_막힘(client, tmp_path, m
|
||||
root = str(tmp_path / "project")
|
||||
with pytest.raises(ValueError, match="필수"):
|
||||
repo.save_structures(root, [item({})], base_revision=0) # 보임 = 필수 그대로
|
||||
(store.FOLDER / opt.FILE).write_text('{"fake_hide": {"a": false}}', encoding="utf-8")
|
||||
monkeypatch.setattr(repo, "hidden_keys", lambda: {"fake_hide": {"a"}})
|
||||
with pytest.raises(ValueError, match="필수"): # a 는 숨겨 통과 · b 는 여전히 필수
|
||||
repo.save_structures(root, [item({})], base_revision=0)
|
||||
assert (
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/* 48-1 — 구조물 정의 한 벌(resources/master_template/구조물정의.json)로 옮긴 뒤 화면 변화 0
|
||||
* 시작 화면: M02 마스터 템플릿 · 마스터 층(#/m02-master-template?root=master) · 시스템 관리자 · 저장 안 함
|
||||
* 부르는 법: orca eval --expression "$(cat 이 파일)" (async 식 · JSON 글을 돌려줌)
|
||||
* 기대 수치:
|
||||
* - def = { 판 ≥ 1, types: 37, options: 235 }(GET /api/m02/structure-definition)
|
||||
* - types = { types: 37, options: 235, visibleAll: true, pipeGroups: ["관", "관 보호공(유입)", "관 보호공(유출)"] }
|
||||
* (GET /api/projects/structure-types — 옛 꼴 그대로)
|
||||
* - 좌측 「구조물 옵션 정의」 → 종류 단추 kinds = 37(옵션 없는 종류도 · 48-2 정의 화면)
|
||||
* · pipe 단추에 그룹 표지 「3」
|
||||
*/
|
||||
(async () => {
|
||||
const W = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const $ = (s, root = document) => root.querySelector(s);
|
||||
const $$ = (s, root = document) => [...root.querySelectorAll(s)];
|
||||
const until = async (fn, ms = 8000) => {
|
||||
for (let t = 0; t < ms; t += 150) {
|
||||
const v = fn();
|
||||
if (v) return v;
|
||||
await W(150);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const get = async (url) => {
|
||||
const r = await fetch(url, { credentials: "include" });
|
||||
return r.ok ? r.json() : { status: r.status };
|
||||
};
|
||||
const out = {};
|
||||
const def = await get("/api/m02/structure-definition");
|
||||
if (!def.types) return JSON.stringify({ error: "정의 못 읽음", def });
|
||||
out.def = {
|
||||
판: def.판,
|
||||
types: def.types.length,
|
||||
options: def.types.reduce((n, t) => n + t.options.length, 0),
|
||||
};
|
||||
const st = await get("/api/projects/structure-types");
|
||||
const pipe = st.types.find((t) => t.type_id === "pipe");
|
||||
out.types = {
|
||||
types: st.types.length,
|
||||
options: st.types.reduce((n, t) => n + t.options.length, 0),
|
||||
visibleAll: st.types.every((t) => t.options.every((o) => typeof o.visible === "boolean")),
|
||||
pipeGroups: pipe.groups.map((g) => g.name),
|
||||
};
|
||||
const row = await until(() => $("[data-options-row]"));
|
||||
if (!row) return JSON.stringify({ ...out, error: "정의 줄 없음" });
|
||||
row.click();
|
||||
await until(() => $$("[data-sod-kind]").length);
|
||||
const kinds = $$("[data-sod-kind]");
|
||||
out.screen = {
|
||||
kinds: kinds.length,
|
||||
withOptions: def.types.filter((t) => t.options.length).length,
|
||||
pipeTag: $('[data-sod-kind="pipe"]')?.innerText.replace(/\s+/g, " ").trim() ?? null,
|
||||
};
|
||||
out.ok =
|
||||
out.def.types === 37 &&
|
||||
out.def.options === 235 &&
|
||||
out.types.types === 37 &&
|
||||
out.types.options === 235 &&
|
||||
out.types.pipeGroups.length === 3;
|
||||
return JSON.stringify(out);
|
||||
})();
|
||||
@@ -0,0 +1,145 @@
|
||||
/* 48-2 — M02 구조물 옵션 정의 편집 화면(군 · 종류 · 옵션 · 선택지 · 배치 격자 · 그룹 형상 · 규칙 · 지난 판)
|
||||
* 시작 화면: M02 마스터 템플릿 · 마스터 층(#/m02-master-template?root=master) · 시스템 관리자
|
||||
* 부르는 법: orca eval --expression "$(cat 이 파일)" (async 식 · JSON 글을 돌려줌 · 20초 안)
|
||||
* 쓰는 것: 없음 — 화면 사본만 고치고 [저장] 안 누름(저장 길은 test_m02_structure_def_screen.py 가 사본으로) ·
|
||||
* 끝에 판이 그대로인지 GET 으로 봄 · 화면을 다시 띄우면 고침은 사라짐
|
||||
* 기대 수치:
|
||||
* - families = 구조물군 수 · kinds = 37 · pipe 줄 options = pipe 옵션 수 · cells = 쓰는 옵션 수 · base = true
|
||||
* - 이름 바꿈 → 왼쪽 단추 글 따라감 · [저장] 켜짐
|
||||
* - 선택지 펼침 = 3(흄관 · VR관 · 파형강관) · 「흄관」 → 「흄관X」 = 기본값 고르기에도 「흄관X」
|
||||
* - 너비 2 → 미리보기 span 2 · 겹치게 두면 issues 에 「배치 겹침」 · [저장] 꺼짐 · 되돌리면 사라짐
|
||||
* - 기준 측점 끔 → 미리보기 기준 측점 칸 0
|
||||
* - 그룹 형상 「길이」 켬 → 뜻 칸 열림
|
||||
* - [규칙 더함] → 규칙 줄 1 · 빨강 아님 · 지우기 → 0
|
||||
* - [지난 판] → 목록 칸(판 단추 또는 「지난 판 없음」)
|
||||
* - 끝 판 = 처음 판(저장 안 함)
|
||||
*/
|
||||
(async () => {
|
||||
const W = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const $ = (s, root = document) => root.querySelector(s);
|
||||
const $$ = (s, root = document) => [...root.querySelectorAll(s)];
|
||||
const until = async (fn, ms = 8000) => {
|
||||
for (let t = 0; t < ms; t += 120) {
|
||||
const v = fn();
|
||||
if (v) return v;
|
||||
await W(120);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
/** 칸 값 바꿈 — 글자 넣음(input) + 떠남(change) */
|
||||
const put = (box, value) => {
|
||||
box.value = value;
|
||||
box.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
box.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
};
|
||||
const tick = (box, on) => {
|
||||
if (box.checked !== on) box.click();
|
||||
};
|
||||
const def = await (
|
||||
await fetch("/api/m02/structure-definition", { credentials: "include" })
|
||||
).json();
|
||||
if (!def.types) return JSON.stringify({ error: "정의 못 읽음" });
|
||||
const pipe = def.types.find((t) => t.type_id === "pipe");
|
||||
const out = { 판: def.판 };
|
||||
|
||||
const row = await until(() => $("[data-options-row]"));
|
||||
if (!row) return JSON.stringify({ error: "정의 줄 없음" });
|
||||
row.click();
|
||||
if (!(await until(() => $$("[data-sod-kind]").length)))
|
||||
return JSON.stringify({ error: "종류 목록 없음" });
|
||||
out.families = [$$("[data-sod-family]").length, Object.keys(def.구조물군).length];
|
||||
out.kinds = $$("[data-sod-kind]").length;
|
||||
$('[data-sod-kind="pipe"]').click();
|
||||
await until(() => $('[data-sod-option="pipe_kind"]'));
|
||||
const liveOpts = pipe.options.filter((o) => o.enabled !== false).length;
|
||||
out.pipe = {
|
||||
options: [$$("[data-sod-option]").length, pipe.options.length],
|
||||
cells: [$$("[data-sod-cell]").length, liveOpts],
|
||||
base: !!$(".m02-sod__cell.is-base"),
|
||||
};
|
||||
|
||||
// 이름 바꿈 → 왼쪽 단추 · [저장] 켜짐
|
||||
put($("[data-sod-kind-name]"), "배수관(시험)");
|
||||
out.rename = {
|
||||
left: $('[data-sod-kind="pipe"]').innerText.includes("배수관(시험)"),
|
||||
save: !$("[data-sod-save]").disabled,
|
||||
};
|
||||
|
||||
// 선택지 펼침 · 이름 바꿈 → 기본값 고르기에도
|
||||
$('[data-sod-choices="pipe_kind"]').click();
|
||||
await until(() => $$("[data-sod-choice]").length);
|
||||
out.choices = $$("[data-sod-choice]").map((r) => $("input", r).value);
|
||||
put($('[data-sod-choice="흄관"] [data-sod-choice-name]'), "흄관X");
|
||||
const defaultPick = $('[data-sod-option="pipe_kind"] [data-sod-default]');
|
||||
out.choiceRename = [...defaultPick.options].some((o) => o.value === "흄관X");
|
||||
|
||||
// 배치 — 너비 2 · 겹침 · 되돌림
|
||||
put($('[data-sod-place-row="pipe_kind"] [data-sod-width]'), "2");
|
||||
const cell = $('[data-sod-cell="pipe_kind"]');
|
||||
out.width2 = cell.style.gridColumn.replace(/\s+/g, " ");
|
||||
const other = pipe.options.filter((o) => o.enabled !== false)[1].key;
|
||||
put($(`[data-sod-place-row="${other}"] [data-sod-row]`), "1");
|
||||
put($(`[data-sod-place-row="${other}"] [data-sod-col]`), "2");
|
||||
out.overlap = {
|
||||
issue: $("[data-sod-issues]").innerText.includes("배치 겹침"),
|
||||
red: $$(".m02-sod__cell.is-bad").length,
|
||||
saveOff: $("[data-sod-save]").disabled,
|
||||
};
|
||||
put($('[data-sod-place-row="pipe_kind"] [data-sod-width]'), "1");
|
||||
out.overlapGone = !$("[data-sod-issues]").innerText.includes("배치 겹침");
|
||||
|
||||
// 기준 측점 끔
|
||||
tick($("[data-sod-base]"), false);
|
||||
out.baseOff = $$(".m02-sod__cell.is-base").length;
|
||||
|
||||
// 그룹 형상 「길이」 켬 → 뜻 칸 열림
|
||||
const shape = $("[data-sog-shape]");
|
||||
const meanBefore = $(".m02-sog__mean", shape).disabled;
|
||||
tick($("[data-sog-shape-0]", shape), true);
|
||||
out.shape = { before: meanBefore, after: $(".m02-sog__mean", $("[data-sog-shape]")).disabled };
|
||||
|
||||
// 규칙 더함 · 지우기
|
||||
$("[data-sod-rule-add]").click();
|
||||
const rule = await until(() => $("[data-sod-rule]"));
|
||||
out.rule = {
|
||||
rows: $$("[data-sod-rule]").length,
|
||||
bad: rule.classList.contains("is-bad"),
|
||||
top: $("[data-sod-rule-top]", rule).value,
|
||||
target: $("[data-sod-rule-target]", rule).value,
|
||||
};
|
||||
[...$$("button", rule)].find((b) => b.innerText.trim() === "지우기").click();
|
||||
out.ruleGone = $$("[data-sod-rule]").length;
|
||||
|
||||
// 지난 판 목록
|
||||
$("[data-sod-history-toggle]").click();
|
||||
await until(() => $("[data-sod-history]").children.length);
|
||||
out.history = $("[data-sod-history]").innerText.replace(/\s+/g, " ").trim().slice(0, 80);
|
||||
|
||||
const after = await (
|
||||
await fetch("/api/m02/structure-definition", { credentials: "include" })
|
||||
).json();
|
||||
out.판그대로 = after.판 === def.판;
|
||||
out.ok =
|
||||
out.families[0] === out.families[1] &&
|
||||
out.kinds === 37 &&
|
||||
out.pipe.options[0] === out.pipe.options[1] &&
|
||||
out.pipe.cells[0] === out.pipe.cells[1] &&
|
||||
out.pipe.base &&
|
||||
out.rename.left &&
|
||||
out.rename.save &&
|
||||
out.choices.length === 3 &&
|
||||
out.choiceRename &&
|
||||
out.width2.includes("span 2") &&
|
||||
out.overlap.issue &&
|
||||
out.overlap.saveOff &&
|
||||
out.overlapGone &&
|
||||
out.baseOff === 0 &&
|
||||
out.shape.before === true &&
|
||||
out.shape.after === false &&
|
||||
out.rule.rows === 1 &&
|
||||
!out.rule.bad &&
|
||||
out.ruleGone === 0 &&
|
||||
out.history.length > 0 &&
|
||||
out.판그대로;
|
||||
return JSON.stringify(out);
|
||||
})();
|
||||
@@ -0,0 +1,168 @@
|
||||
/* 48-3 · 48-4 — B05 · B06 좌측 「구조물 배치」 칸 = 구조물 정의대로 그림
|
||||
* 시작 화면: B05 종단설계(#/b05-profile) · 구조물 목록이 있는 프로젝트 · 폼 비어 있음(아무것도 안 고름)
|
||||
* 부르는 법: orca eval --expression "$(cat 이 파일)" (async 식 · JSON 글을 돌려줌 · 60초 안)
|
||||
* 쓰는 것: 없음 — 목록 항목은 고르고 다시 눌러 풀기만 · 정의 사본은 **메모리 안 종류 객체만** 고침
|
||||
* (파일 · 서버 쓰기 없음) · 끝나면 화면을 다시 읽어(reload) 사본을 버림
|
||||
* 기대 수치:
|
||||
* - 판 1 전후 대조(목록 항목마다): 칸 키 차례 = 정의 옵션 차례(B05 몫 · 보임만) · 격자 = 2열 기본 ·
|
||||
* 규칙 가림 0 · 숨긴 보기 0 · 「지난 정의 값」 상자 숨김 · 기준 측점 보임 · 선택지 밖 옛 값 0 → sameAll = true
|
||||
* - 정의 사본(레지스트리 종류): 이름 바꿈 · 순서(배치 줄) · 안 씀 · 선택지 줄임 · 규칙(보이기 · 선택지 · 기본값)이
|
||||
* 칸에 그대로 → copy.* = true
|
||||
* - 정의 사본(배수관 · 손코딩 시설 폼): 규칙 선택지 · 기본값 · 가림 → pipe.* = true
|
||||
*/
|
||||
(async () => {
|
||||
const w = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const sec = document.querySelector(".b05-structure-section");
|
||||
if (!sec) return JSON.stringify({ error: "구조물 배치 칸 없음" });
|
||||
const url = performance
|
||||
.getEntriesByType("resource")
|
||||
.map((e) => e.name)
|
||||
.filter((n) => n.includes("/B05_Profile/B05_Profile_Api_Structures.ts"))
|
||||
.pop();
|
||||
const api = await import(url);
|
||||
const types = await api.fetchStructureTypes(true);
|
||||
const byId = new Map(types.map((t) => [t.type_id, t]));
|
||||
const [groupSel, typeSel] = sec.querySelectorAll(".b05-structure__grid select");
|
||||
const optionRow = [...sec.querySelectorAll(".b05-structure__grid")][1];
|
||||
const vis = (n) => n && n.offsetParent !== null;
|
||||
const facility = sec.querySelector(".b05-drainage__facility");
|
||||
const stale = sec.querySelector(".b05-structure__stale");
|
||||
const capture = () => {
|
||||
const typeId = typeSel.value;
|
||||
const t = byId.get(typeId);
|
||||
const keys = [...optionRow.querySelectorAll("[data-option-keys]")]
|
||||
.filter((n) => !n.classList.contains("is-rule-hidden"))
|
||||
.map((n) => n.dataset.optionKeys);
|
||||
const groupKeys = [...sec.querySelectorAll(".b05-structure__optgroup [data-option-keys]")].map(
|
||||
(n) => n.dataset.optionKeys,
|
||||
);
|
||||
const want = t.managed_by
|
||||
? []
|
||||
: t.options
|
||||
.filter((o) => o.phase !== "detail" && o.visible !== false && o.enabled !== false)
|
||||
.map((o) => o.key)
|
||||
.filter((k) => !groupKeys.includes(k));
|
||||
return {
|
||||
typeId,
|
||||
keysSame: JSON.stringify(keys) === JSON.stringify(want),
|
||||
grid: optionRow.style.gridTemplateColumns === "",
|
||||
ruleHidden: sec.querySelectorAll(".is-rule-hidden").length,
|
||||
hiddenOptions: sec.querySelectorAll("option[hidden]").length,
|
||||
stale: !stale.hidden,
|
||||
anchor: !sec.querySelectorAll(".b05-structure__position-row > label")[2].hidden,
|
||||
facility: !!t.managed_by && vis(facility),
|
||||
// 같은 값 — 정의 선택지 밖 옛 값을 남겨 보인 칸 수(판 1 이면 0 = 옛 화면과 같은 값)
|
||||
keptOld: [...optionRow.querySelectorAll("[data-option-keys] select")].filter((s) => {
|
||||
const o = t.options.find(
|
||||
(x) => x.key === s.closest("[data-option-keys]").dataset.optionKeys,
|
||||
);
|
||||
return s.value && o && !o.choices.includes(s.value);
|
||||
}).length,
|
||||
};
|
||||
};
|
||||
const out = { items: [], copy: {}, pipe: {} };
|
||||
// ── 판 1 전후 대조 — 목록 항목마다 고르고 · 재고 · 다시 눌러 풂 ──
|
||||
const items = [...document.querySelectorAll(".b05-route__irregular-list li")];
|
||||
for (const li of items) {
|
||||
(li.querySelector("button") ?? li).click();
|
||||
await w(250);
|
||||
if (typeSel.value) out.items.push(capture());
|
||||
(li.querySelector("button") ?? li).click(); // 재선택 = 해제
|
||||
await w(150);
|
||||
}
|
||||
out.count = out.items.length;
|
||||
out.sameAll = out.items.every(
|
||||
(r) =>
|
||||
r.keysSame &&
|
||||
r.grid &&
|
||||
!r.ruleHidden &&
|
||||
!r.hiddenOptions &&
|
||||
!r.stale &&
|
||||
r.anchor &&
|
||||
!r.keptOld,
|
||||
);
|
||||
out.bad = out.items.filter(
|
||||
(r) => !(r.keysSame && r.grid && !r.ruleHidden && !r.hiddenOptions && !r.stale && r.anchor),
|
||||
);
|
||||
// ── 정의 사본 — 레지스트리 종류(고르기 칸 둘 이상) · 새 폼에서만(저장 없음) ──
|
||||
const pickType = (t) => {
|
||||
groupSel.value = t.group;
|
||||
groupSel.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
typeSel.value = t.type_id;
|
||||
typeSel.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
};
|
||||
// 흙막이 — 고르기 칸 하나(설치 측) + 숫자 칸 여럿
|
||||
const reg = byId.get("soil_guard");
|
||||
const opt = (k) => reg.options.find((o) => o.key === k);
|
||||
const side = opt("side");
|
||||
opt("tiers").label = "바꾼 이름";
|
||||
opt("shift_m").enabled = false;
|
||||
side.choices = side.choices.slice(0, 2);
|
||||
reg.spots = { tiers: { 줄: 1, 칸: 1, 너비: 2 } };
|
||||
reg.rules = [
|
||||
{
|
||||
종류: "soil_guard",
|
||||
만약: { 옵션: "side", 값들: [side.choices[0]] },
|
||||
그러면: { 옵션: "tiers", 기본값: 3 },
|
||||
},
|
||||
{
|
||||
종류: "soil_guard",
|
||||
만약: { 옵션: "side", 값들: [side.choices[1]] },
|
||||
그러면: { 옵션: "lift_m", 보이기: false },
|
||||
},
|
||||
];
|
||||
pickType(reg);
|
||||
await w(250);
|
||||
const wrapOf = (k) => sec.querySelector(`[data-option-keys="${k}"]`);
|
||||
const first = optionRow.querySelector("[data-option-keys]");
|
||||
out.copy.type = reg.type_id;
|
||||
out.copy.rename = wrapOf("tiers")?.querySelector("span")?.textContent.startsWith("바꾼 이름");
|
||||
out.copy.order = first?.dataset.optionKeys === "tiers" && first.style.gridColumn === "1 / span 2";
|
||||
out.copy.unused = !wrapOf("shift_m");
|
||||
const c1 = wrapOf("side").querySelector("select");
|
||||
out.copy.choices =
|
||||
[...c1.children]
|
||||
.slice(1)
|
||||
.map((o) => o.value)
|
||||
.join(",") === side.choices.join(",");
|
||||
c1.value = side.choices[0];
|
||||
c1.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
await w(100);
|
||||
out.copy.ruleDefault = wrapOf("tiers").querySelector("input").value === "3";
|
||||
c1.value = side.choices[1];
|
||||
c1.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
await w(100);
|
||||
out.copy.ruleHide = wrapOf("lift_m").classList.contains("is-rule-hidden");
|
||||
// ── 정의 사본 — 배수관(손코딩 시설 폼 · 동작 계곡통과시설) ──
|
||||
const pipe = byId.get("pipe");
|
||||
pipe.rules = [
|
||||
{
|
||||
종류: "pipe",
|
||||
만약: { 옵션: "pipe_kind", 값들: ["흄관"] },
|
||||
그러면: { 옵션: "pipe_diameter_mm", 선택지: ["800", "1000"], 기본값: "800" },
|
||||
},
|
||||
{
|
||||
종류: "pipe",
|
||||
만약: { 옵션: "pipe_kind", 값들: ["VR관"] },
|
||||
그러면: { 옵션: "wing_wall_type", 보이기: false },
|
||||
},
|
||||
];
|
||||
pickType(pipe);
|
||||
await w(300);
|
||||
const pk = sec.querySelector('[data-option-keys="pipe_kind"] select');
|
||||
const pd = () => sec.querySelector('[data-option-keys="pipe_diameter_mm"] select');
|
||||
pk.value = "흄관";
|
||||
pk.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
await w(150);
|
||||
out.pipe.choices = [...pd().options].filter((o) => o.value && !o.hidden).map((o) => o.value);
|
||||
out.pipe.defaultOk = pd().value === "800";
|
||||
pk.value = "VR관";
|
||||
pk.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
await w(150);
|
||||
out.pipe.wingHidden = sec
|
||||
.querySelector('[data-option-keys="wing_wall_type"]')
|
||||
.classList.contains("is-rule-hidden");
|
||||
out.pipe.temp =
|
||||
document.querySelectorAll(".b05-route__irregular-list li").length === items.length;
|
||||
return JSON.stringify(out);
|
||||
})();
|
||||
@@ -0,0 +1,194 @@
|
||||
/* 49 — 오른쪽 접는 패널 = 좌측 패널과 같은 손잡이 양식 · 패널 몸만 따로 스크롤 · M01 원문 표 칸 수 = M02
|
||||
* 시작 화면: M01 마스터 데이터(#/m01-master-data) · 시스템 관리자 · 나무 item1(구조물 조합)이 M02 마스터 층에 있음
|
||||
* 부르는 법: orca eval --expression "$(cat 이 파일)" (async 식 · JSON 글을 돌려줌) · 저장 안 함 · 끝에 열린 패널 되돌림
|
||||
* 하는 일: M01 로직 GC000198 상세(흐름도 · 원문) → M02 일위대가 상세(원문 · 같은 GC000198 줄) → M02 면적산출 상세(일위대가 · 도면)
|
||||
* 기대 수치(화면마다):
|
||||
* - handleClass = 좌측 패널 손잡이와 같은 클래스(ui-workflow-overlay__handle ui-workflow-overlay__handle--side is-labeled)
|
||||
* - handles = 패널 수(M01 2 · 일위대가 1 · 면적 2) · 손잡이 x < 패널 x(왼쪽 가장자리 밖)
|
||||
* - steps[i].open = 누른 패널 하나만 · width 모두 같음(폭 공유) · none.open = [] · none.rootW = 0(손잡이만 남음)
|
||||
* - sticky.tops = [0 · 250 · 400 굴렸을 때 패널 top] — 머리띠(head) 아래 8 에 닿으면 그 자리에 멈춤(250 = 400) · 본문이 짧으면 덜 굴러감(scrolled) · inView = 패널 위아래 끝이 창 안
|
||||
* - ownScroll: 패널 몸을 굴리면 몸 scrollTop 만 바뀌고 본문 스크롤 그대로(mainMoved 0)
|
||||
* - m01Cells = m02Cells(같은 로직 GC000198 원문 표 칸 수) · 표 둘(7칸 · 2칸)
|
||||
* - m01Look = m02Look(sameLook true) — 같은 클래스 · 글자 크기(16px) · 표 칸 꼴 · tableFit 모두 true(표 높이 = 내용 · 자체 세로 스크롤 없음)
|
||||
*/
|
||||
(async () => {
|
||||
const W = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const $ = (s, root = document) => root.querySelector(s);
|
||||
const $$ = (s, root = document) => [...root.querySelectorAll(s)];
|
||||
const until = async (fn, ms = 10000) => {
|
||||
for (let t = 0; t < ms; t += 150) {
|
||||
const v = fn();
|
||||
if (v) return v;
|
||||
await W(150);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const shown = (n) => !!n && n.getBoundingClientRect().height > 0;
|
||||
const LEFT = "ui-workflow-overlay__handle ui-workflow-overlay__handle--side is-labeled";
|
||||
|
||||
/** 한 패널 묶음 — 손잡이 꼴 · 하나만 열림 · 폭 공유 · 모두 접힘 · sticky · 몸 스크롤 */
|
||||
const check = async (sp, scrollId) => {
|
||||
const handles = $$(".ui-side-panels__handles > button", sp);
|
||||
const ids = handles.map((h) => h.dataset.panel);
|
||||
const was =
|
||||
ids.find((id) => shown($(`.ui-side-panels__panel[data-panel="${id}"]`, sp))) ?? null;
|
||||
const box = (id) => $(`.ui-side-panels__panel[data-panel="${id}"]`, sp);
|
||||
const state = () => ({
|
||||
open: ids.filter((id) => shown(box(id))),
|
||||
expanded: handles.map((h) => h.getAttribute("aria-expanded")),
|
||||
});
|
||||
const out = {
|
||||
handles: handles.length,
|
||||
handleClass: handles.every((h) => h.className === LEFT),
|
||||
labels: handles.map((h) => h.innerText.replace(/\s/g, "")),
|
||||
steps: [],
|
||||
};
|
||||
for (const h of handles) {
|
||||
if (h.getAttribute("aria-expanded") !== "true") h.click();
|
||||
await W(300);
|
||||
const b = box(h.dataset.panel);
|
||||
out.steps.push({
|
||||
...state(),
|
||||
width: Math.round(b.getBoundingClientRect().width),
|
||||
handleOutside: Math.round(b.getBoundingClientRect().left - h.getBoundingClientRect().right),
|
||||
});
|
||||
}
|
||||
// sticky · 몸 스크롤 — 고른 패널을 펴고 봄
|
||||
const pick = handles.find((h) => h.dataset.panel === scrollId);
|
||||
if (pick.getAttribute("aria-expanded") !== "true") pick.click();
|
||||
await W(400);
|
||||
const panel = box(scrollId);
|
||||
const body = $(".ui-side-panels__body", panel);
|
||||
// 본문 스크롤 칸 — 위 칸 중 굴러가는 것 · 없으면 창 · 숨은 탭은 scroll 알림이 안 와 직접 보냄
|
||||
let sc = null;
|
||||
for (let n = sp.parentElement; n && !sc; n = n.parentElement) {
|
||||
const oy = getComputedStyle(n).overflowY;
|
||||
if ((oy === "auto" || oy === "scroll") && n.scrollHeight > n.clientHeight + 1) sc = n;
|
||||
}
|
||||
const roll = async (y) => {
|
||||
if (sc) sc.scrollTop = y;
|
||||
else window.scrollTo(0, y);
|
||||
(sc ?? window).dispatchEvent(new Event("scroll"));
|
||||
await W(200);
|
||||
};
|
||||
const pos = () => (sc ? sc.scrollTop : window.scrollY);
|
||||
await roll(0);
|
||||
const t0 = panel.getBoundingClientRect().top;
|
||||
await roll(250);
|
||||
const t1 = panel.getBoundingClientRect().top;
|
||||
await roll(400);
|
||||
const r1 = panel.getBoundingClientRect();
|
||||
const head = Math.round($(".app-header").getBoundingClientRect().bottom);
|
||||
out.sticky = {
|
||||
scroller: sc ? sc.className.split(" ")[0] : "window",
|
||||
scrolled: Math.round(pos()),
|
||||
tops: [t0, t1, r1.top].map(Math.round),
|
||||
head,
|
||||
};
|
||||
out.inView = Math.round(r1.bottom) <= innerHeight && Math.round(r1.top) >= 0;
|
||||
const y = pos();
|
||||
body.scrollTop = 0;
|
||||
body.scrollTop = 300;
|
||||
body.dispatchEvent(new Event("scroll"));
|
||||
await W(150);
|
||||
out.ownScroll = {
|
||||
bodyOverflow: body.scrollHeight > body.clientHeight,
|
||||
bodyTop: Math.round(body.scrollTop),
|
||||
mainMoved: Math.round(pos() - y),
|
||||
};
|
||||
body.scrollTop = 0;
|
||||
await roll(0);
|
||||
// 모두 접힘
|
||||
$$(".ui-side-panels__handles > button[aria-expanded=true]", sp).forEach((h) => h.click());
|
||||
await W(300);
|
||||
out.none = { ...state(), rootW: Math.round(sp.getBoundingClientRect().width) };
|
||||
// 되돌림
|
||||
if (was) handles.find((h) => h.dataset.panel === was).click();
|
||||
await W(300);
|
||||
out.restored = (ids.find((id) => shown(box(id))) ?? null) === was;
|
||||
return out;
|
||||
};
|
||||
const cells = (root) =>
|
||||
$$(".m01lab-src__table", root).map((t) => [...t.rows].map((r) => r.cells.length).join(","));
|
||||
/** 원문 꼴 — 클래스 · 글자 크기 · 표 칸 꼴 · 표 감싸개 높이 = 내용(자체 세로 스크롤 없음) */
|
||||
const look = (root) => {
|
||||
const src = $(".m01lab-src", root);
|
||||
const fs = (sel) => (sel ? getComputedStyle(sel).fontSize : null);
|
||||
const td = $(".m01lab-src__table td", src);
|
||||
return {
|
||||
classes: [...new Set($$("*", src).map((n) => n.className.toString()))].sort().join(" "),
|
||||
font: [fs(src), fs($(".m01lab-src__p", src)), fs($(".m01lab-src__head", src)), fs(td)],
|
||||
td: td ? ["padding", "whiteSpace", "borderBottom"].map((k) => getComputedStyle(td)[k]) : null,
|
||||
tableFit: $$(".m01lab-src__table", src).map((t) => {
|
||||
const box = t.parentElement;
|
||||
return box.scrollHeight <= box.clientHeight;
|
||||
}),
|
||||
};
|
||||
};
|
||||
const result = {};
|
||||
|
||||
// ① M01 로직 상세 — 흐름도 · 원문
|
||||
sessionStorage.setItem("m01.logic.pending", "GC000198");
|
||||
const tab = $$(".ui-collapsible__title").find((h) => h.innerText.trim() === "단가산출 로직");
|
||||
if (!tab) return JSON.stringify({ error: "M01 화면 아님" });
|
||||
if (!tab.closest(".ui-collapsible").classList.contains("is-collapsed")) tab.click();
|
||||
tab.click();
|
||||
const m01 = await until(() => $(".m01lab-side .m02-dfp") && $(".m01lab-side > .ui-side-panels"));
|
||||
if (!m01) return JSON.stringify({ error: "M01 로직 못 엶" });
|
||||
await W(600);
|
||||
result.m01 = await check(m01, "source");
|
||||
const srcHandle = $('.ui-side-panels__handles > button[data-panel="source"]', m01);
|
||||
const m01Was = srcHandle.getAttribute("aria-expanded");
|
||||
if (m01Was !== "true") srcHandle.click();
|
||||
await until(() => $(".m01lab-side .m01lab-src__table"));
|
||||
result.m01Cells = cells($(".m01lab-side"));
|
||||
result.m01Look = look($(".m01lab-side"));
|
||||
if (m01Was !== "true") srcHandle.click();
|
||||
|
||||
// ② M02 일위대가 상세 — 원문(같은 로직 줄)
|
||||
location.hash = "#/m02-master-template?open=구조물조합&root=master";
|
||||
const node = await until(() => $('[data-node="i|item1"]'));
|
||||
if (!node) return JSON.stringify({ ...result, error: "item1 없음" });
|
||||
node.click();
|
||||
const card = await until(() =>
|
||||
$$(".m02-struct__card").find((c) => $("table.m02-labrow tbody tr[data-labor-logic]", c)),
|
||||
);
|
||||
$$(".m02-struct__card-tools button", card)
|
||||
.find((b) => b.innerText.trim() === "수정")
|
||||
?.click();
|
||||
const row = await until(() =>
|
||||
$(".ui-side-panels-row .m02-labor__edit")?.closest(".ui-side-panels-row"),
|
||||
);
|
||||
if (!row) return JSON.stringify({ ...result, error: "일위대가 상세 없음" });
|
||||
const labor = $(":scope > .ui-side-panels", row);
|
||||
const lh = $(".ui-side-panels__handles > button", labor);
|
||||
const laborWas = lh.getAttribute("aria-expanded");
|
||||
if (laborWas !== "true") lh.click();
|
||||
const same = await until(() => $('tbody tr[data-labor-logic="GC000198"] .m02-labrow__name', row));
|
||||
same?.click();
|
||||
await until(() => $("[data-labor-source] .m01lab-src__table", row));
|
||||
result.m02Cells = cells(row);
|
||||
result.m02Look = look(row);
|
||||
if (laborWas !== "true") lh.click();
|
||||
await W(200);
|
||||
result.labor = await check(labor, "source");
|
||||
|
||||
// ③ M02 면적산출 상세 — 일위대가 · 도면
|
||||
node.click();
|
||||
const vis = (b) => b && !b.hidden && b.offsetParent !== null;
|
||||
const edits = () =>
|
||||
$$(".m02-struct button").filter((b) => vis(b) && b.textContent.trim() === "수정");
|
||||
await until(() => edits().length === 3);
|
||||
(edits().find((b) => /면적/.test(b.title)) ?? edits()[2])?.click();
|
||||
const area = await until(() =>
|
||||
$$(".ui-side-panels").find((s) => shown(s) && $('[data-panel="drawing"]', s)),
|
||||
);
|
||||
if (!area) return JSON.stringify({ ...result, error: "면적산출 상세 없음" });
|
||||
await W(600);
|
||||
result.area = await check(area, "labor");
|
||||
node.click();
|
||||
await W(800);
|
||||
result.sameCells = JSON.stringify(result.m01Cells) === JSON.stringify(result.m02Cells);
|
||||
result.sameLook = JSON.stringify(result.m01Look) === JSON.stringify(result.m02Look);
|
||||
return JSON.stringify(result);
|
||||
})();
|
||||
@@ -1,4 +1,5 @@
|
||||
/* 본문 오른쪽 접는 패널 여럿 — 접힌 패널 = 세로 글 띠 · 편 패널 = 공유 폭(--ui-side-panels-width) */
|
||||
/* 본문 오른쪽 접는 패널 여럿 — 왼쪽 가장자리 손잡이(좌측 패널과 같은 꼴) · 편 패널 = 공유 폭(--ui-side-panels-width)
|
||||
보이는 칸 안에 붙음(JS followView) — 몸만 따로 스크롤 · 메인 스크롤과 따로 */
|
||||
|
||||
/* 부르는 쪽 칸 — [본문, 패널 묶음] */
|
||||
.ui-side-panels-row {
|
||||
@@ -17,40 +18,45 @@
|
||||
}
|
||||
|
||||
.ui-side-panels {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: stretch;
|
||||
align-self: flex-start;
|
||||
/* 높이 · 붙는 자리 = JS(followView) · 처음 한 번 그릴 때까지 */
|
||||
height: calc(100vh - 80px);
|
||||
/* 손잡이 자리(24) + 틈 */
|
||||
margin-left: calc(var(--spacing-24) + var(--spacing-8));
|
||||
}
|
||||
|
||||
/* 모두 접힘 — 폭 0 · 손잡이만 왼쪽 밖에 남음 */
|
||||
.ui-side-panels.is-collapsed {
|
||||
width: 0;
|
||||
}
|
||||
|
||||
/* 손잡이 줄 — 패널 왼쪽 밖 세로 가운데 · 패널 둘이면 위아래로 */
|
||||
.ui-side-panels__handles {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: 50%;
|
||||
left: calc(-1 * var(--spacing-24));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
margin-left: var(--spacing-8);
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.ui-side-panels__handles > .ui-workflow-overlay__handle--side {
|
||||
position: static;
|
||||
transform: none;
|
||||
border-right: 0;
|
||||
border-left: 1px solid var(--panel-handle-border, var(--color-border));
|
||||
border-radius: var(--radius-buttons) 0 0 var(--radius-buttons);
|
||||
}
|
||||
|
||||
.ui-side-panels.is-resizing {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.ui-side-panels__strip {
|
||||
flex: none;
|
||||
width: 32px;
|
||||
padding: var(--spacing-8) 0;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-secondary);
|
||||
font: inherit;
|
||||
font-size: var(--text-body-sm);
|
||||
font-weight: 600;
|
||||
writing-mode: vertical-rl;
|
||||
text-orientation: mixed;
|
||||
text-align: start;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ui-side-panels__strip:hover,
|
||||
.ui-side-panels__strip:focus-visible {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.ui-side-panels__panel {
|
||||
position: relative;
|
||||
display: flex;
|
||||
@@ -58,12 +64,12 @@
|
||||
flex-direction: column;
|
||||
width: var(--ui-side-panels-width, 420px);
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.ui-side-panels__strip[hidden],
|
||||
.ui-side-panels__panel[hidden] {
|
||||
display: none;
|
||||
}
|
||||
@@ -101,22 +107,6 @@
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.ui-side-panels__fold {
|
||||
flex: none;
|
||||
padding: 0 var(--spacing-4);
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
color: var(--color-text-secondary);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ui-side-panels__fold:hover,
|
||||
.ui-side-panels__fold:focus-visible {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.ui-side-panels__body {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
/* =============================================================================
|
||||
* ui_template_side_panels.ts
|
||||
* 본문 오른쪽 접는 패널 여럿 — 나란히 · 하나만 열림 · 모두 접힘 됨 · 폭 공유
|
||||
* 접힌 패널 = 제목 세로 글 띠(누르면 폄) · 편 패널 = 머리(제목 · 접기 ▸) + 몸 · 왼쪽 경계 끌기(화살표 키)
|
||||
* 좌측 패널과 같은 접기 양식 — 패널 왼쪽 가장자리 손잡이(공용 createWorkflowPanelHandle · 오른쪽으로 접힘)
|
||||
* 패널마다 손잡이 하나(이름표 세로) · 누르면 폄/접음 · 모두 접히면 손잡이만 남음
|
||||
* 편 패널 = 머리(제목) + 몸 · 왼쪽 경계 끌기(화살표 키) · 보이는 칸 안에 붙어 몸만 따로 스크롤(followView)
|
||||
* 열린 패널 · 폭 = 부르는 쪽 취향 키(`aislo:pref:{prefKey}`)
|
||||
* 부르기: const sp = createSidePanels({ prefKey, panels: [{ id, title, body }] }) →
|
||||
* 본문과 함께 `.ui-side-panels-row` 칸에 [본문, sp.root] 로 넣음
|
||||
@@ -10,11 +12,10 @@
|
||||
import { readByKey, writeByKey } from "../A00_Common/b_page_state";
|
||||
import { el } from "./ui_template_elements";
|
||||
import { currentLanguageIndex } from "./ui_template_locale";
|
||||
import { createWorkflowPanelHandle } from "./ui_template_overlay";
|
||||
import "./ui_template_side_panels.css";
|
||||
|
||||
const TEXT = {
|
||||
Open: ["{v} 펴기", "Open {v}"],
|
||||
Close: ["{v} 접기", "Collapse {v}"],
|
||||
Grip: ["패널 너비 — 끌거나 화살표 키", "Panel width — drag or arrow keys"],
|
||||
} as const;
|
||||
|
||||
@@ -89,29 +90,15 @@ export function createSidePanels(opts: SidePanelsOpts): SidePanels {
|
||||
if (keep) save();
|
||||
};
|
||||
|
||||
const handles = el("div", { className: "ui-side-panels__handles" });
|
||||
root.append(handles);
|
||||
const views = opts.panels.map((p) => {
|
||||
const bodyId = `ui-side-panel-${opts.prefKey}-${p.id}`;
|
||||
const strip = el("button", {
|
||||
className: "ui-side-panels__strip",
|
||||
text: p.title,
|
||||
attrs: { type: "button", "aria-expanded": "false", "aria-controls": bodyId },
|
||||
});
|
||||
strip.title = ts("Open", p.title);
|
||||
strip.addEventListener("click", () => {
|
||||
open(p.id);
|
||||
fold.focus();
|
||||
});
|
||||
const fold = el("button", {
|
||||
className: "ui-side-panels__fold",
|
||||
text: "▸",
|
||||
attrs: { type: "button", "aria-expanded": "true", "aria-controls": bodyId },
|
||||
});
|
||||
fold.title = ts("Close", p.title);
|
||||
fold.setAttribute("aria-label", fold.title);
|
||||
fold.addEventListener("click", () => {
|
||||
open(null);
|
||||
strip.focus();
|
||||
});
|
||||
const handle = createWorkflowPanelHandle("side", "right", p.title);
|
||||
handle.root.setAttribute("aria-controls", bodyId);
|
||||
handle.root.dataset.panel = p.id;
|
||||
handle.root.addEventListener("click", () => open(now === p.id ? null : p.id));
|
||||
handles.append(handle.root);
|
||||
const grip = el("div", {
|
||||
className: "ui-side-panels__grip",
|
||||
attrs: {
|
||||
@@ -128,7 +115,7 @@ export function createSidePanels(opts: SidePanelsOpts): SidePanels {
|
||||
grip,
|
||||
el("header", {
|
||||
className: "ui-side-panels__head",
|
||||
children: [el("strong", { className: "ui-side-panels__title", text: p.title }), fold],
|
||||
children: [el("strong", { className: "ui-side-panels__title", text: p.title })],
|
||||
}),
|
||||
el("div", { className: "ui-side-panels__body", attrs: { id: bodyId }, children: [p.body] }),
|
||||
],
|
||||
@@ -156,16 +143,17 @@ export function createSidePanels(opts: SidePanelsOpts): SidePanels {
|
||||
ev.preventDefault();
|
||||
setWidth(width + d, true);
|
||||
});
|
||||
root.append(strip, box);
|
||||
return { id: p.id, strip, box };
|
||||
root.append(box);
|
||||
return { id: p.id, handle, box };
|
||||
});
|
||||
|
||||
const draw = (): void => {
|
||||
for (const v of views) {
|
||||
const on = v.id === now;
|
||||
v.strip.hidden = on;
|
||||
v.handle.setOpen(on);
|
||||
v.box.hidden = !on;
|
||||
}
|
||||
root.classList.toggle("is-collapsed", now === null);
|
||||
};
|
||||
|
||||
function open(id: string | null): void {
|
||||
@@ -178,5 +166,45 @@ export function createSidePanels(opts: SidePanelsOpts): SidePanels {
|
||||
}
|
||||
|
||||
draw();
|
||||
followView(root);
|
||||
return { root, open, current: () => now, width: () => width };
|
||||
}
|
||||
|
||||
/** 가장 가까운 스크롤 칸(없으면 창) */
|
||||
function scroller(node: HTMLElement): HTMLElement | null {
|
||||
for (let n = node.parentElement; n; n = n.parentElement) {
|
||||
const y = getComputedStyle(n).overflowY;
|
||||
if ((y === "auto" || y === "scroll") && n.scrollHeight > n.clientHeight + 1) return n;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 패널 묶음을 보이는 칸 안에 붙임 — 높이 = 보이는 칸 · 본문을 굴려도 그 자리(몸만 따로 스크롤)
|
||||
* CSS sticky 는 위 칸의 overflow 가 막아 JS 로 — 어느 칸이 굴러도(capture) 다시 잼
|
||||
*/
|
||||
function followView(node: HTMLElement): void {
|
||||
const ref = new WeakRef(node); // 떼었다 다시 붙이는 화면(M01)도 있어 떼어져도 듣기는 남김 · 버려지면 그침
|
||||
const place = (): void => {
|
||||
const root = ref.deref();
|
||||
if (!root) return stop();
|
||||
if (!root.isConnected) return;
|
||||
const box = scroller(root)?.getBoundingClientRect();
|
||||
const head = document.querySelector(".app-header")?.getBoundingClientRect().bottom ?? 0;
|
||||
const top = Math.max(head, box?.top ?? 0) + 8;
|
||||
const bottom = Math.min(window.innerHeight, box?.bottom ?? window.innerHeight) - 8;
|
||||
const row = root.parentElement?.getBoundingClientRect();
|
||||
if (!row) return;
|
||||
const height = Math.max(240, bottom - top);
|
||||
root.style.height = `${height}px`;
|
||||
const shift = Math.min(Math.max(0, top - row.top), Math.max(0, row.height - height));
|
||||
root.style.transform = shift ? `translateY(${Math.round(shift)}px)` : "";
|
||||
};
|
||||
const stop = (): void => {
|
||||
window.removeEventListener("scroll", place, true);
|
||||
window.removeEventListener("resize", place);
|
||||
};
|
||||
window.addEventListener("scroll", place, true);
|
||||
window.addEventListener("resize", place);
|
||||
setTimeout(place); // 붙은 뒤 한 번
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user