auto: 2026-08-17 17:09 (ESD_LAPTOP)
This commit is contained in:
@@ -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", "세월교"],
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 : "구조물 정보를 불러오지 못했습니다.",
|
||||
|
||||
@@ -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를 변화점으로 승격시키므로 규칙 측점과 완전히 같은 파이프라인이다.
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
/** 이어 공사 시작 기준(측점번호·누가거리 오프셋)을 반영해 측점 라벨·누가거리 표시를 옮긴다. */
|
||||
|
||||
@@ -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)));
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -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: [
|
||||
"노선 위에서 물이 특히 많이 모이는 자리를 마커로 표시합니다. 관을 어디에 둘지 판단하는 근거입니다.",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user