diff --git a/B05_Profile/B05_Profile_Structure_Types.json b/B05_Profile/B05_Profile_Structure_Types.json index a7935f6b..f223b9de 100644 --- a/B05_Profile/B05_Profile_Structure_Types.json +++ b/B05_Profile/B05_Profile_Structure_Types.json @@ -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", diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts index a3fd8a27..6ef95a06 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts @@ -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; } +/** 확장 정보가 실제로 있는지 — 배관이면서 아무 부속도 없으면 보관할 이유가 없다. */ +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): 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, 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 = {}; - 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 = {}; + 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(); }, diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts index 368b1085..c6ea363a 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts @@ -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)); } diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index e25b982b..3b32f26e 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -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) {