From 07c4a7897c824a0c3d1d825a85c1a05788e5f494 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 1 Aug 2026 21:48:28 +0900 Subject: [PATCH] =?UTF-8?q?feat(B04):=20=EC=83=81=EC=84=B8=20=EB=B0=B0?= =?UTF-8?q?=EC=88=98=EC=9C=A0=EC=97=AD=20=EA=B3=84=EC=82=B0=20=E2=80=94=20?= =?UTF-8?q?=EA=B4=80=20=EB=A7=A4=EC=84=A4=20=EC=A7=80=EC=A0=90=20=ED=8E=B8?= =?UTF-8?q?=EC=A7=91=20+=20=EC=84=B8=EB=B6=80=EC=9C=A0=EC=97=AD=20?= =?UTF-8?q?=EB=B6=84=ED=95=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 관 매설 지점을 기준으로 세부 배수유역을 나누는 기능을 B04 2D 지도에 추가한다. 경계는 B04 전처리 격자(03_road_routing.npz)의 road_slot — 1m 셀마다 물이 도달하는 도로 셀 — 을 담당 관으로 라벨링해 그 경계로 잡는다. 종단 Z는 경계를 긋지 않고 "도로 셀이 어느 관으로 흐르는가"만 정한다. 공용 승격 (B04 관리자 화면과 B05 사용자 화면이 같은 결과를 내야 함) - common_util_drainage_detail.py: 관 보충(9)·세부유역 분할(10) 알고리즘 - common_util_drainage_context.py: 노선·종단 Z·좌표계 입력 준비 - common_util_drainage_pipes.py: 관 지점 정본 저장소(edits/pipe_points.json) - common_util_route_profile.py: 종단 Z 해석기(계획고 > 경로 정점 > 지표면 > CSV) - common_util_surface_sampler.py: B05 종횡단 sampler 이동 - B05 _prepare()의 노선 소스를 원청 계획노선 CSV로 정정(B04 격자와 누가거리 정합) B04 신규 API - GET /{project_id}/drainage/pipe-points 저장분 조회(없으면 자동 생성) - POST /{project_id}/drainage/detail-basins 편집 중 목록으로 재분할(저장 안 함) - PUT /{project_id}/drainage/pipe-points 모델 확정 시 관 지점·세부유역 커밋 B04 화면 - 관 마커 기본/자동/수동 색 구분, 계획선 스냅 드래그 이동 - 계획선 우클릭 "관 매설 추가" / 마커 우클릭 "관 매설 삭제" - 표시 토글 2그룹(관 매설 / 세부 유역)을 유입 집중점과 분리 - "상세유역 분석" 버튼을 눌렀을 때만 재계산 Co-Authored-By: Claude Opus 5 (1M context) --- B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts | 69 +++ .../B04_wf1_Surface_Router_Basins.py | 225 ++++++++ B04_wf1_Surface/B04_wf1_Surface_UI_Basins.ts | 438 +++++++++++++++ .../B04_wf1_Surface_UI_FlowStrength.ts | 39 +- .../B04_wf1_Surface_UI_MapLayers.ts | 75 +++ .../B04_wf1_Surface_UI_MapViewer.ts | 162 +++--- B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts | 3 + .../B04_wf1_Surface_UI_RouteSamples.ts | 69 +++ B04_wf1_Surface/B04_wf1_Surface_UI_Style.css | 33 ++ .../B05_wf2_Route_Engine_Drainage.py | 27 - .../B05_wf2_Route_Engine_Drainage_Basin.py | 508 ++--------------- .../B05_wf2_Route_Engine_Sections.py | 2 +- .../B05_wf2_Route_Engine_Sections_Core.py | 2 +- .../B05_wf2_Route_Router_Drainage.py | 60 +- .../B06_wf3_ProfileCross_Engine_Design.py | 45 +- .../B06_wf3_ProfileCross_Router.py | 10 +- .../B07_wf4_DesignDetail_Engine_Cad_Long.py | 4 +- .../B07_wf4_DesignDetail_Router.py | 6 +- common_util/common_util_drainage_context.py | 131 +++++ common_util/common_util_drainage_detail.py | 517 ++++++++++++++++++ common_util/common_util_drainage_pipes.py | 139 +++++ common_util/common_util_route_profile.py | 173 ++++++ .../common_util_surface_sampler.py | 6 +- config/config_system.py | 12 +- main.py | 2 + ui_template/ui_template_locale.ts | 32 ++ ui_template/ui_template_theme.css | 4 +- 27 files changed, 2077 insertions(+), 716 deletions(-) create mode 100644 B04_wf1_Surface/B04_wf1_Surface_Router_Basins.py create mode 100644 B04_wf1_Surface/B04_wf1_Surface_UI_Basins.ts create mode 100644 B04_wf1_Surface/B04_wf1_Surface_UI_MapLayers.ts create mode 100644 B04_wf1_Surface/B04_wf1_Surface_UI_RouteSamples.ts delete mode 100644 B05_wf2_Route/B05_wf2_Route_Engine_Drainage.py create mode 100644 common_util/common_util_drainage_context.py create mode 100644 common_util/common_util_drainage_detail.py create mode 100644 common_util/common_util_drainage_pipes.py create mode 100644 common_util/common_util_route_profile.py rename B05_wf2_Route/B05_wf2_Route_Engine_Sections_Sampler.py => common_util/common_util_surface_sampler.py (96%) diff --git a/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts b/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts index fc172ac1..f187db70 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts @@ -431,3 +431,72 @@ export async function fetchRoadInflow( { method: "GET" }, ); } + +/* ── 상세 배수유역(관 매설 지점 + 세부유역 분할) ─────────────────────────── */ + +/** 관이 그 자리에 있는 이유. 백엔드 `common_util_drainage_pipes`가 정의처다. */ +export type PipeSource = "stream" | "spacing" | "user"; + +export interface DetailPipePoint { + chainage_m: number; + lonlat: [number, number]; + source: PipeSource; +} + +export interface DetailBasin { + index: number; + chainage_m: number; + outlet_lonlat: [number, number]; + polygon_lonlat: Array<[number, number]>; + area_m2: number; + relief_m: number; + flow_length_m: number; + /** 관경 수식 미확정 — null이면 "미정"으로 표기한다. */ + pipe_diameter_mm: number | null; +} + +export interface DetailBasinResponse { + status: string; + project_id: string; + /** 종단 Z 출처(design_profile / route_points / surface / csv). */ + z_source: string; + route_length_m: number; + max_spacing_m: number; + min_spacing_m: number; + /** 응답의 관 목록이 저장분에서 온 것인지. */ + saved: boolean; + pipe_points: DetailPipePoint[]; + basins: DetailBasin[]; + pipe_count: number; +} + +/** 저장된 관 매설 지점과 그 세부유역. 저장분이 없으면 백엔드가 자동 생성해 돌려준다. */ +export async function fetchDetailPipePoints(projectId: string): Promise { + return requestJson(`/projects/${projectId}/drainage/pipe-points`, { + method: "GET", + }); +} + +/** 편집 중인 관 목록으로 세부유역을 다시 나눈다(저장하지 않는다). + * + * `points`를 비우면 저장분을 무시하고 기본 관 + 자동 보충으로 되돌린다. */ +export async function computeDetailBasins( + projectId: string, + points: Array<{ chainage_m: number; source: PipeSource }>, +): Promise { + return requestJson(`/projects/${projectId}/drainage/detail-basins`, { + method: "POST", + body: JSON.stringify({ points }), + }); +} + +/** 관 매설 지점을 정본으로 확정하고 세부유역 산출물까지 남긴다(모델 확정 시점). */ +export async function saveDetailPipePoints( + projectId: string, + points: Array<{ chainage_m: number; source: PipeSource }>, +): Promise { + return requestJson(`/projects/${projectId}/drainage/pipe-points`, { + method: "PUT", + body: JSON.stringify({ points }), + }); +} diff --git a/B04_wf1_Surface/B04_wf1_Surface_Router_Basins.py b/B04_wf1_Surface/B04_wf1_Surface_Router_Basins.py new file mode 100644 index 00000000..e1c2caa4 --- /dev/null +++ b/B04_wf1_Surface/B04_wf1_Surface_Router_Basins.py @@ -0,0 +1,225 @@ +"""상세 배수유역 API 라우터 (B04 — 관리자 검토용). + +격자 해석은 하지 않는다. 배수유역 분석이 저장해 둔 `03_road_routing` 산출물을 읽어 + + · 기본 관(도로 × 상류 세류선 교차점) + 최대 간격 자동 보충으로 관 목록을 만들고 + · 계획노선 종단 Z로 노면 물이 어느 관으로 가는지 정해 세부유역을 나눈다 + +계산 알고리즘은 B05 사용자 화면과 공용이다(`common_util_drainage_detail`) — 같은 이름의 +버튼은 같은 결과를 내야 하기 때문이다(2026-08-01 사용자 지시). + +편집분(관 지점)은 "이 모델 확정" 시점에만 파일로 남는다. 그 전까지는 화면 메모리에만 있고, +확정 없이 나가면 저장된 값으로 되돌아온다. +""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Any +from uuid import UUID + +from fastapi import APIRouter +from fastapi.responses import JSONResponse + +from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Export import drainage_dir +from common_util.common_util_drainage_context import DrainageContext, load_drainage_context +from common_util.common_util_drainage_detail import DrainageDetail, build_detail +from common_util.common_util_drainage_pipes import ( + PIPE_SOURCE_USER, + PipePoint, + load_pipe_points, + parse_pipe_points, + route_signature, + save_detail_basins, + save_pipe_points, +) +from common_util.common_util_route_geometry import StructureCandidate +from config.config_system import DRAINAGE_PIPE_MAX_SPACING_M, DRAINAGE_PIPE_MIN_SPACING_M + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/projects", tags=["B04 Surface Drainage Basins"]) + +_NO_ANALYSIS = "배수유역 분석 결과가 없습니다. [유역 분석]을 먼저 실행하세요." + + +def _build( + stored_path: str, context: DrainageContext, points: list[PipePoint] | None +) -> tuple[DrainageDetail | None, list[PipePoint]]: + """세부유역을 계산하고, 그 결과에 쓰인 관 목록을 함께 돌려준다. + + `points`가 없으면 B04 기본 관에 최대 간격 규칙으로 자동 보충한 목록이 만들어진다. + 있으면 그 자리를 그대로 쓰되, 관이 왜 거기 있는지(기본/자동/수동)는 넘겨받은 목록의 + 표시를 유지한다 — 계산기는 확정 좌표만 보므로 그대로 두면 전부 "수동"이 된다. + """ + directory = drainage_dir(stored_path) + chainages = [point.chainage_m for point in points] if points else None + detail = build_detail(directory, context.vertices, chainages) + if detail is None: + return None, [] + if points: + _retag(detail.pipes, points) + return detail, [ + PipePoint(chainage_m=pipe.chainage_m, source=pipe.reason) for pipe in detail.pipes + ] + + +def _retag(pipes: list[StructureCandidate], points: list[PipePoint]) -> None: + """확정 좌표로 되돌아온 관에 원래의 생성 사유를 다시 붙인다.""" + by_chainage = {round(point.chainage_m, 2): point.source for point in points} + for pipe in pipes: + source = by_chainage.get(round(pipe.chainage_m, 2)) + if source is None and points: + nearest = min(points, key=lambda item: abs(item.chainage_m - pipe.chainage_m)) + source = nearest.source + pipe.reason = source or PIPE_SOURCE_USER + + +def _payload( + project_id: UUID, + context: DrainageContext, + detail: DrainageDetail, + points: list[PipePoint], + saved: bool, +) -> dict[str, Any]: + """관 목록과 세부유역을 화면 좌표(WGS84)로 정리한다.""" + to_lonlat = context.to_lonlat + return { + "status": "success", + "project_id": str(project_id), + # 종단 Z를 어디서 가져왔는지 — 관 담당 구간이 갈리는 근거라 화면에서 확인 가능해야 한다. + "z_source": context.z_source, + "route_length_m": round(context.vertices[-1].chainage_m, 2), + "max_spacing_m": DRAINAGE_PIPE_MAX_SPACING_M, + "min_spacing_m": DRAINAGE_PIPE_MIN_SPACING_M, + "saved": saved, + "pipe_points": [ + { + "chainage_m": round(pipe.chainage_m, 2), + "lonlat": list(to_lonlat(pipe.x, pipe.y)), + "source": pipe.reason, + } + for pipe in detail.pipes + ], + "basins": [ + { + "index": basin.index, + "chainage_m": round(basin.chainage_m, 2), + "outlet_lonlat": list(to_lonlat(basin.outlet_x, basin.outlet_y)), + "polygon_lonlat": [list(to_lonlat(x, y)) for x, y in basin.boundary_xy], + "area_m2": round(basin.area_m2, 1), + "relief_m": round(basin.relief_m, 2), + "flow_length_m": round(basin.flow_length_m, 1), + # 관경 수식 미확정 — None이면 프론트가 "미정"으로 표기한다. + "pipe_diameter_mm": basin.pipe_diameter_mm, + } + for basin in detail.basins + ], + "pipe_count": len(points), + } + + +def _basin_features( + context: DrainageContext, detail: DrainageDetail, points: list[PipePoint] +) -> list[dict[str, Any]]: + """세부유역을 저장용 GeoJSON 피처로 바꾼다(관 지점도 같은 파일에 함께 남긴다).""" + to_lonlat = context.to_lonlat + features: list[dict[str, Any]] = [] + for basin in detail.basins: + ring = [list(to_lonlat(x, y)) for x, y in basin.boundary_xy] + if len(ring) < 4: + continue + features.append( + { + "type": "Feature", + "properties": { + "kind": "detail_basin", + "index": basin.index, + "chainage_m": round(basin.chainage_m, 2), + "area_m2": round(basin.area_m2, 1), + "relief_m": round(basin.relief_m, 2), + "flow_length_m": round(basin.flow_length_m, 1), + "pipe_diameter_mm": basin.pipe_diameter_mm, + }, + "geometry": {"type": "Polygon", "coordinates": [ring]}, + } + ) + for pipe, point in zip(detail.pipes, points): + features.append( + { + "type": "Feature", + "properties": { + "kind": "pipe", + "chainage_m": round(point.chainage_m, 2), + "source": point.source, + }, + "geometry": {"type": "Point", "coordinates": list(to_lonlat(pipe.x, pipe.y))}, + } + ) + return features + + +async def _resolve( + project_id: UUID, payload: dict[str, Any] | None, *, use_stored: bool +) -> tuple[DrainageContext, DrainageDetail, list[PipePoint], bool] | JSONResponse: + """요청 본문 → 저장분 → 자동 생성 순으로 관 목록을 정하고 세부유역까지 계산한다.""" + context, reason = await load_drainage_context(project_id) + if context is None: + return JSONResponse(status_code=404, content={"status": "error", "message": reason}) + + requested = parse_pipe_points((payload or {}).get("points")) + signature = route_signature(context.vertices) + stored = load_pipe_points(context.stored_path, signature) if use_stored else None + points = requested or stored or None + + result = await asyncio.to_thread(_build, context.stored_path, context, points) + detail, resolved = result + if detail is None: + return JSONResponse(status_code=404, content={"status": "error", "message": _NO_ANALYSIS}) + return context, detail, resolved, bool(stored) and not requested + + +@router.get("/{project_id}/drainage/pipe-points", response_model=None) +async def get_pipe_points(project_id: UUID) -> dict[str, Any] | JSONResponse: + """저장된 관 지점과 그 세부유역을 돌려준다. 저장분이 없으면 자동 생성한 목록.""" + resolved = await _resolve(project_id, None, use_stored=True) + if isinstance(resolved, JSONResponse): + return resolved + context, detail, points, saved = resolved + return _payload(project_id, context, detail, points, saved) + + +@router.post("/{project_id}/drainage/detail-basins", response_model=None) +async def post_detail_basins( + project_id: UUID, payload: dict[str, Any] | None = None +) -> dict[str, Any] | JSONResponse: + """화면에서 편집 중인 관 목록으로 세부유역을 다시 나눈다(저장하지 않는다). + + `points`를 비우고 부르면 저장분을 무시하고 기본 관 + 자동 보충으로 되돌린다 — + 노선이 바뀌어 전체 분석을 다시 돌린 직후의 경로다. + """ + resolved = await _resolve(project_id, payload, use_stored=False) + if isinstance(resolved, JSONResponse): + return resolved + context, detail, points, _ = resolved + return _payload(project_id, context, detail, points, saved=False) + + +@router.put("/{project_id}/drainage/pipe-points", response_model=None) +async def put_pipe_points( + project_id: UUID, payload: dict[str, Any] | None = None +) -> dict[str, Any] | JSONResponse: + """관 지점을 정본으로 확정하고 세부유역 산출물까지 함께 남긴다(모델 확정 시점).""" + resolved = await _resolve(project_id, payload, use_stored=True) + if isinstance(resolved, JSONResponse): + return resolved + context, detail, points, _ = resolved + + signature = route_signature(context.vertices) + saved = await asyncio.to_thread(save_pipe_points, context.stored_path, signature, points) + await asyncio.to_thread( + save_detail_basins, context.stored_path, _basin_features(context, detail, points) + ) + result = _payload(project_id, context, detail, points, saved=True) + result["saved_count"] = saved + return result diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_Basins.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_Basins.ts new file mode 100644 index 00000000..e086450a --- /dev/null +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_Basins.ts @@ -0,0 +1,438 @@ +/* ============================================================================= + * 상세 배수유역 오버레이 (B04 2D 지도 — 관리자 검토용) + * + * 계획선 위 **관 매설 지점**을 편집하고, 그 관마다 물이 모이는 **세부유역**을 겹쳐 그린다. + * + * · 기본 관 = 도로 × 상류 세류선 교차점 (백엔드 분석 산출물) + * · 자동 보충 = 관 최대 간격을 넘는 구간에 최소 개수로 채운 자리 + * · 수동 = 계획선 위에서 우클릭해 넣었거나, 끌어서 옮긴 관 + * + * 경계는 여기서 긋지 않는다. 백엔드가 1m 격자 셀마다 "이 셀 물이 어느 도로 셀로 가는가"를 + * 이미 풀어 두었고, 도로 셀은 종단 내리막을 따라 담당 관으로 묶인다. 그래서 같은 관으로 + * 묶인 셀 덩어리의 바깥선이 곧 세부유역 경계다 — 화면은 그 폴리곤을 받아 칠하기만 한다. + * + * 재계산은 **버튼을 눌렀을 때만** 돈다(2026-08-01 사용자 지시). 편집 중에는 마커만 움직이고, + * 확정 전에 화면을 떠나면 저장된 값으로 되돌아온다. + * ========================================================================== */ + +import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; +import { themeColor } from "@ui/ui_template_palette"; +import { + computeDetailBasins, + fetchDetailPipePoints, + saveDetailPipePoints, + type DetailBasin, + type PipeSource, + type VWorldMeta, +} from "./B04_wf1_Surface_Api_Fetch"; +import { + haloColor, + lonLatToScreen, + metricToScreen, + type Normalizer, + type ViewState, +} from "./B04_wf1_Surface_UI_MapRender"; +import { + nearestChainage, + pointAtChainage, + resampleRoute, + type RoutePoint, +} from "./B04_wf1_Surface_UI_RouteSamples"; + +function L(key: keyof typeof ui_locales): string { + return ui_locales[key][currentLanguageIndex]; +} + +/** 관 마커 반지름(px)과 잡기 여유(px). 유입 집중점 마커보다 조금 크게 둬서 구분된다. */ +const PIPE_RADIUS = 8; +const PIPE_HIT_SLACK = 5; +/** 계획선에서 이보다 멀면 "노선 위 우클릭"으로 보지 않는다(px). */ +const ROUTE_HIT_PX = 14; + +/** 관 생성 사유별 색. 정의처는 `ui_template_theme.css`. */ +const PIPE_COLORS: Record = { + stream: ["--map-pipe-marker", "#f97316"], + spacing: ["--map-pipe-auto", "#0ea5e9"], + user: ["--map-pipe-user", "#16a34a"], +}; + +/** 세부유역 채움 — 이웃끼리 구분만 되면 되므로 색상환을 균등 분할해 돌려 쓴다. */ +function basinColor(index: number, alpha: number): string { + const hue = (index * 137.508) % 360; // 황금각 — 인접 유역이 비슷한 색으로 붙지 않는다 + return `hsla(${hue.toFixed(0)}, 70%, 55%, ${alpha})`; +} + +interface PipeMarker { + chainage: number; + source: PipeSource; +} + +export interface DetailBasinOverlay { + /** 세부유역 재계산 버튼. */ + button: HTMLButtonElement; + /** 표시 토글 — 관 매설 마커 / 세부 유역. 유입 집중점과 겹쳐 볼 수 있게 따로 둔다. */ + partButtons: HTMLButtonElement[]; + statusElement: HTMLElement; + /** 우클릭 메뉴. 지도 뷰포트에 얹는다(뷰포트 기준 절대 위치). */ + menuElement: HTMLElement; + setProject: (projectId: string) => void; + /** 계획선(사업지 좌표계 m)과 배경지도 메타. 둘이 있어야 화면 좌표를 낼 수 있다. */ + setRoute: (points: ReadonlyArray, meta: VWorldMeta | null) => void; + clear: () => void; + /** 마커를 잡았으면 true(지도 팬 대신 마커 끌기로 넘어간다). */ + handlePointerDown: (view: ViewState, x: number, y: number) => boolean; + handlePointerMove: (view: ViewState, x: number, y: number) => boolean; + handlePointerUp: () => boolean; + /** 우클릭 메뉴를 띄웠으면 true. */ + handleContextMenu: (view: ViewState, x: number, y: number) => boolean; + /** 모델 확정 시점에 관 지점과 세부유역을 영구저장한다. */ + commit: () => Promise; + draw: (context: CanvasRenderingContext2D, normalizer: Normalizer | null, view: ViewState) => void; + dispose: () => void; +} + +export function createDetailBasinOverlay(onChange: () => void): DetailBasinOverlay { + let projectId: string | null = null; + let meta: VWorldMeta | null = null; + let samples: RoutePoint[] = []; + let pipes: PipeMarker[] = []; + let basins: DetailBasin[] = []; + let zSource = ""; + let minSpacing = 20; + let busy = false; + let dirty = false; + /** 끌고 있는 마커 번호. null이면 잡은 것이 없다. */ + let dragging: number | null = null; + let requestSequence = 0; + + const statusElement = document.createElement("span"); + statusElement.className = "b04-map__watershed-status"; + statusElement.hidden = true; + + const menuElement = document.createElement("div"); + menuElement.className = "b04-map__context-menu"; + menuElement.hidden = true; + + const button = document.createElement("button"); + button.type = "button"; + button.className = "b04-map__layer-button b04-map__layer-button--gis"; + button.textContent = L("B04_Surface_Basin_Btn"); + button.style.setProperty("--b04-layer-color", themeColor("--map-pipe-user", "#16a34a")); + button.title = L("B04_Surface_Basin_Btn_Tip"); + + const shownParts = { pipes: true, basins: true }; + const partButtons = ( + [ + ["pipes", "B04_Surface_Basin_Part_Pipes", PIPE_COLORS.stream], + ["basins", "B04_Surface_Basin_Part_Basins", ["--map-pipe-user", "#16a34a"]], + ] as const + ).map(([key, labelKey, token]) => { + const element = document.createElement("button"); + element.type = "button"; + element.className = "b04-map__layer-button b04-map__layer-button--gis is-active"; + element.textContent = L(labelKey); + element.style.setProperty("--b04-layer-color", themeColor(token[0], token[1])); + element.setAttribute("aria-pressed", "true"); + element.addEventListener("click", () => { + shownParts[key] = !shownParts[key]; + element.classList.toggle("is-active", shownParts[key]); + element.setAttribute("aria-pressed", String(shownParts[key])); + onChange(); + }); + return element; + }); + + function say(text: string): void { + statusElement.textContent = text; + statusElement.hidden = text === ""; + } + + function summary(): string { + const count = (source: PipeSource): number => + pipes.filter((pipe) => pipe.source === source).length; + const line = L("B04_Surface_Basin_Summary") + .replace("{pipes}", String(pipes.length)) + .replace("{stream}", String(count("stream"))) + .replace("{spacing}", String(count("spacing"))) + .replace("{user}", String(count("user"))) + .replace("{basins}", String(basins.length)) + .replace("{source}", zSource || "-"); + return dirty ? `${line} · ${L("B04_Surface_Basin_Edited")}` : line; + } + + function pipeScreen(view: ViewState, chainage: number): [number, number] | null { + if (!meta) return null; + const point = pointAtChainage(samples, chainage); + return point ? metricToScreen(meta, view, point.x, point.y) : null; + } + + /** 마커 히트 판정. 가장 가까운 것 하나. */ + function hitPipe(view: ViewState, x: number, y: number): number | null { + let hit: number | null = null; + let best = Number.POSITIVE_INFINITY; + pipes.forEach((pipe, index) => { + const screen = pipeScreen(view, pipe.chainage); + if (!screen) return; + const distance = Math.hypot(screen[0] - x, screen[1] - y); + if (distance <= PIPE_RADIUS + PIPE_HIT_SLACK && distance < best) { + hit = index; + best = distance; + } + }); + return hit; + } + + /** 화면 좌표를 계획선 위 누가거리로 스냅한다. 계획선에서 멀면 null. */ + function snap(view: ViewState, x: number, y: number): number | null { + if (!meta) return null; + const found = nearestChainage( + samples, + (point) => metricToScreen(meta as VWorldMeta, view, point.x, point.y), + x, + y, + ); + return found && found.distance <= ROUTE_HIT_PX ? found.chainage : null; + } + + /** 관끼리 최소 간격을 지키는지 본다(자기 자신은 제외). */ + function tooClose(chainage: number, exceptIndex: number | null): boolean { + return pipes.some( + (pipe, index) => + index !== exceptIndex && Math.abs(pipe.chainage - chainage) < minSpacing - 1e-6, + ); + } + + function markEdited(): void { + dirty = true; + say(summary()); + onChange(); + } + + function closeMenu(): void { + menuElement.hidden = true; + menuElement.replaceChildren(); + } + + function openMenu(x: number, y: number, items: Array<[string, () => void]>): void { + menuElement.replaceChildren(); + items.forEach(([label, action]) => { + const entry = document.createElement("button"); + entry.type = "button"; + entry.className = "b04-map__context-menu-item"; + entry.textContent = label; + entry.addEventListener("click", () => { + closeMenu(); + action(); + }); + menuElement.append(entry); + }); + menuElement.style.left = `${x}px`; + menuElement.style.top = `${y}px`; + menuElement.hidden = false; + } + + /** 응답을 화면 상태로 옮긴다. */ + function apply(response: Awaited>): void { + pipes = response.pipe_points.map((point) => ({ + chainage: point.chainage_m, + source: point.source, + })); + basins = response.basins; + zSource = response.z_source; + minSpacing = response.min_spacing_m; + dirty = false; + say(summary()); + } + + /** 세부유역을 다시 나눈다. 편집 중인 관 목록을 그대로 보낸다. */ + async function recompute(): Promise { + if (!projectId || busy) return; + busy = true; + button.disabled = true; + button.textContent = L("B04_Surface_Basin_Btn_Busy"); + const sequence = ++requestSequence; + try { + const response = await computeDetailBasins( + projectId, + pipes.map((pipe) => ({ chainage_m: pipe.chainage, source: pipe.source })), + ); + if (sequence !== requestSequence) return; + apply(response); + } catch (error) { + if (sequence !== requestSequence) return; + say(error instanceof Error ? error.message : L("B04_Surface_Basin_Failed")); + } finally { + busy = false; + button.disabled = false; + button.textContent = L("B04_Surface_Basin_Btn"); + onChange(); + } + } + + /** 저장분(없으면 자동 생성분)을 불러온다. 지도를 열 때 조용히 돈다. */ + async function loadSaved(): Promise { + if (!projectId) return; + const sequence = ++requestSequence; + try { + const response = await fetchDetailPipePoints(projectId); + if (sequence !== requestSequence) return; + apply(response); + } catch { + if (sequence !== requestSequence) return; + // 배수유역 분석 전이면 여기서 실패하는 것이 정상이다 — 조용히 비워 둔다. + pipes = []; + basins = []; + say(""); + } + onChange(); + } + + button.addEventListener("click", () => { + void recompute(); + }); + + return { + button, + partButtons, + statusElement, + menuElement, + setProject(next) { + projectId = next; + pipes = []; + basins = []; + dirty = false; + say(""); + void loadSaved(); + }, + setRoute(points, nextMeta) { + meta = nextMeta; + samples = resampleRoute(points); + }, + clear() { + pipes = []; + basins = []; + zSource = ""; + dirty = false; + dragging = null; + requestSequence += 1; + closeMenu(); + say(""); + }, + handlePointerDown(view, x, y) { + closeMenu(); + if (!shownParts.pipes || pipes.length === 0) return false; + const hit = hitPipe(view, x, y); + if (hit === null) return false; + dragging = hit; + return true; + }, + handlePointerMove(view, x, y) { + if (dragging === null) return false; + const chainage = snap(view, x, y); + // 계획선에서 벗어난 위치는 무시한다 — 관은 늘 노선 위에 있어야 한다. + if (chainage === null || tooClose(chainage, dragging)) return true; + pipes[dragging] = { chainage, source: "user" }; + pipes.sort((left, right) => left.chainage - right.chainage); + dragging = pipes.findIndex((pipe) => pipe.chainage === chainage); + markEdited(); + return true; + }, + handlePointerUp() { + if (dragging === null) return false; + dragging = null; + return true; + }, + handleContextMenu(view, x, y) { + closeMenu(); + if (!shownParts.pipes) return false; + const hit = hitPipe(view, x, y); + if (hit !== null) { + openMenu(x, y, [ + [ + L("B04_Surface_Basin_Menu_Delete"), + () => { + pipes.splice(hit, 1); + markEdited(); + }, + ], + ]); + return true; + } + const chainage = snap(view, x, y); + if (chainage === null) return false; + openMenu(x, y, [ + [ + L("B04_Surface_Basin_Menu_Add"), + () => { + if (tooClose(chainage, null)) { + say(L("B04_Surface_Basin_TooClose").replace("{min}", String(minSpacing))); + onChange(); + return; + } + pipes.push({ chainage, source: "user" }); + pipes.sort((left, right) => left.chainage - right.chainage); + markEdited(); + }, + ], + ]); + return true; + }, + async commit() { + if (!projectId) return 0; + const response = await saveDetailPipePoints( + projectId, + pipes.map((pipe) => ({ chainage_m: pipe.chainage, source: pipe.source })), + ); + apply(response); + onChange(); + return response.pipe_count; + }, + draw(context, normalizer, view) { + if (shownParts.basins && normalizer && basins.length > 0) { + context.save(); + context.lineJoin = "round"; + basins.forEach((basin, index) => { + if (basin.polygon_lonlat.length < 3) return; + context.beginPath(); + basin.polygon_lonlat.forEach(([lon, lat], order) => { + const [px, py] = lonLatToScreen(normalizer, view, lon, lat); + if (order === 0) context.moveTo(px, py); + else context.lineTo(px, py); + }); + context.closePath(); + context.fillStyle = basinColor(index, 0.22); + context.fill(); + context.strokeStyle = basinColor(index, 0.95); + context.lineWidth = 1.8; + context.stroke(); + }); + context.restore(); + } + if (!shownParts.pipes || pipes.length === 0) return; + context.save(); + context.textAlign = "center"; + context.textBaseline = "middle"; + pipes.forEach((pipe, index) => { + const screen = pipeScreen(view, pipe.chainage); + if (!screen) return; + const [x, y] = screen; + const [token, fallback] = PIPE_COLORS[pipe.source]; + context.beginPath(); + context.arc(x, y, PIPE_RADIUS, 0, Math.PI * 2); + context.fillStyle = themeColor(token, fallback); + context.fill(); + context.lineWidth = index === dragging ? 3 : 1.6; + context.strokeStyle = haloColor(); + context.stroke(); + context.font = "bold 10px sans-serif"; + context.fillStyle = themeColor("--map-marker-text", "#111827"); + context.fillText(String(index + 1), x, y); + }); + context.restore(); + }, + dispose() { + requestSequence += 1; + closeMenu(); + }, + }; +} diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_FlowStrength.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_FlowStrength.ts index a26b209d..016ea844 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_FlowStrength.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_FlowStrength.ts @@ -20,6 +20,7 @@ import { type Normalizer, type ViewState, } from "./B04_wf1_Surface_UI_MapRender"; +import { pointAtChainage, resampleRoute, type RoutePoint } from "./B04_wf1_Surface_UI_RouteSamples"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; @@ -43,7 +44,7 @@ const MARKER_MAX_RADIUS = 10; /** 마커를 눌렀다고 볼 여유(px). */ const MARKER_HIT_SLACK = 4; -export type RoutePoint = { x: number; y: number }; +export type { RoutePoint }; export interface FlowStrengthOverlay { /** 지도 헤더 버튼 줄에 넣을 토글. */ @@ -131,38 +132,6 @@ export function createFlowStrengthOverlay(onChange: () => void): FlowStrengthOve // 늦게 도착한 응답이 최신 선택을 덮어쓰지 않게 요청마다 번호를 매긴다. let requestSequence = 0; - /** 계획선을 1m 간격으로 다시 찍는다. 강도 곡선의 인덱스와 그대로 맞물린다. */ - function resample(): void { - samples = []; - if (routePoints.length < 2) return; - let carried = 0; - samples.push({ x: routePoints[0].x, y: routePoints[0].y }); - for (let i = 1; i < routePoints.length; i += 1) { - const from = routePoints[i - 1]; - const to = routePoints[i]; - const dx = to.x - from.x; - const dy = to.y - from.y; - const length = Math.hypot(dx, dy); - if (length <= 0) continue; - let travelled = 1 - carried; - while (travelled <= length) { - samples.push({ - x: from.x + (dx * travelled) / length, - y: from.y + (dy * travelled) / length, - }); - travelled += 1; - } - carried = (carried + length) % 1; - } - } - - /** 누가거리(m) 위치의 계획선 좌표. 범위를 벗어나면 양 끝으로 자른다. */ - function pointAt(chainage: number): RoutePoint | null { - if (samples.length === 0) return null; - const index = Math.min(samples.length - 1, Math.max(0, Math.round(chainage))); - return samples[index]; - } - /** 로그 스케일 정규화 — 계곡 한 점이 사면보다 수백 배라 선형으로는 못 읽는다. */ function normalize(value: number): number { if (maxStrength <= 0 || value <= 0) return 0; @@ -195,7 +164,7 @@ export function createFlowStrengthOverlay(onChange: () => void): FlowStrengthOve function markerScreen(view: ViewState, chainage: number): [number, number] | null { if (!meta) return null; - const point = pointAt(chainage); + const point = pointAtChainage(samples, chainage); if (!point) return null; return metricToScreen(meta, view, point.x, point.y); } @@ -309,7 +278,7 @@ export function createFlowStrengthOverlay(onChange: () => void): FlowStrengthOve setRoute(points, nextMeta) { routePoints = points; meta = nextMeta; - resample(); + samples = resampleRoute(routePoints); }, clear() { strength = new Float64Array(0); diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_MapLayers.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_MapLayers.ts new file mode 100644 index 00000000..513763ec --- /dev/null +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_MapLayers.ts @@ -0,0 +1,75 @@ +/* ============================================================================= + * 2D 지도 레이어 목록·기본값·색 (B04) + * + * 어떤 레이어를 다루는지, 처음에 무엇이 켜져 있는지, 무슨 색으로 그리는지만 모은다. + * 지도 뷰어 본체(`B04_wf1_Surface_UI_MapViewer.ts`)가 700줄 한계에 닿아 분리했다. + * 색 값의 정의처는 `ui_template_theme.css`(`--map-*`)이며 여기는 이름만 잇는다. + * ========================================================================== */ + +import { themeColor } from "@ui/ui_template_palette"; + +export const BACKGROUND_LAYERS = ["white", "satellite", "hybrid"] as const; +// 유수방향은 화면에 쓰지 않기로 해 목록에서 뺐다(2026-08-01 사용자 지시). +export const GIS_LAYERS = [ + "지적도", + "행정구역_시군구", + "행정구역_읍면동", + "등고선", + "도엽_등고선", + "도엽_하천중심선", + "도엽_표고점", + "도엽_성절토", + "도엽_옹벽석축", +] as const; +export type BackgroundLayer = (typeof BACKGROUND_LAYERS)[number]; +export type GisLayer = (typeof GIS_LAYERS)[number]; + +/* ── 처음 열었을 때 켜져 있을 레이어 (2026-08-01 사용자 지정) ────────────── + * 지도가 복잡해지지 않도록 실제로 자주 보는 것만 켠 채로 시작한다. + * 나머지는 버튼으로 그때그때 켠다. */ +export const BACKGROUND_DEFAULT_ON: Record = { + white: true, + satellite: true, + hybrid: false, +}; + +export const GIS_DEFAULT_ON: Record = { + 지적도: false, + 행정구역_시군구: true, + 행정구역_읍면동: true, + 등고선: false, + 도엽_등고선: false, + 도엽_하천중심선: true, + 도엽_표고점: false, + 도엽_성절토: false, + 도엽_옹벽석축: false, +}; + +/** 등고 라벨(계곡선 수치) 기본 표시 여부. */ +export const CONTOUR_LABEL_DEFAULT_ON = false; + +/** 이만큼(px) 이하로 움직였다 뗐으면 클릭으로 본다 — 손떨림으로 선택이 안 되는 일을 막는다. */ +export const CLICK_SLOP_PX = 4; + +/** 레이어별 색은 `ui_template_theme.css`의 `--map-*`가 정의처다. 여기는 이름만 잇는다. + * (fallback 값은 CSS가 아직 안 붙은 첫 프레임 대비용 안전값) */ +const GIS_LAYER_COLOR_TOKENS: Record = { + 지적도: ["--map-cadastral", "#f97316"], + 행정구역_시군구: ["--map-sigungu", "#7c3aed"], + 행정구역_읍면동: ["--map-eupmyeondong", "#22c55e"], + 등고선: ["--map-contour", "#fdba74"], + 도엽_등고선: ["--map-sheet-contour", "#a5b4fc"], + 도엽_하천중심선: ["--map-sheet-stream", "#2563eb"], + 도엽_표고점: ["--map-sheet-elev-point", "#f9a8d4"], + 도엽_성절토: ["--map-sheet-cutfill", "#f43f5e"], + 도엽_옹벽석축: ["--map-sheet-wall", "#0f766e"], +}; + +export const gisLayerColor = (layer: GisLayer): string => + themeColor(...GIS_LAYER_COLOR_TOKENS[layer]); + +/** 등고 라벨 표기 대상 레이어와 표고 속성 키 (gpkg=CTRLN_HG, 도엽=등고수치) */ +export const CONTOUR_LABEL_KEYS: Partial> = { + 등고선: ["CTRLN_HG"], + 도엽_등고선: ["등고수치"], +}; diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts index eca33c59..c6e4f36b 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts @@ -1,6 +1,5 @@ import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { createProgressCircle } from "@ui/ui_template_progress"; -import { themeColor } from "@ui/ui_template_palette"; import { fetchCachedSheetLayer } from "../A00_Common/b_asset_cache"; import { fetchGisGeoJson, @@ -10,6 +9,19 @@ import { type VWorldMeta, } from "./B04_wf1_Surface_Api_Fetch"; import { niceScaleDistance } from "./B04_wf1_Surface_UI_Camera"; +import { createDetailBasinOverlay } from "./B04_wf1_Surface_UI_Basins"; +import { + BACKGROUND_DEFAULT_ON, + BACKGROUND_LAYERS, + CLICK_SLOP_PX, + CONTOUR_LABEL_DEFAULT_ON, + CONTOUR_LABEL_KEYS, + GIS_DEFAULT_ON, + GIS_LAYERS, + gisLayerColor, + type BackgroundLayer, + type GisLayer, +} from "./B04_wf1_Surface_UI_MapLayers"; import { createFlowStrengthOverlay } from "./B04_wf1_Surface_UI_FlowStrength"; import { createWatershedOverlay } from "./B04_wf1_Surface_UI_Watershed"; import { @@ -35,74 +47,11 @@ export interface SurfaceMapViewer { root: HTMLElement; /** routeBounds: 계획노선 평면 범위 — 초기 화면을 도로 중심으로 맞추는 데 쓴다. */ render: (projectId: string, routeBounds?: PlanBounds | null) => void; + /** 관 매설 지점·세부유역을 영구저장한다. 저장한 관 개수를 돌려준다(모델 확정과 함께 호출). */ + commitDrainage: () => Promise; dispose: () => void; } -const BACKGROUND_LAYERS = ["white", "satellite", "hybrid"] as const; -// 유수방향은 화면에 쓰지 않기로 해 목록에서 뺐다(2026-08-01 사용자 지시). -const GIS_LAYERS = [ - "지적도", - "행정구역_시군구", - "행정구역_읍면동", - "등고선", - "도엽_등고선", - "도엽_하천중심선", - "도엽_표고점", - "도엽_성절토", - "도엽_옹벽석축", -] as const; -type BackgroundLayer = (typeof BACKGROUND_LAYERS)[number]; -type GisLayer = (typeof GIS_LAYERS)[number]; - -/* ── 처음 열었을 때 켜져 있을 레이어 (2026-08-01 사용자 지정) ────────────── - * 지도가 복잡해지지 않도록 실제로 자주 보는 것만 켠 채로 시작한다. - * 나머지는 버튼으로 그때그때 켠다. */ -const BACKGROUND_DEFAULT_ON: Record = { - white: true, - satellite: true, - hybrid: false, -}; - -const GIS_DEFAULT_ON: Record = { - 지적도: false, - 행정구역_시군구: true, - 행정구역_읍면동: true, - 등고선: false, - 도엽_등고선: false, - 도엽_하천중심선: true, - 도엽_표고점: false, - 도엽_성절토: false, - 도엽_옹벽석축: false, -}; - -/** 등고 라벨(계곡선 수치) 기본 표시 여부. */ -const CONTOUR_LABEL_DEFAULT_ON = false; - -/** 이만큼(px) 이하로 움직였다 뗐으면 클릭으로 본다 — 손떨림으로 선택이 안 되는 일을 막는다. */ -const CLICK_SLOP_PX = 4; - -/** 레이어별 색은 `ui_template_theme.css`의 `--map-*`가 정의처다. 여기는 이름만 잇는다. - * (fallback 값은 CSS가 아직 안 붙은 첫 프레임 대비용 안전값) */ -const GIS_LAYER_COLOR_TOKENS: Record = { - 지적도: ["--map-cadastral", "#f97316"], - 행정구역_시군구: ["--map-sigungu", "#7c3aed"], - 행정구역_읍면동: ["--map-eupmyeondong", "#22c55e"], - 등고선: ["--map-contour", "#fdba74"], - 도엽_등고선: ["--map-sheet-contour", "#a5b4fc"], - 도엽_하천중심선: ["--map-sheet-stream", "#2563eb"], - 도엽_표고점: ["--map-sheet-elev-point", "#f9a8d4"], - 도엽_성절토: ["--map-sheet-cutfill", "#f43f5e"], - 도엽_옹벽석축: ["--map-sheet-wall", "#0f766e"], -}; - -const gisLayerColor = (layer: GisLayer): string => themeColor(...GIS_LAYER_COLOR_TOKENS[layer]); - -// 등고 라벨 표기 대상 레이어와 표고 속성 키 (gpkg=CTRLN_HG, 도엽=등고수치) -const CONTOUR_LABEL_KEYS: Partial> = { - 등고선: ["CTRLN_HG"], - 도엽_등고선: ["등고수치"], -}; - function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } @@ -341,12 +290,24 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { flowStatus.hidden = flowStatus.textContent === ""; scheduleDraw(); }); - watershedButtons.append(watershed.button, ...watershed.partButtons, flowStrength.button); + // 상세 배수유역 — 관 매설 지점 편집과 세부유역 분할. 계산은 버튼을 눌렀을 때만 돈다. + const detailBasins = createDetailBasinOverlay(() => { + scheduleDraw(); + }); + watershedButtons.append( + watershed.button, + ...watershed.partButtons, + flowStrength.button, + detailBasins.button, + ...detailBasins.partButtons, + ); watershedGroup.append(watershedTitle, watershedButtons); controls.insertBefore(watershedGroup, resetButton); // 안내·결과 문구는 컨트롤 줄이 아니라 지도 위에 얹는다 — 컨트롤 영역 세로 공간을 먹지 않는다. - statusStack.append(watershed.statusElement, flowStatus); + statusStack.append(watershed.statusElement, flowStatus, detailBasins.statusElement); statusTopRight.append(watershed.busyElement); + // 우클릭 메뉴는 뷰포트 기준 절대 위치라 뷰포트 안에 넣는다. + viewport.append(detailBasins.menuElement); function updateImageTransform(): void { backgroundImages.forEach((image) => { @@ -455,6 +416,8 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { } // 흐름 강도(도로 색·유입 집중점 마커)는 계획선 위에 얹는다. flowStrength.draw(context, normalizer, view); + // 세부유역 채움과 관 마커는 맨 위 — 편집 대상이라 다른 레이어에 가려지면 집을 수 없다. + detailBasins.draw(context, normalizer, view); updateImageTransform(); drawScaleBar(mapRect); } @@ -515,6 +478,8 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { planned.points.length > 1 ? prepareMetricPolyline(planned.points, nextMeta) : null; // 흐름 강도는 계획선 위에 칠하므로 같은 점 목록·같은 메타를 쓴다(어긋나면 색이 밀린다). flowStrength.setRoute(planned.points, nextMeta); + // 관 마커도 같은 계획선 위에 스냅한다 — 목록이 다르면 마커가 노선을 벗어난다. + detailBasins.setRoute(planned.points, nextMeta); // 계획노선을 아직 올리지 않은 프로젝트에서는 켤 것이 없으니 버튼을 잠근다. routeButton.disabled = routeLayer === null; routeButton.title = routeLayer === null ? L("B04_Surface_Map_PlannedRouteEmpty") : ""; @@ -562,10 +527,43 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { }, { passive: false }, ); + /** 현재 화면 상태(팬·줌·지도 사각형). 마커 히트 판정과 그리기가 같은 값을 봐야 한다. */ + function currentView(rect: DOMRect): ViewState { + const width = Math.max(1, Math.floor(rect.width)); + const height = Math.max(1, Math.floor(rect.height)); + return { width, height, scale, offsetX, offsetY, mapRect: computeMapRect(meta, width, height) }; + } + + viewport.addEventListener("contextmenu", (event) => { + const rect = viewport.getBoundingClientRect(); + const opened = detailBasins.handleContextMenu( + currentView(rect), + event.clientX - rect.left, + event.clientY - rect.top, + ); + // 계획선이나 관 마커 위에서만 전용 메뉴를 띄운다 — 그 밖은 브라우저 메뉴를 그대로 둔다. + if (opened) event.preventDefault(); + }); + viewport.addEventListener("pointerdown", (event) => { // 중간 버튼은 브라우저 기본 동작(페이지 자동 스크롤)이 지도 팬과 겹쳐 페이지 전체를 // 흔들므로 기본 동작을 차단하고 지도 팬으로만 사용한다. if (event.button === 1) event.preventDefault(); + // 관 마커를 잡았으면 끌기로 넘어간다 — 지도 팬도, 마커 선택도 하지 않는다. + if (event.button === 0) { + const rect = viewport.getBoundingClientRect(); + if ( + detailBasins.handlePointerDown( + currentView(rect), + event.clientX - rect.left, + event.clientY - rect.top, + ) + ) { + clickStart = null; + viewport.setPointerCapture(event.pointerId); + return; + } + } // 팬은 가운데(휠) 버튼 전용이다. 좌버튼은 화면 위 객체를 고르는 데만 쓴다 // (좌버튼 드래그가 팬까지 겸하면 객체를 집으려다 지도가 딸려 움직인다 — 2026-08-01 사용자 지시). // 가운데 버튼이 없는 터치·펜은 그대로 끌어서 팬 할 수 있게 둔다. @@ -582,24 +580,27 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { viewport.setPointerCapture(event.pointerId); }); viewport.addEventListener("pointerup", (event) => { + // 관 마커를 끌던 중이었으면 그것으로 끝낸다 — 집중점 선택까지 겹쳐 일어나면 안 된다. + if (detailBasins.handlePointerUp()) return; const start = clickStart; clickStart = null; if (!start || event.button !== 0) return; if (Math.hypot(event.clientX - start.x, event.clientY - start.y) > CLICK_SLOP_PX) return; const rect = viewport.getBoundingClientRect(); - const width = Math.max(1, Math.floor(rect.width)); - const height = Math.max(1, Math.floor(rect.height)); - const view: ViewState = { - width, - height, - scale, - offsetX, - offsetY, - mapRect: computeMapRect(meta, width, height), - }; + const view = currentView(rect); flowStrength.handleClick(normalizer, view, event.clientX - rect.left, event.clientY - rect.top); }); viewport.addEventListener("pointermove", (event) => { + const rect = viewport.getBoundingClientRect(); + if ( + detailBasins.handlePointerMove( + currentView(rect), + event.clientX - rect.left, + event.clientY - rect.top, + ) + ) { + return; + } if (!dragStart) return; offsetX = dragStart.offsetX + event.clientX - dragStart.x; offsetY = dragStart.offsetY + event.clientY - dragStart.y; @@ -625,8 +626,12 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { watershed.setProject(projectId); flowStrength.clear(); flowStrength.setProject(projectId); + detailBasins.clear(); + detailBasins.setProject(projectId); void loadLayers(); }, + /** 모델 확정과 함께 관 매설 지점·세부유역을 영구저장한다. */ + commitDrainage: () => detailBasins.commit(), dispose() { loadSequence += 1; if (frameHandle) { @@ -635,6 +640,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { } resizeObserver.disconnect(); flowStrength.dispose(); + detailBasins.dispose(); }, }; } diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts index 68e98a94..b0b47aa5 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts @@ -402,6 +402,9 @@ export async function renderB04Surface(root: HTMLElement): Promise { smooth: terrainViewer.isSmoothingEnabled(), contour_interval_m: terrainViewer.getContourInterval(), }); + // 관 매설 지점·세부유역도 이때 함께 영구저장한다(2026-08-01 사용자 지시). + // 배수유역 분석 전이면 저장할 것이 없으므로 실패해도 모델 확정은 그대로 둔다. + await mapViewer.commitDrainage().catch(() => 0); // 확정본이 바뀌었으므로 브라우저가 담아 둔 옛 자료를 더 이상 쓰지 않게 한다. // 준비 표식을 지우면 아래 goToWorkflowStage가 준비 화면을 거쳐 새 자료를 담는다. clearPreloadMark(); diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_RouteSamples.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_RouteSamples.ts new file mode 100644 index 00000000..6f1540ed --- /dev/null +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_RouteSamples.ts @@ -0,0 +1,69 @@ +/* ============================================================================= + * 계획선 1m 재표본 (B04 2D 지도 공용) + * + * 백엔드는 도로 위 값을 전부 **누가거리 1m 구간**으로 준다(흐름 강도 곡선, 관 매설 지점, + * 유입 집중점). 화면에서 그 값을 계획선 위에 얹으려면 같은 규칙으로 다시 찍은 점 목록이 + * 있어야 한다 — 오버레이마다 따로 찍으면 한 칸씩 밀려 색과 마커가 어긋난다. + * ========================================================================== */ + +export type RoutePoint = { x: number; y: number }; + +/** 계획선을 1m 간격으로 다시 찍는다. 배열 인덱스 = 누가거리(m). */ +export function resampleRoute(points: ReadonlyArray): RoutePoint[] { + const samples: RoutePoint[] = []; + if (points.length < 2) return samples; + let carried = 0; + samples.push({ x: points[0].x, y: points[0].y }); + for (let i = 1; i < points.length; i += 1) { + const from = points[i - 1]; + const to = points[i]; + const dx = to.x - from.x; + const dy = to.y - from.y; + const length = Math.hypot(dx, dy); + if (length <= 0) continue; + let travelled = 1 - carried; + while (travelled <= length) { + samples.push({ + x: from.x + (dx * travelled) / length, + y: from.y + (dy * travelled) / length, + }); + travelled += 1; + } + carried = (carried + length) % 1; + } + return samples; +} + +/** 누가거리(m) 위치의 계획선 좌표. 범위를 벗어나면 양 끝으로 자른다. */ +export function pointAtChainage( + samples: ReadonlyArray, + chainage: number, +): RoutePoint | null { + if (samples.length === 0) return null; + const index = Math.min(samples.length - 1, Math.max(0, Math.round(chainage))); + return samples[index]; +} + +/** 화면 좌표에서 가장 가까운 계획선 위치를 찾는다. (누가거리 m, 화면 거리 px). + + * 관을 우클릭으로 추가하거나 끌어 옮길 때 "계획선 위"로 스냅하는 근거다. 1m 표본을 전부 + * 훑되 화면 변환은 넘겨받은 함수에 맡긴다 — 배경지도 메타를 여기서 알 필요가 없다. */ +export function nearestChainage( + samples: ReadonlyArray, + toScreen: (point: RoutePoint) => [number, number], + screenX: number, + screenY: number, +): { chainage: number; distance: number } | null { + if (samples.length === 0) return null; + let bestIndex = -1; + let bestDistance = Number.POSITIVE_INFINITY; + for (let index = 0; index < samples.length; index += 1) { + const [x, y] = toScreen(samples[index]); + const distance = Math.hypot(x - screenX, y - screenY); + if (distance < bestDistance) { + bestDistance = distance; + bestIndex = index; + } + } + return bestIndex < 0 ? null : { chainage: bestIndex, distance: bestDistance }; +} diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_Style.css b/B04_wf1_Surface/B04_wf1_Surface_UI_Style.css index 08ef9d3f..1ee2142d 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_Style.css +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_Style.css @@ -623,6 +623,39 @@ box-shadow: inset 0 0 0 1px var(--b04-layer-color); } +/* 관 매설 우클릭 메뉴 — 지도 뷰포트 기준 절대 위치. */ +.b04-map__context-menu { + position: absolute; + z-index: 6; + display: flex; + flex-direction: column; + min-width: 140px; + padding: var(--spacing-4); + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + background: var(--color-surface-raised); + box-shadow: 0 4px 16px rgb(0 0 0 / 25%); +} + +.b04-map__context-menu[hidden] { + display: none; +} + +.b04-map__context-menu-item { + padding: var(--spacing-8) var(--spacing-12); + border: 0; + border-radius: var(--radius-cards); + background: transparent; + color: var(--color-text-body); + font-size: 13px; + text-align: left; + cursor: pointer; +} + +.b04-map__context-menu-item:hover { + background: var(--color-surface-sunken); +} + .b04-map__viewport { position: relative; width: 100%; diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Drainage.py b/B05_wf2_Route/B05_wf2_Route_Engine_Drainage.py deleted file mode 100644 index 4b75de22..00000000 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Drainage.py +++ /dev/null @@ -1,27 +0,0 @@ -"""배수 관경 산정. - -유역을 나누는 최종 목적은 각 지점의 파이프 관경 결정이다. 유역 경사면에 100년 강우빈도를 -적용해 모이는 물의 양을 산정하고 그 유량으로 관경을 정한다. - -노선 기하(정점·누가거리·세류 교차점)는 `common_util_route_geometry`로 옮겼다 — B04 분석과 -B05 세부 설계가 같은 표현을 써야 하기 때문이다(2026-07-31 구조 개편). -""" - -from __future__ import annotations - - -def estimate_pipe_diameter_mm( - area_m2: float, - relief_m: float, - flow_length_m: float, - rainfall_mm_per_hour: float | None = None, -) -> float | None: - """유역 제원으로 배수 파이프 관경(mm)을 산정한다. - - 100년 강우빈도와 유역 경사면을 곱해 유출량을 구하고, 그 유량으로 관경을 정하는 것이 - 목적이다. **수식은 아직 확정되지 않았다** — 사용자가 로직을 제공하면 여기를 채운다. - 그때까지는 None을 돌려 호출부가 "미정"으로 표기하게 한다. - """ - # TODO(사용자 로직 대기): 100년 강우강도 × 유역면적 × 유출계수 → 유량 Q → 관경 D 산정. - _ = (area_m2, relief_m, flow_length_m, rainfall_mm_per_hour) - return None diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Basin.py b/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Basin.py index 4e0dbaef..109a6695 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Basin.py +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Basin.py @@ -1,70 +1,42 @@ -"""배수유역 세부 설계 (B05 — 일반 사용자용). +"""배수유역 세부 설계 — B05 저장소 어댑터. -**분석은 하지 않는다.** B04가 미리 돌려 저장한 결과를 읽어, 사용자가 실제로 손대는 두 가지만 -처리한다(2026-07-31 사용자 지시). +계산은 하지 않는다. 관 보충(⑨)과 세부유역 분할(⑩)의 알고리즘은 B04 관리자 화면과 +공용이므로 `common_util_drainage_detail`에 있고, 여기서는 **B05가 읽을 폴더만 정한다** +(2026-08-01 구조 개편). - ⑨ 관 간격이 최대치를 넘는 구간에 **최소 개수**로 관을 보충 - ⑩ 측구 흐름으로 도로 셀 → 담당 관을 정하고, 셀이 도달한 도로 셀의 담당 관을 그대로 - 그 셀의 유역 번호로 삼아 세부유역을 나눈다 - ⑪ 사용자가 관을 옮기거나 추가하면 ⑩만 다시 돈다 — 격자 해석은 재사용한다 - -읽어 오는 것(`B04_wf1_Surface/drainage/`): - · `03_road_routing.geojson` — 계획도로선 · 기본 배관 · 2차 전체 배수유역 - · `03_road_routing.npz` — 셀 → 도로 셀 귀속, 유하장, 강도, 도로 셀 제원, 셀 표고 -화살표(방향 코드)나 밴드 표고 같은 관리자 확인용 배열은 읽지 않는다 — 여기서는 필요 없고 -파일만 무거워진다. +읽는 대상은 B04 원본이 아니라 **B05 사본**이다. B04가 다시 해석했으면 사본을 먼저 +갱신한다 — 편집분(`boundary_overrides.json`)은 사본 갱신과 무관하게 남는다. """ from __future__ import annotations -import json -import logging -from dataclasses import dataclass, field from pathlib import Path -from typing import Any -import numpy as np - -from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Export import STAGES -from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Flow import largest_ring, polygonize_labels -from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Grid import GridSpec -from B05_wf2_Route.B05_wf2_Route_Engine_Drainage import estimate_pipe_diameter_mm from B05_wf2_Route.B05_wf2_Route_Engine_Drainage_Store import b05_drainage_dir, sync_from_b04 -from common_util.common_util_route_geometry import ( - RouteVertex, - StructureCandidate, - interpolate_vertex, - is_uphill_at, -) -from config.config_system import ( - DRAINAGE_DITCH_SAMPLE_M, - DRAINAGE_PIPE_MAX_SPACING_M, - DRAINAGE_PIPE_MIN_SPACING_M, +from common_util.common_util_drainage_detail import ( + DrainageDetail, + RoadRouting, + WatershedBasin, + build_detail, + read_road_routing, + read_upstream_lines, ) +from common_util.common_util_route_geometry import RouteVertex -logger = logging.getLogger(__name__) - -# 관 위치 선정 점수 배분 — 흐름 강도가 주, 종단 저점이 보조. -_SCORE_WEIGHT_STRENGTH = 0.7 -_SCORE_WEIGHT_SAG = 0.3 -# 성토부(내리막)는 물이 노선 밖으로 빠지므로 관 위치로 덜 선호한다. -_SCORE_FILL_PENALTY = 0.5 +__all__ = [ + "DrainageDetail", + "RoadRouting", + "WatershedBasin", + "build_drainage_detail", + "load_road_routing", + "load_upstream_lines", +] -@dataclass -class DrainageDetail: - """B05 산출물 — 화면에 그릴 기하와 세부유역.""" - - route_lonlat: list[list[float]] = field(default_factory=list) - basin_lonlat: list[list[float]] = field(default_factory=list) - pipes: list[StructureCandidate] = field(default_factory=list) - basins: list[WatershedBasin] = field(default_factory=list) - grid_cell_m: float = 1.0 - # B04가 계산해 둔 평균 흐름 화살표를 그대로 넘긴다 — B05는 다시 계산하지 않는다. - flow_arrows: list[list[Any]] = field(default_factory=list) - arrow_spacing_m: float = 0.0 - # 유역 안쪽 상류 세류망(WGS84 lon/lat 조각들). 화면 강조 표시용 — 계산에는 쓰지 않는다. - upstream_lonlat: list[list[list[float]]] = field(default_factory=list) +def _synced_dir(stored_path: str) -> Path: + """B04 원본을 B05 사본으로 맞춘 뒤 사본 폴더를 돌려준다.""" + sync_from_b04(stored_path) + return b05_drainage_dir(stored_path) def build_drainage_detail( @@ -72,431 +44,15 @@ def build_drainage_detail( vertices: list[RouteVertex], confirmed_chainages: list[float] | None = None, ) -> DrainageDetail | None: - """B04 분석 결과를 읽어 관을 보충하고 세부유역을 나눈다. - - `confirmed_chainages`를 주면 그 위치를 관으로 확정하고(사용자 편집), 비우면 B04의 - 기본 관에 최대 간격 규칙으로 최소 개수만 보충한다. 어느 쪽이든 격자 해석은 하지 않는다. - """ - routing = load_road_routing(stored_path) - if routing is None or len(vertices) < 2: - return None - - if confirmed_chainages: - pipes = _pipes_from_chainages(vertices, confirmed_chainages) - else: - # 저장분의 기본 관은 누가거리만 신뢰한다 — 좌표는 현재 노선 위로 다시 찍는다. - base = [ - StructureCandidate( - chainage_m=pipe.chainage_m, - x=interpolate_vertex(vertices, pipe.chainage_m)[0], - y=interpolate_vertex(vertices, pipe.chainage_m)[1], - reason=pipe.reason, - ) - for pipe in routing.base_pipes - ] - pipes = place_pipes(vertices, base, routing.strength_curve) - - detail = DrainageDetail( - route_lonlat=routing.route_lonlat, - basin_lonlat=routing.basin_lonlat, - pipes=pipes, - grid_cell_m=routing.spec.cell_m, - flow_arrows=routing.flow_arrows, - arrow_spacing_m=routing.arrow_spacing_m, - upstream_lonlat=load_upstream_lines(stored_path), - ) - if not pipes: - return detail - pipe_of_slot = assign_road_cells_to_pipes(vertices, pipes, routing.road_chainage) - detail.basins = assemble_basins(routing, pipes, pipe_of_slot) - logger.info( - "배수유역: 세부 설계 — 관 %d개(기본 %d + 보충 %d), 세부유역 %d개", - len(pipes), - sum(1 for pipe in pipes if pipe.reason != "spacing"), - sum(1 for pipe in pipes if pipe.reason == "spacing"), - len(detail.basins), - ) - return detail - - -@dataclass -class WatershedBasin: - """관 하나가 받는 세부 배수유역.""" - - index: int - chainage_m: float - outlet_x: float - outlet_y: float - boundary_xy: list[tuple[float, float]] = field(default_factory=list) - area_m2: float = 0.0 - relief_m: float = 0.0 - flow_length_m: float = 0.0 - pipe_diameter_mm: float | None = None - - -@dataclass -class RoadRouting: - """B04가 남긴 배수유역 분석 결과 — B05가 세부유역을 나누는 데 필요한 최소 묶음.""" - - spec: GridSpec - # (R*C,) int32 — 셀이 물길을 따라 도달하는 도로 셀 슬롯(−1 = 미도달). - road_slot: np.ndarray - path_length: np.ndarray # (R*C,) float32 — 그 도로 셀까지 물길 길이(m) - elevation: np.ndarray # (R*C,) float32 — 셀 표고(유역 낙차 계산용) - road_cell_index: np.ndarray # (K,) int32 — 도로 셀의 평탄 인덱스 - road_chainage: np.ndarray # (K,) float64 — 도로 셀의 누가거리(m) - strength: np.ndarray # (K,) int64 — 도로 셀별 상류 셀 수 - # 화면에 그대로 그릴 기하(WGS84 lon/lat). - route_lonlat: list[list[float]] = field(default_factory=list) - basin_lonlat: list[list[float]] = field(default_factory=list) - base_pipes: list[StructureCandidate] = field(default_factory=list) - # 평균 흐름 화살표 — [x, y, 방위(도), 도로도달, 셀 수]. B04가 계산해 둔 그대로. - flow_arrows: list[list[Any]] = field(default_factory=list) - arrow_spacing_m: float = 0.0 - - @property - def strength_curve(self) -> np.ndarray: - """누가거리 1m 구간별 유입 면적(㎡) 곡선 — 관 보충 위치 점수의 근거.""" - if self.road_chainage.size == 0: - return np.zeros(1) - bins = max(1, int(np.ceil(self.road_chainage.max())) + 1) - index = np.clip(np.round(self.road_chainage).astype(np.int64), 0, bins - 1) - weights = self.strength.astype(np.float64) * self.spec.cell_area_m2 - return np.bincount(index, weights=weights, minlength=bins) + """B04 분석 결과(B05 사본)를 읽어 관을 보충하고 세부유역을 나눈다.""" + return build_detail(_synced_dir(stored_path), vertices, confirmed_chainages) def load_road_routing(stored_path: str) -> RoadRouting | None: - """`03_road_routing` 산출물을 읽는다. 없으면 None. - - 읽는 대상은 B04 원본이 아니라 **B05 사본**이다. B04가 다시 해석했으면 사본을 먼저 - 갱신한다 — 편집분(`boundary_overrides.json`)은 사본 갱신과 무관하게 남는다. - """ - sync_from_b04(stored_path) - directory = b05_drainage_dir(stored_path) - prefix = STAGES["road_routing"] - array_path = directory / f"{prefix}_road_routing.npz" - if not array_path.exists(): - logger.warning("배수유역: B04 분석 결과가 없습니다 (%s).", array_path) - return None - try: - with np.load(array_path, allow_pickle=False) as data: - spec = GridSpec( - x_min=float(data["x_min"]), - y_max=float(data["y_max"]), - cell_m=float(data["cell_m"]), - n_rows=int(data["n_rows"]), - n_cols=int(data["n_cols"]), - ) - routing = RoadRouting( - spec=spec, - road_slot=data["road_slot"].reshape(-1), - path_length=data["path_length"].reshape(-1), - elevation=data["elevation"].reshape(-1), - road_cell_index=data["road_cell_index"], - road_chainage=data["road_chainage"], - strength=data["strength"], - ) - except (OSError, KeyError, ValueError): - logger.warning("배수유역: B04 분석 결과를 읽지 못했습니다 (%s).", array_path) - return None - - _read_geometry(directory / f"{prefix}_road_routing.geojson", routing) - logger.info( - "배수유역: B04 결과 로드 — 격자 %d×%d, 도로 셀 %d, 기본 관 %d", - spec.n_rows, - spec.n_cols, - routing.road_cell_index.size, - len(routing.base_pipes), - ) - return routing - - -def _read_geometry(path: Path, routing: RoadRouting) -> None: - """계획도로선·2차 유역 외곽선·기본 관을 GeoJSON에서 읽어 채운다.""" - if not path.exists(): - logger.warning("배수유역: B04 기하 산출물이 없습니다 (%s).", path) - return - try: - with path.open("r", encoding="utf-8") as file: - document = json.load(file) - except (OSError, json.JSONDecodeError): - logger.warning("배수유역: B04 기하 산출물을 읽지 못했습니다 (%s).", path) - return - routing.arrow_spacing_m = float( - (document.get("properties") or {}).get("arrow_spacing_m") or 0.0 - ) - for feature in document.get("features", []): - properties = feature.get("properties") or {} - geometry = feature.get("geometry") or {} - coordinates = geometry.get("coordinates") - kind = properties.get("kind") - if kind == "route" and geometry.get("type") == "LineString": - routing.route_lonlat = coordinates - elif kind == "basin_boundary" and geometry.get("type") == "Polygon" and coordinates: - routing.basin_lonlat = coordinates[0] - elif kind == "flow_arrow" and geometry.get("type") == "Point": - # 화면이 미터로 그리므로 속성의 x·y를 쓴다(기하는 저장 규약상 lon/lat). - routing.flow_arrows.append( - [ - float(properties.get("x") or 0.0), - float(properties.get("y") or 0.0), - float(properties.get("azimuth_deg") or 0.0), - bool(properties.get("reaches_road")), - int(properties.get("cells") or 0), - ] - ) - elif kind == "pipe" and geometry.get("type") == "Point": - routing.base_pipes.append( - StructureCandidate( - chainage_m=float(properties.get("chainage_m") or 0.0), - x=0.0, - y=0.0, - reason=str(properties.get("reason") or "stream"), - ) - ) + """`03_road_routing` 산출물을 B05 사본에서 읽는다.""" + return read_road_routing(_synced_dir(stored_path)) def load_upstream_lines(stored_path: str) -> list[list[list[float]]]: - """`01_primary_region`에서 상류 세류망만 읽는다(화면 강조용). - - 유역 판정의 기준선이라 B04 오버레이에서도 같은 선을 굵게 그린다 — B05는 그 선을 - 그대로 받아 표시만 한다. - """ - path = b05_drainage_dir(stored_path) / f"{STAGES['primary_region']}_primary_region.geojson" - if not path.exists(): - return [] - try: - with path.open("r", encoding="utf-8") as file: - document = json.load(file) - except (OSError, json.JSONDecodeError): - logger.warning("배수유역: 상류 세류망을 읽지 못했습니다 (%s).", path) - return [] - lines: list[list[list[float]]] = [] - for feature in document.get("features", []): - properties = feature.get("properties") or {} - geometry = feature.get("geometry") or {} - if properties.get("kind") != "upstream": - continue - coordinates = geometry.get("coordinates") - if geometry.get("type") == "LineString" and coordinates: - lines.append(coordinates) - elif geometry.get("type") == "MultiLineString" and coordinates: - lines.extend(part for part in coordinates if part) - return lines - - -# ── ⑨ 관 최소 개수 보충 ───────────────────────────────────────────────────── - - -def place_pipes( - vertices: list[RouteVertex], - base_pipes: list[StructureCandidate], - strength_curve: np.ndarray, -) -> list[StructureCandidate]: - """B04가 정한 기본 관(세류 교차점)에, 최대 간격을 넘는 구간만 최소 개수로 보충한다. - - 기본 관은 여기서 다시 찾지 않는다 — B04 산출물에 이미 들어 있다. - """ - total_length = vertices[-1].chainage_m - base: list[StructureCandidate] = [] - for candidate in sorted(base_pipes, key=lambda item: item.chainage_m): - if base and candidate.chainage_m - base[-1].chainage_m < DRAINAGE_PIPE_MIN_SPACING_M: - continue - base.append(candidate) - - filled: list[StructureCandidate] = [] - previous = 0.0 - for candidate in [*base, None]: - boundary = candidate.chainage_m if candidate else total_length - filled.extend(_fill_gap(vertices, strength_curve, previous, boundary)) - if candidate: - filled.append(candidate) - previous = candidate.chainage_m - else: - previous = boundary - filled.sort(key=lambda item: item.chainage_m) - return filled - - -def _fill_gap( - vertices: list[RouteVertex], - strength_curve: np.ndarray, - start_m: float, - end_m: float, -) -> list[StructureCandidate]: - """[start, end] 구간에 최대 간격을 지키는 **최소 개수**의 관을 배치한다. - - 필요 개수 n은 구간 길이로 정해지고(ceil(L/max) − 1), 각 관은 등분 위치를 중심으로 - 허용 여유(slack) 안에서만 움직인다. 그래서 개수는 늘지 않으면서도 흐름 강도가 크고 - 종단이 낮은 지점으로 붙는다. - """ - span = end_m - start_m - if span <= DRAINAGE_PIPE_MAX_SPACING_M: - return [] - count = int(np.ceil(span / DRAINAGE_PIPE_MAX_SPACING_M)) - 1 - if count <= 0: - return [] - spacing = span / (count + 1) - slack = max(0.0, (DRAINAGE_PIPE_MAX_SPACING_M - spacing) / 2.0) - placed: list[StructureCandidate] = [] - for order in range(1, count + 1): - nominal = start_m + spacing * order - low = max(start_m + DRAINAGE_PIPE_MIN_SPACING_M, nominal - slack) - high = min(end_m - DRAINAGE_PIPE_MIN_SPACING_M, nominal + slack) - chosen = _best_position(vertices, strength_curve, low, high, nominal) - x, y, _ = interpolate_vertex(vertices, chosen) - placed.append(StructureCandidate(chainage_m=chosen, x=x, y=y, reason="spacing")) - return placed - - -def _best_position( - vertices: list[RouteVertex], - strength_curve: np.ndarray, - low_m: float, - high_m: float, - fallback_m: float, -) -> float: - """허용 구간 안에서 흐름 강도가 크고 종단이 낮은 위치를 고른다.""" - if high_m <= low_m: - return fallback_m - positions = np.arange(low_m, high_m + 1.0, 1.0) - if positions.size == 0: - return fallback_m - index = np.clip(np.round(positions).astype(np.int64), 0, strength_curve.size - 1) - strength = strength_curve[index] - heights = np.array([interpolate_vertex(vertices, float(p))[2] for p in positions]) - - strength_score = strength / strength.max() if strength.max() > 0 else np.zeros_like(strength) - height_span = float(heights.max() - heights.min()) - sag_score = ( - (heights.max() - heights) / height_span if height_span > 1e-6 else np.zeros_like(heights) - ) - score = _SCORE_WEIGHT_STRENGTH * strength_score + _SCORE_WEIGHT_SAG * sag_score - for order, position in enumerate(positions): - if not is_uphill_at(vertices, float(position)): - score[order] *= _SCORE_FILL_PENALTY - return float(positions[int(np.argmax(score))]) - - -def _pipes_from_chainages( - vertices: list[RouteVertex], chainages: list[float] -) -> list[StructureCandidate]: - """사용자가 확정·편집한 누가거리 목록을 관 후보로 되돌린다. - - 노선 밖 값은 시·종점으로 당긴다. 그대로 두면 마커는 끝점에 찍히는데 라벨만 −50m처럼 - 나와 좌표와 표기가 어긋난다. - """ - total_length = vertices[-1].chainage_m - clamped = {min(max(round(float(item), 2), 0.0), total_length) for item in chainages} - pipes: list[StructureCandidate] = [] - for value in sorted(clamped): - x, y, _ = interpolate_vertex(vertices, value) - pipes.append(StructureCandidate(chainage_m=value, x=x, y=y, reason="confirmed")) - return pipes - - -# ── ⑩ 측구 흐름으로 도로 셀 → 담당 관 ─────────────────────────────────────── - - -def assign_road_cells_to_pipes( - vertices: list[RouteVertex], - pipes: list[StructureCandidate], - road_chainage: np.ndarray, -) -> np.ndarray: - """도로 셀마다 물이 실제로 흘러가는 담당 관 번호를 정한다. - - 노면 물은 측구를 타고 종단 내리막으로 흐르므로, 종단 계획선을 1차원 지형으로 보고 - 같은 방식(내리막 추적 + 관에서 흡수)으로 푼다. 관이 없는 사그(저점)에 갇힌 구간은 - 가장 가까운 관이 받는 것으로 본다. - """ - total_length = vertices[-1].chainage_m - step = max(DRAINAGE_DITCH_SAMPLE_M, 0.5) - stations = np.arange(0.0, total_length + step, step) - heights = np.array([interpolate_vertex(vertices, float(s))[2] for s in stations]) - pipe_chainages = np.array([pipe.chainage_m for pipe in pipes]) - pipe_station = np.clip(np.round(pipe_chainages / step).astype(np.int64), 0, stations.size - 1) - - # 앞뒤 이웃 중 더 낮은 쪽으로 흘려보낸다(양쪽 다 높으면 사그 = 제자리). - back_z = np.full(stations.size, np.inf) - back_z[1:] = heights[:-1] - forward_z = np.full(stations.size, np.inf) - forward_z[:-1] = heights[1:] - go_back = (back_z < heights) & (back_z <= forward_z) - go_forward = (forward_z < heights) & ~go_back - receiver = np.arange(stations.size, dtype=np.int64) - receiver[go_back] -= 1 - receiver[go_forward] += 1 - receiver[pipe_station] = pipe_station # 관은 물을 흡수한다 - - owner = np.full(stations.size, -1, dtype=np.int64) - owner[pipe_station] = np.arange(pipe_chainages.size) - jump = receiver - for _ in range(40): - next_jump = jump[jump] - if np.array_equal(next_jump, jump): - break - jump = next_jump - resolved = owner[jump] - # 관 없는 사그에 갇힌 구간은 가장 가까운 관에 붙인다. - orphan = resolved < 0 - if orphan.any() and pipe_chainages.size: - nearest = np.abs(stations[orphan, None] - pipe_chainages[None, :]).argmin(axis=1) - resolved[orphan] = nearest - - slot_station = np.clip(np.round(road_chainage / step).astype(np.int64), 0, stations.size - 1) - return resolved[slot_station].astype(np.int32) - - -# ── ⑩ 세부유역 조립 ──────────────────────────────────────────────────────── - - -def assemble_basins( - solution: RoadRouting, - pipes: list[StructureCandidate], - pipe_of_slot: np.ndarray, -) -> list[WatershedBasin]: - """셀이 도달한 도로 셀의 담당 관을 그대로 유역 번호로 삼아 세부유역을 만든다.""" - spec = solution.spec - labels = np.full(spec.size, -1, dtype=np.int32) - reached = solution.road_slot >= 0 - labels[reached] = pipe_of_slot[solution.road_slot[reached]] - - polygons = polygonize_labels(spec, labels) - cell_area = spec.cell_area_m2 - basins: list[WatershedBasin] = [] - for order, pipe in enumerate(pipes): - member = labels == order - count = int(member.sum()) - if count == 0: - continue - geometry = polygons.get(order) - elevations = solution.elevation[member] - highest = float(np.nanmax(elevations)) if np.isfinite(elevations).any() else 0.0 - outlet_z = _outlet_elevation(solution, order, pipe_of_slot) - area = count * cell_area - relief = max(0.0, highest - outlet_z) - flow_length = float(solution.path_length[member].max()) - basins.append( - WatershedBasin( - index=len(basins) + 1, - chainage_m=pipe.chainage_m, - outlet_x=pipe.x, - outlet_y=pipe.y, - boundary_xy=largest_ring(geometry) if geometry is not None else [], - area_m2=area, - relief_m=relief, - flow_length_m=flow_length, - pipe_diameter_mm=estimate_pipe_diameter_mm(area, relief, flow_length), - ) - ) - return basins - - -def _outlet_elevation(solution: RoadRouting, pipe_order: int, pipe_of_slot: np.ndarray) -> float: - """관이 담당하는 도로 셀들의 최저 표고 = 유역 출구 표고.""" - slots = np.flatnonzero(pipe_of_slot == pipe_order) - if slots.size == 0: - return 0.0 - elevations = solution.elevation[solution.road_cell_index[slots]] - finite = elevations[np.isfinite(elevations)] - return float(finite.min()) if finite.size else 0.0 + """상류 세류망을 B05 사본에서 읽는다(화면 강조용).""" + return read_upstream_lines(b05_drainage_dir(stored_path)) diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Sections.py b/B05_wf2_Route/B05_wf2_Route_Engine_Sections.py index 56046154..67aec60d 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Sections.py +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Sections.py @@ -17,8 +17,8 @@ from B05_wf2_Route.B05_wf2_Route_Engine_Sections_Core import ( SectionGenerationOptions, generate_sections, ) -from B05_wf2_Route.B05_wf2_Route_Engine_Sections_Sampler import build_surface_sampler from common_util.common_util_json import atomic_write_json +from common_util.common_util_surface_sampler import build_surface_sampler from config.config_system import FOREST_ROAD_PROFILE_CRITERIA logger = logging.getLogger(__name__) diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Sections_Core.py b/B05_wf2_Route/B05_wf2_Route_Engine_Sections_Core.py index 684ac0cd..631b8f86 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Sections_Core.py +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Sections_Core.py @@ -11,7 +11,7 @@ from typing import Any import numpy as np -from B05_wf2_Route.B05_wf2_Route_Engine_Sections_Sampler import SurfaceElevationSampler +from common_util.common_util_surface_sampler import SurfaceElevationSampler from config.config_system import ( SECTION_CROSS_HALF_WIDTH_M, SECTION_CROSS_SAMPLE_INTERVAL_M, diff --git a/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py b/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py index 72e52e1e..88761208 100644 --- a/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py +++ b/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py @@ -14,7 +14,6 @@ from uuid import UUID from fastapi import APIRouter from fastapi.responses import JSONResponse -from pyproj import Transformer from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path from B05_wf2_Route.B05_wf2_Route_Engine_Drainage_Basin import build_drainage_detail @@ -25,12 +24,8 @@ from B05_wf2_Route.B05_wf2_Route_Engine_Drainage_Store import ( resample_boundary, save_boundary_overrides, ) -from B05_wf2_Route.B05_wf2_Route_Repository import ( - get_latest_route, - get_route_points, - get_surface_crs_epsg, -) -from common_util.common_util_route_geometry import StructureCandidate, build_route_vertices +from common_util.common_util_drainage_context import DrainageContext, load_drainage_context +from common_util.common_util_route_geometry import StructureCandidate from config.config_db import get_db_pool from config.config_system import DRAINAGE_BOUNDARY_HANDLE_SPACING_M @@ -51,36 +46,21 @@ def _candidate_payload(candidate: StructureCandidate, to_lonlat: Any) -> dict[st } -async def _prepare(project_id: UUID) -> dict[str, Any] | JSONResponse: - """확정 노선과 좌표 변환기를 준비한다. 도엽 피처는 읽지 않는다(분석을 안 하므로).""" - pool = get_db_pool() - async with pool.acquire() as connection: - stored_path = await get_project_storage_relative_path(connection, project_id) - route = await get_latest_route(connection, project_id) - if not route: - return JSONResponse( - status_code=404, - content={"status": "error", "message": "확정된 경로가 없습니다."}, - ) - points = await get_route_points(connection, int(route["id"])) - surface_model_id = route.get("surface_model_id") - epsg = await get_surface_crs_epsg( - connection, project_id, int(surface_model_id) if surface_model_id else 0 - ) +async def _prepare(project_id: UUID) -> DrainageContext | JSONResponse: + """노선·종단 Z·좌표 변환기를 준비한다. 도엽 피처는 읽지 않는다(분석을 안 하므로). - vertices = build_route_vertices(points) - if len(vertices) < 2: + 노선 기준선은 원청 계획노선 CSV다 — B04 격자가 그 노선으로 도로 셀을 구웠기 때문이다. + 종단 Z만 B05 계획고/경로 정점 우선으로 갈아 끼운다(공용 준비기가 판단). + """ + context, reason = await load_drainage_context(project_id) + if context is None: + return JSONResponse(status_code=404, content={"status": "error", "message": reason}) + if context.route_id is None: return JSONResponse( - status_code=400, - content={"status": "error", "message": "노선 좌표가 부족합니다."}, + status_code=404, + content={"status": "error", "message": "확정된 경로가 없습니다."}, ) - transformer = Transformer.from_crs(f"EPSG:{epsg or 5186}", "EPSG:4326", always_xy=True) - return { - "route_id": int(route["id"]), - "vertices": vertices, - "stored_path": stored_path, - "to_lonlat": lambda x, y: transformer.transform(x, y), - } + return context @router.post("/{project_id}/drainage/basins", response_model=None) @@ -100,7 +80,7 @@ async def post_drainage_basins( confirmed = _parse_chainages(raw) if isinstance(raw, list) else [] detail = await asyncio.to_thread( - build_drainage_detail, prepared["stored_path"], prepared["vertices"], confirmed + build_drainage_detail, prepared.stored_path, prepared.vertices, confirmed ) if detail is None: return JSONResponse( @@ -111,21 +91,23 @@ async def post_drainage_basins( }, ) - to_lonlat = prepared["to_lonlat"] + to_lonlat = prepared.to_lonlat # 2차 전체 유역 외곽선 — 편집 핸들 간격으로 다시 찍고 저장된 편집분을 얹는다. # 격자 경계라 원래 정점이 1m 간격이라 그대로는 손으로 잡을 수 없다. boundary = resample_boundary(detail.basin_lonlat) - stored_overrides = load_boundary_overrides(prepared["stored_path"]) + stored_overrides = load_boundary_overrides(prepared.stored_path) boundary, kept = apply_boundary_overrides(boundary, stored_overrides) dropped = len(stored_overrides) - len(kept) if dropped > 0: # 새 유역 안쪽으로 들어갔거나 붙일 자리가 없어진 편집분은 파일에서도 지운다. - save_boundary_overrides(prepared["stored_path"], kept) + save_boundary_overrides(prepared.stored_path, kept) return { "status": "success", "project_id": str(project_id), - "route_id": prepared["route_id"], + "route_id": prepared.route_id, + # 종단 Z를 어디서 가져왔는지 — 관 담당 구간이 갈리는 근거라 화면에서 확인 가능해야 한다. + "z_source": prepared.z_source, # B04가 남긴 그대로 — 계획도로선. 외곽선만 편집분을 반영해 내보낸다. "route_lonlat": detail.route_lonlat, "main_polygon_lonlat": boundary, diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Engine_Design.py b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Engine_Design.py index f171d488..4feab76a 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Engine_Design.py +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Engine_Design.py @@ -208,11 +208,13 @@ class _SectionGeometry: # 절/성토 역할은 손대지 않는다. ground_at이 없으면 section_mode 기반 역할을 쓴다. if ground_at is not None: self.left_role = ( - "cut" if ground_at(self.left_extent) > self.road_z(self.left_extent) + 1e-3 + "cut" + if ground_at(self.left_extent) > self.road_z(self.left_extent) + 1e-3 else "fill" ) self.right_role = ( - "cut" if ground_at(-self.right_extent) > self.road_z(-self.right_extent) + 1e-3 + "cut" + if ground_at(-self.right_extent) > self.road_z(-self.right_extent) + 1e-3 else "fill" ) @@ -583,42 +585,3 @@ def compute_cross_design( if preset_key == "rock" and rock_boundary_offset_m is not None: result["rock_boundary_offset_m"] = round(float(rock_boundary_offset_m), 4) return result - - -def design_elevation_from_longitudinal( - longitudinal: dict[str, Any], chainage_m: float -) -> float | None: - """종단 계획선(design_profiles) 샘플을 chainage 기준 선형보간해 계획고를 구한다. - - 프론트 designElevationAt과 동일 규칙(범위 밖 양 끝값 클램프). 계획선이 없으면 - None을 반환해 지반고 폴백/오류 처리를 호출부에 맡긴다. - """ - profiles = longitudinal.get("design_profiles") if isinstance(longitudinal, dict) else None - if not isinstance(profiles, list) or not profiles: - return None - samples = [ - s - for s in profiles[0].get("samples", []) - if isinstance(s.get("elevation_m"), (int, float)) - and isinstance(s.get("chainage_m"), (int, float)) - ] - if not samples: - return None - if chainage_m <= samples[0]["chainage_m"]: - return float(samples[0]["elevation_m"]) - last = samples[-1] - if chainage_m >= last["chainage_m"]: - return float(last["elevation_m"]) - for index in range(1, len(samples)): - previous = samples[index - 1] - current = samples[index] - if chainage_m > current["chainage_m"]: - continue - span = current["chainage_m"] - previous["chainage_m"] - if span <= 0: - return float(current["elevation_m"]) - ratio = (chainage_m - previous["chainage_m"]) / span - return float( - previous["elevation_m"] + (current["elevation_m"] - previous["elevation_m"]) * ratio - ) - return float(last["elevation_m"]) diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py index 4907c8fe..e361066b 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py @@ -19,10 +19,7 @@ from B05_wf2_Route.B05_wf2_Route_Engine_Sections import ( ) from B05_wf2_Route.B05_wf2_Route_Engine_Sections_Core import SectionGenerationOptions from B05_wf2_Route.B05_wf2_Route_Router_Confirm import _merge_uphill_overrides_into_longitudinal -from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Engine_Design import ( - compute_cross_design, - design_elevation_from_longitudinal, -) +from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Engine_Design import compute_cross_design from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Repository import ( confirm_sections_for_route, count_cross_sections, @@ -56,6 +53,7 @@ from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Schema import ( SectionSummaryResponse, ) from common_util.common_util_auth import verify_session +from common_util.common_util_route_profile import design_elevation_from_longitudinal from common_util.common_util_storage import resolve_stored_project_path from common_util.common_util_surface_confirmation import get_surface_confirmation_params from common_util.common_util_workflow_state import complete_stage, get_workflow_state @@ -115,9 +113,7 @@ async def get_forest_road_min_widths(project_id: UUID) -> dict[str, dict[str, fl # 아래 두 엔드포인트는 `/sections/{route_id}`(int)보다 먼저 선언해 라우팅 충돌을 막는다. -@router.get( - "/{project_id}/sections/company-standards", response_model=CompanyStandardListResponse -) +@router.get("/{project_id}/sections/company-standards", response_model=CompanyStandardListResponse) async def list_company_standards( project_id: UUID, session: dict[str, Any] = Depends(verify_session) ) -> CompanyStandardListResponse: diff --git a/B07_wf4_DesignDetail/B07_wf4_DesignDetail_Engine_Cad_Long.py b/B07_wf4_DesignDetail/B07_wf4_DesignDetail_Engine_Cad_Long.py index b549e9ed..5554efe5 100644 --- a/B07_wf4_DesignDetail/B07_wf4_DesignDetail_Engine_Cad_Long.py +++ b/B07_wf4_DesignDetail/B07_wf4_DesignDetail_Engine_Cad_Long.py @@ -15,9 +15,6 @@ import math from typing import Any from uuid import uuid5 -from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Engine_Design import ( - design_elevation_from_longitudinal, -) from B07_wf4_DesignDetail.B07_wf4_DesignDetail_Engine_Cad import ( _ENTITY_NS, DESIGN_COLOR, @@ -48,6 +45,7 @@ from B07_wf4_DesignDetail.B07_wf4_DesignDetail_Engine_Template import ( entities_bbox, frame_entities, ) +from common_util.common_util_route_profile import design_elevation_from_longitudinal # 종단 전용 레이어: 그래프 축·격자(잠금 — 참조용, 편집 제외). LONG_GRID_LAYER_ID = "b07-long-grid" diff --git a/B07_wf4_DesignDetail/B07_wf4_DesignDetail_Router.py b/B07_wf4_DesignDetail/B07_wf4_DesignDetail_Router.py index 5d2d95c6..dfd5acdf 100644 --- a/B07_wf4_DesignDetail/B07_wf4_DesignDetail_Router.py +++ b/B07_wf4_DesignDetail/B07_wf4_DesignDetail_Router.py @@ -13,10 +13,7 @@ from fastapi.responses import JSONResponse from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path from B05_wf2_Route.B05_wf2_Route_Engine_Sections import prune_stale_cross_files -from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Engine_Design import ( - compute_cross_design, - design_elevation_from_longitudinal, -) +from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Engine_Design import compute_cross_design from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Repository import ( get_confirmed_route_context, get_cross_section_design, @@ -43,6 +40,7 @@ from B07_wf4_DesignDetail.B07_wf4_DesignDetail_Schema import ( DesignDrawingListResponse, DesignDrawingResponse, ) +from common_util.common_util_route_profile import design_elevation_from_longitudinal from common_util.common_util_storage import resolve_stored_project_path from common_util.common_util_workflow_state import complete_stage, start_stage from config.config_db import get_db_pool diff --git a/common_util/common_util_drainage_context.py b/common_util/common_util_drainage_context.py new file mode 100644 index 00000000..fe48a4b0 --- /dev/null +++ b/common_util/common_util_drainage_context.py @@ -0,0 +1,131 @@ +"""배수유역 세부 설계 입력 준비 (B04 관리자 화면 · B05 사용자 화면 공용). + +두 화면이 같은 관 목록과 같은 세부유역을 보여 주려면 **입력이 한 글자도 달라선 안 된다** +(2026-08-01 사용자 지시). 그래서 노선·종단 Z·좌표계를 여기 한 곳에서 만들어 양쪽에 넘긴다. + +노선 기준선은 B05가 푼 최적 경로가 아니라 **B03이 업로드한 원청 계획노선 CSV**다. B04 격자 +해석이 그 노선으로 도로 셀을 구웠으므로, 다른 노선의 누가거리를 쓰면 도로 셀과 관 위치가 +어긋난다. 종단 Z만 상황에 따라 갈아 끼운다 → [[common_util_route_profile]]. +""" + +from __future__ import annotations + +import asyncio +import logging +from collections.abc import Callable +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any +from uuid import UUID + +from pyproj import Transformer + +from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path +from B05_wf2_Route.B05_wf2_Route_Repository import ( + get_latest_route, + get_route_points, + get_surface_crs_epsg, +) +from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Repository import get_longitudinal_section +from common_util.common_util_route_geometry import ( + RouteVertex, + build_route_vertices, + find_planned_route_file, + read_planned_route_csv, +) +from common_util.common_util_route_profile import Z_SOURCE_CSV, resolve_route_profile +from common_util.common_util_storage import resolve_stored_project_path +from common_util.common_util_surface_confirmation import get_surface_confirmation_params +from common_util.common_util_surface_sampler import build_surface_sampler +from config.config_db import get_db_pool + +logger = logging.getLogger(__name__) + +_INPUT_SUBDIR = Path("B03_FileInput") / "input" +_MODELS_SUBDIR = Path("B04_wf1_Surface") / "models" + + +@dataclass +class DrainageContext: + """세부 설계 한 번에 필요한 입력 묶음.""" + + stored_path: str + project_root: Path + vertices: list[RouteVertex] = field(default_factory=list) + z_source: str = Z_SOURCE_CSV + epsg: int = 5186 + route_id: int | None = None + to_lonlat: Callable[[float, float], tuple[float, float]] = lambda x, y: (x, y) + + +async def load_drainage_context(project_id: UUID) -> tuple[DrainageContext | None, str]: + """노선·종단 Z·좌표계를 준비한다. 실패하면 (None, 사용자에게 보일 사유). + + B05 확정 경로는 **있으면 쓰고 없으면 넘어간다** — B04는 WF1 화면이라 아직 경로가 없다. + """ + pool = get_db_pool() + async with pool.acquire() as connection: + stored_path = await get_project_storage_relative_path(connection, project_id) + if not stored_path: + return None, "프로젝트 저장 경로가 없습니다." + route = await get_latest_route(connection, project_id) + route_points: list[dict[str, Any]] = [] + longitudinal: dict[str, Any] | None = None + if route: + route_points = await get_route_points(connection, int(route["id"])) + section = await get_longitudinal_section(connection, project_id, int(route["id"])) + longitudinal = (section or {}).get("data") + surface_model_id = (route or {}).get("surface_model_id") + db_epsg = await get_surface_crs_epsg( + connection, project_id, int(surface_model_id) if surface_model_id else 0 + ) + surface_params = await get_surface_confirmation_params(connection, str(project_id)) + + project_root = Path(resolve_stored_project_path(stored_path)) + planned = await asyncio.to_thread(_read_planned_route, project_root) + if planned is None or len(planned.vertices) < 2: + return None, "원청 계획노선 CSV를 읽지 못했습니다. B03에서 노선 파일을 확인하세요." + + sampler = await asyncio.to_thread(_open_sampler, project_root, surface_params) + vertices, z_source = await asyncio.to_thread( + resolve_route_profile, + planned.vertices, + route_vertices=build_route_vertices(route_points) if route_points else None, + longitudinal=longitudinal, + sampler=sampler, + ) + + epsg = int(planned.epsg or db_epsg or 5186) + transformer = Transformer.from_crs(f"EPSG:{epsg}", "EPSG:4326", always_xy=True) + return ( + DrainageContext( + stored_path=stored_path, + project_root=project_root, + vertices=vertices, + z_source=z_source, + epsg=epsg, + route_id=int(route["id"]) if route else None, + to_lonlat=lambda x, y: transformer.transform(x, y), + ), + "", + ) + + +def _read_planned_route(project_root: Path): + """원청 계획노선 CSV를 찾아 읽는다(파일 접근이라 스레드에서 돈다).""" + path = find_planned_route_file(project_root / _INPUT_SUBDIR) + return read_planned_route_csv(path) if path else None + + +def _open_sampler(project_root: Path, surface_params: dict[str, Any]): + """확정 지표면 표고 sampler를 연다. 모델이 없으면 None(종단 Z가 다른 경로로 폴백).""" + try: + return build_surface_sampler( + project_root / _MODELS_SUBDIR, + str(surface_params["source_filter"]), + str(surface_params["method"]), + bool(surface_params["smooth"]), + ) + except (FileNotFoundError, ValueError, OSError) as exc: + logger.warning("배수유역: 확정 지표면 sampler를 열지 못했습니다 — %s", exc) + return None diff --git a/common_util/common_util_drainage_detail.py b/common_util/common_util_drainage_detail.py new file mode 100644 index 00000000..71d40a8c --- /dev/null +++ b/common_util/common_util_drainage_detail.py @@ -0,0 +1,517 @@ +"""배수유역 세부 설계 공용 엔진 (B04 관리자 화면 · B05 사용자 화면 공용). + +**격자 해석은 하지 않는다.** B04 배수유역 분석이 미리 돌려 저장한 결과를 읽어, 사용자가 +실제로 손대는 두 가지만 처리한다(2026-07-31 사용자 지시). + + ⑨ 관 간격이 최대치를 넘는 구간에 **최소 개수**로 관을 보충 + ⑩ 측구 흐름으로 도로 셀 → 담당 관을 정하고, 셀이 도달한 도로 셀의 담당 관을 그대로 + 그 셀의 유역 번호로 삼아 세부유역을 나눈다 + ⑪ 사용자가 관을 옮기거나 추가하면 ⑩만 다시 돈다 — 격자 해석은 재사용한다 + +읽어 오는 것(`{배수유역 폴더}/`): + · `03_road_routing.geojson` — 계획도로선 · 기본 배관 · 2차 전체 배수유역 + · `03_road_routing.npz` — 셀 → 도로 셀 귀속, 유하장, 강도, 도로 셀 제원, 셀 표고 +화살표(방향 코드)나 밴드 표고 같은 관리자 확인용 배열은 읽지 않는다 — 여기서는 필요 없고 +파일만 무거워진다. + +**왜 common_util인가**: B04(관리자 트러블슈팅)와 B05(일반 사용자)가 같은 이름의 버튼을 +누르면 같은 결과가 나와야 한다(2026-08-01 사용자 지시). 두 벌로 두면 언젠가 갈라진다. +읽는 폴더만 다르므로 폴더를 인자로 받고, B04/B05 각자의 어댑터가 경로를 정한다. +격자 산출물의 규격(`GridSpec`·`STAGES`·`polygonize_labels`)은 B04가 만든 것이므로 +정의처인 B04 엔진을 그대로 참조한다(역방향 참조 없음). +""" + +from __future__ import annotations + +import json +import logging +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import numpy as np + +from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Export import STAGES +from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Flow import largest_ring, polygonize_labels +from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Grid import GridSpec +from common_util.common_util_route_geometry import ( + RouteVertex, + StructureCandidate, + interpolate_vertex, + is_uphill_at, +) +from config.config_system import ( + DRAINAGE_DITCH_SAMPLE_M, + DRAINAGE_PIPE_MAX_SPACING_M, + DRAINAGE_PIPE_MIN_SPACING_M, +) + +logger = logging.getLogger(__name__) + +# 관 위치 선정 점수 배분 — 흐름 강도가 주, 종단 저점이 보조. +_SCORE_WEIGHT_STRENGTH = 0.7 +_SCORE_WEIGHT_SAG = 0.3 +# 성토부(내리막)는 물이 노선 밖으로 빠지므로 관 위치로 덜 선호한다. +_SCORE_FILL_PENALTY = 0.5 + + +@dataclass +class DrainageDetail: + """세부 설계 산출물 — 화면에 그릴 기하와 세부유역.""" + + route_lonlat: list[list[float]] = field(default_factory=list) + basin_lonlat: list[list[float]] = field(default_factory=list) + pipes: list[StructureCandidate] = field(default_factory=list) + basins: list[WatershedBasin] = field(default_factory=list) + grid_cell_m: float = 1.0 + # B04가 계산해 둔 평균 흐름 화살표를 그대로 넘긴다 — 여기서 다시 계산하지 않는다. + flow_arrows: list[list[Any]] = field(default_factory=list) + arrow_spacing_m: float = 0.0 + # 유역 안쪽 상류 세류망(WGS84 lon/lat 조각들). 화면 강조 표시용 — 계산에는 쓰지 않는다. + upstream_lonlat: list[list[list[float]]] = field(default_factory=list) + + +@dataclass +class WatershedBasin: + """관 하나가 받는 세부 배수유역.""" + + index: int + chainage_m: float + outlet_x: float + outlet_y: float + boundary_xy: list[tuple[float, float]] = field(default_factory=list) + area_m2: float = 0.0 + relief_m: float = 0.0 + flow_length_m: float = 0.0 + pipe_diameter_mm: float | None = None + + +@dataclass +class RoadRouting: + """B04가 남긴 배수유역 분석 결과 — 세부유역을 나누는 데 필요한 최소 묶음.""" + + spec: GridSpec + # (R*C,) int32 — 셀이 물길을 따라 도달하는 도로 셀 슬롯(−1 = 미도달). + road_slot: np.ndarray + path_length: np.ndarray # (R*C,) float32 — 그 도로 셀까지 물길 길이(m) + elevation: np.ndarray # (R*C,) float32 — 셀 표고(유역 낙차 계산용) + road_cell_index: np.ndarray # (K,) int32 — 도로 셀의 평탄 인덱스 + road_chainage: np.ndarray # (K,) float64 — 도로 셀의 누가거리(m) + strength: np.ndarray # (K,) int64 — 도로 셀별 상류 셀 수 + # 화면에 그대로 그릴 기하(WGS84 lon/lat). + route_lonlat: list[list[float]] = field(default_factory=list) + basin_lonlat: list[list[float]] = field(default_factory=list) + base_pipes: list[StructureCandidate] = field(default_factory=list) + # 평균 흐름 화살표 — [x, y, 방위(도), 도로도달, 셀 수]. B04가 계산해 둔 그대로. + flow_arrows: list[list[Any]] = field(default_factory=list) + arrow_spacing_m: float = 0.0 + + @property + def strength_curve(self) -> np.ndarray: + """누가거리 1m 구간별 유입 면적(㎡) 곡선 — 관 보충 위치 점수의 근거.""" + if self.road_chainage.size == 0: + return np.zeros(1) + bins = max(1, int(np.ceil(self.road_chainage.max())) + 1) + index = np.clip(np.round(self.road_chainage).astype(np.int64), 0, bins - 1) + weights = self.strength.astype(np.float64) * self.spec.cell_area_m2 + return np.bincount(index, weights=weights, minlength=bins) + + +def build_detail( + directory: Path, + vertices: list[RouteVertex], + confirmed_chainages: list[float] | None = None, +) -> DrainageDetail | None: + """B04 분석 결과를 읽어 관을 보충하고 세부유역을 나눈다. + + `confirmed_chainages`를 주면 그 위치를 관으로 확정하고(사용자 편집), 비우면 B04의 + 기본 관에 최대 간격 규칙으로 최소 개수만 보충한다. 어느 쪽이든 격자 해석은 하지 않는다. + """ + routing = read_road_routing(directory) + if routing is None or len(vertices) < 2: + return None + + if confirmed_chainages: + pipes = pipes_from_chainages(vertices, confirmed_chainages) + else: + # 저장분의 기본 관은 누가거리만 신뢰한다 — 좌표는 현재 노선 위로 다시 찍는다. + base = [ + StructureCandidate( + chainage_m=pipe.chainage_m, + x=interpolate_vertex(vertices, pipe.chainage_m)[0], + y=interpolate_vertex(vertices, pipe.chainage_m)[1], + reason=pipe.reason, + ) + for pipe in routing.base_pipes + ] + pipes = place_pipes(vertices, base, routing.strength_curve) + + detail = DrainageDetail( + route_lonlat=routing.route_lonlat, + basin_lonlat=routing.basin_lonlat, + pipes=pipes, + grid_cell_m=routing.spec.cell_m, + flow_arrows=routing.flow_arrows, + arrow_spacing_m=routing.arrow_spacing_m, + upstream_lonlat=read_upstream_lines(directory), + ) + if not pipes: + return detail + pipe_of_slot = assign_road_cells_to_pipes(vertices, pipes, routing.road_chainage) + detail.basins = assemble_basins(routing, pipes, pipe_of_slot) + logger.info( + "배수유역: 세부 설계 — 관 %d개(기본 %d + 보충 %d), 세부유역 %d개", + len(pipes), + sum(1 for pipe in pipes if pipe.reason != "spacing"), + sum(1 for pipe in pipes if pipe.reason == "spacing"), + len(detail.basins), + ) + return detail + + +def read_road_routing(directory: Path) -> RoadRouting | None: + """`03_road_routing` 산출물을 읽는다. 없으면 None.""" + prefix = STAGES["road_routing"] + array_path = directory / f"{prefix}_road_routing.npz" + if not array_path.exists(): + logger.warning("배수유역: B04 분석 결과가 없습니다 (%s).", array_path) + return None + try: + with np.load(array_path, allow_pickle=False) as data: + spec = GridSpec( + x_min=float(data["x_min"]), + y_max=float(data["y_max"]), + cell_m=float(data["cell_m"]), + n_rows=int(data["n_rows"]), + n_cols=int(data["n_cols"]), + ) + routing = RoadRouting( + spec=spec, + road_slot=data["road_slot"].reshape(-1), + path_length=data["path_length"].reshape(-1), + elevation=data["elevation"].reshape(-1), + road_cell_index=data["road_cell_index"], + road_chainage=data["road_chainage"], + strength=data["strength"], + ) + except (OSError, KeyError, ValueError): + logger.warning("배수유역: B04 분석 결과를 읽지 못했습니다 (%s).", array_path) + return None + + _read_geometry(directory / f"{prefix}_road_routing.geojson", routing) + logger.info( + "배수유역: B04 결과 로드 — 격자 %d×%d, 도로 셀 %d, 기본 관 %d", + spec.n_rows, + spec.n_cols, + routing.road_cell_index.size, + len(routing.base_pipes), + ) + return routing + + +def _read_geometry(path: Path, routing: RoadRouting) -> None: + """계획도로선·2차 유역 외곽선·기본 관을 GeoJSON에서 읽어 채운다.""" + if not path.exists(): + logger.warning("배수유역: B04 기하 산출물이 없습니다 (%s).", path) + return + try: + with path.open("r", encoding="utf-8") as file: + document = json.load(file) + except (OSError, json.JSONDecodeError): + logger.warning("배수유역: B04 기하 산출물을 읽지 못했습니다 (%s).", path) + return + routing.arrow_spacing_m = float( + (document.get("properties") or {}).get("arrow_spacing_m") or 0.0 + ) + for feature in document.get("features", []): + properties = feature.get("properties") or {} + geometry = feature.get("geometry") or {} + coordinates = geometry.get("coordinates") + kind = properties.get("kind") + if kind == "route" and geometry.get("type") == "LineString": + routing.route_lonlat = coordinates + elif kind == "basin_boundary" and geometry.get("type") == "Polygon" and coordinates: + routing.basin_lonlat = coordinates[0] + elif kind == "flow_arrow" and geometry.get("type") == "Point": + # 화면이 미터로 그리므로 속성의 x·y를 쓴다(기하는 저장 규약상 lon/lat). + routing.flow_arrows.append( + [ + float(properties.get("x") or 0.0), + float(properties.get("y") or 0.0), + float(properties.get("azimuth_deg") or 0.0), + bool(properties.get("reaches_road")), + int(properties.get("cells") or 0), + ] + ) + elif kind == "pipe" and geometry.get("type") == "Point": + routing.base_pipes.append( + StructureCandidate( + chainage_m=float(properties.get("chainage_m") or 0.0), + x=0.0, + y=0.0, + reason=str(properties.get("reason") or "stream"), + ) + ) + + +def read_upstream_lines(directory: Path) -> list[list[list[float]]]: + """`01_primary_region`에서 상류 세류망만 읽는다(화면 강조용). + + 유역 판정의 기준선이라 B04 오버레이에서도 같은 선을 굵게 그린다 — B05는 그 선을 + 그대로 받아 표시만 한다. + """ + path = directory / f"{STAGES['primary_region']}_primary_region.geojson" + if not path.exists(): + return [] + try: + with path.open("r", encoding="utf-8") as file: + document = json.load(file) + except (OSError, json.JSONDecodeError): + logger.warning("배수유역: 상류 세류망을 읽지 못했습니다 (%s).", path) + return [] + lines: list[list[list[float]]] = [] + for feature in document.get("features", []): + properties = feature.get("properties") or {} + geometry = feature.get("geometry") or {} + if properties.get("kind") != "upstream": + continue + coordinates = geometry.get("coordinates") + if geometry.get("type") == "LineString" and coordinates: + lines.append(coordinates) + elif geometry.get("type") == "MultiLineString" and coordinates: + lines.extend(part for part in coordinates if part) + return lines + + +# ── ⑨ 관 최소 개수 보충 ───────────────────────────────────────────────────── + + +def place_pipes( + vertices: list[RouteVertex], + base_pipes: list[StructureCandidate], + strength_curve: np.ndarray, +) -> list[StructureCandidate]: + """B04가 정한 기본 관(세류 교차점)에, 최대 간격을 넘는 구간만 최소 개수로 보충한다. + + 기본 관은 여기서 다시 찾지 않는다 — B04 산출물에 이미 들어 있다. + """ + total_length = vertices[-1].chainage_m + base: list[StructureCandidate] = [] + for candidate in sorted(base_pipes, key=lambda item: item.chainage_m): + if base and candidate.chainage_m - base[-1].chainage_m < DRAINAGE_PIPE_MIN_SPACING_M: + continue + base.append(candidate) + + filled: list[StructureCandidate] = [] + previous = 0.0 + for candidate in [*base, None]: + boundary = candidate.chainage_m if candidate else total_length + filled.extend(_fill_gap(vertices, strength_curve, previous, boundary)) + if candidate: + filled.append(candidate) + previous = candidate.chainage_m + else: + previous = boundary + filled.sort(key=lambda item: item.chainage_m) + return filled + + +def _fill_gap( + vertices: list[RouteVertex], + strength_curve: np.ndarray, + start_m: float, + end_m: float, +) -> list[StructureCandidate]: + """[start, end] 구간에 최대 간격을 지키는 **최소 개수**의 관을 배치한다. + + 필요 개수 n은 구간 길이로 정해지고(ceil(L/max) − 1), 각 관은 등분 위치를 중심으로 + 허용 여유(slack) 안에서만 움직인다. 그래서 개수는 늘지 않으면서도 흐름 강도가 크고 + 종단이 낮은 지점으로 붙는다. + """ + span = end_m - start_m + if span <= DRAINAGE_PIPE_MAX_SPACING_M: + return [] + count = int(np.ceil(span / DRAINAGE_PIPE_MAX_SPACING_M)) - 1 + if count <= 0: + return [] + spacing = span / (count + 1) + slack = max(0.0, (DRAINAGE_PIPE_MAX_SPACING_M - spacing) / 2.0) + placed: list[StructureCandidate] = [] + for order in range(1, count + 1): + nominal = start_m + spacing * order + low = max(start_m + DRAINAGE_PIPE_MIN_SPACING_M, nominal - slack) + high = min(end_m - DRAINAGE_PIPE_MIN_SPACING_M, nominal + slack) + chosen = _best_position(vertices, strength_curve, low, high, nominal) + x, y, _ = interpolate_vertex(vertices, chosen) + placed.append(StructureCandidate(chainage_m=chosen, x=x, y=y, reason="spacing")) + return placed + + +def _best_position( + vertices: list[RouteVertex], + strength_curve: np.ndarray, + low_m: float, + high_m: float, + fallback_m: float, +) -> float: + """허용 구간 안에서 흐름 강도가 크고 종단이 낮은 위치를 고른다.""" + if high_m <= low_m: + return fallback_m + positions = np.arange(low_m, high_m + 1.0, 1.0) + if positions.size == 0: + return fallback_m + index = np.clip(np.round(positions).astype(np.int64), 0, strength_curve.size - 1) + strength = strength_curve[index] + heights = np.array([interpolate_vertex(vertices, float(p))[2] for p in positions]) + + strength_score = strength / strength.max() if strength.max() > 0 else np.zeros_like(strength) + height_span = float(heights.max() - heights.min()) + sag_score = ( + (heights.max() - heights) / height_span if height_span > 1e-6 else np.zeros_like(heights) + ) + score = _SCORE_WEIGHT_STRENGTH * strength_score + _SCORE_WEIGHT_SAG * sag_score + for order, position in enumerate(positions): + if not is_uphill_at(vertices, float(position)): + score[order] *= _SCORE_FILL_PENALTY + return float(positions[int(np.argmax(score))]) + + +def pipes_from_chainages( + vertices: list[RouteVertex], chainages: list[float] +) -> list[StructureCandidate]: + """사용자가 확정·편집한 누가거리 목록을 관 후보로 되돌린다. + + 노선 밖 값은 시·종점으로 당긴다. 그대로 두면 마커는 끝점에 찍히는데 라벨만 −50m처럼 + 나와 좌표와 표기가 어긋난다. + """ + total_length = vertices[-1].chainage_m + clamped = {min(max(round(float(item), 2), 0.0), total_length) for item in chainages} + pipes: list[StructureCandidate] = [] + for value in sorted(clamped): + x, y, _ = interpolate_vertex(vertices, value) + pipes.append(StructureCandidate(chainage_m=value, x=x, y=y, reason="confirmed")) + return pipes + + +# ── ⑩ 측구 흐름으로 도로 셀 → 담당 관 ─────────────────────────────────────── + + +def assign_road_cells_to_pipes( + vertices: list[RouteVertex], + pipes: list[StructureCandidate], + road_chainage: np.ndarray, +) -> np.ndarray: + """도로 셀마다 물이 실제로 흘러가는 담당 관 번호를 정한다. + + 노면 물은 측구를 타고 종단 내리막으로 흐르므로, 종단 계획선을 1차원 지형으로 보고 + 같은 방식(내리막 추적 + 관에서 흡수)으로 푼다. 관이 없는 사그(저점)에 갇힌 구간은 + 가장 가까운 관이 받는 것으로 본다. + """ + total_length = vertices[-1].chainage_m + step = max(DRAINAGE_DITCH_SAMPLE_M, 0.5) + stations = np.arange(0.0, total_length + step, step) + heights = np.array([interpolate_vertex(vertices, float(s))[2] for s in stations]) + pipe_chainages = np.array([pipe.chainage_m for pipe in pipes]) + pipe_station = np.clip(np.round(pipe_chainages / step).astype(np.int64), 0, stations.size - 1) + + # 앞뒤 이웃 중 더 낮은 쪽으로 흘려보낸다(양쪽 다 높으면 사그 = 제자리). + back_z = np.full(stations.size, np.inf) + back_z[1:] = heights[:-1] + forward_z = np.full(stations.size, np.inf) + forward_z[:-1] = heights[1:] + go_back = (back_z < heights) & (back_z <= forward_z) + go_forward = (forward_z < heights) & ~go_back + receiver = np.arange(stations.size, dtype=np.int64) + receiver[go_back] -= 1 + receiver[go_forward] += 1 + receiver[pipe_station] = pipe_station # 관은 물을 흡수한다 + + owner = np.full(stations.size, -1, dtype=np.int64) + owner[pipe_station] = np.arange(pipe_chainages.size) + jump = receiver + for _ in range(40): + next_jump = jump[jump] + if np.array_equal(next_jump, jump): + break + jump = next_jump + resolved = owner[jump] + # 관 없는 사그에 갇힌 구간은 가장 가까운 관에 붙인다. + orphan = resolved < 0 + if orphan.any() and pipe_chainages.size: + nearest = np.abs(stations[orphan, None] - pipe_chainages[None, :]).argmin(axis=1) + resolved[orphan] = nearest + + slot_station = np.clip(np.round(road_chainage / step).astype(np.int64), 0, stations.size - 1) + return resolved[slot_station].astype(np.int32) + + +# ── ⑩ 세부유역 조립 ──────────────────────────────────────────────────────── + + +def assemble_basins( + routing: RoadRouting, + pipes: list[StructureCandidate], + pipe_of_slot: np.ndarray, +) -> list[WatershedBasin]: + """셀이 도달한 도로 셀의 담당 관을 그대로 유역 번호로 삼아 세부유역을 만든다.""" + spec = routing.spec + labels = np.full(spec.size, -1, dtype=np.int32) + reached = routing.road_slot >= 0 + labels[reached] = pipe_of_slot[routing.road_slot[reached]] + + polygons = polygonize_labels(spec, labels) + cell_area = spec.cell_area_m2 + basins: list[WatershedBasin] = [] + for order, pipe in enumerate(pipes): + member = labels == order + count = int(member.sum()) + if count == 0: + continue + geometry = polygons.get(order) + elevations = routing.elevation[member] + highest = float(np.nanmax(elevations)) if np.isfinite(elevations).any() else 0.0 + outlet_z = _outlet_elevation(routing, order, pipe_of_slot) + area = count * cell_area + relief = max(0.0, highest - outlet_z) + flow_length = float(routing.path_length[member].max()) + basins.append( + WatershedBasin( + index=len(basins) + 1, + chainage_m=pipe.chainage_m, + outlet_x=pipe.x, + outlet_y=pipe.y, + boundary_xy=largest_ring(geometry) if geometry is not None else [], + area_m2=area, + relief_m=relief, + flow_length_m=flow_length, + pipe_diameter_mm=estimate_pipe_diameter_mm(area, relief, flow_length), + ) + ) + return basins + + +def _outlet_elevation(routing: RoadRouting, pipe_order: int, pipe_of_slot: np.ndarray) -> float: + """관이 담당하는 도로 셀들의 최저 표고 = 유역 출구 표고.""" + slots = np.flatnonzero(pipe_of_slot == pipe_order) + if slots.size == 0: + return 0.0 + elevations = routing.elevation[routing.road_cell_index[slots]] + finite = elevations[np.isfinite(elevations)] + return float(finite.min()) if finite.size else 0.0 + + +def estimate_pipe_diameter_mm( + area_m2: float, + relief_m: float, + flow_length_m: float, + rainfall_mm_per_hour: float | None = None, +) -> float | None: + """유역 제원으로 배수 파이프 관경(mm)을 산정한다. + + 100년 강우빈도와 유역 경사면을 곱해 유출량을 구하고, 그 유량으로 관경을 정하는 것이 + 목적이다. **수식은 아직 확정되지 않았다** — 사용자가 로직을 제공하면 여기를 채운다. + 그때까지는 None을 돌려 호출부가 "미정"으로 표기하게 한다. + """ + # TODO(사용자 로직 대기): 100년 강우강도 × 유역면적 × 유출계수 → 유량 Q → 관경 D 산정. + _ = (area_m2, relief_m, flow_length_m, rainfall_mm_per_hour) + return None diff --git a/common_util/common_util_drainage_pipes.py b/common_util/common_util_drainage_pipes.py new file mode 100644 index 00000000..eb6eca84 --- /dev/null +++ b/common_util/common_util_drainage_pipes.py @@ -0,0 +1,139 @@ +"""관 매설 지점 정본 저장소 (B04 관리자 화면 · B05 사용자 화면 공용). + +해석 산출물(`01~03`)은 다시 돌리면 덮어써도 되지만, 사용자가 찍고 옮긴 관은 그러면 안 된다. +그래서 편집분만 `{배수유역 폴더}/edits/pipe_points.json`에 따로 남기고 두 화면이 같은 파일을 +읽고 쓴다 — 관리자 화면에서 옮긴 관이 사용자 화면에서 다르게 보이면 안 되기 때문이다 +(2026-08-01 사용자 지시). + +위치는 좌표가 아니라 **누가거리(chainage_m)** 로 저장한다. 지면 필터나 지표면 모델을 바꾸면 +종단 Z가 달라지지만 관이 놓인 자리는 그대로여야 하고, 그때는 세부유역만 다시 나누면 된다. +노선 자체가 바뀌면(`route_signature` 불일치) 기준이 사라지므로 전량 버리고 다시 만든다. +""" + +from __future__ import annotations + +import hashlib +import json +import logging +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Export import drainage_dir +from common_util.common_util_json import atomic_write_json +from common_util.common_util_route_geometry import RouteVertex +from config.config_system import ( + DRAINAGE_DETAIL_FILENAME, + DRAINAGE_EDITS_DIRNAME, + DRAINAGE_PIPE_POINTS_FILENAME, +) + +logger = logging.getLogger(__name__) + +# 관이 그 자리에 있는 이유. 화면 마커 모양과 "자동/수동" 구분이 여기에 달려 있다. +PIPE_SOURCE_STREAM = "stream" # 기본 관 — 도로 × 상류 세류선 교차점 +PIPE_SOURCE_SPACING = "spacing" # 자동 보충 — 관 최대 간격 규칙 +PIPE_SOURCE_USER = "user" # 수동 — 사용자가 우클릭으로 추가하거나 옮긴 관 +_KNOWN_SOURCES = (PIPE_SOURCE_STREAM, PIPE_SOURCE_SPACING, PIPE_SOURCE_USER) + + +@dataclass +class PipePoint: + """계획선 위 관 매설 지점 한 개.""" + + chainage_m: float + source: str = PIPE_SOURCE_USER + + def as_dict(self) -> dict[str, Any]: + return {"chainage_m": round(float(self.chainage_m), 2), "source": self.source} + + +def edits_dir(stored_path: str) -> Path: + return drainage_dir(stored_path) / DRAINAGE_EDITS_DIRNAME + + +def pipe_points_path(stored_path: str) -> Path: + return edits_dir(stored_path) / DRAINAGE_PIPE_POINTS_FILENAME + + +def detail_basins_path(stored_path: str) -> Path: + return drainage_dir(stored_path) / DRAINAGE_DETAIL_FILENAME + + +def route_signature(vertices: list[RouteVertex]) -> str: + """노선이 바뀌었는지 판별할 지문. 정점 좌표를 0.01m로 끊어 해시한다. + + 연장만 보면 노선이 통째로 옮겨져도 같은 값이 나온다. 좌표를 다 넣되 소수점을 끊어 + 부동소수 잡음으로 지문이 흔들리지 않게 한다. + """ + digest = hashlib.sha1(usedforsecurity=False) + for vertex in vertices: + digest.update(f"{vertex.x:.2f},{vertex.y:.2f};".encode()) + return f"{len(vertices)}-{digest.hexdigest()[:16]}" + + +def load_pipe_points(stored_path: str, signature: str) -> list[PipePoint] | None: + """저장된 관 지점을 읽는다. 파일이 없거나 노선이 바뀌었으면 None(= 다시 만들어야 함).""" + path = pipe_points_path(stored_path) + if not path.exists(): + return None + try: + with path.open("r", encoding="utf-8") as file: + document = json.load(file) + except (OSError, json.JSONDecodeError): + logger.warning("배수유역: 관 지점 파일을 읽지 못했습니다 (%s).", path) + return None + stored_signature = str(document.get("route_signature") or "") + if stored_signature != signature: + logger.info("배수유역: 노선이 바뀌어 저장된 관 지점을 버립니다 (%s).", path.name) + return None + return parse_pipe_points(document.get("points")) + + +def parse_pipe_points(values: Any) -> list[PipePoint]: + """외부에서 들어온 관 목록(파일·요청 본문)을 정리한다. 누가거리 오름차순.""" + if not isinstance(values, list): + return [] + points: list[PipePoint] = [] + for item in values: + if isinstance(item, (int, float)): + points.append(PipePoint(chainage_m=float(item))) + continue + if not isinstance(item, dict): + continue + chainage = item.get("chainage_m") + if not isinstance(chainage, (int, float)): + continue + source = str(item.get("source") or PIPE_SOURCE_USER) + points.append( + PipePoint( + chainage_m=float(chainage), + source=source if source in _KNOWN_SOURCES else PIPE_SOURCE_USER, + ) + ) + points.sort(key=lambda point: point.chainage_m) + return points + + +def save_pipe_points(stored_path: str, signature: str, points: list[PipePoint]) -> int: + """관 지점을 정본 파일에 쓴다. 저장된 개수를 돌려준다.""" + path = pipe_points_path(stored_path) + path.parent.mkdir(parents=True, exist_ok=True) + atomic_write_json( + path, + { + "route_signature": signature, + "points": [point.as_dict() for point in points], + }, + ) + logger.info("배수유역: 관 지점 %d개를 저장했습니다 (%s).", len(points), path.name) + return len(points) + + +def save_detail_basins(stored_path: str, features: list[dict[str, Any]]) -> Path: + """세부유역을 GeoJSON으로 남긴다(파생물 — 관 지점만 있으면 언제든 다시 만든다).""" + path = detail_basins_path(stored_path) + path.parent.mkdir(parents=True, exist_ok=True) + atomic_write_json(path, {"type": "FeatureCollection", "features": features}) + logger.info("배수유역: 세부유역 %d개를 저장했습니다 (%s).", len(features), path.name) + return path diff --git a/common_util/common_util_route_profile.py b/common_util/common_util_route_profile.py new file mode 100644 index 00000000..a4abb124 --- /dev/null +++ b/common_util/common_util_route_profile.py @@ -0,0 +1,173 @@ +"""계획노선 종단 Z 해석 (공용). + +배수유역 세부 설계는 **노면 물이 어느 쪽으로 흐르는가**로 관 담당 구간을 나눈다. 그래서 +노선 정점의 Z가 무엇이냐에 따라 결과가 통째로 달라진다. B04(관리자)와 B05(사용자)가 서로 +다른 Z를 쓰면 같은 프로젝트에서 세부유역이 갈라지므로, 어느 Z를 쓸지는 여기 한 곳에서만 +정한다(2026-08-01 사용자 지시). + +우선순위 + ① B05 종단 계획고(`longitudinal_sections.data.design_profiles`) — 사용자가 편집을 마친 정본 + ② B05 경로 정점 Z(`route_points.z`) — 계획고 편집 전 단계 + ③ 확정 지표면 모델 샘플링 — B05를 아직 지나지 않은 B04 시점의 기본값 + ④ 원청 계획노선 CSV의 z — 위 셋이 모두 없을 때의 최후 폴백 + +①②는 B05가 푼 **최적 경로** 기하 위의 값이다. 원청 계획노선과 노선 자체가 다르면 같은 +누가거리라도 다른 지점이므로, 노선이 실질적으로 같을 때만 채택하고 아니면 ③으로 내려간다. + +DB나 페이지 모듈은 여기서 건드리지 않는다 — 호출부(B04/B05 라우터)가 읽어서 넘긴다. +""" + +from __future__ import annotations + +import logging +import math +from dataclasses import replace +from typing import Any + +import numpy as np + +from common_util.common_util_route_geometry import RouteVertex, interpolate_vertex +from common_util.common_util_surface_sampler import SurfaceElevationSampler + +logger = logging.getLogger(__name__) + +# 노선 동일성 판정 — 시·종점이 이 거리 안이고 연장 차이가 아래 비율 안이면 같은 노선으로 본다. +# 최적 경로는 원청 노선을 따라가되 격자 해상도만큼 흔들리므로 여유를 둔다. +ROUTE_MATCH_ENDPOINT_TOLERANCE_M = 20.0 +ROUTE_MATCH_LENGTH_TOLERANCE = 0.05 + +# Z 출처 표시 — 화면과 로그가 어느 종단을 쓴 결과인지 알 수 있어야 한다. +Z_SOURCE_DESIGN = "design_profile" +Z_SOURCE_ROUTE_POINTS = "route_points" +Z_SOURCE_SURFACE = "surface" +Z_SOURCE_CSV = "csv" + + +def design_elevation_from_longitudinal( + longitudinal: dict[str, Any], chainage_m: float +) -> float | None: + """종단 계획선(design_profiles) 샘플을 chainage 기준 선형보간해 계획고를 구한다. + + 프론트 designElevationAt과 동일 규칙(범위 밖 양 끝값 클램프). 계획선이 없으면 + None을 반환해 지반고 폴백/오류 처리를 호출부에 맡긴다. + """ + profiles = longitudinal.get("design_profiles") if isinstance(longitudinal, dict) else None + if not isinstance(profiles, list) or not profiles: + return None + samples = [ + s + for s in profiles[0].get("samples", []) + if isinstance(s.get("elevation_m"), (int, float)) + and isinstance(s.get("chainage_m"), (int, float)) + ] + if not samples: + return None + if chainage_m <= samples[0]["chainage_m"]: + return float(samples[0]["elevation_m"]) + last = samples[-1] + if chainage_m >= last["chainage_m"]: + return float(last["elevation_m"]) + for index in range(1, len(samples)): + previous = samples[index - 1] + current = samples[index] + if chainage_m > current["chainage_m"]: + continue + span = current["chainage_m"] - previous["chainage_m"] + if span <= 0: + return float(current["elevation_m"]) + ratio = (chainage_m - previous["chainage_m"]) / span + return float( + previous["elevation_m"] + (current["elevation_m"] - previous["elevation_m"]) * ratio + ) + return float(last["elevation_m"]) + + +def routes_match(left: list[RouteVertex], right: list[RouteVertex]) -> bool: + """두 노선이 실질적으로 같은 노선인지 본다(시·종점 근접 + 연장 유사). + + 최적 경로는 원청 노선을 격자 위에서 다시 그은 것이라 정점이 하나도 겹치지 않을 수 + 있다. 그래서 정점 대조가 아니라 끝점과 연장으로만 판단한다. + """ + if len(left) < 2 or len(right) < 2: + return False + start_gap = math.dist((left[0].x, left[0].y), (right[0].x, right[0].y)) + end_gap = math.dist((left[-1].x, left[-1].y), (right[-1].x, right[-1].y)) + if max(start_gap, end_gap) > ROUTE_MATCH_ENDPOINT_TOLERANCE_M: + return False + left_length = left[-1].chainage_m + right_length = right[-1].chainage_m + if left_length <= 0 or right_length <= 0: + return False + return abs(left_length - right_length) / left_length <= ROUTE_MATCH_LENGTH_TOLERANCE + + +def resolve_route_profile( + vertices: list[RouteVertex], + *, + route_vertices: list[RouteVertex] | None = None, + longitudinal: dict[str, Any] | None = None, + sampler: SurfaceElevationSampler | None = None, +) -> tuple[list[RouteVertex], str]: + """계획노선 정점에 종단 Z를 채워 돌려준다. (정점 목록, Z 출처) 형태. + + `vertices`는 원청 계획노선(B04·B05 공용 기준선)이고, `route_vertices`는 B05가 푼 최적 + 경로다. 노선이 같을 때만 ①②를 쓰고, 아니면 ③(지표면 샘플링)으로 내려간다. + """ + if len(vertices) < 2: + return vertices, Z_SOURCE_CSV + + matched = bool(route_vertices) and routes_match(vertices, route_vertices or []) + if matched and longitudinal: + elevations = [ + design_elevation_from_longitudinal(longitudinal, vertex.chainage_m) + for vertex in vertices + ] + if all(value is not None for value in elevations): + logger.info("노선 종단 Z: B05 계획고(design_profiles) 채택 — 정점 %d개", len(vertices)) + return ( + [replace(v, z=float(z)) for v, z in zip(vertices, elevations)], + Z_SOURCE_DESIGN, + ) + + if matched and route_vertices: + logger.info("노선 종단 Z: B05 경로 정점(route_points) 채택 — 정점 %d개", len(vertices)) + return ( + [replace(v, z=interpolate_vertex(route_vertices, v.chainage_m)[2]) for v in vertices], + Z_SOURCE_ROUTE_POINTS, + ) + + if sampler is not None: + sampled = _sample_z(vertices, sampler) + if sampled is not None: + logger.info("노선 종단 Z: 확정 지표면 샘플링 채택 — 정점 %d개", len(vertices)) + return sampled, Z_SOURCE_SURFACE + + logger.info("노선 종단 Z: 원청 CSV z 유지 — 정점 %d개", len(vertices)) + return vertices, Z_SOURCE_CSV + + +def _sample_z( + vertices: list[RouteVertex], sampler: SurfaceElevationSampler +) -> list[RouteVertex] | None: + """확정 지표면에서 노선 정점의 지반고를 뽑는다. + + 모델 밖으로 나간 정점은 유효한 이웃 정점 값으로 메운다 — 노선 한두 점이 DTM 가장자리를 + 벗어났다고 종단 전체를 버리면 세부유역을 못 나눈다. 유효한 값이 하나도 없으면 None. + """ + xy = np.array([[vertex.x, vertex.y] for vertex in vertices], dtype=np.float64) + try: + z, valid = sampler.sample_xy(xy) + except (ValueError, OSError) as exc: + logger.warning("노선 종단 Z: 지표면 샘플링 실패 — %s", exc) + return None + if not bool(valid.any()): + logger.warning("노선 종단 Z: 노선이 확정 지표면 범위 밖입니다.") + return None + if not bool(valid.all()): + index = np.arange(z.size) + known = index[valid] + z = np.interp(index, known, z[valid]) + logger.info( + "노선 종단 Z: 지표면 밖 정점 %d개를 이웃 값으로 메웠습니다.", int((~valid).sum()) + ) + return [replace(vertex, z=float(value)) for vertex, value in zip(vertices, z)] diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Sections_Sampler.py b/common_util/common_util_surface_sampler.py similarity index 96% rename from B05_wf2_Route/B05_wf2_Route_Engine_Sections_Sampler.py rename to common_util/common_util_surface_sampler.py index 8d43a97e..00306d41 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Sections_Sampler.py +++ b/common_util/common_util_surface_sampler.py @@ -1,9 +1,13 @@ -"""B05 종횡단 계산용 지표면 표고 sampler. +"""확정 지표면 모델 표고 sampler (공용). 종·횡단 생성기가 의존하는 최소 표고 조회 인터페이스와, 확정된 지표면 모델 (B04_wf1_Surface/models)을 일괄 XY 표고 sampler로 여는 팩토리를 제공한다. DTM valid_mask를 footprint로 결합해 데이터가 없는 영역을 임의 표고로 메우지 않는다. + +B05 종횡단 전용이었으나 B04 배수유역 세부 설계도 같은 표고면을 써야 해서 +common_util로 옮겼다 — 두 화면의 종단 Z가 갈라지면 세부유역 경계가 달라진다 +(2026-08-01 구조 개편). """ from collections.abc import Callable diff --git a/config/config_system.py b/config/config_system.py index 6b7f1954..5a2bcfec 100644 --- a/config/config_system.py +++ b/config/config_system.py @@ -277,9 +277,9 @@ DRAINAGE_CONTOUR_CLIP_MARGIN_M = float(os.getenv("DRAINAGE_CONTOUR_CLIP_MARGIN_M # 평탄면 해소용 미세 경사(m/셀). 채움 후 흐름 방향이 없는 셀에 출구 쪽 경사를 만들어 준다. DRAINAGE_FLAT_EPSILON_M = float(os.getenv("DRAINAGE_FLAT_EPSILON_M", "0.001")) # 관 매설 최대 간격(m). 이 간격을 넘으면 흐름 강도가 가장 큰 지점에 관을 보충한다. -DRAINAGE_PIPE_MAX_SPACING_M = float(os.getenv("DRAINAGE_PIPE_MAX_SPACING_M", "300.0")) +DRAINAGE_PIPE_MAX_SPACING_M = float(os.getenv("DRAINAGE_PIPE_MAX_SPACING_M", "100.0")) # 관끼리 이보다 가까우면 같은 계곡으로 보고 하나로 합친다. -DRAINAGE_PIPE_MIN_SPACING_M = float(os.getenv("DRAINAGE_PIPE_MIN_SPACING_M", "20.0")) +DRAINAGE_PIPE_MIN_SPACING_M = float(os.getenv("DRAINAGE_PIPE_MIN_SPACING_M", "5.0")) # 측구 흐름(도로 셀 → 담당 관) 판정용 종단 계획선 샘플 간격(m). DRAINAGE_DITCH_SAMPLE_M = float(os.getenv("DRAINAGE_DITCH_SAMPLE_M", "1.0")) # 유역 폴리곤 단순화 허용오차(m). 격자 계단 경계를 매끄럽게 줄여 응답 크기를 낮춘다. @@ -292,6 +292,14 @@ DRAINAGE_CACHE_FILENAME = "watershed_grid.npz" # 분석 응답 자체를 그대로 담아 두는 파일. 재산정하지 않는 한 이걸 그대로 돌려준다 — # 저장 배열에서 응답을 다시 조립하면 원본과 어긋날 여지가 생긴다(2026-07-31 사용자 지시). DRAINAGE_RESPONSE_FILENAME = "00_watershed_response.json" +# 세부유역 산출물. 관 목록이 정해져야 나오므로 해석 산출물(01~03)과 번호를 이어 붙인다. +DRAINAGE_DETAIL_FILENAME = "04_detailed_basins.geojson" + +# ── 관 매설 지점 편집분 ── +# 해석 산출물(01~03)은 다시 돌리면 덮어써도 되지만 사용자가 찍은 관은 그러면 안 된다. +# 같은 폴더 아래 편집분 전용 칸을 따로 두고 B04(관리자)·B05(사용자)가 같은 파일을 본다. +DRAINAGE_EDITS_DIRNAME = "edits" +DRAINAGE_PIPE_POINTS_FILENAME = "pipe_points.json" # ── B05 전용 배수유역 사본 ── # B04 산출물을 그대로 쓰면 B05 편집이 원본을 덮어쓴다. 프로젝트 저장소의 diff --git a/main.py b/main.py index 8ed3fe41..3a2007e7 100644 --- a/main.py +++ b/main.py @@ -29,6 +29,7 @@ from B01_Dashboard.B01_Dashboard_Router import router as b01_dashboard_router from B02_ProjRegister.B02_ProjRegister_Router import router as b02_proj_register_router from B03_FileInput.B03_FileInput_Router import router as b03_file_input_router from B04_wf1_Surface.B04_wf1_Surface_Router import router as b04_surface_router +from B04_wf1_Surface.B04_wf1_Surface_Router_Basins import router as b04_basins_router from B04_wf1_Surface.B04_wf1_Surface_Router_Contour import router as b04_surface_contour_router from B04_wf1_Surface.B04_wf1_Surface_Router_GIS import router as b04_surface_gis_router from B04_wf1_Surface.B04_wf1_Surface_Router_GIS import tiles_router @@ -350,6 +351,7 @@ app.include_router(b04_surface_contour_router, dependencies=protected_with_compa app.include_router(b04_surface_gis_router, dependencies=protected_with_company) app.include_router(b04_watershed_router, dependencies=protected_with_company) app.include_router(b04_inflow_router, dependencies=protected_with_company) +app.include_router(b04_basins_router, dependencies=protected_with_company) app.include_router(tiles_router, dependencies=protected_with_company) app.include_router(b05_route_router, dependencies=protected_with_company) app.include_router(b05_drainage_router, dependencies=protected_with_company) diff --git a/ui_template/ui_template_locale.ts b/ui_template/ui_template_locale.ts index b54c6258..acd7350a 100644 --- a/ui_template/ui_template_locale.ts +++ b/ui_template/ui_template_locale.ts @@ -719,6 +719,38 @@ export const ui_locales = { "유입 셀을 불러오지 못했습니다.", "Failed to load the contributing cells.", ], + /* 상세 배수유역 (관 매설 지점 편집 + 세부유역 분할) */ + B04_Surface_Basin_Btn: ["상세유역 분석", "Detail basins"], + B04_Surface_Basin_Btn_Busy: ["분석 중…", "Analyzing…"], + B04_Surface_Basin_Btn_Tip: [ + "관 매설 지점을 기준으로 세부 배수유역을 나눕니다. 계획선 위에서 우클릭하면 관을 추가하고, 마커 위에서 우클릭하면 삭제합니다. 마커를 끌면 계획선을 따라 옮겨집니다.", + "Splits the basin per culvert. Right-click the route to add a culvert, right-click a marker to remove it, and drag a marker to slide it along the route.", + ], + B04_Surface_Basin_Part_Pipes: ["관 매설", "Culverts"], + B04_Surface_Basin_Part_Basins: ["세부 유역", "Sub-basins"], + B04_Surface_Basin_Menu_Add: ["관 매설 추가", "Add culvert"], + B04_Surface_Basin_Menu_Delete: ["관 매설 삭제", "Remove culvert"], + /* {pipes}=관 개수, {stream}=기본, {spacing}=자동 보충, {user}=수동, {basins}=세부유역 수, {source}=종단 Z 출처 */ + B04_Surface_Basin_Summary: [ + "관 {pipes}개(기본 {stream} / 자동 {spacing} / 수동 {user}) · 세부유역 {basins}개 · 종단 Z {source}", + "{pipes} culverts ({stream} stream / {spacing} auto / {user} manual) · {basins} sub-basins · profile Z {source}", + ], + B04_Surface_Basin_Edited: [ + "편집 중 — [상세유역 분석]을 눌러 다시 나눕니다.", + "Edited — press [Detail basins] to re-split.", + ], + B04_Surface_Basin_TooClose: [ + "관끼리 최소 간격 {min}m 안에는 넣을 수 없습니다.", + "Culverts cannot be closer than {min}m.", + ], + B04_Surface_Basin_Failed: [ + "상세유역을 계산하지 못했습니다.", + "Failed to compute the sub-basins.", + ], + B04_Surface_Basin_Saved: [ + "관 매설 지점 {count}개를 저장했습니다.", + "Saved {count} culvert points.", + ], B04_Surface_Map_PlannedRouteEmpty: [ "B03에서 계획노선 파일을 올리면 표시됩니다.", "Shown after the planned route file is uploaded in B03.", diff --git a/ui_template/ui_template_theme.css b/ui_template/ui_template_theme.css index e04c0167..2a9562fd 100644 --- a/ui_template/ui_template_theme.css +++ b/ui_template/ui_template_theme.css @@ -218,7 +218,9 @@ --map-upstream-toggle: #1d4ed8; /* 토글 버튼 색띠 */ --map-satellite-toggle: #64748b; /* 배경사진은 선 색이 없어 중립 회색 */ --map-pipe-orphan: #e5e7eb; /* 유역이 없는 배관 마커 */ - --map-pipe-marker: rgba(249, 115, 22, 0.95); /* B04 기본 관 마커 */ + --map-pipe-marker: rgba(249, 115, 22, 0.95); /* B04 기본 관 마커(세류 교차점) */ + --map-pipe-auto: rgba(14, 165, 233, 0.95); /* 최대 간격 규칙으로 자동 보충한 관 */ + --map-pipe-user: rgba(22, 163, 74, 0.95); /* 사용자가 넣거나 옮긴 관 */ /* 배수유역 오버레이(B04 지도) */ --map-halo: rgba(255, 255, 255, 0.9); /* 선·글자 뒤에 까는 흰 테두리 */