feat(B08): 수리집수면적표 우리 양식 · 공사지 지역 · 재현기간 드롭다운 · 처음 값 빈칸 · [저장](PLAN 69-4~69-6)

- 엑셀 모양(흰 종이 · 노랑 칸 · 쪽 표시) 대신 공용 카드 · 입력 · 드롭다운 · 테마 토큰 색
- 테두리는 입력 · 선택 칸만 · 굵게 + 붉은 계열은 Qd · 유출량 · 판정 · 유출율만 · 관 단면 그림 테마 색
- 지역 = 전처리 행정구역(노선 대표점) > 시군구 하나 > 프로젝트 등록 지역 · R24 = 전처리 강우량표의 재현기간별 24시간 값(없으면 입력 칸)
- 계산 때 R24 는 서버가 공사지 표로 정함 · 빈칸에 기대는 계산 칸은 「—」
- 처음 값 = 표준 값만(할증 2.0 · 관 종류 · 단면 가정 · 유출계수 0.8 · 재현기간 100년) · 측점 고유 값 빈칸
- [저장] = PUT …/quantity/hydro → B08_Quantity/quantities/hydro_inputs.json 만 · 성공 뒤 캐시 비움 · 캐시 판 올림(v2)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W4uKaV4ZBhsJN4GHG7yRj4
This commit is contained in:
2026-10-06 23:09:21 +09:00
co-authored by Claude Opus 5.5
parent d42efb162b
commit 426e574cd2
9 changed files with 691 additions and 365 deletions
+2 -2
View File
@@ -164,8 +164,8 @@ export const STATE_REGISTRY = {
* 예전에는 버튼을 누를 때마다 서버가 계산해 **바로 저장**했다. 조작은 캐시에 쌓고
* [저장]·[확정]에서만 정본으로 나가야 한다(사용자 확정). */
crossdesign: { bucket: "draft", scope: "route" },
/** B08 홍수량 계산서 입력 `{측점: 입력}` — 지금은 캐시만 · 배수유역 · B05/B06 값과 안 이음(62-2). */
"b08-hydro": { bucket: "draft", scope: "project" },
/** B08 홍수량 계산서 입력 `{측점: 입력}` — [저장] 때 B08 자기 파일로 · 배수유역 · B05/B06 값과 안 이음(69-6). */
"b08-hydro": { bucket: "draft", scope: "project", version: 2 },
/** 미저장 계획고 편집(▲▼ · 곡선반경) — 설계 가게 `alignmentDraft` 의 칸(계획서 24-2c).
* 옛 키는 노선 번호만 붙은 `b05-profile-alignment-draft:{노선}` 이었다. */
"alignment-draft": {
+39 -27
View File
@@ -36,43 +36,55 @@ def section_props(section: str, diameter_m: float) -> dict[str, float]:
return {"area": full_area, "perimeter": math.pi * d, "radius": d / 4.0}
def compute(inputs: dict[str, Any]) -> dict[str, Any]:
"""입력 → 계산서 값 전부(반올림 없음 · 화면이 자릿수를 정함)."""
length_m = float(inputs["length_m"])
height_m = float(inputs["height_m"])
slope_ground = height_m / length_m
tc_raw_hr = 0.0663 * (length_m / 1000.0) ** 0.77 * slope_ground**-0.385
tc_hr = max(tc_raw_hr, float(inputs["tc_min_minutes"]) / 60.0)
r24 = float(inputs["r24_mm"])
intensity = (r24 / 24.0) * (24.0 / tc_hr) ** MONONOBE_EXPONENT
runoff = float(inputs["runoff"])
area_km2 = float(inputs["area_km2"])
flow = runoff * intensity * area_km2 / 3.6
design_flow = flow * float(inputs["factor"])
def _num(inputs: dict[str, Any], key: str) -> float | None:
value = inputs.get(key)
return None if value is None or value == "" else float(value)
n = inputs.get("n")
n = float(n) if n else PIPE_KINDS[inputs["pipe_kind"]]
diameter_m = float(inputs["diameter_mm"]) / 1000.0
props = section_props(inputs["section"], diameter_m)
slope_bed = 1.0 / float(inputs["slope_denominator"])
velocity = (1.0 / n) * props["radius"] ** (2.0 / 3.0) * math.sqrt(slope_bed)
capacity = props["area"] * velocity
def compute(inputs: dict[str, Any]) -> dict[str, Any]:
"""입력 → 계산서 값 전부(반올림 없음 · 화면이 자릿수를 정함).
빈칸(None)이 있으면 그 값에 기대는 칸만 None — 화면은 「—」 (69-6).
"""
length_m, height_m = _num(inputs, "length_m"), _num(inputs, "height_m")
r24, area_km2 = _num(inputs, "r24_mm"), _num(inputs, "area_km2")
slope_ground = tc_raw_hr = tc_hr = intensity = flow = design_flow = None
if length_m and height_m:
slope_ground = height_m / length_m
tc_raw_hr = 0.0663 * (length_m / 1000.0) ** 0.77 * slope_ground**-0.385
tc_hr = max(tc_raw_hr, float(inputs["tc_min_minutes"]) / 60.0)
if tc_hr and r24:
intensity = (r24 / 24.0) * (24.0 / tc_hr) ** MONONOBE_EXPONENT
if intensity is not None and area_km2:
flow = float(inputs["runoff"]) * intensity * area_km2 / 3.6
design_flow = flow * float(inputs["factor"])
n = _num(inputs, "n") or PIPE_KINDS[inputs["pipe_kind"]]
diameter_mm, slope_den = _num(inputs, "diameter_mm"), _num(inputs, "slope_denominator")
props = section_props(inputs["section"], diameter_mm / 1000.0) if diameter_mm else {}
slope_bed = 1.0 / slope_den if slope_den else None
velocity = capacity = None
if props and slope_bed:
velocity = (1.0 / n) * props["radius"] ** (2.0 / 3.0) * math.sqrt(slope_bed)
capacity = props["area"] * velocity
judged = capacity is not None and design_flow is not None
return {
"area_ha": area_km2 * 100.0,
"area_ha": area_km2 * 100.0 if area_km2 else None,
"r24_mm": r24,
"slope_ground": slope_ground,
"tc_raw_hr": tc_raw_hr,
"tc_hr": tc_hr,
"tc_floored": tc_hr > tc_raw_hr,
"tc_floored": tc_hr is not None and tc_hr > tc_raw_hr,
"intensity": intensity,
"flow": flow,
"design_flow": design_flow,
"n": n,
"section_area": props["area"],
"perimeter": props["perimeter"],
"radius": props["radius"],
"section_area": props.get("area"),
"perimeter": props.get("perimeter"),
"radius": props.get("radius"),
"slope_bed": slope_bed,
"velocity": velocity,
"capacity": capacity,
"ok": capacity >= design_flow,
"ratio_pct": capacity / design_flow * 100.0 if design_flow > 0 else None,
"ok": capacity >= design_flow if judged else None,
"ratio_pct": capacity / design_flow * 100.0 if judged and design_flow > 0 else None,
}
+9 -7
View File
@@ -17,20 +17,22 @@ SECTIONS = {"full": "만관", "arc240": "240° 유효단면", "eff70": "유효 7
#: 관 종류별 조도계수 n — 파형강관은 KDS 표에 없음(지금 배관 제안 0.024)
PIPE_KINDS = {"파형강관": 0.024, "흄관": 0.013, "콘크리트관": 0.015}
#: 처음 값 = 표준 값만(69-6) · 측점 고유 값(유역면적 · L · H · D · S)은 None(빈칸)
#: R24 · 지역은 공사지 자료(B08_Quantity_Hydro_Site) — 자료가 없을 때만 화면 입력
DEFAULTS: dict[str, Any] = {
"factor": 2.0,
"runoff": 0.80, # 유출계수 f — KDS 가파른 산지
"area_km2": 0.132,
"length_m": 480.0, # 산정에서 하천시점까지 거리
"height_m": 185.0, # 산정에서 하천시점까지 표고차
"area_km2": None,
"length_m": None, # 산정에서 하천시점까지 거리
"height_m": None, # 산정에서 하천시점까지 표고차
"tc_min_minutes": 5.0, # 최소 도달시간(KDS 2.4)
"r24_mm": 305.9,
"region": "울진",
"r24_mm": None,
"region": "",
"return_period": 100,
"pipe_kind": "파형강관",
"n": None, # None = 관 종류 값
"diameter_mm": 1200.0,
"slope_denominator": 20.0, # 하상경사 S = 1 / 이 값
"diameter_mm": None,
"slope_denominator": None, # 하상경사 S = 1 / 이 값
"section": "full",
}
+121
View File
@@ -0,0 +1,121 @@
"""B08 4탭 홍수량 계산서 — 공사지 지역 · 확률강우량 · 저장 파일 (PLAN 69-5 · 69-6).
읽기만: 전처리 강우량표(`B04_PreProcess/drainage/rainfall_table.json` · 관측소 확률강우량)
· 행정구역(`B04_PreProcess/processed/행정구역_*_bounds.geojson` · WGS84) · 프로젝트 등록 지역(DB).
배수유역 계산 코드는 부르지 않음 — 이미 저장된 파일만 읽음.
쓰기: B08 자기 파일 `B08_Quantity/quantities/hydro_inputs.json` 하나([저장] 때만).
"""
from __future__ import annotations
import json
import os
from pathlib import Path
from typing import Any
from shapely.geometry import Point, shape
RAINFALL_FILE = Path("B04_PreProcess") / "drainage" / "rainfall_table.json"
PROCESSED_DIR = Path("B04_PreProcess") / "processed"
EMD_FILE = "행정구역_읍면동_bounds.geojson"
SGG_FILE = "행정구역_시군구_bounds.geojson"
SAVED_FILE = Path("B08_Quantity") / "quantities" / "hydro_inputs.json"
#: 강우량표가 없을 때 재현기간 드롭다운 — 확률강우량 자료 표의 재현기간
FALLBACK_PERIODS = (2, 3, 5, 10, 20, 30, 50, 70, 80, 100, 200, 300, 500)
DAY_MINUTES = 1440
def _read_json(path: Path) -> dict[str, Any] | None:
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return None
return data if isinstance(data, dict) else None
def _features(path: Path) -> list[dict[str, Any]]:
data = _read_json(path) or {}
return [f for f in data.get("features") or [] if isinstance(f, dict)]
def _feature_at(path: Path, lon: float, lat: float) -> dict[str, Any] | None:
"""점이 드는 행정구역 속성 — 경계 파일은 WGS84."""
point = Point(lon, lat)
for feature in _features(path):
try:
if shape(feature["geometry"]).covers(point):
return feature.get("properties") or {}
except (KeyError, TypeError, ValueError):
continue
return None
def _region(root: Path, rain: dict[str, Any] | None, db_region: str | None) -> dict[str, str]:
"""공사지 지역 — 전처리 행정구역(노선 대표점이 드는 곳) > 시군구 하나뿐 > 프로젝트 등록 지역."""
lat, lon = (rain or {}).get("lat"), (rain or {}).get("lon")
if isinstance(lat, (int, float)) and isinstance(lon, (int, float)):
emd = _feature_at(root / PROCESSED_DIR / EMD_FILE, lon, lat)
sgg = _feature_at(root / PROCESSED_DIR / SGG_FILE, lon, lat)
name = (sgg or {}).get("sig_kor_nm") or ""
full = (emd or {}).get("full_nm") or (sgg or {}).get("full_nm") or ""
if name or full:
return {"name": name or full.split()[-1], "full": full, "source": "전처리 행정구역"}
sggs = _features(root / PROCESSED_DIR / SGG_FILE)
if len(sggs) == 1:
props = sggs[0].get("properties") or {}
return {
"name": props.get("sig_kor_nm") or "",
"full": props.get("full_nm") or "",
"source": "전처리 행정구역",
}
if db_region:
return {"name": db_region, "full": db_region, "source": "프로젝트 등록"}
return {"name": "", "full": "", "source": ""}
def _r24_table(rain: dict[str, Any] | None) -> dict[int, float]:
"""재현기간(년) → 24시간 확률강우량(mm)."""
table: dict[int, float] = {}
for row in (rain or {}).get("values") or []:
try:
if int(row["duration_min"]) == DAY_MINUTES:
table[int(row["return_period_yr"])] = float(row["depth_mm"])
except (KeyError, TypeError, ValueError):
continue
return table
def site_info(root: Path, db_region: str | None) -> dict[str, Any]:
"""화면 머리 — 지역 · 관측소 · 재현기간 목록 · 재현기간별 R24(없으면 빈 표)."""
rain = _read_json(root / RAINFALL_FILE)
r24 = _r24_table(rain)
station = (rain or {}).get("station") or {}
return {
"region": _region(root, rain, db_region),
"station": station.get("name") or "",
"periods": sorted(r24) or list(FALLBACK_PERIODS),
"r24": {str(k): v for k, v in sorted(r24.items())},
}
def site_r24(root: Path, return_period: float) -> float | None:
"""계산 때 서버가 정본으로 쓰는 R24 — 자료가 있으면 그 값 · 없으면 None(화면 입력)."""
return _r24_table(_read_json(root / RAINFALL_FILE)).get(int(return_period))
def read_saved(root: Path) -> dict[str, dict[str, Any]]:
data = _read_json(root / SAVED_FILE) or {}
stations = data.get("stations")
return stations if isinstance(stations, dict) else {}
def write_saved(root: Path, stations: dict[str, dict[str, Any]]) -> None:
"""원자적 쓰기 — 임시 파일에 쓴 뒤 바꿔 끼움."""
path = root / SAVED_FILE
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(".part")
payload = {"version": 1, "stations": stations}
tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=1), encoding="utf-8")
os.replace(tmp, path)
+45 -10
View File
@@ -1,9 +1,12 @@
"""B08 4탭 홍수량 계산서 길 (PLAN 62-2 · 62-3) — 읽기 · 계산만, 아무 파일에도 쓰지 않음.
"""B08 4탭 홍수량 계산서 길 (PLAN 62-2 · 62-3 · 69-5 · 69-6).
GET /api/projects/{id}/quantity/hydro 배수지점 측점(저장된 pipe_points) + 기본값 · 선택지
+ 공사지(지역 · 재현기간별 R24) + 저장한 입력
POST /api/projects/{id}/quantity/hydro/calc 화면 입력 → 계산서 값(B08_Quantity_Engine_Hydro)
PUT /api/projects/{id}/quantity/hydro [저장] — 측점별 입력을 B08 자기 파일에만 씀
측점 목록만 관 지점 정본에서 읽음 — 관경 · 관 종류 등 B05/B06 값은 잇지 않음(자기 기본값).
B05/B06 · 배수유역 파일에는 쓰지 않음.
"""
from __future__ import annotations
@@ -15,9 +18,11 @@ from uuid import UUID
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from B02_ProjRegister.B02_ProjRegister_Repository import get_project_by_id
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from B08_Quantity.B08_Quantity_Engine_Hydro import compute
from B08_Quantity.B08_Quantity_Hydro_Defaults import FACTORS, PIPE_KINDS, options
from B08_Quantity.B08_Quantity_Hydro_Site import read_saved, site_info, site_r24, write_saved
from common_util.common_util_drainage_pipes import pipe_points_path_in, read_pipe_points_file
from common_util.common_util_storage import resolve_stored_project_path
from config.config_db import get_db_pool
@@ -26,22 +31,30 @@ router = APIRouter(prefix="/api/projects", tags=["B08 Hydro"])
class HydroInputs(BaseModel):
"""빈칸(None) = 측점 고유 값을 아직 안 넣음 — 그 값에 기대는 계산 칸만 비움."""
factor: float = Field(gt=0)
runoff: float = Field(gt=0, le=1)
area_km2: float = Field(gt=0)
length_m: float = Field(gt=0)
height_m: float = Field(gt=0)
area_km2: float | None = Field(default=None, gt=0)
length_m: float | None = Field(default=None, gt=0)
height_m: float | None = Field(default=None, gt=0)
tc_min_minutes: float = Field(ge=0)
r24_mm: float = Field(gt=0)
r24_mm: float | None = Field(default=None, gt=0)
region: str = ""
return_period: float = Field(gt=0)
pipe_kind: Literal[tuple(PIPE_KINDS)] # type: ignore[valid-type]
n: float | None = Field(default=None, gt=0)
diameter_mm: float = Field(gt=0)
slope_denominator: float = Field(gt=0)
diameter_mm: float | None = Field(default=None, gt=0)
slope_denominator: float | None = Field(default=None, gt=0)
section: Literal["full", "arc240", "eff70"]
class HydroSave(BaseModel):
"""측점 키(chainage 소수 셋째 자리) → 그 측점 입력 전부."""
stations: dict[str, HydroInputs]
async def _project_root(project_id: UUID) -> Path:
try:
async with get_db_pool().acquire() as connection:
@@ -61,12 +74,34 @@ async def hydro_sheet(project_id: UUID) -> dict[str, Any]:
({"chainage_m": p.chainage_m, "facility": p.facility} for p in points),
key=lambda s: s["chainage_m"],
)
return {"stations": stations, **options()}
project = await get_project_by_id(str(project_id)) or {}
return {
"stations": stations,
**options(),
"site": site_info(root, project.get("region")),
"saved": read_saved(root),
}
@router.post("/{project_id}/quantity/hydro/calc")
async def hydro_calc(project_id: UUID, body: HydroInputs) -> dict[str, Any]:
await _project_root(project_id) # 없는 프로젝트는 404
root = await _project_root(project_id) # 없는 프로젝트는 404
if body.factor not in FACTORS:
raise HTTPException(status_code=400, detail=f"할증은 {FACTORS} 중 하나")
return compute(body.model_dump())
inputs = body.model_dump()
# 공사지 확률강우량이 있으면 그 값이 정본 — 화면 값을 받아 적지 않음
r24 = site_r24(root, body.return_period)
if r24 is not None:
inputs["r24_mm"] = r24
return compute(inputs)
@router.put("/{project_id}/quantity/hydro")
async def hydro_save(project_id: UUID, body: HydroSave) -> dict[str, Any]:
root = await _project_root(project_id)
for key, inputs in body.stations.items():
if inputs.factor not in FACTORS:
raise HTTPException(status_code=400, detail=f"{key}: 할증은 {FACTORS} 중 하나")
saved = {key: inputs.model_dump() for key, inputs in body.stations.items()}
write_saved(root, saved)
return {"saved": saved}
+106 -153
View File
@@ -1,36 +1,41 @@
/* B08 4탭 홍수량 계산서(PLAN 62-2) — 인쇄 같은 흰 종이. 종이 안 색은 테마와 상관없이 고정
(어두움 테마에서도 같은 종이로 읽힘): 입력 빨강 · 계산 파랑 · 주요 입력 노랑 바탕. */
/* B08 4탭 홍수량 계산서(PLAN 62-2 · 69-4) — 공용 카드 · 입력 · 드롭다운 위에 줄 배치만.
색은 테마 토큰만(밝음 · 어두움 같이) · 테두리는 입력 · 선택 칸(공용 ui-input · ui-select)만
· 굵게 + 붉은 계열은 중요 결과(.is-key)만. */
/* 오른쪽 「진행단계」 떠 있는 판에 안 가리게 */
.b08-hydro__toolbar,
.b08-hydro__sheet {
max-width: 1000px;
}
.b08-hydro__toolbar {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--spacing-16);
margin-bottom: var(--spacing-16);
}
.b08-hydro__tabs {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-4, 4px);
margin-bottom: var(--spacing-12, 12px);
gap: var(--spacing-4);
}
.b08-hydro__paper {
--paper: #ffffff;
--ink: #111111;
--input: #d40000;
--calc: #0033cc;
--key: #ffff00;
--ha: #f8cbad;
--rule: #9aa0a6;
/* 오른쪽 「진행단계」 떠 있는 판에 안 가리게 */
max-width: 1000px;
padding: 20px 24px 12px;
background: var(--paper);
color: var(--ink);
border: 1px solid var(--rule);
box-shadow: 0 2px 10px rgb(0 0 0 / 25%);
font-family: "Malgun Gothic", "Noto Sans KR", sans-serif;
font-size: 14px;
.b08-hydro__toolbar > .ui-btn {
flex: 0 0 auto;
}
.b08-hydro__sheet {
color: var(--color-text-body);
font-size: var(--text-body-sm);
}
.b08-hydro__body {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 24px;
align-items: start;
gap: var(--spacing-16);
}
@media (max-width: 960px) {
@@ -39,95 +44,51 @@
}
}
.b08-hydro__h1 {
margin: 0 0 8px;
font-size: 19px;
font-weight: 700;
color: var(--ink);
.b08-hydro__card .ui-card__title {
margin-bottom: 0;
}
.b08-hydro__card .ui-card__body {
gap: var(--spacing-8);
}
.b08-hydro__h2 {
margin: 16px 0 6px;
font-size: 14px;
font-weight: 700;
color: var(--ink);
}
.b08-hydro__station mark {
padding: 0 10px;
background: var(--key);
color: var(--ink);
font-weight: 700;
}
.b08-hydro__note {
margin: 4px 0 4px 16px;
color: var(--ink);
margin: var(--spacing-8) 0 0;
padding-top: var(--spacing-8);
border-top: 1px solid var(--color-border);
font-size: var(--text-body-sm);
font-weight: var(--font-weight-bold);
color: var(--color-text);
}
.b08-hydro__formula {
margin: 6px 0 6px 16px;
margin: 0 0 0 var(--spacing-8);
font-family: "Cambria Math", "Times New Roman", serif;
font-size: 15px;
color: var(--ink);
color: var(--color-text-secondary);
}
.b08-hydro__row {
display: grid;
grid-template-columns: minmax(0, 1fr) 96px 110px;
grid-template-columns: minmax(0, 1fr) 112px minmax(0, 0.8fr);
align-items: center;
gap: 8px;
min-height: 26px;
padding-left: 16px;
gap: var(--spacing-8);
min-height: var(--control-height-sm);
}
.b08-hydro__row--f {
grid-template-columns: max-content minmax(0, 1fr) 96px;
padding-left: 0;
}
.b08-hydro__hint {
color: #666666;
font-size: 12px;
}
.b08-hydro__row.is-key .b08-hydro__label,
.b08-hydro__row.is-key .b08-hydro__in {
background: var(--key);
grid-template-columns: max-content minmax(0, 1fr) 112px;
}
.b08-hydro__label {
margin: 0;
color: var(--ink);
color: var(--color-text-secondary);
}
.b08-hydro__out,
.b08-hydro__in {
box-sizing: border-box;
width: 96px;
padding: 2px 6px;
border: 1px solid #3355cc;
background: var(--paper);
text-align: right;
font: inherit;
}
.b08-hydro__out {
color: var(--calc);
}
.b08-hydro__in {
color: var(--input);
}
.b08-hydro__in--text {
text-align: center;
}
.b08-hydro__select {
width: auto;
text-align: left;
color: var(--input);
.b08-hydro__label--select {
display: flex;
align-items: center;
gap: var(--spacing-8);
}
.b08-hydro__row .b08-hydro__formula {
@@ -135,67 +96,67 @@
font-size: 13px;
}
.b08-hydro__ha {
.b08-hydro__hint {
color: var(--color-text-muted);
font-size: var(--text-caption);
}
/* 계산 칸 — 테두리 없이 글자만 · 숫자 자리 맞춤 */
.b08-hydro__out,
.b08-hydro__value {
padding: 0 var(--spacing-8);
color: var(--color-text-body);
font-variant-numeric: tabular-nums;
text-align: right;
}
.b08-hydro__value {
text-align: left;
font-weight: var(--font-weight-medium);
}
.b08-hydro__in {
width: 100%;
min-width: 0;
box-sizing: border-box;
text-align: right;
font-variant-numeric: tabular-nums;
}
/* 줄에 바로 놓인 드롭다운은 값 칸 + 덧칸 — 선택지 글이 안 잘리게 */
.b08-hydro__row > .b08-hydro__select {
grid-column: 2 / 4;
justify-self: start;
width: auto;
border: none;
background: var(--ha);
}
.b08-hydro__tcnote {
width: auto;
border: none;
font-size: 12px;
/* 식 칸에 놓인 입력(하상경사 1 / ☐)은 값 칸 폭 */
.b08-hydro__row--f > .b08-hydro__in:nth-child(2) {
justify-self: start;
width: 112px;
}
.b08-hydro__place {
display: grid;
grid-template-columns: 96px 96px;
margin: 6px 0 0 16px;
border: 1px solid var(--ink);
width: max-content;
}
.b08-hydro__place-head {
padding: 2px 6px;
text-align: center;
border-bottom: 1px solid var(--ink);
}
.b08-hydro__place .b08-hydro__in {
border: none;
color: var(--calc);
}
.b08-hydro__box {
padding: 12px 18px;
border: 2px solid var(--ink);
}
.b08-hydro__kind {
margin-bottom: 10px;
font-weight: 700;
}
.b08-hydro__row--wide {
padding-left: 0;
font-weight: 700;
/* 중요 결과 — Qd · 유출량 · 판정 · 유출율 */
.is-key.b08-hydro__out,
.is-key.b08-hydro__verdict {
color: var(--color-danger);
font-weight: var(--font-weight-bold);
}
.b08-hydro__figure {
display: grid;
grid-template-columns: minmax(0, 1fr);
justify-items: center;
gap: 4px;
gap: var(--spacing-4);
}
.b08-hydro__dims {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
justify-self: stretch;
}
.b08-hydro__dims .b08-hydro__row {
padding-left: 0;
}
.b08-hydro__pipe {
width: 170px;
height: 145px;
@@ -203,46 +164,38 @@
.b08-hydro__pipe text {
font-size: 12px;
fill: var(--ink);
fill: var(--color-text-secondary);
}
.b08-hydro__water {
fill: #cfe3ff;
fill: color-mix(in srgb, var(--color-chart-0) 30%, transparent);
}
.b08-hydro__wall {
fill: none;
stroke: var(--ink);
stroke: var(--color-text-body);
stroke-width: 2;
}
.b08-hydro__line {
fill: none;
stroke: var(--ink);
stroke: var(--color-text-secondary);
stroke-width: 1;
}
.b08-hydro__verdict {
width: max-content;
margin: 12px auto;
padding: 6px 40px;
border: 2px dashed var(--ink);
color: var(--input);
font-weight: 700;
padding: var(--spacing-8) 0;
color: var(--color-text-muted);
font-size: var(--text-body);
text-align: center;
}
.b08-hydro__verdict.is-ng {
background: #ffe0e0;
background: color-mix(in srgb, var(--color-danger) 12%, transparent);
border-radius: var(--radius-lg);
}
.b08-hydro__status {
margin: 8px 0 0;
color: var(--input);
}
.b08-hydro__page {
margin-top: 8px;
text-align: center;
color: #666666;
font-size: 12px;
margin: var(--spacing-8) 0 0;
color: var(--color-danger);
}
+57 -12
View File
@@ -1,16 +1,22 @@
/* =============================================================================
* B08_Quantity_UI_Hydro.ts
* 4탭 수리집수면적표 — 배수지점 측점마다 보조 탭 · 한 장 = 홍수량 계산서(PLAN 62-2 · 62-3).
* 4탭 수리집수면적표 — 배수지점 측점마다 보조 탭 · 한 장 = 홍수량 계산서(PLAN 62-2 · 69-4~6).
*
* 측점 목록만 저장된 관 지점(pipe_points)에서 · 입력은 이 탭 자기 기본값(B05/B06 값과 안 이음)
* 계산 = 서버 단독(POST …/quantity/hydro/calc · 최신 응답만 채택) · 입력은 캐시(sessionStorage)만
* 어디에도 쓰지 않음 — 배수유역 · B05/B06 파일 그대로.
* 측점 목록만 저장된 관 지점(pipe_points)에서 · 입력은 이 탭 자기 값(B05/B06 값과 안 이음)
* 처음 값 = 표준 값 · 측점 고유 값은 빈칸 · 지역 · R24 = 공사지 자료(서버)
* 조작 = 캐시(sessionStorage) · [저장] = B08 자기 파일(PUT …/quantity/hydro) · 뒤 캐시 비움
* 계산 = 서버 단독(POST …/quantity/hydro/calc · 최신 응답만 채택)
* ========================================================================== */
import "./B08_Quantity_UI_Hydro.css";
import { API_BASE_URL } from "@config/config_frontend";
import { createButton } from "@ui/ui_template_elements";
import { readState, writeState } from "../A00_Common/b_page_state";
import {
createButton,
hideLoadingOverlay,
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
import { clearState, readState, writeState } from "../A00_Common/b_page_state";
import { stationLabel } from "../common_util/common_util_station";
import { stationInterval } from "./B08_Quantity_UI_Summary";
import {
@@ -26,6 +32,7 @@ interface HydroStation {
}
type Drafts = Record<string, Partial<HydroInputs>>;
type HydroPayload = { stations: HydroStation[]; saved: Drafts } & HydroOptions;
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
const response = await fetch(`${API_BASE_URL}${path}`, {
@@ -52,11 +59,11 @@ const keyOf = (station: HydroStation): string => station.chainage_m.toFixed(3);
export async function renderHydro(host: HTMLElement, projectId: string): Promise<void> {
host.replaceChildren(note("홍수량 계산서를 불러오는 중…"));
let sheet: { stations: HydroStation[] } & HydroOptions;
let sheet: HydroPayload;
let interval = 20;
try {
[sheet, interval] = await Promise.all([
request<{ stations: HydroStation[] } & HydroOptions>(`/projects/${projectId}/quantity/hydro`),
request<HydroPayload>(`/projects/${projectId}/quantity/hydro`),
stationInterval(projectId).catch(() => 20),
]);
} catch (error) {
@@ -70,15 +77,33 @@ export async function renderHydro(host: HTMLElement, projectId: string): Promise
return;
}
const drafts: Drafts = readState<Drafts>("b08-hydro", projectId) ?? {};
let drafts: Drafts = readState<Drafts>("b08-hydro", projectId) ?? {};
const inputsOf = (station: HydroStation): HydroInputs =>
({
...sheet.defaults,
...sheet.saved[keyOf(station)],
...drafts[keyOf(station)],
}) as HydroInputs;
const tabs = document.createElement("div");
tabs.className = "b08-hydro__tabs";
tabs.setAttribute("role", "tablist");
const save = createButton({
label: "저장",
variant: "filled",
onClick: () => void saveAll(),
});
save.dataset.b08HydroSave = "";
const toolbar = document.createElement("div");
toolbar.className = "b08-hydro__toolbar";
toolbar.append(tabs, save);
const page = document.createElement("div");
host.replaceChildren(tabs, page);
host.replaceChildren(toolbar, page);
let seq = 0;
let openIndex = 0;
const open = (index: number): void => {
openIndex = index;
const station = sheet.stations[index];
tabs.replaceChildren(
...sheet.stations.map((s, i) => {
@@ -98,7 +123,6 @@ export async function renderHydro(host: HTMLElement, projectId: string): Promise
const draw = (inputs: HydroInputs): void => {
const view = buildHydroSheet(
stationLabel(station.chainage_m, interval),
{ index: index + 1, total: sheet.stations.length },
inputs,
sheet,
(next) => {
@@ -112,7 +136,7 @@ export async function renderHydro(host: HTMLElement, projectId: string): Promise
page.replaceChildren(view.root);
void calc(inputs, view.show);
};
draw({ ...sheet.defaults, ...drafts[keyOf(station)] } as HydroInputs);
draw(inputsOf(station));
};
const calc = async (
@@ -131,5 +155,26 @@ export async function renderHydro(host: HTMLElement, projectId: string): Promise
}
};
/** [저장] — 측점 전부의 지금 입력을 B08 자기 파일로 · 성공하면 캐시를 비우고 저장본으로 다시 엶 */
const saveAll = async (): Promise<void> => {
const stations = Object.fromEntries(sheet.stations.map((s) => [keyOf(s), inputsOf(s)]));
showLoadingOverlay();
try {
const body = await request<{ saved: Drafts }>(`/projects/${projectId}/quantity/hydro`, {
method: "PUT",
body: JSON.stringify({ stations }),
});
sheet.saved = body.saved;
drafts = {};
clearState("b08-hydro", projectId);
showToast("수리집수면적표를 저장했습니다.", "success");
open(openIndex);
} catch (error) {
showToast(`저장하지 못했습니다 — ${String(error)}`, "error");
} finally {
hideLoadingOverlay();
}
};
open(0);
}
+193 -154
View File
@@ -1,56 +1,69 @@
/* =============================================================================
* B08_Quantity_UI_Hydro_Sheet.ts
* 홍수량 계산서 한 장(PLAN 62-2) — 견본(울진 2공구 수리계산서) 모양.
* 홍수량 계산서 한 장(PLAN 62-2 · 69-4) — 우리 양식(공용 카드 · 입력 · 드롭다운 · 테마 색).
*
* 왼쪽 「1. 홍수량 계산」 1-1 합리식 · 1-2 도달시간(Kirpich) · 1-3 강우강도
* 오른쪽 「2. 단면검토」 관 단면 그림 · 단면적 · 윤변 · 경심 · 경사 · n · 유속 · 유출량 · 판정
* 입력값 빨강 · 계산값 파랑 · 주요 입력 노랑 바탕 · 흰 종이(어두움 테마에서도 같음).
* 왼쪽 카드 「1. 홍수량 계산」 1-1 합리식 · 1-2 도달시간(Kirpich) · 1-3 강우강도
* 오른쪽 카드 「2. 단면 검토」 관 단면 그림 · 단면적 · 윤변 · 경심 · 경사 · n · 유속 · 유출량 · 판정
* 테두리는 입력 · 선택 칸만(공용 ui-input · ui-select) · 계산 칸은 글자만
* 굵게 + 붉은 계열은 중요 결과(Qd · 유출량 · 판정 · 유출율)만 · 빈칸에 기대는 계산 칸은 「—」.
*
* 입력 칸은 한 번 만들고 계산 결과만 제자리에 바꿈 — 고치는 중 칸 포커스를 잃지 않게.
* ========================================================================== */
import { createCard, createInputField, createSelectField } from "@ui/ui_template_elements";
export interface HydroInputs {
factor: number;
runoff: number;
area_km2: number;
length_m: number;
height_m: number;
area_km2: number | null;
length_m: number | null;
height_m: number | null;
tc_min_minutes: number;
r24_mm: number;
r24_mm: number | null;
region: string;
return_period: number;
pipe_kind: string;
n: number | null;
diameter_mm: number;
slope_denominator: number;
diameter_mm: number | null;
slope_denominator: number | null;
section: "full" | "arc240" | "eff70";
}
export interface HydroResult {
area_ha: number;
slope_ground: number;
tc_raw_hr: number;
tc_hr: number;
area_ha: number | null;
r24_mm: number | null;
slope_ground: number | null;
tc_raw_hr: number | null;
tc_hr: number | null;
tc_floored: boolean;
intensity: number;
flow: number;
design_flow: number;
intensity: number | null;
flow: number | null;
design_flow: number | null;
n: number;
section_area: number;
perimeter: number;
radius: number;
slope_bed: number;
velocity: number;
capacity: number;
ok: boolean;
section_area: number | null;
perimeter: number | null;
radius: number | null;
slope_bed: number | null;
velocity: number | null;
capacity: number | null;
ok: boolean | null;
ratio_pct: number | null;
}
/** 공사지 — 지역(전처리 행정구역 · 프로젝트 등록) · 관측소 · 재현기간별 24시간 확률강우량 */
export interface HydroSite {
region: { name: string; full: string; source: string };
station: string;
periods: number[];
r24: Record<string, number>;
}
export interface HydroOptions {
defaults: HydroInputs;
factors: number[];
sections: Record<HydroInputs["section"], string>;
pipe_kinds: Record<string, number>;
site: HydroSite;
}
export interface HydroSheet {
@@ -61,8 +74,20 @@ export interface HydroSheet {
type Key = keyof HydroInputs;
const fmt = (value: number | null | undefined, digits: number): string =>
value == null || !Number.isFinite(value) ? "-" : value.toFixed(digits);
/** 비워 둘 수 있는 칸 — 측점 고유 값 · n(관 종류 값) · R24(공사지 자료가 없을 때 입력) */
const NULLABLE = new Set<Key>([
"area_km2",
"length_m",
"height_m",
"diameter_mm",
"slope_denominator",
"n",
"r24_mm",
]);
const DASH = "—";
const fmt = (value: number | null | undefined, digits: number, unit = ""): string =>
value == null || !Number.isFinite(value) ? DASH : `${value.toFixed(digits)}${unit}`;
function node<K extends keyof HTMLElementTagNameMap>(
tag: K,
@@ -87,7 +112,7 @@ function formula(text: string, className = "b08-hydro__formula"): HTMLElement {
return box;
}
/** 관 단면 그림 — 단면 가정대로 물 찬 자리를 칠함. */
/** 관 단면 그림 — 단면 가정대로 물 찬 자리를 칠함(색은 테마 토큰 · CSS). */
function pipeFigure(section: HydroInputs["section"]): SVGSVGElement {
const ns = "http://www.w3.org/2000/svg";
const svg = document.createElementNS(ns, "svg");
@@ -126,29 +151,29 @@ const SECTION_TEXT: Record<
HydroInputs["section"],
{ area: string; perimeter: string; radius: string }
> = {
full: { area: "A = π D^2^ / 4 = 0.785 D^2^", perimeter: "p = π D", radius: "R = A / P = D / 4" },
full: { area: "A = π D^2^ / 4 = 0.785 D^2^", perimeter: "P = π D", radius: "R = A / P = D / 4" },
arc240: {
area: "A = π D^2^/4 × 240/360 + (D/2)^2^ × sin60 × cos60 = 0.632 D^2^",
perimeter: "p = π D × 240/360",
perimeter: "P = π D × 240/360",
radius: "R = A / P",
},
eff70: {
area: "A = 0.7 × π D^2^ / 4",
perimeter: "p = π D",
perimeter: "P = π D",
radius: "R = D / 4 (만관 경심)",
},
};
export function buildHydroSheet(
stationText: string,
page: { index: number; total: number },
inputs: HydroInputs,
options: HydroOptions,
onChange: (next: HydroInputs) => void,
): HydroSheet {
const site = options.site;
const out = new Map<string, HTMLElement>();
const outCell = (id: string, className = ""): HTMLElement => {
const cell = node("span", `b08-hydro__out ${className}`.trim());
const outCell = (id: string, key = false): HTMLElement => {
const cell = node("span", `b08-hydro__out${key ? " is-key" : ""}`, DASH);
out.set(id, cell);
return cell;
};
@@ -157,165 +182,177 @@ export function buildHydroSheet(
current = { ...current, [key]: value } as HydroInputs;
onChange(current);
};
const numberInput = (key: Key, step = "any"): HTMLInputElement => {
const input = node("input", "b08-hydro__in");
input.type = "number";
input.step = step;
const numberInput = (key: Key, placeholder = ""): HTMLInputElement => {
const { input } = createInputField({ type: "number", compact: true, placeholder });
input.classList.add("b08-hydro__in");
input.step = "any";
input.dataset.b08Hydro = key;
const value = inputs[key];
input.value = value == null ? "" : String(value);
input.addEventListener("change", () => {
// n 은 비우면 관 종류 값 · 최소 도달시간은 0 까지 · 그 밖은 0 보다 커야 함 — 아니면 되돌림
// 빈칸은 NULLABLE 만 · 최소 도달시간은 0 까지 · 그 밖은 0 보다 커야 함 — 아니면 되돌림
const parsed = input.value === "" ? null : Number(input.value);
const valid =
parsed === null
? key === "n"
? NULLABLE.has(key)
: Number.isFinite(parsed) && (key === "tc_min_minutes" ? parsed >= 0 : parsed > 0);
if (valid) commit(key, parsed);
else input.value = current[key] == null ? "" : String(current[key]);
});
return input;
};
const textInput = (key: Key): HTMLInputElement => {
const input = node("input", "b08-hydro__in b08-hydro__in--text");
input.value = String(inputs[key] ?? "");
input.dataset.b08Hydro = key;
input.addEventListener("change", () => commit(key, input.value));
return input;
};
const select = (key: Key, choices: Array<[string, string]>): HTMLSelectElement => {
const el = node("select", "b08-hydro__in b08-hydro__select");
const select = (
key: Key,
choices: Array<[string, string]>,
toValue: (raw: string) => unknown = (raw) => raw,
): HTMLSelectElement => {
const { select: el } = createSelectField({
compact: true,
options: choices.map(([value, text]) => ({ value, text })),
value: String(inputs[key]),
});
el.classList.add("b08-hydro__select");
el.dataset.b08Hydro = key;
for (const [value, label] of choices) el.append(new Option(label, value));
el.value = String(inputs[key]);
el.addEventListener("change", () =>
commit(key, key === "factor" ? Number(el.value) : el.value),
);
el.addEventListener("change", () => commit(key, toValue(el.value)));
return el;
};
/** 표 줄 — 이름 · 값 칸(입력 또는 계산) · 덧칸 */
const row = (label: string, value: HTMLElement, extra?: HTMLElement, key = false) => {
const line = node("div", `b08-hydro__row${key ? " is-key" : ""}`);
line.append(node("span", "b08-hydro__label", label), value, extra ?? node("span"));
const row = (label: string | HTMLElement, value: HTMLElement, extra?: HTMLElement) => {
const line = node("div", "b08-hydro__row");
const name = typeof label === "string" ? node("span", "b08-hydro__label", label) : label;
line.append(name, value);
if (extra) line.append(extra);
return line;
};
/** 오른쪽 줄 — 이름 · 식(또는 입력) · 값 칸 */
const rowF = (label: string, middle: HTMLElement | null, value: HTMLElement, key = false) => {
const line = node("div", `b08-hydro__row b08-hydro__row--f${key ? " is-key" : ""}`);
/** 식 줄 — 이름 · 식 · 값 칸 */
const rowF = (label: string, middle: HTMLElement | null, value: HTMLElement) => {
const line = node("div", "b08-hydro__row b08-hydro__row--f");
line.append(node("span", "b08-hydro__label", label), middle ?? node("span"), value);
return line;
};
const sub = (text: string): HTMLElement => node("h4", "b08-hydro__h2", text);
const hint = (text: string): HTMLElement => node("span", "b08-hydro__hint", text);
// ── 왼쪽 1. 홍수량 계산 ──
const left = node("section", "b08-hydro__col");
const station = node("div", "b08-hydro__station");
station.append("측점 : ", node("mark", "", stationText));
const factor = select(
"factor",
options.factors.map((f) => [String(f), f.toFixed(1)]),
options.factors.map((f) => [String(f), `× ${f.toFixed(1)}`]),
Number,
);
const qdLabel = node("span", "b08-hydro__label");
qdLabel.append("Qd : 홍수량 × ", factor);
const qdRow = node("div", "b08-hydro__row");
qdRow.append(qdLabel, outCell("design_flow"));
left.append(
node("h3", "b08-hydro__h1", "1. 홍 수 량 계 산"),
station,
node("h4", "b08-hydro__h2", "1-1. Rational Method 공식"),
formula("Q = 1/3.6 × f × rt × A"),
qdRow,
row("Q : 홍수량 ㎥/sec", outCell("flow")),
row("f : 유출계수", numberInput("runoff")),
row("rt : 평균시간 강우량 mm/hr", outCell("intensity")),
row("A : 유역면적 ㎢", numberInput("area_km2"), outCell("area_ha", "b08-hydro__ha"), true),
node("h4", "b08-hydro__h2", "1-2. 홍수도달시간"),
node("p", "b08-hydro__note", "산지부 (Kirpich 공식)"),
formula("T = 0.0663 × L^0.77^ × s^-0.385^ (L : ㎞ · T : hr)"),
row("T : 홍수도달시간 (hr)", outCell("tc_hr"), outCell("tc_note", "b08-hydro__tcnote")),
row("L : 산정에서 하천시점까지 거리 (m)", numberInput("length_m"), undefined, true),
row("s : 지표면경사 (H/L)", outCell("slope_ground")),
row("H : 산정에서 하천시점까지 표고차 (m)", numberInput("height_m"), undefined, true),
row("최소 도달시간 (분)", numberInput("tc_min_minutes")),
node("h4", "b08-hydro__h2", "1-3. 강우강도 rt의 결정"),
);
const rain = node("div", "b08-hydro__rain");
const r24 = node("div", "b08-hydro__row is-key");
r24.append(formula("R_24_ : 확률강우량 (mm/day)", "b08-hydro__label"), numberInput("r24_mm"));
const place = node("div", "b08-hydro__place");
place.append(
node("span", "b08-hydro__place-head", "지 역"),
node("span", "b08-hydro__place-head", "재현기간"),
textInput("region"),
numberInput("return_period"),
);
const rtRow = node("div", "b08-hydro__row");
rtRow.append(formula("rt = (R_24_/24) × (24/T)^0.557^"), outCell("intensity2"));
rain.append(rtRow, r24, place);
left.append(
rain,
node("p", "b08-hydro__note", "(확률강우량 — 기본값 · 지역 관측소 값으로 고침)"),
const r24Site = (period: number): number | undefined => site.r24[String(period)];
const hasRain = Object.keys(site.r24).length > 0;
const r24Value: HTMLElement = hasRain ? outCell("r24") : numberInput("r24_mm", "입력");
const periodSelect = select(
"return_period",
site.periods.map((p) => [String(p), `${p} 년`]),
Number,
);
periodSelect.addEventListener("change", () => {
// 공사지 자료 값은 바로 보임 — 계산은 서버가 같은 표로 다시 함
if (hasRain) out.get("r24")!.textContent = fmt(r24Site(current.return_period), 1);
});
const region: HTMLElement = site.region.name
? node("span", "b08-hydro__value", site.region.name)
: (() => {
const { input } = createInputField({ compact: true, placeholder: "지역 입력" });
input.classList.add("b08-hydro__in");
input.dataset.b08Hydro = "region";
input.value = inputs.region;
input.addEventListener("change", () => commit("region", input.value.trim()));
return input;
})();
if (site.region.name) region.dataset.b08Hydro = "region";
const regionNote = site.region.full
? `${site.region.full} · ${site.region.source}`
: "공사지 위치 자료 없음";
const rainNote = hasRain
? `${site.station ? `${site.station} 관측소 ` : ""}확률강우량(전처리 자료)`
: "강우 자료 없음 — 직접 입력";
// ── 오른쪽 2. 단면검토 ──
const right = node("section", "b08-hydro__col");
const box = node("div", "b08-hydro__box");
const left = createCard({
title: "1. 홍수량 계산",
body: [
sub("1-1. 합리식 (Rational Method)"),
formula("Q = 1/3.6 × f × rt × A"),
row(
(() => {
const label = node("span", "b08-hydro__label b08-hydro__label--select");
label.append("Qd : 설계 홍수량 (㎥/s) = Q ", factor);
return label;
})(),
outCell("design_flow", true),
),
row("Q : 홍수량 (㎥/s)", outCell("flow")),
row("f : 유출계수", numberInput("runoff")),
row("rt : 평균 강우강도 (mm/hr)", outCell("intensity")),
row("A : 유역면적 (㎢)", numberInput("area_km2", "입력"), outCell("area_ha")),
sub("1-2. 홍수 도달시간 — 산지부 (Kirpich)"),
formula("T = 0.0663 × L^0.77^ × s^-0.385^ (L : ㎞ · T : hr)"),
row("T : 홍수 도달시간 (hr)", outCell("tc_hr"), outCell("tc_note")),
row("L : 산정에서 하천시점까지 거리 (m)", numberInput("length_m", "입력")),
row("H : 산정에서 하천시점까지 표고차 (m)", numberInput("height_m", "입력")),
row("s : 지표면 경사 (H / L)", outCell("slope_ground")),
row("최소 도달시간 (분)", numberInput("tc_min_minutes")),
sub("1-3. 강우강도 rt"),
formula("rt = (R_24_ / 24) × (24 / T)^0.557^"),
row("지역", region, hint(regionNote)),
row("재현기간", periodSelect),
row(formula("R_24_ : 확률강우량 (mm/day)", "b08-hydro__label"), r24Value, hint(rainNote)),
],
});
left.classList.add("b08-hydro__card");
// ── 오른쪽 2. 단면 검토 ──
const kinds = Object.entries(options.pipe_kinds).map(
([kind, n]) => [kind, `${kind} (n ${n})`] as [string, string],
);
const kindLine = node("div", "b08-hydro__kind");
kindLine.append(
"- ",
select("pipe_kind", kinds),
" · ",
select("section", Object.entries(options.sections) as Array<[string, string]>),
);
const total = node("div", "b08-hydro__row b08-hydro__row--wide");
total.append(node("span", "b08-hydro__label", "1. 총 홍수량 Qd (㎥/s)"), outCell("design_flow2"));
const texts = SECTION_TEXT[inputs.section];
const nInput = numberInput("n", String(options.pipe_kinds[inputs.pipe_kind] ?? ""));
const dims = node("div", "b08-hydro__dims");
const nInput = numberInput("n");
nInput.placeholder = String(options.pipe_kinds[inputs.pipe_kind] ?? "");
dims.append(
rowF("단 면 : D (mm)", null, numberInput("diameter_mm"), true),
rowF("단면적 :", formula(texts.area), outCell("section_area")),
rowF("윤 변 :", formula(texts.perimeter), outCell("perimeter")),
rowF("경 심 :", formula(texts.radius), outCell("radius")),
rowF("하상경사 : S = 1 /", numberInput("slope_denominator"), outCell("slope_pct")),
rowF("n", node("span", "b08-hydro__hint", "비우면 관 종류 값"), nInput),
rowF("단면 D (mm)", null, numberInput("diameter_mm", "입력")),
rowF("단면적", formula(texts.area), outCell("section_area")),
rowF("윤변", formula(texts.perimeter), outCell("perimeter")),
rowF("경심", formula(texts.radius), outCell("radius")),
rowF("하상경사 S = 1 /", numberInput("slope_denominator", "입력"), outCell("slope_pct")),
rowF("조도계수 n", hint("비우면 관 종류 값"), nInput),
);
const figure = node("div", "b08-hydro__figure");
figure.append(pipeFigure(inputs.section), dims);
const verdict = node("div", "b08-hydro__verdict");
out.set("verdict", verdict);
box.append(
kindLine,
total,
node("div", "b08-hydro__h2", "2. 유 출 량"),
figure,
rowF("- 유 속 :", formula("V = 1/n × R^2/3^ × S^1/2^ (m/sec)"), outCell("velocity")),
rowF("- 유출량 :", formula("Q = A × V (㎥/sec)"), outCell("capacity")),
verdict,
rowF("- 유출율 (%)", formula("Q / Qd × 100"), outCell("ratio_pct")),
);
right.append(node("h3", "b08-hydro__h1", "2. 단면검토"), box);
const verdict = node("div", "b08-hydro__verdict", DASH);
verdict.dataset.b08HydroVerdict = "";
const right = createCard({
title: "2. 단면 검토",
body: [
row("관 종류", select("pipe_kind", kinds)),
row(
"단면 가정",
select("section", Object.entries(options.sections) as Array<[string, string]>),
),
row("총 홍수량 Qd (㎥/s)", outCell("design_flow2", true)),
sub("유출량"),
figure,
rowF("유속 (m/s)", formula("V = 1/n × R^2/3^ × S^1/2^"), outCell("velocity")),
rowF("유출량 (㎥/s)", formula("Q = A × V"), outCell("capacity", true)),
verdict,
rowF("유출율 (%)", formula("Q / Qd × 100"), outCell("ratio_pct", true)),
],
});
right.classList.add("b08-hydro__card");
const status = node("p", "b08-hydro__status");
const paper = node("article", "b08-hydro__paper");
paper.dataset.b08HydroSheet = stationText;
const sheet = node("div", "b08-hydro__sheet");
sheet.dataset.b08HydroSheet = stationText;
const body = node("div", "b08-hydro__body");
body.append(left, right);
paper.append(
body,
status,
node("footer", "b08-hydro__page", `— ${page.index} / ${page.total} 쪽 —`),
);
sheet.append(body, status);
const set = (id: string, text: string): void => {
const cell = out.get(id);
if (cell) cell.textContent = text;
};
return {
root: paper,
root: sheet,
show(result, message = "") {
status.textContent = message;
const r = result;
@@ -323,27 +360,29 @@ export function buildHydroSheet(
set("design_flow2", fmt(r?.design_flow, 2));
set("flow", fmt(r?.flow, 2));
set("intensity", fmt(r?.intensity, 2));
set("intensity2", fmt(r?.intensity, 2));
set("area_ha", r ? `${fmt(r.area_ha, 1)} ha` : "-");
set("area_ha", fmt(r?.area_ha, 1, " ha"));
set("r24", fmt(r?.r24_mm ?? r24Site(current.return_period), 1));
set("tc_hr", fmt(r?.tc_hr, 3));
set(
"tc_note",
r?.tc_floored
r?.tc_floored && r.tc_raw_hr != null
? `Kirpich ${fmt(r.tc_raw_hr * 60, 1)}분 → 최소 ${current.tc_min_minutes}분`
: "",
);
set("slope_ground", fmt(r?.slope_ground, 3));
set("section_area", r ? `${fmt(r.section_area, 3)} ㎡` : "-");
set("perimeter", r ? `${fmt(r.perimeter, 3)} m` : "-");
set("section_area", fmt(r?.section_area, 3, " ㎡"));
set("perimeter", fmt(r?.perimeter, 3, " m"));
set("radius", fmt(r?.radius, 3));
set("slope_pct", r ? `${fmt(r.slope_bed * 100, 2)} %` : "-");
set("slope_pct", fmt(r?.slope_bed == null ? null : r.slope_bed * 100, 2, " %"));
set("velocity", fmt(r?.velocity, 3));
set("capacity", fmt(r?.capacity, 3));
set("ratio_pct", fmt(r?.ratio_pct, 0));
verdict.classList.toggle("is-ng", !!r && !r.ok);
verdict.textContent = r
? `${fmt(r.capacity, 3)}㎥/s ${r.ok ? ">" : "<"} Qd = ${fmt(r.design_flow, 2)}㎥/s - ${r.ok ? "O K" : "N G"} -`
: "-";
const judged = r != null && r.ok != null;
verdict.classList.toggle("is-key", judged);
verdict.classList.toggle("is-ng", judged && !r.ok);
verdict.textContent = judged
? `${fmt(r.capacity, 3)} ㎥/s ${r.ok ? "≥" : "<"} Qd ${fmt(r.design_flow, 2)} ㎥/s — ${r.ok ? "O K" : "N G"}`
: `판정 ${DASH}`;
},
};
}
+119
View File
@@ -0,0 +1,119 @@
"""B08 4탭 홍수량 계산서 — 처음 값 빈칸 · 공사지 지역 · 재현기간별 R24 · 저장 파일 (PLAN 69-5 · 69-6)."""
from __future__ import annotations
import json
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from B08_Quantity.B08_Quantity_Engine_Hydro import compute # noqa: E402
from B08_Quantity.B08_Quantity_Hydro_Defaults import DEFAULTS # noqa: E402
from B08_Quantity.B08_Quantity_Hydro_Site import ( # noqa: E402
SAVED_FILE,
read_saved,
site_info,
site_r24,
write_saved,
)
SQUARE = [[[128.0, 36.0], [129.0, 36.0], [129.0, 37.0], [128.0, 37.0], [128.0, 36.0]]]
FAR = [[[120.0, 30.0], [121.0, 30.0], [121.0, 31.0], [120.0, 31.0], [120.0, 30.0]]]
def _geojson(*features: tuple[list, dict]) -> str:
return json.dumps(
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"properties": p,
"geometry": {"type": "Polygon", "coordinates": g},
}
for g, p in features
],
},
ensure_ascii=False,
)
@pytest.fixture()
def project(tmp_path: Path) -> Path:
drainage = tmp_path / "B04_PreProcess" / "drainage"
processed = tmp_path / "B04_PreProcess" / "processed"
drainage.mkdir(parents=True)
processed.mkdir(parents=True)
rain = {
"lat": 36.5,
"lon": 128.5,
"station": {"name": "영양군(도곡리)"},
"values": [
{"return_period_yr": 50, "duration_min": 1440, "depth_mm": 207.69},
{"return_period_yr": 100, "duration_min": 1440, "depth_mm": 231.56},
{"return_period_yr": 100, "duration_min": 60, "depth_mm": 70.0},
],
}
(drainage / "rainfall_table.json").write_text(json.dumps(rain), encoding="utf-8")
(processed / "행정구역_시군구_bounds.geojson").write_text(
_geojson(
(FAR, {"sig_kor_nm": "봉화군", "full_nm": "경상북도 봉화군"}),
(SQUARE, {"sig_kor_nm": "영양군", "full_nm": "경상북도 영양군"}),
),
encoding="utf-8",
)
(processed / "행정구역_읍면동_bounds.geojson").write_text(
_geojson((SQUARE, {"full_nm": "경상북도 영양군 일월면 용화리"})), encoding="utf-8"
)
return tmp_path
def test_처음_값은_표준_값만_측점_고유_값은_빈칸() -> None:
for key in ("area_km2", "length_m", "height_m", "diameter_mm", "slope_denominator", "r24_mm"):
assert DEFAULTS[key] is None
assert DEFAULTS["factor"] == 2.0 and DEFAULTS["runoff"] == 0.8
assert DEFAULTS["return_period"] == 100 and DEFAULTS["section"] == "full"
def test_빈칸이면_그_값에_기대는_칸만_비움() -> None:
out = compute(DEFAULTS)
assert out["design_flow"] is None and out["capacity"] is None
assert out["ok"] is None and out["ratio_pct"] is None
assert out["n"] == 0.024 # 관 종류 값은 늘 있음
# 단면만 채우면 유출량은 나오고 판정은 빈칸
half = compute({**DEFAULTS, "diameter_mm": 1000.0, "slope_denominator": 20.0})
assert half["capacity"] > 0 and half["design_flow"] is None and half["ok"] is None
def test_지역은_노선_대표점이_드는_행정구역(project: Path) -> None:
site = site_info(project, db_region="엉뚱")
assert site["region"]["name"] == "영양군"
assert site["region"]["full"] == "경상북도 영양군 일월면 용화리"
assert site["station"] == "영양군(도곡리)"
assert site["periods"] == [50, 100]
assert site["r24"] == {"50": 207.69, "100": 231.56}
def test_재현기간을_바꾸면_R24_가_바뀜(project: Path) -> None:
assert site_r24(project, 100) == 231.56
assert site_r24(project, 50) == 207.69
assert site_r24(project, 30) is None
def test_자료가_없으면_등록_지역_재현기간_기본_목록(tmp_path: Path) -> None:
site = site_info(tmp_path, db_region="울진군")
assert site["region"] == {"name": "울진군", "full": "울진군", "source": "프로젝트 등록"}
assert 100 in site["periods"] and site["r24"] == {}
assert site_r24(tmp_path, 100) is None
def test_저장_파일은_B08_자기_자리(tmp_path: Path) -> None:
stations = {"85.050": {**DEFAULTS, "area_km2": 0.05}}
write_saved(tmp_path, stations)
assert (tmp_path / SAVED_FILE).is_file()
assert read_saved(tmp_path) == stations
assert [p.name for p in (tmp_path / SAVED_FILE).parent.iterdir()] == ["hydro_inputs.json"]