feat(B05): 구조물 선택지 재편·배관 부속 옵션 — B06 이관 보류·리셋 빈값

- 레지스트리: 바닥막이·생태연못 삭제(임도 미사용 확정), B·F·G군 10종
  enabled:false 보존(B06 횡단도 옵션 재사용), 배관 부속 13종(유형/관경 기본
  1000/집수정/기슭막이/돌붙임)·BOX암거 본체+날개벽(유입·유출 개별) 옵션 정의
- FacilityEditor 재작성: 유형 제안(자동 배치 source 승격), 집수정 유무 토글로
  유입부 옵션 전환, 기슭막이 길이 총/앞/뒤 상호 연계로 범위(시작·종료) 자동
- 구조물 배치: 군·종류 빈값 옵션+리셋 시 초기화, 사용법 설명 문단 제거
- tmp/tests 98건·ruff·prettier·typecheck·build 통과 (2026-08-17 사용자 확정)
This commit is contained in:
2026-08-17 14:05:25 +09:00
parent 2028af6152
commit 16010da5a6
4 changed files with 640 additions and 424 deletions
+273 -336
View File
@@ -1,6 +1,6 @@
{
"schema_version": 1,
"comment": "B05 구조물 타입 레지스트리 정본. 근거·옵션 상세는 docs/raw/PLAN.md 구조물 리스트(2026-08-16)와 resources/knowledge/technical_info/01_임도 참조. enabled 기본 true(2026-08-16 사용자 확정 — 제외 표기 항목만 false). 배관은 pipe_points.json 정본 관리(managed_by) — structures.json에 저장하지 않다. ★기본값 원칙(2026-08-16 크로스체크 2차 반영): default를 두는 것은 ①법정이 등급과 무관하게 못박은 단일 수치 ②사용자가 개발 단계에서 확정한 값 ③도메인 수치가 아닌 표시용 문자열, 이 셋뿐이다. 재료·형식 같은 설계자 판단 선택지와 미확정 제원은 default 없이 required로 두어 사용자가 직접 고르게 한다 (지식DB 원칙: 기본값 선정은 사용자 협의 영역).",
"comment": "B05 구조물 타입 레지스트리 정본. 근거·옵션 상세는 docs/raw/PLAN.md 구조물 리스트와 resources/knowledge/technical_info/01_임도 참조. 2026-08-17 선택지 재편: B군(종단배수)·F군(생태/녹화)·G군(노면공)은 enabled:false 보존 — B05 선택지에서 빠지고 B06 개별 횡단도 옵션으로 재사용한다. 바닥막이(bed_sill)·생태연못(eco_pond)은 임도 미사용 확정으로 삭제. 배관·BOX암거·물넘이·세월교는 pipe_points.json 정본 관리(managed_by) — structures.json에 저장하지 않으며, 배관 부속(유형/집수정/기슭막이/돌붙임)·날개벽 옵션 정의는 B06/B07 필수 승격의 선언 근거다. ★기본값 원칙(2026-08-16 크로스체크 2차): default를 두는 것은 ①법정이 등급과 무관하게 못박은 단일 수치 ②사용자가 개발 단계에서 확정한 값 ③도메인 수치가 아닌 표시용 문자열, 이 셋뿐이다. 재료·형식 같은 설계자 판단 선택지와 미확정 제원은 default 없이 required로 두어 사용자가 직접 고르게 한다 (pipe_diameter_mm 1000 = 별표2 원칙값+사용자 확정 — 화이트리스트 등재).",
"types": [
{
"type_id": "pipe",
@@ -12,14 +12,128 @@
"color": "#4f8ef7",
"abbr": "관"
},
"drawing_views": [
"plan",
"profile",
"cross_section",
"detail",
"quantity"
],
"options": []
"drawing_views": ["plan", "profile", "cross_section", "detail", "quantity"],
"options": [
{
"key": "flow_type",
"label": "유형",
"input": "select",
"choices": ["계곡부형", "보완형"],
"default": null,
"required": false
},
{
"key": "pipe_diameter_mm",
"label": "관경",
"input": "select",
"choices": ["800", "1000", "1200", "1500"],
"unit": "mm",
"default": "1000",
"required": false
},
{
"key": "pipe_kind",
"label": "관종",
"input": "select",
"choices": ["흄관", "VR관", "파형강관"],
"default": null,
"required": true,
"phase": "detail"
},
{
"key": "catch_basin",
"label": "집수정",
"input": "select",
"choices": ["있음", "없음"],
"default": null,
"required": false
},
{
"key": "catch_basin_form",
"label": "집수정 형식",
"input": "select",
"choices": ["동물이동형", "□형(기본형)", "돌집수정 ㄴ형", "돌집수정 ㄷ형"],
"default": null,
"required": false,
"phase": "detail"
},
{
"key": "revetment",
"label": "기슭막이",
"input": "select",
"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,
"phase": "detail"
},
{
"key": "revet_height_m",
"label": "기슭막이 높이",
"input": "number",
"unit": "m",
"default": null,
"required": true,
"phase": "detail"
},
{
"key": "stone_pitching",
"label": "돌붙임",
"input": "select",
"choices": ["있음", "없음"],
"default": null,
"required": false
},
{
"key": "stone_pitching_type",
"label": "돌붙임 형식",
"input": "select",
"choices": ["찰", "메"],
"default": null,
"required": true,
"phase": "detail"
},
{
"key": "stone_pitching_area_m2",
"label": "돌붙임 면적",
"input": "number",
"unit": "㎡",
"default": null,
"required": true,
"phase": "detail"
}
]
},
{
"type_id": "box_culvert",
@@ -31,14 +145,97 @@
"color": "#3b6fd4",
"abbr": "암"
},
"drawing_views": [
"plan",
"profile",
"cross_section",
"detail",
"quantity"
],
"options": []
"drawing_views": ["plan", "profile", "cross_section", "detail", "quantity"],
"options": [
{
"key": "body_width_m",
"label": "본체 폭",
"input": "number",
"unit": "m",
"default": null,
"required": true,
"phase": "detail"
},
{
"key": "body_height_m",
"label": "본체 높이",
"input": "number",
"unit": "m",
"default": null,
"required": true,
"phase": "detail"
},
{
"key": "wing_in",
"label": "날개벽(유입)",
"input": "select",
"choices": ["있음", "없음"],
"default": null,
"required": false
},
{
"key": "wing_in_height_m",
"label": "유입 날개벽 짧은쪽 높이",
"input": "number",
"unit": "m",
"default": null,
"required": true,
"phase": "detail"
},
{
"key": "wing_in_length_m",
"label": "유입 날개벽 길이",
"input": "number",
"unit": "m",
"default": null,
"required": true,
"phase": "detail"
},
{
"key": "wing_in_angle_deg",
"label": "유입 날개벽 각도",
"input": "number",
"unit": "°",
"default": null,
"required": true,
"phase": "detail"
},
{
"key": "wing_out",
"label": "날개벽(유출)",
"input": "select",
"choices": ["있음", "없음"],
"default": null,
"required": false
},
{
"key": "wing_out_height_m",
"label": "유출 날개벽 짧은쪽 높이",
"input": "number",
"unit": "m",
"default": null,
"required": true,
"phase": "detail"
},
{
"key": "wing_out_length_m",
"label": "유출 날개벽 길이",
"input": "number",
"unit": "m",
"default": null,
"required": true,
"phase": "detail"
},
{
"key": "wing_out_angle_deg",
"label": "유출 날개벽 각도",
"input": "number",
"unit": "°",
"default": null,
"required": true,
"phase": "detail"
}
]
},
{
"type_id": "ford_pavement",
@@ -50,13 +247,7 @@
"color": "#2f9e8f",
"abbr": "물"
},
"drawing_views": [
"plan",
"profile",
"cross_section",
"detail",
"quantity"
],
"drawing_views": ["plan", "profile", "cross_section", "detail", "quantity"],
"options": []
},
{
@@ -69,23 +260,13 @@
"color": "#28857a",
"abbr": "세"
},
"drawing_views": [
"plan",
"profile",
"cross_section",
"detail",
"quantity"
],
"drawing_views": ["plan", "profile", "cross_section", "detail", "quantity"],
"options": [
{
"key": "pipe_kind",
"label": "구체 내 배관 종류",
"input": "select",
"choices": [
"흄관",
"VR관",
"파형강관"
],
"choices": ["흄관", "VR관", "파형강관"],
"default": null,
"required": false
},
@@ -93,12 +274,7 @@
"key": "pipe_diameter_mm",
"label": "배관 크기",
"input": "select",
"choices": [
"800",
"1000",
"1200",
"1500"
],
"choices": ["800", "1000", "1200", "1500"],
"unit": "mm",
"default": null,
"required": false
@@ -122,12 +298,7 @@
"color": "#5aa7e8",
"abbr": "횡"
},
"drawing_views": [
"plan",
"profile",
"cross_section",
"quantity"
],
"drawing_views": ["plan", "profile", "cross_section", "quantity"],
"options": []
},
{
@@ -139,16 +310,12 @@
"color": "#6d9bd1",
"abbr": "개"
},
"drawing_views": [
"plan",
"profile",
"cross_section",
"quantity"
],
"drawing_views": ["plan", "profile", "cross_section", "quantity"],
"options": []
},
{
"type_id": "ditch_side",
"enabled": false,
"group": "B",
"name": "측구(옆도랑)",
"placement": "interval",
@@ -156,22 +323,13 @@
"color": "#2eaadc",
"abbr": "측"
},
"drawing_views": [
"profile",
"cross_section",
"quantity"
],
"drawing_views": ["profile", "cross_section", "quantity"],
"options": [
{
"key": "form",
"label": "형식",
"input": "select",
"choices": [
"일반형(제형)",
"L형",
"U형",
"J형"
],
"choices": ["일반형(제형)", "L형", "U형", "J형"],
"default": null,
"required": true,
"phase": "detail"
@@ -187,6 +345,7 @@
},
{
"type_id": "ditch_ridge",
"enabled": false,
"group": "B",
"name": "산마루측구",
"placement": "interval",
@@ -194,21 +353,13 @@
"color": "#2e8bdc",
"abbr": "산"
},
"drawing_views": [
"profile",
"cross_section",
"quantity"
],
"drawing_views": ["profile", "cross_section", "quantity"],
"options": [
{
"key": "material",
"label": "재료",
"input": "select",
"choices": [
"콘크리트",
"돌",
"떼"
],
"choices": ["콘크리트", "돌", "떼"],
"default": null,
"required": true,
"phase": "detail"
@@ -217,6 +368,7 @@
},
{
"type_id": "ditch_berm",
"enabled": false,
"group": "B",
"name": "소단측구",
"placement": "interval",
@@ -224,14 +376,12 @@
"color": "#2ec5dc",
"abbr": "소"
},
"drawing_views": [
"cross_section",
"quantity"
],
"drawing_views": ["cross_section", "quantity"],
"options": []
},
{
"type_id": "chute",
"enabled": false,
"group": "B",
"name": "도수로·산비탈수로",
"placement": "interval",
@@ -239,24 +389,13 @@
"color": "#1f9bb8",
"abbr": "도"
},
"drawing_views": [
"profile",
"cross_section",
"quantity"
],
"drawing_views": ["profile", "cross_section", "quantity"],
"options": [
{
"key": "material",
"label": "재료",
"input": "select",
"choices": [
"떼",
"돌",
"콘크리트",
"블록판",
"벤치플륨관",
"반원관"
],
"choices": ["떼", "돌", "콘크리트", "블록판", "벤치플륨관", "반원관"],
"default": null,
"required": true,
"phase": "detail"
@@ -265,11 +404,7 @@
"key": "section",
"label": "단면형",
"input": "select",
"choices": [
"반달꼴",
"U자형",
"사다리꼴"
],
"choices": ["반달꼴", "U자형", "사다리꼴"],
"default": null,
"required": true,
"phase": "detail"
@@ -278,6 +413,7 @@
},
{
"type_id": "slope_drain",
"enabled": false,
"group": "B",
"name": "절토사면 배수로",
"placement": "interval",
@@ -285,19 +421,13 @@
"color": "#28b1a1",
"abbr": "사"
},
"drawing_views": [
"cross_section",
"quantity"
],
"drawing_views": ["cross_section", "quantity"],
"options": [
{
"key": "material",
"label": "재료",
"input": "select",
"choices": [
"떼",
"돌"
],
"choices": ["떼", "돌"],
"default": null,
"required": true,
"phase": "detail"
@@ -306,6 +436,7 @@
},
{
"type_id": "underdrain",
"enabled": false,
"group": "B",
"name": "맹암거",
"placement": "interval",
@@ -313,11 +444,7 @@
"color": "#1f7fb8",
"abbr": "맹"
},
"drawing_views": [
"profile",
"cross_section",
"quantity"
],
"drawing_views": ["profile", "cross_section", "quantity"],
"options": [
{
"key": "note",
@@ -336,23 +463,13 @@
"color": "#e07a2e",
"abbr": "옹"
},
"drawing_views": [
"profile",
"cross_section",
"detail",
"quantity"
],
"drawing_views": ["profile", "cross_section", "detail", "quantity"],
"options": [
{
"key": "form",
"label": "형식",
"input": "select",
"choices": [
"중력식",
"반중력식",
"캔틸레버식",
"부벽식"
],
"choices": ["중력식", "반중력식", "캔틸레버식", "부벽식"],
"default": null,
"required": true,
"phase": "detail"
@@ -377,11 +494,7 @@
"color": "#d0692a",
"abbr": "찰"
},
"drawing_views": [
"profile",
"cross_section",
"quantity"
],
"drawing_views": ["profile", "cross_section", "quantity"],
"options": [
{
"key": "height_m",
@@ -396,15 +509,7 @@
"key": "back_len_cm",
"label": "뒷길이",
"input": "select",
"choices": [
"25",
"30",
"35",
"45",
"55",
"60",
"75"
],
"choices": ["25", "30", "35", "45", "55", "60", "75"],
"default": null,
"required": true,
"phase": "detail"
@@ -420,11 +525,7 @@
"color": "#c25d2c",
"abbr": "메"
},
"drawing_views": [
"profile",
"cross_section",
"quantity"
],
"drawing_views": ["profile", "cross_section", "quantity"],
"options": [
{
"key": "height_m",
@@ -439,15 +540,7 @@
"key": "back_len_cm",
"label": "뒷길이",
"input": "select",
"choices": [
"25",
"30",
"35",
"45",
"55",
"60",
"75"
],
"choices": ["25", "30", "35", "45", "55", "60", "75"],
"default": null,
"required": true,
"phase": "detail"
@@ -463,25 +556,13 @@
"color": "#b8742e",
"abbr": "흙"
},
"drawing_views": [
"profile",
"cross_section",
"quantity"
],
"drawing_views": ["profile", "cross_section", "quantity"],
"options": [
{
"key": "form",
"label": "형식",
"input": "select",
"choices": [
"콘크리트",
"돌(찰)",
"돌(메)",
"블록쌓기",
"돌망태",
"흙포대",
"통나무쌓기"
],
"choices": ["콘크리트", "돌(찰)", "돌(메)", "블록쌓기", "돌망태", "흙포대", "통나무쌓기"],
"default": null,
"required": true,
"phase": "detail"
@@ -506,11 +587,7 @@
"color": "#a86a2a",
"abbr": "큰"
},
"drawing_views": [
"profile",
"cross_section",
"quantity"
],
"drawing_views": ["profile", "cross_section", "quantity"],
"options": [
{
"key": "height_m",
@@ -525,11 +602,7 @@
"key": "stone_cm",
"label": "돌규격",
"input": "select",
"choices": [
"40~60",
"60~80",
"80~100"
],
"choices": ["40~60", "60~80", "80~100"],
"default": null,
"required": true,
"phase": "detail"
@@ -545,25 +618,13 @@
"color": "#7a52c7",
"abbr": "골"
},
"drawing_views": [
"plan",
"profile",
"detail",
"quantity"
],
"drawing_views": ["plan", "profile", "detail", "quantity"],
"options": [
{
"key": "form",
"label": "형식",
"input": "select",
"choices": [
"돌",
"돌망태",
"콘크리트",
"통나무",
"바자",
"흙"
],
"choices": ["돌", "돌망태", "콘크리트", "통나무", "바자", "흙"],
"default": null,
"required": true,
"phase": "detail"
@@ -593,12 +654,7 @@
"color": "#6b46b8",
"abbr": "댐"
},
"drawing_views": [
"plan",
"profile",
"detail",
"quantity"
],
"drawing_views": ["plan", "profile", "detail", "quantity"],
"options": [
{
"key": "height_m",
@@ -611,44 +667,6 @@
}
]
},
{
"type_id": "bed_sill",
"group": "D",
"name": "바닥막이",
"placement": "point",
"style": {
"color": "#8a5cd0",
"abbr": "바"
},
"drawing_views": [
"profile",
"detail",
"quantity"
],
"options": [
{
"key": "height_m",
"label": "높이(≤3.0)",
"input": "number",
"unit": "m",
"default": null,
"required": true,
"phase": "detail"
},
{
"key": "apron",
"label": "물받이",
"input": "select",
"choices": [
"있음",
"없음"
],
"default": null,
"required": true,
"phase": "detail"
}
]
},
{
"type_id": "revetment",
"group": "D",
@@ -658,11 +676,7 @@
"color": "#9b6bdc",
"abbr": "기"
},
"drawing_views": [
"profile",
"cross_section",
"quantity"
],
"drawing_views": ["profile", "cross_section", "quantity"],
"options": [
{
"key": "form",
@@ -701,11 +715,7 @@
"color": "#2fa84f",
"abbr": "대"
},
"drawing_views": [
"plan",
"cross_section",
"quantity"
],
"drawing_views": ["plan", "cross_section", "quantity"],
"options": [
{
"key": "width_m",
@@ -734,11 +744,7 @@
"color": "#268f43",
"abbr": "작"
},
"drawing_views": [
"plan",
"cross_section",
"quantity"
],
"drawing_views": ["plan", "cross_section", "quantity"],
"options": [
{
"key": "width_m",
@@ -765,10 +771,7 @@
"color": "#37b95a",
"abbr": "차"
},
"drawing_views": [
"plan",
"quantity"
],
"drawing_views": ["plan", "quantity"],
"options": [
{
"key": "width_m",
@@ -788,19 +791,13 @@
"color": "#43c76b",
"abbr": "반"
},
"drawing_views": [
"plan"
],
"drawing_views": ["plan"],
"options": [
{
"key": "facing",
"label": "방향",
"input": "select",
"choices": [
"좌",
"우",
"양면"
],
"choices": ["좌", "우", "양면"],
"default": null,
"required": true,
"phase": "detail"
@@ -816,21 +813,13 @@
"color": "#1f9e46",
"abbr": "가"
},
"drawing_views": [
"plan",
"cross_section",
"quantity"
],
"drawing_views": ["plan", "cross_section", "quantity"],
"options": [
{
"key": "kind",
"label": "종류",
"input": "select",
"choices": [
"가드레일",
"경계석",
"위험표지"
],
"choices": ["가드레일", "경계석", "위험표지"],
"default": null,
"required": true,
"phase": "detail"
@@ -846,10 +835,7 @@
"color": "#53a832",
"abbr": "번"
},
"drawing_views": [
"plan",
"detail"
],
"drawing_views": ["plan", "detail"],
"options": []
},
{
@@ -861,10 +847,7 @@
"color": "#3f9e2e",
"abbr": "단"
},
"drawing_views": [
"plan",
"detail"
],
"drawing_views": ["plan", "detail"],
"options": []
},
{
@@ -876,9 +859,7 @@
"color": "#66b344",
"abbr": "준"
},
"drawing_views": [
"plan"
],
"drawing_views": ["plan"],
"options": []
},
{
@@ -890,10 +871,7 @@
"color": "#8c9a2e",
"abbr": "사"
},
"drawing_views": [
"plan",
"quantity"
],
"drawing_views": ["plan", "quantity"],
"options": [
{
"key": "area_m2",
@@ -924,10 +902,7 @@
"color": "#7d8a2a",
"abbr": "토"
},
"drawing_views": [
"plan",
"quantity"
],
"drawing_views": ["plan", "quantity"],
"options": [
{
"key": "area_m2",
@@ -951,6 +926,7 @@
},
{
"type_id": "wildlife_path",
"enabled": false,
"group": "F",
"name": "야생동물 이동통로",
"placement": "point",
@@ -958,51 +934,22 @@
"color": "#3aa88f",
"abbr": "야"
},
"drawing_views": [
"plan",
"cross_section"
],
"drawing_views": ["plan", "cross_section"],
"options": [
{
"key": "form",
"label": "형식",
"input": "select",
"choices": [
"경사로",
"자연형계단"
],
"choices": ["경사로", "자연형계단"],
"default": null,
"required": true,
"phase": "detail"
}
]
},
{
"type_id": "eco_pond",
"group": "F",
"name": "생태연못",
"placement": "site",
"style": {
"color": "#2f9e86",
"abbr": "연"
},
"drawing_views": [
"plan",
"detail",
"quantity"
],
"options": [
{
"key": "area_m2",
"label": "면적(≥100)",
"input": "number",
"unit": "㎡",
"default": 100
}
]
},
{
"type_id": "revegetation",
"enabled": false,
"group": "F",
"name": "비탈면 녹화",
"placement": "interval",
@@ -1010,10 +957,7 @@
"color": "#4cb84c",
"abbr": "녹"
},
"drawing_views": [
"cross_section",
"quantity"
],
"drawing_views": ["cross_section", "quantity"],
"options": [
{
"key": "method",
@@ -1040,6 +984,7 @@
},
{
"type_id": "pavement_concrete",
"enabled": false,
"group": "G",
"name": "콘크리트 포장",
"placement": "interval",
@@ -1047,11 +992,7 @@
"color": "#8a8f98",
"abbr": "포"
},
"drawing_views": [
"profile",
"cross_section",
"quantity"
],
"drawing_views": ["profile", "cross_section", "quantity"],
"options": [
{
"key": "thickness_cm",
@@ -1066,6 +1007,7 @@
},
{
"type_id": "gravel_surfacing",
"enabled": false,
"group": "G",
"name": "쇄석(자갈) 포설",
"placement": "interval",
@@ -1073,10 +1015,7 @@
"color": "#9aa0a8",
"abbr": "쇄"
},
"drawing_views": [
"cross_section",
"quantity"
],
"drawing_views": ["cross_section", "quantity"],
"options": [
{
"key": "thickness_cm",
@@ -1098,9 +1037,7 @@
"color": "#98a2b3",
"abbr": "기"
},
"drawing_views": [
"profile"
],
"drawing_views": ["profile"],
"options": [
{
"key": "name",
+340 -71
View File
@@ -1,14 +1,16 @@
/* =============================================================================
* B05_Profile_UI_Drainage_Facility.ts
* 계곡 통과 시설 편집 소구획 — 선택한 관 지점의 시설 종류·구간·세월교 옵션.
* 계곡 통과 시설 편집 소구획 — 선택한 관 지점의 시설 종류·구간·부속 옵션.
*
* 같은 계곡 교차점에서 유량·지형에 따라 배관/BOX암거/물넘이포장/세월교를 택일한다
* (2026-08-17 사용자 확정 — 교량은 임도용이 아니라 없다). 정본은 `pipe_points.json`
* 하나이고, 세부유역 계산은 기준점(chainage)만 읽으므로 종류를 바꿔도 계산은 같다.
*
* B05는 유무·종류·위치 단계라 여기서는 종류·구간·관 수량 수준만 받는다 — 날개벽
* 규격 같은 상세 치수는 B06/B07 몫이다. 시설 확장 정보는 패널이 chainage 기준으로
* 들고 다니며 요청에 되붙인다(`FacilityStore`).
* 배관 부속(2026-08-17 확정): 유형(계곡부형/보완형 — 자동 배치 source로 제안),
* 집수정 유무(토글 시 유입부 옵션 전환), 관경(기본 1000), 기슭막이(길이 총/앞/뒤
* 상호 연계 — 범위를 만든다), 돌붙임. 형식·치수 제원은 B06/B07 필수 승격 대상이라
* 여기서는 보이되 비필수다. 시설 확장 정보는 패널이 chainage 기준으로 들고 다니며
* 요청에 되붙인다(`FacilityStore`).
* ========================================================================== */
import {
@@ -18,7 +20,8 @@ import {
type PipeSource,
} from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
/** 시설 확장 정보 한 건 — 관 지점(chainage)에 얹힌다. */
/** 시설 확장 정보 한 건 — 관 지점(chainage)에 얹힌다. 배관(pipe)도 부속 옵션을
* 가지면 저장한다. */
export interface FacilityAttributes {
facility: PipeFacility;
start_m?: number;
@@ -26,6 +29,13 @@ export interface FacilityAttributes {
options?: Record<string, string | number>;
}
/** 확장 정보가 실제로 있는지 — 배관이면서 아무 부속도 없으면 보관할 이유가 없다. */
function hasExtras(attributes: FacilityAttributes): boolean {
if (attributes.facility !== "pipe") return true;
if (attributes.start_m !== undefined || attributes.end_m !== undefined) return true;
return !!attributes.options && Object.keys(attributes.options).length > 0;
}
/** 관 이동·재계산 왕복에서 시설 정보를 잃지 않게 chainage 최근접으로 되붙이는 보관소.
* 백엔드 `carry_facility_attributes`와 같은 기준(정확 일치 → 최근접)이다. */
export function createFacilityStore() {
@@ -43,21 +53,21 @@ export function createFacilityStore() {
}>,
): void {
entries = points
.filter((point) => point.facility && point.facility !== "pipe")
.map((point) => ({
chainage: point.chainage_m,
attributes: {
facility: point.facility as PipeFacility,
facility: (point.facility ?? "pipe") as PipeFacility,
start_m: point.start_m,
end_m: point.end_m,
options: point.options,
},
}));
}))
.filter((entry) => hasExtras(entry.attributes));
},
/** 한 지점의 시설을 바꾼다(배관으로 되돌리면 엔트리 삭제). */
/** 한 지점의 시설을 바꾼다(배관으로 되돌리면 엔트리 삭제). */
set(chainageM: number, attributes: FacilityAttributes | null): void {
entries = entries.filter((entry) => Math.abs(entry.chainage - chainageM) > 0.005);
if (attributes && attributes.facility !== "pipe") {
if (attributes && hasExtras(attributes)) {
entries.push({ chainage: chainageM, attributes });
}
},
@@ -95,8 +105,12 @@ interface FacilityEditorCallbacks {
export interface FacilityEditor {
root: HTMLElement;
/** 선택된 관 지점을 폼에 올린다. null이면 숨긴다. */
sync: (chainageM: number | null, attributes: FacilityAttributes | null) => void;
/** 선택된 관 지점을 폼에 올린다. null이면 숨긴다. source는 유형 제안에 쓴다. */
sync: (
chainageM: number | null,
attributes: FacilityAttributes | null,
source?: PipeSource,
) => void;
}
function labeled(text: string, input: HTMLElement): HTMLLabelElement {
@@ -108,6 +122,40 @@ function labeled(text: string, input: HTMLElement): HTMLLabelElement {
return wrapper;
}
function numberInput(step: string, min: string, placeholder = ""): HTMLInputElement {
const input = document.createElement("input");
input.type = "number";
input.step = step;
input.min = min;
if (placeholder) input.placeholder = placeholder;
return input;
}
/** 첫 항목이 빈값(자동/미지정)인 셀렉트 — 명시하지 않으면 저장하지 않는다. */
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)),
);
return element;
}
function row(...fields: HTMLElement[]): HTMLDivElement {
const div = document.createElement("div");
div.className = "b05-route__irregular-row";
div.append(...fields);
return div;
}
/** 자동 배치 source → 유형 제안 (2026-08-17 확정: 세류선 교차 = 계곡부형(유량 多),
* 간격 보충 = 보완형(유량 少·집수정), 수동 = 미지정). */
function suggestFlowType(source: PipeSource | undefined): string {
if (source === "stream") return "계곡부형";
if (source === "spacing") return "보완형";
return "";
}
/** 선택한 관 지점 아래 붙는 시설 편집 폼. */
export function createFacilityEditor(callbacks: FacilityEditorCallbacks): FacilityEditor {
const root = document.createElement("div");
@@ -119,56 +167,188 @@ export function createFacilityEditor(callbacks: FacilityEditorCallbacks): Facili
...PIPE_FACILITY_LABELS.map(([value, label]) => new Option(label, value)),
);
// 구간 — 기준점 앞뒤로 유입·유출 부속이 차지하는 범위(m). 비우면 폭 0(기준점만).
const startInput = document.createElement("input");
startInput.type = "number";
startInput.step = "0.1";
startInput.min = "0";
startInput.placeholder = "비우면 기준점";
const endInput = document.createElement("input");
endInput.type = "number";
endInput.step = "0.1";
endInput.min = "0";
endInput.placeholder = "비우면 기준점";
// 구간 — 기준점 앞뒤로 유입·유출 부속이 차지하는 범위(m, 누가거리). 비우면 폭 0.
// 기슭막이 길이를 넣으면 자동으로 채워지고, 직접 고치면 그 값이 우선한다.
const startInput = numberInput("0.1", "0", "비우면 기준점");
const endInput = numberInput("0.1", "0", "비우면 기준점");
// 세월교 전용 — 구체 내 배관 종류/크기/수량으로 폭을 정한다(2026-08-17 사용자 확정).
const pipeKindSelect = document.createElement("select");
pipeKindSelect.replaceChildren(
...["", "흄관", "VR관", "파형강관"].map(
(kind) => new Option(kind === "" ? "선택하세요" : kind, kind),
),
// ── 배관 부속 (2026-08-17 확정) ──────────────────────────────────────────
const flowSelect = optionalSelect("미지정", ["계곡부형", "보완형"]);
const catchBasinSelect = optionalSelect("자동(유형 따름)", ["있음", "없음"]);
const catchBasinFormSelect = optionalSelect("선택", [
"동물이동형",
"□형(기본형)",
"돌집수정 ㄴ형",
"돌집수정 ㄷ형",
]);
const pipeDiameterSelect = document.createElement("select");
pipeDiameterSelect.replaceChildren(
...["800", "1000", "1200", "1500"].map((size) => new Option(`Ø${size}`, size)),
);
const diameterSelect = document.createElement("select");
diameterSelect.replaceChildren(
...["", "800", "1000", "1200", "1500"].map(
(size) => new Option(size === "" ? "선택하세요" : `Ø${size}`, size),
),
);
const countInput = document.createElement("input");
countInput.type = "number";
countInput.step = "1";
countInput.min = "1";
countInput.placeholder = "련";
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("선택", [
"돌쌓기(찰)",
"돌쌓기(메)",
"돌붙임",
"콘크리트",
"돌망태",
"통나무·목재틀",
"바자",
]);
const pitchSelect = optionalSelect("자동(유형 따름)", ["있음", "없음"]);
const pitchTypeSelect = optionalSelect("선택", ["찰", "메"]);
const spanRow = document.createElement("div");
spanRow.className = "b05-route__irregular-row";
spanRow.append(labeled("시작 (m)", startInput), labeled("종료 (m)", endInput));
const fordRow = document.createElement("div");
fordRow.className = "b05-route__irregular-row";
fordRow.append(
labeled("배관 종류", pipeKindSelect),
labeled("배관 크기", diameterSelect),
labeled("수량 (련)", countInput),
// ── BOX암거 — 본체·날개벽 (짧은쪽 높이/길이/각도, 유입·유출 개별) ─────────
const boxWidth = numberInput("0.1", "0");
const boxHeight = numberInput("0.1", "0");
const wingIn = optionalSelect("미지정", ["있음", "없음"]);
const wingInHeight = numberInput("0.1", "0");
const wingInLength = numberInput("0.1", "0");
const wingInAngle = numberInput("1", "0");
const wingOut = optionalSelect("미지정", ["있음", "없음"]);
const wingOutHeight = numberInput("0.1", "0");
const wingOutLength = numberInput("0.1", "0");
const wingOutAngle = numberInput("1", "0");
// ── 세월교 — 구체 내 배관 종류/크기/수량으로 폭을 정한다 ─────────────────
const fordKindSelect = optionalSelect("선택", ["흄관", "VR관", "파형강관"]);
const fordDiameterSelect = optionalSelect("선택", ["800", "1000", "1200", "1500"]);
const fordCount = numberInput("1", "1", "련");
const spanRow = row(labeled("시작 (m)", startInput), labeled("종료 (m)", endInput));
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),
);
root.append(labeled("시설 종류", facilitySelect), spanRow, fordRow);
root.append(
labeled("시설 종류", facilitySelect),
spanRow,
pipeTypeRow,
pipeInletRow,
revetRow,
pitchRow,
boxRow,
wingInRow,
wingOutRow,
fordRow,
);
let currentChainage: number | null = null;
let currentSource: PipeSource | undefined;
let syncing = false;
function readAttributes(): FacilityAttributes | null {
/** 명시값이 없을 때의 유형 — 자동 배치 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)));
}
/** 기슭막이 길이로 구간을 자동 채운다(범위 = 기준 − 앞 ~ 기준 + 뒤).
* 시작·종료를 직접 고친 경우는 건드리지 않도록, 기슭막이 입력 change에서만 부른다. */
function applyRevetSpan(): void {
if (currentChainage === null) return;
const length = Number.parseFloat(revetLength.value);
const front = Number.parseFloat(revetFront.value);
const back = Number.parseFloat(revetBack.value);
if (!Number.isFinite(length) || length <= 0) return;
if (!Number.isFinite(front) || !Number.isFinite(back)) return;
startInput.value = (currentChainage - front).toFixed(1);
endInput.value = (currentChainage + back).toFixed(1);
}
function syncVisibility(): void {
const facility = facilitySelect.value as PipeFacility;
const isPipe = facility === "pipe";
const isBox = facility === "box_culvert";
pipeTypeRow.hidden = !isPipe;
pipeInletRow.hidden = !isPipe;
revetRow.hidden = !isPipe;
pitchRow.hidden = !isPipe;
boxRow.hidden = !isBox;
wingInRow.hidden = !isBox;
wingOutRow.hidden = !isBox;
fordRow.hidden = facility !== "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),
);
}
}
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 readAttributes(): FacilityAttributes {
const facility = facilitySelect.value as PipeFacility;
if (facility === "pipe") return null;
const attributes: FacilityAttributes = { facility };
const start = Number.parseFloat(startInput.value);
const end = Number.parseFloat(endInput.value);
@@ -176,47 +356,136 @@ export function createFacilityEditor(callbacks: FacilityEditorCallbacks): Facili
attributes.start_m = Math.min(start, end);
attributes.end_m = Math.max(start, end);
}
if (facility === "ford_bridge") {
const options: Record<string, string | number> = {};
if (pipeKindSelect.value) options.pipe_kind = pipeKindSelect.value;
if (diameterSelect.value) options.pipe_diameter_mm = Number(diameterSelect.value);
const count = Number.parseInt(countInput.value, 10);
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 (pitchSelect.value) options.stone_pitching = pitchSelect.value;
if (pitchTypeSelect.value && pitchSelect.value !== "없음")
options.stone_pitching_type = pitchTypeSelect.value;
} else if (facility === "box_culvert") {
putNumber(options, "body_width_m", boxWidth.value);
putNumber(options, "body_height_m", boxHeight.value);
if (wingIn.value) options.wing_in = wingIn.value;
if (wingIn.value === "있음") {
putNumber(options, "wing_in_height_m", wingInHeight.value);
putNumber(options, "wing_in_length_m", wingInLength.value);
putNumber(options, "wing_in_angle_deg", wingInAngle.value);
}
if (wingOut.value) options.wing_out = wingOut.value;
if (wingOut.value === "있음") {
putNumber(options, "wing_out_height_m", wingOutHeight.value);
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);
const count = Number.parseInt(fordCount.value, 10);
if (Number.isFinite(count) && count > 0) options.pipe_count = count;
if (Object.keys(options).length) attributes.options = options;
}
if (Object.keys(options).length) attributes.options = options;
return attributes;
}
function syncVisibility(): void {
const facility = facilitySelect.value as PipeFacility;
spanRow.hidden = facility === "pipe";
fordRow.hidden = facility !== "ford_bridge";
}
function emit(): void {
if (syncing || currentChainage === null) return;
syncVisibility();
callbacks.onApply(currentChainage, readAttributes());
}
[facilitySelect, startInput, endInput, pipeKindSelect, diameterSelect, countInput].forEach(
(input) => input.addEventListener("change", emit),
[revetLength, revetFront, revetBack].forEach((input, index) =>
input.addEventListener("change", () => {
if (syncing) return;
linkRevet(index === 2 ? "back" : index === 1 ? "front" : "length");
applyRevetSpan();
emit();
}),
);
[
facilitySelect,
startInput,
endInput,
flowSelect,
pipeDiameterSelect,
pipeMaterialSelect,
catchBasinSelect,
catchBasinFormSelect,
revetSelect,
revetFormSelect,
pitchSelect,
pitchTypeSelect,
boxWidth,
boxHeight,
wingIn,
wingInHeight,
wingInLength,
wingInAngle,
wingOut,
wingOutHeight,
wingOutLength,
wingOutAngle,
fordKindSelect,
fordDiameterSelect,
fordCount,
].forEach((input) => input.addEventListener("change", emit));
return {
root,
sync(chainageM, attributes) {
sync(chainageM, attributes, source) {
currentChainage = chainageM;
currentSource = source;
root.hidden = chainageM === null;
if (chainageM === null) return;
syncing = true;
const options = attributes?.options ?? {};
const text = (key: string): string =>
options[key] !== undefined ? String(options[key]) : "";
facilitySelect.value = attributes?.facility ?? "pipe";
startInput.value = attributes?.start_m !== undefined ? String(attributes.start_m) : "";
endInput.value = attributes?.end_m !== undefined ? String(attributes.end_m) : "";
const options = attributes?.options ?? {};
pipeKindSelect.value = String(options.pipe_kind ?? "");
diameterSelect.value = options.pipe_diameter_mm ? String(options.pipe_diameter_mm) : "";
countInput.value = options.pipe_count ? String(options.pipe_count) : "";
const isFord = attributes?.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");
boxWidth.value = text("body_width_m");
boxHeight.value = text("body_height_m");
wingIn.value = text("wing_in");
wingInHeight.value = text("wing_in_height_m");
wingInLength.value = text("wing_in_length_m");
wingInAngle.value = text("wing_in_angle_deg");
wingOut.value = text("wing_out");
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();
},
@@ -366,6 +366,8 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
facilityEditor.sync(
picked ? picked.chainage_m : null,
picked ? facilityStore.get(picked.chainage_m) : null,
// 자동 배치 source — 배관 유형(계곡부형/보완형) 제안 근거 (2026-08-17).
picked ? ((picked.reason || "user") as PipeSource) : undefined,
);
selectBasin(basinIndexOfPipe(basins, picked ?? null));
}
+25 -17
View File
@@ -221,12 +221,8 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
const list = document.createElement("ul");
list.className = "b05-route__irregular-list";
const help = document.createElement("p");
help.className = "b05-route__note";
help.textContent =
"구조물군과 종류를 고르고 측점(측점번호 + 잔여거리)을 입력해 추가합니다. 구간형은 " +
"기준 측점에 마크가 찍힙니다(비우면 시작 측점). 계곡 통과 시설(배관·BOX암거·물넘이·" +
"세월교)은 배수유역 정본과 연동되고, 상세 치수는 B06/B07 단계에서 입력합니다.";
// 사용법 설명 문단은 두지 않는다 — 공간 대비 의미 없음(2026-08-17 사용자 지시,
// 필요 시 별도 설명 페이지로).
body.append(
field("구조물군", groupSelect),
@@ -236,7 +232,6 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
optionRow,
actions,
list,
help,
);
let types: StructureType[] = [];
@@ -268,13 +263,16 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
const interval = (): number => callbacks.getInterval();
/** 배치형태·관리 주체에 맞춰 위치 칸을 바꾼다. 계곡 통과 시설은 기준 측점만 받고
* 설치측·이격도 정본(관 지점) 소관이라 감춘다. */
* 설치측·이격도 정본(관 지점) 소관이라 감춘다. 종류 미선택(빈값)이면 입력 칸
* 전체를 숨긴다 — 리셋 직후 상태(2026-08-17 사용자 지시). */
function syncPlacementFields(): void {
const type = currentType();
const isInterval = !type?.managed_by && type?.placement === "interval";
positionRow.hidden = !type;
startFields.wrap.hidden = !isInterval;
endFields.wrap.hidden = !isInterval;
sideRow.hidden = !!type?.managed_by;
sideRow.hidden = !type || !!type.managed_by;
primary.disabled = !type;
anchorFields.wrap.querySelector("span")!.textContent = isInterval
? "기준 측점 (비우면 시작)"
: "기준 측점";
@@ -323,14 +321,17 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
}
/** 고른 구조물군의 타입을 종류 목록에 채운다 — A군은 계곡 통과 시설(managed_by)도
* 포함한다(추가 시 관 지점 정본으로 간다, 2026-08-17 통합). */
* 포함한다(추가 시 관 지점 정본으로 간다, 2026-08-17 통합). 첫 항목은 빈칸 —
* 초기·리셋 상태를 "안 고름"으로 드러낸다. */
function syncTypeOptions(keepTypeId?: string): void {
const group = groupSelect.value;
const candidates = types.filter((type) => type.group === group);
typeSelect.replaceChildren(...candidates.map((type) => new Option(type.name, type.type_id)));
if (keepTypeId && candidates.some((type) => type.type_id === keepTypeId)) {
typeSelect.value = keepTypeId;
}
const candidates = group ? types.filter((type) => type.group === group) : [];
typeSelect.replaceChildren(
new Option("— 선택 —", ""),
...candidates.map((type) => new Option(type.name, type.type_id)),
);
typeSelect.value =
keepTypeId && candidates.some((type) => type.type_id === keepTypeId) ? keepTypeId : "";
syncPlacementFields();
renderOptionFields();
}
@@ -583,7 +584,12 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
loadForm(null);
emit();
});
resetButton.addEventListener("click", () => loadForm(null));
resetButton.addEventListener("click", () => {
// 리셋은 폼만 아니라 구조물군·종류도 빈칸으로 되돌린다(2026-08-17 사용자 지시).
groupSelect.value = "";
syncTypeOptions();
loadForm(null);
});
body.insertBefore(field("메모", memoField), actions);
syncButtons();
@@ -594,10 +600,12 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
setTypes(next) {
types = next;
const groups = [...new Set(next.map((t) => t.group))];
// 첫 항목은 빈칸 — 진입·리셋 상태에서 군·종류를 강제로 고르게 두지 않는다.
groupSelect.replaceChildren(
new Option("— 선택 —", ""),
...groups.map((group) => new Option(GROUP_LABELS[group] ?? group, group)),
);
groupSelect.value = groups.includes("A") ? "A" : (groups[0] ?? "");
groupSelect.value = "";
syncTypeOptions();
},
setStructures(next) {