diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index 2c411ffe8..37d5c757f 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -64,6 +64,8 @@ export const STATE_REGISTRY = { "m02-combo-panel-width": { bucket: "pref", scope: "global" }, /** M02 면적산출 카드 미리보기 아래 알약 줄 보임(기본 숨김 · 상세는 늘 보임) */ "m02-card-pills": { bucket: "pref", scope: "global" }, + /** M01 단가산출 로직 상세 오른쪽 — 열린 패널 하나(flow · source · none) */ + "m01-logic-panel": { bucket: "pref", scope: "global" }, /** B05 계획노선 편집 창 — 보던 화면(회전 · 가운데 · 배율). 프로젝트마다 따로 */ "routeedit-view": { bucket: "pref", scope: "project" }, /** B05 계획노선 편집 창 — 시점 · 종점 · 교각점 고정. [저장] 대상이 아니라 draft 에 안 둠 */ diff --git a/A00_Common/design/design_panel.ts b/A00_Common/design/design_panel.ts index 76094010f..08333ed9b 100644 --- a/A00_Common/design/design_panel.ts +++ b/A00_Common/design/design_panel.ts @@ -112,6 +112,13 @@ export function createDesignPanel(ctx: DesignPanelContext, view: DesignView): De const dockParts: HTMLElement[] = []; const dock = createDesignDock(view, dockParts, ctx.dock); + // 칸은 공용 스크롤 칸(쪼갠 뒤) 안에 둔다 — 쪼개기 전이면 패널 루트의 단추 묶음 앞. + const place = (element: HTMLElement): void => { + const scroll = root.querySelector(":scope > .ui-sidebar-scroll"); + if (scroll) scroll.append(element); + else root.insertBefore(element, dock.root); + }; + const show = (target: DesignView): void => { const wanted = new Set(panelSectionsFor(target).map((def) => def.id)); for (const def of panelSectionsFor(target)) { @@ -120,12 +127,12 @@ export function createDesignPanel(ctx: DesignPanelContext, view: DesignView): De const sectionRoot = "root" in made ? made.root : made; if ("root" in made && made.dock) dock.root.prepend(made.dock); built.set(def.id, sectionRoot); - root.insertBefore(sectionRoot, dock.root); + place(sectionRoot); } // 차례를 정의 순서로 다시 맞춘다 — 늦게 만들어진 칸이 맨 아래에 남지 않게. for (const def of panelSectionsFor(target)) { const element = built.get(def.id); - if (element) root.insertBefore(element, dock.root); + if (element) place(element); } built.forEach((element, id) => { // 칸이 스스로 숨긴 것(선택 포인트)은 건드리지 않고, 보기 밖 칸만 가리고 되살린다. @@ -144,7 +151,9 @@ export function createDesignPanel(ctx: DesignPanelContext, view: DesignView): De dock, setView: (next) => { dock.setView(next); - root.className = next === "profile" ? "b05-route__panel" : "b06-profile__form"; + // 클래스 통째 덮기 금지 — 공용 쪼개기가 붙인 ui-sidebar-fill 이 지워져 단추 묶음이 바닥에서 뜬다. + root.classList.toggle("b05-route__panel", next === "profile"); + root.classList.toggle("b06-profile__form", next !== "profile"); show(next); }, }; diff --git a/A00_Common/design/design_shared.ts b/A00_Common/design/design_shared.ts index 18fe1a8ba..622ceb954 100644 --- a/A00_Common/design/design_shared.ts +++ b/A00_Common/design/design_shared.ts @@ -12,6 +12,7 @@ import { ROUTES } from "@config/config_frontend"; import { showToast } from "@ui/ui_template_elements"; +import { splitSidebarActions } from "@ui/ui_template_overlay"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../b_workflow_nav"; import { navigateTo } from "../router"; @@ -229,6 +230,9 @@ export function sharedPanel(shared: DesignPageShared): DesignPanel { shared.view(), ); shared.left.append(panel.root); + // 틀이 먼저 섰으면 좌측 자리가 빈 채로 지나갔다 — 붙인 지금 [스크롤 칸][아래 고정 단추]로 쪼갠다. + const body = shared.left.closest(".ui-workflow-overlay__body"); + if (body) splitSidebarActions(body); shared.panel = panel; return panel; } diff --git a/B01_Dashboard/B01_Dashboard_UI_Page.ts b/B01_Dashboard/B01_Dashboard_UI_Page.ts index 0c677c35d..4443b06a8 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Page.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Page.ts @@ -178,7 +178,6 @@ function buildSystemSettingsPanel(user: DashboardUser, companyId: number | null) settingCard(L("B01_Dashboard_MasterElements"), L("B01_Dashboard_PermSourceRead"), () => navigateTo(ROUTES.M01_MASTER_DATA), ), - m02(L("B01_Dashboard_CardUnitCombo"), "일위대가", "master", desc), m02(L("B01_Dashboard_CardStructCombo"), "구조물조합", "master", desc), m02(L("B01_Dashboard_CardStructOption"), "옵션", "master", desc), m02(L("B01_Dashboard_CardDrawing"), "도면", "master", desc), @@ -193,10 +192,7 @@ function buildSystemSettingsPanel(user: DashboardUser, companyId: number | null) L(companyAdmin ? "B01_Dashboard_PermCompanyWrite" : "B01_Dashboard_PermCompanyRead"), L("B01_Dashboard_PermPersonalWrite"), ].join(" · "); - wrap.append( - m02(L("B01_Dashboard_CardUnitCombo"), "일위대가", root, desc), - m02(L("B01_Dashboard_CardStructCombo"), "구조물조합", root, desc), - ); + wrap.append(m02(L("B01_Dashboard_CardStructCombo"), "구조물조합", root, desc)); return wrap; } diff --git a/B04_PreProcess/B04_PreProcess_Api_Fetch.ts b/B04_PreProcess/B04_PreProcess_Api_Fetch.ts index cf257786b..aebd1a97f 100644 --- a/B04_PreProcess/B04_PreProcess_Api_Fetch.ts +++ b/B04_PreProcess/B04_PreProcess_Api_Fetch.ts @@ -17,7 +17,7 @@ import { API_SURFACE_BUILD_TIMEOUT_MS, API_TIMEOUT_MS, } from "@config/config_frontend"; -import type { SummaryItem } from "../common_util/common_util_summary_item"; +import type { GroupItems, SummaryItem } from "../common_util/common_util_summary_item"; /** 지표면 분석 실행 요청 (SurfaceAnalyzeRequest) */ export interface SurfaceAnalyzeRequest { @@ -494,6 +494,8 @@ export interface DetailPipePoint { options?: Record; /** 이 관이 뜻하는 구조물 집계표 항목(21-1 · options 밖 칸) — 없으면 null. */ summary_item?: SummaryItem | null; + /** 옵션 그룹마다 고른 집계 항목 `{그룹 id: 참조}`(PLAN 46-3) — 없으면 그룹 미지정. */ + group_items?: GroupItems | null; } /** 편집·저장 요청에 싣는 관 1건 — 좌표(lonlat)는 서버가 다시 계산하므로 뺀다. */ diff --git a/B04_PreProcess/B04_PreProcess_Router_Basins.py b/B04_PreProcess/B04_PreProcess_Router_Basins.py index 886996e70..0d7916ff9 100644 --- a/B04_PreProcess/B04_PreProcess_Router_Basins.py +++ b/B04_PreProcess/B04_PreProcess_Router_Basins.py @@ -23,6 +23,7 @@ from fastapi import APIRouter from fastapi.responses import JSONResponse from B04_PreProcess.B04_PreProcess_Engine_Watershed_Export import drainage_dir +from B05_Profile.B05_Profile_Structures_Schema import structure_type_map from common_util.common_util_drainage_context import DrainageContext, load_drainage_context from common_util.common_util_drainage_detail import DrainageDetail, build_detail from common_util.common_util_drainage_pipes import ( @@ -30,6 +31,7 @@ from common_util.common_util_drainage_pipes import ( PIPE_SOURCE_USER, PipePoint, carry_facility_attributes, + carry_indices, clear_pipe_points, load_pipe_points, parse_pipe_points, @@ -64,14 +66,40 @@ def _build( if points: _retag(detail.pipes, points) rebuilt = [PipePoint(chainage_m=pipe.chainage_m, source=pipe.reason) for pipe in detail.pipes] + # 원본에 없던 자리 = 이번에 새로 놓인 관(자동 배치 · 간격 보충) — 등록부 기본값을 받을 관 + fresh = [ + pick is None + for pick in carry_indices( + [point.chainage_m for point in rebuilt], [point.chainage_m for point in points or []] + ) + ] # 계산기는 chainage만 다뤄 시설 종류·구간·옵션이 사라진다 — 원본에서 되붙인다 # (2026-08-17 계곡 통과 시설: 배관/BOX암거/물넘이/세월교). carried = carry_facility_attributes(rebuilt, points or []) # 되붙인 뒤에 추천을 얹는다 — 순서가 뒤바뀌면 승계된 사용자 값이 추천에 덮인다. _apply_recommendations(detail, carried) + _apply_new_defaults([point for point, new in zip(carried, fresh) if new]) return detail, carried +def _apply_new_defaults(points: list[PipePoint]) -> None: + """새로 놓인 관에만 등록부 기본값을 **빈 키에** 채움(PLAN 46-7 · 추천 뒤 — 추천 관경이 이김). + + 시설이 생길 때 기본값을 칸에 실제 값으로 넣어 두고 사용자가 고침 — 이미 있던 관의 빈 칸은 그대로 + (옛 프로젝트 빈칸 = 미확정 유지 · 사용자 결정 2026-10-05). + """ + types = structure_type_map() + for point in points: + definition = types.get(point.facility) + if definition is None: + continue + options = dict(point.options or {}) + for field in definition.options: + if field.key not in options and field.default not in (None, ""): + options[field.key] = field.default + point.options = options or None + + def _retag(pipes: list[StructureCandidate], points: list[PipePoint]) -> None: """확정 좌표로 되돌아온 관에 원래의 생성 사유를 다시 붙인다.""" by_chainage = {round(point.chainage_m, 2): point.source for point in points} diff --git a/B05_Profile/B05_Profile_Api_Structures.ts b/B05_Profile/B05_Profile_Api_Structures.ts index 98248d1c7..02146289b 100644 --- a/B05_Profile/B05_Profile_Api_Structures.ts +++ b/B05_Profile/B05_Profile_Api_Structures.ts @@ -12,7 +12,7 @@ * ========================================================================== */ import { readState, writeState } from "../A00_Common/b_page_state"; -import type { SummaryItem } from "../common_util/common_util_summary_item"; +import type { GroupItems, SummaryItem } from "../common_util/common_util_summary_item"; import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend"; /** 배치형태 — 점형(측점 1개) / 구간형(시~종점) / 부지형(위치+면적). */ @@ -77,6 +77,15 @@ export interface StructureType { design_owner: string | null; reference_only: boolean; enabled: boolean; + /** 옵션 그룹(M02 PLAN 46) — 폼의 보조 컨테이너(제목 = name) · 안의 옵션 키 · 빈 그룹도 됨. */ + groups?: StructureOptionGroup[]; +} + +/** 옵션 그룹 한 개 — 서버가 M02 「구조물 옵션 보이기」 그룹 표로 채움. */ +export interface StructureOptionGroup { + id: string; + name: string; + options: string[]; } export interface StructureInstance { @@ -94,6 +103,8 @@ export interface StructureInstance { geometry: Record | null; /** 이 구조물이 뜻하는 구조물 집계표 항목(21-1) — 없으면 null(미지정 · 집계표에 안 들어감). */ summary_item?: SummaryItem | null; + /** 옵션 그룹마다 고른 집계 항목 `{그룹 id: 참조}`(PLAN 46-3) — 없으면 그룹 미지정. */ + group_items?: GroupItems | null; } interface StructureTypesResponse { diff --git a/B05_Profile/B05_Profile_Structure_Types.json b/B05_Profile/B05_Profile_Structure_Types.json index 5353df625..43d980283 100644 --- a/B05_Profile/B05_Profile_Structure_Types.json +++ b/B05_Profile/B05_Profile_Structure_Types.json @@ -382,9 +382,10 @@ "label": "월류 폭", "input": "number", "unit": "m", - "default": null, - "required": true, - "phase": "detail" + "default": 5, + "required": false, + "phase": "detail", + "default_basis": "물넘이 월류 폭 기본 5m — 사용자 확정값(엔진 FORD_PAVEMENT_DEFAULT_WIDTH_M 과 같음)" }, { "key": "ford_slope_pct", @@ -458,9 +459,10 @@ "label": "월류 폭", "input": "number", "unit": "m", - "default": null, - "required": true, - "phase": "detail" + "default": 10, + "required": false, + "phase": "detail", + "default_basis": "세월교 월류 폭 기본 10m — 사용자 확정값(엔진 FORD_DEFAULT_WIDTH_M 과 같음)" }, { "key": "wing_in", diff --git a/B05_Profile/B05_Profile_Structures_Repository.py b/B05_Profile/B05_Profile_Structures_Repository.py index 30ae8a441..273874bef 100644 --- a/B05_Profile/B05_Profile_Structures_Repository.py +++ b/B05_Profile/B05_Profile_Structures_Repository.py @@ -14,7 +14,7 @@ from typing import Any, Iterable from B05_Profile.B05_Profile_Structures_Schema import StructureInstance, structure_type_map from common_util.common_util_json import atomic_write_json -from M02_MasterTemplete.M02_MasterTemplete_Options import hidden_keys +from M02_MasterTemplete.StructOptions.M02_SO_Options import hidden_keys STRUCTURES_FILE_NAME = "structures.json" _STAGE_DIR = os.path.join("B05_Profile", "route") diff --git a/B05_Profile/B05_Profile_Structures_Router.py b/B05_Profile/B05_Profile_Structures_Router.py index 1a18351f3..fcf149b02 100644 --- a/B05_Profile/B05_Profile_Structures_Router.py +++ b/B05_Profile/B05_Profile_Structures_Router.py @@ -42,7 +42,7 @@ from common_util.common_util_drainage_pipes import ( ) from common_util.common_util_storage import resolve_stored_project_path from config.config_db import get_db_pool -from M02_MasterTemplete.M02_MasterTemplete_Options import TypesWithVisible, with_visible +from M02_MasterTemplete.StructOptions.M02_SO_Options import TypesWithVisible, with_visible logger = logging.getLogger(__name__) diff --git a/B05_Profile/B05_Profile_Structures_Schema.py b/B05_Profile/B05_Profile_Structures_Schema.py index 42ea75e91..b98fe87e4 100644 --- a/B05_Profile/B05_Profile_Structures_Schema.py +++ b/B05_Profile/B05_Profile_Structures_Schema.py @@ -15,7 +15,7 @@ from typing import Any, Literal from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator -from common_util.common_util_summary_item import parse_summary_item +from common_util.common_util_summary_item import parse_group_items, parse_summary_item # 배치형태: 점형(측점 1개) / 구간형(시~종점) / 부지형(위치+면적). STRUCTURE_PLACEMENTS = ("point", "interval", "site") @@ -160,12 +160,19 @@ class StructureInstance(BaseModel): geometry: dict[str, Any] | None = None # 구조물 집계표 항목(PLAN 21-1) — 없으면 null(옛 파일 호환) · [저장] 뒤 프로젝트 스냅샷 summary_item: dict[str, Any] | None = None + # 옵션 그룹(M02 PLAN 46)마다 고른 집계 항목 `{그룹 id: summary_item}` — 본체는 `summary_item` + group_items: dict[str, Any] | None = None @field_validator("summary_item") @classmethod def validate_summary_item(cls, value: Any) -> dict[str, Any] | None: return parse_summary_item(value) + @field_validator("group_items") + @classmethod + def validate_group_items(cls, value: Any) -> dict[str, Any] | None: + return parse_group_items(value) + @model_validator(mode="after") def validate_placement_fields(self) -> "StructureInstance": if self.placement == "interval": diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts index 3bc90bf7b..ee870d18b 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts @@ -53,13 +53,17 @@ import { optionalSelect, OUTLET_REVET_KEYS, putNumber, - REVET_COMMON_DEFAULTS, stepper, WING_IN_KEYS, WING_OUT_KEYS, } from "./B05_Profile_UI_Drainage_Facility_Fields"; import { pipeWallMinHeightM } from "../common_util/common_util_culvert_sets"; -import type { SummaryItem } from "../common_util/common_util_summary_item"; +import { + hasLinks, + linkFields, + type GroupItems, + type SummaryItem, +} from "../common_util/common_util_summary_item"; import { createFordFields } from "./B05_Profile_UI_Drainage_Facility_Ford"; import { createHiddenOptions } from "./B05_Profile_UI_Drainage_Facility_Hidden"; @@ -74,11 +78,13 @@ export interface FacilityAttributes { options?: Record; /** 이 관이 뜻하는 구조물 집계표 항목(21-1 · options 밖) — null/없음 = 미지정. */ summary_item?: SummaryItem | null; + /** 옵션 그룹마다 고른 집계 항목(PLAN 46-3) — 없음 = 그룹 미지정. */ + group_items?: GroupItems | null; } /** 확장 정보가 실제로 있는지 — 배관이면서 아무 부속도 없으면 보관할 이유가 없다. */ function hasExtras(attributes: FacilityAttributes): boolean { - if (attributes.summary_item) return true; // 집계 항목만 지정한 맨 배관도 보관한다 + if (hasLinks(attributes)) return true; // 집계 항목(본체 · 그룹)만 지정한 맨 배관도 보관한다 if (attributes.facility !== "pipe") return true; if (attributes.start_m !== undefined || attributes.end_m !== undefined) return true; return !!attributes.options && Object.keys(attributes.options).length > 0; @@ -122,13 +128,13 @@ export function createFacilityStore() { end_m?: number; options?: Record; summary_item?: SummaryItem | null; + group_items?: GroupItems | null; }>, ): void { entries = points .map((point) => { // 응답이 정본이다 — 서버는 미지정이면 칸을 안 싣는다(`as_dict`). 예전엔 칸이 없으면 화면이 쥔 // 값을 이어받았는데, 횡단 보기에서 [해제]한 초안이 응답으로 돌아올 때 옛 지정이 되살아났다. - const summary = point.summary_item ?? null; return { chainage: point.chainage_m, attributes: { @@ -136,7 +142,7 @@ export function createFacilityStore() { start_m: point.start_m, end_m: point.end_m, options: point.options, - ...(summary ? { summary_item: summary } : {}), + ...linkFields(point), } as FacilityAttributes, }; }) @@ -176,9 +182,9 @@ export function createFacilityStore() { return points.map((point, index) => { const pick = picks[index]; if (pick === null) return { ...point }; - const { summary_item: summary, ...rest } = entries[pick].attributes; + const { summary_item: _s, group_items: _g, ...rest } = entries[pick].attributes; // 미지정(null)은 칸을 안 싣는다 — 서버가 모르는 칸을 거절하는 동안에도 계산이 안 깨짐. - return { ...point, ...rest, ...(summary ? { summary_item: summary } : {}) }; + return { ...point, ...rest, ...linkFields(entries[pick].attributes) }; }); }, }; @@ -239,11 +245,11 @@ export function createFacilityOptionsForm( root.hidden = true; // ── 배관 본체 — 관종 > 관경 한 행(2026-08-17 사용자 지시 4) ────────────── - // 관종 파형강관·관경 1000(사용자 확정 기본)은 **제안으로만** — 「안 정함」이 실제 상태(판정 ③). - const pipeMaterial = optionalSelect("— 안 정함 (제안 파형강관) —", ["흄관", "VR관", "파형강관"]); + // 기본값(관종 파형강관 · 관경 1000 · 등록부)은 시설이 생길 때 칸에 실제 값으로 들어옴(PLAN 46-7). + const pipeMaterial = optionalSelect("— 안 정함 —", ["흄관", "VR관", "파형강관"]); const pipeDiameter = document.createElement("select"); pipeDiameter.replaceChildren( - new Option("— 안 정함 (제안 Ø1000) —", ""), + new Option("— 안 정함 —", ""), ...["800", "1000", "1200", "1500"].map((size) => new Option(`Ø${size}`, size)), ); // 관보호공 날개벽 — **개소당 원단위**가 형식마다 붙박이라 고르는 칸이 있어야 물량이 선다 @@ -257,10 +263,10 @@ export function createFacilityOptionsForm( // ── 유입구 — 집수정 또는 기슭막이 택일(사용자 지시 5~9) ───────────────── const inletGroup = group("유입구"); - // 「안 정함」이면 기슭막이 칸을 보이되(제안) 값은 안 실음 — 기본값을 몰래 확정으로 안 바꿈(판정 ③). + // 「안 정함」이면 기슭막이 칸을 보이되 값은 안 실음. const inletType = document.createElement("select"); inletType.replaceChildren( - new Option("— 안 정함 (제안 기슭막이) —", ""), + new Option("— 안 정함 —", ""), ...INLET_KINDS.map((kind) => new Option(kind.label, kind.label)), ); const basinForm = optionalSelect("선택", [ @@ -278,7 +284,7 @@ export function createFacilityOptionsForm( ]); const basinMaterialField = labeled("형태", basinMaterial, "inlet_basin_material"); // 집수정 종방향 길이 — 3D 예상형상용, 전후 동일 배분·기본 2m(2026-08-23 사용자). - const basinLength = numberInput("0.1", "0", "제안 2"); + const basinLength = numberInput("0.1", "0"); // 기준측점 전 · 후 — 옛 횡단 조정창에만 있던 칸을 폼으로(계획서 24-4a). const basinSplit = createBasinSplitFields(basinLength); basinSplit.onChange(() => emit()); @@ -291,11 +297,7 @@ export function createFacilityOptionsForm( ]; const height = (): string => String(pipeWallMinHeightM(Number(pipeDiameter.value || "1000") / 1000)); - const inletRevet = createRevetmentFields(INLET_REVET_KEYS, { - ...REVET_COMMON_DEFAULTS, - form: "돌쌓기(찰)", - height, - }); + const inletRevet = createRevetmentFields(INLET_REVET_KEYS, { height }); // 첫 행 = [구조][형태] — 구조에 따라 집수정 형태와 기슭막이 형태가 갈아 끼워진다 // (2026-08-17 사용자 지시 3). // 조정창에서 옮겨 오는 행(단 수·옵션)이 붙을 자리(2026-08-29 사용자 지시 4). @@ -316,12 +318,8 @@ export function createFacilityOptionsForm( // ── 유출구 — 구조 선택지는 기슭막이뿐이지만 양식을 맞춘다(사용자 지시 2·4) ── const outletGroup = group("유출구"); - const outletType = optionalSelect("— 안 정함 (제안 기슭막이) —", ["기슭막이"]); - const outletRevet = createRevetmentFields(OUTLET_REVET_KEYS, { - ...REVET_COMMON_DEFAULTS, - form: "돌쌓기(메)", - height, - }); + const outletType = optionalSelect("— 안 정함 —", ["기슭막이"]); + const outletRevet = createRevetmentFields(OUTLET_REVET_KEYS, { height }); outletGroup.body.append( grid(labeled("구조", outletType, "outlet_type"), outletRevet.formField), ...outletRevet.rows, @@ -342,10 +340,10 @@ export function createFacilityOptionsForm( // 나눈다(2026-08-30 사용자). 설치 측이 한쪽이면 그 칸만 뜬다. // 좌·우가 저장 채널(유입/유출) 중 어느 쪽인지는 측점 지형이 정하므로 이름표만 // 바꿔 단다(`setRevetSideLabels`) — 값은 언제나 그 채널 키로 오간다. - const revetSide = optionalSelect("— 안 정함 (제안 양쪽) —", ["양쪽", "좌", "우"]); + const revetSide = optionalSelect("— 안 정함 —", ["양쪽", "좌", "우"]); const revetTopRow = grid(labeled("설치 측", revetSide, "side")); - const revetInlet = createRevetSideGroup(INLET_REVET_KEYS, "돌쌓기(메)"); - const revetOutlet = createRevetSideGroup(OUTLET_REVET_KEYS, "돌쌓기(메)"); + const revetInlet = createRevetSideGroup(INLET_REVET_KEYS); + const revetOutlet = createRevetSideGroup(OUTLET_REVET_KEYS); /** 좌·우 이름표 — B06이 그 측점의 벽 방향을 알려 주면 바뀐다. 기본은 유입=좌. */ let revetSideLabels: { inlet: "좌" | "우"; outlet: "좌" | "우" } = { inlet: "좌", @@ -380,12 +378,12 @@ export function createFacilityOptionsForm( // ── BOX암거 — 본체 규격(프리셋 + 사용자 지정)·날개벽(유입·유출 개별) ──── // 폭·높이 자유 입력은 "사용자 지정"을 골랐을 때만 펼친다(2026-08-17 사용자 확정). - const boxSize = optionalSelect(`— 안 정함 (제안 ${BOX_SIZE_PRESETS[0][0]}) —`, [ + const boxSize = optionalSelect("— 안 정함 —", [ ...BOX_SIZE_PRESETS.map(([label]) => label), BOX_SIZE_CUSTOM, ]); - const boxWidth = numberInput("0.1", "0", `제안 ${BOX_SIZE_PRESETS[0][1]}`); - const boxHeight = numberInput("0.1", "0", `제안 ${BOX_SIZE_PRESETS[0][2]}`); + const boxWidth = numberInput("0.1", "0"); + const boxHeight = numberInput("0.1", "0"); const boxCustomRow = grid( labeled("본체 폭 (m)", boxWidth, "body_width_m"), labeled("본체 높이 (m)", boxHeight, "body_height_m"), @@ -408,33 +406,14 @@ export function createFacilityOptionsForm( boxWidth.value = String(preset[1]); boxHeight.value = String(preset[2]); } - // 「안 정함」도 폭·높이 칸을 접음 — 값이 없는 상태 그대로(제안은 빈 보기 이름). + // 「안 정함」도 폭·높이 칸을 접음 — 값이 없는 상태 그대로. boxCustomRow.hidden = boxSize.value !== BOX_SIZE_CUSTOM; } - // ── 물넘이·세월교 — 월류 폭·높이·경사·두께·길이·련 수(`_Ford` · 기본값은 제안으로만). + // ── 물넘이·세월교 — 월류 폭·높이·경사·두께·길이·련 수(`_Ford`). + // 기본값(등록부)은 시설이 생길 때 칸에 실제 값으로 들어옴 — 제안값 단추 없음(PLAN 46-7). const ford = createFordFields(() => emit()); - // [제안값 넣기] — 누른 때만 빈 칸에 제안값(관종·관경·구조·집수정 길이·설치 측·BOX 규격·날개벽·월류). - // 벽 칸(기슭막이)은 벽마다 단추가 따로 있음. 기본값을 몰래 확정으로 바꾸지 않는다(2026-09-14 판정 ③). - const suggest = document.createElement("button"); - suggest.type = "button"; - suggest.className = "b05-structure__suggest"; - suggest.textContent = "제안값 넣기"; - suggest.addEventListener("click", () => { - if (!pipeMaterial.value) pipeMaterial.value = "파형강관"; - if (!pipeDiameter.value) pipeDiameter.value = "1000"; - if (!inletType.value) inletType.value = INLET_KINDS[0].label; - if (!outletType.value) outletType.value = "기슭막이"; - if (!basinLength.value) basinLength.value = "2"; - if (!revetSide.value) revetSide.value = "양쪽"; - if (current === "box_culvert" && !boxSize.value) boxSize.value = BOX_SIZE_PRESETS[0][0]; - wingInFields.fillSuggested(); - wingOutFields.fillSuggested(); - if (current === "ford_pavement" || current === "ford_bridge") ford.fillSuggested(current); - emit(); - }); - // 10-A ⑲ 저장 규칙을 폼 머리에 드러냄 — 병합은 `_Drainage_Facility_Merge`(2026-09-14 브레인 판정). const saveNote = document.createElement("p"); saveNote.className = "b05-structure__owner-note"; @@ -444,7 +423,6 @@ export function createFacilityOptionsForm( // 세월교·물넘이 항목은 **관종·관경 바로 다음**에 둔다(2026-08-30 사용자 지시 2) — // 월류 폭·높이 → 바닥 경사 → 수량 → 개략 단면 결과. 다른 시설에서는 전부 숨는다. root.append( - grid(suggest), saveNote, pipeRow, wingRow, @@ -586,7 +564,7 @@ export function createFacilityOptionsForm( } const text = (key: string): string => options[key] !== undefined ? String(options[key]) : ""; - // ⚠ 저장된 값만 칸에 — 없으면 「안 정함」 · 제안은 빈 보기 이름·회색 글씨(2026-09-14 판정 ③). + // 받은 값만 칸에 — 없으면 「안 정함」(새 시설은 부르는 쪽이 등록부 기본값을 실어 줌 · 46-7). pipeMaterial.value = text("pipe_kind"); pipeDiameter.value = text("pipe_diameter_mm"); // 병합 목록: 저장된 정본 둘(inlet_type·inlet_structure)로 라벨을 되찾는다. @@ -609,7 +587,7 @@ export function createFacilityOptionsForm( inletRevet.write(options); outletRevet.write(options); // 저장된 폭·높이가 프리셋과 맞으면 그 프리셋을, 아니면 "사용자 지정"을 고른다. - // 값이 없으면 「안 정함」(첫 프리셋 2.0×2.0 은 제안으로만). + // 값이 없으면 「안 정함」. const savedWidth = Number.parseFloat(text("body_width_m")); const savedHeight = Number.parseFloat(text("body_height_m")); const hasSaved = diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts index f6617b4dc..69906964b 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts @@ -212,27 +212,13 @@ export interface RevetmentKeys { after?: string; } -/** 부위별 **제안값** — 형태는 유입 돌쌓기(찰)·유출 돌쌓기(메), 길이 10m. 관 벽 높이는 관경 기준 - * (함수로 받아 볼 때마다 셈). - * ⚠ 제안값은 **칸에 채우지 않고 회색 글씨로만** 보임 — 사용자가 적거나 [제안값 넣기]를 누른 때만 - * 값이 됨(2026-09-14 브레인 판정 「기본값을 몰래 확정으로 바꾸지 않는다」). 칸에 채워 두면 저장이 - * 그 값을 적어 **미확정이 저장 한 번에 확정으로 바뀜** — 사용자가 한 일이 없는데 금액이 섬. */ +/** 높이 칸 안내 — 관 벽은 비우면 관경 기준 최소 높이(엔진 `pipe_wall_min_height_m` · 함수로 받아 + * 볼 때마다 셈) · 빈 글이면 「설계자 입력」. 형태 · 길이 · 전후 기본값은 등록부가 시설이 생길 때 + * 칸에 실제 값으로 넣음(PLAN 46-7 · 제안값 단추 없음). */ export interface RevetmentDefaults { - form: string; - length: string; height: string | (() => string); - before: string; - after: string; } -/** 부위와 무관한 공통 제안 치수 — 길이 10m(전 5·후 5)·높이 2.5m(독립 기슭막이 · 관 벽은 관경 기준). */ -export const REVET_COMMON_DEFAULTS = { - length: "10", - height: "2.5", - before: "5", - after: "5", -} as const; - export const INLET_REVET_KEYS: RevetmentKeys = { form: "inlet_revet_form", length: "inlet_revet_length_m", @@ -273,16 +259,12 @@ function sabangBasisLines(): string[] { ]; } -export function createRevetSideGroup(keys: RevetmentKeys, formDefault: string): RevetSideGroup { +export function createRevetSideGroup(keys: RevetmentKeys): RevetSideGroup { const box = group("기슭막이"); const kind = document.createElement("select"); kind.replaceChildren(new Option("기슭막이", "기슭막이")); - // 높이는 제안 없음 — 설계자 입력(계획홍수위 + 여유고 · 2026-09-14 브레인 판정). 옛 2.5 는 근거 없어 걷음. - const fields = createRevetmentFields(keys, { - ...REVET_COMMON_DEFAULTS, - form: formDefault, - height: "", - }); + // 높이는 기본값 없음 — 설계자 입력(계획홍수위 + 여유고 · 2026-09-14 브레인 판정). + const fields = createRevetmentFields(keys, { height: "" }); const basis = document.createElement("p"); basis.className = "b05-route__note"; basis.replaceChildren( @@ -326,24 +308,24 @@ export function createRevetmentFields( keys: RevetmentKeys, defaults: RevetmentDefaults, ): RevetmentFields { - // ⚠ 제안값은 **회색 글씨(placeholder)로만** — 칸은 비워 둠(`RevetmentDefaults` 주석). - const suggestHeight = (): string => + const wallFloor = (): string => typeof defaults.height === "function" ? defaults.height() : defaults.height; const form = document.createElement("select"); form.replaceChildren( - new Option(`제안 ${defaults.form}`, ""), + new Option("— 안 정함 —", ""), ...REVET_FORMS.map((value) => new Option(value, value)), ); - const length = keys.length ? numberInput("0.1", "0", `제안 ${defaults.length}`) : null; - // 제안이 없는 칸(독립 기슭막이 높이)은 「설계자 입력」 — 값을 지어내지 않음. - const heightHint = (): string => (suggestHeight() ? `제안 ${suggestHeight()}` : "설계자 입력"); + const length = keys.length ? numberInput("0.1", "0") : null; + // 관 벽은 비우면 관경 기준 최소 높이로 섬(엔진) · 독립 기슭막이 높이는 「설계자 입력」. + const heightHint = (): string => + wallFloor() ? `비우면 관경 기준 ${wallFloor()}` : "설계자 입력"; const height = numberInput("0.1", "0", heightHint()); - // 관 벽 높이 제안은 관경을 따라감 — 칸을 볼 때마다 다시 셈. + // 관 벽 기준 높이는 관경을 따라감 — 칸을 볼 때마다 다시 셈. height.addEventListener("focus", () => (height.placeholder = heightHint())); // 기준측점 전/후(2026-08-23 사용자) — 구조물 패널(D군)과 같은 연동: // 전 + 후 = 길이, 나중에 고친 쪽이 살아남고 반대쪽이 재배분된다. - const before = keys.before && length ? numberInput("0.1", "0", `제안 ${defaults.before}`) : null; - const after = keys.after && length ? numberInput("0.1", "0", `제안 ${defaults.after}`) : null; + const before = keys.before && length ? numberInput("0.1", "0") : null; + const after = keys.after && length ? numberInput("0.1", "0") : null; let lastSplitEdit: "before" | "after" = "before"; const readNumber = (input: HTMLInputElement | null): number => { const value = input ? Number.parseFloat(input.value) : Number.NaN; @@ -367,25 +349,10 @@ export function createRevetmentFields( const beforeField = before ? labeled("기준측점 전 (m)", stepper(before, 0.5), keys.before) : null; const afterField = after ? labeled("기준측점 후 (m)", stepper(after, 0.5), keys.after) : null; - // [제안값 넣기] — **누른 때만** 빈 칸에 제안값을 넣음(적은 칸은 안 건드림) · 층따기 단추와 같은 모양. - const fill = document.createElement("button"); - fill.type = "button"; - fill.className = "b05-structure__suggest"; - fill.textContent = "제안값 넣기"; - fill.addEventListener("click", () => { - if (!form.value) form.value = defaults.form; - if (length && !length.value) length.value = defaults.length; - if (!height.value) height.value = suggestHeight(); - if (before && !before.value) before.value = defaults.before; - if (after && !after.value) after.value = defaults.after; - form.dispatchEvent(new Event("change")); - }); - // 형태는 그룹 첫 행(구조 옆)에 따로 놓이므로 여기 행에서는 뺀다. const rows = [ grid(...[lengthField, heightField].filter((item): item is HTMLLabelElement => item !== null)), ...(beforeField && afterField ? [grid(beforeField, afterField)] : []), - grid(fill), ]; return { @@ -393,7 +360,7 @@ export function createRevetmentFields( rows, lengthInput: length, write(options) { - // 저장된 값만 칸에 — 없으면 비워 두고 제안값은 회색 글씨로(몰래 채우지 않음). + // 받은 값만 칸에 — 없으면 비워 둠(새 시설은 부르는 쪽이 등록부 기본값을 실어 줌 · 46-7). const text = (key: string): string => options[key] !== undefined ? String(options[key]) : ""; form.value = text(keys.form); @@ -447,8 +414,8 @@ export function createBasinSplitFields(length: HTMLInputElement): { read: (target: Record) => void; onChange: (handler: () => void) => void; } { - const before = numberInput("0.1", "0", "제안 1"); - const after = numberInput("0.1", "0", "제안 1"); + const before = numberInput("0.1", "0"); + const after = numberInput("0.1", "0"); let last: HTMLInputElement = before; const sync = (): void => { const total = Number.parseFloat(length.value); @@ -528,10 +495,6 @@ export const WING_OUT_KEYS: WingKeys = { angle: "wing_out_angle_deg", }; -/** 날개벽 기본 제원 — 설치 있음·짧은쪽 높이 1m·길이 2m·각도 45° - * (2026-08-17 사용자 확정). 유입·유출이 같은 값을 쓴다. */ -const WING_DEFAULTS = { install: "있음", height: "1", length: "2", angle: "45" } as const; - interface WingFields { root: HTMLFieldSetElement; /** 횡단도에서 그 측 날개벽·측벽을 고를 때 칸을 세우는 자리(B06 전용, 2026-08-30). @@ -541,18 +504,16 @@ interface WingFields { syncVisibility: () => void; write: (options: Record) => void; read: (target: Record) => void; - /** [제안값 넣기] — 빈 칸에만 기본 제원을 넣음. */ - fillSuggested: () => void; } /** 날개벽 한 벌(설치 유무 + 짧은쪽 높이·길이·각도) — 유입·유출이 공유하는 조각. * 설치 "없음"이면 제원 칸을 접는다(기슭막이 보호공 치수와 같은 패턴). - * ⚠ 기본 제원(있음·1·2·45, 사용자 확정)은 **제안으로만** — 칸은 비워 둠(2026-09-14 판정 ③). */ + * 기본 제원(있음·1·2·45, 사용자 확정)은 등록부 기본값 — 시설이 생길 때 칸에 실제 값(PLAN 46-7). */ export function createWingFields(title: string, keys: WingKeys): WingFields { - const install = optionalSelect(`— 안 정함 (제안 ${WING_DEFAULTS.install}) —`, ["있음", "없음"]); - const height = numberInput("0.1", "0", `제안 ${WING_DEFAULTS.height}`); - const length = numberInput("0.1", "0", `제안 ${WING_DEFAULTS.length}`); - const angle = numberInput("1", "0", `제안 ${WING_DEFAULTS.angle}`); + const install = optionalSelect("— 안 정함 —", ["있음", "없음"]); + const height = numberInput("0.1", "0"); + const length = numberInput("0.1", "0"); + const angle = numberInput("1", "0"); // 숫자 칸은 기슭막이·집수정과 같은 [-][값][+] 묶음으로 맞춘다(2026-08-30 사용자). const heightField = labeled("짧은쪽 높이 (m)", stepper(height, 0.1), keys.height); @@ -594,12 +555,5 @@ export function createWingFields(title: string, keys: WingKeys): WingFields { putNumber(target, keys.length, length.value); putNumber(target, keys.angle, angle.value); }, - fillSuggested() { - if (!install.value) install.value = WING_DEFAULTS.install; - if (!height.value) height.value = WING_DEFAULTS.height; - if (!length.value) length.value = WING_DEFAULTS.length; - if (!angle.value) angle.value = WING_DEFAULTS.angle; - syncVisibility(); - }, }; } diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility_Ford.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility_Ford.ts index 11e0edb36..c1a5a8ae2 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility_Ford.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility_Ford.ts @@ -3,10 +3,9 @@ * 물넘이포장·세월교 칸 묶음 — 월류 폭·높이·바닥 경사·포장 두께·길이·련 수 + 개략 단면 줄. * * 서브폼 본체(`_Drainage_Facility.ts`)가 700줄에 닿아 떼어 냄(2026-09-14). - * ⚠ 기본값을 칸에 안 채움(2026-09-14 브레인 판정 「기본값을 몰래 확정으로 바꾸지 않는다」): - * 월류 폭 세월교 10m·물넘이 5m(사용자 확정)는 **회색 제안**, 월류 높이는 설계유량·폭으로 되짚은 - * **필요 최소 수심을 회색 제안**으로만 — 사용자가 적거나 [제안값 넣기]를 누른 때만 값. - * 적은 높이가 필요 수심보다 낮으면 여전히 되돌림(계산값 미만은 안 받음). + * 월류 폭 기본값(세월교 10m · 물넘이 5m · 등록부)은 시설이 생길 때 칸에 실제 값으로 들어옴 + * (PLAN 46-7 · 제안값 단추 없음). 빈 칸 안내 = 엔진이 비운 칸을 읽는 값(폭 기본 · 필요 수심). + * 적은 높이가 필요 수심보다 낮으면 되돌림(계산값 미만은 안 받음). * ========================================================================== */ import { @@ -35,7 +34,6 @@ export interface FordFields { sync: () => void; write: (kind: FordKind, options: Record) => void; read: (kind: FordKind, target: Record) => void; - fillSuggested: (kind: FordKind) => void; } export function createFordFields(emit: () => void): FordFields { @@ -66,14 +64,15 @@ export function createFordFields(emit: () => void): FordFields { /** 현재 조건(설계유량·월류 폭)의 필요 최소 수심(m). 계산 불가면 null. */ let minDepthM: number | null = null; - const suggestedWidth = (kind: FordKind): number => + /** 폭을 비우면 엔진이 쓰는 폭(`B06_Section_Engine_Culvert.FORD_*_DEFAULT_WIDTH_M` 짝). */ + const emptyWidth = (kind: FordKind): number => kind === "ford_bridge" ? FORD_BRIDGE_DEFAULT_WIDTH_M : FORD_PAVEMENT_DEFAULT_WIDTH_M; function sync(): void { - const widthM = Number.parseFloat(width.value || width.dataset.suggested || ""); + const widthM = Number.parseFloat(width.value || width.dataset.empty || ""); const section = designFlowM3s !== null ? fordSection(designFlowM3s, widthM) : null; minDepthM = section ? Number(section.depthM.toFixed(2)) : null; - height.placeholder = minDepthM !== null ? `제안 필요 수심 ${minDepthM.toFixed(2)}` : ""; + height.placeholder = minDepthM !== null ? `필요 수심 ${minDepthM.toFixed(2)} 이상` : ""; if (designFlowM3s === null) { summary.textContent = "담당 유역의 설계유량이 아직 없습니다 — [유역 분석] 후 개략 단면이 나옵니다."; @@ -121,8 +120,8 @@ export function createFordFields(emit: () => void): FordFields { options[key] !== undefined ? String(options[key]) : ""; count.value = kind === "ford_bridge" ? text("pipe_count") : ""; width.value = text("ford_width_m"); - width.dataset.suggested = String(suggestedWidth(kind)); - width.placeholder = `제안 ${suggestedWidth(kind)}`; + width.dataset.empty = String(emptyWidth(kind)); + width.placeholder = `비우면 ${emptyWidth(kind)}`; height.value = text("ford_height_m"); slope.value = text("ford_slope_pct"); thickness.value = text("thickness_cm"); @@ -142,10 +141,5 @@ export function createFordFields(emit: () => void): FordFields { const pipes = Number.parseInt(count.value, 10); if (Number.isFinite(pipes) && pipes > 0) target.pipe_count = pipes; }, - fillSuggested(kind) { - if (!width.value) width.value = String(suggestedWidth(kind)); - sync(); - if (!height.value && minDepthM !== null) height.value = minDepthM.toFixed(2); - }, }; } diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts index 56e623b29..9a33297c5 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts @@ -84,6 +84,8 @@ interface MergeableAttributes { options?: Record; /** 집계 항목(options 밖) — 다음 값에 칸이 없으면(undefined) 옛 지정을 그대로 잇는다. */ summary_item?: unknown; + /** 옵션 그룹 집계 항목(PLAN 46-3) — 다음 값에 칸이 없으면(undefined) 옛 지정을 그대로 잇는다. */ + group_items?: unknown; } /** 저장할 시설 정보 — 같은 종류면 폼이 모르는 옛 칸을 이어 붙임. */ @@ -99,5 +101,14 @@ export function mergeFacilityOptions( next.summary_item === undefined && previous.summary_item !== undefined ? { summary_item: previous.summary_item } : {}; - return { ...next, ...summary, options: Object.keys(options).length ? options : undefined }; + const groups = + next.group_items === undefined && previous.group_items !== undefined + ? { group_items: previous.group_items } + : {}; + return { + ...next, + ...summary, + ...groups, + options: Object.keys(options).length ? options : undefined, + }; } diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts index c6f308fff..140f8d9e8 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts @@ -7,7 +7,7 @@ * 그대로 다시 내보내므로 기존 import 경로는 유효하다. * ========================================================================== */ -import type { SummaryItem } from "../common_util/common_util_summary_item"; +import type { GroupItems, SummaryItem } from "../common_util/common_util_summary_item"; import type { RoutePoint } from "./B05_Profile_Api_Fetch"; import type { FacilityAttributes } from "./B05_Profile_UI_Drainage_Facility"; import type { PipeFacility, PipeSource } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; @@ -67,6 +67,8 @@ export interface DrainagePipeReport { options?: Record; /** 이 관이 뜻하는 구조물 집계표 항목(21-1) — 없으면 미지정. */ summary_item?: SummaryItem | null; + /** 옵션 그룹마다 고른 집계 항목(PLAN 46-3). */ + group_items?: GroupItems | null; } export interface DrainagePanelCallbacks { diff --git a/B05_Profile/B05_Profile_UI_Drainage_Report.ts b/B05_Profile/B05_Profile_UI_Drainage_Report.ts index a329c75ba..d5596c4ca 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Report.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Report.ts @@ -34,6 +34,7 @@ export function buildPipeReports( source: (pipe.reason || "user") as PipeSource, options: attributes?.options, summary_item: attributes?.summary_item, + group_items: attributes?.group_items, }; }); } diff --git a/B05_Profile/B05_Profile_UI_Profile_Drag.ts b/B05_Profile/B05_Profile_UI_Profile_Drag.ts index 8339c28d6..e3cbe853c 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Drag.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Drag.ts @@ -9,6 +9,7 @@ import { createPanelResizer } from "@ui/ui_template_resizer"; import { readStateRaw, stateKey, writeStateRaw } from "../A00_Common/b_page_state"; +import { profilePanelMaxHeight } from "./B05_Profile_UI_Profile_Heights"; import { MASSHAUL_MIN_HEIGHT } from "./B05_Profile_UI_Profile_MassHaul"; import { TABLE_OVERLAY_MIN_HEIGHT } from "./B05_Profile_UI_Profile_TableOverlay"; @@ -19,8 +20,6 @@ const MIN_CHART_HEIGHT = 100; const MASSHAUL_HANDLE_GUTTER_PX = 13; /** 패널을 끌어 줄일 수 있는 하한(px) — 이보다 낮으면 그래프도 테이블도 못 읽는다. */ const MIN_PANEL_HEIGHT = 180; -/** 상한은 위 칸(3D · 횡단 카드)이 완전히 가려지지 않도록 부모 높이의 90%까지만 허용한다. */ -const MAX_PANEL_HEIGHT_RATIO = 0.9; /** 접힘·높이는 화면 취향(①) — 두 보기가 **같은 키 하나**를 쓴다(계획서 24-2c). */ const HEIGHT_KEY = stateKey("profile-height") ?? ""; @@ -147,7 +146,8 @@ export function createProfileDrag(ctx: ProfileDragContext): ProfileDrag { chrome + MIN_CHART_HEIGHT + MASSHAUL_HANDLE_GUTTER_PX + massMin + tableMin, ); }, - max: () => (root.parentElement?.clientHeight ?? window.innerHeight) * MAX_PANEL_HEIGHT_RATIO, + // 상한 = 가운데 칸 높이의 90%(위 칸 3D · 횡단 카드가 다 가려지지 않게) — Heights 와 한 식 + max: () => profilePanelMaxHeight(root), storageKey: HEIGHT_KEY, }); diff --git a/B05_Profile/B05_Profile_UI_Profile_Heights.ts b/B05_Profile/B05_Profile_UI_Profile_Heights.ts index 170c37ed8..24561487d 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Heights.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Heights.ts @@ -19,6 +19,13 @@ const MASSHAUL_HANDLE_GUTTER_PX = 13; /** 패널 자동 확대 상한 — 메인 리사이저 max와 같은 식이어야 한다(부모 높이 비율). */ const MAX_PANEL_HEIGHT_RATIO = 0.9; +/** 패널 높이 상한(px) — 메인 리사이저 max 와 자동 확대가 같이 쓴다. 기준 = 한 페이지 가운데 칸 + * `.design-page__main`(바로 위 `.design-page__profile` 은 display: contents 라 높이 0). */ +export function profilePanelMaxHeight(root: HTMLElement): number { + const basis = root.closest(".design-page__main") ?? root.parentElement; + return (basis?.clientHeight || window.innerHeight) * MAX_PANEL_HEIGHT_RATIO; +} + export interface HeightCascadeDeps { /** 패널 루트 — 자리가 모자라면 이 높이를 키운다. */ root: HTMLElement; @@ -155,9 +162,7 @@ export function createHeightCascade(deps: HeightCascadeDeps): HeightCascade { // 상한은 메인 리사이저 max와 **같은 식**이어야 한다 — 예전 window 92% 상한은 // 리사이저 상한(부모 90%)보다 높아, 자동 확대 직후 손잡이를 잡는 순간 낮은 // 상한으로 clamp되며 패널이 뚝 떨어졌다(2026-08-06 분석). - const growCap = Math.round( - (deps.root.parentElement?.clientHeight ?? window.innerHeight) * MAX_PANEL_HEIGHT_RATIO, - ); + const growCap = Math.round(profilePanelMaxHeight(deps.root)); const grown = Math.min(deps.root.offsetHeight + deficit, growCap); if (grown > deps.root.offsetHeight + 1) { deps.root.style.setProperty("--b05-profile-height", `${grown}px`); diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel_Types.ts b/B05_Profile/B05_Profile_UI_Profile_Panel_Types.ts index 0ece33c2a..a5380a414 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel_Types.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel_Types.ts @@ -6,7 +6,7 @@ * 이름·필드·주석은 그대로이고, 패널이 다시 `export` 해 옛 임포트 경로도 그대로 동작한다. * ========================================================================== */ -import type { SummaryItem } from "../common_util/common_util_summary_item"; +import type { GroupItems, SummaryItem } from "../common_util/common_util_summary_item"; import type { PipeFacility, PipeSource } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import type { IrregularStation } from "./B05_Profile_UI_IrregularStations"; @@ -27,6 +27,8 @@ export interface RouteProfilePanelCallbacks { options?: Record; /** 이 관이 뜻하는 구조물 집계표 항목(21-1) — 없으면 미지정. */ summary_item?: SummaryItem | null; + /** 옵션 그룹마다 고른 집계 항목(PLAN 46-3). */ + group_items?: GroupItems | null; }>, ) => void; /** 테이블에서 구조물 라인을 끌어 옮김. */ diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts index def15d290..1c6a3648c 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts @@ -16,7 +16,7 @@ * `compute_cross_design`(서버), 성토사면 길이는 B06 화면이 쓰는 `fillSlopeLengths`. * ========================================================================== */ -import { attachSvgZoomPan } from "../A00_Common/b_svg_zoom_pan"; +import { attachSvgZoomPan, type ZoomPanState } from "../A00_Common/b_svg_zoom_pan"; import { fetchCrossPreview, type CrossPreviewResponse } from "./B05_Profile_Api_Replan"; import { buildCrossSvg, @@ -97,10 +97,47 @@ function createPane(title: string, empty: string): CrossPane { const station = root.querySelector(".b05-routeedit__cross-station")!; const foot = root.querySelector(".b05-routeedit__cross-foot")!; const box = root.querySelector(".b05-routeedit__cross-box")!; + /** 그려 둔 횡단 · 그때 판 크기 · 확대·이동 — 판 크기가 바뀌면 이것으로 다시 짓는다. */ + let drawn: CrossPreviewResponse | null = null; + let size = { width: 0, height: 0 }; + let zoom: ZoomPanState | undefined; + function draw(preview: CrossPreviewResponse): void { + // 판의 실제 크기로 짓는다 — `viewBox` 가 픽셀과 1:1 이라야 휠·팬이 커서를 따라가고 + // 글자가 늘어나 보이지 않는다. 칸이 접혀 있으면(1500px 미만) 0 이 나오므로 최소치를 깔고, + // 펼쳐지면 아래 ResizeObserver 가 실제 크기로 다시 짓는다. + const rect = box.getBoundingClientRect(); + size = { width: Math.round(rect.width), height: Math.round(rect.height) }; + const plot = buildCrossSvg(preview, size.width || 240, size.height || 150); + box.replaceChildren(plot.svg); + observer.observe(box); // 창에 붙은 뒤에 지켜본다 — 붙기 전에는 isConnected 가 거짓이다. + // 확대·이동은 **공용 조각**이 맡는다 — B06 횡단 카드와 한 코드다(2026-09-12 지시 ③). + // 판이 둘뿐이라 여기서는 휠을 줌에 묶는다(카드 목록인 B06 은 안 묶는다). + attachSvgZoomPan({ + svg: plot.svg, + layer: plot.layer, + widthPx: plot.width, + heightPx: plot.height, + pad: CROSS_PLOT_PAD, + content: plot.content, + wheelZoom: true, + initial: zoom, + onChange: (state) => (zoom = state), + }); + } + const observer = new ResizeObserver(() => { + if (!box.isConnected) return observer.disconnect(); // 편집 창이 닫혔다. + const rect = box.getBoundingClientRect(); + const width = Math.round(rect.width); + const height = Math.round(rect.height); + if (!drawn || width === 0 || (width === size.width && height === size.height)) return; + draw(drawn); + }); return { root, show(preview, intervalM) { box.replaceChildren(); + drawn = preview; + zoom = undefined; if (!preview) { station.textContent = ""; foot.textContent = empty; @@ -109,30 +146,12 @@ function createPane(title: string, empty: string): CrossPane { // 측점은 **누가거리가 아니라 측점 표기**로 낸다(계획서 0-9 ㉑) — B05 왼쪽 아래 구조물 // 목록이 쓰는 그 규칙이다. 서버가 주는 `STA.0+100.000` 을 그대로 쓰면 표기가 갈린다. station.textContent = formatStation(preview.chainage_m, intervalM); - // 판의 실제 크기로 짓는다 — `viewBox` 가 픽셀과 1:1 이라야 휠·팬이 커서를 따라간다. - // 칸이 접혀 있으면(1500px 미만) 0 이 나오므로 최소치를 깐다. - const rect = box.getBoundingClientRect(); - const plot = buildCrossSvg( - preview, - Math.max(Math.round(rect.width), 240), - Math.max(Math.round(rect.height), 150), - ); - box.append(plot.svg); - // 확대·이동은 **공용 조각**이 맡는다 — B06 횡단 카드와 한 코드다(2026-09-12 지시 ③). - // 판이 둘뿐이라 여기서는 휠을 줌에 묶는다(카드 목록인 B06 은 안 묶는다). - attachSvgZoomPan({ - svg: plot.svg, - layer: plot.layer, - widthPx: plot.width, - heightPx: plot.height, - pad: CROSS_PLOT_PAD, - content: plot.content, - wheelZoom: true, - }); + draw(preview); foot.textContent = summarizeCross(preview); }, wait(text) { box.replaceChildren(); + drawn = null; foot.textContent = text; }, }; diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Measure.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Measure.ts index b7cffb06d..33b9467c9 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Measure.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Measure.ts @@ -4,7 +4,8 @@ * * Shift+클릭으로 a·b 를 찍는다. 직선·곡선을 가리지 않는다 — 그려지는 폴리라인 위라면 어디든 * 짚을 수 있고, 누가거리는 노선 길이 표기와 같은 방식으로 잰다. 계획노선(실선)과 예상노선 - * (점선) 둘 다 잰다 — 두 점은 한 노선 위에 있어야 하고, 다른 노선을 짚으면 새로 시작한다. + * (점선) 둘 다 잰다 — 첫 점을 찍은 노선을 기억해 둘째 점(과 커서 미리보기)은 그 노선에만 + * 붙는다 — 두 노선이 겹쳐 다른 노선이 위에 있어도 무시한다. 길이 = 두 점 누가거리의 차. * * 지반고는 **찍는 순간에만** 서버에 묻는다(`/route/elevations`). 확정된 지표면을 읽기만 하는 * 통로라 「편집 중에는 계산이 안 나간다」(계획서 0-2 확정 7)와 부딪히지 않는다 — 다만 노드를 @@ -56,6 +57,8 @@ export interface MeasureTool { hint: () => string; /** 재고 있나 — 작은 창을 띄울지 정하는 값. */ active: () => boolean; + /** 첫 점만 찍혔을 때 커서 자리 미리보기(첫 점 노선 위). 바뀌었으면 true — 다시 그린다. */ + preview: (px: number, py: number) => boolean; /** 한 번 찍기. 두 점이 차면 지반고를 한 번만 물어 온다. */ pick: (px: number, py: number) => Promise; /** 잰 것을 지운다 — 작은 창을 닫을 때(계획서 0-9 ㉔). */ @@ -109,6 +112,8 @@ export function bindMeasureControls(params: MeasureControlsParams): { export function createMeasureTool(params: MeasureToolParams): MeasureTool { /** 찍은 두 점. 셋째를 찍으면 새 구간의 시작이 된다. */ let picked: MeasurePoint[] = []; + /** 첫 점만 찍혔을 때 커서가 가리키는 둘째 점 후보. */ + let hover: MeasureMark | null = null; const hint = (): string => { if (picked.length === 0) return ""; @@ -133,10 +138,12 @@ export function createMeasureTool(params: MeasureToolParams): MeasureTool { ); }; - /** 클릭에 가장 가까운 노선 위 점. 재던 노선이 잡히면 그쪽이 먼저다(겹친 자리에서 튀지 않게). */ + /** 클릭에 가장 가까운 노선 위 점. 첫 점이 찍혀 있으면 **그 노선만** 본다(겹친 자리에서 튀지 않게). */ const hitAt = (px: number, py: number): (RoutePointHit & { line: MeasureLine }) | null => { const lines = params.lines(); - const hits = (["planned", "expected"] as const) + const candidates: MeasureLine[] = + picked.length === 1 ? [picked[0].line] : ["planned", "expected"]; + const hits = candidates .map((line) => { const hit = routePointAtScreen(lines[line], params.toScreen, px, py, MEASURE_HIT_PX); if (!hit) return null; @@ -145,30 +152,42 @@ export function createMeasureTool(params: MeasureToolParams): MeasureTool { }) .filter((hit): hit is NonNullable => hit !== null); if (!hits.length) return null; - const keep = picked.length === 1 ? hits.find((hit) => hit.line === picked[0].line) : undefined; - const best = keep ?? hits.reduce((a, b) => (b.distance < a.distance ? b : a)); + const best = hits.reduce((a, b) => (b.distance < a.distance ? b : a)); return { point: best.point, chainageM: best.chainageM, line: best.line }; }; return { - marks: () => - picked.map((entry) => ({ point: entry.point, chainageM: entry.chainageM, line: entry.line })), + marks: () => [ + ...picked.map((entry) => ({ + point: entry.point, + chainageM: entry.chainageM, + line: entry.line, + })), + ...(picked.length === 1 && hover ? [hover] : []), + ], hint, active: () => picked.length > 0, + preview(px, py) { + const next = picked.length === 1 ? hitAt(px, py) : null; + if (!next && !hover) return false; + hover = next; + return true; + }, clear() { + hover = null; if (picked.length === 0) return; picked = []; params.onChange(); }, async pick(px, py) { const hit = hitAt(px, py); + hover = null; if (!hit) { picked = []; // 노선을 빗나가면 재던 것을 접는다. params.onChange(); return; } - const fresh = picked.length >= 2 || (picked.length === 1 && picked[0].line !== hit.line); - picked = fresh ? [{ ...hit, z: null }] : [...picked, { ...hit, z: null }]; + picked = picked.length >= 2 ? [{ ...hit, z: null }] : [...picked, { ...hit, z: null }]; params.onChange(); if (picked.length < 2) return; const asked = picked; diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts index a3eb75eb0..ac87885c4 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts @@ -71,7 +71,11 @@ export interface RouteEditPointerParams { /** 돌린 지도의 화면 좌표를 돌리기 전 자리로. */ unrotate: (x: number, y: number) => [number, number]; contourStepM: () => number; - measure: { pick: (px: number, py: number) => unknown; clear: () => void }; + measure: { + pick: (px: number, py: number) => unknown; + preview: (px: number, py: number) => boolean; + clear: () => void; + }; /** 재기 단추가 켜져 있나 — 켜져 있으면 노드 위에서도 잰다. */ measureMode: () => boolean; crossPreview: { open: (chainageM: number) => unknown; refresh: () => unknown }; @@ -287,6 +291,8 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP params.draw(); return; } + // 재는 중 — 둘째 점 미리보기는 첫 점 노선 위에만 붙는다. + if ((event.shiftKey || params.measureMode()) && params.measure.preview(px, py)) params.draw(); const overNode = nodeAt(px, py); // 고정한 교각점 위에서는 **못 끈다**고 커서로 먼저 알린다. canvas.style.cursor = diff --git a/B05_Profile/B05_Profile_UI_Structures_Fields.ts b/B05_Profile/B05_Profile_UI_Structures_Fields.ts index 904e13c5f..2581b79ee 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Fields.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Fields.ts @@ -61,9 +61,8 @@ interface OptionShape { } /** - * 등록부 옵션 입력 한 칸 — **저장된 값만 칸에 넣고, 등록부 기본값은 제안으로만** 보임 - * (숫자·글 칸은 회색 글씨, 고르기 칸은 빈 보기 이름). 2026-09-14 브레인 판정 「기본값을 몰래 - * 확정으로 바꾸지 않는다」 — 칸에 채워 두면 저장이 그 값을 적어 사용자가 한 일 없이 확정이 됨. + * 등록부 옵션 입력 한 칸 — **받은 값만 칸에 넣음**. 새 구조물은 부르는 쪽이 등록부 기본값을 값으로 + * 실어 줌(PLAN 46-7 · 제안값 단추 없음) · 옛 기록의 빈 칸은 빈 칸 그대로(미확정). * 고르기 칸은 늘 빈 보기를 둠 — 첫 보기를 미리 고르지 않음. */ export function optionControl( @@ -71,15 +70,10 @@ export function optionControl( stored: string | number | undefined, ): HTMLInputElement | HTMLSelectElement { const value = stored === undefined || stored === null ? "" : String(stored); - const suggested = option.default === null || option.default === "" ? "" : String(option.default); // 툴팁 — 표가 아직 안 읽는 칸이면 그 사유가 먼저(넣어도 안 바뀜을 알아야 함), 아니면 비울 때의 뜻. const tip = option.not_in_table ?? option.empty_means ?? ""; if (option.input === "select") { - const blank = suggested - ? `— 안 정함 (제안 ${suggested}) —` - : option.required - ? "— 선택 —" - : "— 안 정함 —"; + const blank = option.required ? "— 선택 —" : "— 안 정함 —"; const element = select([["", blank], ...option.choices.map((c) => [c, c] as [string, string])]); element.value = value; if (tip) element.title = tip; @@ -89,7 +83,7 @@ export function optionControl( option.input === "number" ? numberInput("0.1", "0") : document.createElement("input"); if (option.input !== "number") element.type = "text"; element.value = value; - element.placeholder = suggested ? `제안 ${suggested}` : option.required ? "필수 입력" : ""; + element.placeholder = option.required ? "필수 입력" : ""; if (tip) element.title = tip; const limit = option.warn_above; if (typeof limit === "number") { @@ -105,24 +99,15 @@ export function optionControl( return element; } -/** [제안값 넣기] — **누른 때만** 빈 칸에 등록부 기본값을 넣음(적은 칸은 안 건드림) · 층따기 단추와 같은 모양. */ -export function suggestButton( - entries: ReadonlyArray<{ input: HTMLInputElement | HTMLSelectElement; suggested: string }>, -): HTMLButtonElement { - const button = document.createElement("button"); - button.type = "button"; - button.className = "b05-structure__suggest"; - button.textContent = "제안값 넣기"; - button.addEventListener("click", () => { - const filled = entries.filter((entry) => entry.suggested && !entry.input.value); - filled.forEach((entry) => { - entry.input.value = entry.suggested; - entry.input.dispatchEvent(new Event("input", { bubbles: true })); - }); - // 반영은 한 번만 — 칸마다 저장 흐름이 돌지 않게 마지막 칸만 change 를 울림. - filled.at(-1)?.input.dispatchEvent(new Event("change", { bubbles: true })); - }); - return button; +/** 새 구조물 칸에 실을 등록부 기본값(PLAN 46-7) — 기본값이 있는 칸만 · 없는 칸은 빈 칸(미확정). */ +export function optionDefaults( + options: ReadonlyArray<{ key: string; default: string | number | null }>, +): Record { + return Object.fromEntries( + options + .filter((option) => option.default !== null && option.default !== "") + .map((option) => [option.key, option.default as string | number]), + ); } /** 측점번호 + 잔여거리 두 칸 묶음. */ diff --git a/B05_Profile/B05_Profile_UI_Structures_Form.ts b/B05_Profile/B05_Profile_UI_Structures_Form.ts index 515065284..980e75b36 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Form.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Form.ts @@ -23,6 +23,7 @@ import { } from "./B05_Profile_UI_Structures_Fields"; import { createFacilityOptionsForm } from "./B05_Profile_UI_Drainage_Facility"; import { buildSummaryRow, type SummaryRow } from "./B05_Profile_UI_Structures_Summary_Row"; +import { createGroupBoxes, type GroupBoxes } from "./B05_Profile_UI_Structures_Groups"; export interface StructuresFormElements { root: HTMLElement; @@ -52,6 +53,8 @@ export interface StructuresFormElements { list: HTMLElement; /** 옵션 · 시설 서브폼 아래 「집계 항목」 줄(PLAN 21-4) — 이름 · [고르기] · [해제]. */ summary: SummaryRow; + /** 본체 집계 항목 아래 옵션 그룹 보조 컨테이너(PLAN 46-3) — 그룹마다 상자 · 집계 항목. */ + groupBoxes: GroupBoxes; } /** 폼 뼈대를 세운다. 값 채우기·이벤트 배선은 호출한 쪽(패널)이 한다. */ @@ -144,6 +147,7 @@ export function buildStructuresForm(options: { // 집계 항목 줄 — 종류를 고른 뒤에만 보인다(패널이 `syncPlacementFields` 에서 켠다). const summary = buildSummaryRow(); summary.root.hidden = true; + const groupBoxes = createGroupBoxes(); body.append( typeRow, @@ -153,6 +157,7 @@ export function buildStructuresForm(options: { optionRow, facilityOptions.root, summary.root, + groupBoxes.root, actions, ); @@ -178,5 +183,6 @@ export function buildStructuresForm(options: { resetButton, list, summary, + groupBoxes, }; } diff --git a/B05_Profile/B05_Profile_UI_Structures_Groups.ts b/B05_Profile/B05_Profile_UI_Structures_Groups.ts new file mode 100644 index 000000000..c4f75cb0d --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Structures_Groups.ts @@ -0,0 +1,225 @@ +/* ============================================================================= + * B05_Profile_UI_Structures_Groups.ts + * 구조물 폼의 **옵션 그룹 보조 컨테이너**(PLAN 46-3) — M02 「구조물 옵션 보이기」에서 종류마다 만든 + * 그룹(`StructureType.groups`)마다 상자 하나(제목 = 그룹 이름) · 그 그룹 옵션 칸 · 「집계 항목」 줄. + * + * · 칸은 새로 그리지 않고 **옮긴다** — 레지스트리 옵션 칸 · 계곡 통과 시설 칸이 붙인 + * `data-option-keys` 표식으로 찾아 상자 안으로 옮기고, 제자리에는 표지(주석 노드)를 둔다. 값 읽기 · + * 이벤트는 칸 참조 그대로라 안 바뀜. 다시 세울 때(`render`)마다 먼저 제자리로 되돌림. + * · 키가 여럿인 칸(본체 규격 · 유입구 구조)은 **키가 다 그 그룹일 때만** 옮김(숨김 규칙과 같음 · + * 독립 기슭막이 채널 키는 `hiddenFormKeys` 로 넓힘). + * · 그룹 연결 값(`{그룹 id: 참조}`)은 정의가 사라진 그룹 것도 그대로 쥠(값 보존 · 경고 한 줄). + * · 저장은 패널이 한다(`group_items` 칸) — 여기는 값만 쥠. + * ========================================================================== */ + +import { openItemPicker } from "@ui/ui_template_item_picker"; +import { pipeStructureId } from "@ui/ui_template_item_picker_project"; +import type { StructureType } from "./B05_Profile_Api_Structures"; +import { hiddenFormKeys } from "./B05_Profile_UI_Drainage_Facility_Merge"; +import { buildSummaryRow, type SummaryRow } from "./B05_Profile_UI_Structures_Summary_Row"; +import { + GROUP_JOIN, + type GroupItems, + type SummaryItem, +} from "../common_util/common_util_summary_item"; + +export interface GroupBoxes { + /** 상자를 담는 자리 — 폼이 본체 「집계 항목」 줄 아래에 붙임. */ + root: HTMLElement; + /** 종류 · 칸에 맞춰 상자를 다시 세움 — `scopes` 안 표식 칸을 그룹 상자로 옮김. */ + render: (type: StructureType | null, scopes: HTMLElement[], facility: string | null) => void; + get: () => GroupItems; + /** 값만 바꿔 그림 — 저장 · 알림은 안 함(폼 불러오기 · 모달 결과가 씀). */ + set: (items: GroupItems) => void; + /** 한 그룹 값 — null = 해제. */ + setOne: (groupId: string, item: SummaryItem | null) => void; + /** [고르기] · [해제] 배선 — 패널이 한 번 부른다. */ + bind: (handlers: { pick: (groupId: string) => void; clear: (groupId: string) => void }) => void; +} + +export function createGroupBoxes(): GroupBoxes { + const root = document.createElement("div"); + root.className = "b05-structure__optgroups"; + root.dataset.b05Groups = ""; + root.hidden = true; + let items: GroupItems = {}; + let handlers: { pick: (id: string) => void; clear: (id: string) => void } | null = null; + const rows = new Map(); + /** 옮긴 칸 · 제자리 표지 */ + const moved: Array<{ node: HTMLElement; mark: Comment }> = []; + /** 칸이 다 빠져 가린 줄 · 소그룹 상자 */ + const emptied: HTMLElement[] = []; + /** 그룹 상자 몸통 — 옮겨 온 칸이 원래 자리처럼 잠기게(측점 확정 전) */ + let bodies: HTMLElement[] = []; + let lockSource: HTMLElement | null = null; + const syncLock = (): void => { + const locked = !!lockSource?.inert; + bodies.forEach((body) => { + body.inert = locked; + body.classList.toggle("is-locked", locked); + }); + }; + const lockWatch = typeof MutationObserver !== "undefined" ? new MutationObserver(syncLock) : null; + + const restore = (): void => { + for (const { node, mark } of moved.splice(0)) { + if (mark.isConnected) mark.replaceWith(node); + else node.remove(); // 원래 자리가 다시 그려졌음(레지스트리 칸) — 옛 칸은 버림 + } + emptied.splice(0).forEach((node) => node.classList.remove("is-group-emptied")); + }; + + const paintStale = (known: Set): HTMLElement | null => { + const stale = Object.keys(items).filter((id) => !known.has(id)); + if (!stale.length) return null; + const note = document.createElement("p"); + note.className = "b05-structure__owner-note"; + note.textContent = `정의가 사라진 그룹 연결 ${stale.length}개 — 값만 남음(집계표에는 안 들어감)`; + return note; + }; + + return { + root, + render(type, scopes, facility) { + restore(); + rows.clear(); + const groups = type?.groups ?? []; + const boxes: HTMLElement[] = []; + bodies = []; + // 「구조물 옵션 보이기」에서 끈 칸 — 시설 폼 밖으로 옮긴 칸도 같은 규칙으로 가림(Hidden 과 같은 식) + const off = hiddenFormKeys( + facility, + (type?.options ?? []).filter((o) => o.visible === false).map((o) => o.key), + ); + for (const group of groups) { + const keys = hiddenFormKeys(facility, group.options); + const box = document.createElement("fieldset"); + box.className = "b05-structure__group b05-structure__optgroup"; + box.dataset.b05Group = group.id; + const legend = document.createElement("legend"); + legend.textContent = group.name; + const body = document.createElement("div"); + body.className = "b05-structure__grid"; + for (const scope of scopes) { + const marked = [...scope.querySelectorAll("[data-option-keys]")]; + const fits = marked.filter((node) => + (node.dataset.optionKeys ?? "").split(" ").every((key) => key && keys.has(key)), + ); + // 바깥 칸만 — 옮길 칸 안의 칸은 따라 옴 + for (const node of fits.filter((n) => !fits.some((o) => o !== n && o.contains(n)))) { + const parent = node.parentElement; + const mark = document.createComment(`옵션 그룹 ${group.id}`); + node.before(mark); + body.append(node); + moved.push({ node, mark }); + const own = (node.dataset.optionKeys ?? "").split(" "); + node.classList.toggle( + "is-option-hidden", + own.every((key) => off.has(key)), + ); + // 칸이 다 빠진 줄 · 소그룹 상자는 가림(빈 줄 · 빈 상자 제목이 안 남게) + for (let at = parent; at && at !== scope; at = at.parentElement) { + if (at.querySelector("input, select, textarea")) break; + at.classList.add("is-group-emptied"); + emptied.push(at); + } + } + } + if (!body.childElementCount) { + const none = document.createElement("p"); + none.className = "b05-structure__owner-note"; + none.textContent = "옵션 없음 — 집계 항목만 지정"; + body.append(none); + } + const row = buildSummaryRow(); + row.set(items[group.id] ?? null); + row.bind({ + pick: () => handlers?.pick(group.id), + clear: () => handlers?.clear(group.id), + }); + rows.set(group.id, row); + box.append(legend, body, row.root); + boxes.push(box); + bodies.push(body); + } + const stale = type ? paintStale(new Set(groups.map((g) => g.id))) : null; + root.replaceChildren(...boxes, ...(stale ? [stale] : [])); + root.hidden = !type || (!boxes.length && !stale); + // 잠금은 계곡 통과 시설 서브폼(`inert`)을 따름 — 레지스트리 칸은 칸마다 disabled 라 그대로 + const facilityRoot = scopes.find((scope) => + scope.classList.contains("b05-drainage__facility"), + ); + if (facilityRoot && facilityRoot !== lockSource) { + lockSource = facilityRoot; + lockWatch?.observe(facilityRoot, { attributes: true, attributeFilter: ["inert"] }); + } + syncLock(); + }, + get: () => ({ ...items }), + set(next) { + items = { ...next }; + for (const [id, row] of rows) row.set(items[id] ?? null); + }, + setOne(groupId, item) { + if (item) items = { ...items, [groupId]: item }; + else { + const { [groupId]: _gone, ...rest } = items; + items = rest; + } + rows.get(groupId)?.set(item); + }, + bind(next) { + handlers = next; + }, + }; +} + +/** 본체 · 그룹 「집계 항목」 [고르기] · [해제] 배선 — [고르기]는 21-5 모달, 결과는 폼 값으로만 두고 + * 고치는 중이면 곧바로 반영(새 항목은 종전대로 [추가]가 확정) · 안 고른 채 닫으면 그대로. + * 복사본은 구조물마다(고치는 구조물 id · 관 지점 = `pipe@측점`) · 그룹은 `{id}__{그룹 id}` · + * 추가 전이면 null(원본 보기만). */ +export function bindSummaryPickers(opts: { + summary: SummaryRow; + groups: GroupBoxes; + projectId: string | null; + editingId: () => string | null; + selectedPipeChainage: () => number | null; + commitSummary: () => void; +}): void { + const base = (): string | null => { + const pipe = opts.selectedPipeChainage(); + return opts.editingId() ?? (pipe !== null ? pipeStructureId(pipe) : null); + }; + // canCopy — 개인 층에 쓸 수 없는 사용자의 복사는 서버가 막은 까닭을 모달이 그대로 보임 + const pick = (current: SummaryItem | null, structureId: string | null) => + openItemPicker({ current, canCopy: true, projectId: opts.projectId, structureId }); + opts.summary.bind({ + pick: () => + void pick(opts.summary.get(), base()).then((result) => { + if (result.action === "cancel") return; + opts.summary.set(result.action === "apply" ? result.item : null); + opts.commitSummary(); + }), + clear: () => { + opts.summary.set(null); + opts.commitSummary(); + }, + }); + opts.groups.bind({ + pick: (groupId) => { + const id = base(); + void pick( + opts.groups.get()[groupId] ?? null, + id ? `${id}${GROUP_JOIN}${groupId}` : null, + ).then((result) => { + if (result.action === "cancel") return; + opts.groups.setOne(groupId, result.action === "apply" ? result.item : null); + opts.commitSummary(); + }); + }, + clear: (groupId) => { + opts.groups.setOne(groupId, null); + opts.commitSummary(); + }, + }); +} diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index 357b6e80a..7989e539c 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -22,10 +22,9 @@ import { } from "./B05_Profile_Api_Structures"; import type { PipeFacility } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { type FacilityAttributes } from "./B05_Profile_UI_Drainage_Facility"; -import { field } from "./B05_Profile_UI_Structures_Fields"; +import { field, optionDefaults } from "./B05_Profile_UI_Structures_Fields"; import { renderOptionFields as renderOptionFieldsInto } from "./B05_Profile_UI_Structures_Panel_Options"; -import { openItemPicker } from "@ui/ui_template_item_picker"; -import { pipeStructureId } from "@ui/ui_template_item_picker_project"; +import { bindSummaryPickers } from "./B05_Profile_UI_Structures_Groups"; import { buildStructuresForm } from "./B05_Profile_UI_Structures_Form"; import { bindStructuresEvents, @@ -91,7 +90,15 @@ export function createStructuresSection( resetButton, list, summary, + groupBoxes, } = form; + /** 옵션 그룹 상자를 지금 종류 · 칸에 맞춰 다시 세움(PLAN 46-3) — 칸을 그린 뒤마다 부름. */ + const regroup = (): void => + groupBoxes.render( + currentType(), + [optionRow, facilityOptions.root], + facilityFormKind(currentType()), + ); /** 이 타입이 서브폼(계곡 통과 시설 부속)을 쓰는지 — 쓰면 레지스트리 옵션 칸 대신 * 서브폼이 그린다. 독립 기슭막이(D4)는 서브폼을 떠나 C군과 같은 레지스트리 옵션 @@ -232,16 +239,17 @@ export function createStructuresSection( /** 종류에 맞춰 부속 옵션 서브폼을 켠다(계곡 통과 시설·독립 기슭막이, 값은 인자로). */ function syncFacilityForm( - options: Record = {}, + options?: Record, designFlow: number | null = null, ): void { const kind = facilityFormKind(currentType()); - facilityOptions.setFacility(kind, options, designFlow); + facilityOptions.setFacility(kind, options ?? newDefaults(), designFlow); // 「구조물 옵션 보이기」에서 끈 옵션은 시설 폼에서도 가림(값은 옛 값 그대로 저장) facilityOptions.setHiddenOptions( kind, (currentType()?.options ?? []).filter((o) => !isOptionVisible(o)).map((o) => o.key), ); + regroup(); syncOptionLock(); } @@ -310,8 +318,14 @@ export function createStructuresSection( } /** 타입의 옵션 스키마대로 입력 칸을 다시 그린다 — 그리는 규칙은 `_Structures_Panel_Options`. */ - function renderOptionFields(values: Record = {}): void { - renderOptionFieldsInto(optionContext, values); + function renderOptionFields(values?: Record): void { + renderOptionFieldsInto(optionContext, values ?? newDefaults()); + regroup(); + } + + /** 값 없이 그리면 새 구조물 — 등록부 기본값을 칸에 실제 값으로(PLAN 46-7 · 옛 기록은 받은 값만). */ + function newDefaults(): Record { + return optionDefaults(currentType()?.options ?? []); } /** 고른 구조물군의 타입을 종류 목록에 채운다 — A군은 계곡 통과 시설(managed_by)도 @@ -491,31 +505,18 @@ export function createStructuresSection( }, summary: () => summary.get(), setSummary: (value) => summary.set(value), + groupItems: () => groupBoxes.get(), + setGroupItems: (value) => groupBoxes.set(value), }; bindStructuresEvents(panelContext); - // 집계 항목 — [고르기]는 21-5 모달, 결과는 폼 값으로만 두고 고치는 중이면 곧바로 반영한다 - // (새 항목은 종전대로 [추가]가 확정). 안 고른 채 닫으면 그대로. - summary.bind({ - pick: () => { - // canCopy — 개인 층에 쓸 수 없는 사용자의 복사는 서버가 막은 까닭을 모달이 그대로 보임 - void openItemPicker({ - current: summary.get(), - canCopy: true, - projectId: sectionOptions.projectId ?? null, - // 복사본은 구조물마다 — 고치는 구조물의 id(관 지점 = `pipe@측점`) · 추가 전이면 null(원본 보기만) - structureId: - editingId ?? - (selectedPipeChainage !== null ? pipeStructureId(selectedPipeChainage) : null), - }).then((result) => { - if (result.action === "cancel") return; - summary.set(result.action === "apply" ? result.item : null); - commitSummary(); - }); - }, - clear: () => { - summary.set(null); - commitSummary(); - }, + // 집계 항목(본체 · 옵션 그룹) — [고르기]는 21-5 모달 · 배선은 `_Structures_Groups` + bindSummaryPickers({ + summary, + groups: groupBoxes, + projectId: sectionOptions.projectId ?? null, + editingId: () => editingId, + selectedPipeChainage: () => selectedPipeChainage, + commitSummary, }); body.insertBefore(field("메모", memoField), actions); syncButtons(); @@ -643,11 +644,9 @@ export function createStructuresSection( endFields.write(isInterval ? chainageM + DEFAULT_INTERVAL_LENGTH_M : null, step); memoField.value = ""; syncPlacementFields(); - // 기본값을 값으로 안 채움 — 칸은 비우고 제안으로만(브레인 판정 ②). + // 새 구조물 — 등록부 기본값을 칸에 실제 값으로(PLAN 46-7 · 사용자가 고치면 됨). renderOptionFields(); - // BOX암거도 기본값을 안 실음 — 제안으로만(판정 ③ · 옛 「[추가]만 눌러도 제원」 걷음). - // 비운 칸은 하류(횡단도)가 등록부 기본값으로 그림 — 저장만 안 함. - syncFacilityForm({}); + syncFacilityForm(); // 임시 배치도 즉시 진행(A군). if (type.managed_by) commitTempPipe(); syncButtons(); diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts index 7509b7ef9..86196870c 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts @@ -18,7 +18,12 @@ import type { FacilityAttributes, FacilityOptionsForm } from "./B05_Profile_UI_D import type { StationFields } from "./B05_Profile_UI_Structures_Fields"; import { splitPavementRange } from "./B05_Profile_UI_Structures_Pavement"; import { mergeStructureOptions } from "./B05_Profile_UI_Structures_Summary"; -import { readSummaryItem, type SummaryItem } from "../common_util/common_util_summary_item"; +import { + readGroupItems, + readSummaryItem, + type GroupItems, + type SummaryItem, +} from "../common_util/common_util_summary_item"; import type { PipeFacilityItem, StructuresCallbacks, @@ -69,6 +74,15 @@ export interface StructuresCommitContext { /** 폼의 「집계 항목」 값(PLAN 21-4) — 저장은 옵션 밖 `summary_item` 칸으로 나간다. */ summary: () => SummaryItem | null; setSummary: (value: SummaryItem | null) => void; + /** 옵션 그룹 보조 컨테이너마다 고른 항목(PLAN 46-3) — 저장은 `group_items` 칸. */ + groupItems: () => GroupItems; + setGroupItems: (value: GroupItems) => void; +} + +/** 저장할 그룹 연결 — 고른 것이 있을 때만 칸을 실음(빈 묶음 = 칸 없음). */ +function groupField(ctx: StructuresCommitContext): { group_items?: GroupItems } { + const items = ctx.groupItems(); + return Object.keys(items).length ? { group_items: items } : {}; } export function loadForm(ctx: StructuresCommitContext, target: StructureInstance | null): void { @@ -91,6 +105,7 @@ export function loadForm(ctx: StructuresCommitContext, target: StructureInstance ctx.memoField.value = target.memo ?? ""; ctx.renderOptionFields(target.options); ctx.setSummary(readSummaryItem(target.summary_item)); + ctx.setGroupItems(readGroupItems(target.group_items)); // 구간 직접 입력 시절에 저장된 항목 호환 — 길이·전길이 옵션이 없으면 저장된 // 시작~종료·기준점에서 역산해 채워 범위 표시·재저장이 이어지게 한다(2026-08-19). if (target.start_m != null && target.end_m != null && target.end_m > target.start_m) { @@ -119,6 +134,7 @@ export function loadForm(ctx: StructuresCommitContext, target: StructureInstance ctx.memoField.value = ""; ctx.renderOptionFields(); ctx.setSummary(null); + ctx.setGroupItems({}); ctx.syncFacilityForm(); } [ctx.anchorFields, ctx.startFields, ctx.endFields].forEach((fields) => fields.clearInvalid()); @@ -166,6 +182,7 @@ export function loadPipeForm(ctx: StructuresCommitContext, pipe: PipeFacilityIte ctx.memoField.value = ""; ctx.syncFacilityForm(pipe.options ?? {}, pipe.design_flow_m3s ?? null); ctx.setSummary(readSummaryItem(pipe.summary_item)); + ctx.setGroupItems(readGroupItems(pipe.group_items)); [ctx.anchorFields, ctx.startFields, ctx.endFields].forEach((fields) => fields.clearInvalid()); ctx.syncPlacementFields(); ctx.syncButtons(); @@ -201,6 +218,9 @@ export function commitSummaryOnly(ctx: StructuresCommitContext): boolean { const summary = ctx.summary(); if (summary) next.summary_item = summary; else delete next.summary_item; + const groups = groupField(ctx); + if (groups.group_items) next.group_items = groups.group_items; + else delete next.group_items; ctx.structures[index] = next; emit(ctx); return true; @@ -225,6 +245,7 @@ export async function commit(ctx: StructuresCommitContext, live = false): Promis if (Object.keys(options).length) attributes.options = options; // 집계 항목 — 미지정(null)도 그대로 실어 옛 지정을 풀 수 있게 한다. attributes.summary_item = ctx.summary(); + attributes.group_items = groupField(ctx).group_items ?? null; // 빈 것도 실어 옛 지정을 풂 if (ctx.tempPipeChainage() !== null) { // 임시 배치 확정([추가]) — 관은 이미 미리보기로 들어가 있으니 옵션·위치만 반영. const from = ctx.tempPipeChainage() as number; @@ -306,6 +327,7 @@ export async function commit(ctx: StructuresCommitContext, live = false): Promis end_m: end, options: readOptions(ctx), ...(summary ? { summary_item: summary } : {}), + ...groupField(ctx), memo: ctx.memoField.value.trim(), placement_source: "manual" as const, status: "draft" as const, @@ -347,6 +369,7 @@ export async function commit(ctx: StructuresCommitContext, live = false): Promis }; // 해제 = 칸을 지움(옛 지정이 `...previous` 로 살아 오지 않게) if (!summary) delete next.summary_item; + if (!groupField(ctx).group_items) delete next.group_items; ctx.structures[index] = next; } records.slice(1).forEach((record) => ctx.structures.push({ ...record, structure_id: null })); diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts index 6aad12385..f557893d3 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts @@ -10,7 +10,7 @@ import { isB05Option, type StructureType } from "./B05_Profile_Api_Structures"; import type { PipeFacility } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; -import { field, optionControl, suggestButton } from "./B05_Profile_UI_Structures_Fields"; +import { field, optionControl } from "./B05_Profile_UI_Structures_Fields"; import { isOptionVisible } from "./B05_Profile_UI_Structures_Summary"; /** 패널이 쥔 값 중 옵션 그리기에 필요한 것들. 이름은 분리 전 지역변수와 같다. */ @@ -56,12 +56,9 @@ export function renderOptionFields( return; } optionRow.hidden = false; - const suggestions: Array<{ input: HTMLInputElement | HTMLSelectElement; suggested: string }> = []; shown.forEach((option) => { - // ⭐ 저장된 값만 칸에 · 등록부 기본값은 제안(회색 글씨·빈 보기 이름)으로만 — 고르기 칸은 - // 첫 보기를 미리 안 고름(2026-09-14 브레인 판정 ①②, `optionControl` 주석). + // 받은 값만 칸에 — 새 구조물은 부르는 쪽(패널)이 등록부 기본값을 `values` 로 실어 줌(PLAN 46-7). const input = optionControl(option, values[option.key]); - suggestions.push({ input, suggested: String(option.default ?? "") }); const label = `${option.unit ? `${option.label} (${option.unit})` : option.label}${option.not_in_table ? " · 표에 안 쓰임" : ""}`; // `enabled:false` 는 칸을 남기고 잠그기만 한다 — 값은 기본값이 그대로 저장된다 // (고를 수 없는 칸이라 제안이 아니라 프로그램 값). @@ -72,7 +69,9 @@ export function renderOptionFields( input.classList.add("is-locked"); input.title = "지금은 고를 수 없는 항목입니다."; } - optionRow.append(field(label, input, option.default_basis)); + const wrap = field(label, input, option.default_basis); + wrap.dataset.optionKeys = option.key; // 옵션 그룹 상자가 이 표식으로 칸을 옮김(PLAN 46-3) + optionRow.append(wrap); input.addEventListener("change", () => ctx.liveCommit()); optionInputs.push({ key: option.key, @@ -111,9 +110,6 @@ export function renderOptionFields( input.addEventListener("change", () => refresh(true)); } }); - if (suggestions.some((entry) => entry.suggested)) { - optionRow.append(suggestButton(suggestions)); - } ctx.syncOptionLock(); ctx.syncRangeDisplay(); } diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts index 03a7afb73..ff1daad7a 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts @@ -19,7 +19,7 @@ import { type StructureType, } from "./B05_Profile_Api_Structures"; import type { DesignSelection, ResolvedSelection } from "../A00_Common/design/design_selection"; -import type { SummaryItem } from "../common_util/common_util_summary_item"; +import type { GroupItems, SummaryItem } from "../common_util/common_util_summary_item"; /** 구조물군 표시 이름. 리스트 기호(A~G)만으로는 무엇인지 알기 어렵다. * 우클릭 메뉴(종단그래프·배수유역도)도 같은 이름을 쓴다(2026-08-18 일원화). */ @@ -51,6 +51,8 @@ export interface PipeFacilityItem { options?: Record; /** 이 관이 뜻하는 구조물 집계표 항목(21-1) — 없으면 미지정. */ summary_item?: SummaryItem | null; + /** 옵션 그룹마다 고른 집계 항목(PLAN 46-3). */ + group_items?: GroupItems | null; } /** 관 지점 정본 한 건 → 목록·폼 항목. 집계 항목(21-1)도 옮긴다 — 안 옮기면 목록 색 · 폼이 잃는다. */ @@ -69,6 +71,7 @@ export function pipeToFacilityItem( design_flow_m3s: pipe.design_flow_m3s, options: pipe.options, summary_item: pipe.summary_item, + group_items: pipe.group_items, }; } diff --git a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css index caa6c66c3..506fa6eb1 100644 --- a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css +++ b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css @@ -187,7 +187,7 @@ .b05-routeedit__cross-station { color: var(--color-text-secondary); - font-size: var(--text-caption); + font-size: inherit; /* 44-2 — 제목(횡단)과 같은 크기 */ } /* 횡단 판 — 캔버스 그림이 아니라 **SVG 선**이다(2026-09-12 사용자 지시 ③). diff --git a/B05_Profile/B05_Profile_UI_Style_Structures.css b/B05_Profile/B05_Profile_UI_Style_Structures.css index d1a617cc5..57e713798 100644 --- a/B05_Profile/B05_Profile_UI_Style_Structures.css +++ b/B05_Profile/B05_Profile_UI_Style_Structures.css @@ -221,11 +221,6 @@ line-height: 1.3; } -/* 근거 줄로 옆 칸이 길어져도 [제안값 넣기]가 세로로 늘지 않게. */ -.b05-structure__suggest { - align-self: start; -} - /* 시작·기준·종료 측점 = 3행. 한 행은 [라벨][측점][+거리] 가로 배치 * (2026-08-17 사용자 지시 2). */ .b05-structure__position-row { @@ -489,3 +484,34 @@ .b05-drainage__facility .is-option-hidden { display: none; } + +/* 옵션 그룹 보조 컨테이너(PLAN 46-3) — 그룹마다 상자 하나 · 옮겨 온 칸 · 「집계 항목」 줄 */ +.b05-structure__optgroups { + display: flex; + flex-direction: column; + gap: 6px; +} + +.b05-structure__optgroups[hidden], +.is-group-emptied { + display: none; +} + +.b05-structure__optgroup { + display: flex; + flex-direction: column; + gap: 6px; +} + +.b05-structure__optgroup > legend { + font-weight: 600; + opacity: 1; +} + +.b05-structure__optgroup .is-option-hidden { + display: none; +} + +.b05-structure__optgroup > .b05-structure__grid.is-locked { + opacity: 0.5; +} diff --git a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts index bf452c50f..41eb873fd 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts @@ -31,7 +31,11 @@ import { import { readPendingPipes, writePendingPipes } from "../B05_Profile/B05_Profile_Api_Pipes_Draft"; import { mergeFacilityOptions } from "../B05_Profile/B05_Profile_UI_Drainage_Facility_Merge"; import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; -import { readSummaryItem } from "../common_util/common_util_summary_item"; +import { + linkFields, + readGroupItems, + readSummaryItem, +} from "../common_util/common_util_summary_item"; import { readStructurePick } from "../B05_Profile/B05_Profile_UI_Structure_Pick_Session"; import { NO_SELECTION, @@ -180,6 +184,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc source: "user", options: attributes.options ?? {}, summary_item: attributes.summary_item ?? null, + group_items: attributes.group_items ?? null, }); pipeFacilities.sort((left, right) => left.chainage_m - right.chainage_m); currentChainageM = at; @@ -197,13 +202,17 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc // (2026-08-29 사용자 보고: 높이만 바꿨는데 이동 안내가 떴다). if (Math.abs(fromChainageM - toChainageM) > PIPE_MATCH_M) movePipeTo(fromChainageM, toChainageM); - if (attributes.summary_item !== undefined) { + if (attributes.summary_item !== undefined || attributes.group_items !== undefined) { // 옮긴 뒤 자리(toChainageM)에서 찾는다 — 안 옮겼으면 from 과 0.51m 안이라 같은 관이다. const mine = pipeFacilities.find( (entry) => Math.abs(entry.chainage_m - toChainageM) < PIPE_MATCH_M, ); - if (mine && (mine.summary_item ?? null)?.key !== attributes.summary_item?.key) { + const before = mine ? JSON.stringify(linkFields(mine)) : ""; + if (mine && attributes.summary_item !== undefined) mine.summary_item = attributes.summary_item; + if (mine && attributes.group_items !== undefined) + mine.group_items = attributes.group_items; + if (mine && JSON.stringify(linkFields(mine)) !== before) { persistPipes(); section.setPipeFacilities(pipeFacilities); deps.onStructuresChanged?.(); @@ -294,6 +303,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc end_m: pipe.end_m, source: pipe.source, summary_item: readSummaryItem(pipe.summary_item), + group_items: readGroupItems(pipe.group_items), // 폼 기본값 = 조정창이 쓰던 값(횡단 스펙). 저장된 옵션이 있으면 그쪽이 이긴다. options: withSpecDefaults( pipe.options, @@ -374,7 +384,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc ...(pipe.start_m !== undefined ? { start_m: pipe.start_m } : {}), ...(pipe.end_m !== undefined ? { end_m: pipe.end_m } : {}), ...(pipe.options ? { options: pipe.options } : {}), - ...(pipe.summary_item ? { summary_item: pipe.summary_item } : {}), + ...linkFields(pipe), })), ); deps.onPipesWritten?.(); diff --git a/B07_DesignDetail/openwebcad/src/components/CommandLine.tsx b/B07_DesignDetail/openwebcad/src/components/CommandLine.tsx index 8a3c73e7e..86c97e203 100644 --- a/B07_DesignDetail/openwebcad/src/components/CommandLine.tsx +++ b/B07_DesignDetail/openwebcad/src/components/CommandLine.tsx @@ -53,6 +53,7 @@ export const CommandLine: FC = () => { value={text} onChange={(event) => setText(event.target.value)} onKeyDown={(event) => { + if (event.nativeEvent.isComposing) return; // 한글 조합 중 키는 IME 몫 if (event.key === 'Escape') { event.preventDefault(); getInputController().handleEscapeKey(); diff --git a/B07_DesignDetail/openwebcad/src/helpers/debug-hook.ts b/B07_DesignDetail/openwebcad/src/helpers/debug-hook.ts index 06d070803..465047359 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/debug-hook.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/debug-hook.ts @@ -31,6 +31,8 @@ export function registerCadDebugHook(): void { opacity: entity.opacity ?? null, hidden: isEntityHidden(entity), color: entity.lineColor, + // 글자 객체의 내용 — 한글 입력(IME) 검증이 읽는다 + label: (entity as { getLabel?: () => string }).getLabel?.() ?? null, box: [ Math.round(box.xmin), Math.round(box.ymin), diff --git a/B07_DesignDetail/openwebcad/src/inputController/ime-sink.test.ts b/B07_DesignDetail/openwebcad/src/inputController/ime-sink.test.ts new file mode 100644 index 000000000..1be500de3 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/inputController/ime-sink.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it, vi } from 'vitest'; +import { ImeSink, isImeKey } from './ime-sink.ts'; +import { InputController } from './input-controller.ts'; + +describe('한글 IME 입력', () => { + it('조합 사건으로 「가나다」 가 들어간다', () => { + let text = ''; + const sink = new ImeSink((data) => { + text += data; + }); + for (const [steps, done] of [ + [['ㄱ', '가'], '가'], + [['ㄴ', '나'], '나'], + [['ㄷ', '다'], '다'], + ] as const) { + for (const step of steps) sink.handleCompositionUpdate(step); + expect(sink.composing).toBe(steps[steps.length - 1]); + sink.handleCompositionEnd(done); + } + expect(text).toBe('가나다'); + expect(sink.composing).toBe(''); + // 조합 중 input 은 compositionend 몫 — 겹쳐 넣지 않음 · 조합 뒤 띄어쓰기는 받음 + sink.handleInput({ isComposing: true, data: '다' }); + sink.handleInput({ isComposing: false, data: ' ' }); + expect(text).toBe('가나다 '); + }); + + it('조합 중 키는 단축키로 먹지 않는다', () => { + expect(isImeKey({ isComposing: true, keyCode: 0, key: 'r' } as KeyboardEvent)).toBe(true); + expect(isImeKey({ isComposing: false, keyCode: 229, key: 'r' } as KeyboardEvent)).toBe(true); + expect(isImeKey({ isComposing: false, keyCode: 0, key: 'Process' } as KeyboardEvent)).toBe( + true + ); + expect(isImeKey({ isComposing: false, keyCode: 76, key: 'l' } as KeyboardEvent)).toBe(false); + + const controller = new InputController(); + const preventDefault = vi.fn(); + controller.handleKeyStroke({ + key: 'Process', + keyCode: 229, + isComposing: true, + target: null, + preventDefault, + stopPropagation: vi.fn(), + } as unknown as KeyboardEvent); + expect(preventDefault).not.toHaveBeenCalled(); + controller.ime.handleCompositionEnd('가나다'); + expect((controller as unknown as { text: string }).text).toBe('가나다'); + }); +}); diff --git a/B07_DesignDetail/openwebcad/src/inputController/ime-sink.ts b/B07_DesignDetail/openwebcad/src/inputController/ime-sink.ts new file mode 100644 index 000000000..e79a4e02b --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/inputController/ime-sink.ts @@ -0,0 +1,68 @@ +/** + * 한글 IME 받개 — 캔버스에는 글 칸이 없어 조합(compositionstart/update/end)이 생기지 않는다. + * 문자를 기다리는 단계(TEXT · MTEXT · 표 칸 · 지시선 …)에서만 숨은 input 에 초점을 줘 조합을 받고, + * 끝난 글자를 넘긴다. 명령 단계에서는 초점을 풀어 한글 자판이어도 L · REC 같은 영문 명령이 들어간다. + */ +import { getActiveToolActor } from '../state.ts'; + +/** 조합 중 키 — 단축키·글자 처리가 먹지 않고 IME 에 넘긴다 */ +export const isImeKey = (evt: KeyboardEvent): boolean => + evt.isComposing || evt.keyCode === 229 || evt.key === 'Process'; + +/** 활성 도구의 지금 단계가 TEXT_INPUT 을 받는가 */ +export function activeToolAcceptsText(): boolean { + const snapshot = getActiveToolActor()?.getSnapshot(); + return !!snapshot?.machine?.states?.[snapshot?.value]?.config?.on?.TEXT_INPUT; +} + +export class ImeSink { + /** 조합 중인 글자 — 커서 옆 입력 칸에 미리 보인다 */ + public composing = ''; + public element: HTMLInputElement | null = null; + + constructor(private readonly onCommit: (text: string) => void) {} + + public attach() { + const el = document.createElement('input'); + el.className = 'cad-ime-sink'; + el.tabIndex = -1; + el.autocomplete = 'off'; + el.setAttribute('aria-hidden', 'true'); + el.style.cssText = + 'position:fixed;left:0;top:0;width:1px;height:1px;opacity:0;border:0;padding:0;pointer-events:none'; + el.addEventListener('compositionupdate', (evt) => this.handleCompositionUpdate(evt.data)); + el.addEventListener('compositionend', (evt) => this.handleCompositionEnd(evt.data)); + el.addEventListener('input', (evt) => this.handleInput(evt as InputEvent)); + document.body.appendChild(el); + this.element = el; + } + + public handleCompositionUpdate(data: string) { + this.composing = data ?? ''; + } + + public handleCompositionEnd(data: string) { + this.composing = ''; + if (this.element) this.element.value = ''; + if (data) this.onCommit(data); + } + + /** 조합 직후 IME 가 바로 넣는 글자(띄어쓰기 · 숫자) — 조합 중 input 은 compositionend 가 맡는다 */ + public handleInput(evt: Pick) { + if (evt.isComposing || !evt.data) return; + if (this.element) this.element.value = ''; + this.onCommit(evt.data); + } + + /** 문자를 기다리면 받개에 초점 · 아니면 놓음. 다른 입력 칸(명령줄 · 대화상자)의 초점은 뺏지 않는다 */ + public sync(wantsText: boolean) { + const el = this.element; + if (!el) return; + const active = document.activeElement; + if (wantsText && !active?.closest('input, textarea, select, [contenteditable]')) { + el.focus({ preventScroll: true }); + } else if (!wantsText && active === el && !this.composing) { + el.blur(); + } + } +} diff --git a/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts b/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts index ae5fcdc79..acec2ed3e 100644 --- a/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts +++ b/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts @@ -65,21 +65,15 @@ import { type RelativePointInputEvent, type TextInputEvent, } from '../tools/tool.types.ts'; - -const NUMBER_REGEXP = /^[0-9]+([.][0-9]+)?$/; -/** 부호 있는 실수 한 개 (좌표는 음수가 될 수 있다) */ -const SIGNED = '(-?[0-9]+(?:[.][0-9]+)?)'; -const ABSOLUTE_POINT_REGEXP = new RegExp(`^${SIGNED}[ ]*,[ ]*${SIGNED}$`); -const RELATIVE_POINT_REGEXP = new RegExp(`^@${SIGNED}[ ]*,[ ]*${SIGNED}$`); -/** 극좌표 — 거리<각도(도). `@`가 붙으면 직전 점 기준 */ -const ABSOLUTE_POLAR_REGEXP = new RegExp(`^${SIGNED}[ ]*<[ ]*${SIGNED}$`); -const RELATIVE_POLAR_REGEXP = new RegExp(`^@${SIGNED}[ ]*<[ ]*${SIGNED}$`); - -/** 거리·각도(도)를 x·y 변위로 바꾼다 */ -function polarToPoint(distance: number, degrees: number): Point { - const radians = (degrees * Math.PI) / 180; - return new Point(distance * Math.cos(radians), distance * Math.sin(radians)); -} +import { activeToolAcceptsText, ImeSink, isImeKey } from './ime-sink.ts'; +import { + ABSOLUTE_POINT_REGEXP, + ABSOLUTE_POLAR_REGEXP, + NUMBER_REGEXP, + polarToPoint, + RELATIVE_POINT_REGEXP, + RELATIVE_POLAR_REGEXP, +} from './input-patterns.ts'; /** 이만큼 넘게 끌면 클릭이 아니라 드래그로 본다 (화면 픽셀) */ const DRAG_SELECT_MIN_PX = 4; @@ -88,11 +82,15 @@ export class InputController { private text = ''; /** 왼쪽 버튼을 누른 화면 좌표 — 놓을 때 끌었는지 판단한다 */ private leftPressLocation: Point | null = null; + public readonly ime = new ImeSink((data) => { + this.text += data; + }); constructor() { if (typeof process === 'object' && process?.env?.NODE_ENV === 'test') { return; // used during unit testing } + this.ime.attach(); // Listen for keystrokes document.addEventListener('keydown', (evt) => { this.handleKeyStroke(evt); @@ -141,10 +139,11 @@ export class InputController { CANVAS_INPUT_FIELD_HEIGHT, CANVAS_INPUT_FIELD_BACKGROUND_COLOR ); - // Draw text in input field - if (this.text) { + this.ime.sync(activeToolAcceptsText()); + // Draw text in input field (조합 중인 한글도 함께) + if (this.text || this.ime.composing) { drawController.drawTextScreen( - this.text, + this.text + this.ime.composing, new Point( screenMouseLocation.x + CANVAS_INPUT_FIELD_MOUSE_OFFSET + 2, screenMouseLocation.y - CANVAS_INPUT_FIELD_MOUSE_OFFSET - CANVAS_INPUT_FIELD_HEIGHT - 2 @@ -217,6 +216,7 @@ export class InputController { return; } this.sendMouseClick(evt); + this.ime.sync(activeToolAcceptsText()); // 클릭으로 문자 단계에 들면 바로 한글 받개로 } } @@ -392,9 +392,11 @@ export class InputController { } public handleKeyStroke(evt: KeyboardEvent) { - if ((evt.target as HTMLElement | null)?.closest('input, textarea, select')) { + const target = evt.target as HTMLElement | null; + if (target !== this.ime.element && target?.closest('input, textarea, select')) { return; } + if (isImeKey(evt)) return; // 한글 조합 중 — 단축키가 먹지 않게 IME 에 넘김 console.log(`key pressed: ${evt.key}`); if (evt.key === 'F12') { // F12 => open developer tools @@ -472,6 +474,7 @@ export class InputController { } else if (evt.key === 'Enter') { // User wants to submit the input or submit the action this.handleEnterKey(); + this.ime.sync(activeToolAcceptsText()); } else if (evt.key === 'ArrowDown') { // Move the screen down getScreenCanvasDrawController().setScreenOffset(this.getScreenPanStep('down', evt.shiftKey)); @@ -517,11 +520,7 @@ export class InputController { public handleEnterKey() { // submit the text as input to the active tool and clear the input field - const activeTool = getActiveToolActor(); - const activeToolSnapshot = activeTool?.getSnapshot(); - const activeToolState = activeToolSnapshot?.value; - const activeToolCanHandleTextInput = - !!activeToolSnapshot?.machine?.states?.[activeToolState]?.config?.on?.TEXT_INPUT; + const activeToolCanHandleTextInput = activeToolAcceptsText(); if (this.text === '') { console.log('ENTER: ', { diff --git a/B07_DesignDetail/openwebcad/src/inputController/input-patterns.ts b/B07_DesignDetail/openwebcad/src/inputController/input-patterns.ts new file mode 100644 index 000000000..d2483f459 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/inputController/input-patterns.ts @@ -0,0 +1,17 @@ +/** 명령 입력 해석 — 숫자 · 좌표 · 극좌표 꼴 */ +import { Point } from '@flatten-js/core'; + +export const NUMBER_REGEXP = /^[0-9]+([.][0-9]+)?$/; +/** 부호 있는 실수 한 개 (좌표는 음수가 될 수 있다) */ +const SIGNED = '(-?[0-9]+(?:[.][0-9]+)?)'; +export const ABSOLUTE_POINT_REGEXP = new RegExp(`^${SIGNED}[ ]*,[ ]*${SIGNED}$`); +export const RELATIVE_POINT_REGEXP = new RegExp(`^@${SIGNED}[ ]*,[ ]*${SIGNED}$`); +/** 극좌표 — 거리<각도(도). `@`가 붙으면 직전 점 기준 */ +export const ABSOLUTE_POLAR_REGEXP = new RegExp(`^${SIGNED}[ ]*<[ ]*${SIGNED}$`); +export const RELATIVE_POLAR_REGEXP = new RegExp(`^@${SIGNED}[ ]*<[ ]*${SIGNED}$`); + +/** 거리·각도(도)를 x·y 변위로 바꾼다 */ +export function polarToPoint(distance: number, degrees: number): Point { + const radians = (degrees * Math.PI) / 180; + return new Point(distance * Math.cos(radians), distance * Math.sin(radians)); +} diff --git a/M01_MasterData/M01_MasterData_Router.py b/M01_MasterData/M01_MasterData_Router.py index f487a6d78..5b0aaa349 100644 --- a/M01_MasterData/M01_MasterData_Router.py +++ b/M01_MasterData/M01_MasterData_Router.py @@ -190,6 +190,12 @@ def get_logic(key: str) -> dict: return _call(store.logic, key) +@router.get("/logic/source") +def get_logic_source(src: str) -> dict: + """로직 원문 — 출처 글로 찾은 품셈 본문 절(읽기만).""" + return _call(make.source, src) + + @router.get("/logic/auto") def get_logic_auto(key: str) -> dict: """견본 입력 — 마스터 보기용 자동값(프로젝트 기본값 아님) · 흐름 로직은 설계 예시값.""" diff --git a/M01_MasterData/M01_MasterData_Store_Make.py b/M01_MasterData/M01_MasterData_Store_Make.py index 76dc319d9..c5ab2df45 100644 --- a/M01_MasterData/M01_MasterData_Store_Make.py +++ b/M01_MasterData/M01_MasterData_Store_Make.py @@ -103,6 +103,79 @@ def sections(book: str, division: str = "", chapter: str = "", q: str = "", limi return {"total": len(hits), "sections": hits[: min(max(limit, 1), 1000)]} +# ── 로직 원문(출처 글 → 품셈 본문 절) ────────────────────────────────── +_NUMBER = re.compile(r"(? list[dict]: + """md 줄 → 화면 조각 — 제목 {h} · 표 {table} · 글 {p} · 주석 줄은 뺌.""" + out: list[dict] = [] + for line in lines: + text = line.strip() + if not text or text.startswith("