refactor(B05·B06): 배수유역도 판 홀로 서기 뼈대 — _Drainage_Side(끼우는 창구 · 켜고 끄기) · _Drainage_Report(관 보고) 떼기 · Panel 700 → 660줄 (PLAN 24-2e 1단계)

- createDrainageSide(host) — 종단쪽 이음은 DrainagePanelCallbacks 약속으로만 · setVisible · 켜고 끄는 단추
- 관 보고 만들기를 _Report 로 · DrainagePipeReport 형을 _Panel_Types 에 이름 붙임 · 관 요청 몸통 pipeRequest 하나로
- 시험: 종단 · B06 를 안 가져옴 · 700줄 · 관 보고 셈 · 켜고 끄는 창구

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019BVZd1M6WUf1YEy9gbAE1G
This commit is contained in:
2026-09-29 20:05:59 +09:00
co-authored by Claude Sonnet 5.5
parent cd26699d21
commit 34b0ec6e2a
7 changed files with 285 additions and 70 deletions
+15 -55
View File
@@ -32,6 +32,7 @@ import { createPipeEditor } from "./B05_Profile_UI_Drainage_Pipes";
import { createFacilityStore } from "./B05_Profile_UI_Drainage_Facility";
import { mergeFacilityOptions } from "./B05_Profile_UI_Drainage_Facility_Merge";
import { readPendingPipes, writePendingPipes } from "./B05_Profile_Api_Pipes_Draft";
import { buildPipeReports } from "./B05_Profile_UI_Drainage_Report";
import { createDrainageChrome } from "./B05_Profile_UI_Drainage_Chrome";
import { bindDrainageInteractions } from "./B05_Profile_UI_Drainage_Interact";
import type { RouteSpanBand } from "./B05_Profile_UI_Drainage_Spans";
@@ -355,31 +356,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
summaryText(summary, pipeEditor.pipes().length, basins.length, zSource);
// 목록 통지가 선택 통지보다 **먼저** 가야 한다 — 사이드 폼은 이 목록에서 관을
// 찾아 편집 화면을 채우므로, 순서가 뒤집히면 새로 넣은 관의 폼이 열리지 않는다.
callbacks.onPipesChanged?.(
// 관마다 담당 유역의 배수 유효직경을 붙인다(±0.5m 매칭). 유역 없는 관은 null.
// 세월교 검토 유역(bridge_required)도 null — 관 규격을 최대치로 올려 봐야 무의미하고,
// 그 지점은 세월교·물넘이로 별도 설계한다(임도설치규정 제12조).
// 시설 종류·구간·부속 옵션·출처(source)도 함께 — 통합 목록·사이드 폼이
// 이 목록만으로 편집 화면을 채운다(2026-08-17 단일 폼 통합).
pipeEditor.pipes().map((pipe) => {
const chainage = Math.round(pipe.chainage_m * 100) / 100;
const basin = basins.find((entry) => Math.abs(entry.chainage_m - chainage) < 0.51);
const attributes = facilityStore.get(chainage);
return {
chainage_m: chainage,
effective_diameter_mm:
basin && !basin.bridge_required ? (basin.pipe_diameter_mm ?? null) : null,
// 설계유량은 세월교 검토 유역에도 붙인다 — 물넘이·세월교 단면이 이 값에서 나온다.
design_flow_m3s: basin?.design_flow_m3s ?? null,
facility: attributes?.facility ?? "pipe",
start_m: attributes?.start_m,
end_m: attributes?.end_m,
source: (pipe.reason || "user") as PipeSource,
options: attributes?.options,
summary_item: attributes?.summary_item,
};
}),
);
callbacks.onPipesChanged?.(buildPipeReports(pipeEditor.pipes(), basins, facilityStore));
syncPipeSelection();
}
@@ -405,18 +382,19 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
}
}
/** 지금 관 목록 + 시설 정보 — 저장 · 재계산 · 세션 초안 요청 몸통이 모두 이것이다. */
const pipeRequest = () =>
facilityStore.attach(
pipeEditor.pipes().map((pipe) => ({
chainage_m: pipe.chainage_m,
source: (pipe.reason || "user") as PipeSource,
})),
);
/** 지금 화면의 관 목록을 세션 초안에 담는다 — 정본 쓰기는 [저장]·[확정] 몫이다. */
function rememberPipes(): void {
if (!projectId) return;
writePendingPipes(
projectId,
facilityStore.attach(
pipeEditor.pipes().map((pipe) => ({
chainage_m: pipe.chainage_m,
source: (pipe.reason || "user") as PipeSource,
})),
),
);
writePendingPipes(projectId, pipeRequest());
}
/** 저장된 관 지점(없으면 자동 배치)을 불러온다. 화면에 들어올 때 1회. 저장 전 조작분(세션 초안)이
@@ -436,17 +414,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
/** 세부유역을 다시 나눈다. 격자 해석은 하지 않으므로 즉시 끝난다.
* 시설 종류·구간·옵션은 계산기에서 사라지므로 요청에 되붙인다. */
const analyze = (): Promise<void> =>
run(() =>
computeDetailBasins(
projectId as string,
facilityStore.attach(
pipeEditor.pipes().map((pipe) => ({
chainage_m: pipe.chainage_m,
source: (pipe.reason || "user") as PipeSource,
})),
),
),
);
run(() => computeDetailBasins(projectId as string, pipeRequest()));
analyzeButton.addEventListener("click", () => void analyze());
deleteButton.addEventListener("click", () => void pipeEditor.deleteSelected());
@@ -664,16 +632,8 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
},
async savePipes() {
if (!projectId) return 0;
const response = await saveDetailPipePoints(
projectId,
// 시설 종류·구간·옵션까지 정본에 남긴다(계산기 왕복에서 사라지므로 되붙임).
facilityStore.attach(
pipeEditor.pipes().map((pipe) => ({
chainage_m: pipe.chainage_m,
source: (pipe.reason || "user") as PipeSource,
})),
),
);
// 시설 종류·구간·옵션까지 정본에 남긴다(계산기 왕복에서 사라지므로 되붙임).
const response = await saveDetailPipePoints(projectId, pipeRequest());
apply(response);
scheduleDraw();
return response.pipe_count;
@@ -52,24 +52,25 @@ export interface DrainagePanel {
dispose: () => void;
}
/** 관 하나의 보고 — 누가거리 + 담당 유역의 배수 유효직경(mm) + 시설 종류 · 구간 · 옵션 · 집계 항목. */
export interface DrainagePipeReport {
chainage_m: number;
effective_diameter_mm: number | null;
/** 담당 유역의 설계유량(㎥/s, 100년빈도·2.0배). 물넘이·세월교 개략 단면의 입력이다. */
design_flow_m3s?: number | null;
facility: PipeFacility;
start_m?: number;
end_m?: number;
source?: PipeSource;
options?: Record<string, string | number>;
/** 이 관이 뜻하는 구조물 집계표 항목(21-1) — 없으면 미지정. */
summary_item?: SummaryItem | null;
}
export interface DrainagePanelCallbacks {
/** 관 목록이 바뀔 때마다 누가거리 + 담당 유역의 배수 유효직경(mm) + 시설 종류를
* 넘긴다 — 종단 구조물 라인·통합 목록 동기화 및 관경 자동 지정(D800 기본)용. */
onPipesChanged?: (
pipes: Array<{
chainage_m: number;
effective_diameter_mm: number | null;
/** 담당 유역의 설계유량(㎥/s, 100년빈도·2.0배). 물넘이·세월교 개략 단면의 입력이다. */
design_flow_m3s?: number | null;
facility: PipeFacility;
start_m?: number;
end_m?: number;
source?: PipeSource;
options?: Record<string, string | number>;
/** 이 관이 뜻하는 구조물 집계표 항목(21-1) — 없으면 미지정. */
summary_item?: SummaryItem | null;
}>,
) => void;
onPipesChanged?: (pipes: DrainagePipeReport[]) => void;
/** 유역을 고르거나 풀 때 그 관의 누가거리(없으면 null)를 넘긴다 — 그래프·사이드 패널 동기화용. */
onBasinSelected?: (chainageM: number | null) => void;
/** 관 마커 선택이 바뀔 때(클릭·해제) 누가거리를 알린다 — 유역이 없는 관도 전 화면
@@ -0,0 +1,39 @@
/* =============================================================================
* B05_Profile_UI_Drainage_Report.ts
* 배수유역 패널이 관 목록이 바뀔 때 밖에 알리는 **관 보고**(`DrainagePipeReport`)를 만든다.
* `B05_Profile_UI_Drainage_Panel` 에서 떼어냄(700줄 제한) — 셈은 그대로, 상태를 안 든다.
* ========================================================================== */
import type { DetailBasin, PipeSource } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import type { createFacilityStore } from "./B05_Profile_UI_Drainage_Facility";
import type { DrainagePipeReport } from "./B05_Profile_UI_Drainage_Panel_Types";
/** 관마다 담당 유역의 배수 유효직경을 붙인다(±0.5m 매칭). 유역 없는 관은 null.
* 세월교 검토 유역(bridge_required)도 null — 관 규격을 최대치로 올려 봐야 무의미하고,
* 그 지점은 세월교·물넘이로 별도 설계한다(임도설치규정 제12조).
* 시설 종류·구간·부속 옵션·출처(source)도 함께 — 통합 목록·사이드 폼이
* 이 목록만으로 편집 화면을 채운다(2026-08-17 단일 폼 통합). */
export function buildPipeReports(
pipes: ReadonlyArray<{ chainage_m: number; reason?: string }>,
basins: ReadonlyArray<DetailBasin>,
facilityStore: ReturnType<typeof createFacilityStore>,
): DrainagePipeReport[] {
return pipes.map((pipe) => {
const chainage = Math.round(pipe.chainage_m * 100) / 100;
const basin = basins.find((entry) => Math.abs(entry.chainage_m - chainage) < 0.51);
const attributes = facilityStore.get(chainage);
return {
chainage_m: chainage,
effective_diameter_mm:
basin && !basin.bridge_required ? (basin.pipe_diameter_mm ?? null) : null,
// 설계유량은 세월교 검토 유역에도 붙인다 — 물넘이·세월교 단면이 이 값에서 나온다.
design_flow_m3s: basin?.design_flow_m3s ?? null,
facility: attributes?.facility ?? "pipe",
start_m: attributes?.start_m,
end_m: attributes?.end_m,
source: (pipe.reason || "user") as PipeSource,
options: attributes?.options,
summary_item: attributes?.summary_item,
};
});
}
@@ -0,0 +1,92 @@
/* =============================================================================
* B05_Profile_UI_Drainage_Side.ts
* 배수유역도를 종단도의 **side 판**으로 끼우는 홀로 선 창구(계획서 24-2e).
*
* 판(`createDrainagePanel`)은 종단 패널을 모른다 — 종단쪽 일은 전부 `DrainageSideHost`
* (= `DrainagePanelCallbacks`, `_Panel_Types` 약속)로 받고, 종단쪽에서 들어오는 일은
* `panel` 의 창구(`setRoute` · `setIntervalSpans` · `pipeChainages` · `setPipeChainages` ·
* `selectPipeAtChainage` …)로만 한다.
* · 관 목록이 바뀜(최소 흙덮기 경고 · 구조물 라인 · 통합 목록) = `host.onPipesChanged`
* · 관 자리 읽기 · 쓰기(되돌리기) = `panel.pipeChainages()` · `panel.setPipeChainages()`
* · 우클릭 메뉴의 구조물 종류 = `host.structureMenuItems`
* · 구간 띠 · 노선 = `panel.setIntervalSpans()` · `panel.setRoute()`
* 두 보기(종단 · 횡단)에서 같은 판을 끼우고 **켜고 끈다** — 끄면 자리를 비우되 관 · 유역
* 상태는 그대로 살아 있다(다시 켜면 이어서 보임). 켬 · 끔을 어디에 적을지는 끼우는 쪽 몫이다.
* ========================================================================== */
import { createDrainagePanel } from "./B05_Profile_UI_Drainage_Panel";
import type { DrainagePanel, DrainagePanelCallbacks } from "./B05_Profile_UI_Drainage_Panel_Types";
/** 종단쪽이 넘기는 이음 — 판 바깥 약속(`DrainagePanelCallbacks`) 그대로. */
export type DrainageSideHost = DrainagePanelCallbacks;
export interface DrainageSide {
/** 종단도 side 자리에 붙일 뿌리. */
root: HTMLElement;
/** 판의 창구 — 종단쪽이 노선 · 구간 띠 · 관 자리 · 선택을 밀어 넣는다. */
panel: DrainagePanel;
/** 켜고 끈다. 켜면 숨겼던 사이 바뀐 크기 · 그림을 판이 스스로 다시 맞춘다. */
setVisible: (visible: boolean) => void;
isVisible: () => boolean;
/** 켬 · 끔이 바뀔 때마다 부른다(단추 표시 · 저장). 해제 함수를 돌려준다. */
onVisibleChange: (listener: (visible: boolean) => void) => () => void;
dispose: () => void;
}
export interface DrainageSideOptions {
/** 처음 켤지(기본 켬). */
visible?: boolean;
}
export function createDrainageSide(
host: DrainageSideHost = {},
options: DrainageSideOptions = {},
): DrainageSide {
const panel = createDrainagePanel(host);
const listeners = new Set<(visible: boolean) => void>();
let visible = true;
function setVisible(next: boolean): void {
if (next === visible) return;
visible = next;
panel.root.hidden = !visible;
listeners.forEach((listener) => listener(visible));
}
// 처음 상태는 알림 없이 맞춘다.
if (options.visible === false) {
visible = false;
panel.root.hidden = true;
}
return {
root: panel.root,
panel,
setVisible,
isVisible: () => visible,
onVisibleChange(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
dispose() {
listeners.clear();
panel.dispose();
},
};
}
/** 켜고 끄는 단추 — 어느 보기에서든 같은 모양 · 같은 글. 누르면 `side` 를 뒤집는다. */
export function createDrainageToggleButton(side: DrainageSide): HTMLButtonElement {
const button = document.createElement("button");
button.type = "button";
button.className = "ui-btn ui-btn--ghost b05-drainage__toggle";
button.textContent = "배수유역도";
button.title = "배수유역도 판을 켜고 끕니다";
const sync = (visible: boolean): void => {
button.setAttribute("aria-pressed", String(visible));
button.classList.toggle("is-active", visible);
};
sync(side.isVisible());
side.onVisibleChange(sync);
button.addEventListener("click", () => side.setVisible(!side.isVisible()));
return button;
}
@@ -288,3 +288,8 @@
}
/* 구조물(배관) 배치로 갈라진 구배 블록 — 평소엔 값 없이 윤곽만, 선택하면 강조와 함께 값 표시. */
/* 종단도 판 자리에서 끈 배수유역도(`_Drainage_Side` · 계획서 24-2e) — 자리를 비운다. */
.b05-drainage[hidden] {
display: none;
}
@@ -0,0 +1,31 @@
/* 배수유역 관 보고(`B05_Profile_UI_Drainage_Report.ts`) 셈 단위검증 헬퍼 — TS 를 트랜스파일해 Node 로 돈다. */
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const file = path.join(__dirname, "..", "..", "B05_Profile", "B05_Profile_UI_Drainage_Report.ts");
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
new Function("exports", "module", "require", js)(box.exports, box, require);
const { buildPipeReports } = box.exports;
const store = {
get: (chainage) =>
chainage === 100.5 ? { facility: "box_culvert", start_m: 95, end_m: 106, options: { h: 2 } } : null,
};
const basins = [
{ chainage_m: 40, pipe_diameter_mm: 800, design_flow_m3s: 1.2, bridge_required: false },
{ chainage_m: 100.5, pipe_diameter_mm: 1000, design_flow_m3s: 3.4, bridge_required: true },
];
const out = buildPipeReports(
[
{ chainage_m: 40.004, reason: "auto" },
{ chainage_m: 100.499 },
{ chainage_m: 200 },
],
basins,
store,
);
process.stdout.write(JSON.stringify(out));
@@ -0,0 +1,87 @@
# -*- coding: utf-8 -*-
"""배수유역도 판이 종단 패널을 모르고 홀로 선다(계획서 24-2e).
- 배수유역 파일(`B05_Profile_UI_Drainage_*`)은 종단 패널(`_Profile_*`)이나 B06 을 가져오지 않는다 —
종단쪽 일은 `_Panel_Types` 약속(콜백 · 창구)으로만 오간다.
- 파일마다 700줄 이하.
- 관 보고 만들기(`_Report`)는 상태 없이 같은 결과: ±0.5m 유역 매칭 · 세월교 유역은 관경 null ·
시설 정보 · 출처 기본값 user.
- `createDrainageSide` 가 켜고 끄는 창구를 낸다(두 보기에서 같은 판).
"""
import json
import os
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
B05 = ROOT / "B05_Profile"
HERE = os.path.dirname(os.path.abspath(__file__))
def _drainage_files() -> list[Path]:
return sorted(B05.glob("B05_Profile_UI_Drainage_*.ts"))
def test_배수유역_파일은_종단_패널과_B06을_안_가져옴():
files = _drainage_files()
assert len(files) >= 10
for path in files:
source = path.read_text(encoding="utf-8")
for line in source.splitlines():
if not line.lstrip().startswith(("import ", "} from ", "export {", "export type {")):
continue
assert "UI_Profile_" not in line, (path.name, line)
assert "B06_Section" not in line, (path.name, line)
def test_배수유역_파일은_700줄_이하():
for path in _drainage_files():
lines = len(path.read_text(encoding="utf-8").splitlines())
assert lines <= 700, (path.name, lines)
def test_켜고_끄는_창구():
side = (B05 / "B05_Profile_UI_Drainage_Side.ts").read_text(encoding="utf-8")
for name in (
"createDrainageSide",
"createDrainageToggleButton",
"setVisible",
"onVisibleChange",
):
assert name in side
assert "root.hidden" in side or "panel.root.hidden" in side
types = (B05 / "B05_Profile_UI_Drainage_Panel_Types.ts").read_text(encoding="utf-8")
assert "export interface DrainagePipeReport" in types
assert "onPipesChanged?: (pipes: DrainagePipeReport[]) => void;" in types
@pytest.fixture(scope="module")
def reports():
proc = subprocess.run(
["node", os.path.join(HERE, "helper_b05_drainage_report.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_관_보고_유역_매칭과_기본값(reports):
first, box, plain = reports
# 누가거리는 소수 둘째 자리로 · 담당 유역의 관경 · 설계유량이 붙는다.
assert first["chainage_m"] == 40 and first["effective_diameter_mm"] == 800
assert first["design_flow_m3s"] == 1.2 and first["facility"] == "pipe"
assert first["source"] == "auto"
# 세월교 검토 유역은 관경만 null · 설계유량은 남김 · 시설 정보를 데려옴.
assert box["chainage_m"] == 100.5 and box["effective_diameter_mm"] is None
assert box["design_flow_m3s"] == 3.4
assert box["facility"] == "box_culvert" and (box["start_m"], box["end_m"]) == (95, 106)
assert box["options"] == {"h": 2} and box["source"] == "user"
# 유역 없는 관 — 모두 null · 맨 배관.
assert plain["effective_diameter_mm"] is None and plain["design_flow_m3s"] is None
assert plain["facility"] == "pipe"