diff --git a/A00_Common/design/design_dock.css b/A00_Common/design/design_dock.css index dcfcf207f..e69de29bb 100644 --- a/A00_Common/design/design_dock.css +++ b/A00_Common/design/design_dock.css @@ -1,7 +0,0 @@ -/* 좌측 패널 아래 고정 영역의 단추 넷([초기화][저장][확정][보기 전환]) — 좁은 패널에서도 글자가 - 두 줄로 꺾이지 않게 안쪽 여백을 줄이고 줄바꿈을 막는다(계획서 24-2b). */ -.b05-route__dock .b05-route__actions .ui-btn, -.b05-route__dock .b06-profile__actions .ui-btn { - padding-inline: var(--spacing-8); - white-space: nowrap; -} diff --git a/A00_Common/design/design_structure_form.ts b/A00_Common/design/design_structure_form.ts index 4adf4a6c4..875291406 100644 --- a/A00_Common/design/design_structure_form.ts +++ b/A00_Common/design/design_structure_form.ts @@ -59,6 +59,14 @@ export interface SpecFormHooks { * [추가] · [빼기] 없음 · 줄면 끝에서부터 가림(값은 남음 · 54-10) · null = 모름(저장된 것 그대로) · * undefined = 손 [추가]. */ count?: (c: SpecContainer) => number | null | undefined; + /** 그림이 쓰는 값(54-13) — 고르기 칸이 비었거나 자동 규칙일 때 그 값 · 꼬리(「기본」 · 「자동」) · + * null = 없음. 보여 주기만(값 · 저장은 사용자가 고를 때만). `values` = 보이는 값(위 칸 반영). */ + shown?: ( + c: SpecContainer, + item: SpecItem, + values: Record, + index: number, + ) => { value: string; tag: string } | null; } export interface SpecForm { @@ -105,18 +113,27 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = { return slot; }; - const control = (c: SpecContainer, item: SpecItem, values: Instance): HTMLElement => { + const control = ( + c: SpecContainer, + item: SpecItem, + values: Instance, + tag?: string, + ): HTMLElement => { if (item.kind === "select") { const select = document.createElement("select"); const choices = choicesOf(item, values); + const now = values[item.id] ?? ""; + const drawn = choices.find((ch) => ch.value === now); + // 그림이 쓰는 값은 빈 보기 자리에 꼬리를 달아 보임 — 고른 값과 구별(54-13) + const name = drawn ? choiceLabel(c, item, drawn, names) : now; + const blank = !tag ? "— 안 정함 —" : name ? `${name} (${tag})` : tag; select.replaceChildren( - new Option("— 안 정함 —", ""), + new Option(blank, ""), ...choices.map((ch) => new Option(choiceLabel(c, item, ch, names), ch.value)), ); - const now = values[item.id] ?? ""; // 선택지 밖 옛 값은 그 값만 남겨 보임(지운 값을 몰래 바꾸지 않음) - if (now && !choices.some((ch) => ch.value === now)) select.add(new Option(now, now)); - select.value = now; + if (!tag && now && !drawn) select.add(new Option(now, now)); + select.value = tag ? "" : now; return select; } if (item.kind === "text") { @@ -139,7 +156,19 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = { return stepper(input, item.step ?? 0.1); }; - const onEdit = (c: SpecContainer, item: SpecItem, values: Instance, raw: string): void => { + const onEdit = ( + c: SpecContainer, + item: SpecItem, + values: Instance, + raw: string, + view: Instance, + ): void => { + // 그림 값을 보이던 위 칸 아래를 고침 = 그 위 값을 고른 것 — 다른 갈래 값은 비움(54-13) + const parent = item.by?.item; + if (parent && view[parent] !== values[parent]) { + values[parent] = view[parent]; + for (const child of c.items) if (child.by?.item === parent) values[child.id] = ""; + } values[item.id] = raw; if (item.split) { const order = [item.split, ...(recent.get(values) ?? []).filter((r) => r !== item.split)]; @@ -170,15 +199,26 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = { const title = containerTitle(c, names, groupNames[c.link]); const box = group(c.required ? title : `${title} ${index + 1}`); box.root.dataset.specContainer = c.id; + // 보이는 값 = 고른 값 + 그림이 쓰는 값(54-13) · 위 칸이 다른 갈래를 보이면 아래 칸은 빈 채로 + const view: Instance = { ...values }; + const tags = new Map(); + for (const item of c.items) { + if (item.by && view[item.by.item] !== values[item.by.item]) view[item.id] = ""; + const drawn = item.kind === "select" ? hooks.shown?.(c, item, view, index) : null; + if (!drawn) continue; + view[item.id] = drawn.value; + tags.set(item.id, drawn.tag); + } const fields = c.items - .filter((item) => item.kind === "auto" || !item.by || branchOf(item, values)) + .filter((item) => item.kind === "auto" || !item.by || branchOf(item, view)) .map((item) => { - const input = control(c, item, values); + const input = control(c, item, view, tags.get(item.id)); const wrap = labeled(itemLabel(c, item, names), input); wrap.dataset.specItem = `${c.id}.${item.id}`; + if (tags.has(item.id)) wrap.dataset.specShown = tags.get(item.id); const target = input.querySelector?.("input, select") ?? input; target.addEventListener("change", () => - onEdit(c, item, values, (target as HTMLInputElement | HTMLSelectElement).value), + onEdit(c, item, values, (target as HTMLInputElement | HTMLSelectElement).value, view), ); return wrap; }); diff --git a/A00_Common/design/design_structure_pipe_calc.ts b/A00_Common/design/design_structure_pipe_calc.ts index 6e60c27bf..357ab0857 100644 --- a/A00_Common/design/design_structure_pipe_calc.ts +++ b/A00_Common/design/design_structure_pipe_calc.ts @@ -25,6 +25,13 @@ export function setPipeExtraCountSource(source: (section: CrossSection) => numbe extraCountSource = source; } +/** 유입 형식을 대는 곳 — 횡단 보기의 세션 값 우선(`valueFor`) · 없으면 정본 patch(54-13). */ +type InletChoice = NonNullable["inlet_structure"]; +let inletSource: ((section: CrossSection) => InletChoice) | null = null; +export function setPipeInletSource(source: (section: CrossSection) => InletChoice): void { + inletSource = source; +} + /** 그 관의 주인 측점(관 자리에서 가장 가까운 측점) — 횡단 자료가 없으면 null. */ function ownerSection( chainageM: number | null, @@ -64,6 +71,34 @@ export function pipeExtraCount(chainageM: number | null): number | null { return computeStoredLayouts(probe, sections)?.culvert?.extraWalls.length ?? outlet; } +/** 그림이 쓰는 유입 · 유출 값(54-13) — 집수정 모양(없으면 null = 흙막이 벽) · 벽 형태(유입 · 유출 · + * 흙막이 n). 폼 빈 · 자동 칸에 보여 주기만. 횡단 자료가 없으면 null(모름). */ +export function pipeDrawn(chainageM: number | null): { + basin: string | null; + forms: Record; + extras: Array; +} | null { + const found = ownerSection(chainageM); + const design = found?.owner.design; + if (!found || !design) return null; + const { owner, sections } = found; + const outlet = extraCountSource?.(owner) ?? design.extra_wall_counts?.outlet ?? 0; + const counts = { basin: 0, ...design.extra_wall_counts, outlet }; + const inlet = inletSource?.(owner) ?? design.inlet_structure; + const probe = { + ...owner, + design: { ...design, extra_wall_counts: counts, inlet_structure: inlet }, + }; + const layout = computeStoredLayouts(probe, sections)?.culvert; + if (!layout) return null; + const formOf = (role: string) => layout.walls.find((w) => w.role === role)?.form ?? null; + return { + basin: layout.basin?.shape ?? null, + forms: { inlet: formOf("inlet"), outlet: formOf("outlet") }, + extras: layout.extraWalls.map((w) => w.form), + }; +} + /** 흙막이 높이 빈 칸 안내 — 관경 기준 최소 높이(엔진이 빈 칸을 이 값으로 그림). */ export function guardHeightHint(diameterMm: string): string { const d = Number(diameterMm || "1000") / 1000; diff --git a/A00_Common/design/design_view_cross.ts b/A00_Common/design/design_view_cross.ts index 065766020..00144aa80 100644 --- a/A00_Common/design/design_view_cross.ts +++ b/A00_Common/design/design_view_cross.ts @@ -76,6 +76,7 @@ import type { DesignPageHost } from "./design_page"; import { pipeExtraCount, setPipeExtraCountSource, + setPipeInletSource, setPipeSectionsSource, } from "./design_structure_pipe_calc"; import { @@ -296,6 +297,8 @@ export async function mountCrossView( }); // 배관 흙막이 개수 = 유출 단 수(54-1) — 세션 값 우선 setPipeExtraCountSource((section) => stationControls.extraWalls.countFor(section, "outlet")); + // 폼이 보이는 유입 그림 값(54-13) — 세션 형식 우선 + setPipeInletSource((section) => stationControls.inletStructure.valueFor(section)); const sectionView = createSectionView( (chainageM, change) => { @@ -576,6 +579,7 @@ export async function mountCrossView( } // 공유 캐시 — 종단 보기가 이미 받아 뒀으면 같은 객체를 즉시 재사용한다. sectionDetail = await loadSectionDetail(projectId, context.route_id); + structuresPanel.facility.recount(); // 단 수는 횡단 자료로 셈 — 흙막이 상자 · 목록 램프 다시(54-15) // 구조물 초안(저장 안 한 벽)은 저장소가 이미 얹어 준다. 면적만 여기서 다시 얹으면 // 첫 화면의 카드·유토곡선이 초안 기준으로 선다(2026-09-06 실측). if (sectionDetail) { diff --git a/B05_Profile/B05_Profile_Engine_Skeleton.py b/B05_Profile/B05_Profile_Engine_Skeleton.py index 385e31a78..947981955 100644 --- a/B05_Profile/B05_Profile_Engine_Skeleton.py +++ b/B05_Profile/B05_Profile_Engine_Skeleton.py @@ -20,6 +20,7 @@ from B05_Profile.B05_Profile_Engine_Solver import ( _cost_surface_signature, _load_or_build_cost_surface, ) +from common_util.common_util_surface_sampler import check_surface_keys from config.config_system import ( SKELETON_MAIN_RIDGE_ACC_THRESHOLD_CELLS, SKELETON_MAIN_VALLEY_ACC_THRESHOLD_CELLS, @@ -277,6 +278,7 @@ def load_or_build_skeleton( project_root: Path, filter_key: str, method: str, smooth: bool ) -> dict[str, Any]: """스켈레톤을 캐시에서 로드하거나 새로 계산해 저장한다.""" + check_surface_keys(filter_key, method) project_root = Path(project_root) models_dir = project_root / _MODELS_SUBDIR cache_dir = project_root / _ROUTE_CACHE_SUBDIR diff --git a/B05_Profile/B05_Profile_Engine_Solver.py b/B05_Profile/B05_Profile_Engine_Solver.py index 804d9c8de..4058c979b 100644 --- a/B05_Profile/B05_Profile_Engine_Solver.py +++ b/B05_Profile/B05_Profile_Engine_Solver.py @@ -19,6 +19,7 @@ from B05_Profile.B05_Profile_Engine_Geometry import ( resample_polyline_2d, single_segment_dijkstra, ) +from common_util.common_util_surface_sampler import check_surface_keys from config.config_system import ( FOREST_ROAD_MAX_GRADE, FOREST_ROAD_MIN_CURVE_R_M, @@ -45,6 +46,7 @@ _ROUTE_CACHE_SUBDIR = Path("B05_Profile") / "route" def _load_dtm_grid(models_dir: Path, filter_key: str, smooth: bool): """필터의 정규 DTM 격자(x, y, z, valid_mask)를 로드한다.""" + check_surface_keys(filter_key) suffix = "_smooth" if smooth else "" dtm_path = models_dir / f"dtm_{filter_key}{suffix}.npz" if not dtm_path.exists(): @@ -70,6 +72,7 @@ def _sample_surface_on_grid( dtm_z: np.ndarray, ) -> np.ndarray: """확정 지표면 모델의 표고를 DTM 격자에 샘플링한다(실패 시 DTM으로 폴백).""" + check_surface_keys(filter_key, method) if method == "dtm": return dtm_z @@ -145,6 +148,7 @@ def _sample_surface_on_grid( def _source_npz_paths(models_dir: Path, filter_key: str, method: str, smooth: bool) -> list[Path]: """비용면이 의존하는 소스 모델 파일(존재하는 것만, 안정 순서).""" + check_surface_keys(filter_key, method) suffix = "_smooth" if smooth else "" candidates = [ models_dir / f"dtm_{filter_key}{suffix}.npz", @@ -211,6 +215,7 @@ def _load_or_build_cost_surface( project_root: Path, models_dir: Path, filter_key: str, method: str, smooth: bool ): """비용면을 반환한다(서명 일치 시 캐시 재사용, 아니면 재빌드·재캐시).""" + check_surface_keys(filter_key, method) cache_dir = project_root / _ROUTE_CACHE_SUBDIR suffix = "_smooth" if smooth else "" cache_path = cache_dir / f"cost_surface_{filter_key}_{method}{suffix}.npz" diff --git a/B05_Profile/B05_Profile_Schema.py b/B05_Profile/B05_Profile_Schema.py index d2790ec5b..e6407c988 100644 --- a/B05_Profile/B05_Profile_Schema.py +++ b/B05_Profile/B05_Profile_Schema.py @@ -4,6 +4,7 @@ from typing import Any, Literal from pydantic import BaseModel, ConfigDict, Field, model_validator +from common_util.common_util_surface_sampler import SurfaceFilter, SurfaceMethod from config.config_system import ( GRADE_MAIN_DIRECTIONS, GRADE_TERRAIN_TYPES, @@ -63,8 +64,10 @@ class RouteSolveRequest(BaseModel): model_config = ConfigDict(extra="forbid") - filter_key: str = Field(description="지면 필터 키 (grid_min_z/csf/pmf)") - method: str = Field(default="dtm", description="지표면 표현 (dtm/tin/nurbs/implicit/meshfree)") + filter_key: SurfaceFilter = Field(description="지면 필터 키 (grid_min_z/csf/pmf)") + method: SurfaceMethod = Field( + default="dtm", description="지표면 표현 (dtm/tin/nurbs/implicit/meshfree)" + ) smooth: bool = Field(default=False) surface_model_id: int | None = Field(default=None, description="기반 지표면 모델 id") algorithm: str = Field( @@ -269,8 +272,8 @@ class RouteConfirmRequest(BaseModel): model_config = ConfigDict(extra="forbid") - filter_key: str | None = None - method: str | None = None + filter_key: SurfaceFilter | None = None + method: SurfaceMethod | None = None smooth: bool = False surface_model_id: int | None = None irregular_stations: list[IrregularStationInput] = Field(default_factory=list) diff --git a/B05_Profile/B05_Profile_Structures_Schema.py b/B05_Profile/B05_Profile_Structures_Schema.py index e7c8db289..c289c3363 100644 --- a/B05_Profile/B05_Profile_Structures_Schema.py +++ b/B05_Profile/B05_Profile_Structures_Schema.py @@ -164,6 +164,10 @@ def registry_schema_version() -> int: return int(_read_definition(*_stamp()).get("schema_version", 1)) +#: 구조물 id 모양 — 복사본 폴더 이름(`M02_Item_Snapshot.FOLDER_PATTERN`)이 되므로 경로 글자 금지 +STRUCTURE_ID_RULE = r"[0-9A-Za-z][0-9A-Za-z_-]*" + + class StructureInstance(BaseModel): """배치된 구조물 1건. @@ -173,7 +177,7 @@ class StructureInstance(BaseModel): model_config = ConfigDict(extra="forbid") - structure_id: str | None = None + structure_id: str | None = Field(default=None, pattern=rf"^{STRUCTURE_ID_RULE}$", max_length=64) type_id: str placement: Placement chainage_m: float | None = Field(default=None, ge=0) diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts index 35ee3c8ea..15d55e4c7 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts @@ -46,6 +46,7 @@ import { WING_OUT_KEYS, } from "./B05_Profile_UI_Drainage_Facility_Fields"; import { createSpecForm, type SpecLinks } from "../A00_Common/design/design_structure_form"; +import { branchOf } from "../A00_Common/design/design_structure_spec"; import { PIPE_SPEC } from "../A00_Common/design/design_structure_pipe"; import { BOX_SPEC, @@ -57,6 +58,7 @@ import { FORD_SPEC, withLegacyGuards } from "../A00_Common/design/design_structu import { fordHeightHint, fordHeightM } from "../A00_Common/design/design_structure_ford_calc"; import { guardHeightHint, + pipeDrawn, pipeExtraCount, pipeLengthM, } from "../A00_Common/design/design_structure_pipe_calc"; @@ -212,6 +214,8 @@ export interface FacilityOptionsForm { definition?: { spec_names?: Record; groups?: Array<{ id: string; name: string }>; + /** 옵션 정의 기본값 — 빈 고르기 칸이 그림 값을 보임(54-13). */ + options?: Array<{ key: string; default: unknown }>; } | null, ) => void; /** 배관 명세 컨테이너 「구조물 조합」 잇기 자리 — 폼이 패널 그룹 값에 이음(PLAN 52-1). */ @@ -267,7 +271,52 @@ export function createFacilityOptionsForm( clear: (key) => linkSource?.clear(key), set: (key, item) => linkSource?.set(key, item), }; + // 그림이 쓰는 값(54-13) — 빈 고르기 칸 = 정의 기본값(엔진 · 횡단도가 그 값으로 그림) · 보여 주기만 + let defaults: Record = {}; + const byDefault = (key: string | undefined): { value: string; tag: string } | null => { + const value = key ? defaults[key] : null; + return value === null || value === undefined || value === "" + ? null + : { value: String(value), tag: "기본" }; + }; + /** 빈 날개벽 설치 = 정의 기본 설치(옛 「있음」 · 빈 값 = 날개벽 · 엔진 `_wing_spec`). */ + const wingDefault = (key: string): { value: string; tag: string } => { + const value = String(defaults[key] ?? ""); + return { value: ["없음", "흙막이"].includes(value) ? value : "날개벽", tag: "기본" }; + }; + // 유입 「자동(규칙)」 = 기슭막이 + 유입 형식 auto(옛 값 · 정의 기본) — 그림은 지형 규칙이 집수정 · 흙막이를 + // 정함. 사용자가 유입 구조를 고를 때까지 저장도 그대로(흙막이 고름의 형식 revet 을 안 적음) + let inletAuto: { structure?: string | number } | null = null; + const inletIsAuto = (): boolean => + !!inletAuto && pipeForm.state().inlet?.[0]?.structure === "기슭막이"; + // 그림 계산은 한 번 그릴 동안 한 번만(칸마다 부름) + let drawnMemo: ReturnType | undefined; + const drawnNow = (): ReturnType => { + if (drawnMemo === undefined) { + drawnMemo = pipeDrawn(chainage); + queueMicrotask(() => (drawnMemo = undefined)); + } + return drawnMemo; + }; const pipeForm = createSpecForm(specLinks, { + shown: (c, item, values, index) => { + if (c.id === "inlet" && item.id === "structure" && (!values.structure || inletIsAuto())) { + const drawn = drawnNow(); + return drawn + ? { value: drawn.basin ? "집수정" : "기슭막이", tag: "자동" } + : { value: "", tag: "자동(지형 규칙)" }; + } + if (values[item.id]) return null; + if (item.id !== "form") return byDefault(branchOf(item, values)?.key); + const basin = c.id === "inlet" && values.structure === "집수정"; + const drawn = drawnNow(); + const form = basin + ? drawn?.basin + : c.id === "soil_add" + ? drawn?.extras[index] + : drawn?.forms[c.id]; + return form ? { value: form, tag: basin ? "자동" : "기본" } : null; + }, auto: (_c, item) => { if (item.id !== "length") return ""; const length = pipeLengthM(chainage); @@ -276,13 +325,21 @@ export function createFacilityOptionsForm( // 흙막이 개수 = 유출 「추가 기슭막이(단)」(54-1) count: (c) => (c.id === "soil_add" ? pipeExtraCount(chainage) : undefined), hint: (c, item, state) => { - const soil = c.id !== "inlet" || state.inlet?.[0]?.structure === "기슭막이"; + const soil = + c.id !== "inlet" || + (state.inlet?.[0]?.structure === "기슭막이" && !(inletIsAuto() && drawnNow()?.basin)); return item.id === "height" && soil ? guardHeightHint(state.pipe?.[0]?.diameter ?? "") : ""; }, onChange: (c, item) => { + if (c.id === "inlet" && item.id === "structure") inletAuto = null; if (c.id === "inlet" && (item.id === "structure" || item.id === "form")) inletStructureHandler?.(inletStructureOf()); emit(); + // 고른 뒤 그림이 바뀜 — 자동 표시를 새 그림으로 다시 그림(54-13) + if (c.id === "inlet") { + drawnMemo = undefined; + pipeForm.refresh(); + } }, }); // 조정창에서 옮겨 오는 행(단 수·옵션)이 붙을 자리 — 유입 · 유출 컨테이너 끝(2026-08-29 지시 4) @@ -303,6 +360,11 @@ export function createFacilityOptionsForm( const fordNeed = (): number | null => fordHeightM(fordFlow, Number(fordForm.state().ford?.[0]?.width ?? "")); const fordForm = createSpecForm(specLinks, { + shown: (c, item, values) => { + if (values[item.id]) return null; + if (c.id !== "ford") return wingDefault(c.id === "inlet" ? "wing_in" : "wing_out"); + return item.id === "diameter" ? byDefault("pipe_diameter_mm") : null; + }, hint: (c, item, state) => c.id === "ford" && item.id === "height" ? fordHeightHint(fordFlow, Number(state.ford?.[0]?.width ?? "")) @@ -368,7 +430,18 @@ export function createFacilityOptionsForm( }; // ── BOX암거 = 사용자 구조물 표 02 명세(PLAN 52-2) — 본체 · 유입 구조 · 유출 구조 ── - const boxForm = createSpecForm(specLinks, { onChange: () => emit() }); + const boxForm = createSpecForm(specLinks, { + onChange: () => emit(), + shown: (c, item, values) => { + if (values[item.id]) return null; + if (c.id !== "body") return wingDefault(c.id === "inlet" ? "wing_in" : "wing_out"); + const size = bodyOf({ + body_width_m: Number(defaults.body_width_m ?? 2), + body_height_m: Number(defaults.body_height_m ?? 2), + }).size; + return item.id === "size" && size ? { value: size, tag: "기본" } : null; + }, + }); // 날개벽 칸 — 세월교 · BOX 가 같은 조각을 쓰던 자리 · 조정 행은 명세 유입 · 유출 칸으로 옮겨 붙음 const wingInFields = createWingFields("날개벽(유입)", WING_IN_KEYS); const wingOutFields = createWingFields("날개벽(유출)", WING_OUT_KEYS); @@ -460,8 +533,10 @@ export function createFacilityOptionsForm( inletStructure: inletStructureOf, setInletStructure(value) { // 조정창 형식 → 유입 보호공 구조 · 형태(자동 = 고른 구조는 지키고 형태만 비움) - if (value === "revet") pipeForm.setValue("inlet", "structure", "기슭막이"); - else if (value !== "auto") pipeForm.setValue("inlet", "structure", "집수정"); + if (value === "revet") { + inletAuto = null; + pipeForm.setValue("inlet", "structure", "기슭막이"); + } else if (value !== "auto") pipeForm.setValue("inlet", "structure", "집수정"); if (value !== "revet" && pipeForm.state().inlet?.[0]?.structure === "집수정") pipeForm.setValue("inlet", "form", value === "auto" ? "" : value); syncVisibility(); @@ -505,9 +580,16 @@ export function createFacilityOptionsForm( // 받은 값만 칸에 — 없으면 「안 정함」(새 시설은 부르는 쪽이 등록부 기본값을 실어 줌 · 46-7). const groups = Object.fromEntries((definition?.groups ?? []).map((g) => [g.id, g.name])); const names = definition?.spec_names ?? {}; + defaults = Object.fromEntries((definition?.options ?? []).map((o) => [o.key, o.default])); if (facility === "pipe") { // 정의 파일의 옛 이름 「흙막이 추가」 — 명세 이름 「흙막이」가 앞섬(54-1) const { soil_add: _old, ...pipeNames } = names; + const kind = options.inlet_structure; + inletAuto = + options.inlet_type === "기슭막이" && + !["revet", "I", "L", "U"].includes(String(kind ?? "")) + ? { structure: kind } + : null; pipeForm.write(PIPE_SPEC, options, pipeNames, groups); } if (facility === "box_culvert") { @@ -537,6 +619,11 @@ export function createFacilityOptionsForm( // 고른 칸만 싣는다 — 「안 정함」은 빈 값(기본값을 몰래 확정으로 안 바꿈). if (current === "pipe") { Object.assign(options, pipeForm.read()); // 명세 컨테이너 값 = 옛 키 그대로(PLAN 52-1) + // 자동(규칙)은 고를 때까지 그대로 — 보여 주기만으로 파일이 안 바뀜(54-13) + if (inletIsAuto()) { + if (inletAuto!.structure === undefined) delete options.inlet_structure; + else options.inlet_structure = inletAuto!.structure; + } } else if (current === "box_culvert") { // 정본은 폭 · 높이 두 칸 — 규격이면 규격 수 · 사용자 입력이면 명세 숫자 칸(PLAN 52-2) Object.assign(options, boxForm.read(), bodyOptions(boxForm.state().body?.[0])); diff --git a/B05_Profile/B05_Profile_UI_Panel_View.ts b/B05_Profile/B05_Profile_UI_Panel_View.ts index 6b340ab0e..b09d2e0e0 100644 --- a/B05_Profile/B05_Profile_UI_Panel_View.ts +++ b/B05_Profile/B05_Profile_UI_Panel_View.ts @@ -33,19 +33,29 @@ export function createViewControls(callbacks: ViewControlCallbacks): { (["iso", "top", "front", "side"] as const).forEach((preset) => viewButtons.append(button(preset.toUpperCase(), () => callbacks.onView(preset), "glass")), ); - // 직교/원근 전환(2026-09-04 사용자 지시) — 기본은 원근이고, 단추 글자는 **바뀔 쪽**을 - // 가리킨다(누르면 그쪽으로 간다). - let projection: "perspective" | "ortho" = "perspective"; - const projectionButton = button( - "직교로", - () => { - projection = projection === "perspective" ? "ortho" : "perspective"; - projectionButton.textContent = projection === "perspective" ? "직교로" : "원근으로"; - callbacks.onProjection(projection); - }, - "glass", - ); - viewButtons.append(projectionButton); + // 원근·직교 두 단추 — 지금 켜진 쪽을 강조한다. 기본은 직교(카메라 기본과 같음). + const projectionButtons = ( + [ + ["perspective", "원근"], + ["ortho", "직교"], + ] as const + ).map(([kind, label]) => { + const element = button( + label, + () => { + projectionButtons.forEach(({ kind: other, element: el }) => { + el.classList.toggle("is-active", other === kind); + el.setAttribute("aria-pressed", String(other === kind)); + }); + callbacks.onProjection(kind); + }, + "glass", + ); + element.classList.toggle("is-active", kind === "ortho"); + element.setAttribute("aria-pressed", String(kind === "ortho")); + return { kind, element }; + }); + viewButtons.append(...projectionButtons.map(({ element }) => element)); const visibilityButtons = document.createElement("div"); visibilityButtons.className = "b05-route__view-group"; visibilityButtons.append( diff --git a/B05_Profile/B05_Profile_UI_Profile_Render.ts b/B05_Profile/B05_Profile_UI_Profile_Render.ts index 062d250ed..6dd9a018e 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Render.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Render.ts @@ -52,6 +52,7 @@ import { import { irregularStationId, type IrregularStation } from "./B05_Profile_UI_IrregularStations"; import { mountStructureMenu } from "./B05_Profile_UI_Profile_Structures"; import { buildStructureLane, STRUCTURE_LANE_HEIGHT_PX } from "./B05_Profile_UI_Structures_Marks"; +import { pipeExtraCount } from "../A00_Common/design/design_structure_pipe_calc"; import type { StructureInstance, StructureType } from "./B05_Profile_Api_Structures"; import type { RouteProfilePanelCallbacks } from "./B05_Profile_UI_Profile_Panel"; @@ -335,6 +336,11 @@ export function renderProfile(ctx: ProfileRenderContext): void { ctx.redraw(); }, onMove: (structureId, toChainage) => callbacks?.onStructureMarkMove?.(structureId, toChainage), + // 라벨 흙막이 상자 수 = 폼과 같은 유출 단 수(54-1 · 54-14) + containerCount: (structure) => + structure.type_id === "pipe" + ? (id) => (id === "soil_add" ? pipeExtraCount(structure.chainage_m ?? null) : undefined) + : undefined, }); // 세로 창 갱신기 — 가로로 스크롤할 때마다 패널이 이것을 부른다. 도형은 그대로 두고 // 겹 하나의 변환만 갈아 끼우므로 매 프레임 불러도 된다(2026-09-04 사용자 확정). diff --git a/B05_Profile/B05_Profile_UI_Structures_Balloon.ts b/B05_Profile/B05_Profile_UI_Structures_Balloon.ts new file mode 100644 index 000000000..a627c2640 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Structures_Balloon.ts @@ -0,0 +1,160 @@ +/* ============================================================================= + * B05_Profile_UI_Structures_Balloon.ts + * 종단 구조물 알약 정보 라벨 줄(54-14) — 화면 요소 없는 순수 규칙. B05 · B06 알약 레인이 같이 씀. + * + * · 줄 = 종류 이름(기타 = 손 이름) · 기준 측점(구간 = 시작 ~ 끝) · 컨테이너마다 첫 옵션 값 한 줄 + * (「관: 관경 1000」) · 컨테이너가 없으면 앞 두 줄만 · 메모 · 그 밖 옵션은 안 씀 + * · 컨테이너 = 좌측 패널 폼과 같은 상자 — 명세 종류(`STRUCTURE_SPECS`)는 명세 컨테이너(선택 컨테이너는 + * 인스턴스마다) · 그 밖 종류는 정의 옵션 그룹(`StructureType.groups`) + * · 첫 옵션 = 지금 보이는 첫 입력 칸(자동 칸 · 갈래가 없어 가린 칸 · 끈 칸은 건너뜀) + * · 빈 값 = 등록부 기본값 「(기본)」 · 값 `auto` = 「(자동)」(54-13 표시와 같음) · 그것도 없으면 「—」 + * ========================================================================== */ + +import type { StructureInstance, StructureType } from "./B05_Profile_Api_Structures"; +import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe"; +import { bodyOf, boxOptionsIn } from "../A00_Common/design/design_structure_box"; +import { withLegacyGuards } from "../A00_Common/design/design_structure_ford"; +import { + branchOf, + choiceLabel, + choicesOf, + containerTitle, + itemLabel, + readState, + type Instance, + type SpecContainer, + type SpecItem, + type StructureSpec, +} from "../A00_Common/design/design_structure_spec"; + +const DEFAULT_MARK = "(기본)"; +const AUTO_MARK = "(자동)"; +const NONE = "—"; + +/** 선택 컨테이너 개수를 밖에서 정함(배관 흙막이 = 유출 단 수 · 폼 `hooks.count` 와 같은 값). */ +export type ContainerCount = (containerId: string) => number | null | undefined; + +/** 라벨 줄 — [이름, 기준 측점, ...컨테이너 줄]. */ +export function balloonLines( + structure: StructureInstance, + type: StructureType | undefined, + head: { name: string; position: string }, + count?: ContainerCount, +): string[] { + const spec = STRUCTURE_SPECS[structure.type_id]; + const rest = spec + ? specLines(spec, structure, type, count) + : groupLines(structure.options ?? {}, type); + return [head.name, head.position, ...rest]; +} + +/** 명세 컨테이너 줄 — 폼(`Drainage_Facility.setFacility` · `paintStructureSpec`)과 같은 값 · 이름. */ +function specLines( + spec: StructureSpec, + structure: StructureInstance, + type: StructureType | undefined, + count?: ContainerCount, +): string[] { + let names = type?.spec_names ?? {}; + if (structure.type_id === "pipe") { + // 정의 파일의 옛 이름 「흙막이 추가」 — 명세 이름 「흙막이」가 앞섬(54-1) + const { soil_add: _old, ...pipeNames } = names; + names = pipeNames; + } + const state = formState(spec, structure.type_id, structure.options ?? {}); + // 빈 칸이 그림에서 쓰는 값 — 등록부 기본값을 같은 길로 읽은 필수 컨테이너 값 + const fallback = formState(spec, structure.type_id, registryDefaults(type)); + const groups = Object.fromEntries((type?.groups ?? []).map((g) => [g.id, g.name])); + return spec.containers.flatMap((c) => { + const title = containerTitle(c, names, groups[c.link]); + return instancesOf(c, state[c.id] ?? [], count).map((values, index) => { + const item = c.items.find( + (entry) => entry.kind !== "auto" && (!entry.by || branchOf(entry, values)), + ); + const head = c.required ? title : `${title} ${index + 1}`; + const base = c.required ? (fallback[c.id]?.[0]?.[item?.id ?? ""] ?? "") : ""; + return item + ? `${head}: ${itemLabel(c, item, names)} ${specValue(c, item, values, names, base)}` + : `${head}: ${NONE}`; + }); + }); +} + +/** 저장 값 → 폼 칸 값 — 폼(`Drainage_Facility.setFacility`)과 같은 손질(BOX 옛 설치 · 본체 규격 · + * 세월교 옛 날개벽). */ +function formState( + spec: StructureSpec, + typeId: string, + options: Record, +): Record { + const input = + typeId === "box_culvert" + ? boxOptionsIn(options) + : typeId === "ford_bridge" + ? withLegacyGuards(options) + : options; + const state = readState(spec, input); + // 본체 규격은 저장 안 함 — 폭 · 높이로 되찾음 + if (typeId === "box_culvert") state.body = [{ ...state.body?.[0], ...bodyOf(options) }]; + return state; +} + +/** 보이는 인스턴스 — 필수 = 한 벌 · 선택 = 개수가 정해졌으면 그 수(모자라면 빈 인스턴스) · 아니면 저장된 것. */ +function instancesOf(c: SpecContainer, list: Instance[], count?: ContainerCount): Instance[] { + if (c.required) return [list[0] ?? {}]; + const n = count?.(c.id); + return n === undefined || n === null + ? list + : Array.from({ length: n }, (_, index) => list[index] ?? {}); +} + +function specValue( + c: SpecContainer, + item: SpecItem, + values: Instance, + names: Record, + fallback: string, +): string { + const shown = (raw: string): string => { + const choice = choicesOf(item, values).find((ch) => ch.value === raw); + return choice ? choiceLabel(c, item, choice, names) : raw; + }; + const raw = (values[item.id] ?? "").trim(); + if (raw === "auto") return AUTO_MARK; + if (raw) return shown(raw); + return fallback.trim() ? `${shown(fallback.trim())} ${DEFAULT_MARK}` : NONE; +} + +/** 정의 옵션 그룹 줄 — 그룹마다 첫 옵션(정의에 있고 켜진 칸). */ +function groupLines( + options: Record, + type: StructureType | undefined, +): string[] { + const byKey = new Map((type?.options ?? []).map((option) => [option.key, option])); + return (type?.groups ?? []).map((group) => { + const option = group.options + .map((key) => byKey.get(key)) + .find((entry) => entry && entry.visible !== false && entry.enabled !== false); + if (!option) return `${group.name}: ${NONE}`; + const unit = option.unit ?? ""; + const raw = options[option.key]; + const value = + raw === "auto" + ? AUTO_MARK + : raw !== undefined && raw !== "" + ? `${raw}${unit}` + : option.default !== null && option.default !== undefined && option.default !== "" + ? `${option.default}${unit} ${DEFAULT_MARK}` + : NONE; + return `${group.name}: ${option.label} ${value}`; + }); +} + +/** 등록부 기본값(키 → 값) — 빈 칸이 그림에서 쓰는 값. */ +function registryDefaults(type: StructureType | undefined): Record { + return Object.fromEntries( + (type?.options ?? []) + .filter((option) => option.default !== null && option.default !== undefined) + .map((option) => [option.key, option.default as string | number]), + ); +} diff --git a/B05_Profile/B05_Profile_UI_Structures_Fields.ts b/B05_Profile/B05_Profile_UI_Structures_Fields.ts index 3ea90331b..8f446c04f 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Fields.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Fields.ts @@ -65,7 +65,14 @@ export function optionControl( // 툴팁 — 표가 아직 안 읽는 칸이면 그 사유가 먼저(넣어도 안 바뀜을 알아야 함), 아니면 비울 때의 뜻. const tip = option.not_in_table ?? option.empty_means ?? ""; if (option.input === "select") { - const blank = option.required ? "— 선택 —" : "— 안 정함 —"; + // 빈 칸이 쓰는 정의 기본값은 빈 보기에 꼬리를 달아 보임 — 값은 안 고름(54-13) + const fallback = option.default === null || option.default === "" ? null : option.default; + const blank = + fallback !== null && fallback !== undefined + ? `${label(String(fallback))} (기본)` + : option.required + ? "— 선택 —" + : "— 안 정함 —"; // 정의에서 뺀 선택지(48)를 고른 옛 값 — 그 값만 남겨 보임(값을 잃지 않음) const kept = value && !option.choices.includes(value) ? [value] : []; const element = select([ diff --git a/B05_Profile/B05_Profile_UI_Structures_List.ts b/B05_Profile/B05_Profile_UI_Structures_List.ts index ee699ec95..9f75cc147 100644 --- a/B05_Profile/B05_Profile_UI_Structures_List.ts +++ b/B05_Profile/B05_Profile_UI_Structures_List.ts @@ -15,7 +15,17 @@ import { } from "./B05_Profile_Api_Structures"; import type { PipeFacilityItem } from "./B05_Profile_UI_Structures_Panel"; import { stationLabel as formatStation } from "../common_util/common_util_station"; -import { readSummaryItem, summaryTooltip } from "../common_util/common_util_summary_item"; +import { + readGroupItems, + readSummaryItem, + summaryLabel, + summaryTooltip, + type SummaryItem, +} from "../common_util/common_util_summary_item"; +import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe"; +import { pipeExtraCount } from "../A00_Common/design/design_structure_pipe_calc"; +import { containerTitle, linkOf, readState } from "../A00_Common/design/design_structure_spec"; +import { asRoad } from "./B05_Profile_UI_Structures_Define"; export interface StructureListParams { /** 목록을 담을
    . 그릴 때마다 통째로 갈아 끼운다. */ @@ -62,6 +72,72 @@ export function etcName(structure: StructureInstance): string | null { return name || null; } +/** 잇기 자리 하나 — 상자 이름 · 이은 항목(없으면 null) · 필수 여부. */ +interface LinkLamp { + title: string; + item: SummaryItem | null; + required: boolean; +} + +/** 줄의 잇기 자리(54-15) — 폼이 잇기 줄을 세우는 규칙 그대로: 명세 컨테이너마다(선택은 인스턴스마다 · + * 배관 흙막이는 지형이 세운 단까지) · 정의 그룹마다(옛 종류 단위 값 = 첫 그룹 · 54-6) · 그 밖은 + * 종류 단위 하나 · 종단배수(B군)는 없음. */ +export function linkLamps( + type: StructureType | undefined, + source: { + type_id: string; + options?: Record; + summary_item?: unknown; + group_items?: unknown; + }, + chainageM: number | null, +): LinkLamp[] { + const links = readGroupItems(source.group_items); + const summary = readSummaryItem(source.summary_item); + const spec = STRUCTURE_SPECS[source.type_id]; + if (spec?.containers.length) { + const groupNames = Object.fromEntries((type?.groups ?? []).map((g) => [g.id, g.name])); + // 배관 흙막이 = 명세 이름 「흙막이」가 정의 옛 이름 「흙막이 추가」보다 앞섬(54-1 · 시설 폼과 같음) + const { soil_add: _old, ...pipeNames } = type?.spec_names ?? {}; + const names = source.type_id === "pipe" ? pipeNames : (type?.spec_names ?? {}); + const state = readState(spec, source.options ?? {}); + return spec.containers.flatMap((c): LinkLamp[] => { + const title = containerTitle(c, names, groupNames[c.link]); + if (c.required) return [{ title, item: links[c.link] ?? null, required: true }]; + const n = (c.id === "soil_add" ? pipeExtraCount(chainageM) : null) ?? state[c.id].length; + return Array.from({ length: n }, (_, i) => ({ + title: `${title} ${i + 1}`, + item: links[linkOf(c, i + 1)] ?? null, + required: false, + })); + }); + } + if (type?.groups?.length) { + const first = type.groups[0].id; + return type.groups.map((g) => ({ + title: g.name, + item: links[g.id] ?? (g.id === first ? summary : null), + required: true, + })); + } + if (!type || type.group === "B") return []; + return [{ title: "구조물 조합", item: summary, required: true }]; +} + +/** 램프 칸 — 3행 × 열(위 → 아래 → 다음 열) · 색 = 이음 · 필수 미지정 · 선택 미지정 · 툴팁 = 상자 · 항목. */ +function lampCell(lamps: LinkLamp[]): HTMLElement { + const cell = document.createElement("div"); + cell.className = "b05-route__lamps"; + for (const lamp of lamps) { + const dot = document.createElement("i"); + dot.className = lamp.item ? "is-linked" : lamp.required ? "is-required" : "is-optional"; + cell.append(dot); + } + if (lamps.length) + cell.title = lamps.map((lamp) => `${lamp.title} · ${summaryLabel(lamp.item)}`).join("\n"); + return cell; +} + export function renderStructureList(params: StructureListParams): void { const { list, typeMap, intervalM, editingId, selectedPipeChainage } = params; list.replaceChildren(); @@ -101,6 +177,20 @@ export function renderStructureList(params: StructureListParams): void { : (typeMap.get(row.pipe.facility)?.name ?? row.pipe.facility), ), ); + const lamps = rows.map((row) => + row.kind === "structure" + ? linkLamps(typeMap.get(asRoad(row.structure).type_id), asRoad(row.structure), null) + : linkLamps( + typeMap.get(row.pipe.facility), + { ...row.pipe, type_id: row.pipe.facility }, + row.pipe.chainage_m, + ), + ); + // 램프 칸 폭 = 목록에서 가장 많은 열 — 줄마다 글 시작 자리가 같게(54-15) + list.style.setProperty( + "--lamp-cols", + String(Math.max(0, ...lamps.map((l) => Math.ceil(l.length / 3)))), + ); rows.forEach((row, index) => { const item = document.createElement("li"); item.className = "b05-route__irregular-item"; @@ -132,7 +222,7 @@ export function renderStructureList(params: StructureListParams): void { // 사용자). 패널이 막 펼쳐진 참이라 자리가 잡힌 다음 프레임에 민다. if (picked) requestAnimationFrame(() => item.scrollIntoView({ block: "nearest" })); } - item.append(station, name); + item.append(lampCell(lamps[index]), station, name); list.append(item); }); } diff --git a/B05_Profile/B05_Profile_UI_Structures_Marks.ts b/B05_Profile/B05_Profile_UI_Structures_Marks.ts index 895276fd4..e05b782d7 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Marks.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Marks.ts @@ -20,6 +20,7 @@ import { type StructureType, } from "./B05_Profile_Api_Structures"; import { etcName, numberDuplicateNames } from "./B05_Profile_UI_Structures_List"; +import { balloonLines, type ContainerCount } from "./B05_Profile_UI_Structures_Balloon"; import { stationLabel as formatStation } from "../common_util/common_util_station"; /** 마크를 잡았다고 볼 여유(px) — 시작점에서 이만큼 **벗어나기 전에는** 이동으로 @@ -40,6 +41,8 @@ const LANE_PADDING_PX = 2; const OVERLAP_PX = 52; /** 기타 알약 머리 글자 수 — 약호(3자 이하)와 같은 폭(54-9). */ const ETC_PILL_CHARS = 3; +/** 라벨을 위로 비키는 높이(px) — 그래프 바닥 ▼ 단추(바닥 2 + 높이 17) + 틈 2(54-14). */ +const EDIT_BUTTON_CLEARANCE_PX = 21; /** 레인 전체 높이(px) — 2행 구조를 항상 확보한다(항목이 하나여도 높이는 같다). * 큰(혼자) 알약도 이 안에 들어간다. 아래 서브패널 손잡이와의 사이는 CSS 여백을 @@ -64,18 +67,8 @@ export interface StructureMarksOptions { selectedId: string | null; onSelect: (structureId: string | null) => void; onMove: (structureId: string, toChainageM: number) => void; -} - -function optionSummary(structure: StructureInstance, type: StructureType | undefined): string { - if (!type) return ""; - return type.options - .map((option) => { - const value = structure.options?.[option.key]; - if (value === undefined || value === "") return null; - return `${option.label} ${value}${option.unit ?? ""}`; - }) - .filter(Boolean) - .join(" · "); + /** 라벨 선택 컨테이너 개수(폼과 같은 값 · 배관 흙막이 = 유출 단 수) — 없으면 저장된 것. */ + containerCount?: (structure: StructureInstance) => ContainerCount | undefined; } /** 위치 표기 — 측점번호+잔여거리(예: 3+18.0). 누가거리 표기는 쓰지 않는다(2026-08-17). */ @@ -236,7 +229,17 @@ export function buildStructureLane(options: StructureMarksOptions): HTMLElement if (selected) lane.append( - buildBalloon(structure, type, anchorPx, row, options.stationIntervalM, displayName), + buildBalloon( + balloonLines( + structure, + type, + { name: displayName, position: positionText(structure, options.stationIntervalM) }, + options.containerCount?.(structure), + ), + type, + anchorPx, + row, + ), ); }); @@ -259,44 +262,57 @@ export function buildStructureLane(options: StructureMarksOptions): HTMLElement /** 고른 구조물의 값 벌룬. 유토곡선 벌룬과 같은 결(도형+텍스트)로 맞춘다. * 알약 **위**로 띄운다 — 아래로 펴면 도면 테이블에 가려 안 보인다(2026-08-18 * 사용자 지시). 2행 구조에서 1행(윗줄)은 좌측 위, 2행(아랫줄)은 우측 위로 - * 전개해 서로 겹치지 않게 하고, 솔로 알약은 위 중앙이다. */ + * 전개해 서로 겹치지 않게 하고, 솔로 알약은 위 중앙이다. + * 줄 = 이름 · 기준 측점 · 컨테이너마다 첫 옵션 값(`balloonLines` · 54-14) · 그래프 바닥 ▼ 단추를 가리지 + * 않게 단추 높이만큼 더 위 · 위가 잘리면 알약 아래로. */ function buildBalloon( - structure: StructureInstance, + lines: string[], type: StructureType | undefined, anchorPx: number, row: number | null, - stationIntervalM: number, - /** 중복 번호가 붙은 표시 이름("옹벽 1") — 좌측 목록과 같은 표기(2026-08-19). */ - displayName?: string, ): HTMLElement { const balloon = document.createElement("div"); balloon.className = "b05-structure__balloon"; balloon.classList.add(row === 0 ? "is-left" : row === 1 ? "is-right" : "is-center"); balloon.style.left = `${anchorPx}px`; - balloon.style.bottom = `${STRUCTURE_LANE_HEIGHT_PX + ROW_GAP_PX}px`; + balloon.style.bottom = `${STRUCTURE_LANE_HEIGHT_PX + ROW_GAP_PX + EDIT_BUTTON_CLEARANCE_PX}px`; balloon.style.borderColor = type?.style?.color ?? "#888"; + const [name, ...rest] = lines; const title = document.createElement("strong"); - title.textContent = displayName ?? type?.name ?? structure.type_id; - const position = document.createElement("span"); - position.textContent = positionText(structure, stationIntervalM); - balloon.append(title, position); - - const summary = optionSummary(structure, type); - if (summary) { - const detail = document.createElement("span"); - detail.textContent = summary; - balloon.append(detail); - } - if (structure.memo) { - const memo = document.createElement("span"); - memo.className = "b05-structure__balloon-memo"; - memo.textContent = structure.memo; - balloon.append(memo); - } + title.textContent = name; + balloon.append( + title, + ...rest.map((line) => { + const span = document.createElement("span"); + span.textContent = line; + return span; + }), + ); + requestAnimationFrame(() => flipIfClipped(balloon)); return balloon; } +/** 라벨 위가 스크롤 상자 · 창 밖으로 잘리면 알약 아래로 편다(아래가 덜 잘릴 때만). */ +function flipIfClipped(balloon: HTMLElement): void { + const lane = balloon.parentElement; + if (!balloon.isConnected || !lane) return; + let top = 0; + let bottom = window.innerHeight; + for (let at: HTMLElement | null = lane; at; at = at.parentElement) + if (getComputedStyle(at).overflowY !== "visible") { + const clip = at.getBoundingClientRect(); + top = Math.max(top, clip.top); + bottom = Math.min(bottom, clip.bottom); + } + const box = balloon.getBoundingClientRect(); + const hiddenAbove = top - box.top; + const hiddenBelow = lane.getBoundingClientRect().bottom + ROW_GAP_PX + box.height - bottom; + if (hiddenAbove <= 0 || hiddenBelow >= hiddenAbove) return; + balloon.style.bottom = "auto"; + balloon.style.top = `${STRUCTURE_LANE_HEIGHT_PX + ROW_GAP_PX}px`; +} + /** 마크를 좌우로 끌어 위치를 옮긴다. 구간형은 길이를 유지한 채 통째로 움직인다. */ function attachMarkDrag( mark: HTMLElement, diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index 4f3e2e137..aec944f83 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -540,7 +540,7 @@ export function createStructuresSection( return { root, - facility: facilityOptions, + facility: { ...facilityOptions, recount: () => (facilityOptions.recount(), renderList()) }, // 단 수 → 목록 램프(54-15) listRoot: list, hasSelection: () => editingId !== null || selectedPipeChainage !== null, setTypes(next, pick = () => true) { diff --git a/B05_Profile/B05_Profile_UI_Style_Structures.css b/B05_Profile/B05_Profile_UI_Style_Structures.css index f1c2b5f1a..78c34e1c3 100644 --- a/B05_Profile/B05_Profile_UI_Style_Structures.css +++ b/B05_Profile/B05_Profile_UI_Style_Structures.css @@ -6,11 +6,11 @@ * ========================================================================== */ /* 구조물 알약 레인 — 그래프 바로 아래 한 줄(2026-08-17 사용자 지시 1·3·6). - 높이는 2행분 고정이고, 겹치지 않으면 알약이 세로 가운데에 놓인다. */ + 높이는 2행분 고정이고, 겹치지 않으면 알약이 세로 가운데에 놓인다. + z-index 를 두지 않음 — 쌓임 층을 만들면 라벨이 그 안에 갇혀 ▲▼ 단추층(4) 밑으로 감(54-14). */ .b05-structure__lane { position: relative; overflow: visible; - z-index: 3; } /* 레인 왼쪽 Y축 띠 — 그래프 sticky Y축과 같은 폭·같은 배경으로 이어 붙여, 가로 @@ -105,7 +105,8 @@ /* 값 벌룬 — 유토곡선 벌룬과 같은 결(테두리 도형 + 여러 줄 텍스트). */ /* 벌룬은 알약 위로 뜬다(2026-08-18) — 아래는 도면 테이블에 가린다. 전개 방향: - 1행 알약 = 좌측 위(is-left), 2행 = 우측 위(is-right), 솔로 = 위 중앙(is-center). */ + 1행 알약 = 좌측 위(is-left), 2행 = 우측 위(is-right), 솔로 = 위 중앙(is-center). + 층 = 종단 패널 맨 위 — ▲▼ 단추층(4) · 고정 Y축 · 테이블 · 유토곡선(5) 위 · 우클릭 메뉴(7) 아래(54-14). */ .b05-structure__balloon { position: absolute; z-index: 6; @@ -122,7 +123,6 @@ line-height: 1.35; white-space: nowrap; pointer-events: none; - z-index: 4; } .b05-structure__balloon.is-left { @@ -138,11 +138,6 @@ font-size: 11.5px; } -.b05-structure__balloon-memo { - opacity: 0.7; - font-style: italic; -} - /* ── 계곡 통과 시설 부속 옵션 서브폼 (구조물 배치 폼 안, 2026-08-17 UI 개편) ── * 배관·BOX암거·세월교·독립 기슭막이의 부속 옵션을 2열 격자로 담는다. */ .b05-drainage__facility { @@ -462,7 +457,37 @@ /* 목록 줄 — 집계 항목이 지정된 구조물 · 관은 왼쪽 선 + 이름 색으로(밝음 · 어두움 같은 토큰). */ .b05-route__irregular-item.is-assigned { - border-left: 3px solid var(--color-success); + /* 테두리 두께 대신 안쪽 그림자 — 글 시작 자리가 다른 줄과 같게(54-15) */ + border-left-color: var(--color-success); + box-shadow: inset 2px 0 0 var(--color-success); +} + +/* 목록 줄 잇기 램프(54-15) — 측점 왼쪽 고정 폭 칸 · 3행 × 열(위 → 아래 → 다음 열) · + 폭 = 목록에서 가장 많은 열(`--lamp-cols`) · 줄 높이를 늘리지 않음 */ +.b05-route__lamps { + display: grid; + flex: none; + grid-auto-flow: column; + grid-template-rows: repeat(3, 5px); + grid-auto-columns: 5px; + gap: 2px; + align-self: center; + width: max(0px, calc(var(--lamp-cols, 0) * 7px - 2px)); +} + +.b05-route__lamps i { + border-radius: 50%; + background: var(--color-success); +} + +.b05-route__lamps i.is-required { + background: var(--color-text-muted); +} + +.b05-route__lamps i.is-optional { + box-sizing: border-box; + border: 1px solid var(--color-text-muted); + background: transparent; } .b05-route__irregular-item.is-assigned span { diff --git a/B05_Profile/B05_Profile_UI_Viewer.ts b/B05_Profile/B05_Profile_UI_Viewer.ts index 165456dad..1d0d2e415 100644 --- a/B05_Profile/B05_Profile_UI_Viewer.ts +++ b/B05_Profile/B05_Profile_UI_Viewer.ts @@ -289,8 +289,9 @@ export function createRouteViewer(): RouteViewer { iso: [distance, distance, distance], // 정확히 수직이면 lookAt이 화면 방향을 못 정해 첫 드래그에 화면이 뒤집힌다. top: [0, distance, distance * TOP_VIEW_TILT], - front: [0, distance * 0.25, distance], - side: [distance, distance * 0.25, 0], + // 정면 = 남에서 북을, 측면 = 동에서 서를 수평으로 본다(장면 +z 가 남, +x 가 동). + front: [0, 0, distance], + side: [distance, 0, 0], } as const; const [x, y, z] = positions[view]; const camera = cameraRig.camera(); @@ -309,7 +310,9 @@ export function createRouteViewer(): RouteViewer { if (!bounds) return; const width = bounds.x[1] - bounds.x[0]; const depth = bounds.y[1] - bounds.y[0]; - placeCamera(new THREE.Vector3(0, 0, 0), Math.max(width, depth, 20) * 1.35, view); + // 수평 보기는 지형 앞 가장자리가 카메라에 가까워 원근에서 잘린다 — 그 반폭만큼 물린다. + const nearHalf = view === "front" ? depth / 2 : view === "side" ? width / 2 : 0; + placeCamera(new THREE.Vector3(0, 0, 0), Math.max(width, depth, 20) * 1.35 + nearHalf, view); } async function reloadContours(interval: number): Promise { diff --git a/B05_Profile/B05_Profile_UI_Viewer_Camera.ts b/B05_Profile/B05_Profile_UI_Viewer_Camera.ts index 32f94f3fd..7dbe1cdd7 100644 --- a/B05_Profile/B05_Profile_UI_Viewer_Camera.ts +++ b/B05_Profile/B05_Profile_UI_Viewer_Camera.ts @@ -2,8 +2,7 @@ * B05_Profile_UI_Viewer_Camera.ts * B05 뷰어의 **원근/직교 두 카메라**와 그 사이 갈아 끼우기. * - * 기본은 원근(시야각 45°) — B04 지표면 화면과 같은 조작감이다(2026-09-04 사용자 확정). - * 탑뷰에서 크기를 정밀하게 대조할 때만 직교로 바꾼다. 갈아 끼울 때 위치·시선·근평면· + * 기본은 직교 — 원근(시야각 45°)은 단추로 고른다. 갈아 끼울 때 위치·시선·근평면· * 먼평면과 **보이는 크기**를 그대로 옮기므로 화면이 튀지 않는다. * * 카메라 객체가 바뀌므로 쓰는 쪽은 붙잡아 두지 말고 `camera()`로 그때그때 읽을 것. @@ -22,7 +21,7 @@ export function createCameraRig() { const perspective = new THREE.PerspectiveCamera(FOV, 1, 0.1, 100000); perspective.position.set(100, 120, 100); const ortho = new THREE.OrthographicCamera(-100, 100, 100, -100, 0.1, 100000); - let kind: ProjectionKind = "perspective"; + let kind: ProjectionKind = "ortho"; let aspect = 1; let halfHeight = 100; diff --git a/M02_MasterTemplete/M02_Item_Snapshot.py b/M02_MasterTemplete/M02_Item_Snapshot.py index d7e67022f..9df1bb0cb 100644 --- a/M02_MasterTemplete/M02_Item_Snapshot.py +++ b/M02_MasterTemplete/M02_Item_Snapshot.py @@ -26,6 +26,7 @@ from pathlib import Path from typing import Any from B05_Profile.B05_Profile_Structures_Repository import load_structures +from B05_Profile.B05_Profile_Structures_Schema import STRUCTURE_ID_RULE from common_util.common_util_drainage_pipes import pipe_points_path_in, read_pipe_points_file from common_util.common_util_json import atomic_write_json from common_util.common_util_summary_item import GROUP_JOIN, snapshot_folder_name @@ -51,7 +52,7 @@ LOCAL_FILES = (VALUES_FILE, PICKS_FILE) #: 복사본 폴더 이름 — 구조물 id(영문 · 숫자 · `_` `-`) 또는 `pipe@측점` · 옵션 그룹(PLAN 46)은 #: 뒤에 `__{그룹 id}` FOLDER_PATTERN = re.compile( - r"^(?:pipe@\d+(?:\.\d+)?(?:__g[0-9a-z]{1,16})?|[0-9A-Za-z][0-9A-Za-z_-]*)$" + rf"^(?:pipe@\d+(?:\.\d+)?(?:__g[0-9a-z]{{1,16}})?|{STRUCTURE_ID_RULE})$" ) @@ -69,6 +70,14 @@ def folder_name(owner_id: str) -> str: return owner_id.replace("#", "n") +def safe_folder(base: Path, name: str) -> Path | None: + """`base` 바로 아래 복사본 폴더 — 이름이 규칙 밖 · `base` 밖이면 None(지우기 · 쓰기 막음).""" + target = base / name + if not FOLDER_PATTERN.match(name) or target.resolve().parent != base.resolve(): + return None + return target + + def now_text() -> str: return datetime.now().isoformat(timespec="seconds") @@ -181,8 +190,8 @@ def migrate_items(project_root: str | Path) -> list[str]: continue users = [o for o, item in wanted.items() if snapshot_folder_name(item) == child.name] for owner in users: - target = base / folder_name(owner) - if (target / MANIFEST_NAME).is_file(): + target = safe_folder(base, folder_name(owner)) + if target is None or (target / MANIFEST_NAME).is_file(): continue # 이미 새 복사본이 있음 shutil.copytree(child, target, dirs_exist_ok=True) origin = manifest.get("원본") or {} @@ -212,7 +221,11 @@ def sync_snapshots(project_root: str | Path) -> dict[str, list[str]]: failed: list[str] = [] removed: list[str] = [] for folder, (owner, item) in sorted(wanted.items()): - target = base / folder + target = safe_folder(base, folder) + if target is None: + logger.warning("복사본 폴더 이름이 규칙 밖 — 건너뜀: %r", folder) + failed.append(folder) + continue manifest = read_json(target / MANIFEST_NAME) if isinstance(manifest, dict) and same_item(manifest, item): continue diff --git a/common_util/common_util_surface_sampler.py b/common_util/common_util_surface_sampler.py index 597243f2c..fddba295a 100644 --- a/common_util/common_util_surface_sampler.py +++ b/common_util/common_util_surface_sampler.py @@ -13,11 +13,21 @@ common_util로 옮겼다 — 두 화면의 종단 Z가 갈라지면 세부유역 from collections.abc import Callable from dataclasses import dataclass from pathlib import Path -from typing import Protocol +from typing import Literal, Protocol, get_args import numpy as np from scipy.interpolate import RegularGridInterpolator +#: 지면 필터 · 지표면 표현 허용 목록 — 파일 이름에 들어감(B04 `_FILTERS` · `MODEL_METHODS`) +SurfaceFilter = Literal["classification", "grid_min_z", "csf", "pmf", "ransac"] +SurfaceMethod = Literal["dtm", "tin", "nurbs", "implicit", "meshfree"] + + +def check_surface_keys(filter_key: str, method: str = "dtm") -> None: + """파일 이름 만들기 전 검사 — 허용 목록 밖이면 ValueError(경로 넘기 막음).""" + if filter_key not in get_args(SurfaceFilter) or method not in get_args(SurfaceMethod): + raise ValueError(f"지면 필터 · 표현 이름이 허용 목록 밖: {filter_key!r} · {method!r}") + class SurfaceElevationSampler(Protocol): """종·횡단 생성기가 의존하는 최소 표고 조회 인터페이스.""" @@ -129,6 +139,7 @@ def build_surface_sampler( models_dir: Path | str, source_filter: str, method: str, smooth: bool ) -> SurfaceElevationSampler: """1단계 확정 모델을 종·횡단용 일괄 XY 표고 sampler로 연다.""" + check_surface_keys(source_filter, method) models_dir = Path(models_dir) smooth_suffix = "_smooth" if smooth and method in {"dtm", "tin"} else "" dtm_smooth = models_dir / f"dtm_{source_filter}_smooth.npz" diff --git a/docs/raw/구조물정의_표/09_사면안정_흙막이_옹벽_소단.md b/docs/raw/구조물정의_표/09_사면안정_흙막이_옹벽_소단.md new file mode 100644 index 000000000..e69aa254b --- /dev/null +++ b/docs/raw/구조물정의_표/09_사면안정_흙막이_옹벽_소단.md @@ -0,0 +1,22 @@ +# 구조물 표 09 — 사면안정 › 흙막이 · 옹벽 · 소단 (사용자 10-6 · 원문 그대로) + +구조물군 상세 종류 기본 옵션 컨테이너 1 컨테이너 1 옵셩 항목 + (필수)컨테이너 제목 : 흙막이 +사면안정 흙막이 측점 No, + 측점거리 형태 돌 쌓기(찰), 돌 쌓기(메), 콘크리트, 돌망태, 통나무/목재틀, 바자 + 길이 수기입력 + 높이 수기입력 및 횡단도에서 제어 + 기준측점 전 수기입력(길이 + 기준측점 전/후에 따라 연동 계산) + 기준측점 후 수기입력(길이 + 기준측점 전/후에 따라 연동 계산) + +구조물군 상세 종류 기본 옵션 컨테이너 1 컨테이너 1 옵셩 항목 + (필수)컨테이너 제목 : 옹벽 +사면안정 옹벽 측점 No, + 측점거리 형태 콘크리트 + 길이 수기입력 + 높이 수기입력 및 횡단도에서 제어 + 기준측점 전 수기입력(길이 + 기준측점 전/후에 따라 연동 계산) + 기준측점 후 수기입력(길이 + 기준측점 전/후에 따라 연동 계산) + +구조물군 상세 종류 기본 옵션 컨테이너 1 컨테이너 1 옵셩 항목 + (필수)컨테이너 제목 : 상세 내용 +사면안정 소단 측점 No, + 측점거리 소단 폭 수기입력 + 소단 높이 수기입력 diff --git a/docs/raw/조사/2026-10-06_54-16_자료_한곳.md b/docs/raw/조사/2026-10-06_54-16_자료_한곳.md new file mode 100644 index 000000000..8a1e5415e --- /dev/null +++ b/docs/raw/조사/2026-10-06_54-16_자료_한곳.md @@ -0,0 +1,91 @@ +# 54-16 B05 · B06 자료 한 곳 확인 (조사 · 보고만) + +- 기준 커밋 `2104cc359` · 검증 프로젝트 `sub8_검증`(`c81b21f8…`) · 노선 224 +- 측정: 같은 창 안 넘김은 ORCA 탭 + `orca eval`. 도중 ORCA 탭이 통째로 새로 만들어져 세션 초안이 사라짐(앱 흠 아님) → 저장 · 새로고침 시나리오는 측정 전용 헤드리스 Chrome(같은 프로브 · 화면 안 띄움). ORCA 탭은 닫음 +- 값 되돌림: 높이 2 · 관경 1000 · 60m 계획고 865.48 · 3+0 측구 일반 으로 저장해 되돌림 확인. 단 횡단 저장 때 서버가 면적을 다시 계산해 누가토량은 −13,417.5 → −12,843.4 로 바뀜(서버 정본 재계산 결과 · 되돌릴 값 아님) + +## ① 자료마다 쥔 곳 + +세션 키 꼴 `aislo:{bucket}:{name}:{project}[:{route}]` (`A00_Common/b_page_state.ts:229-238`). 설계 가게(`design_store`)는 따로 값을 들지 않고 이 키를 바로 읽고 씀(`A00_Common/design/design_store.ts:133-153`) · 구독자 0. + +| 자료 | 기준 자리 | 같이 쥔 곳(두 벌) | 쓰는 곳 | 읽는 곳 | +|---|---|---|---|---| +| 구조물 목록 | ss `draft:structures` | 종단 다리 `ownStructures` · 횡단 패널 `structures` · 한 벌 폼 `structures` · 한 벌 종단도 `structures` · 서버 `structures.json`(보일 때마다 다시 받음) · 가게 칸 `structures`(아무도 안 씀) | `B05_Profile/B05_Profile_Api_Structures.ts:305` ← `B05_Profile_UI_Page_Structures.ts:245` · `B06_Section/B06_Section_UI_Page_Structures_Panel.ts:177` | `B05_Profile_UI_Page_Structures.ts:331` · `B06_..._Structures_Panel.ts:345` · `B06_Section_Section_Store.ts:61` | +| 구조물 옵션 | 구조물 초안 안 `options` | 명세 폼 `state`(`A00_Common/design/design_structure_form.ts:278,286`) | 폼 commit | `B05_Profile_UI_Structures_Panel_Commit.ts:94` | +| 관 옵션 | 관 초안 안 `options` | 배수유역도 `facilityStore` · 횡단 캐시 culvert/ford/box 스펙 · ss `culvertopt` · ss `revetx`/`fordadjust`/`boxadjust` | 종단 `B05_Profile_UI_Drainage_Panel.ts:612` · 횡단 `B06_..._Structures_Panel.ts:277-284` · `B06_Section_UI_Page_Span_Control.ts:112` · `B06_Section_UI_Page_Ford_Controls.ts:204,398` | `design_view_cross.ts:623`(마운트 한 번) · 저장 `B06_Section_Api_Culvert_Options.ts:64` | +| 컨테이너 값 | 관 `options` 의 `soil_add_{n}_*` | ss `extrawall`(단 수) · ss `extraspan` + 캐시 `extra_spans` · ss `revetx` · 모듈 전역 `sectionsSource`/`extraCountSource`(나중에 선 보기가 이김) | `B06_Section_UI_Page_Station_Controls.ts:324` · `B06_Section_UI_Page_Span_Control.ts:78-80` · `B06_Section_UI_Page_Pipe_Options.ts:229` | `design_structure_spec.ts:112-140` · `design_view_cross.ts:298` · `design_structure_pipe_calc.ts:32,60` | +| 잇기 | 관 · 구조물 초안 `summary_item`/`group_items` | 배수유역도 `facilityStore` · ss `revetlink`(마운트 때 한 번) | `B05_Profile_UI_Drainage_Panel.ts:599-600` · `B06_..._Structures_Panel.ts:245-259` | `B05_Profile_UI_Structures_Panel_Commit.ts:114,198` | +| 관 지점 | ss `draft:pipes` | 배수유역도 `pipeEditor` + `facilityStore` · `shared.lastPipes` · 종단 보기 `lastPipes` · 횡단 패널 `pipeFacilities` · 한 벌 폼 `pipeFacilities` · 종단 다리 `pipeStations`/`pipeMarks` · 서버 `pipe_points.json` | `B05_Profile_Api_Pipes_Draft.ts:27` ← `Drainage_Panel.ts:397` · `B06_..._Structures_Panel.ts:430` | `Drainage_Panel.ts:404` · `B06_..._Structures_Panel.ts:350` · `B06_Section_Section_Store.ts:105` | +| 종단 계획선 | ss `draft:alignment-draft` | 모듈 전역 EditState Map(`B05_Profile_UI_Profile_Edit.ts:143,188`) · 한 벌 종단도 `store`/`base` | `B05_Profile_UI_Profile_Edit.ts:198` | `:97` · `B06_Section_UI_Page_Design_Sync.ts:132` | +| 횡단 설계 patch | 메모리 캐시 Map(`B06_Section/B06_Section_Section_Store.ts:178-201` · 두 보기 공유) | ss `result:section-detail` 사본(받을 때만 씀 · 고침 안 받음) · ss `crossdesign` · ss `rockb`/`cutslope`/`berm`/`crossw`/`inletstruct`/`basinadjust`/`std-cross` · 종단 보기 `currentSectionDetail` · 횡단 보기 `sectionDetail` | `B06_Section_Cross_Design_Session.ts:55` · `design_view_cross.ts:270-296,430` | `B06_Section_Cross_Refresh.ts:253`(재계산 때만) · `B06_Section_UI_Page_Persist.ts:377` · 마운트 `design_view_cross.ts:565-567,612` | +| 사토 | 구조물 목록 안 사토장 | 캐시 `design.spoil_fill_*` | 서버 저장 때만 `B06_Section_Server_Calc_Prebuild.py:197` | `B06_Section_Cross_Refresh.ts:316-327`(저장분 폭 그대로) | +| 선택 상태 | ss `draft:structure-pick` | 가게 `selection` + ss `draft:selection`(아무도 안 씀) · 횡단 `appliedPick` | `design_selection.ts:152,172` | `design_selection.ts:112` · `B05_Profile_UI_Structure_Pick_Session.ts:34` | + +## ② 재현 (측정) + +같은 창 안 넘김 · 저장 전 + +| 자료 | B05 바꿈 → B06 | B06 바꿈 → B05 | +|---|---|---| +| 구조물 옵션(돌쌓기 높이) | 맞음 — 폼 1.5 · 카드 H=1.5m | 맞음 — 1.8 | +| 관 옵션(관경) | 맞음 — 1200 | 맞음 — 800 | +| 종단 계획선(60m ▲) | 맞음 — 865.68 | 맞음 — 865.78 | +| 선택 | 맞음 — 같은 구조물 고름 | 맞음 | +| **누가토량(같은 초안)** | **어긋남 — B05 −13,478.3 · B06 −12,888.7** | 넘긴 뒤 B05 가 B06 값(−12,931.8)으로 바뀜 | + +저장 · 새로고침 (B05: 높이 1.2 · 관경 1200 · ▲2 → B06: 측구 L형 · 관경 800 · 높이 1.0) + +| 단계 | 높이 | 관경 | 60m 계획고 | 3+0 측구 | 누가토량 | 판정 | +|---|---|---|---|---|---|---| +| 새로고침 뒤 B05 (저장 전) | 1 | 800 | 865.68 | (B05 안 보임) | **−12,915.6** (직전 −12,931.8) | **옛 값** — L형 초안이 계산에 안 얹힘 | +| 새로고침 뒤 B05 에서 [저장] → 새 창 | 1 | 800 | 865.68 | **일반** | −13,494.6 | **옛 값** — 「편집 내용을 저장했습니다」인데 L형이 안 나감 | +| 같은 창 → B06 | 1 | 800 | 865.68 | **일반**(ss 엔 L형 초안 남음) | −13,494.6 | **옛 값** — 화면은 일반 · [저장]하면 L형이 나감(화면 ≠ 저장) | +| B06 에서 [저장] → 새 창 | 1 | 800 | 865.68 | L형 | −12,931.8 | 맞음 | +| B06 관경 1000 바꾸고 바로 [저장] → 새 창 | 1 | **800** | 865.68 | L형 | −12,931.8 | **옛 값** — `pipe-points` PUT 두 번 · 둘째(배수유역도 메모리 800)가 덮음 | + +재지 않은 것(코드 읽기만): 컨테이너 값 · 잇기 · 사토 + +## ③ 원인 + +두 벌 (6) + +| # | 자리 | 왜 | +|---|---|---| +| D1 | 관 목록 세 벌 — ss 초안 · 배수유역도 메모리 · 횡단 `pipeFacilities` | [저장]이 초안을 보낸 **뒤** 배수유역도 메모리를 한 번 더 PUT(`A00_Common/design/design_shared.ts:275-281` → `B05_Profile/B05_Profile_UI_Drainage_Panel.ts:636-642`) → 늦은 메모리가 새 값을 덮음 | +| D2 | 관 옵션 네 곳 — 관 초안 · 횡단 캐시 스펙 · `culvertopt` · `revetx`/`fordadjust`/`boxadjust` | 저장 차례상 `culvertopt` 가 관 초안 뒤에 나가 이김(`design_save.ts:217-222,282-285`) | +| D3 | 흙막이 단 수 두 벌 — ss `extrawall` · 관 `soil_add_{n}_*` | 컨테이너 수는 `extrawall` 을 따름 · 모듈 전역 `sectionsSource` 는 나중에 선 보기가 이김 | +| D4 | 구조물 목록 — ss 하나 + 메모리 셋 + 보일 때마다 서버 | 숨은 쪽은 보일 때 서버 + ss 로 다시 세움(`B05_Profile_UI_Page_Structures.ts:319-322` · `B06_..._Structures_Panel.ts:334-341`) | +| D5 | 종횡단 상세 — 메모리 캐시 · ss 사본 · 종단 `currentSectionDetail` · 횡단 `sectionDetail` | ss 사본은 제자리 고침을 안 받아 늘 옛 값 · 종단 쪽은 진입 때만 받음(`design_view_profile.ts:410,442`) | +| D6 | 가게 빈 칸 셋(`structures` · `sectionDetail` · `selection`) + `draft:selection` 키 | 실제 자리는 다른 키 — 「한 곳」이 겉모양뿐 · `subscribe` 구독자 0 | + +다른 보기로 갈 때 덮음 · 안 얹음 + +- **카드 선택 초안이 재계산 길에서만 얹힘** — `crossDesignChoices` 를 읽는 곳은 `B06_Section/B06_Section_Cross_Refresh.ts:253`(계획고 재계산)과 저장(`B06_Section_UI_Page_Persist.ts:377`)뿐 → 새로고침 · 저장 뒤 카드는 서버 설계(일반)를 보이고 ss 엔 L형이 남음 (재현 2 · 3) +- **일반 [저장]은 횡단 보기가 섰을 때만 횡단 초안을 보냄** — 안 섰으면 `saveCachedCrossPatches` 가 캐시 메모리만(`B06_Section_Section_Store.ts:259`) · ss 초안(`crossdesign` · `rockb` 등) 빠짐 · 그래도 「저장했습니다」 (재현 3) +- 종단 보기 onShow 가 횡단 자료를 다시 안 읽음(`B05_Profile_UI_Page_Life.ts:30-35` · `design_view_profile.ts:548-551`) · `applyPipes(lastPipes)`(`:550`)가 끝나지 않은 메모리 목록을 폼에 실음 +- 횡단 onShow 에 관 옵션 초안 다시 얹기 없음 — 마운트 때만(`design_view_cross.ts:623`) +- 종단 [저장] 뒤 캐시를 비워도(`B05_Profile_UI_Page_Actions.ts:154`) 숨은 횡단 `sectionDetail` 은 옛 객체 → `forwardPipeOptions`(`design_view_profile.ts:299`)가 버린 객체를 고침 +- 관을 전부 지운 빈 초안 `[]` 을 「없음」으로 봐 서버 목록을 받음(`Drainage_Panel.ts:405-407`) → [저장]이 지운 관을 되살림 + +한쪽만 알림 + +- **구조물 · 관 바꿈이 횡단 면적에 다시 얹히는 길이 횡단 쪽에만** — `refreshDetailForStructures`(`design_view_cross.ts:143`) · 종단 보기에서 바꾸면 종단 누가토량이 옛 면적 (재현 1: 같은 초안 −13,478.3 대 −12,888.7) +- `toAll("onPipesChanged")`(`design_shared.ts:158-161`) — 횡단 쪽 묶음(`B06_Section_UI_Section_View_Profile.ts:179-216`)에 받는 칸 없음 → 실제로는 종단만 받음 +- 「구조물 배치」 폼 알림은 지금 보기에만(`design_shared.ts:183`) +- 관 옵션 넘김 한 방향 — 종단 → 횡단 `forwardPipeOptions` · 횡단 → 종단은 초안 + 200ms 재읽기(`design_view_cross.ts:146`)뿐 +- 숨은 종단 보기는 관 목록을 담기만(`design_view_profile.ts:153`) → 횡단에서 [저장]하면 옛 비정규 측점이 `uphill` 로 나감(`B05_Profile_UI_Page_Actions.ts:128-134`) + +## ④ 한 곳으로 모을 갈래 (8 · 작은 것부터) + +목표: 자료 하나 = 세션 키 하나(가게 칸 하나) · 두 보기는 읽기만 · 바꿈 알림 한 길 · 보기 넘길 때 서버로 덮지 않음 · [저장]은 세션 키를 그대로 보냄(5장 데이터 3층) + +| 차례 | 갈래 | 크기 | 고치는 재현 | +|---|---|---|---| +| 1 | 저장 줄기 관 한 번 — 둘째 `side.savePipes()` PUT 을 빼고 초안 하나만(시설 되붙이기는 초안 쪽에서) · `design_shared.ts:275-281` | 작음 | 관경 바로 저장 옛 값 | +| 2 | 카드 선택 초안(`crossdesign` · `rockb` · `cutslope` 등)을 상세를 **불러올 때 한 번** 캐시에 얹음(`B06_Section_Section_Store.ts` load) — 재계산 길에만 두지 않음 | 작음~중간 | 새로고침 뒤 카드 일반 · B05 누가토량 어긋남 | +| 3 | 일반 [저장]이 보기와 무관하게 횡단 ss 초안을 늘 보냄(`saveCachedCrossPatches` 가 ss 초안 포함) | 작음~중간 | B05 [저장] L형 빠짐 | +| 4 | ss `result:section-detail` 사본 — 제자리 고침을 같이 쓰거나 지움 | 작음 | 새로고침 뒤 옛 사본 | +| 5 | 구조물 · 관 바꿈 → 횡단 상세 벽 · 면적 다시 얹기를 공용 한 길로(어느 보기에서 바꿔도) · 종단 `currentSectionDetail` 대신 `Section_Store` 캐시 하나만 읽음 | 중간 | 같은 초안 누가토량 두 값 | +| 6 | 관 목록 한 벌 — 배수유역도 메모리 · 횡단 `pipeFacilities` · `shared.lastPipes` 를 ss 초안 읽기 전용으로 · `onPipesChanged` 를 두 보기 모두 · 빈 초안 `[]` 도 초안으로 | 중간 | 관 지점 두 벌 | +| 7 | 관 옵션 한 자리 — `culvertopt` · `revetx` · `extrawall` 을 관 초안 `options` 로 모음 · 흙막이 단 수 한 벌 · 저장 차례 겹침 없앰 | 큼 | 관 옵션 네 곳 | +| 8 | 가게를 실제 한 창구로 — 자료마다 키 하나 · `subscribe` 알림으로 두 보기 다시 그림 · 빈 칸 정리 · 보기 onShow 서버 재조회는 초안 없을 때만 | 큼 | 두 벌 전반 | diff --git a/resources/tester/helper_54_14_balloon_lines.cjs b/resources/tester/helper_54_14_balloon_lines.cjs new file mode 100644 index 000000000..5816c7935 --- /dev/null +++ b/resources/tester/helper_54_14_balloon_lines.cjs @@ -0,0 +1,121 @@ +/* 54-14 종단 알약 정보 라벨 줄 단위검증 헬퍼 — TS 를 그 자리에서 트랜스파일해 Node 로 돌린다. + * 결과는 표준출력에 JSON 한 벌. 화면 그릇은 쓰지 않는다. + * + * ⚠ 확장자가 `.cjs` 인 까닭은 `helper_b05_structures_form_summary.cjs` 머리와 같다. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const cache = new Map(); + +function loadTs(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`)); + return require(request); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + cache.set(full, box.exports); + return box.exports; +} + +const { balloonLines } = loadTs( + path.join(ROOT, "B05_Profile", "B05_Profile_UI_Structures_Balloon.ts"), +); + +const opt = (key, label, extra = {}) => ({ key, label, default: null, unit: null, ...extra }); +const TYPES = { + pipe: { + type_id: "pipe", + name: "배관", + options: [ + opt("pipe_diameter_mm", "관경", { default: "1000" }), + opt("inlet_type", "유입", { default: "기슭막이" }), + ], + groups: [ + { id: "grv6lny", name: "관", options: [] }, + { id: "gnbf9m9", name: "관 보호공(유입)", options: [] }, + { id: "g9lq9uk", name: "관 보호공(유출)", options: [] }, + ], + spec_names: { soil_add: "흙막이 추가" }, + }, + box_culvert: { + type_id: "box_culvert", + name: "BOX암거", + options: [ + opt("body_width_m", "폭", { default: 2 }), + opt("body_height_m", "높이", { default: 2 }), + ], + groups: [], + }, + cross_underdrain: { + type_id: "cross_underdrain", + name: "맹암거", + options: [ + opt("culvert_height_m", "높이", { unit: "m" }), + opt("culvert_slope_pct", "기울기", { unit: "%" }), + ], + groups: [ + { id: "gcrossud", name: "맹암거", options: ["culvert_height_m", "culvert_slope_pct"] }, + ], + }, + retaining_wall: { + type_id: "retaining_wall", + name: "옹벽", + options: [ + opt("height_m", "높이", { default: 2, unit: "m", visible: false }), + opt("length_m", "길이", { default: 10, unit: "m" }), + ], + groups: [{ id: "gretaindetail", name: "상세 내용", options: ["height_m", "length_m"] }], + }, + mirror: { type_id: "mirror", name: "반사경", options: [], groups: [] }, + etc: { type_id: "etc", name: "기타", options: [opt("name", "이름")], groups: [] }, +}; +const point = (type_id, options = {}) => ({ + structure_id: type_id, + type_id, + placement: "point", + chainage_m: 100, + options, + memo: "메모는 안 씀", +}); +const head = (name) => ({ name, position: "5+0" }); +const run = (structure, count) => + balloonLines(structure, TYPES[structure.type_id], head("이름"), count); + +const out = { + pipeFull: run( + point("pipe", { + pipe_diameter_mm: 1200, + inlet_type: "집수정", + inlet_structure: "L", + outlet_type: "기슭막이", + soil_add_1_structure: "기슭막이", + soil_add_1_form: "돌망태", + chute_add_1_structure: "도수로", + }), + ), + pipeBlank: run(point("pipe")), + // 흙막이 개수 = 밖에서 정한 단 수(2) — 저장 1 벌 · 모자란 둘째는 빈 상자 + pipeCount: run(point("pipe", { soil_add_1_structure: "기슭막이" }), (id) => + id === "soil_add" ? 2 : undefined, + ), + boxPreset: run(point("box_culvert", { body_width_m: 3, body_height_m: 3, wing_in: "있음" })), + boxBlank: run(point("box_culvert")), + underdrain: run(point("cross_underdrain", { culvert_height_m: 0.6 })), + underdrainBlank: run(point("cross_underdrain")), + wall: run(point("retaining_wall")), + wallAuto: run(point("retaining_wall", { length_m: "auto" })), + mirror: run(point("mirror", { anything: 1 })), + etcEmpty: run(point("etc", { name: "안내판" })), + etcNote: run(point("etc", { name: "안내판", etc_detail_1_note: "시험 비고" })), +}; +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_54_14_balloon_lines.py b/resources/tester/test_54_14_balloon_lines.py new file mode 100644 index 000000000..6374f7903 --- /dev/null +++ b/resources/tester/test_54_14_balloon_lines.py @@ -0,0 +1,99 @@ +# -*- coding: utf-8 -*- +"""54-14 종단 구조물 알약 정보 라벨 — 중요한 것만. + +• 줄 = 이름 · 기준 측점 · 컨테이너마다 첫 옵션 값 한 줄(컨테이너가 없으면 앞 두 줄만) +• 컨테이너 = 좌측 패널 폼과 같은 상자(명세 컨테이너 · 선택 컨테이너는 인스턴스마다 · 정의 옵션 그룹) +• 빈 값 = 등록부 기본값 「(기본)」 · 값 auto = 「(자동)」 · 그것도 없으면 「—」 · 메모 · 그 밖 옵션은 안 씀 +• 라벨은 ▲▼ 단추층 위 · 단추 높이만큼 위로 비킴 · B05 · B06 은 같은 레인 — 실제 화면은 ORCA 로 따로 본다. +화면 그릇 없이 Node 헬퍼가 TS 를 트랜스파일해 돌린다(`helper_54_14_balloon_lines.cjs`). +""" + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.abspath(os.path.join(HERE, "..", "..")) +MARKS = os.path.join(ROOT, "B05_Profile", "B05_Profile_UI_Structures_Marks.ts") +STYLE = os.path.join(ROOT, "B05_Profile", "B05_Profile_UI_Style_Structures.css") + + +@pytest.fixture(scope="module") +def out(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_54_14_balloon_lines.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["pipeFull"] == [ + "이름", + "5+0", + "관: 관경 1200", + "관 보호공(유입): 구조 집수정", + "관 보호공(유출): 구조 흙막이", + "흙막이 1: 구조 흙막이", + "도수로/돌붙임 1: 구조 도수로", + ] + + +def test_빈_값은_등록부_기본값에_기본_표시(out): + assert out["pipeBlank"] == [ + "이름", + "5+0", + "관: 관경 1000 (기본)", + "관 보호공(유입): 구조 흙막이 (기본)", + "관 보호공(유출): 구조 —", + ] + + +def test_흙막이_상자_수는_밖에서_정한_단_수(out): + assert out["pipeCount"][-2:] == ["흙막이 1: 구조 흙막이", "흙막이 2: 구조 —"] + assert len(out["pipeCount"]) == 2 + 3 + 2 + + +def test_박스_본체_규격은_폭_높이로_되찾음(out): + assert out["boxPreset"] == [ + "이름", + "5+0", + "본체: 본체 규격 3.0 × 3.0", + "유입 구조: 구조 날개벽", + "유출 구조: 구조 —", + ] + assert out["boxBlank"][2] == "본체: 본체 규격 2.0 × 2.0 (기본)" + + +def test_정의_옵션_그룹은_그룹마다_첫_켜진_옵션(out): + assert out["underdrain"] == ["이름", "5+0", "맹암거: 높이 0.6m"] + assert out["underdrainBlank"] == ["이름", "5+0", "맹암거: 높이 —"] + # 끈 칸(높이)은 건너뜀 · 빈 값은 기본값 + assert out["wall"] == ["이름", "5+0", "상세 내용: 길이 10m (기본)"] + assert out["wallAuto"] == ["이름", "5+0", "상세 내용: 길이 (자동)"] + + +def test_컨테이너가_없으면_이름과_기준_측점만(out): + assert out["mirror"] == ["이름", "5+0"] + assert out["etcEmpty"] == ["이름", "5+0"] + assert out["etcNote"] == ["이름", "5+0", "상세 내용 1: 비고 시험 비고"] + + +def test_라벨은_단추층_위로_비켜_뜬다(): + with open(MARKS, encoding="utf-8") as handle: + marks = handle.read() + with open(STYLE, encoding="utf-8") as handle: + style = handle.read() + assert "EDIT_BUTTON_CLEARANCE_PX = 21" in marks + assert "ROW_GAP_PX + EDIT_BUTTON_CLEARANCE_PX" in marks + assert "balloonLines(" in marks and "structure.memo" not in marks + lane = style.split(".b05-structure__lane {", 1)[1].split("}", 1)[0] + assert "z-index" not in lane # 레인이 쌓임 층을 만들면 라벨이 그 안에 갇힘 + balloon = style.split(".b05-structure__balloon {", 1)[1].split("}", 1)[0] + assert balloon.count("z-index") == 1 and "z-index: 6;" in balloon diff --git a/resources/tester/test_54_22_security.py b/resources/tester/test_54_22_security.py new file mode 100644 index 000000000..42cf375a6 --- /dev/null +++ b/resources/tester/test_54_22_security.py @@ -0,0 +1,109 @@ +"""54-22 보안 — H1 구조물 id 경로 넘기 · H2 지면 필터 · 표현 이름 경로 넘기. + +못박는 것 + - H1: 구조물 저장 `structure_id` 는 안전 이름만(어기면 검사 실패 = 422) · 복사본 맞춤은 + 규칙 밖 · `base` 밖 폴더를 지우지도 쓰지도 않음. + - H2: `filter_key` · `method` 는 허용 목록만(스키마) · 파일 이름 만드는 곳에서도 한 번 더. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from pydantic import ValidationError + +from B05_Profile.B05_Profile_Engine_Skeleton import load_or_build_skeleton +from B05_Profile.B05_Profile_Engine_Solver import _load_dtm_grid, _source_npz_paths +from B05_Profile.B05_Profile_Schema import RouteConfirmRequest, RouteSolveRequest +from B05_Profile.B05_Profile_Structures_Schema import StructureInstance +from common_util.common_util_surface_sampler import build_surface_sampler, check_surface_keys +from M02_MasterTemplete import M02_Item_Snapshot as snap + +BAD_IDS = ["../../..", "..", "a/b", "a\\b", "C:/Windows", "/etc", ".hidden", "", "x" * 65] +BAD_KEYS = ["../other/dtm_csf", "csf/../../x", "C:/x", "", "CSF"] + + +def _structure(structure_id): + return StructureInstance( + structure_id=structure_id, type_id="x", placement="point", chainage_m=1 + ) + + +@pytest.mark.parametrize("bad", BAD_IDS) +def test_H1_악성_id_거절(bad): + with pytest.raises(ValidationError): + _structure(bad) + + +@pytest.mark.parametrize("good", [None, "0c3d99cc5d8f49139c871d662537da42", "abc_1-2", "s1__g1"]) +def test_H1_정상_id_통과(good): + assert _structure(good).structure_id == good + + +def test_H1_safe_folder(tmp_path): + base = tmp_path / "templates" / "structures" + base.mkdir(parents=True) + assert snap.safe_folder(base, "abc123") == base / "abc123" + assert snap.safe_folder(base, "pipe@40.0__gin") == base / "pipe@40.0__gin" + for bad in BAD_IDS[:-1] + [str(tmp_path)]: # 길이는 스키마 몫 + assert snap.safe_folder(base, bad) is None + + +def test_H1_맞춤이_base_밖을_안_건드림(tmp_path, monkeypatch): + root = tmp_path / "proj" + base = snap.structures_dir(root) + base.mkdir(parents=True) + victim = root / "victim" # base / "../../victim" + victim.mkdir() + (victim / "keep.txt").write_text("살아야 함", encoding="utf-8") + item = {"layer": "master", "owner": "", "key": "k"} + monkeypatch.setattr( + snap, "assignments", lambda _root: {"../../victim": item, str(victim): item, "ok1": item} + ) + + def fake_write(target: Path, *_args): + target.mkdir(parents=True, exist_ok=True) + return {} + + monkeypatch.setattr(snap, "write_snapshot", fake_write) + result = snap.sync_snapshots(root) + assert (victim / "keep.txt").is_file() + assert set(result["못만듦"]) == {"../../victim", str(victim)} + assert result["만듦"] == ["ok1"] + assert result["지움"] == [] + + +@pytest.mark.parametrize("bad", BAD_KEYS) +def test_H2_스키마_허용_목록(bad): + body = {"bp": {"x": 0, "y": 0}, "ep": {"x": 1, "y": 1}} + with pytest.raises(ValidationError): + RouteSolveRequest(filter_key=bad, **body) + with pytest.raises(ValidationError): + RouteSolveRequest(filter_key="csf", method=bad, **body) + with pytest.raises(ValidationError): + RouteConfirmRequest(filter_key=bad) + with pytest.raises(ValidationError): + RouteConfirmRequest(method=bad) + + +def test_H2_스키마_정상값_통과(): + assert RouteConfirmRequest(filter_key="grid_min_z", method="tin").method == "tin" + assert RouteConfirmRequest().filter_key is None + + +@pytest.mark.parametrize("bad", BAD_KEYS) +def test_H2_파일_이름_만드는_곳(bad, tmp_path): + with pytest.raises(ValueError): + check_surface_keys(bad) + with pytest.raises(ValueError): + check_surface_keys("csf", bad) + with pytest.raises(ValueError): + build_surface_sampler(tmp_path, bad, "dtm", False) + with pytest.raises(ValueError): + _load_dtm_grid(tmp_path, bad, False) + with pytest.raises(ValueError): + _source_npz_paths(tmp_path, "csf", bad, False) + with pytest.raises(ValueError): + load_or_build_skeleton(tmp_path, bad, "dtm", False) + check_surface_keys("classification", "meshfree") diff --git a/resources/tester/test_b05_pipe_clearance_switch.py b/resources/tester/test_b05_pipe_clearance_switch.py index 7daef7d92..4787d6a9f 100644 --- a/resources/tester/test_b05_pipe_clearance_switch.py +++ b/resources/tester/test_b05_pipe_clearance_switch.py @@ -71,7 +71,7 @@ def test_request_wins_over_stored(): def test_schema_defaults_to_none_and_ships_the_flag(): """스키마 기본은 None(미지정) — 저장값을 덮어쓰지 않는다.""" request = RouteSolveRequest( - filter_key="f", + filter_key="csf", bp={"x": 0.0, "y": 0.0}, ep={"x": 100.0, "y": 0.0}, ) diff --git a/resources/tester/ui_checks/54-13_그림값_표시.js b/resources/tester/ui_checks/54-13_그림값_표시.js new file mode 100644 index 000000000..07f6a2918 --- /dev/null +++ b/resources/tester/ui_checks/54-13_그림값_표시.js @@ -0,0 +1,255 @@ +/* 54-13 — 좌측 폼 고르기 칸이 그림이 쓰는 값(기본값 · 자동 규칙 결과)을 보이는가 · 보여 주기만으로 값이 안 바뀌는가 + * 시작 화면: B06 횡단설계(#/b06-section) 또는 B05 종단설계(#/b05-profile) · 검증 프로젝트(sub6_검증) · 로그인 + * 부르는 법: 단계마다 한 번씩 — 앞에 `window.__m5413 = "단계";` · 결과 `sessionStorage.getItem("__5413_out_{단계}")` + * check → 목록의 배관 · 세월교 · BOX 전부를 열어 폼 글 ↔ 그림(B06 카드 SVG 글 · 벽 툴팁 · 상세 세트) 대조 · + * B05 에서 부르면 B06 에서 읽은 폼 글과 같은지 · 초안(관 지점 세션) 안 생김 + * old → 4+5 배관을 옛 「자동(규칙)」(유입 기슭막이 · 형식 없음)으로 초안에 넣고 새로 고침 + * oldpick→ (새로 고친 뒤) 폼 = 그림 · 관경만 고르면 유입 형식 안 굳음 · 흙막이 고르면 revet · 그림 벽 · + * 12+18 배관 집수정 높이 고침 = 집수정을 고른 것 + * box → 17+12 세월교(빈 칸) 대조 · 세션에서만 BOX 로 바꾸고 칸을 비워 규격 · 유입 · 유출 = 기본 · 저장 칸 없음 + * restore→ 초안 지우고 새로 고침 · 서버 관 지점 = 처음 사진 + * 쓰는 것: sessionStorage 만(서버 저장 없음) + * 기대 수치: 모든 단계 bad = [] + */ +(async () => { + const MODE = window.__m5413 || "check"; + const w = (ms) => new Promise((r) => setTimeout(r, ms)); + const until = async (fn, ms = 15000) => { + for (let t = 0; t < ms; t += 200) { + if (fn()) return true; + await w(200); + } + return false; + }; + const pid = localStorage.getItem("frd_current_project_id"); + const DRAFT = `aislo:draft:pipes:${pid}`; + const B06 = location.hash.includes("b06"); + const txt = (el) => el.innerText.replace(/\s+/g, " ").trim(); + const flat = (s) => String(s ?? "").replace(/\s+/g, ""); + const out = { mode: MODE, page: B06 ? "B06" : "B05", bad: [] }; + const bad = (what) => out.bad.push(what); + const done = () => sessionStorage.setItem(`__5413_out_${MODE}`, JSON.stringify(out)); + const api = async () => + (await (await fetch(`/api/projects/${pid}/drainage/pipe-points`)).json()).pipe_points; + const rows = () => [...document.querySelectorAll(".b05-route__irregular-list li")]; + const rowOf = (head) => rows().find((li) => txt(li).startsWith(head)); + const draft = (at) => { + const raw = sessionStorage.getItem(DRAFT); + const list = raw ? (JSON.parse(raw).value ?? JSON.parse(raw)) : null; + return list?.find((p) => Math.abs(p.chainage_m - at) < 0.51) ?? null; + }; + const spec = () => + [...document.querySelectorAll(".b05-structure__spec")].find((s) => s.offsetParent); + /** 폼 고르기 칸 `{항목: 보이는 글}` · 꼬리 표시(data-spec-shown) 칸 목록. */ + const formOf = () => { + const fields = {}; + const tagged = []; + for (const wrap of spec()?.querySelectorAll("[data-spec-item]") ?? []) { + const sel = wrap.querySelector("select"); + if (!sel) continue; + fields[wrap.dataset.specItem] = sel.options[sel.selectedIndex]?.text ?? ""; + if (wrap.dataset.specShown) tagged.push(wrap.dataset.specItem); + } + return { fields, tagged }; + }; + const detailAt = (at) => { + const key = Object.keys(sessionStorage).find((k) => k.includes("section-detail")); + const raw = key ? JSON.parse(sessionStorage.getItem(key)) : null; + const list = (raw?.value ?? raw)?.cross_sections ?? []; + return list.reduce( + (a, b) => (Math.abs(b.chainage_m - at) < Math.abs((a?.chainage_m ?? 1e9) - at) ? b : a), + null, + ); + }; + /** 그림 — 고른 카드 SVG 글 · 벽 툴팁(역할 + 형태). */ + const drawn = () => { + const svg = document.querySelector(".b06-cross-card--selected svg"); + if (!svg) return null; + return { + labels: [...svg.querySelectorAll(".b06-chart__culvert-label")].map((t) => t.textContent), + walls: [...svg.querySelectorAll(".b06-chart__culvert-revet title")].map( + (t) => t.textContent.split(" H=")[0], + ), + }; + }; + const open = async (head) => { + const row = await until(() => rowOf(head)).then(() => rowOf(head)); + if (!row) return (bad(`${head}: 목록에 없음`), false); + row.click(); + await until(() => spec() && (!B06 || document.querySelector(".b06-cross-card--selected svg"))); + await w(1200); + return true; + }; + const fire = (sel, value) => { + sel.value = value; + sel.dispatchEvent(new Event("change", { bubbles: true })); + }; + const selectOf = (item) => spec()?.querySelector(`[data-spec-item="${item}"] select`); + const SHAPE = { I형: "I형", L형: "ㄴ형", ㄷ형: "ㄷ형" }; + /** 폼 글 ↔ 그림 — 종류마다 그림이 쓰는 값을 따로 읽어 대조. */ + const compare = (head, at, kind) => { + const { fields, tagged } = formOf(); + const pic = B06 ? drawn() : null; + const set = detailAt(at); + const got = { head, fields, tagged, pic }; + const blank = Object.entries(fields).filter( + ([k, v]) => v === "— 안 정함 —" && !/^(soil_add|chute_add)/.test(k), + ); + if (blank.length) bad(`${head}: 빈 칸 글 ${blank.map(([k]) => k)}`); + if (!pic) return got; // B05 — 글 대조는 B06 사진과 + const head0 = (v) => flat(v).replace(/\((기본|자동)\)$/, ""); + if (kind === "pipe") { + const basin = pic.labels.find((l) => l.startsWith("집수정")); + const inlet = head0(fields["inlet.structure"]); + if ((inlet === "집수정") !== !!basin) bad(`${head}: 유입 ${inlet} ↔ 그림 ${basin ?? "벽"}`); + const form = head0(fields["inlet.form"]); + if (basin && !basin.includes(SHAPE[form] ?? "?")) bad(`${head}: 모양 ${form} ↔ ${basin}`); + for (const [side, role] of [ + ["inlet", "유입"], + ["outlet", "유출"], + ]) { + const wall = pic.walls.find((t) => t.startsWith(`${role} 기슭막이`)); + if (side === "inlet" && basin) continue; + const f = head0(fields[`${side}.form`]); + if (!wall || flat(wall) !== flat(`${role} 기슭막이 ${f}`)) + bad(`${head}: ${side} 형태 ${f} ↔ ${wall}`); + } + const d = Math.round((set?.culvert?.diameter_m ?? 0) * 1000); + if (tagged.includes("pipe.diameter") && head0(fields["pipe.diameter"]) !== String(d)) + bad(`${head}: 관경 ${fields["pipe.diameter"]} ↔ ${d}`); + } + if (kind === "ford") { + const label = pic.labels.find((l) => l.includes("Ø")) ?? ""; + if (!label.includes(`Ø${head0(fields["ford.diameter"])}`)) + bad(`${head}: 관경 ${fields["ford.diameter"]} ↔ ${label}`); + for (const side of ["in", "out"]) { + const wing = set?.ford?.[`wing_${side}`]?.installed; + const text = head0(fields[`${side === "in" ? "inlet" : "outlet"}.structure`]); + if (wing !== undefined && (text === "날개벽") !== wing) + bad(`${head}: ${side} ${text} ↔ 설치 ${wing}`); + } + } + if (kind === "box") { + const label = pic.labels.find((l) => l.startsWith("BOX")) ?? ""; + if (flat(label) !== `BOX${head0(fields["body.size"])}`) + bad(`${head}: 규격 ${fields["body.size"]} ↔ ${label}`); + } + return got; + }; + const KIND = { 배관: "pipe", 세월교: "ford", BOX암거: "box" }; + + try { + if (MODE === "check") { + if (!sessionStorage.getItem("__5413_orig")) + sessionStorage.setItem("__5413_orig", JSON.stringify(await api())); + const before = sessionStorage.getItem(DRAFT); + const heads = rows() + .map(txt) + .filter((t) => /^\d+\+\d+ (배관|세월교|BOX암거)/.test(t)); + const b06 = JSON.parse(sessionStorage.getItem("__5413_b06") ?? "{}"); + out.rows = []; + for (const t of heads) { + const head = t.split(" ").slice(0, 2).join(" "); + if (!(await open(head))) continue; + const station = head.split(" ")[0].split("+").map(Number); + const got = compare(head, station[0] * 20 + station[1], KIND[t.split(" ")[1]]); + out.rows.push(`${head} ${JSON.stringify(got.fields)} | ${JSON.stringify(got.pic)}`); + if (B06) b06[t] = got.fields; + else if (b06[t] && JSON.stringify(b06[t]) !== JSON.stringify(got.fields)) + bad(`${head}: B05 ${JSON.stringify(got.fields)} ≠ B06 ${JSON.stringify(b06[t])}`); + else if (!b06[t]) bad(`${head}: B06 사진 없음 — B06 에서 check 먼저`); + } + if (B06) sessionStorage.setItem("__5413_b06", JSON.stringify(b06)); + if (sessionStorage.getItem(DRAFT) !== before) bad("열기만 했는데 관 초안이 바뀜"); + out.count = heads.length; + } else if (MODE === "old") { + const list = (await api()).map(({ lonlat, ...rest }) => rest); + const pipe = list.find((p) => Math.abs(p.chainage_m - 85.05) < 0.51); + pipe.options = { ...pipe.options, inlet_type: "기슭막이" }; + delete pipe.options.inlet_structure; + sessionStorage.setItem(DRAFT, JSON.stringify(list)); + out.note = "초안 넣음 — 새로 고친 뒤 oldpick"; + done(); + location.reload(); + return; + } else if (MODE === "oldpick") { + await open("4+5 배관"); + out.start = compare("4+5 옛 자동", 85.05, "pipe"); + if (!flat(out.start.fields["inlet.structure"]).endsWith("(자동)")) + bad(`옛 자동 유입 글 ${out.start.fields["inlet.structure"]}`); + // 관경만 고름 — 유입 형식은 그대로(자동 유지) + fire(selectOf("pipe.diameter"), "1200"); + await w(2500); + out.afterDiameter = draft(85.05)?.options; + if (out.afterDiameter?.inlet_structure !== undefined) + bad(`관경만 골랐는데 형식 ${out.afterDiameter.inlet_structure}`); + if (out.afterDiameter?.inlet_type !== "기슭막이") + bad(`유입 저장 값 바뀜 ${out.afterDiameter?.inlet_type}`); + out.stillAuto = formOf().fields["inlet.structure"]; + // 흙막이를 고름 = 형식 revet · 그림 벽 + fire(selectOf("inlet.structure"), "기슭막이"); + await w(3500); + out.afterPick = draft(85.05)?.options; + if (out.afterPick?.inlet_structure !== "revet") + bad(`흙막이 골랐는데 형식 ${out.afterPick?.inlet_structure}`); + out.picked = compare("4+5 흙막이 고름", 85.05, "pipe"); + if (out.picked.fields["inlet.structure"] !== "흙막이") + bad(`고른 글 ${out.picked.fields["inlet.structure"]}`); + // 다른 관(자동 집수정) — 집수정 높이 고침 = 집수정을 고른 것 + await open("12+18 배관"); + const shown = formOf().fields["inlet.structure"]; + out.other = shown; + if (flat(shown) === "집수정(자동)") { + const h = spec().querySelector('[data-spec-item="inlet.height"] input'); + h.value = "1.5"; + h.dispatchEvent(new Event("change", { bubbles: true })); + await w(3000); + const o = draft(258.12)?.options; + out.basinEdit = o; + if (o?.inlet_type !== "집수정" || o?.inlet_basin_height_m !== 1.5) + bad(`집수정 높이 고침 저장 ${JSON.stringify(o)}`); + if (formOf().fields["inlet.structure"] !== "집수정") + bad(`높이 고친 뒤 글 ${formOf().fields["inlet.structure"]}`); + } + } else if (MODE === "box") { + await open("17+12 세월교"); + out.ford = compare("17+12 세월교", 352.14, "ford"); + const type = document.querySelectorAll( + ".b05-structure-section .b05-structure__grid select", + )[1]; + fire(type, "box_culvert"); + await until(() => rowOf("17+12 BOX암거")); + await w(3000); + // 새 종류는 정의 기본값이 값으로 실림(46-7) — 칸을 비워 옛 빈 저장과 같게 + for (const k of ["body.size", "inlet.structure", "outlet.structure"]) { + fire(selectOf(k), ""); + await w(1500); + } + await w(2000); + out.box = compare("17+12 BOX", 352.14, "box"); + const f = out.box.fields; + if (flat(f["body.size"]) !== "2.0×2.0(기본)") bad(`BOX 규격 ${f["body.size"]}`); + for (const k of ["inlet.structure", "outlet.structure"]) + if (flat(f[k]) !== "날개벽(기본)") bad(`BOX ${k} ${f[k]}`); + const o = draft(352.14)?.options ?? {}; + if (["body_width_m", "wing_in", "wing_out"].some((k) => k in o)) + bad(`보여 주기만 했는데 BOX 저장 칸 ${JSON.stringify(o)}`); + } else if (MODE === "restore") { + sessionStorage.removeItem(DRAFT); + // 고르기 단계가 남긴 B06 유입 형식 세션 · 그걸 얹은 상세 캐시(다시 받음) + for (const key of Object.keys(sessionStorage)) + if (key.includes(`inletstruct:${pid}`) || key.includes(`section-detail:${pid}`)) + sessionStorage.removeItem(key); + const orig = JSON.parse(sessionStorage.getItem("__5413_orig") ?? "null"); + const now = await api(); + out.same = JSON.stringify(now) === JSON.stringify(orig); + if (!out.same) bad("서버 관 지점이 처음 사진과 다름"); + done(); + location.reload(); + return; + } + } catch (error) { + bad(String(error?.stack ?? error)); + } + done(); +})(); diff --git a/resources/tester/ui_checks/54-14_알약_라벨.js b/resources/tester/ui_checks/54-14_알약_라벨.js new file mode 100644 index 000000000..1e04a6b09 --- /dev/null +++ b/resources/tester/ui_checks/54-14_알약_라벨.js @@ -0,0 +1,210 @@ +/* 54-14 — 종단도 구조물 알약 정보 라벨: 맨 위 층 · ▲▼ 단추를 안 가림 · 줄 = 이름 · 기준 측점 · 컨테이너마다 첫 값 + * 시작 화면: B05 종단설계(#/b05-profile) · 검증 프로젝트(데스크탑_메인) · 로그인 · 종단 도크에 배관 1(4+5) · 배관 3(22+0) + * 부르는 법: 단계마다 한 번씩 — 앞에 `window.__m5414 = "단계";` + * seed → 처음 초안(세션 원문)을 `__5414_orig` 에 남김 · 구조물 초안에 맹암거 · 반사경 · 기타(안내판 · 비고) 심음 + * → 그 뒤 orca reload + * box → 배관 3(22+0) 종류를 BOX암거로(관 초안만 · 저장 안 함) + * check → 배관 · 박스 · 맹암거 · 반사경 · 기타 알약을 눌러 라벨 판정 — B05 에서 한 번(값을 세션에 둠) · + * B06(#/b06-section) 에서 한 번(B05 = B06) + * restore → 처음 초안 원문으로 되돌림 → 그 뒤 orca reload + * 긴 단계는 `void` 로 띄우고 `sessionStorage.getItem("__5414_out_{단계}")` 로 읽음 + * 판정: 라벨 위 점 15 곳 모두 맨 위 = 라벨 · 라벨 상자와 ▲▼ 단추 상자 겹침 0 · 줄 수 = 1 + 1 + 컨테이너 수 + * (컨테이너 수 = 좌측 패널 폼에 보이는 상자 · 줄 머리 = 상자 제목) · B05 = B06 + * 쓰는 것: sessionStorage(구조물 · 관 초안) — 정본(서버)은 안 씀 + */ +(async () => { + const MODE = window.__m5414 || "check"; + const w = (ms) => new Promise((r) => setTimeout(r, ms)); + const until = async (fn, ms = 15000) => { + for (let t = 0; t < ms; t += 200) { + if (fn()) return true; + await w(200); + } + return false; + }; + const pid = localStorage.getItem("frd_current_project_id"); + const modUrl = (part) => + performance + .getEntriesByType("resource") + .map((e) => e.name) + .filter((n) => n.includes(part)) + .pop(); + const api = await import(modUrl("/B05_Profile/B05_Profile_Api_Structures.ts")); + const txt = (el) => el.innerText.replace(/\s+/g, " ").trim(); + const visible = (n) => n.offsetParent !== null; + const DRAFTS = /^aislo:draft:(structures|pipes):/; + const b06 = location.hash.includes("b06"); + const out = { mode: MODE, page: location.hash, fails: [] }; + const fail = (what) => out.fails.push(what); + const marks = () => [...document.querySelectorAll(".b05-structure__mark")]; + const TARGETS = { + pipe: (m) => m.dataset.structureId === "pipe-85.05", + box: (m) => m.dataset.structureId === "pipe-440.24", + underdrain: (m) => m.title.startsWith("맹암거"), + mirror: (m) => m.title.startsWith("반사경"), + etc: (m) => m.title.startsWith("안내판"), + }; + const openDock = async () => { + const handle = document.querySelector(".ui-workflow-overlay__handle--bottom"); + if (handle?.getAttribute("aria-expanded") !== "true") handle?.click(); + await until(() => marks().length > 0, 30000); + }; + + if (MODE === "seed") { + const orig = Object.fromEntries( + Object.keys(sessionStorage) + .filter((k) => DRAFTS.test(k)) + .map((k) => [k, sessionStorage.getItem(k)]), + ); + sessionStorage.setItem("__5414_orig", JSON.stringify(orig)); + const types = Object.fromEntries((await api.fetchStructureTypes()).map((t) => [t.type_id, t])); + const base = api.readPendingStructures(pid) ?? (await api.fetchStructures(pid)).structures; + const add = (id, type_id, at, options) => { + const interval = types[type_id].placement === "interval"; + return { + structure_id: id, + type_id, + placement: types[type_id].placement, + chainage_m: at, + start_m: interval ? at - 5 : null, + end_m: interval ? at + 5 : null, + options, + memo: "54-14 메모(라벨에 안 나옴)", + placement_source: "manual", + status: "draft", + revision: 0, + geometry: null, + }; + }; + api.writePendingStructures(pid, [ + ...base.filter((s) => !String(s.structure_id).startsWith("t5414")), + add("t5414ud", "cross_underdrain", 600, { culvert_height_m: 0.6 }), + add("t5414mr", "mirror", 660, {}), + add("t5414etc", "etc", 720, { name: "안내판", etc_detail_1_note: "시험 비고" }), + ]); + out.seeded = api.readPendingStructures(pid).length - base.length; + } + + if (MODE === "box") { + const rows = () => [...document.querySelectorAll(".b05-route__irregular-list li")]; + const row = () => rows().find((li) => /^22\+0 (배관|BOX암거)/.test(txt(li))); + await until(() => row(), 30000); + if (!row().classList.contains("is-selected")) row().click(); + await w(1500); + const sel = document.querySelectorAll(".b05-structure-section .b05-structure__grid select")[1]; + sel.value = "box_culvert"; + sel.dispatchEvent(new Event("change", { bubbles: true })); + await openDock(); + await until(() => marks().some((m) => TARGETS.box(m) && m.title.startsWith("BOX암거"))); + out.box = marks().find(TARGETS.box)?.title ?? null; + if (!out.box?.startsWith("BOX암거")) fail(`박스 알약 ${out.box}`); + row()?.click(); // 고른 것 풂 + await w(800); + } + + if (MODE === "check") { + await openDock(); + const body = document.querySelector(".b05-structure__lane").closest("[class*='__body']"); + const r = {}; + for (const [key, find] of Object.entries(TARGETS)) { + const mark = marks().find(find); + if (!mark) { + fail(`${key}: 알약 없음`); + continue; + } + mark.scrollIntoView({ inline: "center", block: "nearest" }); + await w(400); + const now = marks().find(find); + if (!now.classList.contains("is-selected")) now.click(); + await until(() => document.querySelector(".b05-structure__balloon")); + await w(1200); // 좌측 폼 · 레인 다시 그림 + const balloon = document.querySelector(".b05-structure__balloon"); + const box = balloon.getBoundingClientRect(); + const lines = [...balloon.children].map((c) => c.textContent); + // ① 맨 위 층 — 라벨 위 15 점에서 가장 위 요소가 라벨(재는 동안만 누름 받기 켬) + balloon.style.pointerEvents = "auto"; + let top = 0; + for (const fx of [0.1, 0.3, 0.5, 0.7, 0.9]) + for (const fy of [0.2, 0.5, 0.8]) { + const hit = document.elementFromPoint( + box.left + box.width * fx, + box.top + box.height * fy, + ); + if (hit && balloon.contains(hit)) top += 1; + } + balloon.style.pointerEvents = ""; + // ② ▲▼ 단추와 안 겹침(실좌표) · 바로 아래 ▼ 단추와 틈 + const buttons = [...document.querySelectorAll(".b05-profile-edit__btn")].map((b) => ({ + down: b.classList.contains("is-down"), + rect: b.getBoundingClientRect(), + })); + const overlap = buttons.filter( + ({ rect }) => + rect.left < box.right && + rect.right > box.left && + rect.top < box.bottom && + rect.bottom > box.top, + ).length; + const under = buttons.filter( + ({ down, rect }) => down && rect.left < box.right && rect.right > box.left, + ); + const gap = under.length ? Math.min(...under.map(({ rect }) => rect.top)) - box.bottom : null; + // ③ 줄 수 = 1 + 1 + 좌측 폼 상자 수 · 줄 머리 = 상자 제목 + const legends = [ + ...document.querySelectorAll("fieldset[data-spec-container], [data-b05-group]"), + ] + .filter(visible) + .map((f) => { + const legend = f.querySelector("legend").cloneNode(true); + legend.querySelectorAll("button").forEach((b) => b.remove()); + return legend.textContent.trim(); + }); + const heads = lines.slice(2).map((l) => l.slice(0, l.indexOf(": "))); + r[key] = { + lines, + top, + overlap, + gap, + boxes: legends.length, + z: getComputedStyle(balloon).zIndex, + clipTop: body ? box.top - body.getBoundingClientRect().top : null, + }; + if (top !== 15) fail(`${key}: 맨 위 아님 ${top}/15`); + if (overlap) fail(`${key}: 단추 겹침 ${overlap}`); + if (lines.length !== 2 + legends.length) + fail(`${key}: 줄 ${lines.length} ≠ 2 + 상자 ${legends.length}`); + if (JSON.stringify(heads) !== JSON.stringify(legends)) + fail(`${key}: 줄 머리 ${heads} ≠ 상자 ${legends}`); + if (lines.some((l) => l.includes("메모"))) fail(`${key}: 메모 줄`); + marks().find(find).click(); // 고른 것 풂 + await w(800); + } + out.read = r; + const lineMap = JSON.stringify( + Object.fromEntries(Object.entries(r).map(([k, v]) => [k, v.lines])), + ); + if (!b06) sessionStorage.setItem("__5414_b05", lineMap); + else { + out.sameAsB05 = sessionStorage.getItem("__5414_b05") === lineMap; + if (!out.sameAsB05) fail("B05 ≠ B06"); + } + } + + if (MODE === "restore") { + const orig = JSON.parse(sessionStorage.getItem("__5414_orig") || "null"); + if (!orig) return JSON.stringify({ error: "원래 값 없음" }); + for (const k of Object.keys(sessionStorage)) if (DRAFTS.test(k)) sessionStorage.removeItem(k); + for (const [k, v] of Object.entries(orig)) sessionStorage.setItem(k, v); + const now = Object.fromEntries( + Object.keys(sessionStorage) + .filter((k) => DRAFTS.test(k)) + .map((k) => [k, sessionStorage.getItem(k)]), + ); + out.restored = JSON.stringify(now) === JSON.stringify(orig); + for (const k of Object.keys(sessionStorage)) + if (k.startsWith("__5414_")) sessionStorage.removeItem(k); + } + const result = JSON.stringify(out); + if (MODE !== "restore") sessionStorage.setItem(`__5414_out_${MODE}`, result); + return result; +})(); diff --git a/resources/tester/ui_checks/54-15_잇기_램프.js b/resources/tester/ui_checks/54-15_잇기_램프.js new file mode 100644 index 000000000..44cb3e42e --- /dev/null +++ b/resources/tester/ui_checks/54-15_잇기_램프.js @@ -0,0 +1,242 @@ +/* 54-15 — 구조물 목록 잇기 램프(측점 왼쪽 · 3행 × 열 · 초록 이음 · 빨강 필수 미지정 · 노랑 선택 미지정) + * 시작 화면: B06(#/b06-section) 또는 B05 · 검증 프로젝트(sub4_검증) · 로그인 + * 부르는 법: orca eval --expression "window.__m5415={mode:'단계'}; $(cat 이 파일)" + * look → 줄마다 램프 = 폼 잇기 줄(수 · 색) 대조 · 줄 높이(램프 끈 때와 같음) · 글 시작 x · 칸 폭 + * live → ARGS.row(기본 「배관 1」) 빨강 하나 잇기 → 초록 → 해제 → 빨강 · 「도수로/돌붙임」 추가 → + * 노랑 → 잇기 → 초록 → 해제 → 노랑 → 빼기 → 램프 수 되돌아옴(저장 안 함) + * sig → 줄마다 램프 색 글(B05 · B06 대조 · 저장 · 다시 열기 대조용) + * (결과는 window.__m5415_out 에도 — orca eval 응답이 끊기면 그것을 읽음) + * link / unlink → ARGS.row 첫 빨강 잇기 / 첫 초록 해제 → [저장] → sig + */ +(async () => { + const ARGS = window.__m5415 || { mode: "look" }; + const ROW = ARGS.row || "배관 1"; + const w = (ms) => new Promise((r) => setTimeout(r, ms)); + const until = async (fn, ms = 20000) => { + for (let t = 0; t < ms; t += 200) { + if (fn()) return true; + await w(200); + } + return false; + }; + const modUrl = (part) => + performance + .getEntriesByType("resource") + .map((e) => e.name) + .filter((n) => n.includes(part)) + .pop(); + // 명세 모듈 주소 — 자원 목록에 없으면(먼저 불린 계산 모듈 주소에서) 이름만 바꿔 씀 + const specUrl = + modUrl("/design/design_structure_pipe.ts") ?? + modUrl("/design/design_structure_pipe_calc.ts").replace("_calc.ts", ".ts"); + const { STRUCTURE_SPECS } = await import(specUrl); + const items = () => [...document.querySelectorAll(".b05-route__irregular-list li")]; + const nameOf = (li) => li.querySelector("span")?.textContent ?? ""; + const rowOf = (name) => items().find((li) => nameOf(li) === name); + const color = (dot) => + dot.classList.contains("is-linked") ? "G" : dot.classList.contains("is-required") ? "R" : "Y"; + const lampsOf = (li) => [...li.querySelectorAll(".b05-route__lamps i")].map(color).join(""); + const visible = (n) => n.offsetParent !== null; + /** 폼에 보이는 잇기 줄 — 색 글(같은 차례 · 위 → 아래). */ + const formLinks = () => + [...document.querySelectorAll(".b05-structure__summary")].filter(visible).map((row) => { + if (row.querySelector(".b05-structure__summary-name.is-assigned")) return "G"; + const box = row.closest("[data-spec-container]"); + if (!box) return "R"; // 종류 단위 · 정의 그룹 상자 = 필수 + const c = Object.values(STRUCTURE_SPECS) + .flatMap((s) => s.containers) + .find( + (c) => c.id === box.dataset.specContainer && row.dataset.specLink?.startsWith(c.link), + ); + return c && !c.required ? "Y" : "R"; + }); + const sorted = (s) => [...s].sort().join(""); + const select = async (name) => { + await until(() => items().length > 0); + const li = rowOf(name); + if (!li) return false; + if (!li.classList.contains("is-selected")) { + li.click(); + await until(() => rowOf(name)?.classList.contains("is-selected"), 5000); + } + await w(700); + return true; + }; + const release = async () => { + const li = document.querySelector(".b05-route__irregular-list li.is-selected"); + if (li) li.click(); + await w(500); + }; + const sig = () => + items().map((li) => `${li.querySelector("strong")?.textContent}${nameOf(li)}:${lampsOf(li)}`); + const pickLink = async (row) => { + row.querySelector('[data-b05-summary="pick"]').click(); + await until(() => document.querySelector(".ui-item-picker__tree .ui-tree__row")); + for (let k = 0; k < 8; k++) { + const rows = [...document.querySelectorAll(".ui-item-picker__tree .ui-tree__row")]; + const leaf = rows.find((r) => !r.classList.contains("is-branch")); + if (leaf) { + leaf.click(); + break; + } + rows.find((r) => r.querySelector(".ui-tree__caret")?.textContent === "▸")?.click(); + await w(300); + } + await w(300); + [...document.querySelectorAll(".ui-item-picker__actions button")] + .find((b) => b.textContent.includes("적용")) + .click(); + await w(1500); + }; + const linkRows = () => [...document.querySelectorAll(".b05-structure__summary")].filter(visible); + const save = async () => { + document.querySelector('[data-dock-action="save"]').click(); + await w(500); + const ok = await until(() => !document.querySelector(".ui-overlay.is-active"), 90000); + await w(1500); + return ok; + }; + const out = { mode: ARGS.mode }; + + if (ARGS.mode === "look") { + await release(); + const list = document.querySelector(".b05-route__irregular-list"); + const heights = () => + items().map((li) => Math.round(li.getBoundingClientRect().height * 10) / 10); + const withLamps = heights(); + list.classList.add("m5415-off"); + const style = document.createElement("style"); + style.textContent = ".m5415-off .b05-route__lamps{display:none}"; + document.head.append(style); + const without = heights(); + style.remove(); + list.classList.remove("m5415-off"); + const xs = items().map( + (li) => Math.round(li.querySelector("strong").getBoundingClientRect().left * 10) / 10, + ); + const widths = items().map( + (li) => li.querySelector(".b05-route__lamps").getBoundingClientRect().width, + ); + const dot = items() + .map((li) => li.querySelector(".b05-route__lamps i")) + .find(Boolean) + ?.getBoundingClientRect(); + // 줄마다 폼을 열어 잇기 줄과 대조 + const rows = []; + for (const li of items()) { + const name = nameOf(li); + const lamps = lampsOf(li); + await select(name); + const form = formLinks().join(""); + rows.push({ name, lamps, form, ok: sorted(lamps) === sorted(form) }); + await release(); + } + const cols = Math.max(...rows.map((r) => Math.ceil(r.lamps.length / 3))); + Object.assign(out, { + rows: rows.length, + mismatch: rows.filter((r) => !r.ok), + lampTotal: rows.reduce((n, r) => n + r.lamps.length, 0), + colors: Object.fromEntries( + ["G", "R", "Y"].map((c) => [ + c, + rows + .map((r) => r.lamps) + .join("") + .split(c).length - 1, + ]), + ), + byKind: [...new Set(rows.map((r) => `${r.name.replace(/ \d+$/, "")}=${r.lamps.length}`))], + heightSame: JSON.stringify(withLamps) === JSON.stringify(without), + heights: [...new Set(withLamps)], + textX: [...new Set(xs)], + lampWidth: [...new Set(widths)], + cols, + dot: dot && [dot.width, dot.height], + dotColors: ["is-linked", "is-required", "is-optional"].map((c) => { + const d = document.querySelector(`.b05-route__lamps i.${c}`); + return d ? getComputedStyle(d).backgroundColor : null; + }), + }); + } + + if (ARGS.mode === "live") { + await select(ROW); + const lamps = () => lampsOf(rowOf(ROW)); + const steps = [["start", lamps(), formLinks().join("")]]; + const red = linkRows().find( + (r) => + !r.querySelector(".is-assigned") && + !r.closest("[data-spec-container]")?.querySelector("[data-spec-remove]"), + ); + if (red) { + await pickLink(red); + steps.push(["link", lamps(), formLinks().join("")]); + red.querySelector('[data-b05-summary="clear"]').click(); + await w(1500); + steps.push(["clear", lamps(), formLinks().join("")]); + } + const add = [...document.querySelectorAll("[data-spec-add]")].find(visible); + if (add) { + const id = add.dataset.specAdd; + add.click(); + await w(1500); + steps.push([`add ${id}`, lamps(), formLinks().join("")]); + // 램프 4 개 → 2 열 — 칸 폭 · 글 시작 x · 줄 높이가 목록 전체에서 하나 + const box2 = (sel) => [ + ...new Set( + items().map( + (li) => + Math.round( + li.querySelector(sel).getBoundingClientRect()[sel === "strong" ? "left" : "width"] * + 10, + ) / 10, + ), + ), + ]; + out.twoCols = { + lampWidth: box2(".b05-route__lamps"), + textX: box2("strong"), + heights: [ + ...new Set(items().map((li) => Math.round(li.getBoundingClientRect().height * 10) / 10)), + ], + }; + const box = [...document.querySelectorAll(`[data-spec-container="${id}"]`)] + .filter(visible) + .at(-1); + await pickLink(box); + steps.push(["link", lamps(), formLinks().join("")]); + box.querySelector('[data-b05-summary="clear"]').click(); + await w(1500); + steps.push(["clear", lamps(), formLinks().join("")]); + [...document.querySelectorAll(`[data-spec-container="${id}"]`)] + .filter(visible) + .at(-1) + .querySelector("[data-spec-remove]") + .click(); + await w(1500); + steps.push(["remove", lamps(), formLinks().join("")]); + } + out.steps = steps.map(([s, l, f]) => `${s}: ${l} (폼 ${f})`); + out.back = steps[0][1] === steps.at(-1)[1]; + await release(); + } + + if (ARGS.mode === "link" || ARGS.mode === "unlink") { + await select(ROW); + const target = linkRows().find((r) => + ARGS.mode === "link" ? !r.querySelector(".is-assigned") : r.querySelector(".is-assigned"), + ); + if (ARGS.mode === "link") await pickLink(target); + else { + target.querySelector('[data-b05-summary="clear"]').click(); + await w(1500); + } + out.row = lampsOf(rowOf(ROW)); + out.saved = await save(); + await release(); + } + + out.sig = sig(); + window.__m5415_out = JSON.stringify(out); // 응답이 끊겨도 뒤에 읽음 + return window.__m5415_out; +})(); diff --git a/resources/tester/ui_checks/54-20_좌측단추_치수.js b/resources/tester/ui_checks/54-20_좌측단추_치수.js new file mode 100644 index 000000000..e99c7b4dd --- /dev/null +++ b/resources/tester/ui_checks/54-20_좌측단추_치수.js @@ -0,0 +1,83 @@ +/* 54-20 — B02~B09 좌측 패널 아래 공통 단추 묶음([?] · [초기화] · [저장] · [확정] · [횡단 보기] …) 치수 + * 시작 화면: 아무 B 화면(로그인 · 환경 배정 storage 프로젝트가 현재 프로젝트) + * 부르는 법: orca eval --expression "$(cat 이 파일)" · 1분 넘으면 끊기므로 + * `window.__btn5420 = ["b05-profile", …];` 를 앞에 붙여 나눠 부름 + * 쓰는 것: 없음(주소 이동 · 재기만 · 끝나면 처음 주소로) + * 기대 수치: + * - 줄마다 보이는 단추 h 하나 · fs 하나 · ff 하나 · 줄 안 gap 하나 · wrap = false · over = false + * - [?] = 정사각(w = h) + * - 좁은 패널(narrow = true 로 패널 폭을 최소로 줄여 다시 잼)에서도 wrap · over 없음 + */ +(async () => { + const w = (ms) => new Promise((r) => setTimeout(r, ms)); + const vis = (n) => n && n.offsetParent !== null; + const start = location.hash; + const routes = window.__btn5420 ?? [ + "b02-proj-register", + "b03-file-input", + "b04-preprocess", + "b05-profile", + "b06-section", + "b07-design-detail", + "b08-quantity", + "b09-estimation", + ]; + const measure = () => { + const help = [...document.querySelectorAll(".ui-help-btn")].find(vis); + const rows = help + ? [help.closest(".ui-sidebar-actions") ?? help.parentElement] + : [...document.querySelectorAll(".ui-sidebar-actions")].filter(vis); + return rows.map((row) => { + const btns = [...row.querySelectorAll("button")].filter(vis); + const rr = row.getBoundingClientRect(); + return { + row: row.className, + rowW: Math.round(rr.width), + gap: getComputedStyle(help?.parentElement ?? row).gap, + btns: btns.map((b) => { + const s = getComputedStyle(b); + const r = b.getBoundingClientRect(); + const text = document.createRange(); + text.selectNodeContents(b); + return { + t: b.innerText.trim(), + cls: b.className, + h: +r.height.toFixed(1), + w: +r.width.toFixed(1), + fs: s.fontSize, + fw: s.fontWeight, + ff: s.fontFamily.split(",")[0], + pad: s.padding, + bd: s.borderWidth + " " + s.borderStyle, + rad: s.borderRadius, + top: +(r.top - rr.top).toFixed(1), + wrap: text.getBoundingClientRect().height > parseFloat(s.fontSize) * 1.6, + // 글이 단추 안쪽(여백 뺀 폭)을 넘거나 단추가 줄 밖으로 나감 + over: + text.getBoundingClientRect().width > + b.clientWidth - parseFloat(s.paddingLeft) - parseFloat(s.paddingRight) + 0.5 || + r.right > rr.right + 1, + }; + }), + }; + }); + }; + const out = []; + for (const route of routes) { + location.hash = `#/${route}`; + await w(4500); + const res = { page: route.slice(0, 3).toUpperCase(), rows: measure() }; + const panel = document.querySelector(".ui-workflow-overlay__panel--title"); + if (window.__btn5420narrow && panel) { + const was = panel.style.width; + panel.style.width = "var(--wf-left-panel-narrow-width)"; // 좁은 창(860px 아래) 폭 + await w(400); + res.narrowW = Math.round(panel.getBoundingClientRect().width); + res.narrow = measure(); + panel.style.width = was; + } + out.push(res); + } + location.hash = start; + return JSON.stringify(out); +})(); diff --git a/ui_template/ui_template_help.css b/ui_template/ui_template_help.css index 7c9f3f69e..3585bea06 100644 --- a/ui_template/ui_template_help.css +++ b/ui_template/ui_template_help.css @@ -2,9 +2,10 @@ .ui-sidebar-actions .ui-help-btn, .ui-help-btn { flex: none; - width: auto; - min-width: 2.25rem; - padding-inline: var(--spacing-12); + /* 정사각 — 너비 = 높이(글 한 줄 · 위아래 여백 · 테두리) */ + width: calc(1em + 2 * var(--spacing-8) + 2px); + min-width: 0; + padding-inline: 0; } .ui-help.ui-modal { diff --git a/ui_template/ui_template_overlay.css b/ui_template/ui_template_overlay.css index e7b8a37c0..cff809cab 100644 --- a/ui_template/ui_template_overlay.css +++ b/ui_template/ui_template_overlay.css @@ -368,6 +368,21 @@ min-width: 0; } +/* 아래 단추 묶음(54-20) — B03~B09 한 양식 · 사이 = --btn-group-gap · 고르게 늘되 글보다 좁아지지 + 않음 · 좁은 패널(280px)에서도 한 줄. 단추 줄 = [data-help-row](없으면 묶음 자신) */ +.ui-sidebar-actions:not(:has([data-help-row])), +.ui-sidebar-actions [data-help-row] { + gap: var(--btn-group-gap); +} + +.ui-sidebar-actions > .ui-btn:not(.ui-help-btn), +.ui-sidebar-actions [data-help-row] > .ui-btn:not(.ui-help-btn) { + flex: 1 1 0; + min-width: auto; + padding-inline: var(--spacing-4); + white-space: nowrap; +} + /* 사이드 패널 내부 컨테이너 공통 외곽선 — 기본 --color-border가 두 테마 모두 흐려 안 보인다는 보고(2026-08-05). 본문 보조색을 섞어 어느 테마에서든 뚜렷하게. 45%는 아직 흐리다는 재보고(2026-08-06)로 보조색 비중을 75%로 올렸다 — 테마 변수