From 426e574cd2b2c2f2da085d834d98879ec03eb8f1 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 23:09:21 +0900 Subject: [PATCH] =?UTF-8?q?feat(B08):=20=EC=88=98=EB=A6=AC=EC=A7=91?= =?UTF-8?q?=EC=88=98=EB=A9=B4=EC=A0=81=ED=91=9C=20=EC=9A=B0=EB=A6=AC=20?= =?UTF-8?q?=EC=96=91=EC=8B=9D=20=C2=B7=20=EA=B3=B5=EC=82=AC=EC=A7=80=20?= =?UTF-8?q?=EC=A7=80=EC=97=AD=20=C2=B7=20=EC=9E=AC=ED=98=84=EA=B8=B0?= =?UTF-8?q?=EA=B0=84=20=EB=93=9C=EB=A1=AD=EB=8B=A4=EC=9A=B4=20=C2=B7=20?= =?UTF-8?q?=EC=B2=98=EC=9D=8C=20=EA=B0=92=20=EB=B9=88=EC=B9=B8=20=C2=B7=20?= =?UTF-8?q?[=EC=A0=80=EC=9E=A5](PLAN=2069-4~69-6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 엑셀 모양(흰 종이 · 노랑 칸 · 쪽 표시) 대신 공용 카드 · 입력 · 드롭다운 · 테마 토큰 색 - 테두리는 입력 · 선택 칸만 · 굵게 + 붉은 계열은 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 Claude-Session: https://claude.ai/code/session_01W4uKaV4ZBhsJN4GHG7yRj4 --- A00_Common/b_page_state.ts | 4 +- B08_Quantity/B08_Quantity_Engine_Hydro.py | 66 ++-- B08_Quantity/B08_Quantity_Hydro_Defaults.py | 16 +- B08_Quantity/B08_Quantity_Hydro_Site.py | 121 +++++++ B08_Quantity/B08_Quantity_Router_Hydro.py | 55 +++- B08_Quantity/B08_Quantity_UI_Hydro.css | 259 ++++++--------- B08_Quantity/B08_Quantity_UI_Hydro.ts | 69 +++- B08_Quantity/B08_Quantity_UI_Hydro_Sheet.ts | 347 +++++++++++--------- resources/tester/test_69_b08_hydro_site.py | 119 +++++++ 9 files changed, 691 insertions(+), 365 deletions(-) create mode 100644 B08_Quantity/B08_Quantity_Hydro_Site.py create mode 100644 resources/tester/test_69_b08_hydro_site.py diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index 3891fae18..d1b9b599f 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -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": { diff --git a/B08_Quantity/B08_Quantity_Engine_Hydro.py b/B08_Quantity/B08_Quantity_Engine_Hydro.py index 06b2d6fc1..3bd039bec 100644 --- a/B08_Quantity/B08_Quantity_Engine_Hydro.py +++ b/B08_Quantity/B08_Quantity_Engine_Hydro.py @@ -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, } diff --git a/B08_Quantity/B08_Quantity_Hydro_Defaults.py b/B08_Quantity/B08_Quantity_Hydro_Defaults.py index 4de09e8bf..5bef667eb 100644 --- a/B08_Quantity/B08_Quantity_Hydro_Defaults.py +++ b/B08_Quantity/B08_Quantity_Hydro_Defaults.py @@ -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", } diff --git a/B08_Quantity/B08_Quantity_Hydro_Site.py b/B08_Quantity/B08_Quantity_Hydro_Site.py new file mode 100644 index 000000000..5ce27adf7 --- /dev/null +++ b/B08_Quantity/B08_Quantity_Hydro_Site.py @@ -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) diff --git a/B08_Quantity/B08_Quantity_Router_Hydro.py b/B08_Quantity/B08_Quantity_Router_Hydro.py index 7cdb51edb..a3cc5c206 100644 --- a/B08_Quantity/B08_Quantity_Router_Hydro.py +++ b/B08_Quantity/B08_Quantity_Router_Hydro.py @@ -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} diff --git a/B08_Quantity/B08_Quantity_UI_Hydro.css b/B08_Quantity/B08_Quantity_UI_Hydro.css index 43f4fa418..9d1b50e5f 100644 --- a/B08_Quantity/B08_Quantity_UI_Hydro.css +++ b/B08_Quantity/B08_Quantity_UI_Hydro.css @@ -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); } diff --git a/B08_Quantity/B08_Quantity_UI_Hydro.ts b/B08_Quantity/B08_Quantity_UI_Hydro.ts index 9cdb87bbe..07367b58b 100644 --- a/B08_Quantity/B08_Quantity_UI_Hydro.ts +++ b/B08_Quantity/B08_Quantity_UI_Hydro.ts @@ -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>; +type HydroPayload = { stations: HydroStation[]; saved: Drafts } & HydroOptions; async function request(path: string, init: RequestInit = {}): Promise { 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 { 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(`/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("b08-hydro", projectId) ?? {}; + let drafts: Drafts = readState("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 => { + 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); } diff --git a/B08_Quantity/B08_Quantity_UI_Hydro_Sheet.ts b/B08_Quantity/B08_Quantity_UI_Hydro_Sheet.ts index 24ac61ad2..a5c1a6b1a 100644 --- a/B08_Quantity/B08_Quantity_UI_Hydro_Sheet.ts +++ b/B08_Quantity/B08_Quantity_UI_Hydro_Sheet.ts @@ -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; +} + export interface HydroOptions { defaults: HydroInputs; factors: number[]; sections: Record; pipe_kinds: Record; + 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([ + "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( 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(); - 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}`; }, }; } diff --git a/resources/tester/test_69_b08_hydro_site.py b/resources/tester/test_69_b08_hydro_site.py new file mode 100644 index 000000000..a033371d5 --- /dev/null +++ b/resources/tester/test_69_b08_hydro_site.py @@ -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"]