auto: 2026-08-17 17:09 (ESD_LAPTOP)

This commit is contained in:
2026-08-17 17:09:05 +09:00
parent 38ea71e15b
commit 3e9be53e7c
15 changed files with 1028 additions and 524 deletions
+1 -1
View File
@@ -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", "세월교"],
+178 -124
View File
@@ -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": [
+359 -242
View File
@@ -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<typeof createFacilityStore>;
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<string, string | number>,
source?: PipeSource,
) => void;
/** 현재 필드 값을 시설 옵션으로 읽는다(빈 값 생략). */
readOptions: () => Record<string, string | number>;
}
/* ── 폼 조각 ────────────────────────────────────────────────────────────── */
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<string>): HTMLSelectElement {
/** 선택지 셀렉트 — 빈("미지정") 항목을 두지 않는다. 첫 항목이 곧 기본값이고,
* 기본값은 구조물별로 따로 지정한다(2026-08-17 사용자 지시 1). */
function optionalSelect(_blankLabel: string, choices: ReadonlyArray<string>): 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<string, string | number>) => void;
read: (target: Record<string, string | number>) => 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<string, string | number>,
) => void;
/** 현재 필드 값을 시설 옵션으로 읽는다(빈 값 생략). */
readOptions: () => Record<string, string | number>;
}
/** 구조물 배치 폼의 옵션 자리에 끼워지는 계곡 통과 시설 부속 옵션 서브폼. */
@@ -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<string, string | number>, 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<string, string | number>, 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<string, string | number> = {};
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;
}
+6 -1
View File
@@ -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();
}
/** 세부유역 요청 한 번을 감싼다 — 버튼 잠금·진행 문구·오류 표기를 한 자리에 모은다. */
@@ -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
);
}
+112 -9
View File
@@ -223,7 +223,12 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
// 관 매설 목록 → 그래프 배관 측점선·통합 목록을 한 방향으로 맞춘다.
// 정본은 배수유역도의 관 지점이며, 화면 목록은 그것을 실체화한 것이다.
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<void> {
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<void> {
/** 시설 종류별 표시 이름 — 그래프·3D 라벨용(배관은 관종·관경까지). */
const FACILITY_NAMES: Record<PipeFacility, string> = {
pipe: "배관",
pipe: "배관",
box_culvert: "BOX암거",
ford_pavement: "물넘이포장",
ford_bridge: "세월교",
@@ -516,6 +536,7 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
/** [초기선 복원] 등에서 그래프의 배관 투영만 지운다 — 관 정본은 건드리지 않는다. */
function clearProjectedStations(): void {
pipeStations = [];
irregularStations = [];
if (currentSectionDetail) renderStationLines(currentSectionDetail);
profilePanel.setIrregularStations([]);
@@ -545,8 +566,82 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
let structureRevision = 0;
let structureSaving: Promise<void> = Promise.resolve();
/* ── 횡단배수(A군) 측점 세로선 ──────────────────────────────────────────
* 횡단배수 시설은 계획선을 그 지점에 물리므로 그래프에 세로 점선과 계획고 틸팅
* 버튼(▲▼)이 있어야 한다(2026-08-17 사용자 지시). 대상은 관 정본(배수관·BOX암거·
* 물넘이·세월교)에 더해 수동 A군(노출형 횡단수로·개거)까지다. */
let pipeStations: IrregularStation[] = [];
let structureTypeMap = new Map<string, { group: string; name: string }>();
/** 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<string, string | number>;
}>,
): 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<void> {
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<void> {
]);
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 : "구조물 정보를 불러오지 못했습니다.",
+15 -9
View File
@@ -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를 변화점으로 승격시키므로 규칙 측점과 완전히 같은 파이프라인이다.
+63 -11
View File
@@ -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<HTMLElement>(".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();
},
/** 이어 공사 시작 기준(측점번호·누가거리 오프셋)을 반영해 측점 라벨·누가거리 표시를 옮긴다. */
+103 -45
View File
@@ -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<StructureInstance>;
@@ -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<string, string> = {
cross: "횡단",
};
/** 같은 자리에 겹친 마크를 위로 쌓아 올릴 높이를 정한다. */
function stackOffsets(
/** 알약 세로 자리 배정 — 이웃과 겹치는 것만 두 줄로 나누고, 나머지는 가운데에 둔다
* (2026-08-17 사용자 지시 2). 값 0 = 윗줄, 1 = 아랫줄, null = 가운데. */
function rowAssignments(
structures: ReadonlyArray<StructureInstance>,
x: (chainageM: number) => number,
): Map<string, number> {
const offsets = new Map<string, number>();
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<string, number | null> {
const rows = new Map<string, number | null>();
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)));
});
+58 -53
View File
@@ -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<string, string | number> = {},
source?: PipeSource,
): void {
const type = currentType();
facilityOptions.setFacility(
type?.managed_by ? (type.type_id as PipeFacility) : null,
options,
source,
);
/** 종류에 맞춰 부속 옵션 서브폼을 켠다(계곡 통과 시설·독립 기슭막이, 값은 인자로). */
function syncFacilityForm(options: Record<string, string | number> = {}): 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<string, string | number> {
// 서브폼이 담당하는 타입(독립 기슭막이)은 서브폼이 값을 읽는다.
if (facilityFormKind(currentType())) return facilityOptions.readOptions();
const values: Record<string, string | number> = {};
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();
+110 -23
View File
@@ -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;
}
+2 -2
View File
@@ -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: [
"노선 위에서 물이 특히 많이 모이는 자리를 마커로 표시합니다. 관을 어디에 둘지 판단하는 근거입니다.",
+11
View File
@@ -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;
}