From 3e9be53e7ce4a8bc732baba793c72dc34c1f693e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 17 Aug 2026 17:09:05 +0900 Subject: [PATCH] auto: 2026-08-17 17:09 (ESD_LAPTOP) --- B04_PreProcess/B04_PreProcess_Api_Fetch.ts | 2 +- B05_Profile/B05_Profile_Structure_Types.json | 302 +++++---- .../B05_Profile_UI_Drainage_Facility.ts | 601 +++++++++++------- B05_Profile/B05_Profile_UI_Drainage_Panel.ts | 7 +- .../B05_Profile_UI_IrregularStations.ts | 14 +- B05_Profile/B05_Profile_UI_Page.ts | 121 +++- B05_Profile/B05_Profile_UI_Profile_Edit.ts | 24 +- B05_Profile/B05_Profile_UI_Profile_Panel.ts | 74 ++- .../B05_Profile_UI_Structures_Marks.ts | 148 +++-- .../B05_Profile_UI_Structures_Panel.ts | 111 ++-- .../B05_Profile_UI_Style_Structures.css | 133 +++- .../수량산출/~$4.2 수량산출(2공구).xlsx | Bin 0 -> 165 bytes .../수량산출/~$4.3 수량산출(3공구).xlsx | Bin 0 -> 165 bytes ui_template/ui_template_locale_b2.ts | 4 +- ui_template/ui_template_overlay.css | 11 + 15 files changed, 1028 insertions(+), 524 deletions(-) create mode 100644 resources/knowledge/original/실무문서/2024년 간선임도(기번3-울진.대흥)(2공구)/수량산출/~$4.2 수량산출(2공구).xlsx create mode 100644 resources/knowledge/original/실무문서/2024년 간선임도(기번3-울진.대흥)(3공구)/수량산출/~$4.3 수량산출(3공구).xlsx diff --git a/B04_PreProcess/B04_PreProcess_Api_Fetch.ts b/B04_PreProcess/B04_PreProcess_Api_Fetch.ts index e86f91cb..6b722201 100644 --- a/B04_PreProcess/B04_PreProcess_Api_Fetch.ts +++ b/B04_PreProcess/B04_PreProcess_Api_Fetch.ts @@ -442,7 +442,7 @@ export type PipeSource = "stream" | "spacing" | "user"; export type PipeFacility = "pipe" | "box_culvert" | "ford_pavement" | "ford_bridge"; export const PIPE_FACILITY_LABELS: ReadonlyArray<[PipeFacility, string]> = [ - ["pipe", "배관"], + ["pipe", "배수관"], ["box_culvert", "BOX암거"], ["ford_pavement", "물넘이포장"], ["ford_bridge", "세월교"], diff --git a/B05_Profile/B05_Profile_Structure_Types.json b/B05_Profile/B05_Profile_Structure_Types.json index f223b9de..9ca7f80f 100644 --- a/B05_Profile/B05_Profile_Structure_Types.json +++ b/B05_Profile/B05_Profile_Structure_Types.json @@ -5,21 +5,21 @@ { "type_id": "pipe", "group": "A", - "name": "배관(횡단배수관)", + "name": "배수관", "placement": "point", "managed_by": "pipe_points", "style": { "color": "#4f8ef7", - "abbr": "관" + "abbr": "배수관" }, "drawing_views": ["plan", "profile", "cross_section", "detail", "quantity"], "options": [ { - "key": "flow_type", - "label": "유형", + "key": "pipe_kind", + "label": "관종", "input": "select", - "choices": ["계곡부형", "보완형"], - "default": null, + "choices": ["흄관", "VR관", "파형강관"], + "default": "파형강관", "required": false }, { @@ -32,24 +32,15 @@ "required": false }, { - "key": "pipe_kind", - "label": "관종", + "key": "inlet_type", + "label": "유입구 구조", "input": "select", - "choices": ["흄관", "VR관", "파형강관"], - "default": null, - "required": true, - "phase": "detail" - }, - { - "key": "catch_basin", - "label": "집수정", - "input": "select", - "choices": ["있음", "없음"], - "default": null, + "choices": ["기슭막이", "집수정"], + "default": "기슭막이", "required": false }, { - "key": "catch_basin_form", + "key": "inlet_basin_form", "label": "집수정 형식", "input": "select", "choices": ["동물이동형", "□형(기본형)", "돌집수정 ㄴ형", "돌집수정 ㄷ형"], @@ -58,79 +49,124 @@ "phase": "detail" }, { - "key": "revetment", - "label": "기슭막이", + "key": "inlet_basin_material", + "label": "집수정 형태", "input": "select", - "choices": ["있음", "없음"], + "choices": ["콘크리트", "조립식 주철맨홀", "돌쌓기(찰)", "돌쌓기(메)"], "default": null, - "required": false - }, - { - "key": "revet_length_m", - "label": "기슭막이 길이", - "input": "number", - "unit": "m", - "default": null, - "required": false - }, - { - "key": "revet_front_m", - "label": "기슭막이 앞 분배", - "input": "number", - "unit": "m", - "default": null, - "required": false - }, - { - "key": "revet_form", - "label": "기슭막이 형식", - "input": "select", - "choices": [ - "돌쌓기(찰)", - "돌쌓기(메)", - "돌붙임", - "콘크리트", - "돌망태", - "통나무·목재틀", - "바자" - ], - "default": null, - "required": true, + "required": false, "phase": "detail" }, { - "key": "revet_height_m", - "label": "기슭막이 높이", - "input": "number", - "unit": "m", - "default": null, - "required": true, + "key": "inlet_revet_form", + "label": "유입 기슭막이 형태", + "input": "select", + "choices": ["돌쌓기(찰)", "돌쌓기(메)", "콘크리트", "돌망태", "통나무·목재틀", "바자"], + "default": "돌쌓기(찰)", + "required": false, "phase": "detail" }, { - "key": "stone_pitching", - "label": "돌붙임", - "input": "select", - "choices": ["있음", "없음"], - "default": null, + "key": "inlet_revet_length_m", + "label": "유입 기슭막이 길이", + "input": "number", + "unit": "m", + "default": 10, "required": false }, { - "key": "stone_pitching_type", - "label": "돌붙임 형식", + "key": "inlet_revet_height_m", + "label": "유입 기슭막이 높이", + "input": "number", + "unit": "m", + "default": 2.5, + "required": false, + "phase": "detail" + }, + { + "key": "inlet_pitching", + "label": "유입 돌붙임", + "input": "select", + "choices": ["있음", "없음"], + "default": "있음", + "required": false + }, + { + "key": "inlet_pitching_finish", + "label": "유입 돌붙임 표면처리", "input": "select", "choices": ["찰", "메"], - "default": null, - "required": true, + "default": "찰", + "required": false, "phase": "detail" }, { - "key": "stone_pitching_area_m2", - "label": "돌붙임 면적", + "key": "inlet_pitching_area_m2", + "label": "유입 돌붙임 면적", "input": "number", "unit": "㎡", - "default": null, - "required": true, + "default": 10, + "required": false, + "phase": "detail" + }, + { + "key": "outlet_type", + "label": "유출구 구조", + "input": "select", + "choices": ["기슭막이"], + "default": "기슭막이", + "required": false + }, + { + "key": "outlet_revet_form", + "label": "유출 기슭막이 형태", + "input": "select", + "choices": ["돌쌓기(찰)", "돌쌓기(메)", "콘크리트", "돌망태", "통나무·목재틀", "바자"], + "default": "돌쌓기(메)", + "required": false, + "phase": "detail" + }, + { + "key": "outlet_revet_length_m", + "label": "유출 기슭막이 길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false + }, + { + "key": "outlet_revet_height_m", + "label": "유출 기슭막이 높이", + "input": "number", + "unit": "m", + "default": 2.5, + "required": false, + "phase": "detail" + }, + { + "key": "outlet_pitching", + "label": "유출 돌붙임", + "input": "select", + "choices": ["있음", "없음"], + "default": "있음", + "required": false + }, + { + "key": "outlet_pitching_finish", + "label": "유출 돌붙임 표면처리", + "input": "select", + "choices": ["찰", "메"], + "default": "메", + "required": false, + "phase": "detail" + }, + { + "key": "outlet_pitching_area_m2", + "label": "유출 돌붙임 면적", + "input": "number", + "unit": "㎡", + "default": 10, + "required": false, "phase": "detail" } ] @@ -143,7 +179,7 @@ "managed_by": "pipe_points", "style": { "color": "#3b6fd4", - "abbr": "암" + "abbr": "암거" }, "drawing_views": ["plan", "profile", "cross_section", "detail", "quantity"], "options": [ @@ -245,7 +281,7 @@ "managed_by": "pipe_points", "style": { "color": "#2f9e8f", - "abbr": "물" + "abbr": "물넘이" }, "drawing_views": ["plan", "profile", "cross_section", "detail", "quantity"], "options": [] @@ -258,25 +294,25 @@ "managed_by": "pipe_points", "style": { "color": "#28857a", - "abbr": "세" + "abbr": "세월교" }, "drawing_views": ["plan", "profile", "cross_section", "detail", "quantity"], "options": [ { "key": "pipe_kind", - "label": "구체 내 배관 종류", + "label": "관종", "input": "select", "choices": ["흄관", "VR관", "파형강관"], - "default": null, + "default": "파형강관", "required": false }, { "key": "pipe_diameter_mm", - "label": "배관 크기", + "label": "관경", "input": "select", "choices": ["800", "1000", "1200", "1500"], "unit": "mm", - "default": null, + "default": "1000", "required": false }, { @@ -296,7 +332,7 @@ "placement": "point", "style": { "color": "#5aa7e8", - "abbr": "횡" + "abbr": "횡수로" }, "drawing_views": ["plan", "profile", "cross_section", "quantity"], "options": [] @@ -308,7 +344,7 @@ "placement": "point", "style": { "color": "#6d9bd1", - "abbr": "개" + "abbr": "개거" }, "drawing_views": ["plan", "profile", "cross_section", "quantity"], "options": [] @@ -321,7 +357,7 @@ "placement": "interval", "style": { "color": "#2eaadc", - "abbr": "측" + "abbr": "측구" }, "drawing_views": ["profile", "cross_section", "quantity"], "options": [ @@ -351,7 +387,7 @@ "placement": "interval", "style": { "color": "#2e8bdc", - "abbr": "산" + "abbr": "산마루" }, "drawing_views": ["profile", "cross_section", "quantity"], "options": [ @@ -374,7 +410,7 @@ "placement": "interval", "style": { "color": "#2ec5dc", - "abbr": "소" + "abbr": "소단" }, "drawing_views": ["cross_section", "quantity"], "options": [] @@ -387,7 +423,7 @@ "placement": "interval", "style": { "color": "#1f9bb8", - "abbr": "도" + "abbr": "도수로" }, "drawing_views": ["profile", "cross_section", "quantity"], "options": [ @@ -419,7 +455,7 @@ "placement": "interval", "style": { "color": "#28b1a1", - "abbr": "사" + "abbr": "사면로" }, "drawing_views": ["cross_section", "quantity"], "options": [ @@ -442,7 +478,7 @@ "placement": "interval", "style": { "color": "#1f7fb8", - "abbr": "맹" + "abbr": "맹암거" }, "drawing_views": ["profile", "cross_section", "quantity"], "options": [ @@ -461,7 +497,7 @@ "placement": "interval", "style": { "color": "#e07a2e", - "abbr": "옹" + "abbr": "옹벽" }, "drawing_views": ["profile", "cross_section", "detail", "quantity"], "options": [ @@ -492,7 +528,7 @@ "placement": "interval", "style": { "color": "#d0692a", - "abbr": "찰" + "abbr": "찰쌓기" }, "drawing_views": ["profile", "cross_section", "quantity"], "options": [ @@ -523,7 +559,7 @@ "placement": "interval", "style": { "color": "#c25d2c", - "abbr": "메" + "abbr": "메쌓기" }, "drawing_views": ["profile", "cross_section", "quantity"], "options": [ @@ -554,7 +590,7 @@ "placement": "interval", "style": { "color": "#b8742e", - "abbr": "흙" + "abbr": "흙막이" }, "drawing_views": ["profile", "cross_section", "quantity"], "options": [ @@ -585,7 +621,7 @@ "placement": "interval", "style": { "color": "#a86a2a", - "abbr": "큰" + "abbr": "큰돌" }, "drawing_views": ["profile", "cross_section", "quantity"], "options": [ @@ -616,7 +652,7 @@ "placement": "point", "style": { "color": "#7a52c7", - "abbr": "골" + "abbr": "골막이" }, "drawing_views": ["plan", "profile", "detail", "quantity"], "options": [ @@ -652,7 +688,7 @@ "placement": "point", "style": { "color": "#6b46b8", - "abbr": "댐" + "abbr": "사방댐" }, "drawing_views": ["plan", "profile", "detail", "quantity"], "options": [ @@ -674,23 +710,15 @@ "placement": "interval", "style": { "color": "#9b6bdc", - "abbr": "기" + "abbr": "기슭" }, "drawing_views": ["profile", "cross_section", "quantity"], "options": [ { "key": "form", - "label": "형식", + "label": "형태", "input": "select", - "choices": [ - "돌쌓기(찰)", - "돌쌓기(메)", - "돌붙임", - "콘크리트", - "돌망태", - "통나무·목재틀", - "바자" - ], + "choices": ["돌쌓기(찰)", "돌쌓기(메)", "콘크리트", "돌망태", "통나무·목재틀", "바자"], "default": null, "required": true, "phase": "detail" @@ -700,7 +728,33 @@ "label": "높이", "input": "number", "unit": "m", - "default": null, + "default": 2.5, + "required": true, + "phase": "detail" + }, + { + "key": "pitching", + "label": "돌붙임", + "input": "select", + "choices": ["있음", "없음"], + "default": "있음", + "required": false + }, + { + "key": "pitching_finish", + "label": "돌붙임 표면처리", + "input": "select", + "choices": ["찰", "메"], + "default": "메", + "required": false, + "phase": "detail" + }, + { + "key": "pitching_area_m2", + "label": "돌붙임 면적", + "input": "number", + "unit": "㎡", + "default": 10, "required": true, "phase": "detail" } @@ -713,7 +767,7 @@ "placement": "interval", "style": { "color": "#2fa84f", - "abbr": "대" + "abbr": "대피소" }, "drawing_views": ["plan", "cross_section", "quantity"], "options": [ @@ -742,7 +796,7 @@ "placement": "interval", "style": { "color": "#268f43", - "abbr": "작" + "abbr": "작업장" }, "drawing_views": ["plan", "cross_section", "quantity"], "options": [ @@ -769,7 +823,7 @@ "placement": "site", "style": { "color": "#37b95a", - "abbr": "차" + "abbr": "차돌림" }, "drawing_views": ["plan", "quantity"], "options": [ @@ -789,7 +843,7 @@ "placement": "point", "style": { "color": "#43c76b", - "abbr": "반" + "abbr": "반사경" }, "drawing_views": ["plan"], "options": [ @@ -811,7 +865,7 @@ "placement": "interval", "style": { "color": "#1f9e46", - "abbr": "가" + "abbr": "가드" }, "drawing_views": ["plan", "cross_section", "quantity"], "options": [ @@ -833,7 +887,7 @@ "placement": "point", "style": { "color": "#53a832", - "abbr": "번" + "abbr": "번호판" }, "drawing_views": ["plan", "detail"], "options": [] @@ -845,7 +899,7 @@ "placement": "point", "style": { "color": "#3f9e2e", - "abbr": "단" + "abbr": "차단기" }, "drawing_views": ["plan", "detail"], "options": [] @@ -857,7 +911,7 @@ "placement": "point", "style": { "color": "#66b344", - "abbr": "준" + "abbr": "준공" }, "drawing_views": ["plan"], "options": [] @@ -869,7 +923,7 @@ "placement": "site", "style": { "color": "#8c9a2e", - "abbr": "사" + "abbr": "사토장" }, "drawing_views": ["plan", "quantity"], "options": [ @@ -900,7 +954,7 @@ "placement": "site", "style": { "color": "#7d8a2a", - "abbr": "토" + "abbr": "토취장" }, "drawing_views": ["plan", "quantity"], "options": [ @@ -932,7 +986,7 @@ "placement": "point", "style": { "color": "#3aa88f", - "abbr": "야" + "abbr": "동물길" }, "drawing_views": ["plan", "cross_section"], "options": [ @@ -955,7 +1009,7 @@ "placement": "interval", "style": { "color": "#4cb84c", - "abbr": "녹" + "abbr": "녹화" }, "drawing_views": ["cross_section", "quantity"], "options": [ @@ -990,7 +1044,7 @@ "placement": "interval", "style": { "color": "#8a8f98", - "abbr": "포" + "abbr": "포장" }, "drawing_views": ["profile", "cross_section", "quantity"], "options": [ @@ -1013,7 +1067,7 @@ "placement": "interval", "style": { "color": "#9aa0a8", - "abbr": "쇄" + "abbr": "쇄석" }, "drawing_views": ["cross_section", "quantity"], "options": [ @@ -1035,7 +1089,7 @@ "placement": "point", "style": { "color": "#98a2b3", - "abbr": "기" + "abbr": "기타" }, "drawing_views": ["profile"], "options": [ diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts index 2ddbb700..c281ec60 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts @@ -7,10 +7,12 @@ * 시설 종류는 구조물 배치의 "종류" 드롭다운이, 위치(시작·기준·종료 측점)는 위치 * 칸이 담당하므로 여기에는 부속 옵션 필드만 있다. * - * 배관 부속(2026-08-17 확정): 유형(계곡부형/보완형 — 자동 배치 source로 제안), - * 집수정 유무(토글 시 유입부 옵션 전환), 관경(기본 1000), 기슭막이(길이 총/앞/뒤 - * 상호 연계 — 범위를 만든다 → onSpanSuggest로 위치 칸에 반영), 돌붙임. 형식·치수 - * 제원은 B06/B07 필수 승격 대상이라 보이되 비필수다. + * 배관 부속(2026-08-17 UI 개편): 관종·관경(기본 1000) 한 행 + 유입구·유출구 + * 소그룹. 유입구는 집수정/기슭막이 택일이고, 유출구는 기슭막이뿐이다(현재 다른 + * 선택지 없음). 기슭막이 조각은 재질·표면처리(찰/메)·길이·높이·돌붙임을 한 벌로 + * 묶어 유입·유출·독립 구조물(D4 기슭막이)이 **같은 인터페이스**를 쓴다. + * 유입·유출 길이가 정해지면 위치 칸(시작·종료 측점)을 기준 − 유입 ~ 기준 + 유출로 + * 자동 채운다. 형식·치수 제원은 B06/B07 필수 승격 대상이라 보이되 비필수다. * ========================================================================== */ import type { @@ -97,26 +99,7 @@ export function createFacilityStore() { export type FacilityStore = ReturnType; -interface FacilityOptionsFormCallbacks { - /** 아무 옵션이나 바뀌었을 때 — 패널이 저장 흐름(수정 확정)을 안내하는 데 쓴다. */ - onChange?: () => void; - /** 기슭막이 총/앞/뒤가 정해졌을 때 — 위치 칸(시작·종료 측점) 자동 채움 제안. - * 범위 = 기준 − 앞 ~ 기준 + 뒤 (2026-08-17 확정). */ - onSpanSuggest?: (frontM: number, backM: number) => void; -} - -export interface FacilityOptionsForm { - root: HTMLElement; - /** 시설 종류에 맞는 옵션 필드를 보여주고 값을 채운다. null이면 통째로 숨긴다. - * source는 배관 유형(계곡부형/보완형) 제안 근거다. */ - setFacility: ( - facility: PipeFacility | null, - options?: Record, - source?: PipeSource, - ) => void; - /** 현재 필드 값을 시설 옵션으로 읽는다(빈 값 생략). */ - readOptions: () => Record; -} +/* ── 폼 조각 ────────────────────────────────────────────────────────────── */ function labeled(text: string, input: HTMLElement): HTMLLabelElement { const wrapper = document.createElement("label"); @@ -127,7 +110,7 @@ function labeled(text: string, input: HTMLElement): HTMLLabelElement { return wrapper; } -function numberInput(step: string, min: string, placeholder = ""): HTMLInputElement { +function numberInput(step: string, min = "0", placeholder = ""): HTMLInputElement { const input = document.createElement("input"); input.type = "number"; input.step = step; @@ -136,29 +119,212 @@ function numberInput(step: string, min: string, placeholder = ""): HTMLInputElem return input; } -/** 첫 항목이 빈값(자동/미지정)인 셀렉트 — 명시하지 않으면 저장하지 않는다. */ -function optionalSelect(blankLabel: string, choices: ReadonlyArray): HTMLSelectElement { +/** 선택지 셀렉트 — 빈("미지정") 항목을 두지 않는다. 첫 항목이 곧 기본값이고, + * 기본값은 구조물별로 따로 지정한다(2026-08-17 사용자 지시 1). */ +function optionalSelect(_blankLabel: string, choices: ReadonlyArray): HTMLSelectElement { const element = document.createElement("select"); - element.replaceChildren( - new Option(blankLabel, ""), - ...choices.map((choice) => new Option(choice, choice)), - ); + element.replaceChildren(...choices.map((choice) => new Option(choice, choice))); return element; } -function row(...fields: HTMLElement[]): HTMLDivElement { +/** 2열 격자 한 줄(칸이 둘 이하면 그대로, 넘치면 줄바꿈). */ +function grid(...fields: HTMLElement[]): HTMLDivElement { const div = document.createElement("div"); - div.className = "b05-route__irregular-row"; + div.className = "b05-structure__grid"; div.append(...fields); return div; } -/** 자동 배치 source → 유형 제안 (2026-08-17 확정: 세류선 교차 = 계곡부형(유량 多), - * 간격 보충 = 보완형(유량 少·집수정), 수동 = 미지정). */ -function suggestFlowType(source: PipeSource | undefined): string { - if (source === "stream") return "계곡부형"; - if (source === "spacing") return "보완형"; - return ""; +/** 제목 달린 소그룹 상자(유입구·유출구). */ +function group(title: string): { root: HTMLFieldSetElement; body: HTMLDivElement } { + const root = document.createElement("fieldset"); + root.className = "b05-structure__group"; + const legend = document.createElement("legend"); + legend.textContent = title; + const body = document.createElement("div"); + body.className = "b05-structure__group-body"; + root.append(legend, body); + return { root, body }; +} + +/** 기슭막이 형태 — 재질과 표면처리를 한 축으로 합친 목록(2026-08-17 사용자 지시 1). + * 기존 기슭막이 형식 목록에 돌붙임의 찰·메 구분만 더한 것이다. */ +const REVET_FORMS = ["돌쌓기(찰)", "돌쌓기(메)", "콘크리트", "돌망태", "통나무·목재틀", "바자"]; +/** 돌붙임 표면처리 — 찰(모르타르 채움)/메(건쌓기) (돌쌓기 §1·§2). */ +const STONE_FINISHES = ["찰", "메"]; + +/** 기슭막이 옵션 키 묶음 — 유입·유출·독립 구조물이 같은 조각을 쓰되 키만 다르다. */ +export interface RevetmentKeys { + form: string; + length?: string; + height: string; + pitching: string; + pitchingFinish: string; + pitchingArea: string; +} + +/** 부위별 기본 구성 — 형태는 유입 돌쌓기(찰)·유출 돌쌓기(메), 길이 10m·높이 2.5m, + * 돌붙임 있음(면적 10㎡)에 표면처리만 유입 찰·유출 메 (2026-08-17 사용자 확정). */ +export interface RevetmentDefaults { + form: string; + length: string; + height: string; + pitching: string; + pitchingFinish: string; + pitchingArea: string; +} + +/** 부위와 무관한 공통 기본 치수 — 길이 10m·높이 2.5m·돌붙임 있음 10㎡. */ +const REVET_COMMON_DEFAULTS = { + length: "10", + height: "2.5", + pitching: "있음", + pitchingArea: "10", +} as const; + +export const INLET_REVET_KEYS: RevetmentKeys = { + form: "inlet_revet_form", + length: "inlet_revet_length_m", + height: "inlet_revet_height_m", + pitching: "inlet_pitching", + pitchingFinish: "inlet_pitching_finish", + pitchingArea: "inlet_pitching_area_m2", +}; + +export const OUTLET_REVET_KEYS: RevetmentKeys = { + form: "outlet_revet_form", + length: "outlet_revet_length_m", + height: "outlet_revet_height_m", + pitching: "outlet_pitching", + pitchingFinish: "outlet_pitching_finish", + pitchingArea: "outlet_pitching_area_m2", +}; + +/** 독립 기슭막이(D4) — 길이는 구간형 측점이 정하므로 길이 칸이 없다. */ +export const STANDALONE_REVET_KEYS: RevetmentKeys = { + form: "form", + height: "height_m", + pitching: "pitching", + pitchingFinish: "pitching_finish", + pitchingArea: "pitching_area_m2", +}; + +export interface RevetmentFields { + /** 형태 칸 — 유입·유출 그룹에서 "구조" 옆 2열째에 놓는다(2026-08-17 지시 3·4). */ + formField: HTMLLabelElement; + /** 형태를 뺀 나머지 행(길이·높이, 돌붙임). */ + rows: HTMLElement[]; + /** 길이 칸(없는 구성이면 null). */ + lengthInput: HTMLInputElement | null; + write: (options: Record) => void; + read: (target: Record) => void; + onChange: (handler: () => void) => void; + syncVisibility: () => void; +} + +/** 기슭막이 한 벌(형태·길이·높이 + 돌붙임) — 배수관 유입·유출부와 독립 구조물이 + * 공유하는 입력 인터페이스(2026-08-17 사용자 지시 9·11·12). 재질과 표면처리는 + * "형태" 한 축으로 합쳤고(지시 1), 돌붙임 유무는 빈값 없이 있음/없음뿐이며 기본값이 + * 부위마다 다르다 — 유입 있음·찰 / 유출 없음·메(지시 3). */ +export function createRevetmentFields( + keys: RevetmentKeys, + defaults: RevetmentDefaults, +): RevetmentFields { + const form = document.createElement("select"); + form.replaceChildren(...REVET_FORMS.map((value) => new Option(value, value))); + form.value = defaults.form; + const length = keys.length ? numberInput("0.1") : null; + if (length) length.value = defaults.length; + const height = numberInput("0.1"); + height.value = defaults.height; + const pitching = document.createElement("select"); + pitching.replaceChildren(...["있음", "없음"].map((value) => new Option(value, value))); + pitching.value = defaults.pitching; + const pitchingFinish = document.createElement("select"); + pitchingFinish.replaceChildren(...STONE_FINISHES.map((value) => new Option(value, value))); + pitchingFinish.value = defaults.pitchingFinish; + const pitchingArea = numberInput("0.1"); + pitchingArea.value = defaults.pitchingArea; + + const formField = labeled("형태", form); + const lengthField = length ? labeled("길이 (m)", length) : null; + const heightField = labeled("높이 (m)", height); + const pitchingField = labeled("돌붙임", pitching); + const pitchingFinishField = labeled("돌붙임 표면처리", pitchingFinish); + const pitchingAreaField = labeled("돌붙임 면적 (㎡)", pitchingArea); + + // 형태는 그룹 첫 행(구조 옆)에 따로 놓이므로 여기 행에서는 뺀다. + const rows = [ + grid(...[lengthField, heightField].filter((item): item is HTMLLabelElement => item !== null)), + grid(pitchingField, pitchingFinishField, pitchingAreaField), + ]; + + function syncVisibility(): void { + const noPitching = pitching.value === "없음"; + pitchingFinishField.hidden = noPitching; + pitchingAreaField.hidden = noPitching; + } + + // 만들자마자 기본값 상태를 맞춘다 — 첫 화면에서 유입 "있음"이 접혀 보이지 않게. + syncVisibility(); + + return { + formField, + rows, + lengthInput: length, + write(options) { + const text = (key: string): string => + options[key] !== undefined ? String(options[key]) : ""; + form.value = text(keys.form) || defaults.form; + if (length && keys.length) length.value = text(keys.length) || defaults.length; + height.value = text(keys.height) || defaults.height; + pitching.value = text(keys.pitching) || defaults.pitching; + pitchingFinish.value = text(keys.pitchingFinish) || defaults.pitchingFinish; + pitchingArea.value = text(keys.pitchingArea) || defaults.pitchingArea; + syncVisibility(); + }, + read(target) { + const putNumber = (key: string, raw: string): void => { + const value = Number.parseFloat(raw); + if (Number.isFinite(value) && value > 0) target[key] = Number(value.toFixed(1)); + }; + if (form.value) target[keys.form] = form.value; + if (length && keys.length) putNumber(keys.length, length.value); + putNumber(keys.height, height.value); + target[keys.pitching] = pitching.value; + if (pitching.value !== "없음") { + target[keys.pitchingFinish] = pitchingFinish.value; + putNumber(keys.pitchingArea, pitchingArea.value); + } + }, + onChange(handler) { + [form, height, pitching, pitchingFinish, pitchingArea].forEach((input) => + input.addEventListener("change", () => { + syncVisibility(); + handler(); + }), + ); + length?.addEventListener("change", handler); + }, + syncVisibility, + }; +} + +interface FacilityOptionsFormCallbacks { + /** 아무 옵션이나 바뀌었을 때 — 패널이 저장 흐름(수정 확정)을 안내하는 데 쓴다. */ + onChange?: () => void; +} + +export interface FacilityOptionsForm { + root: HTMLElement; + /** 시설 종류에 맞는 옵션 필드를 보여주고 값을 채운다. null이면 통째로 숨긴다. + * "revetment"는 독립 기슭막이 구조물(D4) — 배관 유입·유출부와 같은 조각을 쓴다. */ + setFacility: ( + facility: PipeFacility | "revetment" | null, + options?: Record, + ) => void; + /** 현재 필드 값을 시설 옵션으로 읽는다(빈 값 생략). */ + readOptions: () => Record; } /** 구조물 배치 폼의 옵션 자리에 끼워지는 계곡 통과 시설 부속 옵션 서브폼. */ @@ -169,195 +335,160 @@ export function createFacilityOptionsForm( root.className = "b05-drainage__facility"; root.hidden = true; - // ── 배관 부속 (2026-08-17 확정) ────────────────────────────────────────── - const flowSelect = optionalSelect("미지정", ["계곡부형", "보완형"]); - const catchBasinSelect = optionalSelect("자동(유형 따름)", ["있음", "없음"]); - const catchBasinFormSelect = optionalSelect("선택", [ + // ── 배관 본체 — 관종 > 관경 한 행(2026-08-17 사용자 지시 4) ────────────── + // 관종 기본값 = 파형강관(2026-08-17 사용자 확정) — 빈 항목 없이 셋 중 하나. + const pipeMaterial = document.createElement("select"); + pipeMaterial.replaceChildren( + ...["흄관", "VR관", "파형강관"].map((value) => new Option(value, value)), + ); + pipeMaterial.value = "파형강관"; + const pipeDiameter = document.createElement("select"); + pipeDiameter.replaceChildren( + ...["800", "1000", "1200", "1500"].map((size) => new Option(`Ø${size}`, size)), + ); + pipeDiameter.value = "1000"; // 별표2 원칙값 + 사용자 확정 기본 (2026-08-17) + const pipeRow = grid(labeled("관종", pipeMaterial), labeled("관경", pipeDiameter)); + + // ── 유입구 — 집수정 또는 기슭막이 택일(사용자 지시 5~9) ───────────────── + const inletGroup = group("유입구"); + // 빈값(미지정)을 두면 하위 칸이 전부 숨어 기본값이 안 보인다 — 기슭막이를 기본으로 + // 두고 저유량 지점에서 집수정으로 바꾼다(2026-08-17 사용자 지시). + const inletType = document.createElement("select"); + inletType.replaceChildren(...["기슭막이", "집수정"].map((value) => new Option(value, value))); + inletType.value = "기슭막이"; + const basinForm = optionalSelect("선택", [ "동물이동형", "□형(기본형)", "돌집수정 ㄴ형", "돌집수정 ㄷ형", ]); - const pipeDiameterSelect = document.createElement("select"); - pipeDiameterSelect.replaceChildren( - ...["800", "1000", "1200", "1500"].map((size) => new Option(`Ø${size}`, size)), - ); - pipeDiameterSelect.value = "1000"; // 별표2 원칙값 + 사용자 확정 기본 (2026-08-17) - const pipeMaterialSelect = optionalSelect("선택", ["흄관", "VR관", "파형강관"]); - const revetSelect = optionalSelect("자동(유형 따름)", ["있음", "없음"]); - const revetLength = numberInput("0.1", "0"); - const revetFront = numberInput("0.1", "0"); - const revetBack = numberInput("0.1", "0"); - const revetFormSelect = optionalSelect("선택", [ + // 집수정도 재질·표면처리를 형태 한 축으로 합친다(사용자 지시 1). + const basinMaterial = optionalSelect("선택", [ + "콘크리트", + "조립식 주철맨홀", "돌쌓기(찰)", "돌쌓기(메)", - "돌붙임", - "콘크리트", - "돌망태", - "통나무·목재틀", - "바자", ]); - const pitchSelect = optionalSelect("자동(유형 따름)", ["있음", "없음"]); - const pitchTypeSelect = optionalSelect("선택", ["찰", "메"]); + const basinMaterialField = labeled("형태", basinMaterial); + const basinRows = [grid(labeled("형식", basinForm))]; + const inletRevet = createRevetmentFields(INLET_REVET_KEYS, { + ...REVET_COMMON_DEFAULTS, + form: "돌쌓기(찰)", + pitchingFinish: "찰", + }); + // 첫 행 = [구조][형태] — 구조에 따라 집수정 형태와 기슭막이 형태가 갈아 끼워진다 + // (2026-08-17 사용자 지시 3). + inletGroup.body.append( + grid(labeled("구조", inletType), basinMaterialField, inletRevet.formField), + ...basinRows, + ...inletRevet.rows, + ); - // ── BOX암거 — 본체·날개벽 (짧은쪽 높이/길이/각도, 유입·유출 개별) ───────── - const boxWidth = numberInput("0.1", "0"); - const boxHeight = numberInput("0.1", "0"); + // ── 유출구 — 구조 선택지는 기슭막이뿐이지만 양식을 맞춘다(사용자 지시 2·4) ── + const outletGroup = group("유출구"); + const outletType = document.createElement("select"); + outletType.replaceChildren(new Option("기슭막이", "기슭막이")); + const outletRevet = createRevetmentFields(OUTLET_REVET_KEYS, { + ...REVET_COMMON_DEFAULTS, + form: "돌쌓기(메)", + pitchingFinish: "메", + }); + outletGroup.body.append( + grid(labeled("구조", outletType), outletRevet.formField), + ...outletRevet.rows, + ); + + // ── 독립 기슭막이(D4) — 같은 조각을 그대로 쓴다(사용자 지시 12) ───────── + const standaloneRevet = createRevetmentFields(STANDALONE_REVET_KEYS, { + ...REVET_COMMON_DEFAULTS, + form: "돌쌓기(메)", + pitchingFinish: "메", + }); + const standaloneWrap = document.createElement("div"); + standaloneWrap.append(grid(standaloneRevet.formField), ...standaloneRevet.rows); + + // ── BOX암거 — 본체·날개벽(유입·유출 개별) ────────────────────────────── + const boxWidth = numberInput("0.1"); + const boxHeight = numberInput("0.1"); + const boxRow = grid(labeled("본체 폭 (m)", boxWidth), labeled("본체 높이 (m)", boxHeight)); const wingIn = optionalSelect("미지정", ["있음", "없음"]); - const wingInHeight = numberInput("0.1", "0"); - const wingInLength = numberInput("0.1", "0"); - const wingInAngle = numberInput("1", "0"); + const wingInHeight = numberInput("0.1"); + const wingInLength = numberInput("0.1"); + const wingInAngle = numberInput("1"); const wingOut = optionalSelect("미지정", ["있음", "없음"]); - const wingOutHeight = numberInput("0.1", "0"); - const wingOutLength = numberInput("0.1", "0"); - const wingOutAngle = numberInput("1", "0"); + const wingOutHeight = numberInput("0.1"); + const wingOutLength = numberInput("0.1"); + const wingOutAngle = numberInput("1"); + const wingInGroup = group("날개벽(유입)"); + wingInGroup.body.append( + grid(labeled("설치", wingIn), labeled("짧은쪽 높이 (m)", wingInHeight)), + grid(labeled("길이 (m)", wingInLength), labeled("각도 (°)", wingInAngle)), + ); + const wingOutGroup = group("날개벽(유출)"); + wingOutGroup.body.append( + grid(labeled("설치", wingOut), labeled("짧은쪽 높이 (m)", wingOutHeight)), + grid(labeled("길이 (m)", wingOutLength), labeled("각도 (°)", wingOutAngle)), + ); - // ── 세월교 — 구체 내 배관 종류/크기/수량으로 폭을 정한다 ───────────────── - const fordKindSelect = optionalSelect("선택", ["흄관", "VR관", "파형강관"]); - const fordDiameterSelect = optionalSelect("선택", ["800", "1000", "1200", "1500"]); + // ── 세월교 — 구체 내 배관은 배수관과 같은 관종·관경 칸을 쓰고(2026-08-17 사용자 + // 지시) 수량만 따로 받는다. const fordCount = numberInput("1", "1", "련"); - - const pipeTypeRow = row( - labeled("유형", flowSelect), - labeled("관경", pipeDiameterSelect), - labeled("관종", pipeMaterialSelect), - ); - const pipeInletRow = row( - labeled("집수정", catchBasinSelect), - labeled("집수정 형식", catchBasinFormSelect), - ); - const revetRow = row( - labeled("기슭막이", revetSelect), - labeled("길이 (m)", revetLength), - labeled("앞 (m)", revetFront), - labeled("뒤 (m)", revetBack), - labeled("형식", revetFormSelect), - ); - const pitchRow = row(labeled("돌붙임", pitchSelect), labeled("형식", pitchTypeSelect)); - const boxRow = row(labeled("본체 폭 (m)", boxWidth), labeled("본체 높이 (m)", boxHeight)); - const wingInRow = row( - labeled("날개벽(유입)", wingIn), - labeled("짧은쪽 높이 (m)", wingInHeight), - labeled("길이 (m)", wingInLength), - labeled("각도 (°)", wingInAngle), - ); - const wingOutRow = row( - labeled("날개벽(유출)", wingOut), - labeled("짧은쪽 높이 (m)", wingOutHeight), - labeled("길이 (m)", wingOutLength), - labeled("각도 (°)", wingOutAngle), - ); - const fordRow = row( - labeled("배관 종류", fordKindSelect), - labeled("배관 크기", fordDiameterSelect), - labeled("수량 (련)", fordCount), - ); + const fordRow = grid(labeled("수량 (련)", fordCount)); root.append( - pipeTypeRow, - pipeInletRow, - revetRow, - pitchRow, + pipeRow, + inletGroup.root, + outletGroup.root, + standaloneWrap, boxRow, - wingInRow, - wingOutRow, + wingInGroup.root, + wingOutGroup.root, fordRow, ); - let currentFacility: PipeFacility | null = null; - let currentSource: PipeSource | undefined; - let syncing = false; - - /** 명시값이 없을 때의 유형 — 자동 배치 source 제안을 따른다. */ - function effectiveFlow(): string { - return flowSelect.value || suggestFlowType(currentSource); - } - - /** 집수정 유무 — 명시값 우선, 없으면 유형이 정한다(보완형 = 있음). */ - function effectiveCatchBasin(): string { - if (catchBasinSelect.value) return catchBasinSelect.value; - const flow = effectiveFlow(); - return flow ? (flow === "보완형" ? "있음" : "없음") : ""; - } - - /** 기슭막이 총/앞/뒤 상호 연계(2026-08-17 확정 — 예: 총 10 중 앞 4 → 뒤 6). - * changed 필드를 기준으로 나머지를 재계산한다. */ - function linkRevet(changed: "length" | "front" | "back"): void { - const length = Number.parseFloat(revetLength.value); - if (!Number.isFinite(length) || length <= 0) return; - let front = Number.parseFloat(revetFront.value); - let back = Number.parseFloat(revetBack.value); - if (changed === "back" && Number.isFinite(back)) { - back = Math.min(Math.max(back, 0), length); - front = length - back; - } else { - front = Number.isFinite(front) ? Math.min(Math.max(front, 0), length) : length / 2; - back = length - front; - } - revetFront.value = String(Number(front.toFixed(1))); - revetBack.value = String(Number(back.toFixed(1))); - } + let current: PipeFacility | "revetment" | null = null; function syncVisibility(): void { - const facility = currentFacility; - root.hidden = facility === null; - if (facility === null) return; - const isPipe = facility === "pipe"; - const isBox = facility === "box_culvert"; - pipeTypeRow.hidden = !isPipe; - pipeInletRow.hidden = !isPipe; - revetRow.hidden = !isPipe; - pitchRow.hidden = !isPipe; + root.hidden = current === null; + if (current === null) return; + const isPipe = current === "pipe"; + const isBox = current === "box_culvert"; + const isRevetment = current === "revetment"; + // 관종·관경은 배수관과 세월교가 공유한다. + pipeRow.hidden = !isPipe && current !== "ford_bridge"; + inletGroup.root.hidden = !isPipe; + outletGroup.root.hidden = !isPipe; + standaloneWrap.hidden = !isRevetment; boxRow.hidden = !isBox; - wingInRow.hidden = !isBox; - wingOutRow.hidden = !isBox; - fordRow.hidden = facility !== "ford_bridge"; + wingInGroup.root.hidden = !isBox; + wingOutGroup.root.hidden = !isBox; + fordRow.hidden = current !== "ford_bridge"; if (isPipe) { - // 유형 첫 항목에 제안을 드러낸다 — 저장은 사용자가 고른 값만. - const suggested = suggestFlowType(currentSource); - flowSelect.options[0].text = suggested ? `자동(${suggested})` : "미지정"; - catchBasinFormSelect.parentElement!.hidden = effectiveCatchBasin() !== "있음"; - pitchTypeSelect.parentElement!.hidden = pitchSelect.value === "없음"; - const noRevet = revetSelect.value === "없음"; - [revetLength, revetFront, revetBack, revetFormSelect].forEach( - (input) => (input.parentElement!.hidden = noRevet), - ); + // 유입구 구조에 따라 집수정 칸과 기슭막이 칸을 갈아 끼운다(사용자 지시 6~8). + const basin = inletType.value === "집수정"; + const revet = inletType.value === "기슭막이"; + basinRows.forEach((row) => (row.hidden = !basin)); + basinMaterialField.hidden = !basin; + inletRevet.formField.hidden = !revet; + inletRevet.rows.forEach((row) => (row.hidden = !revet)); + if (revet) inletRevet.syncVisibility(); } } - function putNumber(target: Record, key: string, raw: string): void { - const value = Number.parseFloat(raw); - if (Number.isFinite(value) && value > 0) target[key] = Number(value.toFixed(1)); - } - function emit(): void { - if (syncing || currentFacility === null) return; syncVisibility(); callbacks.onChange?.(); } - [revetLength, revetFront, revetBack].forEach((input, index) => - input.addEventListener("change", () => { - if (syncing) return; - linkRevet(index === 2 ? "back" : index === 1 ? "front" : "length"); - const front = Number.parseFloat(revetFront.value); - const back = Number.parseFloat(revetBack.value); - if (Number.isFinite(front) && Number.isFinite(back)) { - callbacks.onSpanSuggest?.(front, back); - } - emit(); - }), + [inletType, outletType, basinForm, basinMaterial].forEach((input) => + input.addEventListener("change", emit), ); + inletRevet.onChange(emit); + outletRevet.onChange(emit); + standaloneRevet.onChange(emit); [ - flowSelect, - pipeDiameterSelect, - pipeMaterialSelect, - catchBasinSelect, - catchBasinFormSelect, - revetSelect, - revetFormSelect, - pitchSelect, - pitchTypeSelect, + pipeMaterial, + pipeDiameter, boxWidth, boxHeight, wingIn, @@ -368,42 +499,34 @@ export function createFacilityOptionsForm( wingOutHeight, wingOutLength, wingOutAngle, - fordKindSelect, - fordDiameterSelect, fordCount, ].forEach((input) => input.addEventListener("change", emit)); + function putNumber(target: Record, key: string, raw: string): void { + const value = Number.parseFloat(raw); + if (Number.isFinite(value) && value > 0) target[key] = Number(value.toFixed(1)); + } + return { root, - setFacility(facility, options = {}, source) { - currentFacility = facility; - currentSource = source; + setFacility(facility, options = {}) { + current = facility; if (facility === null) { syncVisibility(); return; } - syncing = true; const text = (key: string): string => options[key] !== undefined ? String(options[key]) : ""; const isFord = facility === "ford_bridge"; - flowSelect.value = text("flow_type"); - pipeDiameterSelect.value = (!isFord && text("pipe_diameter_mm")) || "1000"; - pipeMaterialSelect.value = isFord ? "" : text("pipe_kind"); - catchBasinSelect.value = text("catch_basin"); - catchBasinFormSelect.value = text("catch_basin_form"); - revetSelect.value = text("revetment"); - revetLength.value = text("revet_length_m"); - revetFront.value = text("revet_front_m"); - // 뒤 = 총 − 앞 (파생값 — 저장하지 않고 표시만 한다). - const length = Number.parseFloat(revetLength.value); - const front = Number.parseFloat(revetFront.value); - revetBack.value = - Number.isFinite(length) && Number.isFinite(front) - ? String(Number((length - front).toFixed(1))) - : ""; - revetFormSelect.value = text("revet_form"); - pitchSelect.value = text("stone_pitching"); - pitchTypeSelect.value = text("stone_pitching_type"); + pipeMaterial.value = text("pipe_kind") || "파형강관"; + pipeDiameter.value = text("pipe_diameter_mm") || "1000"; + inletType.value = text("inlet_type") || "기슭막이"; + outletType.value = text("outlet_type") || "기슭막이"; + basinForm.value = text("inlet_basin_form"); + basinMaterial.value = text("inlet_basin_material"); + inletRevet.write(options); + outletRevet.write(options); + standaloneRevet.write(options); boxWidth.value = text("body_width_m"); boxHeight.value = text("body_height_m"); wingIn.value = text("wing_in"); @@ -414,32 +537,26 @@ export function createFacilityOptionsForm( wingOutHeight.value = text("wing_out_height_m"); wingOutLength.value = text("wing_out_length_m"); wingOutAngle.value = text("wing_out_angle_deg"); - fordKindSelect.value = isFord ? text("pipe_kind") : ""; - fordDiameterSelect.value = isFord ? text("pipe_diameter_mm") : ""; fordCount.value = isFord ? text("pipe_count") : ""; - syncing = false; syncVisibility(); }, readOptions() { - const facility = currentFacility; const options: Record = {}; - if (facility === "pipe") { - if (flowSelect.value) options.flow_type = flowSelect.value; - options.pipe_diameter_mm = Number(pipeDiameterSelect.value); - if (pipeMaterialSelect.value) options.pipe_kind = pipeMaterialSelect.value; - if (catchBasinSelect.value) options.catch_basin = catchBasinSelect.value; - if (catchBasinFormSelect.value && effectiveCatchBasin() === "있음") - options.catch_basin_form = catchBasinFormSelect.value; - if (revetSelect.value) options.revetment = revetSelect.value; - if (revetSelect.value !== "없음") { - putNumber(options, "revet_length_m", revetLength.value); - putNumber(options, "revet_front_m", revetFront.value); - if (revetFormSelect.value) options.revet_form = revetFormSelect.value; + if (current === "pipe") { + options.pipe_diameter_mm = Number(pipeDiameter.value); + options.pipe_kind = pipeMaterial.value; + options.inlet_type = inletType.value; + if (inletType.value === "집수정") { + if (basinForm.value) options.inlet_basin_form = basinForm.value; + if (basinMaterial.value) options.inlet_basin_material = basinMaterial.value; + } else if (inletType.value === "기슭막이") { + inletRevet.read(options); } - if (pitchSelect.value) options.stone_pitching = pitchSelect.value; - if (pitchTypeSelect.value && pitchSelect.value !== "없음") - options.stone_pitching_type = pitchTypeSelect.value; - } else if (facility === "box_culvert") { + options.outlet_type = outletType.value; + outletRevet.read(options); + } else if (current === "revetment") { + standaloneRevet.read(options); + } else if (current === "box_culvert") { putNumber(options, "body_width_m", boxWidth.value); putNumber(options, "body_height_m", boxHeight.value); if (wingIn.value) options.wing_in = wingIn.value; @@ -454,9 +571,9 @@ export function createFacilityOptionsForm( putNumber(options, "wing_out_length_m", wingOutLength.value); putNumber(options, "wing_out_angle_deg", wingOutAngle.value); } - } else if (facility === "ford_bridge") { - if (fordKindSelect.value) options.pipe_kind = fordKindSelect.value; - if (fordDiameterSelect.value) options.pipe_diameter_mm = Number(fordDiameterSelect.value); + } else if (current === "ford_bridge") { + options.pipe_kind = pipeMaterial.value; + options.pipe_diameter_mm = Number(pipeDiameter.value); const count = Number.parseInt(fordCount.value, 10); if (Number.isFinite(count) && count > 0) options.pipe_count = count; } diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts index 9fae6e75..c5068c63 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts @@ -431,10 +431,14 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra ); // 시설 확장 정보(종류·구간·옵션)는 응답이 정본 — 통째로 다시 채운다. facilityStore.replaceFromResponse(response.pipe_points); + // 재계산 뒤에는 선택을 다시 알려야 한다 — 관을 새로 넣은 직후에는 목록이 아직 + // 비어 폼이 열리지 못했고, 중복 억제 가드에 걸려 두 번째 기회를 잃는다. + lastNotifiedPipeChainage = null; selectBasin(null, false); renderBasinList(); - syncPipeSelection(); summaryText(summary, pipeEditor.pipes().length, basins.length, zSource); + // 목록 통지가 선택 통지보다 **먼저** 가야 한다 — 사이드 폼은 이 목록에서 관을 + // 찾아 편집 화면을 채우므로, 순서가 뒤집히면 새로 넣은 관의 폼이 열리지 않는다. callbacks.onPipesChanged?.( // 관마다 담당 유역의 배수 유효직경을 붙인다(±0.5m 매칭). 유역 없는 관은 null. // 세월교 검토 유역(bridge_required)도 null — 관 규격을 최대치로 올려 봐야 무의미하고, @@ -457,6 +461,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra }; }), ); + syncPipeSelection(); } /** 세부유역 요청 한 번을 감싼다 — 버튼 잠금·진행 문구·오류 표기를 한 자리에 모은다. */ diff --git a/B05_Profile/B05_Profile_UI_IrregularStations.ts b/B05_Profile/B05_Profile_UI_IrregularStations.ts index a9ca5ae5..e3837b76 100644 --- a/B05_Profile/B05_Profile_UI_IrregularStations.ts +++ b/B05_Profile/B05_Profile_UI_IrregularStations.ts @@ -49,20 +49,26 @@ export interface IrregularStation { origin?: "user" | "pipe"; } -/** 관 매설 지점이 구조물 목록에 실체화될 때 쓰는 종류 이름. */ +/** 관 매설 지점이 구조물 목록에 실체화될 때 쓰는 종류 값(구 데이터 판정 키라 유지 — + * 화면 표시 이름은 "배수관"이다, 2026-08-17 사용자 지시). */ export const PIPE_STRUCTURE_NAME = "배관"; +/** 화면에 보이는 이름. */ +export const PIPE_DISPLAY_NAME = "배수관"; -/** 배관으로 볼 항목인가. 드롭다운 종류가 "배관"이거나 손으로 이름을 "배관"이라 적은 것도 - * 관 지점 정본을 따르게 한다 — 아니면 [초기화]로 지워지지 않고 배수유역도와 어긋난다. */ +/** 배수관으로 볼 항목인가. 드롭다운 종류가 "배관"이거나 손으로 이름을 "배관"·"배수관"이라 + * 적은 것도 관 지점 정본을 따르게 한다 — 아니면 [초기화]로 지워지지 않고 배수유역도와 + * 어긋난다. */ export function isPipeStation(station: { origin?: "user" | "pipe"; structure: string; structureType?: StructureType; }): boolean { + const name = station.structure.trim(); return ( station.origin === "pipe" || station.structureType === PIPE_STRUCTURE_NAME || - station.structure.trim() === PIPE_STRUCTURE_NAME + name === PIPE_STRUCTURE_NAME || + name === PIPE_DISPLAY_NAME ); } diff --git a/B05_Profile/B05_Profile_UI_Page.ts b/B05_Profile/B05_Profile_UI_Page.ts index c02ccaf7..4ba9e851 100644 --- a/B05_Profile/B05_Profile_UI_Page.ts +++ b/B05_Profile/B05_Profile_UI_Page.ts @@ -223,7 +223,12 @@ export async function renderB05Route(root: HTMLElement): Promise { // 관 매설 목록 → 그래프 배관 측점선·통합 목록을 한 방향으로 맞춘다. // 정본은 배수유역도의 관 지점이며, 화면 목록은 그것을 실체화한 것이다. onPipesChanged: (pipes) => { - applyIrregularStations(pipesToStations(pipes)); + // 횡단배수는 계획선을 그 지점에 물리는 시설이라 그래프 세로 점선·계획고 틸팅 + // 버튼이 필요하다(2026-08-17 사용자 지시). 알약 레인은 종류·선택을 맡는다. + pipeStations = pipesToStations(pipes); + syncCrossDrainStations(); + pipeMarks = pipesToMarks(pipes); + syncGraphStructures(); panel.structures.setPipeFacilities( pipes.map((pipe) => ({ chainage_m: pipe.chainage_m, @@ -252,10 +257,25 @@ export async function renderB05Route(root: HTMLElement): Promise { onBasinSelected: (chainageM) => selectStationOfPipe(chainageM), // 관 마커 선택(유역 없는 관 포함)도 같은 경로로 전 화면을 맞춘다(2026-08-17). onPipeSelected: (chainageM) => selectStationOfPipe(chainageM), - // 구조물 서클마크 — 그래프에서 고르면 사이드 폼도 같은 항목을 연다. - onStructureSelect: (structureId) => panel.structures.selectById(structureId), - onStructureMarkMove: (structureId, toChainage) => - panel.structures.moveById(structureId, toChainage), + // 구조물 알약 — 그래프에서 고르면 사이드 폼도 같은 항목을 연다. 계곡 통과 시설 + // (가상 id `pipe-*`)은 관 정본 소관이라 누가거리로 되돌려 보낸다(2026-08-17). + onStructureSelect: (structureId) => { + const chainage = pipeMarkChainage(structureId); + if (chainage !== null) { + panel.structures.selectPipeByChainage(chainage); + selectStationOfPipe(chainage); + return; + } + panel.structures.selectById(structureId); + }, + onStructureMarkMove: (structureId, toChainage) => { + const chainage = pipeMarkChainage(structureId); + if (chainage !== null) { + profilePanel.drainage.movePipe(chainage, toChainage); + return; + } + panel.structures.moveById(structureId, toChainage); + }, onStructureTypeAdd: (chainage, typeId) => panel.structures.addAt(chainage, typeId), }, ); @@ -475,7 +495,7 @@ export async function renderB05Route(root: HTMLElement): Promise { /** 시설 종류별 표시 이름 — 그래프·3D 라벨용(배관은 관종·관경까지). */ const FACILITY_NAMES: Record = { - pipe: "배관", + pipe: "배수관", box_culvert: "BOX암거", ford_pavement: "물넘이포장", ford_bridge: "세월교", @@ -516,6 +536,7 @@ export async function renderB05Route(root: HTMLElement): Promise { /** [초기선 복원] 등에서 그래프의 배관 투영만 지운다 — 관 정본은 건드리지 않는다. */ function clearProjectedStations(): void { + pipeStations = []; irregularStations = []; if (currentSectionDetail) renderStationLines(currentSectionDetail); profilePanel.setIrregularStations([]); @@ -545,8 +566,82 @@ export async function renderB05Route(root: HTMLElement): Promise { let structureRevision = 0; let structureSaving: Promise = Promise.resolve(); + /* ── 횡단배수(A군) 측점 세로선 ────────────────────────────────────────── + * 횡단배수 시설은 계획선을 그 지점에 물리므로 그래프에 세로 점선과 계획고 틸팅 + * 버튼(▲▼)이 있어야 한다(2026-08-17 사용자 지시). 대상은 관 정본(배수관·BOX암거· + * 물넘이·세월교)에 더해 수동 A군(노출형 횡단수로·개거)까지다. */ + let pipeStations: IrregularStation[] = []; + let structureTypeMap = new Map(); + + /** A군 수동 구조물을 그래프 측점 목록으로 투영한다(관 정본 항목은 pipeStations 몫). */ + function crossDrainStationsOf(structures: StructureInstance[]): IrregularStation[] { + const interval = panel.values().stationInterval || 20; + return structures + .filter((structure) => structureTypeMap.get(structure.type_id)?.group === "A") + .map((structure) => { + const chainage = structure.chainage_m ?? structure.start_m ?? 0; + const station = Math.floor(chainage / interval); + return { + id: structure.structure_id ?? `structure-${chainage.toFixed(2)}`, + station, + remainder: chainage - station * interval, + chainage_m: chainage, + structure: structureTypeMap.get(structure.type_id)?.name ?? structure.type_id, + }; + }); + } + + /** 관 정본 + A군 수동 구조물을 합쳐 그래프·3D 측점 목록을 맞춘다. */ + function syncCrossDrainStations(): void { + applyIrregularStations([...pipeStations, ...crossDrainStationsOf(ownStructures)]); + } + + /** 알약 id가 계곡 통과 시설(관 정본)이면 그 누가거리, 아니면 null. */ + function pipeMarkChainage(structureId: string | null): number | null { + if (!structureId?.startsWith("pipe-")) return null; + const value = Number(structureId.slice("pipe-".length)); + return Number.isFinite(value) ? value : null; + } + + /** 그래프 알약 레인에 올릴 목록 — 구조물 정본 + 계곡 통과 시설(관 정본)을 합친다. + * 자동 배수관도 세로 점선이 아니라 같은 알약으로 나온다(2026-08-17 사용자 지시 1). */ + let ownStructures: StructureInstance[] = []; + let pipeMarks: StructureInstance[] = []; + + function syncGraphStructures(): void { + profilePanel.setStructures([...ownStructures, ...pipeMarks]); + } + + /** 관 지점을 알약 레인용 가상 구조물로 만든다(정본은 pipe_points, 저장하지 않는다). */ + function pipesToMarks( + pipes: Array<{ + chainage_m: number; + facility: PipeFacility; + options?: Record; + }>, + ): StructureInstance[] { + return pipes.map((pipe) => ({ + structure_id: `pipe-${pipe.chainage_m.toFixed(2)}`, + type_id: pipe.facility, + placement: "point", + chainage_m: pipe.chainage_m, + start_m: null, + end_m: null, + side: "cross", + offset_m: 0, + options: pipe.options ?? {}, + memo: "", + placement_source: "automatic", + status: "draft", + revision: 0, + geometry: null, + })); + } + function applyStructures(next: StructureInstance[]): void { - profilePanel.setStructures(next); + ownStructures = next; + syncGraphStructures(); + syncCrossDrainStations(); // 저장 요청이 겹치면 판번호가 어긋나므로 앞의 저장이 끝난 뒤에 보낸다. structureSaving = structureSaving.then(() => persistStructures(next)); } @@ -557,7 +652,9 @@ export async function renderB05Route(root: HTMLElement): Promise { if (!stored) return false; structureRevision = stored.revision; panel.structures.setStructures(stored.structures); - profilePanel.setStructures(stored.structures); + ownStructures = stored.structures; + syncGraphStructures(); + syncCrossDrainStations(); return true; } @@ -599,9 +696,15 @@ export async function renderB05Route(root: HTMLElement): Promise { ]); panel.structures.setTypes(types); profilePanel.setStructureTypes(types); + // A군 판정에 쓸 타입 정보 — 횡단배수만 그래프 세로선·틸팅 대상이다. + structureTypeMap = new Map( + types.map((type) => [type.type_id, { group: type.group, name: type.name }]), + ); structureRevision = stored.revision; panel.structures.setStructures(stored.structures); - profilePanel.setStructures(stored.structures); + ownStructures = stored.structures; + syncGraphStructures(); + syncCrossDrainStations(); } catch (error) { showToast( error instanceof Error ? error.message : "구조물 정보를 불러오지 못했습니다.", diff --git a/B05_Profile/B05_Profile_UI_Profile_Edit.ts b/B05_Profile/B05_Profile_UI_Profile_Edit.ts index 79c6dac8..97542062 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Edit.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Edit.ts @@ -274,22 +274,28 @@ export function createEditOverlay(options: EditOverlayOptions): HTMLElement { ); const stationXs = stationPlacements.map((entry) => entry.left); + /** 이미 자리를 잡은 구간 버튼 x — 구간끼리도 겹치지 않게 기억해 둔다. */ + const segmentXs: number[] = []; + /** * 구간 버튼을 측점 버튼과 같은 행에 두되, 겹치는 자리면 옆으로 비킨다. * 측점 버튼은 측점 수직선 위에 있어야 의미가 통하므로 **측점 쪽을 고정**하고 * 구간 버튼만 오른쪽으로 밀어낸다(구간이 짝수 개 측점을 걸치면 중점이 측점과 겹친다). + * + * 한 번만 밀면 밀어낸 자리에 또 다른 측점·구간 버튼이 있을 때 그대로 겹친다 + * (2026-08-17 사용자 보고). 빈자리가 나올 때까지 반복해서 밀어낸다. */ function avoidStations(center: number): number { - let nearest = center; - let best = Infinity; - for (const stationX of stationXs) { - const distance = Math.abs(stationX - center); - if (distance < best) { - best = distance; - nearest = stationX; - } + let placed = center; + for (let guard = 0; guard < 40; guard += 1) { + const blocking = [...stationXs, ...segmentXs] + .filter((px) => Math.abs(px - placed) < BUTTON_CLEARANCE_PX) + .sort((left, right) => right - left)[0]; + if (blocking === undefined) break; + placed = blocking + BUTTON_CLEARANCE_PX; } - return best < BUTTON_CLEARANCE_PX ? nearest + BUTTON_CLEARANCE_PX : center; + segmentXs.push(placed); + return placed; } // 측점 하나에 ▲/▼(+편집됐으면 원복 ↺) 버튼을 단다. 규칙·비정규 측점 공용 — 비정규 측점도 // `onStation`이 임의 chainage를 변화점으로 승격시키므로 규칙 측점과 완전히 같은 파이프라인이다. diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel.ts b/B05_Profile/B05_Profile_UI_Profile_Panel.ts index 76eb332a..c0f01a90 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel.ts @@ -23,8 +23,12 @@ import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; import { createPanelResizer } from "@ui/ui_template_resizer"; import { createDrainagePanel } from "./B05_Profile_UI_Drainage_Panel"; import type { PipeFacility, PipeSource } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; -import type { StructureInstance, StructureType } from "./B05_Profile_Api_Structures"; -import { mountStructureMarks } from "./B05_Profile_UI_Structures_Marks"; +import { + structureAnchorM, + type StructureInstance, + type StructureType, +} from "./B05_Profile_Api_Structures"; +import { buildStructureLane, STRUCTURE_LANE_HEIGHT_PX } from "./B05_Profile_UI_Structures_Marks"; import { mountStructureMenu } from "./B05_Profile_UI_Profile_Structures"; import { createProfileTableOverlay, @@ -85,8 +89,10 @@ const HEIGHT_KEY = "b05-route-profile-height"; /* 테이블 높이는 오버레이 서브패널(--b05-table-height 리사이저)이 관리한다 — 예전 4:6 고정 분할(TABLE_HEIGHT_KEY·CHART_HEIGHT_RATIO)은 폐지(2026-08-05). */ const MIN_CHART_HEIGHT = 100; -/** 접힌 유토곡선 손잡이(24px)가 앉을 테이블 아래 전용 띠 — 곡선 행 클릭을 가리지 않게. */ -const MASSHAUL_HANDLE_GUTTER_PX = 26; +/** 접힌 유토곡선 손잡이가 앉을 테이블 아래 전용 띠 — 곡선 행 클릭을 가리지 않게. + * 구조물 알약 레인이 그 위에 붙은 뒤로는 사이가 떠 보여 절반으로 줄였다 + * (2026-08-17 사용자 지시 2). */ +const MASSHAUL_HANDLE_GUTTER_PX = 13; /** 패널을 끌어 줄일 수 있는 하한(px) — 이보다 낮으면 그래프도 테이블도 못 읽는다. */ const MIN_PANEL_HEIGHT = 180; /** 상한은 3D 뷰포트가 완전히 가려지지 않도록 부모 높이의 90%까지만 허용한다. */ @@ -411,9 +417,43 @@ export function createRouteProfilePanel( * 측점 선택 — 같은 측점 재선택이면 해제한다(2026-08-04 사용자 지시). * 그래프(종단·유토곡선) 클릭이 모두 이 경로를 탄다. 해제는 null로 Page에 알린다. */ + /** 같은 자리로 볼 여유(m) — 측점선과 알약은 같은 누가거리를 쓰지만 소수점이 갈린다. */ + const SAME_CHAINAGE_M = 0.51; + + /** 측점선 id → 알약(구조물) id. 같은 누가거리의 구조물이 없으면 null. */ + function structureIdAtStation(stationId: string | null): string | null { + if (stationId === null) return null; + const prefix = irregularStationId(""); + if (!stationId.startsWith(prefix)) return null; + const station = irregularStations.find((entry) => irregularStationId(entry.id) === stationId); + if (!station) return null; + const hit = structures.find( + (item) => Math.abs(structureAnchorM(item) - station.chainage_m) < SAME_CHAINAGE_M, + ); + return hit?.structure_id ?? null; + } + + /** 알약(구조물) id → 측점선 id. 세로선이 없는 구조물(A군 외)이면 null. */ + function stationIdAtStructure(structureId: string | null): string | null { + if (structureId === null) return null; + const structure = structures.find((item) => item.structure_id === structureId); + if (!structure) return null; + const anchor = structureAnchorM(structure); + const station = irregularStations.find( + (entry) => Math.abs(entry.chainage_m - anchor) < SAME_CHAINAGE_M, + ); + return station ? irregularStationId(station.id) : null; + } + + /** + * 측점 선택 — 같은 측점 재선택이면 해제한다(2026-08-04 사용자 지시). + * 그래프(종단·유토곡선) 클릭이 모두 이 경로를 탄다. 해제는 null로 Page에 알린다. + * 같은 자리 알약도 함께 켜고 끈다(2026-08-17 사용자 지시 — 둘은 한 구조물이다). + */ function selectStation(stationId: string | null): void { const next = stationId !== null && stationId === selectedStationId ? null : stationId; selectedStationId = next; + selectedStructureId = structureIdAtStation(next); draw(); onSelectStation(next); } @@ -564,7 +604,9 @@ export function createRouteProfilePanel( * 이 함수만 프레임마다 돌리고, 무거운 차트·테이블 재구성은 손을 뗀 뒤 한 번만 한다. */ function applyHeightCascade(allowGrow: boolean): { chartHeight: number } { - const available = Math.max(120, body.clientHeight); + // 구조물 알약 레인이 그래프 아래 한 줄을 차지한다 — 그만큼 먼저 떼어 놓고 나눈다 + // (2026-08-17 사용자 지시 6: 그래프가 밀려도 되지만 여유는 최소). + const available = Math.max(120, body.clientHeight - STRUCTURE_LANE_HEIGHT_PX); const massOpen = !massHaul.overlay.hidden; const tableOpen = tableOverlay.isOpen(); // 판정 기준은 항상 **저장 높이**(리사이저 변수·기본값)다 — 임시 축소가 반영된 화면 @@ -696,7 +738,7 @@ export function createRouteProfilePanel( const { chartHeight } = applyHeightCascade(subPanelDragging); const canvas = body.firstElementChild as HTMLElement | null; if (!canvas || !canvas.classList.contains("b05-profile__canvas")) return; - canvas.style.height = `${chartHeight}px`; + canvas.style.height = `${chartHeight + STRUCTURE_LANE_HEIGHT_PX}px`; const chartWrap = canvas.querySelector(".b05-profile__chart"); if (chartWrap) { chartWrap.style.height = `${chartHeight}px`; @@ -846,18 +888,24 @@ export function createRouteProfilePanel( .map((type) => ({ type_id: type.type_id, group: type.group, name: type.name })), onAddStructureType: (chainage, typeId) => callbacks?.onStructureTypeAdd?.(chainage, typeId), }); - // 구조물 정본 서클마크 — 배치형태와 무관하게 마크 하나, 고르면 벌룬(+구간 띠). - mountStructureMarks(chartWrap, { + // 구조물 알약 레인 — 그래프 아래 별도 줄(2026-08-17 사용자 지시 1·3). 자동 배수관도 + // 세로 점선이 아니라 같은 알약으로 여기에 놓인다. + const structureLane = buildStructureLane({ structures, types: structureTypes, x, chainageAt: chainageInverter(longitudinal, width, layout.originOffset), maxChainageM: maxChainageOf(longitudinal), + widthPx: width, + // 그래프 sticky Y축과 같은 폭으로 레인 축을 이어 붙인다(2026-08-17 지시 2). + axisWidthPx: LONG_PAD.left, // 벌룬·툴팁 위치 표기는 측점번호+잔여거리(2026-08-17 사용자 확정). stationIntervalM: stationInterval ?? 20, selectedId: selectedStructureId, onSelect: (structureId) => { selectedStructureId = structureId; + // 같은 자리 측점 세로선도 함께 켜고 끈다 — 알약과 세로선은 한 구조물이다. + selectedStationId = stationIdAtStructure(structureId); callbacks?.onStructureSelect?.(structureId); draw(); }, @@ -894,8 +942,9 @@ export function createRouteProfilePanel( ); } // 캔버스 높이 = 종단도 영역만. 서브패널이 차지한 아래 공간을 덮지 않는다(밀어올리기). - canvas.style.height = `${chartHeight}px`; - canvas.append(chartWrap); + // 구조물 알약 레인이 그래프 밑에 한 줄 붙으므로 그만큼 캔버스가 커진다(2026-08-17 지시 6). + canvas.style.height = `${chartHeight + STRUCTURE_LANE_HEIGHT_PX}px`; + canvas.append(chartWrap, structureLane); body.replaceChildren(canvas); body.scrollLeft = scrollLeft; // 테이블은 바닥 고정 오버레이에 담는다 — 폭은 종단 캔버스와 같아 세로선이 맞물린다. @@ -1015,6 +1064,8 @@ export function createRouteProfilePanel( }, setSelectedStation(stationId: string | null) { selectedStationId = stationId; + // 3D·리스트·배수유역도에서 온 선택도 같은 자리 알약을 함께 켠다(2026-08-17). + selectedStructureId = structureIdAtStation(stationId); draw(); }, /** 확정된 노선 평면 선형을 우측 배수유역 지도에 겹친다(사업지 좌표계 m). */ @@ -1039,9 +1090,10 @@ export function createRouteProfilePanel( } draw(); }, - /** 사이드 목록에서 고른 구조물을 그래프 마크 선택에 맞춘다. */ + /** 사이드 목록에서 고른 구조물을 그래프 알약·측점 세로선 선택에 함께 맞춘다. */ setSelectedStructure(structureId: string | null) { selectedStructureId = structureId; + selectedStationId = stationIdAtStructure(structureId); draw(); }, /** 이어 공사 시작 기준(측점번호·누가거리 오프셋)을 반영해 측점 라벨·누가거리 표시를 옮긴다. */ diff --git a/B05_Profile/B05_Profile_UI_Structures_Marks.ts b/B05_Profile/B05_Profile_UI_Structures_Marks.ts index bdbaf790..7cf7d5d0 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Marks.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Marks.ts @@ -1,13 +1,17 @@ /* ============================================================================= * B05_Profile_UI_Structures_Marks.ts - * 종단 그래프 위 구조물 서클마크·벌룬 오버레이. + * 종단 그래프 아래 구조물 알약(pill) 레인 + 벌룬. * - * 표기 규칙 (2026-08-16 사용자 확정): - * - 배치형태와 무관하게 **서클마크 하나**로 표시한다. 구간형은 기점(시점)에 찍는다. - * - 마크를 고르면 벌룬으로 종류·위치·제원을 띄우고, 구간형은 그때만 시~종점 구간을 - * 띠로 펼쳐 보여 준다(평상시에는 띠를 그리지 않는다). + * 표기 규칙 (2026-08-17 사용자 확정): + * - 구조물은 종류와 무관하게 **알약 하나**로 표시한다. 구간형은 기준점에 찍는다. + * - 자동 배치 배수관도 세로 점선이 아니라 같은 알약으로 나온다 — 자동이든 수동이든 + * 같은 구조물이기 때문이다. + * - 레인은 그래프 편집 버튼(구간 ⬆⬇) 아래에 붙고 높이는 2행분뿐이다. 알약이 겹치지 + * 않으면 세로 가운데 한 줄로, 겹칠 때만 두 줄로 쌓는다. + * - 알약을 고르면 벌룬으로 종류·위치·제원을 띄우고, 구간형은 그때만 시~종점 띠를 + * 그래프 폭에 맞춰 보여 준다. * - * 그래프는 매 그리기마다 새로 만들어지므로 이 오버레이도 그때마다 다시 붙인다. + * 그래프는 매 그리기마다 새로 만들어지므로 이 레인도 그때마다 다시 붙인다. * ========================================================================== */ import { @@ -17,11 +21,24 @@ import { } from "./B05_Profile_Api_Structures"; import { formatStation } from "./B05_Profile_Util_Station"; -/** 마크를 잡았다고 볼 여유(px). 원 반지름보다 조금 넉넉히 준다. */ +/** 마크를 잡았다고 볼 여유(px). 손떨림을 이동으로 오해하지 않게 한다. */ const GRAB_SLACK_PX = 10; -/** 마크가 겹칠 때 위로 띄우는 간격(px). 같은 자리 구조물이 서로를 가리지 않게 한다. */ -const STACK_STEP_PX = 22; -const STACK_BASE_PX = 10; +/** 겹쳐 두 줄로 갈릴 때의 알약 높이(px) — 레인 높이를 정하는 값이다. */ +const PILL_HEIGHT_PX = 24; +/** 혼자 있는(가운데 한 줄) 알약 높이(px) — 자리가 남으니 30%만 키운다 + * (2026-08-17 사용자 지시 1: 32px는 과했다). */ +const SOLO_PILL_HEIGHT_PX = 31; +/** 두 줄 사이 간격(px). */ +const ROW_GAP_PX = 2; +/** 레인 위아래 여백(px) — 그래프를 밀어내되 여유는 최소로. */ +const LANE_PADDING_PX = 2; +/** 두 알약이 겹친다고 볼 가로 거리(px). 3자 라벨 폭 기준. */ +const OVERLAP_PX = 52; + +/** 레인 전체 높이(px) — 2행 구조를 항상 확보한다(항목이 하나여도 높이는 같다). + * 큰(혼자) 알약도 이 안에 들어간다. 아래 서브패널 손잡이와의 사이는 CSS 여백을 + * 절반으로 줄여 붙여 놓는다(2026-08-17 사용자 지시 2). */ +export const STRUCTURE_LANE_HEIGHT_PX = PILL_HEIGHT_PX * 2 + ROW_GAP_PX + LANE_PADDING_PX * 2; export interface StructureMarksOptions { structures: ReadonlyArray; @@ -31,6 +48,11 @@ export interface StructureMarksOptions { /** x(px) → chainage. 마크를 끌 때 역변환. */ chainageAt: (px: number) => number; maxChainageM: number; + /** 레인 가로 폭(px) — 그래프와 같은 폭이어야 스크롤이 함께 움직인다. */ + widthPx: number; + /** 그래프 Y축 띠 폭(px) — 레인도 같은 폭으로 축을 이어 붙여, 스크롤된 알약이 + * 축 값 위로 새 나가지 않게 가린다(2026-08-17 사용자 지시 2). */ + axisWidthPx: number; /** 측점간격(m) — 위치 표기를 측점번호+잔여거리로 한다(2026-08-17 사용자 확정). */ stationIntervalM: number; selectedId: string | null; @@ -64,44 +86,65 @@ const SIDE_TEXT: Record = { cross: "횡단", }; -/** 같은 자리에 겹친 마크를 위로 쌓아 올릴 높이를 정한다. */ -function stackOffsets( +/** 알약 세로 자리 배정 — 이웃과 겹치는 것만 두 줄로 나누고, 나머지는 가운데에 둔다 + * (2026-08-17 사용자 지시 2). 값 0 = 윗줄, 1 = 아랫줄, null = 가운데. */ +function rowAssignments( structures: ReadonlyArray, x: (chainageM: number) => number, -): Map { - const offsets = new Map(); - const used: Array<{ px: number; level: number }> = []; - [...structures] - .sort((left, right) => structureAnchorM(left) - structureAnchorM(right)) - .forEach((structure) => { - const px = x(structureAnchorM(structure)); - const conflicts = used.filter((entry) => Math.abs(entry.px - px) < STACK_STEP_PX); - const level = conflicts.length ? Math.max(...conflicts.map((e) => e.level)) + 1 : 0; - used.push({ px, level }); - offsets.set(structure.structure_id ?? "", STACK_BASE_PX + level * STACK_STEP_PX); - }); - return offsets; +): Map { + const rows = new Map(); + const sorted = [...structures].sort( + (left, right) => structureAnchorM(left) - structureAnchorM(right), + ); + const centers = sorted.map((structure) => x(structureAnchorM(structure))); + + let row = 0; + sorted.forEach((structure, index) => { + const id = structure.structure_id ?? ""; + const near = + (index > 0 && centers[index] - centers[index - 1] < OVERLAP_PX) || + (index < sorted.length - 1 && centers[index + 1] - centers[index] < OVERLAP_PX); + if (!near) { + // 앞뒤 어디와도 안 겹친다 — 혼자이므로 레인 세로 가운데. + rows.set(id, null); + row = 0; + return; + } + // 겹치는 무리 안에서만 위·아래를 번갈아 쓴다. + rows.set(id, row); + row = row === 0 ? 1 : 0; + }); + + return rows; } /** - * 그래프 칸에 구조물 마크 레이어를 붙인다. + * 그래프 아래에 구조물 알약 레인을 만들어 돌려준다(호출부가 원하는 위치에 붙인다). */ -export function mountStructureMarks(host: HTMLElement, options: StructureMarksOptions): void { - const layer = document.createElement("div"); - layer.className = "b05-structure__layer"; +export function buildStructureLane(options: StructureMarksOptions): HTMLElement { + const lane = document.createElement("div"); + lane.className = "b05-structure__lane"; + lane.style.width = `${options.widthPx}px`; + lane.style.height = `${STRUCTURE_LANE_HEIGHT_PX}px`; const typeMap = new Map(options.types.map((type) => [type.type_id, type])); - const offsets = stackOffsets(options.structures, options.x); + const rows = rowAssignments(options.structures, options.x); options.structures.forEach((structure) => { const id = structure.structure_id; if (!id) return; const type = typeMap.get(structure.type_id); const anchorPx = options.x(structureAnchorM(structure)); - const bottom = offsets.get(id) ?? STACK_BASE_PX; const selected = options.selectedId === id; + // 이웃과 겹치는 알약만 위·아래 두 줄로 가르고, 혼자면 레인 세로 가운데에 크게 둔다. + const row = rows.get(id) ?? null; + const height = row === null ? SOLO_PILL_HEIGHT_PX : PILL_HEIGHT_PX; + const top = + row === null + ? (STRUCTURE_LANE_HEIGHT_PX - SOLO_PILL_HEIGHT_PX) / 2 + : LANE_PADDING_PX + row * (PILL_HEIGHT_PX + ROW_GAP_PX); - // 구간형은 고른 동안만 시~종점을 띠로 펼친다(2026-08-16 사용자 확정). + // 구간형은 고른 동안만 시~종점을 띠로 펼친다. if (selected && structure.placement === "interval") { const band = document.createElement("div"); band.className = "b05-structure__band"; @@ -109,19 +152,21 @@ export function mountStructureMarks(host: HTMLElement, options: StructureMarksOp const endPx = options.x(structure.end_m ?? 0); band.style.left = `${Math.min(startPx, endPx)}px`; band.style.width = `${Math.max(Math.abs(endPx - startPx), 2)}px`; - band.style.bottom = `${bottom}px`; + band.style.top = `${top + height / 2 - 1}px`; band.style.background = type?.style?.color ?? "#888"; - layer.append(band); + lane.append(band); } const mark = document.createElement("button"); mark.type = "button"; mark.className = "b05-structure__mark"; mark.classList.toggle("is-selected", selected); + mark.classList.toggle("is-solo", row === null); mark.style.left = `${anchorPx}px`; - mark.style.bottom = `${bottom}px`; + mark.style.top = `${top}px`; mark.style.borderColor = type?.style?.color ?? "#888"; mark.style.color = type?.style?.color ?? "#888"; + // 라벨은 3자 이하 짧은 이름(레지스트리 style.abbr, 2026-08-17 지시 4). mark.textContent = type?.style?.abbr ?? "?"; mark.title = `${type?.name ?? structure.type_id} ${positionText(structure, options.stationIntervalM)}`; mark.dataset.structureId = id; @@ -131,13 +176,26 @@ export function mountStructureMarks(host: HTMLElement, options: StructureMarksOp options.onSelect(selected ? null : id); }); attachMarkDrag(mark, structure, options); - layer.append(mark); + lane.append(mark); if (selected) - layer.append(buildBalloon(structure, type, anchorPx, bottom, options.stationIntervalM)); + lane.append(buildBalloon(structure, type, anchorPx, top + height, options.stationIntervalM)); }); - host.append(layer); + // Y축 띠 — 그래프 축을 레인까지 이어 붙인다. sticky라 가로 스크롤에도 왼쪽에 + // 머물며, 지나가는 알약을 덮어 축 값 영역으로 새 나가지 않게 한다. + const axis = document.createElement("div"); + axis.className = "b05-structure__axis"; + const axisInner = document.createElement("div"); + axisInner.className = "b05-structure__axis-inner"; + axisInner.style.width = `${options.axisWidthPx}px`; + axisInner.style.height = `${STRUCTURE_LANE_HEIGHT_PX}px`; + // 레인이 무엇을 담는 줄인지 축 자리에 적어 준다(2026-08-17 사용자 지시 2). + axisInner.textContent = "구조물"; + axis.append(axisInner); + lane.prepend(axis); + + return lane; } /** 고른 구조물의 값 벌룬. 유토곡선 벌룬과 같은 결(도형+텍스트)로 맞춘다. */ @@ -151,7 +209,7 @@ function buildBalloon( const balloon = document.createElement("div"); balloon.className = "b05-structure__balloon"; balloon.style.left = `${anchorPx}px`; - balloon.style.bottom = `${bottomPx + STACK_STEP_PX}px`; + balloon.style.top = `${bottomPx + ROW_GAP_PX}px`; balloon.style.borderColor = type?.style?.color ?? "#888"; const title = document.createElement("strong"); @@ -195,9 +253,9 @@ function attachMarkDrag( mark.addEventListener("pointermove", (event) => { if (!dragging) return; movedPx += Math.abs(event.movementX); - const host = mark.parentElement?.parentElement; - if (!host) return; - const localX = event.clientX - host.getBoundingClientRect().left; + const lane = mark.parentElement; + if (!lane) return; + const localX = event.clientX - lane.getBoundingClientRect().left; mark.style.left = `${localX}px`; }); @@ -207,9 +265,9 @@ function attachMarkDrag( mark.releasePointerCapture(event.pointerId); // 손이 떨린 정도(수 px)는 이동이 아니라 클릭으로 본다 — 고르려다 옮겨지면 곤란하다. if (movedPx <= GRAB_SLACK_PX) return; - const host = mark.parentElement?.parentElement; - if (!host) return; - const localX = event.clientX - host.getBoundingClientRect().left; + const lane = mark.parentElement; + if (!lane) return; + const localX = event.clientX - lane.getBoundingClientRect().left; const chainage = Math.min(Math.max(options.chainageAt(localX), 0), options.maxChainageM); options.onMove(structure.structure_id ?? "", Number(chainage.toFixed(2))); }); diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index df555c77..3a0a3196 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -130,31 +130,38 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu memoField.type = "text"; memoField.placeholder = "메모(선택)"; + // 측점은 3행 — 한 행이 [라벨][측점번호][잔여거리](2026-08-17 사용자 지시 2). const positionRow = document.createElement("div"); - positionRow.className = "b05-route__irregular-row"; + positionRow.className = "b05-structure__position-row"; positionRow.append(startFields.wrap, anchorFields.wrap, endFields.wrap); + // 기본 인터페이스는 2열 격자(2026-08-17 사용자 지시 1). + const typeRow = document.createElement("div"); + typeRow.className = "b05-structure__grid"; + typeRow.append(field("구조물군", groupSelect), field("종류", typeSelect)); + const sideRow = document.createElement("div"); - sideRow.className = "b05-route__irregular-row"; + sideRow.className = "b05-structure__grid"; sideRow.append(field("설치측", sideSelect), field("이격 (m)", offsetField)); // 옵션 칸은 타입마다 다르므로 선택할 때마다 새로 그린다. const optionRow = document.createElement("div"); - optionRow.className = "b05-route__irregular-row"; + optionRow.className = "b05-structure__grid"; // 계곡 통과 시설(배관 등)의 부속 옵션 서브폼 — 일반 구조물의 옵션 칸과 같은 // 자리에서, 같은 흐름(종류 선택 → 옵션 → 추가/수정)으로 편집한다(2026-08-17 // 사용자 지시 — 자동·수동 배관은 같은 구조물, 별도 UI 금지). 기슭막이 길이를 // 넣으면 시작·종료 측점이 기준 − 앞 ~ 기준 + 뒤로 자동 채워진다. - const facilityOptions = createFacilityOptionsForm({ - onSpanSuggest: (frontM, backM) => { - const step = interval(); - const anchor = anchorFields.read(step, false); - if (anchor === null) return; - startFields.write(Math.max(0, anchor - frontM), step); - endFields.write(anchor + backM, step); - }, - }); + const facilityOptions = createFacilityOptionsForm(); + + /** 이 타입이 서브폼(계곡 통과 시설 부속·독립 기슭막이)을 쓰는지 — 쓰면 레지스트리 + * 옵션 칸 대신 서브폼이 그린다(2026-08-17 사용자 지시 12: 독립 기슭막이도 같은 + * 인터페이스). */ + function facilityFormKind(type: StructureType | null): PipeFacility | "revetment" | null { + if (!type) return null; + if (type.managed_by) return type.type_id as PipeFacility; + return type.type_id === "revetment" ? "revetment" : null; + } const primary = document.createElement("button"); primary.type = "button"; @@ -176,10 +183,14 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu // 사용법 설명 문단은 두지 않는다 — 공간 대비 의미 없음(2026-08-17 사용자 지시, // 필요 시 별도 설명 페이지로). + // 측점 그룹과 옵션 나열 사이 구분선(2026-08-17 사용자 지시 2). + const positionDivider = document.createElement("hr"); + positionDivider.className = "b05-structure__divider"; + body.append( - field("구조물군", groupSelect), - field("종류", typeSelect), + typeRow, positionRow, + positionDivider, sideRow, optionRow, facilityOptions.root, @@ -215,34 +226,28 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu const interval = (): number => callbacks.getInterval(); - /** 배치형태·관리 주체에 맞춰 위치 칸을 바꾼다. 계곡 통과 시설은 기준 측점 필수 + - * 시작·종료(범위, 기슭막이 길이로 자동 채움 가능)를 받고, 설치측·이격은 정본 - * (관 지점) 소관이라 감춘다. 종류 미선택(빈값)이면 입력 칸 전체를 숨긴다 — - * 리셋 직후 상태(2026-08-17 사용자 지시). */ + /** 배치형태·관리 주체에 맞춰 위치 칸을 바꾼다. 계곡 통과 시설(배수관 등)은 점형이라 + * 기준 측점 하나만 받고(2026-08-17 사용자 지시 2 — 시작·종료 불필요), 설치측·이격도 + * 정본(관 지점) 소관이라 감춘다. 종류 미선택(빈값)이면 입력 칸 전체를 숨긴다 — + * 리셋 직후 상태. */ function syncPlacementFields(): void { const type = currentType(); const managed = !!type?.managed_by; - const isInterval = !!type && (managed || type.placement === "interval"); + const isInterval = !!type && !managed && type.placement === "interval"; positionRow.hidden = !type; + positionDivider.hidden = !type; startFields.wrap.hidden = !isInterval; endFields.wrap.hidden = !isInterval; sideRow.hidden = !type || managed; primary.disabled = !type; - anchorFields.wrap.querySelector("span")!.textContent = - isInterval && !managed ? "기준 측점 (비우면 시작)" : "기준 측점"; + anchorFields.wrap.querySelector("span")!.textContent = isInterval + ? "기준 측점 (비우면 시작)" + : "기준 측점"; } - /** 종류에 맞춰 부속 옵션 서브폼을 켠다(계곡 통과 시설일 때만, 값은 인자로). */ - function syncFacilityForm( - options: Record = {}, - source?: PipeSource, - ): void { - const type = currentType(); - facilityOptions.setFacility( - type?.managed_by ? (type.type_id as PipeFacility) : null, - options, - source, - ); + /** 종류에 맞춰 부속 옵션 서브폼을 켠다(계곡 통과 시설·독립 기슭막이, 값은 인자로). */ + function syncFacilityForm(options: Record = {}): void { + facilityOptions.setFacility(facilityFormKind(currentType()), options); } /** 타입의 옵션 스키마대로 입력 칸을 다시 그린다 — 상세(detail) 옵션은 B06/B07 @@ -251,7 +256,8 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu const type = currentType(); optionInputs = []; optionRow.replaceChildren(); - const visible = type && !type.managed_by ? type.options.filter(isB05Option) : []; + // 서브폼이 담당하는 타입(계곡 통과 시설·독립 기슭막이)은 여기서 그리지 않는다. + const visible = type && !facilityFormKind(type) ? type.options.filter(isB05Option) : []; if (!visible.length) { optionRow.hidden = true; return; @@ -261,11 +267,11 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu const preset = values[option.key] ?? option.default ?? ""; let input: HTMLInputElement | HTMLSelectElement; if (option.input === "select") { - // 필수 선택지는 첫 항목을 슬쩍 고른 것처럼 두지 않는다 — 재료·형식은 설계자가 - // 고르는 값이라, 안 고른 상태를 빈칸으로 드러낸다(2026-08-16 크로스체크 지적 2). + // 빈("선택하세요") 항목은 두지 않는다 — 첫 항목이 곧 기본값이고, 기본값은 + // 구조물별로 레지스트리에서 지정한다(2026-08-17 사용자 지시 1). const choices = option.choices.map((choice) => [choice, choice] as [string, string]); - input = select(option.required ? [["", "선택하세요"], ...choices] : choices); - input.value = String(preset || (option.required ? "" : (option.choices[0] ?? ""))); + input = select(choices); + input.value = String(preset || (option.choices[0] ?? "")); } else if (option.input === "number") { input = numberInput("0.1", "0"); input.value = String(preset ?? ""); @@ -323,12 +329,8 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu function labelOfPipe(pipe: PipeFacilityItem): string { const type = typeMap().get(pipe.facility); const name = type?.name ?? pipe.facility; - const step = interval(); - const position = - pipe.start_m !== undefined && pipe.end_m !== undefined && pipe.end_m > pipe.start_m - ? `${formatStation(pipe.start_m, step)}~${formatStation(pipe.end_m, step)}` - : formatStation(pipe.chainage_m, step); - return `${position} · ${name}`; + // 계곡 통과 시설은 점형 — 기준 측점 하나로 표기한다(2026-08-17 지시 2). + return `${formatStation(pipe.chainage_m, interval())} · ${name}`; } function renderList(): void { @@ -407,6 +409,7 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu offsetField.value = String(target.offset_m ?? 0); memoField.value = target.memo ?? ""; renderOptionFields(target.options); + syncFacilityForm(target.options); } else { anchorFields.write(null, step); startFields.write(null, step); @@ -414,8 +417,8 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu offsetField.value = "0"; memoField.value = ""; renderOptionFields(); + syncFacilityForm(); } - syncFacilityForm(); [anchorFields, startFields, endFields].forEach((fields) => fields.clearInvalid()); syncPlacementFields(); syncButtons(); @@ -436,10 +439,11 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu syncTypeOptions(pipe.facility); } anchorFields.write(pipe.chainage_m, step); - startFields.write(pipe.start_m ?? null, step); - endFields.write(pipe.end_m ?? null, step); + // 계곡 통과 시설은 점형 — 시작·종료 칸은 감춰지므로 비워 둔다. + startFields.write(null, step); + endFields.write(null, step); memoField.value = ""; - syncFacilityForm(pipe.options ?? {}, pipe.source); + syncFacilityForm(pipe.options ?? {}); [anchorFields, startFields, endFields].forEach((fields) => fields.clearInvalid()); syncPlacementFields(); syncButtons(); @@ -448,6 +452,8 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu } function readOptions(): Record { + // 서브폼이 담당하는 타입(독립 기슭막이)은 서브폼이 값을 읽는다. + if (facilityFormKind(currentType())) return facilityOptions.readOptions(); const values: Record = {}; optionInputs.forEach((input) => { // 빈 칸은 아예 넣지 않는다 — 빈 숫자를 0으로 저장하면 "0으로 확정"과 구분이 안 된다. @@ -475,13 +481,8 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu anchorFields.station.focus(); return; } + // 배수관 등 계곡 통과 시설은 점형 — 기준 측점 하나뿐이다(2026-08-17 지시 2). const attributes: FacilityAttributes = { facility: type.type_id as PipeFacility }; - const start = startFields.read(step, false); - const end = endFields.read(step, false); - if (start !== null && end !== null && end > start) { - attributes.start_m = start; - attributes.end_m = end; - } const options = facilityOptions.readOptions(); if (Object.keys(options).length) attributes.options = options; if (selectedPipeChainage !== null) { @@ -565,6 +566,10 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu typeSelect.addEventListener("change", () => { syncPlacementFields(); renderOptionFields(); + // 배수관·BOX암거·세월교·독립 기슭막이는 부속 옵션 서브폼이 따라 열려야 한다 — + // 수동 추가든 자동 배치 지점 편집이든 같은 옵션을 받는다(2026-08-17 사용자 지시: + // 자동은 통수단면으로 자리를 잡아 준 것일 뿐 같은 구조물이다). + syncFacilityForm(); if (editingId) { editingId = null; syncButtons(); diff --git a/B05_Profile/B05_Profile_UI_Style_Structures.css b/B05_Profile/B05_Profile_UI_Style_Structures.css index 60cd06bc..9e592eaa 100644 --- a/B05_Profile/B05_Profile_UI_Style_Structures.css +++ b/B05_Profile/B05_Profile_UI_Style_Structures.css @@ -5,27 +5,56 @@ * 우클릭 메뉴)을 막지 않도록 레이어는 이벤트를 통과시키고, 마크·벌룬만 이벤트를 받는다. * ========================================================================== */ -.b05-structure__layer { - position: absolute; - inset: 0; - pointer-events: none; +/* 구조물 알약 레인 — 그래프 바로 아래 한 줄(2026-08-17 사용자 지시 1·3·6). + 높이는 2행분 고정이고, 겹치지 않으면 알약이 세로 가운데에 놓인다. */ +.b05-structure__lane { + position: relative; overflow: visible; z-index: 3; } -/* 서클마크 — 배치형태와 무관하게 하나. 구간형은 기점에 찍힌다. */ +/* 레인 왼쪽 Y축 띠 — 그래프 sticky Y축과 같은 폭·같은 배경으로 이어 붙여, 가로 + 스크롤된 알약이 축 값 영역으로 새 나가지 않게 덮는다(2026-08-17 사용자 지시 2). */ +.b05-structure__axis { + position: sticky; + left: 0; + z-index: 5; + width: 0; + height: 0; +} + +.b05-structure__axis-inner { + position: absolute; + top: 0; + left: 0; + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: flex-end; + padding-right: 6px; + overflow: hidden; + background: var(--color-surface-raised); + border-right: 1px solid var(--color-text-secondary); + color: var(--color-text-secondary); + font-size: 12px; + white-space: nowrap; + pointer-events: none; +} + +/* 알약 — 종류를 3자 이하 이름으로 보여 준다. 높이는 구 서클마크와 같은 20px. */ .b05-structure__mark { position: absolute; transform: translateX(-50%); - width: 18px; - height: 18px; - padding: 0; - border-radius: 50%; - border: 2px solid currentColor; + height: 24px; + min-width: 24px; + padding: 0 9px; + border-radius: 12px; + border: 1.5px solid currentColor; background: var(--color-surface, #fff); - font-size: 10px; + font-size: 11px; font-weight: 700; line-height: 1; + white-space: nowrap; cursor: grab; pointer-events: auto; display: flex; @@ -33,6 +62,15 @@ justify-content: center; } +/* 혼자 있는 알약 — 겹치는 알약보다 30%만 키운다(2026-08-17 사용자 지시 1). */ +.b05-structure__mark.is-solo { + height: 31px; + min-width: 31px; + padding: 0 12px; + border-radius: 16px; + font-size: 12px; +} + .b05-structure__mark:hover { filter: brightness(0.92); } @@ -83,29 +121,78 @@ font-style: italic; } -/* ── 계곡 통과 시설 편집 폼 (배수유역 패널 하단, 2026-08-17 컨테이너 병합) ── - * 관 마커를 고르면 나타난다. 시설 종류(배관/BOX암거/물넘이/세월교)·구간·세월교 - * 관 옵션을 받고, 값이 바뀌면 즉시 재계산·정본 경로를 탄다. */ +/* ── 계곡 통과 시설 부속 옵션 서브폼 (구조물 배치 폼 안, 2026-08-17 UI 개편) ── + * 배관·BOX암거·세월교·독립 기슭막이의 부속 옵션을 2열 격자로 담는다. */ .b05-drainage__facility { display: flex; flex-direction: column; gap: 6px; - padding: 8px 10px; - border-top: 1px solid var(--border-color, #3a3f4a); } -.b05-drainage__facility .b05-route__irregular-row { - display: flex; +/* 2열 기본 격자 — 칸이 셋이면 세 번째가 다음 줄로 흐른다. */ +.b05-structure__grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px; - /* 사이드 「구조물 배치」 섹션 폭에서는 행이 줄바꿈되며 들어간다(2026-08-17 이동). */ - flex-wrap: wrap; } -.b05-drainage__facility .b05-route__field { - min-width: 96px; +.b05-structure__grid > .b05-route__field { + min-width: 0; } -.b05-drainage__facility .b05-route__field { +/* 유입구·유출구·날개벽 소그룹 상자. */ +.b05-structure__group { + margin: 0; + padding: 6px 8px 8px; + border: 1px solid var(--border-color, #3a3f4a); + border-radius: 6px; +} + +.b05-structure__group > legend { + padding: 0 4px; + font-size: 11px; + opacity: 0.8; +} + +.b05-structure__group-body { + display: flex; + flex-direction: column; + gap: 6px; +} + +/* 구조물 배치 폼 본문 — 기본 2열(측점 행 등 전폭 항목은 아래에서 되돌린다). */ +.b05-route__panel-section .b05-route__panel-body > .b05-route__field { + min-width: 0; +} + +/* 측점 그룹과 그 아래 옵션 나열을 가르는 선(2026-08-17 사용자 지시 2). */ +.b05-structure__divider { + width: 100%; + height: 0; + margin: 2px 0; + border: 0; + border-top: 1px solid var(--color-border, #3a3f4a); +} + +/* 시작·기준·종료 측점 = 3행. 한 행은 [라벨][측점][+거리] 가로 배치 + * (2026-08-17 사용자 지시 2). */ +.b05-structure__position-row { + display: flex; + flex-direction: column; + gap: 6px; +} + +.b05-structure__position-row > .b05-route__field { + flex-direction: row; + align-items: center; + gap: 6px; +} + +.b05-structure__position-row > .b05-route__field > span { + flex: 0 0 72px; +} + +.b05-structure__position-row .b05-structure__station-pair { flex: 1; min-width: 0; } diff --git a/resources/knowledge/original/실무문서/2024년 간선임도(기번3-울진.대흥)(2공구)/수량산출/~$4.2 수량산출(2공구).xlsx b/resources/knowledge/original/실무문서/2024년 간선임도(기번3-울진.대흥)(2공구)/수량산출/~$4.2 수량산출(2공구).xlsx new file mode 100644 index 0000000000000000000000000000000000000000..6b57c04deaa023b648c1e51e7088d6d41e670401 GIT binary patch literal 165 ecmZSE_igv*t=kny1k4OajvhR4VzUARJs<#H78Sh! literal 0 HcmV?d00001 diff --git a/resources/knowledge/original/실무문서/2024년 간선임도(기번3-울진.대흥)(3공구)/수량산출/~$4.3 수량산출(3공구).xlsx b/resources/knowledge/original/실무문서/2024년 간선임도(기번3-울진.대흥)(3공구)/수량산출/~$4.3 수량산출(3공구).xlsx new file mode 100644 index 0000000000000000000000000000000000000000..6b57c04deaa023b648c1e51e7088d6d41e670401 GIT binary patch literal 165 ecmZSE_igv*t=kny1k4OajvhR4VzUARJs<#H78Sh! literal 0 HcmV?d00001 diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index a9c80964..f4a46210 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -41,8 +41,8 @@ export const ui_locales_b2 = { "손으로 넣거나 옮긴 배관을 버리고 기본 관 + 자동 보충 배치로 되돌립니다.", "Discards manual culvert edits and restores the automatic placement.", ], - B05_Drainage_Menu_Add: ["배관 추가", "Add culvert"], - B05_Drainage_Menu_Delete: ["배관 삭제", "Remove culvert"], + B05_Drainage_Menu_Add: ["배수관 추가", "Add culvert"], + B05_Drainage_Menu_Delete: ["배수관 삭제", "Remove culvert"], B05_Drainage_Layer_Hotspots: ["집중유역", "Inflow hotspots"], B05_Drainage_Layer_Hotspots_Tip: [ "노선 위에서 물이 특히 많이 모이는 자리를 마커로 표시합니다. 관을 어디에 둘지 판단하는 근거입니다.", diff --git a/ui_template/ui_template_overlay.css b/ui_template/ui_template_overlay.css index ac5a52db..2f89cb03 100644 --- a/ui_template/ui_template_overlay.css +++ b/ui_template/ui_template_overlay.css @@ -205,6 +205,17 @@ border-top: 1px solid var(--color-border); } +/* 좌측 사이드 패널은 스크롤바 자리를 **항상** 확보한다 — 스크롤이 생겼다 사라질 때마다 + 폼 칸 폭이 흔들리던 문제를 없앤다(2026-08-17 사용자 지시, B04~B09 공통). + `overflow: overlay`는 폐기돼 auto로 동작하고, 음수 마진은 부모 클리핑에 잘리므로 + 가장 확실한 방법으로 간다: 스크롤을 상시 예약하고(scroll) 그만큼 오른쪽 패딩을 줄여 + 내용 폭을 원래대로 유지한다. 스크롤바는 전역 테마의 얇은 pill(8px)이다. */ +.ui-workflow-overlay__panel--title > .ui-workflow-overlay__body { + overflow-y: scroll; + scrollbar-gutter: stable; + padding-right: calc(var(--spacing-16) - var(--overlay-scrollbar-width, 8px)); +} + .ui-workflow-overlay__panel.is-collapsed .ui-workflow-overlay__body { display: none; }