From 4c96e8f8ddab83024765b2c29f4bf7f1d83515ed Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 22:59:53 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat(B07):=20=EA=B3=84=ED=9A=8D=ED=8F=89?= =?UTF-8?q?=EB=A9=B4=EB=8F=84=20=EC=A1=B0=EA=B0=81=20=EC=9E=98=EA=B2=8C=20?= =?UTF-8?q?=C2=B7=20=EC=A2=85=EB=A5=98=20=EC=97=85=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=20API=20(67-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 성토사면 외곽선 → 노견선 · 성토 외곽선 · 절토 외곽선 셋 · 배수시설 기호 조각 신설 · 라이다 음영 - 기본 넷 처음 체크 = 지금 그림(배수시설 기호는 빼 둠) · 옛 종류 파일 slope_outline 은 셋으로 펼침 - PUT /plan-kinds/{id} — 이름 · 조각 고침(기본 넷 포함) · 그 종류 확정본 풀림 - GET /plan-kinds 에 측점 간격 실음 — 측점 NO + 거리 입력용 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK --- .../B07_DesignDetail_Engine_Cad_PlanKinds.py | 152 +++++++++++++----- .../B07_DesignDetail_Router_PlanKinds.py | 36 ++++- .../B07_DesignDetail_Router_Support_Plan.py | 82 ++++++++-- resources/tester/test_b07_plan_kinds.py | 77 ++++++++- 4 files changed, 296 insertions(+), 51 deletions(-) diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py index 34e5cc64b..cd51607fb 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py @@ -1,9 +1,10 @@ """B07 계획평면도 종류 — 정보 조각을 골라 한 장으로 얹는다(PLAN 60장). 계획평면도는 여러 종류(지형 · 노선배치도 · 대피소 · 라이다 · 사용자 추가)가 **같은 양식**이다. -정보 조각(등고선 · 중심선 · 비탈 외곽선 · 비탈 눈금 · IP · R · 측점 NO · 시종점 · -대피소/사토장 · 라이다 배경)마다 프로젝트 저장소에 파일이 하나씩 있고, 종류는 그중 -무엇을 얹을지 고른 목록이다. 이 모듈은 조각 기하 계산과 도면 조립만 한다(파일 입출력 없음). +정보 조각(등고선 · 중심선 · 노견선 · 성토 · 절토 외곽선 · 비탈 눈금 · IP · R · 측점 NO · +시종점 · 대피소/사토장 · 배수시설 기호 · 라이다 음영)마다 프로젝트 저장소에 파일이 하나씩 +있고, 종류는 그중 무엇을 얹을지 고른 목록이다. 이 모듈은 조각 기하 계산과 도면 조립만 +한다(파일 입출력 없음). 좌표 규약: 종이 mm = (사업지 좌표 m - 그 장 도곽 최소점) x MM (1/1,200). """ @@ -25,10 +26,13 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import ( _COMPASS_SIZE, _FONT_SIZE, _ROUTE_WIDTH, + _STRUCTURE_FONT_SIZE, + _STRUCTURE_SIZE_MM, _TITLE_FONT_SIZE, CONTOUR_COLOR, MM, STREAM_COLOR, + STRUCTURE_COLOR, ) from B07_DesignDetail.B07_DesignDetail_Engine_Template import ( compass_entities, @@ -40,21 +44,29 @@ from config.config_system import DRAWING_SCALE_PLAN # 조각 (id, 이름, 도면층 잠금) — 그리는 차례(배경이 먼저). PIECES: tuple[tuple[str, str, bool], ...] = ( - ("lidar", "라이다 배경", True), + ("lidar", "라이다 음영", True), ("contour", "등고선", True), ("stream", "계류", True), ("slope_ticks", "비탈 눈금", False), - ("slope_outline", "성토사면 외곽선", False), + ("road_edge", "노견선", False), + ("fill_outline", "성토 외곽선", False), + ("cut_outline", "절토 외곽선", False), ("centerline", "중심선", False), ("ip", "IP", False), ("radius", "R", False), ("station", "측점 NO", False), ("endpoints", "시종점", False), ("refuge", "대피소 · 사토장", False), + ("drainage", "배수시설 기호", False), ) PIECE_IDS: tuple[str, ...] = tuple(piece for piece, _name, _locked in PIECES) +# 옛 조각 id → 나눈 조각들 — 옛 종류 파일을 읽을 때 펼친다. +LEGACY_PIECES: dict[str, tuple[str, ...]] = { + "slope_outline": ("road_edge", "fill_outline", "cut_outline"), +} +_OUTLINE = LEGACY_PIECES["slope_outline"] -_TERRAIN = ("contour", "stream", "centerline", "slope_outline", "slope_ticks", "ip", "radius") +_TERRAIN = ("contour", "stream", "centerline", *_OUTLINE, "slope_ticks", "ip", "radius") # 기본 종류 넷 — 지울 수 있다. id 는 옛 도면 id(plan_terrain …)와 같아 확정본이 이어진다. DEFAULT_KINDS: tuple[dict[str, Any], ...] = ( {"id": "terrain", "name": "지형", "pieces": [*_TERRAIN, "station"]}, @@ -80,13 +92,16 @@ _COLORS = { "contour": CONTOUR_COLOR, "stream": STREAM_COLOR, "centerline": "#ff4d4d", - "slope_outline": "#4dd8e6", + "road_edge": "#4dd8e6", + "fill_outline": "#4dd8e6", + "cut_outline": "#4dd8e6", "slope_ticks": "#ff4d4d", "ip": "#4d7dff", "radius": "#4d7dff", "station": "#ffe066", "endpoints": "#ff9d4d", "refuge": "#7ddc7d", + "drainage": STRUCTURE_COLOR, } TICK_STEP_M = 2.0 # 비탈 눈금 간격(실거리 m) — 긴 · 짧은 눈금이 번갈아 선다 _MAJOR_EVERY = 5 # 측점 NO 큰 글자 · 동그라미 간격(측점 수) @@ -129,6 +144,27 @@ def _interp(records: list[dict[str, float]], chainage: float, key: str) -> float return before[key] + (after[key] - before[key]) * ratio +def _ground(samples: list[dict[str, Any]]) -> list[tuple[float, float]]: + return sorted( + (float(item["offset_m"]), float(item["elevation_m"])) + for item in samples + if item.get("valid", True) and isinstance(item.get("elevation_m"), (int, float)) + ) + + +def _ground_at(ground: list[tuple[float, float]], offset: float) -> float: + offsets = [x for x, _z in ground] + index = min(max(bisect_left(offsets, offset), 1), len(ground) - 1) + (x0, z0), (x1, z1) = ground[index - 1], ground[index] + return z0 + (z1 - z0) * ((offset - x0) / (x1 - x0) if x1 > x0 else 0.0) + + +def side_is_cut(samples: list[dict[str, Any]], edge: float, elevation: float) -> bool: + """노견 끝에서 지반이 노면보다 높으면 그 쪽 비탈은 절토다(낮으면 성토).""" + ground = _ground(samples) + return len(ground) >= 2 and _ground_at(ground, edge) > elevation + + def slope_end( design_line: list[dict[str, Any]], samples: list[dict[str, Any]], edge: float, side: str ) -> float: @@ -137,11 +173,7 @@ def slope_end( 설계선(B06 횡단 설계 결과)은 비탈이 지반과 만난 뒤로 지반을 따라간다 — 그 첫 자리가 끝이다. 좌측은 오프셋이 커지는 쪽, 우측은 작아지는 쪽이 바깥이다. """ - ground = sorted( - (float(item["offset_m"]), float(item["elevation_m"])) - for item in samples - if item.get("valid", True) and isinstance(item.get("elevation_m"), (int, float)) - ) + ground = _ground(samples) points = sorted((float(p["offset_m"]), float(p["elevation_m"])) for p in design_line) outward = ( [p for p in points if p[0] > edge] @@ -150,36 +182,61 @@ def slope_end( ) if not ground: return outward[-1][0] if outward else edge - offsets = [offset for offset, _z in ground] - - def ground_at(offset: float) -> float: - index = min(max(bisect_left(offsets, offset), 1), len(ground) - 1) - (x0, z0), (x1, z1) = ground[index - 1], ground[index] - return z0 + (z1 - z0) * ((offset - x0) / (x1 - x0) if x1 > x0 else 0.0) - end, differs = edge, False for offset, elevation in outward: - if abs(elevation - ground_at(offset)) <= 1e-3: + if abs(elevation - _ground_at(ground, offset)) <= 1e-3: return offset if differs else end differs, end = True, offset return end +def _split_runs( + points: list[list[float]], flags: list[bool] +) -> tuple[list[list[list[float]]], list[list[list[float]]]]: + """한 선을 참 · 거짓 구간으로 끊는다 — 바뀌는 자리 점은 앞뒤 구간이 나눠 가져 이어진다.""" + runs: dict[bool, list[list[list[float]]]] = {True: [], False: []} + current: list[list[float]] = [] + previous: bool | None = None + for point, flag in zip(points, flags): + if flag != previous: + if current: + current.append(point) + current = [point] + runs[flag].append(current) + previous = flag + else: + current.append(point) + return ( + [run for run in runs[True] if len(run) >= 2], + [run for run in runs[False] if len(run) >= 2], + ) + + def slope_pieces( route: list[tuple[float, float, float]], records: list[dict[str, Any]], start_m: float, end_m: float, -) -> tuple[dict[str, Any], dict[str, Any]]: - """측점별 노견 끝 · 비탈 끝 오프셋에서 외곽선 넷과 비탈 눈금을 만든다. +) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any], dict[str, Any]]: + """측점별 노견 끝 · 비탈 끝 오프셋에서 노견선 · 성토 외곽선 · 절토 외곽선 · 비탈 눈금을 만든다. `records` = [{chainage_m, left_edge_m, left_end_m, right_edge_m, right_end_m, - (x, y, nx, ny)?}] — 좌(+) · 우(-) 오프셋. 측점 자리는 횡단 파일의 중심 · 좌축을 그대로 - 써서 측점에서 외곽선 폭이 횡단 설계 값과 같다. 측점 사이는 노선을 따라 보간한다. + left_cut?, right_cut?, (x, y, nx, ny)?}] — 좌(+) · 우(-) 오프셋. 측점 자리는 횡단 파일의 + 중심 · 좌축을 그대로 써서 측점에서 외곽선 폭이 횡단 설계 값과 같다. 측점 사이는 노선을 + 따라 보간한다. 비탈 끝 선은 그 쪽이 절토인지 성토인지로 나눠 두 조각에 싣는다. """ - records = sorted(records, key=lambda record: record["chainage_m"]) + records = sorted( + ( + { + **record, + **{f"{s}_cut": float(bool(record.get(f"{s}_cut"))) for s in ("left", "right")}, + } + for record in records + ), + key=lambda record: record["chainage_m"], + ) if len(route) < 2 or not records: - return {"lines": [], "stations": []}, {"ticks": []} + return {"lines": []}, {"lines": []}, {"lines": []}, {"ticks": []} keys = ("left_edge_m", "left_end_m", "right_edge_m", "right_end_m") by_chainage = {round(record["chainage_m"], 3): record for record in records} samples = { @@ -192,6 +249,7 @@ def slope_pieces( samples.add(round(step, 3)) step += TICK_STEP_M lines: dict[str, list[list[float]]] = {key: [] for key in keys} + cut: dict[str, list[bool]] = {"left": [], "right": []} ticks: list[list[list[float]]] = [] for index, chainage in enumerate(sorted(samples)): record = by_chainage.get(chainage) @@ -206,6 +264,8 @@ def slope_pieces( } for key in keys: lines[key].append(points[key]) + for side in cut: + cut[side].append(_interp(records, chainage, f"{side}_cut") > 0.5) # 눈금은 노견 끝에서 비탈 끝으로 — 긴 것 · 짧은 것이 번갈아 선다(납품 도면 관례). for side in ("left", "right"): edge, end = offsets[f"{side}_edge_m"], offsets[f"{side}_end_m"] @@ -213,15 +273,18 @@ def slope_pieces( continue reach = end if index % 2 == 0 else edge + (end - edge) / 2.0 ticks.append([points[f"{side}_edge_m"], [x + nx * reach, y + ny * reach]]) - outline = { - "lines": [lines[key] for key in keys], - "stations": [ - {key: record[key] for key in ("chainage_m", *keys)} - for record in records - if start_m <= record["chainage_m"] <= end_m - ], - } - return outline, {"ticks": ticks} + cut_lines: list[list[list[float]]] = [] + fill_lines: list[list[list[float]]] = [] + for side in cut: + cut_runs, fill_runs = _split_runs(lines[f"{side}_end_m"], cut[side]) + cut_lines += cut_runs + fill_lines += fill_runs + return ( + {"lines": [lines["left_edge_m"], lines["right_edge_m"]]}, + {"lines": fill_lines}, + {"lines": cut_lines}, + {"ticks": ticks}, + ) def curve_pieces( @@ -326,7 +389,7 @@ def _piece_entities( }, } ) - elif piece in ("contour", "stream", "slope_outline"): + elif piece in ("contour", "stream", *_OUTLINE): for index, points in enumerate(data.get("lines") or []): line(points, f":{index}") elif piece == "centerline": @@ -395,6 +458,23 @@ def _piece_entities( line([(x + sx * half, y + sy * half) for sx, sy in corners], f":{index}") cx, cy = paper((x, y)) text(f"{index}", item["label"], (cx + size + 1.0, cy), _LABEL_FONT, align="left") + elif piece == "drainage": + size = _STRUCTURE_SIZE_MM # 마름모 기호 반크기(종이 mm) + for index, item in enumerate(data.get("items") or []): + cx, cy = paper((item["x"], item["y"])) + corners = [(0, 1), (1, 0), (0, -1), (-1, 0), (0, 1)] + diamond = polyline_entity( + drawing_id, + [(cx + sx * size, cy + sy * size) for sx, sy in corners], + layer, + color, + suffix=f":{index}", + ) + if diamond: + out.append(diamond) + text( + f"{index}", item["label"], (cx + size + 1.0, cy), _STRUCTURE_FONT_SIZE, align="left" + ) return out diff --git a/B07_DesignDetail/B07_DesignDetail_Router_PlanKinds.py b/B07_DesignDetail/B07_DesignDetail_Router_PlanKinds.py index a6ec7848d..051b9622e 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_PlanKinds.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_PlanKinds.py @@ -1,6 +1,6 @@ """B07 계획평면도 종류 · 대피소 자리 라우터 (PLAN 60장). -종류 = 정보 조각 고르기 목록. 더하기 · 지우기는 파일 하나씩(`plan_kinds/{id}.json`)이다. +종류 = 정보 조각 고르기 목록. 추가 · 업데이트 · 지우기는 파일 하나씩(`plan_kinds/{id}.json`)이다. 대피소 · 사토장 자리는 구조물 정본 밖에서 사용자가 더 정한 자리(`plan_marks.json`)다. """ @@ -13,13 +13,17 @@ from fastapi import APIRouter from fastapi.responses import JSONResponse from pydantic import BaseModel, Field +from B07_DesignDetail.B07_DesignDetail_Engine_Cad import infer_station_interval from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import PIECES +from B07_DesignDetail.B07_DesignDetail_Router import _confirmed_source from B07_DesignDetail.B07_DesignDetail_Router_Frame import _project_root +from B07_DesignDetail.B07_DesignDetail_Router_Support_Io import _read_json from B07_DesignDetail.B07_DesignDetail_Router_Support_Plan import ( add_kind, delete_kind, list_kinds, read_marks, + update_kind, write_marks, ) @@ -55,16 +59,27 @@ def _error(status: int, exc: Exception) -> JSONResponse: return JSONResponse(status_code=status, content={"status": "error", "message": str(exc)}) +async def _station_interval(project_id: UUID) -> float | None: + """종단 측점 간격(m) — 측점 NO + 거리 입력을 누가거리로 바꾸는 기준. 종단이 없으면 None.""" + try: + _route_id, _root, longitudinal_path, _bypassed = await _confirmed_source(project_id) + longitudinal = await asyncio.to_thread(_read_json, longitudinal_path) + except (FileNotFoundError, PermissionError, OSError, ValueError): + return None + return infer_station_interval(longitudinal.get("stations") or []) + + @router.get("/{project_id}/plan-kinds") async def get_plan_kinds(project_id: UUID) -> Any: - """고를 수 있는 조각 · 지금 종류 · 대피소 자리.""" + """고를 수 있는 조각 · 지금 종류 · 대피소 자리 · 측점 간격.""" root = await _project_root(project_id) - return await asyncio.to_thread(_state, root) + state = await asyncio.to_thread(_state, root) + return {**state, "station_interval_m": await _station_interval(project_id)} @router.post("/{project_id}/plan-kinds") async def post_plan_kind(project_id: UUID, request: PlanKindRequest) -> Any: - """종류 더하기 — 조각을 골라 이름을 붙인다.""" + """종류 추가 — 조각을 골라 이름을 붙인다.""" try: root = await _project_root(project_id) await asyncio.to_thread(add_kind, root, request.name, request.pieces) @@ -73,6 +88,19 @@ async def post_plan_kind(project_id: UUID, request: PlanKindRequest) -> Any: return _error(400, exc) +@router.put("/{project_id}/plan-kinds/{kind_id}") +async def put_plan_kind(project_id: UUID, kind_id: str, request: PlanKindRequest) -> Any: + """종류 업데이트 — 이름 · 조각을 고친다(기본 넷도).""" + try: + root = await _project_root(project_id) + await asyncio.to_thread(update_kind, root, kind_id, request.name, request.pieces) + return await asyncio.to_thread(_state, root) + except ValueError as exc: + return _error(400, exc) + except FileNotFoundError as exc: + return _error(404, exc) + + @router.delete("/{project_id}/plan-kinds/{kind_id}") async def delete_plan_kind(project_id: UUID, kind_id: str) -> Any: """종류 지우기 — 기본 넷도 지울 수 있다.""" diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support_Plan.py b/B07_DesignDetail/B07_DesignDetail_Router_Support_Plan.py index 70770f60e..1274d78e1 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support_Plan.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support_Plan.py @@ -6,7 +6,7 @@ plan_marks.json 사용자가 정한 대피소 · 사토장 자리 `plan_kinds/` 가 없으면 기본 넷(지형 · 노선배치도 · 대피소 · 라이다)을 쓴다. 처음 고칠 때 -기본 넷을 파일로 내려 두고 그 위에서 더하고 지운다 — 기본 넷도 지울 수 있다. +기본 넷을 파일로 내려 두고 그 위에서 추가 · 업데이트 · 지운다 — 기본 넷도 고치고 지울 수 있다. 조각은 서버가 도면을 열 때마다 원자료(B05 노선 · 곡선표 · 구조물, B06 횡단 설계, B04 배경) 에서 다시 계산해 파일로 쓰고, 도면은 그 파일을 읽어 고른 조각만 얹는다. """ @@ -24,23 +24,35 @@ from B05_Profile.B05_Profile_Engine_Sections import ( from B05_Profile.B05_Profile_Structures_Repository import load_structures from B06_Section.B06_Section_Engine_Design import compute_cross_design, curve_widening_args from B07_DesignDetail.B07_DesignDetail_Engine_Cad import infer_station_interval, station_no_label -from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import plan_area_mm, plan_chunks +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import ( + _structure_label, + plan_area_mm, + plan_chunks, +) from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import ( DEFAULT_KINDS, + LEGACY_PIECES, PIECE_IDS, curve_pieces, route_frame, + side_is_cut, slope_end, slope_pieces, ) from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( _chunk_route, _hillshade_for_box, + _plan_structures, _sheet_box, map_background, plan_stations, ) -from B07_DesignDetail.B07_DesignDetail_Router_Support_Io import _design_root, _read_json +from B07_DesignDetail.B07_DesignDetail_Router_Support_Io import ( + _design_root, + _read_json, + _read_manifest, + _write_manifest, +) from common_util.common_util_json import atomic_write_json from common_util.common_util_route_profile import design_elevation_from_longitudinal from config.config_system import DRAWING_SCALE_PLAN @@ -76,7 +88,10 @@ def list_kinds(project_root: Path) -> list[dict[str, Any]]: document = _read_json(path) except (OSError, ValueError): continue - pieces = [piece for piece in document.get("pieces") or [] if piece in PIECE_IDS] + stored = set() + for piece in document.get("pieces") or []: + stored.update(LEGACY_PIECES.get(piece, (piece,))) + pieces = [piece for piece in PIECE_IDS if piece in stored] rows.append( ( int(document.get("order") or 0), @@ -100,13 +115,18 @@ def _materialize(project_root: Path) -> list[dict[str, Any]]: return kinds -def add_kind(project_root: Path, name: str, pieces: list[str]) -> dict[str, Any]: +def _checked(name: str, pieces: list[str]) -> tuple[str, list[str]]: text = str(name or "").strip() if not text or len(text) > _NAME_MAX: raise ValueError(f"종류 이름은 1~{_NAME_MAX}자로 적어 주세요.") chosen = [piece for piece in PIECE_IDS if piece in set(pieces or [])] if not chosen: raise ValueError("얹을 정보를 하나 이상 고르세요.") + return text, chosen + + +def add_kind(project_root: Path, name: str, pieces: list[str]) -> dict[str, Any]: + text, chosen = _checked(name, pieces) kinds = _materialize(project_root) if any(kind["name"] == text for kind in kinds): raise ValueError(f"같은 이름의 종류가 이미 있습니다: {text}") @@ -118,6 +138,31 @@ def add_kind(project_root: Path, name: str, pieces: list[str]) -> dict[str, Any] return {"id": kind_id, "name": text, "pieces": chosen} +def update_kind(project_root: Path, kind_id: str, name: str, pieces: list[str]) -> dict[str, Any]: + """종류 이름 · 조각을 고친다(차례는 그대로). 그 종류의 확정본은 풀어 새 조각으로 다시 그린다.""" + if not KIND_ID.fullmatch(kind_id): + raise ValueError("올바르지 않은 종류 id 입니다.") + text, chosen = _checked(name, pieces) + kinds = _materialize(project_root) + if any(kind["name"] == text and kind["id"] != kind_id for kind in kinds): + raise ValueError(f"같은 이름의 종류가 이미 있습니다: {text}") + path = _kinds_dir(project_root) / f"{kind_id}.json" + if not path.is_file(): + raise FileNotFoundError("고칠 종류를 찾을 수 없습니다.") + order = int(_read_json(path).get("order") or 0) + atomic_write_json(path, {"name": text, "pieces": chosen, "order": order}) + manifest = _read_manifest(project_root) + released = False + for drawing_id, entry in manifest["drawings"].items(): + match = PLAN_KIND_ID.fullmatch(drawing_id) + if match and match.group(1) == kind_id and entry.get("confirmed"): + entry["confirmed"] = False + released = True + if released: + _write_manifest(project_root, manifest) + return {"id": kind_id, "name": text, "pieces": chosen} + + def delete_kind(project_root: Path, kind_id: str) -> None: if not KIND_ID.fullmatch(kind_id): raise ValueError("올바르지 않은 종류 id 입니다.") @@ -223,6 +268,10 @@ def slope_records( "right_edge_m": right_edge, "right_end_m": slope_end(line, samples, right_edge, "right"), } + for side in ("left", "right"): + elevation = edges[side].get("elevation_m") + if isinstance(elevation, (int, float)): + record[f"{side}_cut"] = side_is_cut(samples, record[f"{side}_edge_m"], elevation) frame = source.get("frame") or {} origin, axis = frame.get("origin") or {}, frame.get("left_xy") if isinstance(axis, list) and len(axis) == 2 and "x" in origin and "y" in origin: @@ -298,11 +347,14 @@ def build_pieces( pieces["stream"] = {"lines": background["streams"]} if "centerline" in wanted: pieces["centerline"] = {"lines": [route_xy]} - if {"slope_outline", "slope_ticks"} & set(wanted): + if {*LEGACY_PIECES["slope_outline"], "slope_ticks"} & set(wanted): records = slope_records(longitudinal_path, longitudinal, designs, start_m, end_m) - pieces["slope_outline"], pieces["slope_ticks"] = slope_pieces( - route, records, start_m, end_m - ) + ( + pieces["road_edge"], + pieces["fill_outline"], + pieces["cut_outline"], + pieces["slope_ticks"], + ) = slope_pieces(route, records, start_m, end_m) if {"ip", "radius"} & set(wanted): pieces["ip"], pieces["radius"] = curve_pieces(_load_design_curves(project_root), route, box) if "station" in wanted: @@ -341,6 +393,18 @@ def build_pieces( pieces["endpoints"] = {"items": items} if "refuge" in wanted: pieces["refuge"] = _refuge_items(project_root, route, start_m, end_m) + if "drainage" in wanted: + min_x, min_y, max_x, max_y = box + pieces["drainage"] = { + "items": [ + {"x": float(item["x"]), "y": float(item["y"]), "label": _structure_label(item)} + for item in _plan_structures(context) + if isinstance(item.get("x"), (int, float)) + and isinstance(item.get("y"), (int, float)) + and min_x <= item["x"] <= max_x + and min_y <= item["y"] <= max_y + ] + } if "lidar" in wanted: try: image, image_box = _hillshade_for_box(context, box) diff --git a/resources/tester/test_b07_plan_kinds.py b/resources/tester/test_b07_plan_kinds.py index da75092e7..a3cc127a6 100644 --- a/resources/tester/test_b07_plan_kinds.py +++ b/resources/tester/test_b07_plan_kinds.py @@ -4,6 +4,8 @@ ② 종류 더하기 · 지우기(기본 넷 포함) · 다시 읽어 같음 ③ 대피소 · 사토장 자리 — 한 자리에 둘 다 ④ 고른 조각만 도면 층으로 얹힘 +⑤ 조각 잘게(67장) — 노견선 · 성토 외곽선 · 절토 외곽선 따로 · 기본 넷 처음 체크 = 옛 그림 · + 옛 종류 파일의 slope_outline 펼침 · 종류 업데이트(기본 넷 포함) · 확정본 풀림 · 배수시설 기호 """ import json @@ -19,13 +21,16 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import ( layer_id, slope_pieces, ) +from B07_DesignDetail.B07_DesignDetail_Router_Support_Io import _read_manifest, _write_manifest from B07_DesignDetail.B07_DesignDetail_Router_Support_Plan import ( + _kinds_dir, _refuge_items, add_kind, delete_kind, list_kinds, read_marks, slope_records, + update_kind, write_marks, ) @@ -89,8 +94,11 @@ def test_outline_width_equals_cross_design_at_two_stations(tmp_path: Path) -> No longitudinal_path, longitudinal, designs = _project(tmp_path) records = slope_records(longitudinal_path, longitudinal, designs, 0.0, 40.0) route = [(0.0, 0.0, 0.0), (40.0, 0.0, 40.0)] - outline, ticks = slope_pieces(route, records, 0.0, 40.0) - left_edge, left_end, right_edge, right_end = outline["lines"] + edge, fill, cut, ticks = slope_pieces(route, records, 0.0, 40.0) + left_edge, right_edge = edge["lines"] + # 좌(+)가 높은 지반 — 좌측 비탈은 절토 · 우측은 성토, 각각 한 줄로 이어짐 + assert all(r["left_cut"] and not r["right_cut"] for r in records) + (left_end,), (right_end,) = cut["lines"], fill["lines"] for chainage in (20, 40): # 견본 측점 둘 design = {**designs[chainage], "_c": chainage} index = next(i for i, p in enumerate(left_edge) if abs(p[1] - chainage) < 1e-6) @@ -157,3 +165,68 @@ def test_drawing_has_only_chosen_pieces() -> None: assert layer_id("contour") not in layers and layer_id("station") not in layers labels = [e["shapeData"]["label"] for e in drawing["entities"] if e["type"] == "Text"] assert "R=15" in labels and "계획평면도(시험)" in labels + + +def test_split_runs_switch_cut_fill_and_stay_joined() -> None: + route = [(0.0, 0.0, 0.0), (40.0, 0.0, 40.0)] + base = {"left_edge_m": 2.0, "left_end_m": 5.0, "right_edge_m": -2.0, "right_end_m": -5.0} + records = [ + {"chainage_m": 0.0, **base, "left_cut": True, "right_cut": False}, + {"chainage_m": 40.0, **base, "left_cut": False, "right_cut": False}, + ] + _edge, fill, cut, _ticks = slope_pieces(route, records, 0.0, 40.0) + (cut_left,) = cut["lines"] + fill_left = next(line for line in fill["lines"] if line[0][0] < 0) + assert cut_left[-1] == fill_left[0] # 바뀌는 자리에서 두 선이 이어짐 + assert cut_left[0][1] == 0.0 and fill_left[-1][1] == 40.0 + + +def test_default_kinds_keep_old_picture_and_legacy_file(tmp_path: Path) -> None: + root = tmp_path / "project" + outline = {"road_edge", "fill_outline", "cut_outline"} + for ( + kind + ) in DEFAULT_KINDS: # 옛 slope_outline 자리를 나눈 셋이 그대로 채움 · 배수시설은 옛 그림에 없음 + assert outline <= set(kind["pieces"]) and "drainage" not in kind["pieces"] + folder = _kinds_dir(root) + folder.mkdir(parents=True) + (folder / "old.json").write_text( + json.dumps({"name": "옛것", "pieces": ["slope_outline", "station"], "order": 0}), "utf-8" + ) + (kind,) = list_kinds(root) + assert set(kind["pieces"]) == outline | {"station"} + + +def test_update_kind_default_and_release_confirmed(tmp_path: Path) -> None: + root = tmp_path / "project" + manifest = _read_manifest(root) + manifest["drawings"] = { + "plan_terrain": {"confirmed": True}, + "plan_route": {"confirmed": True}, + } + _write_manifest(root, manifest) + update_kind(root, "terrain", "지형2", ["contour", "centerline"]) # 기본 종류 고침 + again = {k["id"]: k for k in list_kinds(root)} # 다시 읽어 같음 + assert again["terrain"] == { + "id": "terrain", + "name": "지형2", + "pieces": ["contour", "centerline"], + } + assert [k["id"] for k in list_kinds(root)][0] == "terrain" # 차례 그대로 + drawings = _read_manifest(root)["drawings"] + assert drawings["plan_terrain"]["confirmed"] is False + assert drawings["plan_route"]["confirmed"] is True + with pytest.raises(ValueError): + update_kind(root, "terrain", "노선배치도", ["contour"]) # 다른 종류와 같은 이름 + with pytest.raises(ValueError): + update_kind(root, "terrain", "지형", []) + with pytest.raises(FileNotFoundError): + update_kind(root, "nokind", "없음", ["contour"]) + + +def test_drainage_symbol_piece() -> None: + pieces = {"drainage": {"items": [{"x": 0.0, "y": 20.0, "label": "D800"}]}} + drawing = build_plan_kind_drawing("plan_k2", "시험", (-50.0, -10.0, 50.0, 50.0), pieces) + mine = [e for e in drawing["entities"] if e.get("layerId") == layer_id("drainage")] + assert {e["type"] for e in mine} >= {"Text"} and len(mine) >= 2 + assert any(e["type"] == "Text" and e["shapeData"]["label"] == "D800" for e in mine) From 184d79cbdfcacfcd4fb74f0e79fa69ab0c567cac Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 23:06:23 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat(ui=5Ftemplate):=20B08=20=C2=B7=20B09?= =?UTF-8?q?=20=EA=B3=B5=EC=9A=A9=20=EB=A7=8E=EC=9D=80=20=EC=9E=90=EB=A3=8C?= =?UTF-8?q?=20=ED=91=9C=20=EC=96=91=EC=8B=9D(70-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - createDataTable — 묶음 머리 여러 줄 · 머리 · 앞 열 · 합계 줄 고정 스크롤 - 숫자 오른쪽 맞춤 · 고정폭 숫자 · 천 단위 쉼표 · 열마다 소수 자리 · 빈 값 「—」 - 줄 번갈아 색 · 올린 줄 강조 · 테마 토큰만 · 보이는 줄 근처만 그림(1,000줄 64~118ms) - 견본(머리 3줄 · 30열 · 66줄 · 합계) 시험 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7 --- resources/tester/fixture_data_table_sample.js | 58 ++++++ resources/tester/helper_ui_data_table.cjs | 79 +++++++ resources/tester/test_ui_data_table.py | 87 ++++++++ ui_template/ui_template_data_table.css | 129 ++++++++++++ ui_template/ui_template_data_table.ts | 195 ++++++++++++++++++ 5 files changed, 548 insertions(+) create mode 100644 resources/tester/fixture_data_table_sample.js create mode 100644 resources/tester/helper_ui_data_table.cjs create mode 100644 resources/tester/test_ui_data_table.py create mode 100644 ui_template/ui_template_data_table.css create mode 100644 ui_template/ui_template_data_table.ts diff --git a/resources/tester/fixture_data_table_sample.js b/resources/tester/fixture_data_table_sample.js new file mode 100644 index 000000000..d4ad61c0d --- /dev/null +++ b/resources/tester/fixture_data_table_sample.js @@ -0,0 +1,58 @@ +/* 공용 표(`ui_template/ui_template_data_table.ts`) 견본 — 토적표 모양 · 머리 3줄 · 30열 · 합계. + * 그냥 글(스크립트)로 읽어 씀 — Node 헬퍼와 화면 eval 이 같은 견본을 씀. + * const s = sampleDataTable(66); // { columns, rows, total, frozen } */ +function sampleDataTable(n) { + const num = (key, label, group, decimals) => ({ key, label, group, kind: "number", decimals }); + const trio = (pre, group) => [ + num(pre + "_area", "단면적", group, 2), + num(pre + "_vol", "입적", group, 1), + num(pre + "_adj", "보정량", group, 1), + ]; + const pair = (pre, group) => [ + num(pre + "_len", "거리", group, 1), + num(pre + "_area", "면적", group, 1), + ]; + const columns = [ + { key: "sta", label: "측점", width: 80 }, + num("dist", "거리", [], 0), + ...trio("cs", ["절토", "토사"]), + ...trio("cr", ["절토", "연암"]), + ...trio("ds", ["절토", "측구파기 토사"]), + ...trio("dr", ["절토", "측구파기 암"]), + num("adj_sum", "보정량계", ["절토"], 1), + num("fill_area", "단면적", ["성토"], 2), + num("fill_vol", "입적", ["성토"], 1), + num("reuse", "유용토", [], 1), + num("diff", "차인토량", [], 1), + num("cum", "누가토량", [], 1), + ...pair("gf", ["면고르기", "성토면"]), + ...pair("gc", ["면고르기", "절토면"]), + ...pair("tf", ["지장목제거", "성토"]), + ...pair("tc", ["지장목제거", "절토"]), + ...pair("rd", ["노면정리"]), + ]; + const rows = []; + let cum = 0; + for (let i = 0; i < n; i++) { + const r = { + sta: i % 5 ? `${120 + Math.floor(i / 5)}+${(i % 5) * 4}` : String(120 + i / 5), + dist: i ? 4 : 0, + }; + columns.slice(2).forEach((c, j) => { + // 빈 값 · 큰 값 · 음수가 섞이게 + r[c.key] = + (i + j) % 7 === 0 + ? null + : Math.round(((i * 37 + j * 101) % 997) * (j % 4 === 0 ? 13.7 : 1.13) * 100) / 100; + }); + r.diff = Math.round((r.fill_vol ?? 0) - (r.cs_vol ?? 0)) / 1; + cum += r.diff; + r.cum = cum; + rows.push(r); + } + const total = { sta: "합계" }; + columns.slice(1).forEach((c) => { + if (c.key !== "cum") total[c.key] = rows.reduce((s, r) => s + (r[c.key] ?? 0), 0); + }); + return { columns, rows, total, frozen: 1 }; +} diff --git a/resources/tester/helper_ui_data_table.cjs b/resources/tester/helper_ui_data_table.cjs new file mode 100644 index 000000000..aae5bae00 --- /dev/null +++ b/resources/tester/helper_ui_data_table.cjs @@ -0,0 +1,79 @@ +/* 공용 표(`ui_template/ui_template_data_table.ts`) 단위검증 헬퍼 — + * 트랜스파일해 Node 로 견본 표 HTML 을 만들고 판정할 값을 JSON 으로 냄. */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const file = path.join(__dirname, "..", "..", "ui_template", "ui_template_data_table.ts"); +const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, +}).outputText; +const box = { exports: {} }; +const req = (p) => (p.endsWith(".css") ? {} : require(p)); +new Function("exports", "module", "require", js)(box.exports, box, req); +const m = box.exports; + +const src = fs.readFileSync(path.join(__dirname, "fixture_data_table_sample.js"), "utf8"); +const sampleDataTable = new Function(src + "; return sampleDataTable;")(); + +const s = sampleDataTable(66); +const html = m.renderDataTableHtml(s); +const head = html.split("")[1].split("")[0]; +const headRows = head.split("").filter(Boolean); +const body = html.split("")[1].split("")[0]; +const bodyRows = body.split("").filter(Boolean); +const foot = html.split("")[1].split("")[0]; + +const t0 = process.hrtime.bigint(); +const big = m.renderDataTableHtml(sampleDataTable(1000)); +const ms = Number(process.hrtime.bigint() - t0) / 1e6; + +const one = (c, v) => + m.formatCell( + v, + c.kind === "number" + ? new Intl.NumberFormat("ko-KR", { + minimumFractionDigits: c.decimals ?? 0, + maximumFractionDigits: c.decimals ?? 0, + }) + : null, + )[0]; +const nc = { key: "a", label: "a", kind: "number", decimals: 2 }; +const tc = { key: "b", label: "b" }; + +console.log( + JSON.stringify({ + cols: s.columns.length, + headRows: headRows.length, + firstHeadRow: headRows[0], + secondHeadRow: headRows[1], + bodyRows: bodyRows.length, + firstBodyRow: bodyRows[0], + foot, + frozenCells: (body.match(/class="is-frozen is-edge"/g) || []).length, + bigRows: big.split("")[1].split("")[0].split("").length - 1, + bigMs: ms, + fmt: { + thousands: one(nc, 1234567.891), + neg: one(nc, -97.9), + zero: one(nc, 0), + nul: one(nc, null), + undef: one(nc, undefined), + blank: one(nc, ""), + nan: one(nc, NaN), + text: one(tc, "120+18"), + textNum: one(tc, 5), + }, + win: (() => { + const b = m + .renderDataTableHtml(sampleDataTable(1000), { from: 61, to: 121, rowH: 25 }) + .split("")[1] + .split("")[0] + .split("") + .filter(Boolean); + return { n: b.length, top: b[0], second: b[1].slice(0, 20), bottom: b[b.length - 1] }; + })(), + escaped: m.renderDataTableHtml({ columns: [{ key: "x", label: "" }], rows: [{ x: "a&<" }] }), + noTotal: m.renderDataTableHtml({ columns: [tc], rows: [] }).includes(""), + }), +); diff --git a/resources/tester/test_ui_data_table.py b/resources/tester/test_ui_data_table.py new file mode 100644 index 000000000..1b32696f0 --- /dev/null +++ b/resources/tester/test_ui_data_table.py @@ -0,0 +1,87 @@ +# -*- coding: utf-8 -*- +"""공용 표 양식(PLAN 70-1) 회귀검증 — 묶음 머리 · 고정 열 · 숫자 꼴 · 빈 값 · 합계 · 1,000줄. + +Node 헬퍼(`helper_ui_data_table.cjs`)가 `ui_template/ui_template_data_table.ts` 를 트랜스파일해 +견본(`fixture_data_table_sample.js` · 머리 3줄 · 30열 · 66줄)으로 HTML 을 만들고, 여기서 판정함. +""" + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) + + +@pytest.fixture(scope="module") +def r(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_ui_data_table.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_견본_크기(r): + assert r["cols"] == 30 + assert r["headRows"] == 3 + assert r["bodyRows"] == 66 + + +def test_묶음_머리_합침(r): + first = r["firstHeadRow"] + # 측점 · 거리 · 유용토 등 묶음 없는 열은 머리 3줄을 세로로 차지 + assert 'rowspan="3" class="is-frozen is-edge" style="left:0px" title="측점">측점' in first + assert first.count('rowspan="3"') == 5 # 측점 거리 유용토 차인토량 누가토량 + assert 'colspan="13">절토' in first # 토사 · 연암 · 측구파기 토사 · 암 각 3 + 보정량계 + assert 'colspan="4">면고르기' in first + assert 'colspan="2">노면정리' in first + second = r["secondHeadRow"] + assert 'colspan="3">토사' in second and 'colspan="3">연암' in second + assert 'rowspan="2" data-num="" title="보정량계"' in second # 한 단 묶음은 머리 2줄 + + +def test_고정_열과_숫자_칸(r): + row = r["firstBodyRow"] + assert row.startswith('120') + assert r["frozenCells"] == 66 + assert '0' in row # 거리 0 은 빈 값 아님 + + +def test_숫자_꼴(r): + f = r["fmt"] + assert f["thousands"] == "1,234,567.89" + assert f["neg"] == "-97.90" + assert f["zero"] == "0.00" + assert f["nul"] == f["undef"] == f["blank"] == f["nan"] == "—" + assert f["text"] == "120+18" and f["textNum"] == "5" + + +def test_합계_줄(r): + assert r["foot"].startswith('합계') + assert "is-empty" in r["foot"] # 누가토량 합계는 비움 + assert r["noTotal"] is False + + +def test_글_새지_않음(r): + assert "<b>" in r["escaped"] and "a&<" in r["escaped"] + + +def test_천_줄_빠름(r): + assert r["bigRows"] == 1000 + assert r["bigMs"] < 300 + + +def test_보이는_줄만_그림(r): + w = r["win"] + assert w["n"] == 62 # 위 빈 줄 + 60줄 + 아래 빈 줄 + assert 'style="height:1525px">' in w["top"] # 61줄 × 25 + assert w["second"].startswith( + '' + ) # 61 번째(홀수) 줄 — 번갈아 색은 전체 줄 번호로 + assert 'style="height:21975px"' in w["bottom"] # (1000 - 121) × 25 diff --git a/ui_template/ui_template_data_table.css b/ui_template/ui_template_data_table.css new file mode 100644 index 000000000..cf9b36aec --- /dev/null +++ b/ui_template/ui_template_data_table.css @@ -0,0 +1,129 @@ +/* ============================================================================= + * ui_template_data_table.css — 많은 자료용 읽기 표(`ui_template_data_table.ts`) + * 색은 테마 토큰만 · 칸 배경은 불투명(고정 칸 아래로 비치지 않게) + * ========================================================================== */ + +.ui-data-table { + --dt-row-h: 24px; + --dt-zebra: var(--color-surface); + --dt-hover: color-mix(in srgb, var(--color-accent) 14%, var(--color-canvas)); + --dt-head: color-mix(in srgb, var(--color-accent) 6%, var(--color-surface)); + --dt-line: var(--color-border); + --dt-edge: color-mix(in srgb, var(--color-text-secondary) 45%, var(--color-border)); + max-width: 100%; + max-height: calc(100vh - 260px); + overflow: auto; + border: 1px solid var(--dt-line); + border-radius: var(--radius-lg); + background: var(--color-canvas); + outline: none; + overflow-anchor: none; /* 줄을 갈아 그릴 때 브라우저가 스크롤을 옮기지 않게 */ +} + +.ui-data-table:focus-visible { + box-shadow: 0 0 0 2px var(--color-focus-ring); +} + +.ui-data-table__table { + table-layout: fixed; + border-collapse: separate; + border-spacing: 0; + color: var(--color-text-body); + font-size: var(--text-caption); + line-height: 1.3; +} + +.ui-data-table__table caption { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); +} + +.ui-data-table__table th, +.ui-data-table__table td { + height: var(--dt-row-h); + padding: 0 var(--spacing-8); + overflow: hidden; + border-right: 1px solid var(--dt-line); + border-bottom: 1px solid var(--dt-line); + background: var(--color-canvas); + text-overflow: ellipsis; + white-space: nowrap; +} + +/* 숫자 — 오른쪽 맞춤 · 자리 폭 같게 */ +.ui-data-table__table [data-num] { + text-align: right; + font-variant-numeric: tabular-nums; +} + +.ui-data-table__table td.is-empty { + color: var(--color-text-secondary); /* muted 는 작은 글에 대비 3:1 — 모자람 */ + text-align: center; +} + +/* 머리 — 스크롤해도 위에 붙음 */ +.ui-data-table__table thead { + position: sticky; + top: 0; + z-index: 2; +} + +.ui-data-table__table thead th { + background: var(--dt-head); + color: var(--color-text); + font-weight: var(--font-weight-medium); + text-align: center; +} + +.ui-data-table__table thead tr:last-child th, +.ui-data-table__table thead th[rowspan] { + border-bottom-color: var(--dt-edge); +} + +/* 줄 번갈아 색 · 올린 줄 강조 */ +.ui-data-table__table tbody tr.is-alt td { + background: var(--dt-zebra); +} + +.ui-data-table__table tbody tr:hover td { + background: var(--dt-hover); +} + +/* 안 그린 줄 자리 */ +.ui-data-table__table tbody tr.ui-data-table__pad td { + height: auto; + padding: 0; + border: 0; + background: none; +} + +/* 앞 고정 열 — left 는 칸마다 글에 들어 있음 */ +.ui-data-table__table .is-frozen { + position: sticky; + z-index: 1; +} + +.ui-data-table__table thead .is-frozen { + z-index: 3; +} + +.ui-data-table__table .is-edge { + border-right-color: var(--dt-edge); +} + +/* 합계 줄 — 맨 아래 붙음 · 굵게 */ +.ui-data-table__table tfoot { + position: sticky; + bottom: 0; + z-index: 2; +} + +.ui-data-table__table tfoot td { + border-top: 1px solid var(--dt-edge); + background: var(--dt-head); + color: var(--color-text); + font-weight: var(--font-weight-bold); +} diff --git a/ui_template/ui_template_data_table.ts b/ui_template/ui_template_data_table.ts new file mode 100644 index 000000000..c39f2c1a7 --- /dev/null +++ b/ui_template/ui_template_data_table.ts @@ -0,0 +1,195 @@ +/* ============================================================================= + * ui_template_data_table.ts + * 많은 자료용 읽기 표(B08 · B09 공용) — 묶음 머리 여러 줄 · 머리 · 앞 열 고정 스크롤 · + * 숫자 오른쪽 맞춤 · 천 단위 쉼표 · 열마다 소수 자리 · 빈 값 「—」 · 합계 줄. + * 줄 1,000개도 빠르게 — HTML 글로 통째로 넣고(칸마다 요소 만들지 않음) 보이는 줄 근처만 그림. + * + * const t = createDataTable({ columns, rows, frozen: 1, total }); + * host.append(t.root); t.setRows(rows2, total2); + * 열 = { key: "cut_soil_area", label: "단면적", group: ["절토", "토사"], kind: "number", decimals: 2 } + * ========================================================================== */ + +import "./ui_template_data_table.css"; + +export interface DataColumn { + /** 줄 객체에서 값을 꺼낼 키 */ + key: string; + /** 맨 아래 머리 글 */ + label: string; + /** 묶음 머리(위 → 아래) — 이웃 열과 앞부분이 같으면 한 칸으로 합침 */ + group?: string[]; + /** 기본 글 · 숫자면 오른쪽 맞춤 · 천 단위 쉼표 */ + kind?: "number" | "text"; + /** 숫자 소수 자리(기본 0) */ + decimals?: number; + /** 열 폭 px(기본 숫자 84 · 글 96) */ + width?: number; +} + +export type DataRow = Record; + +export interface DataTableOptions { + columns: DataColumn[]; + rows: DataRow[]; + /** 앞에서부터 고정할 열 수(기본 0) */ + frozen?: number; + /** 합계 줄(맨 아래 고정) — 없으면 안 그림 */ + total?: DataRow | null; + /** 표 이름(화면 읽기용) */ + caption?: string; +} + +export interface DataTable { + root: HTMLElement; + setRows: (rows: DataRow[], total?: DataRow | null) => void; +} + +const EMPTY = "—"; +const ESC: Record = { "&": "&", "<": "<", ">": ">", '"': """ }; +const esc = (s: string): string => s.replace(/[&<>"]/g, (c) => ESC[c]); + +function colWidth(c: DataColumn): number { + return c.width ?? (c.kind === "number" ? 84 : 96); +} + +/** 값 한 칸 → [보일 글, 빈 값인지] */ +export function formatCell(v: unknown, fmt: Intl.NumberFormat | null): [string, boolean] { + if (v === null || v === undefined || v === "") return [EMPTY, true]; + if (fmt && typeof v === "number") + return Number.isFinite(v) ? [fmt.format(v), false] : [EMPTY, true]; + return [String(v), false]; +} + +function headHtml(cols: DataColumn[], left: (number | null)[]): string { + const depth = Math.max(0, ...cols.map((c) => c.group?.length ?? 0)); + const same = (a: DataColumn, b: DataColumn, n: number) => + (b.group?.length ?? 0) > n && + a.group!.slice(0, n + 1).join("\u0000") === b.group!.slice(0, n + 1).join("\u0000"); + const stick = (from: number, to: number) => + left[to] !== null + ? ` class="is-frozen${left[to + 1] == null ? " is-edge" : ""}" style="left:${left[from]}px"` + : ""; + const rows: string[] = []; + for (let lv = 0; lv <= depth; lv++) { + let tr = ""; + for (let j = 0; j < cols.length; j++) { + const c = cols[j]; + const g = c.group?.length ?? 0; + if (g > lv) { + let k = j; + while (k + 1 < cols.length && same(c, cols[k + 1], lv)) k++; + tr += `${esc(c.group![lv])}`; + j = k; + } else if (g === lv) { + const num = c.kind === "number" ? ' data-num=""' : ""; + const span = depth - lv + 1; + tr += `${esc(c.label)}`; + } + } + rows.push(`${tr}`); + } + return rows.join(""); +} + +function rowHtml( + cols: DataColumn[], + fmts: (Intl.NumberFormat | null)[], + left: (number | null)[], + r: DataRow, + alt = false, +): string { + let tr = ""; + for (let j = 0; j < cols.length; j++) { + const [txt, empty] = formatCell(r[cols[j].key], fmts[j]); + const frz = left[j] !== null; + const cls = [frz && "is-frozen", frz && left[j + 1] == null && "is-edge", empty && "is-empty"] + .filter(Boolean) + .join(" "); + const attrs = + (cls ? ` class="${cls}"` : "") + + (frz ? ` style="left:${left[j]}px"` : "") + + (fmts[j] ? ' data-num=""' : ""); + tr += `${esc(txt)}`; + } + return alt ? `${tr}` : `${tr}`; +} + +/** 그릴 줄 구간 — 밖의 줄은 빈 줄 높이(rowH px)로만 채움 */ +export interface RowWindow { + from: number; + to: number; + rowH: number; +} + +const pad = (h: number, n: number) => + h > 0 ? `` : ""; + +/** 표 전체 HTML 글 — DOM 없이 만듦(시험 · 서버 그리기도 같은 글) · win 없으면 모든 줄 */ +export function renderDataTableHtml(o: DataTableOptions, win?: RowWindow): string { + const cols = o.columns; + const frozen = Math.min(o.frozen ?? 0, cols.length); + const left: (number | null)[] = []; + let x = 0; + cols.forEach((c, i) => { + left.push(i < frozen ? x : null); + x += colWidth(c); + }); + const fmts = cols.map((c) => + c.kind === "number" + ? new Intl.NumberFormat("ko-KR", { + minimumFractionDigits: c.decimals ?? 0, + maximumFractionDigits: c.decimals ?? 0, + }) + : null, + ); + const colgroup = cols.map((c) => ``).join(""); + const n = o.rows.length; + const from = win ? Math.max(0, Math.min(win.from, n)) : 0; + const to = win ? Math.max(from, Math.min(win.to, n)) : n; + let body = win ? pad(from * win.rowH, cols.length) : ""; + for (let i = from; i < to; i++) body += rowHtml(cols, fmts, left, o.rows[i], i % 2 === 1); + if (win) body += pad((n - to) * win.rowH, cols.length); + const foot = o.total ? `${rowHtml(cols, fmts, left, o.total)}` : ""; + const cap = o.caption ? `${esc(o.caption)}` : ""; + return ( + `${cap}${colgroup}` + + `${headHtml(cols, left)}${body}${foot}
` + ); +} + +/** 화면 위아래로 더 그려 둘 줄 수 · 구간은 이 단위로 끊어 스크롤마다 다시 그리지 않음 */ +const OVER = 40; + +export function createDataTable(opts: DataTableOptions): DataTable { + const root = document.createElement("div"); + root.className = "ui-data-table"; + root.tabIndex = 0; // 키보드로 표 안 스크롤 + const state = { ...opts }; + let win: RowWindow = { from: -1, to: -1, rowH: 24 }; // --dt-row-h · 그린 뒤 재서 고침 + // 보이는 줄 근처만 그림 — 줄 1,000개를 다 배치하면 수백 ms + const draw = (force: boolean) => { + const n = state.rows.length; + const view = Math.ceil((root.clientHeight || 800) / win.rowH); + const from = Math.max(0, Math.floor(root.scrollTop / win.rowH / OVER) * OVER - OVER); + const to = Math.min(n, from + view + 3 * OVER); + if (!force && from === win.from && to === win.to) return; + win = { ...win, from, to }; + root.innerHTML = renderDataTableHtml(state, win); + // 줄 높이는 그린 줄을 재서 맞춤(글꼴 · 토큰이 바뀌어도) + const tr = root.querySelector("tbody tr:not(.ui-data-table__pad)"); + if (tr && tr.offsetHeight && tr.offsetHeight !== win.rowH) { + win = { ...win, rowH: tr.offsetHeight }; + draw(true); + } + }; + root.addEventListener("scroll", () => draw(false), { passive: true }); + draw(true); + return { + root, + setRows(rows, total) { + state.rows = rows; + if (total !== undefined) state.total = total; + draw(true); + }, + }; +} From 3e1186861a58790360e8773b763c225a74a3c76f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 23:09:17 +0900 Subject: [PATCH 3/6] =?UTF-8?q?feat(B07):=20=EA=B3=84=ED=9A=8D=ED=8F=89?= =?UTF-8?q?=EB=A9=B4=EB=8F=84=20=EC=A2=85=EB=A5=98=20=EA=B3=B5=ED=86=B5=20?= =?UTF-8?q?=ED=8E=B8=EC=A7=91=20=C2=B7=20=EB=8C=80=ED=94=BC=EC=86=8C=20?= =?UTF-8?q?=EC=9E=90=EB=A6=AC=20=EC=B8=A1=EC=A0=90=20NO=20+=20=EA=B1=B0?= =?UTF-8?q?=EB=A6=AC=20=C2=B7=20=EA=B3=B5=EC=9A=A9=20=EC=B2=B4=ED=81=AC?= =?UTF-8?q?=EB=B0=95=EC=8A=A4=20(67-1~4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 종류 고르기(지형 · 노선배치도 · 대피소 · 라이다 · 사용자 · 새 종류) → 든 조각 체크 → [업데이트] · 새 종류는 [추가] - 대피소 · 사토장 자리 — B05 구조물 폼의 측점 NO + 거리 두 칸 · 체크박스 · [업데이트] - ui_template_checkbox.ts · .css 신설 — 테마 토큰 색(밝음 · 어두움) - 「더하기」 → 「추가」 · 「자리 더하기」 → 「자리 추가」 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK --- .../B07_DesignDetail_Api_Fetch.ts | 14 +++ .../B07_DesignDetail_UI_PlanKinds.ts | 117 ++++++++++-------- .../B07_DesignDetail_UI_Style.css | 35 ++++-- ui_template/ui_template_checkbox.css | 34 +++++ ui_template/ui_template_checkbox.ts | 32 +++++ 5 files changed, 173 insertions(+), 59 deletions(-) create mode 100644 ui_template/ui_template_checkbox.css create mode 100644 ui_template/ui_template_checkbox.ts diff --git a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts index c7e856856..9f712e219 100644 --- a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts +++ b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts @@ -188,6 +188,8 @@ export interface PlanKindsState { pieces: { id: string; name: string }[]; kinds: PlanKind[]; marks: PlanMark[]; + /** 종단 측점 간격(m) — 측점 NO + 거리 입력용(GET 만 · 종단이 없으면 null). */ + station_interval_m?: number | null; } export function fetchPlanKinds(projectId: string): Promise { @@ -205,6 +207,18 @@ export function addPlanKind( }); } +export function updatePlanKind( + projectId: string, + kindId: string, + name: string, + pieces: string[], +): Promise { + return requestJson(`/projects/${projectId}/plan-kinds/${encodeURIComponent(kindId)}`, { + method: "PUT", + body: JSON.stringify({ name, pieces }), + }); +} + export function deletePlanKind(projectId: string, kindId: string): Promise { return requestJson(`/projects/${projectId}/plan-kinds/${encodeURIComponent(kindId)}`, { method: "DELETE", diff --git a/B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts b/B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts index 65a634851..7ec37a5f6 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts @@ -1,22 +1,29 @@ /* ============================================================================= * B07_DesignDetail_UI_PlanKinds.ts * 좌측 도면 목록의 「계획평면도」 한 묶음 — 종류(지형 · 노선배치도 · 대피소 · 라이다 · - * 사용자 추가)별 도면 단추, 종류 지우기 · 더하기, 대피소 · 사토장 자리 (PLAN 60장). + * 사용자 추가)별 도면 단추, 종류 지우기 · 추가 · 업데이트, 대피소 · 사토장 자리 (PLAN 60 · 67장). * * 종류 = 정보 조각 고르기 목록이다. 고치면 서버 파일이 바뀌고 `onChanged` 로 화면을 다시 세운다. + * 체크박스는 공용(`ui_template_checkbox`) · 측점 입력은 B05 구조물 폼과 같은 측점 NO + 거리 두 칸. * ========================================================================== */ +import { createCheckbox } from "@ui/ui_template_checkbox"; import { showToast } from "@ui/ui_template_elements"; +import { stationFields } from "../B05_Profile/B05_Profile_UI_Structures_Fields"; import { addPlanKind, deletePlanKind, fetchPlanKinds, savePlanMarks, + updatePlanKind, type DesignDrawingItem, type PlanKindsState, type PlanMark, } from "./B07_DesignDetail_Api_Fetch"; +/** 종단이 없어 측점 간격을 못 받았을 때 — 서버 `infer_station_interval` 기본값과 같음. */ +const DEFAULT_STATION_INTERVAL_M = 20; + export interface PlanKindsControls { projectId: string; /** 종류 · 대피소 자리를 고친 뒤 — 목록과 도면을 다시 받는다. */ @@ -44,30 +51,45 @@ async function run(action: () => Promise, controls: PlanKindsControls): } } -function addKindForm(state: PlanKindsState, controls: PlanKindsControls): HTMLElement { +/** 종류 — 공통 항목. 종류를 고르면 든 조각이 체크로 보이고 [업데이트] · 「새 종류」면 [추가]. */ +function kindForm(state: PlanKindsState, controls: PlanKindsControls): HTMLElement { const form = el("div", "b07-plan-tools__block"); - form.append(el("strong", "", "종류 더하기")); + form.append(el("strong", "", "종류")); + const pick = el("select", "b07-plan-tools__kind"); + pick.append( + ...state.kinds.map((kind) => new Option(kind.name, kind.id)), + new Option("새 종류", ""), + ); const name = el("input", "b07-plan-tools__name"); name.type = "text"; name.placeholder = "종류 이름"; name.maxLength = 40; - const boxes = state.pieces.map((piece) => { - const label = el("label", "b07-plan-tools__piece"); - const box = el("input", ""); - box.type = "checkbox"; - box.value = piece.id; - label.append(box, document.createTextNode(piece.name)); - return { label, box }; - }); + const boxes = state.pieces.map((piece) => createCheckbox(piece.name, { value: piece.id })); const pieceList = el("div", "b07-plan-tools__pieces"); - pieceList.append(...boxes.map((item) => item.label)); - const add = el("button", "b07-plan-tools__button", "더하기"); - add.type = "button"; - add.addEventListener("click", () => { - const pieces = boxes.filter((item) => item.box.checked).map((item) => item.box.value); - void run(() => addPlanKind(controls.projectId, name.value.trim(), pieces), controls); + pieceList.append(...boxes.map((box) => box.root)); + const action = el("button", "b07-plan-tools__button"); + action.type = "button"; + const show = (): void => { + const kind = state.kinds.find((item) => item.id === pick.value); + name.value = kind?.name ?? ""; + boxes.forEach(({ input }) => (input.checked = kind?.pieces.includes(input.value) ?? false)); + action.textContent = kind ? "업데이트" : "추가"; + }; + pick.addEventListener("change", show); + show(); + action.addEventListener("click", () => { + const pieces = boxes.filter((box) => box.input.checked).map((box) => box.input.value); + const kindId = pick.value; + const label = name.value.trim(); + void run( + () => + kindId + ? updatePlanKind(controls.projectId, kindId, label, pieces) + : addPlanKind(controls.projectId, label, pieces), + controls, + ); }); - form.append(name, pieceList, add); + form.append(pick, name, pieceList, action); return form; } @@ -78,28 +100,18 @@ function marksForm(state: PlanKindsState, controls: PlanKindsControls): HTMLElem el( "p", "b07-plan-tools__hint", - "구조물로 놓은 대피소 · 사토장은 저절로 실립니다. 더 놓을 측점(m)만 적으세요.", + "구조물로 놓은 대피소 · 사토장은 저절로 실립니다. 더 놓을 측점(NO + 거리)만 적으세요.", ), ); + const interval = state.station_interval_m || DEFAULT_STATION_INTERVAL_M; const rows = el("div", "b07-plan-tools__marks"); - const readers = new Map PlanMark>(); + const readers = new Map PlanMark | null>(); const addRow = (mark: PlanMark): void => { const row = el("div", "b07-plan-tools__mark"); - const chainage = el("input", "b07-plan-tools__chainage"); - chainage.type = "number"; - chainage.min = "0"; - chainage.step = "0.1"; - chainage.value = String(mark.chainage_m); - const flag = (text: string, checked: boolean): [HTMLLabelElement, HTMLInputElement] => { - const label = el("label", "b07-plan-tools__piece"); - const box = el("input", ""); - box.type = "checkbox"; - box.checked = checked; - label.append(box, document.createTextNode(text)); - return [label, box]; - }; - const [refugeLabel, refuge] = flag("대피소", mark.refuge); - const [spoilLabel, spoil] = flag("사토장", mark.spoil); + const station = stationFields("측점", () => interval); + station.write(mark.chainage_m, interval); + const refuge = createCheckbox("대피소", { checked: mark.refuge }); + const spoil = createCheckbox("사토장", { checked: mark.spoil }); const remove = el("button", "b07-plan-tools__remove", "×"); remove.type = "button"; remove.title = "이 자리 빼기"; @@ -107,23 +119,28 @@ function marksForm(state: PlanKindsState, controls: PlanKindsControls): HTMLElem readers.delete(row); row.remove(); }); - row.append(chainage, refugeLabel, spoilLabel, remove); - readers.set(row, () => ({ - chainage_m: Number(chainage.value), - refuge: refuge.checked, - spoil: spoil.checked, - })); + row.append(station.wrap, refuge.root, spoil.root, remove); + readers.set(row, () => { + const chainage = station.read(interval, true); + return chainage === null + ? null + : { chainage_m: chainage, refuge: refuge.input.checked, spoil: spoil.input.checked }; + }); rows.append(row); }; state.marks.forEach(addRow); - const more = el("button", "b07-plan-tools__button", "자리 더하기"); + const more = el("button", "b07-plan-tools__button", "자리 추가"); more.type = "button"; more.addEventListener("click", () => addRow({ chainage_m: 0, refuge: true, spoil: false })); - const save = el("button", "b07-plan-tools__button", "자리 저장"); + const save = el("button", "b07-plan-tools__button", "업데이트"); save.type = "button"; save.addEventListener("click", () => { const marks = [...readers.values()].map((read) => read()); - void run(() => savePlanMarks(controls.projectId, marks), controls); + if (marks.some((mark) => mark === null)) { + showToast("측점 NO + 거리를 바르게 적어 주세요.", "error"); + return; + } + void run(() => savePlanMarks(controls.projectId, marks as PlanMark[]), controls); }); const actions = el("div", "b07-plan-tools__actions"); actions.append(more, save); @@ -131,7 +148,7 @@ function marksForm(state: PlanKindsState, controls: PlanKindsControls): HTMLElem return form; } -/** 「계획평면도」 묶음 — 종류마다 이름 줄(× 지우기) + 장 단추, 아래에 더하기 · 대피소 자리. */ +/** 「계획평면도」 묶음 — 종류마다 이름 줄(× 지우기) + 장 단추, 아래에 종류 · 대피소 자리. */ export function buildPlanSection( items: DesignDrawingItem[], makeButton: (drawing: DesignDrawingItem, label: string) => HTMLButtonElement, @@ -161,9 +178,7 @@ export function buildPlanSection( remove.type = "button"; remove.title = `「${name}」 종류 지우기`; remove.addEventListener("click", () => { - if ( - !window.confirm(`「${name}」 종류를 지웁니다. 다시 쓰려면 종류 더하기로 새로 만듭니다.`) - ) + if (!window.confirm(`「${name}」 종류를 지웁니다. 다시 쓰려면 「새 종류」로 추가합니다.`)) return; void run(() => deletePlanKind(controls.projectId, kindId), controls); }); @@ -174,14 +189,14 @@ export function buildPlanSection( } if (controls) { - // 더하기 · 대피소 자리는 펼칠 때 서버에서 지금 상태를 받는다. + // 종류 · 대피소 자리는 펼칠 때 서버에서 지금 상태를 받는다. const tools = el("details", "b07-plan-tools"); - tools.append(el("summary", "", "종류 더하기 · 대피소 자리")); + tools.append(el("summary", "", "종류 추가 · 업데이트 · 대피소 자리")); tools.addEventListener("toggle", () => { if (!tools.open || tools.dataset.loaded) return; tools.dataset.loaded = "true"; void fetchPlanKinds(controls.projectId) - .then((state) => tools.append(addKindForm(state, controls), marksForm(state, controls))) + .then((state) => tools.append(kindForm(state, controls), marksForm(state, controls))) .catch((error: unknown) => { delete tools.dataset.loaded; showToast( diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Style.css b/B07_DesignDetail/B07_DesignDetail_UI_Style.css index d278b1b24..d098787a4 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Style.css +++ b/B07_DesignDetail/B07_DesignDetail_UI_Style.css @@ -235,7 +235,7 @@ /* 표준도 제원 입력 모양은 B08 로 이관(2026-09-13) — `B08_Quantity_UI_StructureSheet_Spec.ts` 가 스스로 넣음. */ -/* 계획평면도 묶음 — 종류별 이름 줄 + 장 단추, 아래 더하기 · 대피소 자리(PLAN 60장) */ +/* 계획평면도 묶음 — 종류별 이름 줄 + 장 단추, 아래 종류 추가 · 업데이트 · 대피소 자리(PLAN 60 · 67장) */ .b07-plan-kind { display: flex; flex-direction: column; @@ -278,11 +278,9 @@ grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); } -.b07-plan-tools__piece { - display: flex; - align-items: center; - gap: var(--spacing-4); - white-space: nowrap; +.b07-plan-tools__kind, +.b07-plan-tools__name { + min-width: 0; } .b07-plan-tools__hint { @@ -291,9 +289,30 @@ font-size: var(--text-caption); } -.b07-plan-tools__chainage { - width: 72px; +/* 측점 NO + 거리 두 칸(B05 stationFields) — 한 줄: [측점][NO][+m] */ +.b07-plan-tools__mark .b05-route__field { + display: flex; + flex: 1; + align-items: center; + gap: var(--spacing-4); min-width: 0; + color: var(--color-text-muted); + font-size: var(--text-caption); +} + +.b07-plan-tools__mark .b05-structure__station-pair { + display: flex; + gap: var(--spacing-4); + min-width: 0; +} + +.b07-plan-tools__mark .b05-structure__station-pair input { + width: 52px; + min-width: 0; +} + +.b07-plan-tools__mark input.is-invalid { + border-color: var(--color-danger); } .b07-plan-tools__button, diff --git a/ui_template/ui_template_checkbox.css b/ui_template/ui_template_checkbox.css new file mode 100644 index 000000000..c896db75b --- /dev/null +++ b/ui_template/ui_template_checkbox.css @@ -0,0 +1,34 @@ +/* 공용 체크박스(ui_template_checkbox.ts) — 글 한 줄 · 색은 테마 토큰 */ +.ui-checkbox { + display: inline-flex; + align-items: center; + gap: var(--spacing-4); + min-width: 0; + color: var(--color-text-body); + white-space: nowrap; + cursor: pointer; +} + +.ui-checkbox__input { + flex: 0 0 auto; + width: 14px; + height: 14px; + margin: 0; + accent-color: var(--color-primary); + cursor: pointer; +} + +.ui-checkbox__input:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 1px; +} + +.ui-checkbox__text { + overflow: hidden; + text-overflow: ellipsis; +} + +.ui-checkbox:has(.ui-checkbox__input:disabled) { + color: var(--color-text-muted); + cursor: default; +} diff --git a/ui_template/ui_template_checkbox.ts b/ui_template/ui_template_checkbox.ts new file mode 100644 index 000000000..0f8845a31 --- /dev/null +++ b/ui_template/ui_template_checkbox.ts @@ -0,0 +1,32 @@ +/* ============================================================================= + * ui_template_checkbox.ts + * 공용 체크박스 — [□ 글] 한 줄 · 색은 테마 토큰(밝음 · 어두움 같이). 줄 세우기는 부르는 쪽 몫. + * + * const box = createCheckbox("등고선", { checked: true, value: "contour" }); + * panel.append(box.root); // 읽기 = box.input.checked · box.input.value + * ========================================================================== */ + +import "./ui_template_checkbox.css"; + +export interface Checkbox { + root: HTMLLabelElement; + input: HTMLInputElement; +} + +export function createCheckbox( + text: string, + options: { checked?: boolean; value?: string } = {}, +): Checkbox { + const root = document.createElement("label"); + root.className = "ui-checkbox"; + const input = document.createElement("input"); + input.type = "checkbox"; + input.className = "ui-checkbox__input"; + input.checked = options.checked ?? false; + if (options.value !== undefined) input.value = options.value; + const caption = document.createElement("span"); + caption.className = "ui-checkbox__text"; + caption.textContent = text; + root.append(input, caption); + return { root, input }; +} From 426e574cd2b2c2f2da085d834d98879ec03eb8f1 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 23:09:21 +0900 Subject: [PATCH 4/6] =?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"] From 0639e029a7cf463e055b42fa5f1b1ac7c7a6edf8 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 23:13:08 +0900 Subject: [PATCH 5/6] =?UTF-8?q?chore(plan):=2069-7=20=EB=B0=95=EC=8A=A4?= =?UTF-8?q?=EC=95=94=EA=B1=B0=20=C2=B7=20=EC=84=B8=EC=9B=94=EA=B5=90=20?= =?UTF-8?q?=EA=B3=84=EC=82=B0=20=EB=B0=A9=EB=B2=95=20=EA=B2=80=ED=86=A0=20?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FJqWQpoB1RE7hiaQ2wH8qV --- .../조사/2026-10-07_69-7_박스_세월교_계산.md | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 docs/raw/조사/2026-10-07_69-7_박스_세월교_계산.md diff --git a/docs/raw/조사/2026-10-07_69-7_박스_세월교_계산.md b/docs/raw/조사/2026-10-07_69-7_박스_세월교_계산.md new file mode 100644 index 000000000..b984eb30c --- /dev/null +++ b/docs/raw/조사/2026-10-07_69-7_박스_세월교_계산.md @@ -0,0 +1,60 @@ +# 69-7 박스암거 · 세월교 지점 계산 방법 검토 (검토만 · 코드 안 고침) + +## 결론 한 줄 + +- 박스암거 = 사각 단면 Manning(만관 대신 수심 비율 · 20 % 단면 여유) · 근거 있음. +- 세월교 · 물넘이 = 지식DB에 월류(위어) 공식 없음 · 실무 사례는 개수로 Manning(지금 B05 `fordSection` 과 같음) · 이 방식 권고. +- 「관 + 월류」 유량 나눔 기준 없음 → 사용자 판단 필요. + +## 1. 박스암거 + +| 근거 | 자리 | 내용 | 성격 | +|---|---|---|---| +| 선정 | `original/임도기술교본/3. 임도 계획과 설계/3. 실시설계.md` L387~403 | 수리계산상 배수관 Ø1,500 ㎜ 이상 유역 · 협곡 · 계곡 횡단경사 40 % 이내 | 실무(교본) | +| 선정 보조 | `original/임도기술교본/8. 배수공사/1. 암거(속도랑).md` L43 | 유량 많고 계곡 좁으면 박스 · 관 2련 이상이면 물넘이 검토 | 실무(교본) | +| 할증 · 여유 | `technical_info/01_임도/02_상세설계/횡단배수관_암거.md` §6(별표2 Ⅰ.2.카) | 통수단면 = 최대홍수유출량 2.0배 이상 · 복토 50 ㎝ 이상 · DB-18 | 법령 정리 | +| 식 · n | `technical_info/01_임도/03_계산정보/통수단면.md` §2~4 | V = 1/n·R^2/3·S^1/2 · 콘크리트 수로 n 0.015(양호)/0.017(보통) · 직사각형 B = 2H 경제 | KDS 44 40 00 | +| 단면 여유 | `original/표준시방서/…/KDS 44 40 15 횡단배수(23.01).md` L50 | 암거 · 배수관 20 % 단면적 여유 | KDS(도로) | +| 사례 | `original/실무문서/2024년 간선임도(기번3-울진.대흥)(1공구)/_분석_수량산출.md` L109 | BOX 3.0×1.2 · n 0.013 → 29.236 ㎥/s · 흄관 Ø1000 부족으로 BOX/물넘이 채택 | 실무 | + +제안 식(사각 단면 · 화면 칸 = 폭 B · 높이 H · 련수 · n · 경사 S): + +- 수심 h = 0.8 H(20 % 여유 · KDS 44 40 15 L50을 높이로 옮긴 것 — 사용자 확정 필요) +- A = B·h · P = B + 2h(바닥 + 양 벽 · 뚜껑 안 닿음) · R = A / P +- Q = 련수 × A × (1/n)·R^2/3·S^1/2 ≥ Qd(= Q × 2.0) → OK/NG +- 지금 관 방식 화면에 「단면 가정」 갈래로 「박스」 하나 더함 → 단면 그림만 사각 · 나머지 같음. + +## 2. 세월교 · 물넘이 포장 + +| 근거 | 자리 | 내용 | 성격 | +|---|---|---|---| +| 법정 | 별표2 Ⅰ.2.아.(2) (`technical_info/01_임도/02_상세설계/개거_세월시설.md` §3) | 소계류는 물넘이포장 · 세월교 · 수리계산으로 적정 단면 — 계산 방법 미지정 | 법령 | +| 행정규칙 | `original/행정규칙/임도설치 및 관리 등에 관한 규정/현행_20240719.md` L118 | 계류 횡단 구간은 물넘이 포장(세월교) · 교량 | 행정규칙 | +| 실무 기준 | `임도기술교본/3. 임도 계획과 설계/3. 실시설계.md` L341~385 | 종단 3~5 % · 계곡 횡단경사 30 % 이내 · 물받이 길이 ≥ 2H · 두께 ≥ 30 ㎝ · 구체 길이 = 홍수흔적법 · 세월교 구체에 원형 관 검토 | 실무(교본) | +| 정의 | `임도기술교본/1. 교본의 개요/3. 용어해설.md` L131·L133 | 세월시설 = 노면 아래 관거 함께 · 평시 관 · 홍수 때 노면 월류 | 실무(교본) | +| 사례 | 울진 1공구 `_분석_수량산출.md` L106~109 | 물넘이 B 20 m · 수심 0.27 m · n 0.017 · 개수로 Manning(V 7.636 m/s) · 위어 식 안 씀 | 실무 | +| 유일한 위어형 | `original/사방기술교본/3. 야계사방/2-다2. 사방댐설치사업 - 불투과형 설계.md` L171~233 | 사방댐 방수로 Qp = (2/15)·C·√(2g)·(3B1+2B2)·h^3/2 · C 0.6~0.66 | 실무(사방 · 임도 규정 아님) | + +지금 코드: `B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts` `fordSection` — 사각 개수로 Manning 역산으로 월류 수심 · `A00_Common/design/design_structure_ford_calc.ts` 0.01 m 올림 · `config/config_system_design.py` FORD_MANNING_N 0.017 · FORD_SLOPE 0.10(울진 역산). + +제안 갈래(사용자 판단): + +1. **월류만(권고 · 실무 사례 · 지금 B05 와 같은 식)** — 물넘이 폭 B · n 0.017 · S 로 Qd 전량을 노면 월류로 봄 · 수심 h = Manning 역산 · 판정 = h ≤ 허용 월류 수심(값 없음 → 사용자 지정). +2. **관 + 월류** — 관 통수량 Qp(지금 원형 관 식) 먼저 · 남는 Qd − Qp 를 월류 수심으로 · 근거 = 용어해설의 정성 정의만 · 나눔 기준 · 빈도 근거 없음. +3. **위어 식** — 광정위어 Q = C·B·h^1.5 · 지식DB 근거 없음(사방댐 식만) · 권고 안 함. + +B08 계산서에 넣을 때: 「단면 검토」 카드 머리에 시설 종류(관 · 박스 · 세월교 · 물넘이)를 측점의 facility 로 고르고 칸만 갈래별로 바꿈 · B05 값은 안 이음(69-6 원칙 유지) · 식은 서버 단독. + +## 3. 없는 것 + +- 임도용 광정위어 공식 · 계수(C 1.7 · 1.84) — 법령 · KDS · 교본 어디에도 없음. +- 세월교 관 · 월류 유량 배분 · 각 빈도 — 없음(공통 100년 · 2.0배만). +- 월류 폭 · 수심 상한 — 수치 없음(홍수흔적법 · 지형으로 정함). +- 박스 수심 비율(0.8H 등) · 최소 규격 — 없음(20 % 단면 여유 · 2.0배만). +- 세월교 · 박스 표준도 수치 — 교본 그림 이미지뿐. + +## 4. 사용자 확인 칸 + +- [ ] 박스 수심 비율 0.8H(20 % 여유)로 갈지 +- [ ] 세월교 · 물넘이 = 갈래 1(월류만) · 2(관 + 월류) 중 +- [ ] 허용 월류 수심 값 From 89433a352dab2a9bfd0fa4a35f1647f67d23b78a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 23:14:14 +0900 Subject: [PATCH 6/6] =?UTF-8?q?feat(B05):=20=EB=85=B8=EC=84=A0=20=EC=B6=94?= =?UTF-8?q?=EC=B2=9C=20=EC=A2=85=EB=8B=A8=20=EB=B0=98=EC=98=81=20=EC=BC=9C?= =?UTF-8?q?=EA=B8=B0=C2=B7=EB=81=84=EA=B8=B0=20+=20=EA=B0=88=EB=9E=98=20?= =?UTF-8?q?=EC=84=A4=EB=AA=85=20=ED=95=9C=20=EC=AA=BD=EC=94=A9=20=EB=84=98?= =?UTF-8?q?=EA=B8=B0=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 64-1: 추천 창 「종단 반영」 체크(기본 켬) — 요청 with_profile 로 서버가 가름 · 끄면 원지반만(63-4 전 점수) · 묶음 캐시에 남은 종단은 걷어 냄 · 값은 편집 창 안에서만(저장 안 함) - 64-2: 설명 칸을 갈래 한 쪽씩 ◀ ▶ + 「n / 8」 · 고른 갈래 쪽부터 · 토공 최소 · 공사비 어림 최소 쪽에 종단 반영 설명 추가 - 시험: test_64_1_profile_toggle.py · ui_checks 64-1 · 64-2 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- B05_Profile/B05_Profile_Api_RouteInitial.ts | 2 + .../B05_Profile_Engine_RouteInitial.py | 27 ++++-- .../B05_Profile_Engine_RouteInitial_Pool.py | 17 ++-- ...B05_Profile_Engine_RouteInitial_Profile.py | 17 ++++ B05_Profile/B05_Profile_Router_Replan.py | 2 + .../B05_Profile_Router_RouteInitial.py | 5 +- .../B05_Profile_UI_RouteEdit_Initial.ts | 5 ++ .../B05_Profile_UI_RouteEdit_Recommend.ts | 30 ++++++- ...B05_Profile_UI_RouteEdit_Recommend_Help.ts | 82 +++++++++++++++---- .../B05_Profile_UI_Style_RouteRecommend.css | 45 +++++++++- resources/tester/test_64_1_profile_toggle.py | 73 +++++++++++++++++ .../ui_checks/64-1_추천_종단반영_켬끔.js | 18 ++++ .../tester/ui_checks/64-2_추천_설명_쪽넘김.js | 20 +++++ 13 files changed, 309 insertions(+), 34 deletions(-) create mode 100644 resources/tester/test_64_1_profile_toggle.py create mode 100644 resources/tester/ui_checks/64-1_추천_종단반영_켬끔.js create mode 100644 resources/tester/ui_checks/64-2_추천_설명_쪽넘김.js diff --git a/B05_Profile/B05_Profile_Api_RouteInitial.ts b/B05_Profile/B05_Profile_Api_RouteInitial.ts index 4fc9e802f..83781dede 100644 --- a/B05_Profile/B05_Profile_Api_RouteInitial.ts +++ b/B05_Profile/B05_Profile_Api_RouteInitial.ts @@ -40,6 +40,8 @@ export interface RouteInitialRequest { mode: RouteInitialLogic; weights?: RouteInitialWeights; criteria?: RouteInitialCriteria; + /** 추천 점수에 균형 종단(63-4)을 넣을지 — 끄면 원지반만. 안 보내면 켬(서버 기본). */ + with_profile?: boolean; } /** 서버 길이 아직 없다(404·405) — 화면은 이 글로 알린다. */ diff --git a/B05_Profile/B05_Profile_Engine_RouteInitial.py b/B05_Profile/B05_Profile_Engine_RouteInitial.py index ad9fe1e3b..2ce0afed1 100644 --- a/B05_Profile/B05_Profile_Engine_RouteInitial.py +++ b/B05_Profile/B05_Profile_Engine_RouteInitial.py @@ -64,7 +64,11 @@ from B05_Profile.B05_Profile_Engine_RouteInitial_Pool import ( similar, weighted, ) -from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import attach_profile, cover_shortfall +from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import ( + attach_profile, + cover_shortfall, + plain_metrics, +) from B05_Profile.B05_Profile_Engine_RouteInitial_Search import ( CORRIDOR_M, ROAD_WIDTH_M, @@ -219,6 +223,7 @@ def generate_initial_route( target_grade: float | None = None, design_speed: int = 20, pipes: list[tuple[float, float, float]] | None = None, + with_profile: bool = True, ) -> dict[str, Any]: """갈래 하나로 초기 계획노선을 만들고 비교값을 붙여 돌려준다. `max_grade` 는 비율(0.14). @@ -228,6 +233,7 @@ def generate_initial_route( 후보마다 균형 종단(`..._Profile` · PLAN 63-4)을 붙여 토공 점수를 그것으로 고른다 — 편집 창 추천 점수 · 미리 보기에만 쓰고 확정 종단과 무관. `pipes`(x, y, 덮개 여유)는 덮개 부족 수만 센다. + `with_profile` 끄면(종단 반영 끔) 종단 없이 63-4 전 점수(원지반 가늠)로 고른다. """ if mode not in MODES: raise ValueError(f"모르는 갈래: {mode}") @@ -303,12 +309,16 @@ def generate_initial_route( if target == "weighted": outline, metrics = weighted(pool, used, min_radius_m, measure) else: - for route in [*pool.candidates, pool.follow]: - profiled(route) - outline, metrics = pick(target, pool.candidates) - profiled((outline, metrics)) - if pipes and "error" not in metrics["profile"]: - metrics["profile"].update(cover_shortfall(metrics["profile"], outline.vertices, pipes)) + if with_profile: + for route in [*pool.candidates, pool.follow]: + profiled(route) + outline, metrics = pick(target, pool.candidates, with_profile) + if with_profile: + profiled((outline, metrics)) + if pipes and "error" not in metrics["profile"]: + metrics["profile"].update(cover_shortfall(metrics["profile"], outline.vertices, pipes)) + else: + metrics = plain_metrics(metrics) return { "mode": mode, "logic": mode, @@ -321,7 +331,8 @@ def generate_initial_route( "curves": [curve.as_dict() for curve in outline.curves], "metrics": metrics, # 10 m 안인 다른 갈래(같은 노선 알림) · 제약 안 띠 폭 어림 — 추적은 묶음이 이미 있을 때만 - "similar": None if pool is None else similar(pool, target, outline), + "similar": None if pool is None else similar(pool, target, outline, with_profile), + "with_profile": with_profile, "band_width_m": None if pool is None else round(pool.band_width_m, 1), "criteria": { "max_grade_pct": round(max_grade * 100.0, 2), diff --git a/B05_Profile/B05_Profile_Engine_RouteInitial_Pool.py b/B05_Profile/B05_Profile_Engine_RouteInitial_Pool.py index 3161130a1..f09392ef6 100644 --- a/B05_Profile/B05_Profile_Engine_RouteInitial_Pool.py +++ b/B05_Profile/B05_Profile_Engine_RouteInitial_Pool.py @@ -36,7 +36,7 @@ from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import ( COST_EARTH_M3, COST_LENGTH_M, ) -from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import profile_earth +from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import plain_metrics, profile_earth from B05_Profile.B05_Profile_Engine_RouteInitial_Search import ( CROSSING_BAND, OBJECTIVES, @@ -213,12 +213,15 @@ def build_pool( return pool -def similar(pool: Pool, mode: str, outline: Any) -> list[dict[str, Any]]: +def similar(pool: Pool, mode: str, outline: Any, with_profile: bool = True) -> list[dict[str, Any]]: """이 노선과 `SIMILAR_M` 안인 다른 갈래 — 갈래 이름 · 가장 먼 거리(m). 화면이 「이 지형은 제약 안에서 갈 길이 한 띠뿐 · ○○ 과 거의 같은 노선」을 알리는 데 쓴다.""" here = LineString(outline.vertices) - others = {"follow": pool.follow, **{name: pick(name, pool.candidates) for name in GOALS}} + others = { + "follow": pool.follow, + **{name: pick(name, pool.candidates, with_profile) for name in GOALS}, + } found = [] for name, (other, _) in others.items(): if name == mode or other is None: @@ -284,7 +287,9 @@ GOALS: dict[str, Callable[[dict[str, Any]], tuple[float, ...]]] = { } -def pick(mode: str, candidates: list[Route]) -> Route: - """갈래 목표 비교값이 가장 좋은 후보. 같으면 앞 후보.""" +def pick(mode: str, candidates: list[Route], with_profile: bool = True) -> Route: + """갈래 목표 비교값이 가장 좋은 후보. 같으면 앞 후보. `with_profile` 끔 = 원지반 가늠 점수.""" goal = GOALS[mode] - return min(candidates, key=lambda item: goal(item[1])) + return min( + candidates, key=lambda item: goal(item[1] if with_profile else plain_metrics(item[1])) + ) diff --git a/B05_Profile/B05_Profile_Engine_RouteInitial_Profile.py b/B05_Profile/B05_Profile_Engine_RouteInitial_Profile.py index 869eba157..87b991664 100644 --- a/B05_Profile/B05_Profile_Engine_RouteInitial_Profile.py +++ b/B05_Profile/B05_Profile_Engine_RouteInitial_Profile.py @@ -366,6 +366,7 @@ def attach_profile( metrics["profile"] = {"error": str(error)} return estimate = metrics["cut_m3"] + metrics["fill_m3"] + metrics["cost_index_base"] = metrics["cost_index"] # 「종단 반영」 끔이 되돌아갈 값 metrics["profile"] = profile metrics["earth_estimate_m3"] = round(estimate, 1) metrics["cost_index"] = round( @@ -376,3 +377,19 @@ def attach_profile( def profile_earth(profile: dict[str, Any]) -> float: """균형 종단 토공 점수 — 절토(자연) + 성토 + 반입 + 사토 ㎥.""" return profile["cut_m3"] + profile["fill_m3"] + profile["borrow_m3"] + profile["spoil_m3"] + + +def plain_metrics(metrics: dict[str, Any]) -> dict[str, Any]: + """균형 종단을 붙이기 전 비교값(복사본) — 「종단 반영」 끔(원지반 가늠 점수). + + 묶음 캐시의 비교값은 종단을 붙인 채 남으므로, 끌 때는 붙인 것을 걷어 낸다.""" + if "profile" not in metrics and "cost_index_base" not in metrics: + return metrics + plain = { + key: value + for key, value in metrics.items() + if key not in ("profile", "earth_estimate_m3", "cost_index_base") + } + if "cost_index_base" in metrics: + plain["cost_index"] = metrics["cost_index_base"] + return plain diff --git a/B05_Profile/B05_Profile_Router_Replan.py b/B05_Profile/B05_Profile_Router_Replan.py index 569e62863..902f5390c 100644 --- a/B05_Profile/B05_Profile_Router_Replan.py +++ b/B05_Profile/B05_Profile_Router_Replan.py @@ -118,6 +118,8 @@ class RouteInitialRequest(BaseModel): ] = Field("follow", validation_alias=AliasChoices("logic", "mode")) weights: RouteInitialWeights | None = None criteria: RouteInitialCriteria | None = None + # 추천 점수에 균형 종단(PLAN 63-4)을 넣을지 — 끄면 원지반만(63-4 전 점수). 파일에 안 적힘. + with_profile: bool = True class RouteReplanRequest(BaseModel): diff --git a/B05_Profile/B05_Profile_Router_RouteInitial.py b/B05_Profile/B05_Profile_Router_RouteInitial.py index 020bec581..e748f7c34 100644 --- a/B05_Profile/B05_Profile_Router_RouteInitial.py +++ b/B05_Profile/B05_Profile_Router_RouteInitial.py @@ -80,7 +80,9 @@ async def initial_route( terrain, streams = await asyncio.to_thread( load_terrain_and_streams, project_root, selection ) - pipes = await asyncio.to_thread(project_pipes, project_root) + pipes = ( + await asyncio.to_thread(project_pipes, project_root) if request.with_profile else None + ) result = await asyncio.to_thread( functools.partial( generate_initial_route, @@ -96,6 +98,7 @@ async def initial_route( target_grade=None if target_pct is None else target_pct / 100.0, design_speed=design_speed or 20, pipes=pipes, + with_profile=request.with_profile, ) ) except (FileNotFoundError, ValueError) as error: diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts index 8c440275e..d12e1c55f 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts @@ -91,6 +91,8 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel { weights: null as RouteInitialWeights | null, locks: { ...NO_WEIGHT_LOCKS }, }; + /** 추천 「종단 반영」 — 이 편집 창이 사는 동안만 남는다(저장 · 캐시 · [취소] 되돌림 없이 창과 함께 사라짐). */ + const profileMemory = { withProfile: true }; /** `/route/plan` 한 번 읽은 것 — 예상노선(지도 바탕) · 초기값 노선([초기화]). 창이 사는 동안 안 바뀐다. */ let planRead: Promise | null = null; @@ -114,11 +116,13 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel { logic: RouteInitialLogic, weights: RouteInitialWeights | null, criteria: RouteInitialCriteria | null, + withProfile: boolean, ): Promise { const response = await fetchRouteInitial(projectId, { mode: logic, ...(weights ? { weights } : {}), ...(criteria ? { criteria } : {}), + ...(withProfile ? {} : { with_profile: false }), }); return normalizeRouteInitial(response, logic, weights, criteria); } @@ -137,6 +141,7 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel { defaults, weights: { ...(choice?.weights ?? base.weights ?? DEFAULT_WEIGHTS) }, weightMemory, + profileMemory, planMinRadiusM: params.planMinRadiusM(), expected: (plan?.expected ?? []).map(([x, y]) => [x, y]), current: opening!.planned.map(([x, y]) => [x, y]), diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts index dc18035ed..b91155e4b 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts @@ -45,7 +45,11 @@ import { type WeightKey, } from "./B05_Profile_UI_RouteEdit_Initial_Model"; import { createRecommendMap, RECOMMEND_MAP_HTML } from "./B05_Profile_UI_RouteEdit_Recommend_Map"; -import { recommendHelpHtml, syncRecommendHelp } from "./B05_Profile_UI_RouteEdit_Recommend_Help"; +import { + bindRecommendHelp, + recommendHelpHtml, + syncRecommendHelp, +} from "./B05_Profile_UI_RouteEdit_Recommend_Help"; import { balanceLine, candidatesTable, @@ -128,6 +132,8 @@ export interface RecommendParams { weights: RouteInitialWeights; /** 비중 · 고정 상태 — 편집 창이 사는 동안 모달을 닫았다 열어도 남는다. 여기서 고쳐 간다. */ weightMemory: { weights: RouteInitialWeights | null; locks: WeightLocks }; + /** 「종단 반영」 — 켬이 기본(지금 동작). 편집 창이 사는 동안만 남고 저장하지 않는다. */ + profileMemory: { withProfile: boolean }; planMinRadiusM: number; /** 지도 바탕 — 예상노선(원본 점 묶음) · 창을 연 노선(노드). */ expected: Vertex[]; @@ -137,6 +143,7 @@ export interface RecommendParams { logic: RouteInitialLogic, weights: RouteInitialWeights | null, criteria: RouteInitialCriteria | null, + withProfile: boolean, ) => Promise; /** [적용] — 미리 본 노선을 편집 창에 놓는다. */ onApply: (choice: InitialChoice) => void; @@ -211,6 +218,11 @@ export function openRecommendModal(params: RecommendParams): void { +
${CRITERIA_KEYS.map( (key) => `