feat(B05): 보호공 개편·종단 기준 기본 접힘·700줄 파일 분리 3건

지시 1 — 사이드 「종단 설계 기준」 컨테이너를 기본 접힘으로. section()에
collapsed 인자를 더해 초기 is-collapsed만 붙이고 토글은 전역 collapsible이
그대로 맡는다(B06 buildGroup과 같은 패턴).

지시 4 — 배수관 유입·유출 바닥 보호를 "보호공" 한 축으로 개편:
- 구 돌붙임 있음/없음 + 표면처리(찰/메) 두 축 → 돌붙임(찰)/돌붙임(메)/도수로.
  도수로·산비탈수로(B4)는 유출부에서 돌붙임 대신 쓰는 공종이라 별도 구조물이
  아니라 이 선택지로 받는다(별표2 유출구~원지반 도달 의무)
- "없음" 삭제 — 물이 흐르는 자리라 보호공은 필수(사용자 확정). 기본값은
  유입 돌붙임(찰)·유출 돌붙임(메)
- 치수는 배타 — 돌붙임이면 면적(㎡, 기본 10), 도수로면 폭(m, 기본 1.0)
- 저장 키 개명 *_pitching → *_protection, *_pitching_finish 삭제,
  *_protection_width_m 신설. 독립 기슭막이(D4)도 같은 축
- 구 저장분은 parse_pipe_points가 읽는 순간 새 키로 옮긴다("없음"은 부위
  기본값으로 승격). 폼 write()도 구 키를 폴백으로 읽어 structures.json 몫을 덮는다
- revetment.pitching_area_m2가 기본값과 required를 동시에 갖던 모순도 정리

지시 5 — 700줄 초과 파일 분리(순수 이동 + import 정리, 공개 인터페이스 불변):
- Drainage_Facility 724 → 401 (+_Facility_Fields 354)
- Structures_Panel 753 → 695 (+_Structures_List 113)
- Drainage_Panel 822 → 693 (+_Drainage_Chrome 150, +_Drainage_Interact 159)
- Page 1031 → 911 (+_Page_Helpers 144) — 아직 초과
- Profile_Panel 1152 — 미착수. 두 파일은 가변 클로저 상태를 곳곳에서 직접
  읽고 써 상태 승격이 선행돼야 하며, 프론트 테스트가 없어 순수 이동으로는
  떼어낼 수 없다. 분리안은 PLAN.md 3-3에 기록

