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:
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 사용자 지시).
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user