검증: pytest tmp/tests 107 passed·7 skipped(신규 보호공 마이그레이션 6건 포함),
npm run typecheck 무오류, npm run build 성공(374 modules).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-17 18:19:46 +09:00
co-authored by Claude Opus 5
parent 70bfddce1b
commit 77f833bb25
12 changed files with 1109 additions and 750 deletions
+39 -39
View File
@@ -1,6 +1,6 @@
{
"schema_version": 1,
"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 원칙값+사용자 확정 — 화이트리스트 등재). BOX암거 본체 2.0×2.0·날개벽 유입/유출 있음·짧은쪽 높이 1m·길이 2m·각도 45°는 2026-08-17 사용자 확정값이다 — 본체 규격은 실무 관측 규격(2.0×2.0·3.0×3.0)을 폼에서 프리셋 select로 받고 필요 시 사용자 지정으로 자유 입력하되, 저장 키는 body_width_m·body_height_m 숫자 그대로다(프리셋 전용 키를 만들지 않는다). 이 값들은 B05 서브폼이 실제로 받으므로 phase를 detail로 두지 않는다.",
"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 원칙값+사용자 확정 — 화이트리스트 등재). BOX암거 본체 2.0×2.0·날개벽 유입/유출 있음·짧은쪽 높이 1m·길이 2m·각도 45°는 2026-08-17 사용자 확정값이다 — 본체 규격은 실무 관측 규격(2.0×2.0·3.0×3.0)을 폼에서 프리셋 select로 받고 필요 시 사용자 지정으로 자유 입력하되, 저장 키는 body_width_m·body_height_m 숫자 그대로다(프리셋 전용 키를 만들지 않는다). 이 값들은 B05 서브폼이 실제로 받으므로 phase를 detail로 두지 않는다. ★보호공 개편(2026-08-17 사용자 지시): 구 `*_pitching`(있음/없음) + `*_pitching_finish`(찰/메) 두 축을 `*_protection` 한 축(돌붙임(찰)·돌붙임(메)·도수로)으로 합쳤다 — 도수로·산비탈수로(B4)는 유출부에서 돌붙임 대신 쓰는 공종이라 별도 구조물이 아니라 보호공 선택지로 받는다. \"없음\"은 삭제(물이 흐르는 자리라 보호공 필수). 치수는 배타 — 돌붙임이면 `*_protection_area_m2`(10㎡), 도수로면 `*_protection_width_m`(1m, 지식DB 수치 근거 없음·사용자 확정, 실무 확인 후 개선). 구 저장분은 `common_util_drainage_pipes.parse_pipe_points`가 새 키로 옮긴다.",
"types": [
{
"type_id": "pipe",
@@ -84,28 +84,28 @@
"phase": "detail"
},
{
"key": "inlet_pitching",
"label": "유입 돌붙임",
"key": "inlet_protection",
"label": "유입 보호공",
"input": "select",
"choices": ["있음", "없음"],
"default": "있음",
"choices": ["돌붙임(찰)", "돌붙임(메)", "도수로"],
"default": "돌붙임(찰)",
"required": false
},
{
"key": "inlet_pitching_finish",
"label": "유입 돌붙임 표면처리",
"input": "select",
"choices": ["", "메"],
"default": "찰",
"key": "inlet_protection_area_m2",
"label": "유입 보호공 면적",
"input": "number",
"unit": "",
"default": 10,
"required": false,
"phase": "detail"
},
{
"key": "inlet_pitching_area_m2",
"label": "유입 돌붙임 면적",
"key": "inlet_protection_width_m",
"label": "유입 도수로 폭",
"input": "number",
"unit": "",
"default": 10,
"unit": "m",
"default": 1,
"required": false,
"phase": "detail"
},
@@ -144,28 +144,28 @@
"phase": "detail"
},
{
"key": "outlet_pitching",
"label": "유출 돌붙임",
"key": "outlet_protection",
"label": "유출 보호공",
"input": "select",
"choices": ["있음", "없음"],
"default": "있음",
"choices": ["돌붙임(찰)", "돌붙임(메)", "도수로"],
"default": "돌붙임(메)",
"required": false
},
{
"key": "outlet_pitching_finish",
"label": "유출 돌붙임 표면처리",
"input": "select",
"choices": ["", "메"],
"default": "메",
"key": "outlet_protection_area_m2",
"label": "유출 보호공 면적",
"input": "number",
"unit": "",
"default": 10,
"required": false,
"phase": "detail"
},
{
"key": "outlet_pitching_area_m2",
"label": "유출 돌붙임 면적",
"key": "outlet_protection_width_m",
"label": "유출 도수로 폭",
"input": "number",
"unit": "",
"default": 10,
"unit": "m",
"default": 1,
"required": false,
"phase": "detail"
}
@@ -725,29 +725,29 @@
"phase": "detail"
},
{
"key": "pitching",
"label": "돌붙임",
"key": "protection",
"label": "보호공",
"input": "select",
"choices": ["있음", "없음"],
"default": "있음",
"choices": ["돌붙임(찰)", "돌붙임(메)", "도수로"],
"default": "돌붙임(메)",
"required": false
},
{
"key": "pitching_finish",
"label": "돌붙임 표면처리",
"input": "select",
"choices": ["", "메"],
"default": "메",
"key": "protection_width_m",
"label": "도수로 폭",
"input": "number",
"unit": "m",
"default": 1,
"required": false,
"phase": "detail"
},
{
"key": "pitching_area_m2",
"label": "돌붙임 면적",
"key": "protection_area_m2",
"label": "보호공 면적",
"input": "number",
"unit": "㎡",
"default": 10,
"required": true,
"required": false,
"phase": "detail"
}
]
@@ -0,0 +1,151 @@
/* =============================================================================
* B05_Profile_UI_Drainage_Chrome.ts
* 배수유역 패널의 DOM 뼈대 — 헤더·버튼·뷰포트·범례·유역 목록·폭 조절 손잡이.
*
* 패널 본체(B05_Profile_UI_Drainage_Panel)가 700줄 한계에 닿아 분리했다.
* 여기에는 상태도 이벤트 배선도 없다 — 요소를 만들어 붙이고 그대로 넘긴다.
* 배선(토글·포인터·그리기)은 전부 본체가 맡는다.
* ========================================================================== */
import { createWorkflowPanelHandle } from "@ui/ui_template_overlay";
import { createPanelResizer } from "@ui/ui_template_resizer";
import { createProgressCircle } from "@ui/ui_template_progress";
import { createMapContextMenu } from "@ui/ui_template_context_menu";
import { createFlowLegend } from "../B04_PreProcess/B04_PreProcess_UI_FlowRamp";
export interface DrainageChrome {
root: HTMLElement;
panelHandle: ReturnType<typeof createWorkflowPanelHandle>;
layerButtons: HTMLElement;
analyzeButton: HTMLButtonElement;
deleteButton: HTMLButtonElement;
autoButton: HTMLButtonElement;
viewport: HTMLElement;
backgroundImage: HTMLImageElement;
canvas: HTMLCanvasElement;
status: HTMLElement;
progress: ReturnType<typeof createProgressCircle>;
contextMenu: ReturnType<typeof createMapContextMenu>;
legend: ReturnType<typeof createFlowLegend>;
summary: HTMLElement;
basinList: HTMLElement;
/** 폭 조절 손잡이 — 패널을 접을 때 본체가 dispose 한다. */
widthResizer: ReturnType<typeof createPanelResizer>;
}
export interface DrainageChromeLabels {
title: string;
analyze: string;
analyzeTip: string;
deleteSelected: string;
auto: string;
autoTip: string;
imageAlt: string;
statusNeedRoute: string;
}
export interface DrainageChromeMetrics {
minWidth: number;
maxWidthRatio: number;
widthStorageKey: string;
}
export function createDrainageChrome(
labels: DrainageChromeLabels,
metrics: DrainageChromeMetrics,
): DrainageChrome {
const root = document.createElement("aside");
root.className = "b05-drainage";
// 이 패널은 하단 패널의 **오른쪽**에 도킹해 오른쪽으로 접힌다(제목 카드와 반대).
// 방향을 넘기지 않으면 화살표가 정확히 거꾸로 나온다.
// 이름표 "배수유역" — 세로(90°)로 화살표 위에 얹힌다(2026-08-04 사용자 지시).
const panelHandle = createWorkflowPanelHandle("side", "right", "배수유역");
const header = document.createElement("div");
header.className = "b05-drainage__header";
const title = document.createElement("h3");
title.textContent = labels.title;
const layerButtons = document.createElement("div");
layerButtons.className = "b05-drainage__layers";
header.append(title, layerButtons);
// 세부유역 산정 — B04가 미리 분석해 둔 결과를 읽어 관을 보충하고 세부유역만 나눈다.
// 격자 해석은 하지 않으므로 즉시 끝난다.
const analyzeButton = document.createElement("button");
analyzeButton.type = "button";
analyzeButton.className = "b05-drainage__analyze";
analyzeButton.textContent = labels.analyze;
analyzeButton.title = labels.analyzeTip;
// 선택된 배관 삭제 — 편집 모드에서 마커를 선택해야 활성화된다.
const deleteButton = document.createElement("button");
deleteButton.type = "button";
deleteButton.className = "b05-drainage__analyze b05-drainage__tool";
deleteButton.textContent = labels.deleteSelected;
deleteButton.disabled = true;
// 자동 제안으로 되돌리기 — 편집한 배관 배치를 버리고 백엔드 자동 제안으로 재산정.
const autoButton = document.createElement("button");
autoButton.type = "button";
autoButton.className = "b05-drainage__analyze b05-drainage__tool";
autoButton.textContent = labels.auto;
autoButton.title = labels.autoTip;
header.append(analyzeButton, deleteButton, autoButton);
const viewport = document.createElement("div");
viewport.className = "b05-drainage__viewport";
const backgroundImage = document.createElement("img");
backgroundImage.className = "b05-drainage__image";
backgroundImage.alt = labels.imageAlt;
backgroundImage.draggable = false;
const canvas = document.createElement("canvas");
canvas.className = "b05-drainage__canvas";
const status = document.createElement("span");
status.className = "b05-drainage__status";
status.textContent = labels.statusNeedRoute;
// 지도 정중앙 로딩 서클 — 배경도·도엽 레이어·유역 산정이 끝날 때까지 화면이 비어 보인다.
const progress = createProgressCircle({ overlay: true });
progress.root.hidden = true;
// 배관 추가·삭제 우클릭 메뉴 — 편집 토글을 없앤 대신 이쪽으로 옮겼다(B04 지도와 같은 조작).
const contextMenu = createMapContextMenu("b05-drainage");
// 유입 강도 색띠 범례 — CSS가 지도 우측 세로로 세운다.
const legend = createFlowLegend();
viewport.append(backgroundImage, canvas, status, progress.root, contextMenu.element, legend.root);
const summary = document.createElement("div");
summary.className = "b05-drainage__summary";
summary.hidden = true;
const basinList = document.createElement("div");
basinList.className = "b05-drainage__basins";
basinList.hidden = true;
// 왼쪽 경계를 끌어 폭을 조절한다. 상한은 하단 패널 폭의 70%,
// 세로는 하단 패널에 그대로 딸려 간다(따로 조절하지 않는다 — 사용자 지시).
const widthResizer = createPanelResizer({
axis: "horizontal",
target: root,
cssVar: "--b05-drainage-width",
direction: -1,
min: metrics.minWidth,
max: () => (root.parentElement?.clientWidth ?? window.innerWidth) * metrics.maxWidthRatio,
storageKey: metrics.widthStorageKey,
});
root.append(panelHandle.root, widthResizer.root, header, viewport, summary, basinList);
return {
root,
panelHandle,
layerButtons,
analyzeButton,
deleteButton,
autoButton,
viewport,
backgroundImage,
canvas,
status,
progress,
contextMenu,
legend,
summary,
basinList,
widthResizer,
};
}
+29 -317
View File
@@ -9,8 +9,13 @@
*
* 배관 부속(2026-08-17 UI 개편): 관종·관경(기본 1000) 한 행 + 유입구·유출구
* 소그룹. 유입구는 집수정/기슭막이 택일이고, 유출구는 기슭막이뿐이다(현재 다른
* 선택지 없음). 기슭막이 조각은 재질·표면처리(찰/메)·길이·높이·돌붙임을 한 벌로
* 묶어 유입·유출·독립 구조물(D4 기슭막이)이 **같은 인터페이스**를 쓴다.
* 선택지 없음). 기슭막이 조각은 형태·길이·높이·바닥 보호공을 한 벌로 묶어
* 유입·유출·독립 구조물(D4 기슭막이)이 **같은 인터페이스**를 쓴다.
*
* 보호공(2026-08-17 사용자 지시): 구 "돌붙임 있음/없음 + 표면처리(찰/메)" 두 축을
* `돌붙임(찰)·돌붙임(메)·도수로` 한 축으로 합쳤다. 도수로·산비탈수로(B4)는 유출부에서
* 돌붙임 대신 쓰는 공종이라 별도 구조물이 아니라 이 선택지로 받는다. 치수는 돌붙임이면
* 면적(㎡), 도수로면 폭(m)이며 둘은 배타다.
* 유입·유출 길이가 정해지면 위치 칸(시작·종료 측점)을 기준 − 유입 ~ 기준 + 유출로
* 자동 채운다. 형식·치수 제원은 B06/B07 필수 승격 대상이라 보이되 비필수다.
*
@@ -27,6 +32,25 @@ import type {
PipeSource,
} from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import {
BOX_SIZE_CUSTOM,
BOX_SIZE_PRESETS,
createRevetmentFields,
createWingFields,
grid,
group,
INLET_REVET_KEYS,
labeled,
numberInput,
optionalSelect,
OUTLET_REVET_KEYS,
putNumber,
REVET_COMMON_DEFAULTS,
STANDALONE_REVET_KEYS,
WING_IN_KEYS,
WING_OUT_KEYS,
} from "./B05_Profile_UI_Drainage_Facility_Fields";
/** 시설 확장 정보 한 건 — 관 지점(chainage)에 얹힌다. 배관(pipe)도 부속 옵션을
* 가지면 저장한다. */
export interface FacilityAttributes {
@@ -105,318 +129,6 @@ export function createFacilityStore() {
export type FacilityStore = ReturnType<typeof createFacilityStore>;
/* ── 폼 조각 ────────────────────────────────────────────────────────────── */
function labeled(text: string, input: HTMLElement): HTMLLabelElement {
const wrapper = document.createElement("label");
wrapper.className = "b05-route__field";
const caption = document.createElement("span");
caption.textContent = text;
wrapper.append(caption, input);
return wrapper;
}
function numberInput(step: string, min = "0", placeholder = ""): HTMLInputElement {
const input = document.createElement("input");
input.type = "number";
input.step = step;
input.min = min;
if (placeholder) input.placeholder = placeholder;
return input;
}
/** 양수만 옵션에 싣는다 — 빈칸·0·음수는 값이 없는 것으로 본다. */
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));
}
/** 선택지 셀렉트 — 빈("미지정") 항목을 두지 않는다. 첫 항목이 곧 기본값이고,
* 기본값은 구조물별로 따로 지정한다(2026-08-17 사용자 지시 1). */
function optionalSelect(_blankLabel: string, choices: ReadonlyArray<string>): HTMLSelectElement {
const element = document.createElement("select");
element.replaceChildren(...choices.map((choice) => new Option(choice, choice)));
return element;
}
/** 2열 격자 한 줄(칸이 둘 이하면 그대로, 넘치면 줄바꿈). */
function grid(...fields: HTMLElement[]): HTMLDivElement {
const div = document.createElement("div");
div.className = "b05-structure__grid";
div.append(...fields);
return div;
}
/** 제목 달린 소그룹 상자(유입구·유출구). */
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,
};
}
/* ── BOX암거 ────────────────────────────────────────────────────────────── */
/** 본체 규격 프리셋 — 실무 관측 규격이다(2026-08-17 사용자 확정). 그 밖의 규격은
* "사용자 지정"으로 폭·높이를 직접 받는다. 저장 키는 프리셋이든 자유 입력이든
* body_width_m·body_height_m 숫자 그대로다 — 프리셋 전용 키를 만들지 않아야
* 하류(B06 횡단도·수량)가 폭·높이만 읽고 끝난다. */
const BOX_SIZE_PRESETS: ReadonlyArray<readonly [string, number, number]> = [
["2.0×2.0", 2, 2],
["3.0×3.0", 3, 3],
];
const BOX_SIZE_CUSTOM = "사용자 지정";
/** 날개벽 옵션 키 묶음 — 유입·유출이 같은 조각을 쓰되 키만 다르다. */
interface WingKeys {
install: string;
height: string;
length: string;
angle: string;
}
const WING_IN_KEYS: WingKeys = {
install: "wing_in",
height: "wing_in_height_m",
length: "wing_in_length_m",
angle: "wing_in_angle_deg",
};
const WING_OUT_KEYS: WingKeys = {
install: "wing_out",
height: "wing_out_height_m",
length: "wing_out_length_m",
angle: "wing_out_angle_deg",
};
/** 날개벽 기본 제원 — 설치 있음·짧은쪽 높이 1m·길이 2m·각도 45°
* (2026-08-17 사용자 확정). 유입·유출이 같은 값을 쓴다. */
const WING_DEFAULTS = { install: "있음", height: "1", length: "2", angle: "45" } as const;
interface WingFields {
root: HTMLFieldSetElement;
inputs: Array<HTMLInputElement | HTMLSelectElement>;
syncVisibility: () => void;
write: (options: Record<string, string | number>) => void;
read: (target: Record<string, string | number>) => void;
}
/** 날개벽 한 벌(설치 유무 + 짧은쪽 높이·길이·각도) — 유입·유출이 공유하는 조각.
* 설치 "없음"이면 제원 칸을 접는다(기슭막이 돌붙임과 같은 패턴). */
function createWingFields(title: string, keys: WingKeys): WingFields {
const install = optionalSelect("미지정", ["있음", "없음"]);
install.value = WING_DEFAULTS.install;
const height = numberInput("0.1");
height.value = WING_DEFAULTS.height;
const length = numberInput("0.1");
length.value = WING_DEFAULTS.length;
const angle = numberInput("1");
angle.value = WING_DEFAULTS.angle;
const heightField = labeled("짧은쪽 높이 (m)", height);
const dimsRow = grid(labeled("길이 (m)", length), labeled("각도 (°)", angle));
const box = group(title);
box.body.append(grid(labeled("설치", install), heightField), dimsRow);
function syncVisibility(): void {
const off = install.value === "없음";
heightField.hidden = off;
dimsRow.hidden = off;
}
// 만들자마자 기본값("있음") 상태를 맞춘다.
syncVisibility();
return {
root: box.root,
inputs: [install, height, length, angle],
syncVisibility,
write(options) {
const text = (key: string): string =>
options[key] !== undefined ? String(options[key]) : "";
install.value = text(keys.install) || WING_DEFAULTS.install;
height.value = text(keys.height) || WING_DEFAULTS.height;
length.value = text(keys.length) || WING_DEFAULTS.length;
angle.value = text(keys.angle) || WING_DEFAULTS.angle;
syncVisibility();
},
read(target) {
target[keys.install] = install.value;
if (install.value !== "있음") return;
putNumber(target, keys.height, height.value);
putNumber(target, keys.length, length.value);
putNumber(target, keys.angle, angle.value);
},
};
}
interface FacilityOptionsFormCallbacks {
/** 아무 옵션이나 바뀌었을 때 — 패널이 저장 흐름(수정 확정)을 안내하는 데 쓴다. */
onChange?: () => void;
@@ -481,7 +193,7 @@ export function createFacilityOptionsForm(
const inletRevet = createRevetmentFields(INLET_REVET_KEYS, {
...REVET_COMMON_DEFAULTS,
form: "돌쌓기(찰)",
pitchingFinish: "",
protection: "돌붙임(찰)",
});
// 첫 행 = [구조][형태] — 구조에 따라 집수정 형태와 기슭막이 형태가 갈아 끼워진다
// (2026-08-17 사용자 지시 3).
@@ -498,7 +210,7 @@ export function createFacilityOptionsForm(
const outletRevet = createRevetmentFields(OUTLET_REVET_KEYS, {
...REVET_COMMON_DEFAULTS,
form: "돌쌓기(메)",
pitchingFinish: "",
protection: "돌붙임(메)",
});
outletGroup.body.append(
grid(labeled("구조", outletType), outletRevet.formField),
@@ -509,7 +221,7 @@ export function createFacilityOptionsForm(
const standaloneRevet = createRevetmentFields(STANDALONE_REVET_KEYS, {
...REVET_COMMON_DEFAULTS,
form: "돌쌓기(메)",
pitchingFinish: "",
protection: "돌붙임(메)",
});
const standaloneWrap = document.createElement("div");
standaloneWrap.append(grid(standaloneRevet.formField), ...standaloneRevet.rows);
@@ -0,0 +1,354 @@
/* =============================================================================
* B05_Profile_UI_Drainage_Facility_Fields.ts
* 계곡 통과 시설 부속 옵션 서브폼의 입력 조각 — 라벨·숫자칸·격자·소그룹 상자와
* 기슭막이 한 벌(형태·길이·높이·보호공), BOX암거 본체 규격·날개벽 조각.
*
* 서브폼 본체(B05_Profile_UI_Drainage_Facility)가 700줄 한계에 닿아 분리했다.
* 조각은 전부 순수 DOM 생성·값 읽기라 본체의 시설 전환 흐름과 독립적이다 —
* 유입·유출·독립 기슭막이(D4)가 같은 조각을 키만 바꿔 쓰는 구조도 그대로다.
* ========================================================================== */
/* ── 폼 조각 ────────────────────────────────────────────────────────────── */
export function labeled(text: string, input: HTMLElement): HTMLLabelElement {
const wrapper = document.createElement("label");
wrapper.className = "b05-route__field";
const caption = document.createElement("span");
caption.textContent = text;
wrapper.append(caption, input);
return wrapper;
}
export function numberInput(step: string, min = "0", placeholder = ""): HTMLInputElement {
const input = document.createElement("input");
input.type = "number";
input.step = step;
input.min = min;
if (placeholder) input.placeholder = placeholder;
return input;
}
/** 양수만 옵션에 싣는다 — 빈칸·0·음수는 값이 없는 것으로 본다. */
export 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));
}
/** 선택지 셀렉트 — 빈("미지정") 항목을 두지 않는다. 첫 항목이 곧 기본값이고,
* 기본값은 구조물별로 따로 지정한다(2026-08-17 사용자 지시 1). */
export function optionalSelect(
_blankLabel: string,
choices: ReadonlyArray<string>,
): HTMLSelectElement {
const element = document.createElement("select");
element.replaceChildren(...choices.map((choice) => new Option(choice, choice)));
return element;
}
/** 2열 격자 한 줄(칸이 둘 이하면 그대로, 넘치면 줄바꿈). */
export function grid(...fields: HTMLElement[]): HTMLDivElement {
const div = document.createElement("div");
div.className = "b05-structure__grid";
div.append(...fields);
return div;
}
/** 제목 달린 소그룹 상자(유입구·유출구). */
export 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 = ["돌쌓기(찰)", "돌쌓기(메)", "콘크리트", "돌망태", "통나무·목재틀", "바자"];
/** 바닥 보호공 — 돌붙임의 찰·메 구분에 도수로를 더한 한 축(2026-08-17 사용자 지시).
* 도수로·산비탈수로(B4)는 별표2가 유출구~원지반 도달을 의무화한 공종인데, 실제로는
* 유출부에서 돌붙임 **대신** 쓰는 선택지라 별도 구조물이 아니라 여기에 넣는다.
* "없음"은 두지 않는다 — 물이 흐르는 자리라 보호공은 반드시 있다(사용자 확정). */
const PROTECTION_FORMS = ["돌붙임(찰)", "돌붙임(메)", "도수로"];
const PROTECTION_CHANNEL = "도수로";
/** 기슭막이 옵션 키 묶음 — 유입·유출·독립 구조물이 같은 조각을 쓰되 키만 다르다. */
export interface RevetmentKeys {
form: string;
length?: string;
height: string;
protection: string;
protectionArea: string;
protectionWidth: string;
}
/** 부위별 기본 구성 — 형태는 유입 돌쌓기(찰)·유출 돌쌓기(메), 길이 10m·높이 2.5m,
* 보호공은 유입 돌붙임(찰)·유출 돌붙임(메) (2026-08-17 사용자 확정). */
export interface RevetmentDefaults {
form: string;
length: string;
height: string;
protection: string;
protectionArea: string;
protectionWidth: string;
}
/** 부위와 무관한 공통 기본 치수 — 길이 10m·높이 2.5m·보호공 면적 10㎡·도수로 폭 1m.
* 도수로 폭 1m는 지식DB에 수치 근거가 없어 2026-08-17 사용자가 확정한 값이다
* (실무 확인 후 개선 예정). */
export const REVET_COMMON_DEFAULTS = {
length: "10",
height: "2.5",
protectionArea: "10",
protectionWidth: "1",
} as const;
/** 보호공 개편(2026-08-17) 전 키 — `*_pitching` 계열이었다. 구 저장분을 읽을 때만 쓴다. */
const legacyProtectionKey = (key: string): string => key.replace("protection", "pitching");
export const INLET_REVET_KEYS: RevetmentKeys = {
form: "inlet_revet_form",
length: "inlet_revet_length_m",
height: "inlet_revet_height_m",
protection: "inlet_protection",
protectionArea: "inlet_protection_area_m2",
protectionWidth: "inlet_protection_width_m",
};
export const OUTLET_REVET_KEYS: RevetmentKeys = {
form: "outlet_revet_form",
length: "outlet_revet_length_m",
height: "outlet_revet_height_m",
protection: "outlet_protection",
protectionArea: "outlet_protection_area_m2",
protectionWidth: "outlet_protection_width_m",
};
/** 독립 기슭막이(D4) — 길이는 구간형 측점이 정하므로 길이 칸이 없다. */
export const STANDALONE_REVET_KEYS: RevetmentKeys = {
form: "form",
height: "height_m",
protection: "protection",
protectionArea: "protection_area_m2",
protectionWidth: "protection_width_m",
};
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;
}
/** 구 저장분의 보호공 값을 새 한 축으로 옮긴다 — 개편 전에는 `*_pitching`(있음/없음)과
* `*_pitching_finish`(찰/메) 두 축이었다. "없음"은 선택지가 사라졌으므로 부위
* 기본값으로 올린다(물이 흐르는 자리라 보호공은 필수 — 2026-08-17 사용자 확정). */
function migrateProtection(
options: Record<string, string | number>,
keys: RevetmentKeys,
fallback: string,
): string {
const current = options[keys.protection];
if (typeof current === "string" && PROTECTION_FORMS.includes(current)) return current;
const legacy = options[legacyProtectionKey(keys.protection)];
const finish = options[`${legacyProtectionKey(keys.protection)}_finish`];
if (legacy === "있음" && (finish === "찰" || finish === "메")) return `돌붙임(${finish})`;
return fallback;
}
/** 기슭막이 한 벌(형태·길이·높이 + 보호공) — 배수관 유입·유출부와 독립 구조물이
* 공유하는 입력 인터페이스(2026-08-17 사용자 지시 9·11·12). 재질과 표면처리는
* "형태" 한 축으로 합쳤고(지시 1), 바닥 보호공도 돌붙임 찰·메와 도수로를 한 축으로
* 합쳤다. 기본값은 부위마다 다르다 — 유입 돌붙임(찰) / 유출 돌붙임(메).
* 치수 칸은 보호공에 따라 갈아 끼운다: 돌붙임이면 면적(㎡), 도수로면 폭(m). */
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 protection = document.createElement("select");
protection.replaceChildren(...PROTECTION_FORMS.map((value) => new Option(value, value)));
protection.value = defaults.protection;
const protectionArea = numberInput("0.1");
protectionArea.value = defaults.protectionArea;
const protectionWidth = numberInput("0.1");
protectionWidth.value = defaults.protectionWidth;
const formField = labeled("형태", form);
const lengthField = length ? labeled("길이 (m)", length) : null;
const heightField = labeled("높이 (m)", height);
const protectionField = labeled("보호공", protection);
const protectionAreaField = labeled("보호공 면적 (㎡)", protectionArea);
const protectionWidthField = labeled("도수로 폭 (m)", protectionWidth);
// 형태는 그룹 첫 행(구조 옆)에 따로 놓이므로 여기 행에서는 뺀다.
const rows = [
grid(...[lengthField, heightField].filter((item): item is HTMLLabelElement => item !== null)),
grid(protectionField, protectionAreaField, protectionWidthField),
];
function syncVisibility(): void {
const channel = protection.value === PROTECTION_CHANNEL;
protectionAreaField.hidden = channel;
protectionWidthField.hidden = !channel;
}
// 만들자마자 기본값 상태를 맞춘다 — 첫 화면에서 면적 칸이 접혀 보이지 않게.
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;
protection.value = migrateProtection(options, keys, defaults.protection);
protectionArea.value =
text(keys.protectionArea) ||
text(legacyProtectionKey(keys.protectionArea)) ||
defaults.protectionArea;
protectionWidth.value = text(keys.protectionWidth) || defaults.protectionWidth;
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.protection] = protection.value;
// 면적과 폭은 배타 — 고르지 않은 쪽 값을 남기면 수량이 두 벌로 잡힌다.
if (protection.value === PROTECTION_CHANNEL) {
putNumber(keys.protectionWidth, protectionWidth.value);
} else {
putNumber(keys.protectionArea, protectionArea.value);
}
},
onChange(handler) {
[form, height, protection, protectionArea, protectionWidth].forEach((input) =>
input.addEventListener("change", () => {
syncVisibility();
handler();
}),
);
length?.addEventListener("change", handler);
},
syncVisibility,
};
}
/* ── BOX암거 ────────────────────────────────────────────────────────────── */
/** 본체 규격 프리셋 — 실무 관측 규격이다(2026-08-17 사용자 확정). 그 밖의 규격은
* "사용자 지정"으로 폭·높이를 직접 받는다. 저장 키는 프리셋이든 자유 입력이든
* body_width_m·body_height_m 숫자 그대로다 — 프리셋 전용 키를 만들지 않아야
* 하류(B06 횡단도·수량)가 폭·높이만 읽고 끝난다. */
export const BOX_SIZE_PRESETS: ReadonlyArray<readonly [string, number, number]> = [
["2.0×2.0", 2, 2],
["3.0×3.0", 3, 3],
];
export const BOX_SIZE_CUSTOM = "사용자 지정";
/** 날개벽 옵션 키 묶음 — 유입·유출이 같은 조각을 쓰되 키만 다르다. */
interface WingKeys {
install: string;
height: string;
length: string;
angle: string;
}
export const WING_IN_KEYS: WingKeys = {
install: "wing_in",
height: "wing_in_height_m",
length: "wing_in_length_m",
angle: "wing_in_angle_deg",
};
export const WING_OUT_KEYS: WingKeys = {
install: "wing_out",
height: "wing_out_height_m",
length: "wing_out_length_m",
angle: "wing_out_angle_deg",
};
/** 날개벽 기본 제원 — 설치 있음·짧은쪽 높이 1m·길이 2m·각도 45°
* (2026-08-17 사용자 확정). 유입·유출이 같은 값을 쓴다. */
const WING_DEFAULTS = { install: "있음", height: "1", length: "2", angle: "45" } as const;
interface WingFields {
root: HTMLFieldSetElement;
inputs: Array<HTMLInputElement | HTMLSelectElement>;
syncVisibility: () => void;
write: (options: Record<string, string | number>) => void;
read: (target: Record<string, string | number>) => void;
}
/** 날개벽 한 벌(설치 유무 + 짧은쪽 높이·길이·각도) — 유입·유출이 공유하는 조각.
* 설치 "없음"이면 제원 칸을 접는다(기슭막이 보호공 치수와 같은 패턴). */
export function createWingFields(title: string, keys: WingKeys): WingFields {
const install = optionalSelect("미지정", ["있음", "없음"]);
install.value = WING_DEFAULTS.install;
const height = numberInput("0.1");
height.value = WING_DEFAULTS.height;
const length = numberInput("0.1");
length.value = WING_DEFAULTS.length;
const angle = numberInput("1");
angle.value = WING_DEFAULTS.angle;
const heightField = labeled("짧은쪽 높이 (m)", height);
const dimsRow = grid(labeled("길이 (m)", length), labeled("각도 (°)", angle));
const box = group(title);
box.body.append(grid(labeled("설치", install), heightField), dimsRow);
function syncVisibility(): void {
const off = install.value === "없음";
heightField.hidden = off;
dimsRow.hidden = off;
}
// 만들자마자 기본값("있음") 상태를 맞춘다.
syncVisibility();
return {
root: box.root,
inputs: [install, height, length, angle],
syncVisibility,
write(options) {
const text = (key: string): string =>
options[key] !== undefined ? String(options[key]) : "";
install.value = text(keys.install) || WING_DEFAULTS.install;
height.value = text(keys.height) || WING_DEFAULTS.height;
length.value = text(keys.length) || WING_DEFAULTS.length;
angle.value = text(keys.angle) || WING_DEFAULTS.angle;
syncVisibility();
},
read(target) {
target[keys.install] = install.value;
if (install.value !== "있음") return;
putNumber(target, keys.height, height.value);
putNumber(target, keys.length, length.value);
putNumber(target, keys.angle, angle.value);
},
};
}
@@ -0,0 +1,159 @@
/* =============================================================================
* B05_Profile_UI_Drainage_Interact.ts
* 배수유역 지도의 포인터 조작 — 휠 확대/축소, 가운데 버튼 팬, 배관 마커 잡기,
* 유역 폴리곤 고르기.
*
* 패널 본체(B05_Profile_UI_Drainage_Panel)가 700줄 한계에 닿아 분리했다.
* 화면 변환값(배율·오프셋)은 여전히 본체가 들고 있고 여기서는 접근자로만 읽고 쓴다 —
* 그리기·유역 목록·마커 편집기가 같은 값을 보던 구조를 그대로 두기 위함이다.
* ========================================================================== */
import {
lonLatToScreen,
type Normalizer,
type ViewState,
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
import type { DetailBasin } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import { pointInRing } from "./B05_Profile_UI_Drainage_Parts";
import type { createPipeEditor } from "./B05_Profile_UI_Drainage_Pipes";
import type { createMapContextMenu } from "@ui/ui_template_context_menu";
/** 이만큼(px) 이하로 움직였다 뗐으면 클릭으로 본다 — 손떨림으로 선택이 안 되는 일을 막는다. */
const CLICK_SLOP_PX = 4;
export interface DrainageInteractParams {
viewport: HTMLElement;
contextMenu: ReturnType<typeof createMapContextMenu>;
pipeEditor: ReturnType<typeof createPipeEditor>;
/** 현재 화면 변환(배율·오프셋·지도 사각형). 본체의 것을 그대로 쓴다. */
currentView: () => ViewState;
getScale: () => number;
setScale: (value: number) => void;
getOffset: () => { x: number; y: number };
setOffset: (x: number, y: number) => void;
scheduleDraw: () => void;
/** 유역 고르기에 필요한 현재 상태 — 좌표 변환기가 아직 없으면 고르지 않는다. */
getNormalizer: () => Normalizer | null;
getBasins: () => ReadonlyArray<DetailBasin>;
getSelectedBasin: () => number | null;
/** 유역 강조를 바꾸는 유일한 자리(본체 selectBasin). */
selectBasin: (index: number | null) => void;
}
/** 뷰포트에 포인터·휠 조작을 붙인다. 반환값은 없다 — 리스너는 뷰포트와 수명이 같다. */
export function bindDrainageInteractions(params: DrainageInteractParams): void {
const { viewport, contextMenu, pipeEditor, currentView, scheduleDraw } = params;
let dragStart: { x: number; y: number; offsetX: number; offsetY: number } | null = null;
/** 좌클릭을 시작한 자리 — 끌지 않고 뗐을 때만 유역 고르기로 본다. */
let basinClickStart: { x: number; y: number } | null = null;
/** 마커를 잡은 좌클릭 — 이 경우 유역 고르기로 넘기지 않는다. */
let pipeClickStart: { x: number; y: number } | null = null;
viewport.addEventListener(
"wheel",
(event) => {
event.preventDefault();
const prevScale = params.getScale();
// 휠을 **당기면 확대**, 밀면 축소한다(2026-08-02 사용자 지시). B04 2D 지도와 같은 방향이다.
const scale = Math.min(16, Math.max(0.5, prevScale * (event.deltaY > 0 ? 1.15 : 0.87)));
params.setScale(scale);
// 커서 아래 지점을 고정한 채 확대/축소 (B04 지도와 동일 동작).
const ratio = scale / prevScale;
const rect = viewport.getBoundingClientRect();
const cursorX = event.clientX - rect.left - rect.width / 2;
const cursorY = event.clientY - rect.top - rect.height / 2;
const offset = params.getOffset();
params.setOffset(
cursorX * (1 - ratio) + offset.x * ratio,
cursorY * (1 - ratio) + offset.y * ratio,
);
scheduleDraw();
},
{ passive: false },
);
viewport.addEventListener("pointerdown", (event) => {
if (contextMenu.contains(event.target)) return;
contextMenu.close();
// 중간 버튼 기본 동작(페이지 자동 스크롤)이 지도 팬과 겹치지 않게 막는다.
if (event.button === 1) event.preventDefault();
const rect = viewport.getBoundingClientRect();
// 배관 마커는 **좌클릭으로만** 잡는다 — 우클릭은 메뉴, 가운데 버튼은 팬 전용이다
// (2026-08-01 사용자 지시).
if (
event.button === 0 &&
pipeEditor.handleDown(currentView(), event.clientX - rect.left, event.clientY - rect.top)
) {
// 마커를 잡은 좌클릭이므로 유역 고르기로는 넘기지 않는다.
pipeClickStart = { x: event.clientX, y: event.clientY };
viewport.setPointerCapture(event.pointerId);
return;
}
// 마커를 못 잡은 좌클릭은 유역 고르기 후보로 기억한다(끌지 않고 뗐을 때만 고른다).
if (event.button === 0) basinClickStart = { x: event.clientX, y: event.clientY };
// 팬은 가운데(휠) 버튼 전용이다. 좌버튼은 유역선 핸들·배관 마커를 고르고 끄는 데만 쓴다
// (좌버튼이 팬까지 겸하면 마커를 집으려다 지도가 딸려 움직인다 — 2026-08-01 사용자 지시).
// 가운데 버튼이 없는 터치·펜은 그대로 끌어서 팬 할 수 있게 둔다.
if (event.pointerType === "mouse" && event.button !== 1) return;
const offset = params.getOffset();
dragStart = { x: event.clientX, y: event.clientY, offsetX: offset.x, offsetY: offset.y };
viewport.style.cursor = "grabbing";
viewport.setPointerCapture(event.pointerId);
});
viewport.addEventListener("pointermove", (event) => {
const rect = viewport.getBoundingClientRect();
// 배관 드래그 중이면 마커 이동(계획선 스냅)만 처리한다.
if (pipeEditor.handleMove(currentView(), event.clientX - rect.left, event.clientY - rect.top))
return;
if (!dragStart) return;
params.setOffset(
dragStart.offsetX + event.clientX - dragStart.x,
dragStart.offsetY + event.clientY - dragStart.y,
);
scheduleDraw();
});
/** 유역 폴리곤을 눌러 고른다. 유역 밖을 누르면 강조를 푼다. */
function pickBasinAt(x: number, y: number): void {
const normalizer = params.getNormalizer();
if (!normalizer) return;
const view = currentView();
let hit: number | null = null;
let smallest = Number.POSITIVE_INFINITY;
params.getBasins().forEach((basin) => {
if (basin.polygon_lonlat.length < 3) return;
const ring = basin.polygon_lonlat.map(([lon, lat]) =>
lonLatToScreen(normalizer, view, lon, lat),
);
if (!pointInRing(ring, x, y)) return;
// 겹치면 면적이 작은 쪽을 고른다(안쪽 조각 우선).
if (basin.area_m2 < smallest) {
smallest = basin.area_m2;
hit = basin.index;
}
});
params.selectBasin(hit === params.getSelectedBasin() ? null : hit);
}
const stopDragging = (): void => {
pipeEditor.handleUp();
dragStart = null;
viewport.style.removeProperty("cursor");
};
viewport.addEventListener("pointerup", (event) => {
const start = basinClickStart;
basinClickStart = null;
const dragged = pipeClickStart !== null;
pipeClickStart = null;
if (!dragged && start && event.button === 0) {
const rect = viewport.getBoundingClientRect();
// 끌었으면 지도 조작이지 고르기가 아니다.
if (Math.hypot(event.clientX - start.x, event.clientY - start.y) <= CLICK_SLOP_PX) {
pickBasinAt(event.clientX - rect.left, event.clientY - rect.top);
}
}
stopDragging();
});
viewport.addEventListener("pointercancel", stopDragging);
}
+57 -186
View File
@@ -1,5 +1,3 @@
import { createWorkflowPanelHandle } from "@ui/ui_template_overlay";
import { createPanelResizer } from "@ui/ui_template_resizer";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import {
computeDetailBasins,
@@ -16,7 +14,6 @@ import {
import {
computeMapRect,
createNormalizer,
lonLatToScreen,
prepareLayer,
prepareMetricPolyline,
type MapRect,
@@ -26,12 +23,12 @@ import {
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
import { type RoutePoint } from "./B05_Profile_Api_Fetch";
import type { FlowArrow } from "../B04_PreProcess/B04_PreProcess_UI_FlowArrows";
import { buildStrengthArray, createFlowLegend } from "../B04_PreProcess/B04_PreProcess_UI_FlowRamp";
import { buildStrengthArray } from "../B04_PreProcess/B04_PreProcess_UI_FlowRamp";
import { resampleRoute } from "../B04_PreProcess/B04_PreProcess_UI_RouteSamples";
import { createPipeEditor } from "./B05_Profile_UI_Drainage_Pipes";
import { createFacilityStore, type FacilityAttributes } from "./B05_Profile_UI_Drainage_Facility";
import { createProgressCircle } from "@ui/ui_template_progress";
import { createMapContextMenu } from "@ui/ui_template_context_menu";
import { createDrainageChrome } from "./B05_Profile_UI_Drainage_Chrome";
import { bindDrainageInteractions } from "./B05_Profile_UI_Drainage_Interact";
import { drawDrainageScene } from "./B05_Profile_UI_Drainage_Render";
import {
mountDrainageToggles,
@@ -49,15 +46,11 @@ import {
summaryText,
basinIndexOfPipe,
pipeMarkerColor,
pointInRing,
reconcilePipes,
WIDTH_KEY,
type DrainageLayer,
} from "./B05_Profile_UI_Drainage_Parts";
/** 이만큼(px) 이하로 움직였다 뗐으면 클릭으로 본다 — 손떨림으로 선택이 안 되는 일을 막는다. */
const CLICK_SLOP_PX = 4;
// 배수유역도 패널 — 하단 종단 패널 안쪽 우측에 도킹되는 2단 사이드 패널.
// 하단 패널이 닫히면 이 패널도 함께 화면에서 사라진다(부모 안에 들어 있으므로 자동).
// 지도는 B04에서 분리한 렌더 엔진(B04_PreProcess_UI_MapRender)을 그대로 재사용해
@@ -126,90 +119,49 @@ export interface DrainagePanelCallbacks {
}
export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): DrainagePanel {
const root = document.createElement("aside");
root.className = "b05-drainage";
// 이 패널은 하단 패널의 **오른쪽**에 도킹해 오른쪽으로 접힌다(제목 카드와 반대).
// 방향을 넘기지 않으면 화살표가 정확히 거꾸로 나온다.
// 이름표 "배수유역" — 세로(90°)로 화살표 위에 얹힌다(2026-08-04 사용자 지시).
const panelHandle = createWorkflowPanelHandle("side", "right", "배수유역");
const header = document.createElement("div");
header.className = "b05-drainage__header";
const title = document.createElement("h3");
title.textContent = L("B05_Drainage_Title");
const layerButtons = document.createElement("div");
layerButtons.className = "b05-drainage__layers";
header.append(title, layerButtons);
// 세부유역 산정 — B04가 미리 분석해 둔 결과를 읽어 관을 보충하고 세부유역만 나눈다.
// 격자 해석은 하지 않으므로 즉시 끝난다.
const analyzeButton = document.createElement("button");
analyzeButton.type = "button";
analyzeButton.className = "b05-drainage__analyze";
analyzeButton.textContent = L("B05_Drainage_Btn_Analyze");
analyzeButton.title = L("B05_Drainage_Btn_Analyze_Tip");
// 선택된 배관 삭제 — 편집 모드에서 마커를 선택해야 활성화된다.
const deleteButton = document.createElement("button");
deleteButton.type = "button";
deleteButton.className = "b05-drainage__analyze b05-drainage__tool";
deleteButton.textContent = L("B05_Drainage_Btn_DeleteSelected");
deleteButton.disabled = true;
// 자동 제안으로 되돌리기 — 편집한 배관 배치를 버리고 백엔드 자동 제안으로 재산정.
const autoButton = document.createElement("button");
autoButton.type = "button";
autoButton.className = "b05-drainage__analyze b05-drainage__tool";
autoButton.textContent = L("B05_Drainage_Btn_Auto");
autoButton.title = L("B05_Drainage_Btn_Auto_Tip");
header.append(analyzeButton, deleteButton, autoButton);
const viewport = document.createElement("div");
viewport.className = "b05-drainage__viewport";
const backgroundImage = document.createElement("img");
backgroundImage.className = "b05-drainage__image";
backgroundImage.alt = L("B05_Drainage_ImageAlt");
backgroundImage.draggable = false;
const canvas = document.createElement("canvas");
canvas.className = "b05-drainage__canvas";
const status = document.createElement("span");
status.className = "b05-drainage__status";
status.textContent = L("B05_Drainage_Status_NeedRoute");
// 지도 정중앙 로딩 서클 — 배경도·도엽 레이어·유역 산정이 끝날 때까지 화면이 비어 보인다.
const progress = createProgressCircle({ overlay: true });
progress.root.hidden = true;
// 배관 추가·삭제 우클릭 메뉴 — 편집 토글을 없앤 대신 이쪽으로 옮겼다(B04 지도와 같은 조작).
const contextMenu = createMapContextMenu("b05-drainage");
// 유입 강도 색띠 범례 — CSS가 지도 우측 세로로 세운다.
const legend = createFlowLegend();
viewport.append(backgroundImage, canvas, status, progress.root, contextMenu.element, legend.root);
const chrome = createDrainageChrome(
{
title: L("B05_Drainage_Title"),
analyze: L("B05_Drainage_Btn_Analyze"),
analyzeTip: L("B05_Drainage_Btn_Analyze_Tip"),
deleteSelected: L("B05_Drainage_Btn_DeleteSelected"),
auto: L("B05_Drainage_Btn_Auto"),
autoTip: L("B05_Drainage_Btn_Auto_Tip"),
imageAlt: L("B05_Drainage_ImageAlt"),
statusNeedRoute: L("B05_Drainage_Status_NeedRoute"),
},
{
minWidth: MIN_PANEL_WIDTH,
maxWidthRatio: MAX_PANEL_WIDTH_RATIO,
widthStorageKey: WIDTH_KEY,
},
);
const {
root,
panelHandle,
layerButtons,
analyzeButton,
deleteButton,
autoButton,
viewport,
backgroundImage,
canvas,
status,
progress,
contextMenu,
legend,
summary,
basinList,
widthResizer,
} = chrome;
const showProgress = createProgressReporter(progress);
// 유역 제원 목록(면적·표고·유하거리·관경). 관경 수식 미확정이라 당분간 "미정"으로 나온다.
// 관 개수·세부유역 수·종단 Z 출처 — 세부유역이 갈리는 근거라 목록 위에 한 줄로 남긴다.
const summary = document.createElement("div");
summary.className = "b05-drainage__summary";
summary.hidden = true;
const basinList = document.createElement("div");
basinList.className = "b05-drainage__basins";
basinList.hidden = true;
// 왼쪽 경계를 끌어 폭을 조절한다. 상한은 하단 패널 폭의 70%,
// 세로는 하단 패널에 그대로 딸려 간다(따로 조절하지 않는다 — 사용자 지시).
const widthResizer = createPanelResizer({
axis: "horizontal",
target: root,
cssVar: "--b05-drainage-width",
direction: -1,
min: MIN_PANEL_WIDTH,
max: () => (root.parentElement?.clientWidth ?? window.innerWidth) * MAX_PANEL_WIDTH_RATIO,
storageKey: WIDTH_KEY,
});
// 계곡 통과 시설(배관/BOX암거/물넘이/세월교) 확장 정보 — 계산기는 chainage만 다루므로
// 여기 보관해 두고 요청마다 되붙인다(2026-08-17 컨테이너 병합). 편집 폼은 사이드
// 「구조물 배치」 하나뿐이다(자동·수동 배관 = 같은 구조물, 별도 UI 없음 —
// 2026-08-17 사용자 지시). 여기는 정본 보관·재계산만 맡는다.
const facilityStore = createFacilityStore();
root.append(panelHandle.root, widthResizer.root, header, viewport, summary, basinList);
let projectId: string | null = null;
let meta: VWorldMeta | null = null;
const preparedLayers = new Map<DrainageLayer, PreparedLayer>();
@@ -256,11 +208,6 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
let scale = 1;
let offsetX = 0;
let offsetY = 0;
let dragStart: { x: number; y: number; offsetX: number; offsetY: number } | null = null;
/** 좌클릭을 시작한 자리 — 끌지 않고 뗐을 때만 유역 고르기로 본다. */
let basinClickStart: { x: number; y: number } | null = null;
/** 마커를 잡은 좌클릭 — 이 경우 유역 고르기로 넘기지 않는다. */
let pipeClickStart: { x: number; y: number } | null = null;
let frameHandle = 0;
let loadSequence = 0;
let canvasWidth = 0;
@@ -579,106 +526,30 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
}
}
viewport.addEventListener(
"wheel",
(event) => {
event.preventDefault();
const prevScale = scale;
// 휠을 **당기면 확대**, 밀면 축소한다(2026-08-02 사용자 지시). B04 2D 지도와 같은 방향이다.
scale = Math.min(16, Math.max(0.5, scale * (event.deltaY > 0 ? 1.15 : 0.87)));
// 커서 아래 지점을 고정한 채 확대/축소 (B04 지도와 동일 동작).
const ratio = scale / prevScale;
const rect = viewport.getBoundingClientRect();
const cursorX = event.clientX - rect.left - rect.width / 2;
const cursorY = event.clientY - rect.top - rect.height / 2;
offsetX = cursorX * (1 - ratio) + offsetX * ratio;
offsetY = cursorY * (1 - ratio) + offsetY * ratio;
scheduleDraw();
},
{ passive: false },
);
bindPipeContextMenu(viewport, contextMenu, pipeEditor, currentView, (chainage, type) =>
callbacks.onStructureAdd?.(chainage, type),
);
viewport.addEventListener("pointerdown", (event) => {
if (contextMenu.contains(event.target)) return;
contextMenu.close();
// 중간 버튼 기본 동작(페이지 자동 스크롤)이 지도 팬과 겹치지 않게 막는다.
if (event.button === 1) event.preventDefault();
const rect = viewport.getBoundingClientRect();
// 배관 마커는 **좌클릭으로만** 잡는다 — 우클릭은 메뉴, 가운데 버튼은 팬 전용이다
// (2026-08-01 사용자 지시).
if (
event.button === 0 &&
pipeEditor.handleDown(currentView(), event.clientX - rect.left, event.clientY - rect.top)
) {
// 마커를 잡은 좌클릭이므로 유역 고르기로는 넘기지 않는다.
pipeClickStart = { x: event.clientX, y: event.clientY };
viewport.setPointerCapture(event.pointerId);
return;
}
// 마커를 못 잡은 좌클릭은 유역 고르기 후보로 기억한다(끌지 않고 뗐을 때만 고른다).
if (event.button === 0) basinClickStart = { x: event.clientX, y: event.clientY };
// 팬은 가운데(휠) 버튼 전용이다. 좌버튼은 유역선 핸들·배관 마커를 고르고 끄는 데만 쓴다
// (좌버튼이 팬까지 겸하면 마커를 집으려다 지도가 딸려 움직인다 — 2026-08-01 사용자 지시).
// 가운데 버튼이 없는 터치·펜은 그대로 끌어서 팬 할 수 있게 둔다.
if (event.pointerType === "mouse" && event.button !== 1) return;
dragStart = { x: event.clientX, y: event.clientY, offsetX, offsetY };
viewport.style.cursor = "grabbing";
viewport.setPointerCapture(event.pointerId);
bindDrainageInteractions({
viewport,
contextMenu,
pipeEditor,
currentView,
getScale: () => scale,
setScale: (value) => {
scale = value;
},
getOffset: () => ({ x: offsetX, y: offsetY }),
setOffset: (x, y) => {
offsetX = x;
offsetY = y;
},
scheduleDraw,
getNormalizer: () => normalizer,
getBasins: () => basins,
getSelectedBasin: () => selectedBasin,
selectBasin: (index) => selectBasin(index),
});
viewport.addEventListener("pointermove", (event) => {
const rect = viewport.getBoundingClientRect();
// 배관 드래그 중이면 마커 이동(계획선 스냅)만 처리한다.
if (pipeEditor.handleMove(currentView(), event.clientX - rect.left, event.clientY - rect.top))
return;
if (!dragStart) return;
offsetX = dragStart.offsetX + event.clientX - dragStart.x;
offsetY = dragStart.offsetY + event.clientY - dragStart.y;
scheduleDraw();
});
/** 유역 폴리곤을 눌러 고른다. 유역 밖을 누르면 강조를 푼다. */
function pickBasinAt(x: number, y: number): void {
if (!normalizer) return;
const view = currentView();
let hit: number | null = null;
let smallest = Number.POSITIVE_INFINITY;
basins.forEach((basin) => {
if (basin.polygon_lonlat.length < 3) return;
const ring = basin.polygon_lonlat.map(([lon, lat]) =>
lonLatToScreen(normalizer as Normalizer, view, lon, lat),
);
if (!pointInRing(ring, x, y)) return;
// 겹치면 면적이 작은 쪽을 고른다(안쪽 조각 우선).
if (basin.area_m2 < smallest) {
smallest = basin.area_m2;
hit = basin.index;
}
});
selectBasin(hit === selectedBasin ? null : hit);
}
const stopDragging = (): void => {
pipeEditor.handleUp();
dragStart = null;
viewport.style.removeProperty("cursor");
};
viewport.addEventListener("pointerup", (event) => {
const start = basinClickStart;
basinClickStart = null;
const dragged = pipeClickStart !== null;
pipeClickStart = null;
if (!dragged && start && event.button === 0) {
const rect = viewport.getBoundingClientRect();
// 끌었으면 지도 조작이지 고르기가 아니다.
if (Math.hypot(event.clientX - start.x, event.clientY - start.y) <= CLICK_SLOP_PX) {
pickBasinAt(event.clientX - rect.left, event.clientY - rect.top);
}
}
stopDragging();
});
viewport.addEventListener("pointercancel", stopDragging);
// 패널을 늘리면 지도를 더 보여 줄 뿐, 배율은 그대로 둔다 — 늘릴 때마다 확대되면
// 방금 보던 자리를 다시 찾아야 한다(2026-08-02 사용자 지시).
+10 -130
View File
@@ -24,16 +24,8 @@ import {
writeRouteLatestCache,
solveRoute,
updateContourInterval,
type CirclePoint,
type RouteLatestResponse,
type RoutePoint,
} from "./B05_Profile_Api_Fetch";
import {
type ModelBounds,
type PlacedRoutePoint,
type RouteDesignPoints,
type RoutePointKind,
} from "./B05_Profile_UI_Markers";
import { createRoutePanel, type RoutePanelValues } from "./B05_Profile_UI_Panel";
import { createRouteProfilePanel } from "./B05_Profile_UI_Profile_Panel";
import { leaveForDashboard } from "../A00_Common/b_missing_data_guard";
@@ -41,7 +33,6 @@ import { navigateTo } from "../A00_Common/router";
import { createSelectionSync } from "./B05_Profile_UI_Selection";
import { createRouteViewer } from "./B05_Profile_UI_Viewer";
import {
irregularLabel,
irregularStationId,
isPipeStation,
structureLabel,
@@ -52,7 +43,6 @@ import { pickPipeDiameter, PIPE_DEFAULT_TYPE } from "@config/config_frontend";
import {
fetchSectionContext,
type SectionDetailResponse,
type SectionStation,
} from "../B06_Section/B06_Section_Api_Fetch";
import {
invalidateSectionDetail,
@@ -68,132 +58,22 @@ import {
} from "./B05_Profile_Api_Structures";
import "./B05_Profile_UI_Style.css";
import "./B05_Profile_UI_Style_Structures.css";
import {
circlePoint,
DEFAULT_ROAD_WIDTHS,
fetchRoadWidths,
interpolateIrregularStations,
restorePoints,
routePoint,
toBounds,
} from "./B05_Profile_UI_Page_Helpers";
type GradeClass = RoutePanelValues["gradeClass"];
type RoadWidths = Record<GradeClass, number>;
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
const DEFAULT_ROAD_WIDTHS: RoadWidths = { trunk: 3, branch: 3, work: 2.5 };
async function fetchRoadWidths(projectId: string): Promise<RoadWidths> {
const response = await fetch(`/api/projects/${projectId}/sections/road-widths`);
if (!response.ok) return DEFAULT_ROAD_WIDTHS;
const payload = (await response.json()) as { forest_road_min_width_m?: Partial<RoadWidths> };
return { ...DEFAULT_ROAD_WIDTHS, ...payload.forest_road_min_width_m };
}
function toBounds(bounds: {
x_min: number;
x_max: number;
y_min: number;
y_max: number;
z_min: number;
z_max: number;
}): ModelBounds {
return {
x: [bounds.x_min, bounds.x_max],
y: [bounds.y_min, bounds.y_max],
z: [bounds.z_min, bounds.z_max],
};
}
function placed(
type: RoutePointKind,
point: RoutePoint | CirclePoint,
index = 0,
): PlacedRoutePoint {
return {
id: `${type}-restored-${index}`,
type,
x: point.x,
y: point.y,
// 표고를 모르면 모르는 채로 넘긴다 — 0으로 눕히면 마커가 지형 한참 아래 평면에 깔린다.
z: point.z ?? null,
...(type === "ap" || type === "fp" ? { radius_m: (point as CirclePoint).radius_m ?? 25 } : {}),
};
}
function restorePoints(latest: RouteLatestResponse): RouteDesignPoints {
const points = latest.route_params?.points;
return {
bp: points?.bp ? placed("bp", points.bp) : null,
ep: points?.ep ? placed("ep", points.ep) : null,
cp: (points?.cp ?? []).map((point, index) => placed("cp", point, index)),
ap: (points?.ap ?? []).map((point, index) => placed("ap", point, index)),
fp: (points?.fp ?? []).map((point, index) => placed("fp", point, index)),
};
}
function routePoint(point: PlacedRoutePoint): RoutePoint {
// 서버 스키마는 표고를 빼면 "모름"으로 받는다 — null은 undefined로 바꿔 보낸다.
return { x: point.x, y: point.y, z: point.z ?? undefined };
}
function circlePoint(point: PlacedRoutePoint): CirclePoint {
return { ...routePoint(point), radius_m: point.radius_m ?? 25 };
}
/**
* 비정규 측점을 규칙 측점 좌표 사이 chainage로 선형보간해 `SectionStation`(월드 좌표·프레임 포함)으로
* 만든다. 백엔드가 아직 이 측점의 횡단을 생성하지 않으므로, 3D 표시에 필요한 위치만 근사한다.
* 노선 범위를 벗어난 chainage는 제외한다.
*/
function interpolateIrregularStations(
base: SectionStation[],
list: IrregularStation[],
maxChainage: number,
): SectionStation[] {
const sorted = [...base].sort((a, b) => a.chainage_m - b.chainage_m);
if (!sorted.length) return [];
const anchorAt = (chainage: number): SectionStation => {
if (chainage <= sorted[0].chainage_m) return sorted[0];
const last = sorted[sorted.length - 1];
if (chainage >= last.chainage_m) return last;
let lo = sorted[0];
let hi = last;
for (let index = 1; index < sorted.length; index += 1) {
if (sorted[index].chainage_m >= chainage) {
lo = sorted[index - 1];
hi = sorted[index];
break;
}
}
const span = hi.chainage_m - lo.chainage_m;
const t = span > 1e-9 ? (chainage - lo.chainage_m) / span : 0;
const lerp = (a: number, b: number): number => a + (b - a) * t;
const centerZ =
lo.center_z !== null && hi.center_z !== null
? lerp(lo.center_z, hi.center_z)
: (lo.center_z ?? hi.center_z);
return {
...lo,
center_x: lerp(lo.center_x, hi.center_x),
center_y: lerp(lo.center_y, hi.center_y),
center_z: centerZ,
frame: {
left_xy: [
lerp(lo.frame.left_xy[0], hi.frame.left_xy[0]),
lerp(lo.frame.left_xy[1], hi.frame.left_xy[1]),
],
},
};
};
return list
.filter((entry) => entry.chainage_m >= 0 && entry.chainage_m <= maxChainage + 1e-6)
.map((entry) => ({
...anchorAt(entry.chainage_m),
station_id: irregularStationId(entry.id),
chainage_m: entry.chainage_m,
label: irregularLabel(entry),
kind: "irregular" as const,
// 3D 측점 라벨이 `측점번호 구조물명`으로 표기할 수 있게 구조물 이름을 실어 보낸다.
structure: entry.structure,
}));
}
export async function renderB05Route(root: HTMLElement): Promise<void> {
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
if (!projectId) {
+144
View File
@@ -0,0 +1,144 @@
/* =============================================================================
* B05_Profile_UI_Page_Helpers.ts
* B05 화면의 순수 도우미 — 설계폭 조회, 모델 경계 변환, 저장분 → 마커 복원,
* 비정규 측점의 3D 좌표 선형보간.
*
* 화면 본체(B05_Profile_UI_Page)가 700줄 한계에 닿아 분리했다.
* 전부 상태를 갖지 않는 변환 함수라 화면 흐름과 독립적이다.
* ========================================================================== */
import type { RoutePanelValues } from "./B05_Profile_UI_Panel";
import type {
ModelBounds,
PlacedRoutePoint,
RouteDesignPoints,
RoutePointKind,
} from "./B05_Profile_UI_Markers";
import type { CirclePoint, RouteLatestResponse, RoutePoint } from "./B05_Profile_Api_Fetch";
import type { SectionStation } from "../B06_Section/B06_Section_Api_Fetch";
import {
irregularLabel,
irregularStationId,
type IrregularStation,
} from "./B05_Profile_UI_IrregularStations";
type GradeClass = RoutePanelValues["gradeClass"];
export type RoadWidths = Record<GradeClass, number>;
export const DEFAULT_ROAD_WIDTHS: RoadWidths = { trunk: 3, branch: 3, work: 2.5 };
export async function fetchRoadWidths(projectId: string): Promise<RoadWidths> {
const response = await fetch(`/api/projects/${projectId}/sections/road-widths`);
if (!response.ok) return DEFAULT_ROAD_WIDTHS;
const payload = (await response.json()) as { forest_road_min_width_m?: Partial<RoadWidths> };
return { ...DEFAULT_ROAD_WIDTHS, ...payload.forest_road_min_width_m };
}
export function toBounds(bounds: {
x_min: number;
x_max: number;
y_min: number;
y_max: number;
z_min: number;
z_max: number;
}): ModelBounds {
return {
x: [bounds.x_min, bounds.x_max],
y: [bounds.y_min, bounds.y_max],
z: [bounds.z_min, bounds.z_max],
};
}
export function placed(
type: RoutePointKind,
point: RoutePoint | CirclePoint,
index = 0,
): PlacedRoutePoint {
return {
id: `${type}-restored-${index}`,
type,
x: point.x,
y: point.y,
// 표고를 모르면 모르는 채로 넘긴다 — 0으로 눕히면 마커가 지형 한참 아래 평면에 깔린다.
z: point.z ?? null,
...(type === "ap" || type === "fp" ? { radius_m: (point as CirclePoint).radius_m ?? 25 } : {}),
};
}
export function restorePoints(latest: RouteLatestResponse): RouteDesignPoints {
const points = latest.route_params?.points;
return {
bp: points?.bp ? placed("bp", points.bp) : null,
ep: points?.ep ? placed("ep", points.ep) : null,
cp: (points?.cp ?? []).map((point, index) => placed("cp", point, index)),
ap: (points?.ap ?? []).map((point, index) => placed("ap", point, index)),
fp: (points?.fp ?? []).map((point, index) => placed("fp", point, index)),
};
}
export function routePoint(point: PlacedRoutePoint): RoutePoint {
// 서버 스키마는 표고를 빼면 "모름"으로 받는다 — null은 undefined로 바꿔 보낸다.
return { x: point.x, y: point.y, z: point.z ?? undefined };
}
export function circlePoint(point: PlacedRoutePoint): CirclePoint {
return { ...routePoint(point), radius_m: point.radius_m ?? 25 };
}
/**
* 비정규 측점을 규칙 측점 좌표 사이 chainage로 선형보간해 `SectionStation`(월드 좌표·프레임 포함)으로
* 만든다. 백엔드가 아직 이 측점의 횡단을 생성하지 않으므로, 3D 표시에 필요한 위치만 근사한다.
* 노선 범위를 벗어난 chainage는 제외한다.
*/
export function interpolateIrregularStations(
base: SectionStation[],
list: IrregularStation[],
maxChainage: number,
): SectionStation[] {
const sorted = [...base].sort((a, b) => a.chainage_m - b.chainage_m);
if (!sorted.length) return [];
const anchorAt = (chainage: number): SectionStation => {
if (chainage <= sorted[0].chainage_m) return sorted[0];
const last = sorted[sorted.length - 1];
if (chainage >= last.chainage_m) return last;
let lo = sorted[0];
let hi = last;
for (let index = 1; index < sorted.length; index += 1) {
if (sorted[index].chainage_m >= chainage) {
lo = sorted[index - 1];
hi = sorted[index];
break;
}
}
const span = hi.chainage_m - lo.chainage_m;
const t = span > 1e-9 ? (chainage - lo.chainage_m) / span : 0;
const lerp = (a: number, b: number): number => a + (b - a) * t;
const centerZ =
lo.center_z !== null && hi.center_z !== null
? lerp(lo.center_z, hi.center_z)
: (lo.center_z ?? hi.center_z);
return {
...lo,
center_x: lerp(lo.center_x, hi.center_x),
center_y: lerp(lo.center_y, hi.center_y),
center_z: centerZ,
frame: {
left_xy: [
lerp(lo.frame.left_xy[0], hi.frame.left_xy[0]),
lerp(lo.frame.left_xy[1], hi.frame.left_xy[1]),
],
},
};
};
return list
.filter((entry) => entry.chainage_m >= 0 && entry.chainage_m <= maxChainage + 1e-6)
.map((entry) => ({
...anchorAt(entry.chainage_m),
station_id: irregularStationId(entry.id),
chainage_m: entry.chainage_m,
label: irregularLabel(entry),
kind: "irregular" as const,
// 3D 측점 라벨이 `측점번호 구조물명`으로 표기할 수 있게 구조물 이름을 실어 보낸다.
structure: entry.structure,
}));
}
+6 -3
View File
@@ -88,10 +88,11 @@ function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
function section(title: string): { root: HTMLElement; body: HTMLElement } {
function section(title: string, collapsed = false): { root: HTMLElement; body: HTMLElement } {
const root = document.createElement("section");
// ui-sidebar-section: 사이드 컨테이너 공통 외곽선(진하게, 2026-08-05 사용자 지시).
root.className = "b05-route__panel-section ui-collapsible ui-sidebar-section";
// 접힘 초기값만 여기서 준다 — 펼치기·접기 토글은 ui_template_collapsible 전역 몫.
root.className = `b05-route__panel-section ui-collapsible ui-sidebar-section${collapsed ? " is-collapsed" : ""}`;
const heading = document.createElement("h3");
heading.className = "ui-collapsible__title";
heading.textContent = title;
@@ -296,7 +297,9 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
longSampleInterval.wrapper,
);
const gradeLine = section("종단 설계 기준");
// 종단 설계 기준은 한 번 정하면 자주 손대지 않는다 — 기본은 접힌 상태
// (2026-08-17 사용자 지시).
const gradeLine = section("종단 설계 기준", true);
const terrainField = createSelectField({
label: "지형 구분",
options: [
@@ -0,0 +1,112 @@
/* =============================================================================
* B05_Profile_UI_Structures_List.ts
* (structures)
* (pipe_points) .
*
* (B05_Profile_UI_Structures_Panel) 700 .
* · ,
* .
* ========================================================================== */
import { structureAnchorM, type StructureInstance, type StructureType } from "./B05_Profile_Api_Structures";
import type { PipeFacilityItem } from "./B05_Profile_UI_Structures_Panel";
import { formatStation } from "./B05_Profile_Util_Station";
export interface StructureListParams {
/** 목록을 담을 <ul>. 그릴 때마다 통째로 갈아 끼운다. */
list: HTMLElement;
structures: ReadonlyArray<StructureInstance>;
pipeFacilities: ReadonlyArray<PipeFacilityItem>;
/** 타입 이름을 찾을 레지스트리 사전. */
typeMap: Map<string, StructureType>;
/** 측점 표기용 측점 간격(m). */
intervalM: number;
/** 설치측 표시 이름 — 패널과 같은 목록을 쓴다. */
sideLabels: ReadonlyArray<[string, string]>;
/** 지금 폼에 올라와 있는 구조물(없으면 null). */
editingId: string | null;
/** 지금 폼에 올라와 있는 계곡 통과 시설의 누가거리(없으면 null). */
selectedPipeChainage: number | null;
onSelectStructure: (structure: StructureInstance) => void;
onSelectPipe: (pipe: PipeFacilityItem) => void;
}
/** 구조물 한 건의 표시 이름 — 점형은 기준 측점, 구간형은 시작~종료 측점. */
export function labelOfStructure(
structure: StructureInstance,
typeMap: Map<string, StructureType>,
intervalM: number,
): string {
const name = typeMap.get(structure.type_id)?.name ?? structure.type_id;
const position =
structure.placement === "interval"
? `${formatStation(structure.start_m ?? 0, intervalM)}~${formatStation(structure.end_m ?? 0, intervalM)}`
: formatStation(structure.chainage_m ?? 0, intervalM);
return `${position} · ${name}`;
}
/**
* (2026-08-17 2). */
export function labelOfPipe(
pipe: PipeFacilityItem,
typeMap: Map<string, StructureType>,
intervalM: number,
): string {
const name = typeMap.get(pipe.facility)?.name ?? pipe.facility;
return `${formatStation(pipe.chainage_m, intervalM)} · ${name}`;
}
export function renderStructureList(params: StructureListParams): void {
const { list, typeMap, intervalM, editingId, selectedPipeChainage } = params;
list.replaceChildren();
// 두 정본을 하나의 목록으로 — 기준점 오름차순 병합 (2026-08-17 통합 표시).
const rows: Array<
| { kind: "structure"; anchor: number; structure: StructureInstance }
| { kind: "pipe"; anchor: number; pipe: PipeFacilityItem }
> = [
...params.structures.map((structure) => ({
kind: "structure" as const,
anchor: structureAnchorM(structure),
structure,
})),
...params.pipeFacilities.map((pipe) => ({
kind: "pipe" as const,
anchor: pipe.chainage_m,
pipe,
})),
].sort((left, right) => left.anchor - right.anchor);
if (!rows.length) {
const empty = document.createElement("li");
empty.className = "b05-route__irregular-empty";
empty.textContent = "배치된 구조물이 없습니다.";
list.append(empty);
return;
}
rows.forEach((row) => {
const item = document.createElement("li");
item.className = "b05-route__irregular-item";
const name = document.createElement("strong");
const info = document.createElement("span");
if (row.kind === "structure") {
const { structure } = row;
item.classList.toggle("is-selected", structure.structure_id === editingId);
name.textContent = labelOfStructure(structure, typeMap, intervalM);
const sideLabel = params.sideLabels.find(([value]) => value === structure.side)?.[1] ?? "";
info.textContent = [sideLabel, structure.memo].filter(Boolean).join(" · ");
item.addEventListener("click", () => params.onSelectStructure(structure));
if (structure.structure_id === editingId) item.scrollIntoView({ block: "nearest" });
} else {
const { pipe } = row;
item.classList.toggle(
"is-selected",
selectedPipeChainage !== null && Math.abs(selectedPipeChainage - pipe.chainage_m) < 0.05,
);
name.textContent = labelOfPipe(pipe, typeMap, intervalM);
info.textContent = "배수유역 연동";
item.addEventListener("click", () => params.onSelectPipe(pipe));
}
item.append(name, info);
list.append(item);
});
}
+16 -74
View File
@@ -28,7 +28,7 @@ import {
type FacilityAttributes,
} from "./B05_Profile_UI_Drainage_Facility";
import { field, numberInput, select, stationFields } from "./B05_Profile_UI_Structures_Fields";
import { formatStation } from "./B05_Profile_Util_Station";
import { renderStructureList } from "./B05_Profile_UI_Structures_List";
/** 구조물군 표시 이름. 리스트 기호(A~G)만으로는 무엇인지 알기 어렵다. */
const GROUP_LABELS: Record<string, string> = {
@@ -315,80 +315,22 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
removeButton.disabled = editingId === null && selectedPipeChainage === null;
}
function labelOfStructure(structure: StructureInstance): string {
const type = typeMap().get(structure.type_id);
const name = type?.name ?? structure.type_id;
const step = interval();
const position =
structure.placement === "interval"
? `${formatStation(structure.start_m ?? 0, step)}~${formatStation(structure.end_m ?? 0, step)}`
: formatStation(structure.chainage_m ?? 0, step);
return `${position} · ${name}`;
}
function labelOfPipe(pipe: PipeFacilityItem): string {
const type = typeMap().get(pipe.facility);
const name = type?.name ?? pipe.facility;
// 계곡 통과 시설은 점형 — 기준 측점 하나로 표기한다(2026-08-17 지시 2).
return `${formatStation(pipe.chainage_m, interval())} · ${name}`;
}
function renderList(): void {
list.replaceChildren();
// 두 정본을 하나의 목록으로 — 기준점 오름차순 병합 (2026-08-17 통합 표시).
const rows: Array<
| { kind: "structure"; anchor: number; structure: StructureInstance }
| { kind: "pipe"; anchor: number; pipe: PipeFacilityItem }
> = [
...structures.map((structure) => ({
kind: "structure" as const,
anchor: structureAnchorM(structure),
structure,
})),
...pipeFacilities.map((pipe) => ({
kind: "pipe" as const,
anchor: pipe.chainage_m,
pipe,
})),
].sort((left, right) => left.anchor - right.anchor);
if (!rows.length) {
const empty = document.createElement("li");
empty.className = "b05-route__irregular-empty";
empty.textContent = "배치된 구조물이 없습니다.";
list.append(empty);
return;
}
rows.forEach((row) => {
const item = document.createElement("li");
item.className = "b05-route__irregular-item";
const name = document.createElement("strong");
const info = document.createElement("span");
if (row.kind === "structure") {
const { structure } = row;
item.classList.toggle("is-selected", structure.structure_id === editingId);
name.textContent = labelOfStructure(structure);
const sideLabel = SIDE_LABELS.find(([value]) => value === structure.side)?.[1] ?? "";
info.textContent = [sideLabel, structure.memo].filter(Boolean).join(" · ");
item.addEventListener("click", () =>
loadForm(structure.structure_id === editingId ? null : structure),
);
if (structure.structure_id === editingId) item.scrollIntoView({ block: "nearest" });
} else {
const { pipe } = row;
item.classList.toggle(
"is-selected",
selectedPipeChainage !== null && Math.abs(selectedPipeChainage - pipe.chainage_m) < 0.05,
);
name.textContent = labelOfPipe(pipe);
info.textContent = "배수유역 연동";
item.addEventListener("click", () => {
loadPipeForm(pipe);
callbacks.onPipeSelect(pipe.chainage_m);
});
}
item.append(name, info);
list.append(item);
renderStructureList({
list,
structures,
pipeFacilities,
typeMap: typeMap(),
intervalM: interval(),
sideLabels: SIDE_LABELS,
editingId,
selectedPipeChainage,
onSelectStructure: (structure) =>
loadForm(structure.structure_id === editingId ? null : structure),
onSelectPipe: (pipe) => {
loadPipeForm(pipe);
callbacks.onPipeSelect(pipe.chainage_m);
},
});
}
+32 -1
View File
@@ -138,6 +138,33 @@ def _parse_span(item: dict[str, Any], chainage: float) -> tuple[float | None, fl
return min(values), max(values)
# 보호공 부위별 기본값 (2026-08-17 사용자 확정) — 구 "없음" 저장분을 끌어올릴 때 쓴다.
_PROTECTION_FALLBACK = {"inlet": "돌붙임(찰)", "outlet": "돌붙임(메)"}
def _migrate_protection(options: dict[str, Any]) -> dict[str, Any]:
"""구 저장분의 보호공 키를 새 한 축으로 옮긴다 (2026-08-17 보호공 개편).
개편 전에는 `*_pitching`(있음/없음) `*_pitching_finish`(/) 축이었다.
읽는 순간 `*_protection`(돌붙임()/돌붙임()/도수로) 축으로 바꿔 두면 화면도
수량도 키를 필요가 없다. "없음" 선택지가 사라졌으므로 부위 기본값으로
올린다 물이 흐르는 자리라 보호공은 반드시 있다(사용자 확정).
"""
for side, fallback in _PROTECTION_FALLBACK.items():
legacy = options.pop(f"{side}_pitching", None)
finish = options.pop(f"{side}_pitching_finish", None)
area = options.pop(f"{side}_pitching_area_m2", None)
if legacy is None and finish is None and area is None:
continue
if f"{side}_protection" not in options:
options[f"{side}_protection"] = (
f"돌붙임({finish})" if legacy == "있음" and finish in ("", "") else fallback
)
if area is not None and f"{side}_protection_area_m2" not in options:
options[f"{side}_protection_area_m2"] = area
return options
def parse_pipe_points(values: Any) -> list[PipePoint]:
"""외부에서 들어온 시설 목록(파일·요청 본문)을 정리한다. 누가거리 오름차순."""
if not isinstance(values, list):
@@ -163,7 +190,11 @@ def parse_pipe_points(values: Any) -> list[PipePoint]:
facility=facility if facility in _KNOWN_FACILITIES else PIPE_FACILITY_PIPE,
start_m=start,
end_m=end,
options=dict(options) if isinstance(options, dict) and options else None,
options=(
_migrate_protection(dict(options))
if isinstance(options, dict) and options
else None
),
)
)
points.sort(key=lambda point: point.chainage_m)