diff --git a/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts b/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts index f187db70..f00179aa 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts @@ -468,6 +468,22 @@ export interface DetailBasinResponse { pipe_points: DetailPipePoint[]; basins: DetailBasin[]; pipe_count: number; + /** 도로 1m 구간별 유입 면적 — [누가거리, 면적]. 계획선 색칠에 쓴다. */ + strength_profile: Array<[number, number]>; + /** 유입 집중점 — [누가거리, 유입면적, 구역번호, 구역 내 순위]. */ + inflow_hotspots: Array<[number, number, number, number]>; + /** B04가 분석에 쓴 계획 노선 선형(lon/lat). */ + route_lonlat: Array<[number, number]>; + /** 2차 전체 배수유역 외곽선 = 분수령. 해석 결과 그대로. */ + main_polygon_lonlat: Array<[number, number]>; + /** B04 해석 격자 한 변(m). */ + grid_cell_m: number; + /** 평균 흐름 화살표 — [x, y(사업지 CRS m), 방위(도), 도로도달, 셀 수]. */ + flow_arrows: Array<[number, number, number, boolean, number]>; + /** 화살표 사이 실제 간격(m). */ + arrow_spacing_m: number; + /** 유역 안쪽 상류 세류망 — 하이라이트 토글용. */ + upstream_lonlat: Array>; } /** 저장된 관 매설 지점과 그 세부유역. 저장분이 없으면 백엔드가 자동 생성해 돌려준다. */ diff --git a/B04_wf1_Surface/B04_wf1_Surface_Router_Basins.py b/B04_wf1_Surface/B04_wf1_Surface_Router_Basins.py index 74a7695e..c2cb9464 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Router_Basins.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Router_Basins.py @@ -95,6 +95,17 @@ def _payload( "saved": saved, # 도로 1m 구간별 유입 면적 — 계획선을 색으로 칠하는 데 쓴다(B05와 같은 값). "strength_profile": detail.strength_profile, + # 유입 집중점 — 관을 어디에 둘지 판단하는 근거. 화면에서 토글로 켠다. + "inflow_hotspots": detail.inflow_hotspots, + # ── 아래는 B05 배수유역도가 그리는 데 필요한 값. B04 지도는 자체 오버레이가 있어 + # 쓰지 않지만, 두 화면이 같은 응답을 받아야 결과가 갈리지 않는다(2026-08-01 일원화). + "route_lonlat": detail.route_lonlat, + # 2차 전체 유역 외곽선 = 분수령. 해석 결과 그대로(손으로 고치는 기능 없음). + "main_polygon_lonlat": detail.basin_lonlat, + "grid_cell_m": detail.grid_cell_m, + "flow_arrows": detail.flow_arrows, + "arrow_spacing_m": detail.arrow_spacing_m, + "upstream_lonlat": detail.upstream_lonlat, "pipe_points": [ { "chainage_m": round(pipe.chainage_m, 2), diff --git a/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts b/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts index 77da381e..427818da 100644 --- a/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts +++ b/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts @@ -11,7 +11,7 @@ * - 오류 응답 형식 {status:"error", message:"..."}을 Error로 변환. * ========================================================================== */ -import { API_ANALYSIS_TIMEOUT_MS, API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend"; +import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend"; /** 경로 제어점 (BP/EP/CP) */ export interface RoutePoint { @@ -266,6 +266,30 @@ export const routeLatestCacheKey = (projectId: string): string => `b05:latest:${ /** 담아 둔 최신 경로 값을 버린다. B04에서 지표면을 다시 확정하면 옛 확정값이 남아 * B05가 이전 지형을 그리게 되므로, 확정 직후 이 값을 지운다. */ +/** 세션 캐시에서 최신 경로 응답을 읽는다. 없거나 깨졌으면 null(다음 진입은 DB 조회). */ +export function readRouteLatestCache(projectId: string): RouteLatestResponse | null { + try { + const raw = window.sessionStorage.getItem(routeLatestCacheKey(projectId)); + return raw ? (JSON.parse(raw) as RouteLatestResponse) : null; + } catch { + return null; + } +} + +/** 최신 경로 응답을 세션 캐시에 넣는다. 용량 초과 등으로 실패하면 캐시를 비운다. */ +export function writeRouteLatestCache(projectId: string, value: RouteLatestResponse): void { + const key = routeLatestCacheKey(projectId); + try { + window.sessionStorage.setItem(key, JSON.stringify(value)); + } catch { + try { + window.sessionStorage.removeItem(key); + } catch { + /* noop */ + } + } +} + export function clearRouteLatestCache(projectId: string): void { try { window.sessionStorage.removeItem(routeLatestCacheKey(projectId)); @@ -273,72 +297,3 @@ export function clearRouteLatestCache(projectId: string): void { /* 세션 접근 실패 시에는 다음 진입에서 DB를 읽게 되므로 그대로 둔다. */ } } - -/* ── 배수유역도 (B05_wf2_Route_Router_Drainage.py) ───────────────────────── */ - -/** 관 매설 구조물 측점 후보 1개. reason: stream=세류 교차, spacing=300m 보충. */ -export interface DrainageCandidate { - chainage_m: number; - x: number; - y: number; - lon: number; - lat: number; - reason: "stream" | "spacing" | "confirmed"; - stream_name: string | null; -} - -export interface DrainageCandidateResponse { - status: string; - project_id: string; - route_id: number; - candidates: DrainageCandidate[]; -} - -/** 관 1개가 받는 세부 배수유역. 관경(pipe_diameter_mm)은 수식 미확정이라 당분간 항상 null이다. */ -export interface DrainageBasin { - index: number; - chainage_m: number; - /** 관(배관) 매설 지점 좌표 — 계획선 위 마커 렌더용. */ - outlet_lonlat: [number, number]; - polygon_lonlat: Array<[number, number]>; - area_m2: number; - relief_m: number; - flow_length_m: number; - pipe_diameter_mm: number | null; -} - -export interface DrainageBasinResponse { - status: string; - project_id: string; - route_id: number; - /** 산정에 실제 사용된 배관 지점 — 유역이 없는 관도 포함(마커 동기화용). */ - pipes: DrainageCandidate[]; - /** B04가 분석에 쓴 계획 노선 선형(lon/lat). */ - route_lonlat: Array<[number, number]>; - /** 2차 전체 배수유역 외곽선 = 분수령. 해석 결과 그대로이며 손으로 고치지 않는다. */ - main_polygon_lonlat: Array<[number, number]>; - /** 유역 안쪽 상류 세류망 — 하이라이트 토글용. */ - upstream_lonlat: Array>; - /** 도로 1m 구간별 유입 면적 — [누가거리, 면적]. 계획선 색칠에 쓴다(B04 지도와 같은 값). */ - strength_profile?: Array<[number, number]>; - /** B04 해석 격자 한 변(m). */ - grid_cell_m: number; - /** 평균 흐름 화살표 — [x, y(사업지 CRS m), 방위(도), 도로도달, 셀 수]. */ - flow_arrows: Array<[number, number, number, boolean, number]>; - /** 화살표 사이 실제 간격(m). */ - arrow_spacing_m: number; - basins: DrainageBasin[]; -} - -/** chainages를 주면 그 위치로 확정 산정하고, 비우면 자동 제안분으로 산정한다. */ -export async function fetchDrainageBasins( - projectId: string, - chainages?: number[], -): Promise { - // 격자 해석이 포함된 요청이라 캐시가 없으면 수십 초가 걸린다. - return requestJson( - `/projects/${projectId}/drainage/basins`, - { method: "POST", body: JSON.stringify({ chainages: chainages ?? [] }) }, - API_ANALYSIS_TIMEOUT_MS, - ); -} diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Basin.py b/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Basin.py deleted file mode 100644 index 109a6695..00000000 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Basin.py +++ /dev/null @@ -1,58 +0,0 @@ -"""배수유역 세부 설계 — B05 저장소 어댑터. - -계산은 하지 않는다. 관 보충(⑨)과 세부유역 분할(⑩)의 알고리즘은 B04 관리자 화면과 -공용이므로 `common_util_drainage_detail`에 있고, 여기서는 **B05가 읽을 폴더만 정한다** -(2026-08-01 구조 개편). - -읽는 대상은 B04 원본이 아니라 **B05 사본**이다. B04가 다시 해석했으면 사본을 먼저 -갱신한다 — 편집분(`boundary_overrides.json`)은 사본 갱신과 무관하게 남는다. -""" - -from __future__ import annotations - -from pathlib import Path - -from B05_wf2_Route.B05_wf2_Route_Engine_Drainage_Store import b05_drainage_dir, sync_from_b04 -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 - -__all__ = [ - "DrainageDetail", - "RoadRouting", - "WatershedBasin", - "build_drainage_detail", - "load_road_routing", - "load_upstream_lines", -] - - -def _synced_dir(stored_path: str) -> Path: - """B04 원본을 B05 사본으로 맞춘 뒤 사본 폴더를 돌려준다.""" - sync_from_b04(stored_path) - return b05_drainage_dir(stored_path) - - -def build_drainage_detail( - stored_path: str, - vertices: list[RouteVertex], - confirmed_chainages: list[float] | None = None, -) -> DrainageDetail | None: - """B04 분석 결과(B05 사본)를 읽어 관을 보충하고 세부유역을 나눈다.""" - return build_detail(_synced_dir(stored_path), vertices, confirmed_chainages) - - -def load_road_routing(stored_path: str) -> RoadRouting | None: - """`03_road_routing` 산출물을 B05 사본에서 읽는다.""" - return read_road_routing(_synced_dir(stored_path)) - - -def load_upstream_lines(stored_path: str) -> list[list[list[float]]]: - """상류 세류망을 B05 사본에서 읽는다(화면 강조용).""" - return read_upstream_lines(b05_drainage_dir(stored_path)) diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Store.py b/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Store.py deleted file mode 100644 index b850a2ae..00000000 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Store.py +++ /dev/null @@ -1,56 +0,0 @@ -"""B05 전용 배수유역 저장소 (B04 산출물 사본 관리). - -B04는 배수유역을 해석해 `B04_wf1_Surface/drainage/`에 남긴다. B05는 그 결과를 읽어 관을 -보충하고 세부유역을 나누는데, 같은 폴더를 그대로 쓰면 B05 쪽 작업이 B04 원본을 덮어쓴다. -그래서 여기서 사본을 따로 둔다(2026-08-01 사용자 지시). - - · `B05_wf2_Route/drainage/` — B04 산출물의 사본. B04가 다시 해석하면 자동으로 갱신된다. - -유역 외곽선을 손으로 고치는 기능은 없앴다 — 등고선을 전부 해석해 얻은 경계라 사람이 다시 -그릴 이유가 없다(2026-08-01 사용자 지시). 그래서 여기에는 편집분 보존 로직이 없다. -""" - -from __future__ import annotations - -import logging -import shutil -from pathlib import Path - -from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Export import drainage_dir -from common_util.common_util_storage import resolve_stored_project_path -from config.config_system import DRAINAGE_B05_DIRNAME - -logger = logging.getLogger(__name__) - - -def b05_drainage_dir(stored_path: str) -> Path: - """B05 전용 배수유역 폴더. B04 원본과 분리된 사본이 여기 들어간다.""" - return Path(resolve_stored_project_path(stored_path)) / "B05_wf2_Route" / DRAINAGE_B05_DIRNAME - - -def sync_from_b04(stored_path: str) -> bool: - """B04 산출물을 B05 사본으로 맞춘다. 실제로 복사했으면 True. - - 사본이 없으면 초안으로 1회 복사하고, B04 쪽이 더 최신이면(재해석) 그 파일만 덮어쓴다. - """ - source = drainage_dir(stored_path) - if not source.is_dir(): - return False - target = b05_drainage_dir(stored_path) - copied = 0 - try: - target.mkdir(parents=True, exist_ok=True) - for item in source.iterdir(): - if not item.is_file(): - continue - destination = target / item.name - if destination.exists() and destination.stat().st_mtime >= item.stat().st_mtime: - continue - shutil.copy2(item, destination) - copied += 1 - except OSError: - logger.warning("배수유역: B05 사본 갱신 실패 (%s → %s)", source, target) - return False - if copied: - logger.info("배수유역: B05 사본 갱신 — %d개 파일 (%s)", copied, target) - return copied > 0 diff --git a/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py b/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py deleted file mode 100644 index 9f67bdfc..00000000 --- a/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py +++ /dev/null @@ -1,131 +0,0 @@ -"""배수유역 세부 설계 API 라우터 (B05 — 일반 사용자용). - -**분석하지 않는다.** B04가 미리 돌려 저장한 결과를 읽어 관을 보충하고 세부유역만 나눈다. -격자 해석은 30초가 걸려 일반 사용자를 붙잡아 두므로 여기서는 아예 돌리지 않는다 -(2026-07-31 사용자 지시). - -좌표는 사업지 CRS(m)에서 계산하고 응답만 WGS84로 바꿔 내보낸다. -""" - -import asyncio -import logging -from typing import Any -from uuid import UUID - -from fastapi import APIRouter -from fastapi.responses import JSONResponse - -from B05_wf2_Route.B05_wf2_Route_Engine_Drainage_Basin import build_drainage_detail -from common_util.common_util_drainage_context import DrainageContext, load_drainage_context -from common_util.common_util_route_geometry import StructureCandidate - -logger = logging.getLogger(__name__) -router = APIRouter(prefix="/api/projects", tags=["B05 Route Drainage"]) - - -def _candidate_payload(candidate: StructureCandidate, to_lonlat: Any) -> dict[str, Any]: - lon, lat = to_lonlat(candidate.x, candidate.y) - return { - "chainage_m": round(candidate.chainage_m, 2), - "x": candidate.x, - "y": candidate.y, - "lon": lon, - "lat": lat, - "reason": candidate.reason, - "stream_name": candidate.stream_name, - } - - -async def _prepare(project_id: UUID) -> DrainageContext | JSONResponse: - """노선·종단 Z·좌표 변환기를 준비한다. 도엽 피처는 읽지 않는다(분석을 안 하므로). - - 노선 기준선은 원청 계획노선 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=404, - content={"status": "error", "message": "확정된 경로가 없습니다."}, - ) - return context - - -@router.post("/{project_id}/drainage/basins", response_model=None) -async def post_drainage_basins( - project_id: UUID, - payload: dict[str, Any] | None = None, -) -> dict[str, Any] | JSONResponse: - """B04 분석 결과로 관을 보충하고 세부유역을 나눈다. - - payload에 `chainages`(누가거리 목록)를 주면 그 위치로 관을 확정하고(사용자 편집), - 없으면 B04의 기본 관에 최대 간격 규칙으로 최소 개수만 보충한다. - """ - prepared = await _prepare(project_id) - if isinstance(prepared, JSONResponse): - return prepared - raw = (payload or {}).get("chainages") - confirmed = _parse_chainages(raw) if isinstance(raw, list) else [] - - detail = await asyncio.to_thread( - build_drainage_detail, prepared.stored_path, prepared.vertices, confirmed - ) - if detail is None: - return JSONResponse( - status_code=404, - content={ - "status": "error", - "message": "배수유역 분석 결과가 없습니다. B04에서 먼저 분석을 실행하세요.", - }, - ) - - to_lonlat = prepared.to_lonlat - return { - "status": "success", - "project_id": str(project_id), - "route_id": prepared.route_id, - # 종단 Z를 어디서 가져왔는지 — 관 담당 구간이 갈리는 근거라 화면에서 확인 가능해야 한다. - "z_source": prepared.z_source, - # B04가 남긴 그대로 — 계획도로선. 외곽선만 편집분을 반영해 내보낸다. - "route_lonlat": detail.route_lonlat, - # 2차 전체 유역 외곽선 — 해석 결과 그대로. 손으로 고치는 기능은 없앴다 - # (등고선을 전부 해석한 결과라 수정할 이유가 없다 — 2026-08-01 사용자 지시). - "main_polygon_lonlat": detail.basin_lonlat, - "grid_cell_m": detail.grid_cell_m, - # 평균 흐름 화살표 — B04가 계산해 저장한 것을 그대로 넘긴다(사업지 CRS m). - "flow_arrows": detail.flow_arrows, - "arrow_spacing_m": detail.arrow_spacing_m, - # 유역 안쪽 상류 세류망 — 화면 강조 토글용(WGS84). - "upstream_lonlat": detail.upstream_lonlat, - # 도로 1m 구간별 유입 면적 — 계획선을 색으로 칠하는 데 쓴다(B04와 같은 값). - "strength_profile": detail.strength_profile, - # 계획선 위 배관 지점 — 유역이 없는 관도 마커로 표시해야 하므로 별도 목록. - "pipes": [_candidate_payload(pipe, to_lonlat) 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 - ], - } - - -def _parse_chainages(values: list[Any]) -> list[float]: - """사용자가 확정·편집한 누가거리 목록을 숫자로 정리한다.""" - parsed: list[float] = [] - for value in values: - try: - parsed.append(float(value)) - except (TypeError, ValueError): - continue - return parsed diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts index ccd20c45..9ab7353a 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts @@ -4,62 +4,54 @@ import { themeColor } from "@ui/ui_template_palette"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { fetchCachedSheetLayer } from "../A00_Common/b_asset_cache"; import { + computeDetailBasins, + fetchDetailPipePoints, fetchVWorldMeta, getVWorldMapUrl, + saveDetailPipePoints, + type DetailBasin, + type DetailBasinResponse, + type PipeSource, type VWorldMeta, } from "../B04_wf1_Surface/B04_wf1_Surface_Api_Fetch"; import { computeMapRect, computeRouteView, createNormalizer, - drawFilledRing, - drawPreparedLayer, - drawRidgeRing, - drawUpstreamLines, + lonLatToScreen, prepareLayer, prepareMetricPolyline, - routeLineColor, - ROUTE_LINE_WIDTH, type GeoJsonCollection, type MapRect, type Normalizer, type PreparedLayer, type ViewState, } from "../B04_wf1_Surface/B04_wf1_Surface_UI_MapRender"; -import { - fetchDrainageBasins, - type DrainageBasin, - type RoutePoint, -} from "./B05_wf2_Route_Api_Fetch"; -import { drawFlowArrows, type FlowArrow } from "../B04_wf1_Surface/B04_wf1_Surface_UI_FlowArrows"; -import { - buildStrengthArray, - drawStrengthLine, -} from "../B04_wf1_Surface/B04_wf1_Surface_UI_FlowRamp"; +import { type RoutePoint } from "./B05_wf2_Route_Api_Fetch"; +import type { FlowArrow } from "../B04_wf1_Surface/B04_wf1_Surface_UI_FlowArrows"; +import { buildStrengthArray } from "../B04_wf1_Surface/B04_wf1_Surface_UI_FlowRamp"; import { resampleRoute } from "../B04_wf1_Surface/B04_wf1_Surface_UI_RouteSamples"; import { createPipeEditor } from "./B05_wf2_Route_UI_Drainage_Pipes"; import { createProgressCircle } from "@ui/ui_template_progress"; import { createMapContextMenu } from "@ui/ui_template_context_menu"; +import { drawDrainageScene } from "./B05_wf2_Route_UI_Drainage_Render"; import { - addLayerToggle, - arrowToggleColor, + mountDrainageToggles, basinColor, + DRAINAGE_LAYERS, bindPipeContextMenu, COLLAPSED_KEY, - DRAINAGE_LAYERS, - LAYER_LABEL_KEYS, - layerColor, MAX_PANEL_WIDTH_RATIO, MIN_PANEL_WIDTH, - createMetricProjector, renderBasinRows, - satelliteToggleColor, - strengthToggleColor, - upstreamToggleColor, + pointInRing, WIDTH_KEY, type DrainageLayer, } from "./B05_wf2_Route_UI_Drainage_Parts"; +/** 이만큼(px) 이하로 움직였다 뗐으면 클릭으로 본다 — 손떨림으로 선택이 안 되는 일을 막는다. */ +const CLICK_SLOP_PX = 4; + // 배수유역도 패널 — 하단 종단 패널 안쪽 우측에 도킹되는 2단 사이드 패널. // 하단 패널이 닫히면 이 패널도 함께 화면에서 사라진다(부모 안에 들어 있으므로 자동). // 지도는 B04에서 분리한 렌더 엔진(B04_wf1_Surface_UI_MapRender)을 그대로 재사용해 @@ -75,10 +67,24 @@ export interface DrainagePanel { load: (projectId: string) => void; /** 확정된 노선 평면 선형(사업지 좌표계 m)을 지도 위에 겹친다. */ setRoute: (points: ReadonlyArray) => void; + /** 현재 관 매설 누가거리 목록(종단 테이블의 "배관" 구조물 라인과 맞추는 데 쓴다). */ + pipeChainages: () => number[]; + /** 종단 테이블에서 배관 라인을 끌었을 때 — 그 자리로 옮기고 세부유역을 다시 나눈다. */ + movePipe: (fromChainage: number, toChainage: number) => void; + /** 종단 테이블 우클릭으로 배관을 넣거나 지울 때. */ + addPipe: (chainageM: number) => void; + removePipe: (chainageM: number) => void; + /** 경로 확정 시 관 매설 지점을 영구저장한다(B04 "모델 확정"과 같은 저장소). */ + savePipes: () => Promise; dispose: () => void; } -export function createDrainagePanel(): DrainagePanel { +export interface DrainagePanelCallbacks { + /** 관 목록이 바뀔 때마다 누가거리 목록을 넘긴다 — 종단 테이블 구조물 라인 동기화용. */ + onPipesChanged?: (chainages: number[]) => void; +} + +export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): DrainagePanel { const root = document.createElement("aside"); root.className = "b05-drainage"; const panelHandle = createWorkflowPanelHandle("side"); @@ -136,6 +142,10 @@ export function createDrainagePanel(): DrainagePanel { if (label !== null) progress.set(ratio, label); } // 유역 제원 목록(면적·표고·유하거리·관경). 관경 수식 미확정이라 당분간 "미정"으로 나온다. + // 관 개수·세부유역 수·종단 Z 출처 — 세부유역이 갈리는 근거라 목록 위에 한 줄로 남긴다. + const summary = document.createElement("div"); + summary.className = "b05-drainage__summary"; + summary.hidden = true; const basinList = document.createElement("div"); basinList.className = "b05-drainage__basins"; basinList.hidden = true; @@ -150,7 +160,7 @@ export function createDrainagePanel(): DrainagePanel { max: () => (root.parentElement?.clientWidth ?? window.innerWidth) * MAX_PANEL_WIDTH_RATIO, storageKey: WIDTH_KEY, }); - root.append(panelHandle.root, widthResizer.root, header, viewport, basinList); + root.append(panelHandle.root, widthResizer.root, header, viewport, summary, basinList); let projectId: string | null = null; let meta: VWorldMeta | null = null; @@ -159,7 +169,7 @@ export function createDrainagePanel(): DrainagePanel { let routeLayer: PreparedLayer | null = null; let routePoints: ReadonlyArray = []; let normalizer: Normalizer | null = null; - let basins: DrainageBasin[] = []; + let basins: DetailBasin[] = []; let selectedBasin: number | null = null; // 배관 편집기 — 마커 선택/추가/이동/삭제 시 재그리기와 버튼 상태만 갱신한다. const pipeEditor = createPipeEditor( @@ -177,6 +187,12 @@ export function createDrainagePanel(): DrainagePanel { let flowArrows: FlowArrow[] = []; let arrowSpacingM = 0; let showArrows = true; + // 유입 집중점 — 관 자리를 판단하는 근거. 기본 꺼짐(마커가 관 마커와 겹쳐 읽기 어렵다). + let hotspots: Array<{ chainage: number; area: number }> = []; + let maxHotspotArea = 0; + let showHotspots = false; + /** 종단 Z 출처 — 세부유역이 갈리는 근거라 화면에서 확인 가능해야 한다. */ + let zSource = ""; // 유역 안쪽 상류 세류망 — B04가 채택한 기준선을 그대로 받아 강조만 한다. let upstreamLines: Array> = []; let showUpstream = true; @@ -189,68 +205,49 @@ export function createDrainagePanel(): DrainagePanel { let offsetX = 0; let offsetY = 0; let dragStart: { x: number; y: number; offsetX: number; offsetY: number } | null = null; + /** 좌클릭을 시작한 자리 — 끌지 않고 뗐을 때만 유역 고르기로 본다. */ + let basinClickStart: { x: number; y: number } | null = null; + /** 마커를 잡은 좌클릭 — 이 경우 유역 고르기로 넘기지 않는다. */ + let pipeClickStart: { x: number; y: number } | null = null; let frameHandle = 0; let loadSequence = 0; let canvasWidth = 0; let canvasHeight = 0; let canvasDpr = 0; - // 배경 위성사진 — 등고선 앞에 둔다(2026-08-01 사용자 지시). 사진이 어두워 유역 채움색이 - // 묻힐 때 끄고 본다. 캔버스가 아니라 배경 이미지라 표시 여부만 직접 바꾼다. - addLayerToggle( - layerButtons, - L("B05_Drainage_Layer_Satellite"), - satelliteToggleColor(), - true, - (next) => { + mountDrainageToggles(layerButtons, { + initial: { + arrows: showArrows, + strength: showStrength, + hotspots: showHotspots, + upstream: showUpstream, + }, + onSatellite: (next) => { backgroundImage.hidden = !next; scheduleDraw(); }, - L("B05_Drainage_Layer_Satellite_Tip"), - ); - DRAINAGE_LAYERS.forEach((layer) => { - addLayerToggle(layerButtons, L(LAYER_LABEL_KEYS[layer]), layerColor(layer), true, (next) => { + onSheetLayer: (layer, next) => { if (next) activeLayers.add(layer); else activeLayers.delete(layer); scheduleDraw(); - }); - }); - // 흐름 화살표 — 도면이 지저분해질 때 끄기 위한 토글(등고선·세류와 같은 줄·같은 양식). - addLayerToggle( - layerButtons, - L("B05_Drainage_Layer_Arrows"), - arrowToggleColor(), - showArrows, - (next) => { + }, + onArrows: (next) => { showArrows = next; scheduleDraw(); }, - L("B05_Drainage_Layer_Arrows_Tip"), - ); - // 유입 강도 색칠 — 노선 1m 구간별 상류 면적. B04 지도와 같은 색띠를 쓴다. - addLayerToggle( - layerButtons, - L("B05_Drainage_Layer_Strength"), - strengthToggleColor(), - showStrength, - (next) => { + onStrength: (next) => { showStrength = next; scheduleDraw(); }, - L("B05_Drainage_Layer_Strength_Tip"), - ); - // 상류 세류선 강조 — 유역 판정의 기준선이라 항상 같은 굵기·색으로 얹는다. - addLayerToggle( - layerButtons, - L("B05_Drainage_Layer_Upstream"), - upstreamToggleColor(), - showUpstream, - (next) => { + onHotspots: (next) => { + showHotspots = next; + scheduleDraw(); + }, + onUpstream: (next) => { showUpstream = next; scheduleDraw(); }, - L("B05_Drainage_Layer_Upstream_Tip"), - ); + }); function updateImageTransform(): void { backgroundImage.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${scale})`; @@ -276,65 +273,30 @@ export function createDrainagePanel(): DrainagePanel { context.clearRect(0, 0, width, height); const mapRect: MapRect = computeMapRect(meta, width, height); const view: ViewState = { width, height, scale, offsetX, offsetY, mapRect }; - // 세부유역 채움을 가장 아래에 깔아 등고선·세류 판독을 가리지 않게 한다. - if (normalizer) { - basins.forEach((basin) => { - const color = basinColor(basin.index); - drawFilledRing( - context, - { ring: basin.polygon_lonlat, label: String(basin.index) }, - normalizer!, - view, - selectedBasin === null || selectedBasin === basin.index - ? color - : color.replace(/0\.45\)$/, "0.18)"), - ); - }); - // 전체 유역 외곽선 = 분수령(능선). 세부유역 경계와 구분되게 파선 한 겹만 얹는다. - // 해석 결과를 그대로 그린다 — 손으로 고치지 않는다(2026-08-01 사용자 지시). - if (mainBoundary.length > 2) drawRidgeRing(context, mainBoundary, normalizer, view); - } - // 등고선을 얇게 깔고 세류를 그 위에, 노선을 맨 위에 둔다. - DRAINAGE_LAYERS.forEach((layer) => { - if (!activeLayers.has(layer)) return; - const prepared = preparedLayers.get(layer); - if (!prepared) return; - context.lineWidth = layer === "도엽_등고선" ? 0.7 : 1.5; - context.strokeStyle = layerColor(layer); - drawPreparedLayer(context, prepared, view, "dot"); + drawDrainageScene(context, view, { + meta, + normalizer, + basins, + selectedBasin, + mainBoundary, + preparedLayers, + activeLayers, + routeLayer, + upstreamLines, + showUpstream, + strengthSamples, + strength, + maxStrength, + showStrength, + flowArrows, + arrowSpacingM, + showArrows, + hotspots, + maxHotspotArea, + showHotspots, + pipeEditor, + pipeColor, }); - // 상류 세류선 강조 — 유역 채움 위, 흐름 화살표 아래(2026-08-01 사용자 지시). - // 그리기는 B04 오버레이와 같은 공용 렌더러를 쓴다. - if (showUpstream && normalizer && upstreamLines.length > 0) { - drawUpstreamLines(context, upstreamLines, normalizer, view); - } - if (routeLayer) { - context.lineWidth = ROUTE_LINE_WIDTH; - context.strokeStyle = routeLineColor(); - drawPreparedLayer(context, routeLayer, view, "dot"); - } - // 유입 강도 색칠 — 계획선 위, 배관 마커 아래. 색띠는 B04 지도와 공용이다. - if (showStrength && meta && strength.length > 0) { - const { toScreen } = createMetricProjector(meta, view); - drawStrengthLine(context, strengthSamples, strength, maxStrength, (point) => - toScreen(point.x, point.y), - ); - } - // 평균 흐름 화살표 — 유역 채움 위, 배관 마커 아래. 좌표는 사업지 CRS(m)라 - // 도엽 메타로 바로 화면에 옮긴다(배관 마커와 같은 변환). - if (showArrows && meta && flowArrows.length > 0) { - const projector = createMetricProjector(meta, view); - drawFlowArrows( - context, - flowArrows, - arrowSpacingM, - projector.pxPerMeter, - projector.toScreen, - view, - ); - } - // 배관(관 매설) 마커 — 계획선 위 최상단. - pipeEditor.draw(context, view, pipeColor); updateImageTransform(); } @@ -383,8 +345,62 @@ export function createDrainagePanel(): DrainagePanel { }); } - /** 구조물 측점 후보 제안 + 유역 산정을 백엔드에 요청한다(계산은 전부 백엔드). - * 편집된 배관이 있으면 그 누가거리로 확정 산정하고, auto=true면 자동 제안으로 되돌린다. */ + /** 응답을 화면 상태로 옮긴다. B04 지도와 **같은 엔드포인트·같은 응답**을 쓴다 + * — 두 화면이 다른 결과를 보이면 안 되기 때문이다(2026-08-01 일원화). */ + function apply(response: DetailBasinResponse): void { + basins = response.basins; + mainBoundary = response.main_polygon_lonlat ?? []; + upstreamLines = (response.upstream_lonlat ?? []) as Array>; + flowArrows = (response.flow_arrows ?? []) as FlowArrow[]; + arrowSpacingM = response.arrow_spacing_m ?? 0; + hotspots = (response.inflow_hotspots ?? []).map(([chainage, area]) => ({ chainage, area })); + zSource = response.z_source ?? ""; + const built = buildStrengthArray( + (response.strength_profile ?? []) as ReadonlyArray, + ); + strength = built.strength; + maxStrength = built.maximum; + maxHotspotArea = hotspots.reduce((max, spot) => (spot.area > max ? spot.area : max), 0); + // 실제 사용된 배관 목록으로 마커를 동기화한다(유역 없는 관 포함). + pipeEditor.setPipes( + response.pipe_points.map((pipe) => ({ + chainage_m: pipe.chainage_m, + reason: pipe.source, + })), + ); + selectedBasin = null; + renderBasinList(); + syncPipeSelection(); + summary.textContent = L("B05_Drainage_Summary") + .replace("{pipes}", String(pipeEditor.pipes().length)) + .replace("{basins}", String(basins.length)) + .replace("{source}", zSource || "-"); + summary.hidden = basins.length === 0; + callbacks.onPipesChanged?.(pipeEditor.chainages()); + } + + /** 저장된 관 지점(없으면 자동 배치)을 불러온다. 화면에 들어올 때 1회. */ + async function loadSaved(): Promise { + if (!projectId) return; + status.hidden = false; + status.textContent = L("B05_Drainage_Status_Analyzing"); + showProgress(null, L("B05_Drainage_Status_Analyzing")); + try { + apply(await fetchDetailPipePoints(projectId)); + status.hidden = basins.length > 0; + if (basins.length === 0) status.textContent = L("B05_Drainage_Status_NoBasin"); + scheduleDraw(); + } catch (error) { + status.hidden = false; + status.textContent = + error instanceof Error ? error.message : L("B05_Drainage_Status_AnalyzeFailed"); + } finally { + showProgress(null, null); + } + } + + /** 세부유역을 다시 나눈다. `auto=true`면 편집분을 버리고 자동 배치로 되돌린다. + * 격자 해석은 하지 않으므로 즉시 끝난다. */ async function analyze(auto = false): Promise { if (!projectId) return; analyzeButton.disabled = true; @@ -392,29 +408,14 @@ export function createDrainagePanel(): DrainagePanel { status.textContent = L("B05_Drainage_Status_Analyzing"); showProgress(null, L("B05_Drainage_Status_Analyzing")); try { - const chainages = !auto && pipeEditor.pipes().length > 0 ? pipeEditor.chainages() : undefined; - const response = await fetchDrainageBasins(projectId, chainages); - basins = response.basins; - mainBoundary = response.main_polygon_lonlat ?? []; - upstreamLines = (response.upstream_lonlat ?? []) as Array>; - flowArrows = (response.flow_arrows ?? []) as FlowArrow[]; - arrowSpacingM = response.arrow_spacing_m ?? 0; - const built = buildStrengthArray( - (response.strength_profile ?? []) as ReadonlyArray, - ); - strength = built.strength; - maxStrength = built.maximum; - // 계획도로선·2차 유역 외곽선은 B04 산출물을 그대로 받는다 — 여기서 다시 계산하지 않는다. - // 실제 사용된 배관 목록으로 마커를 동기화한다(유역 없는 관 포함). - pipeEditor.setPipes( - (response.pipes ?? []).map((pipe) => ({ - chainage_m: pipe.chainage_m, - reason: pipe.reason, - })), - ); - selectedBasin = null; - renderBasinList(); - syncPipeSelection(); + const points = + !auto && pipeEditor.pipes().length > 0 + ? pipeEditor.pipes().map((pipe) => ({ + chainage_m: pipe.chainage_m, + source: (pipe.reason || "user") as PipeSource, + })) + : []; + apply(await computeDetailBasins(projectId, points)); status.hidden = basins.length > 0; if (basins.length === 0) status.textContent = L("B05_Drainage_Status_NoBasin"); scheduleDraw(); @@ -509,8 +510,9 @@ export function createDrainagePanel(): DrainagePanel { fitToRoute(); scheduleDraw(); showProgress(2 / 3, L("B05_Drainage_Status_Analyzing")); - // B04 분석 결과를 읽어 오는 것뿐이라 즉시 끝난다 — 페이지에 들어오면 바로 보여 준다. - void analyze(true); + // B04가 확정해 둔 관 지점을 그대로 불러온다 — 저장분이 없으면 백엔드가 자동 배치를 준다. + // 두 화면이 같은 파일을 보므로 B04에서 옮긴 관이 여기서도 같은 자리에 있다. + void loadSaved(); } catch (error) { if (sequence !== loadSequence) return; status.hidden = false; @@ -568,12 +570,49 @@ export function createDrainagePanel(): DrainagePanel { offsetY = dragStart.offsetY + event.clientY - dragStart.y; scheduleDraw(); }); + /** 유역 폴리곤을 눌러 고른다. 유역 밖을 누르면 강조를 푼다. */ + function pickBasinAt(x: number, y: number): void { + if (!normalizer) return; + const view = currentView(); + let hit: number | null = null; + let smallest = Number.POSITIVE_INFINITY; + basins.forEach((basin) => { + if (basin.polygon_lonlat.length < 3) return; + const ring = basin.polygon_lonlat.map(([lon, lat]) => + lonLatToScreen(normalizer as Normalizer, view, lon, lat), + ); + if (!pointInRing(ring, x, y)) return; + // 겹치면 면적이 작은 쪽을 고른다(안쪽 조각 우선). + if (basin.area_m2 < smallest) { + smallest = basin.area_m2; + hit = basin.index; + } + }); + if (hit === null && selectedBasin === null) return; + selectedBasin = hit === selectedBasin ? null : hit; + renderBasinList(); + scheduleDraw(); + } + const stopDragging = (): void => { pipeEditor.handleUp(); dragStart = null; viewport.style.removeProperty("cursor"); }; - viewport.addEventListener("pointerup", stopDragging); + viewport.addEventListener("pointerup", (event) => { + const start = basinClickStart; + basinClickStart = null; + const dragged = pipeClickStart !== null; + pipeClickStart = null; + if (!dragged && start && event.button === 0) { + const rect = viewport.getBoundingClientRect(); + // 끌었으면 지도 조작이지 고르기가 아니다. + if (Math.hypot(event.clientX - start.x, event.clientY - start.y) <= CLICK_SLOP_PX) { + pickBasinAt(event.clientX - rect.left, event.clientY - rect.top); + } + } + stopDragging(); + }); viewport.addEventListener("pointercancel", stopDragging); const resizeObserver = new ResizeObserver(scheduleDraw); @@ -597,6 +636,38 @@ export function createDrainagePanel(): DrainagePanel { projectId = nextProjectId; void loadLayers(); }, + pipeChainages: () => pipeEditor.chainages(), + movePipe(fromChainage, toChainage) { + const index = pipeEditor + .pipes() + .findIndex((pipe) => Math.abs(pipe.chainage_m - fromChainage) < 0.51); + if (index < 0) return; + pipeEditor.moveTo(index, toChainage); + }, + addPipe(chainageM) { + pipeEditor.addAtChainage(chainageM); + }, + removePipe(chainageM) { + const index = pipeEditor + .pipes() + .findIndex((pipe) => Math.abs(pipe.chainage_m - chainageM) < 0.51); + if (index < 0) return; + pipeEditor.select(index); + pipeEditor.deleteSelected(); + }, + async savePipes() { + if (!projectId) return 0; + const response = await saveDetailPipePoints( + projectId, + pipeEditor.pipes().map((pipe) => ({ + chainage_m: pipe.chainage_m, + source: (pipe.reason || "user") as PipeSource, + })), + ); + apply(response); + scheduleDraw(); + return response.pipe_count; + }, setRoute(points) { routePoints = points; routeLayer = meta && points.length > 1 ? prepareMetricPolyline(points, meta) : null; diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Parts.ts b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Parts.ts index c41518b1..0694adfa 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Parts.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Parts.ts @@ -11,8 +11,9 @@ import { themeColor } from "@ui/ui_template_palette"; import { DRAINAGE_SHEET_LAYERS } from "../A00_Common/b_asset_cache"; import type { VWorldMeta } from "../B04_wf1_Surface/B04_wf1_Surface_Api_Fetch"; import type { ViewState } from "../B04_wf1_Surface/B04_wf1_Surface_UI_MapRender"; +import { normalizeStrength, rampColor } from "../B04_wf1_Surface/B04_wf1_Surface_UI_FlowRamp"; import type { MapContextMenu } from "@ui/ui_template_context_menu"; -import type { DrainageBasin } from "./B05_wf2_Route_Api_Fetch"; +import type { DetailBasin } from "../B04_wf1_Surface/B04_wf1_Surface_Api_Fetch"; import type { PipeEditor } from "./B05_wf2_Route_UI_Drainage_Pipes"; function L(key: keyof typeof ui_locales): string { @@ -42,6 +43,7 @@ export const LAYER_LABEL_KEYS: Record = export const arrowToggleColor = (): string => themeColor("--map-flow-arrow", "#7c3aed"); export const upstreamToggleColor = (): string => themeColor("--map-upstream-toggle", "#1d4ed8"); export const strengthToggleColor = (): string => themeColor("--map-flow-ramp-5", "#dc2626"); +export const hotspotToggleColor = (): string => themeColor("--map-flow-ramp-4", "#f97316"); /** 위성사진은 선이 아니라 배경이라 맞출 선 색이 없다 — 중립 회색을 띠 색으로 쓴다. */ export const satelliteToggleColor = (): string => themeColor("--map-satellite-toggle", "#64748b"); @@ -106,7 +108,7 @@ export function addLayerToggle( /** 유역 제원 목록을 다시 그린다. 항목을 누르면 `onPick`으로 번호를 돌려준다. */ export function renderBasinRows( container: HTMLElement, - basins: ReadonlyArray, + basins: ReadonlyArray, selected: number | null, onPick: (index: number) => void, ): void { @@ -199,3 +201,108 @@ export function bindPipeContextMenu( menu.open(x, y, [[L("B05_Drainage_Menu_Add"), () => void editor.addAt(view, x, y)]]); }); } + +/** 유입 집중점 마커 — 계획선 위에서 물이 특히 많이 모이는 자리. 크기·색은 유입면적 로그 스케일. + * B04 지도와 같은 색띠를 쓴다(같은 값을 다르게 보여 주면 안 된다). */ +export function drawHotspots( + context: CanvasRenderingContext2D, + toScreen: (x: number, y: number) => [number, number], + samples: ReadonlyArray<{ x: number; y: number }>, + spots: ReadonlyArray<{ chainage: number; area: number }>, + maximum: number, +): void { + if (spots.length === 0 || samples.length === 0) return; + context.save(); + spots.forEach((spot) => { + const index = Math.min(samples.length - 1, Math.max(0, Math.round(spot.chainage))); + const [x, y] = toScreen(samples[index].x, samples[index].y); + const ratio = normalizeStrength(spot.area, maximum); + const radius = 3 + 5 * ratio; + context.beginPath(); + context.arc(x, y, radius, 0, Math.PI * 2); + context.fillStyle = rampColor(ratio); + context.fill(); + context.lineWidth = 1.2; + context.strokeStyle = themeColor("--map-halo", "rgba(255, 255, 255, 0.9)"); + context.stroke(); + }); + context.restore(); +} + +/** 화면 좌표 폴리곤 안에 점이 있는지(홀짝 규칙). 유역을 눌러 고를 때 쓴다. */ +export function pointInRing(ring: ReadonlyArray<[number, number]>, x: number, y: number): boolean { + let inside = false; + for (let index = 0, previous = ring.length - 1; index < ring.length; previous = index++) { + const [xi, yi] = ring[index]; + const [xj, yj] = ring[previous]; + if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside; + } + return inside; +} + +/** 표시 토글 한 줄을 통째로 만든다 — 위성/도엽/화살표/강도/집중점/상류세류 6종. + * 패널이 700줄 한계에 닿아 옮겼다. 상태는 갖지 않고 켜고 끌 때 넘겨받은 함수만 부른다. */ +export function mountDrainageToggles( + container: HTMLElement, + handlers: { + initial: { arrows: boolean; strength: boolean; hotspots: boolean; upstream: boolean }; + onSatellite: (next: boolean) => void; + onSheetLayer: (layer: DrainageLayer, next: boolean) => void; + onArrows: (next: boolean) => void; + onStrength: (next: boolean) => void; + onHotspots: (next: boolean) => void; + onUpstream: (next: boolean) => void; + }, +): void { + // 배경 위성사진 — 등고선 앞에 둔다(2026-08-01 사용자 지시). 사진이 어두워 유역 채움색이 + // 묻힐 때 끄고 본다. 캔버스가 아니라 배경 이미지라 표시 여부만 직접 바꾼다. + addLayerToggle( + container, + L("B05_Drainage_Layer_Satellite"), + satelliteToggleColor(), + true, + handlers.onSatellite, + L("B05_Drainage_Layer_Satellite_Tip"), + ); + DRAINAGE_LAYERS.forEach((layer) => { + addLayerToggle(container, L(LAYER_LABEL_KEYS[layer]), layerColor(layer), true, (next) => + handlers.onSheetLayer(layer, next), + ); + }); + // 흐름 화살표 — 도면이 지저분해질 때 끄기 위한 토글(등고선·세류와 같은 줄·같은 양식). + addLayerToggle( + container, + L("B05_Drainage_Layer_Arrows"), + arrowToggleColor(), + handlers.initial.arrows, + handlers.onArrows, + L("B05_Drainage_Layer_Arrows_Tip"), + ); + // 유입 강도 색칠 — 노선 1m 구간별 상류 면적. B04 지도와 같은 색띠를 쓴다. + addLayerToggle( + container, + L("B05_Drainage_Layer_Strength"), + strengthToggleColor(), + handlers.initial.strength, + handlers.onStrength, + L("B05_Drainage_Layer_Strength_Tip"), + ); + // 유입 집중점 — 관을 어디에 둘지 판단하는 근거. 관 마커와 겹쳐 읽기 어려우므로 기본 꺼짐. + addLayerToggle( + container, + L("B05_Drainage_Layer_Hotspots"), + hotspotToggleColor(), + handlers.initial.hotspots, + handlers.onHotspots, + L("B05_Drainage_Layer_Hotspots_Tip"), + ); + // 상류 세류선 강조 — 유역 판정의 기준선이라 항상 같은 굵기·색으로 얹는다. + addLayerToggle( + container, + L("B05_Drainage_Layer_Upstream"), + upstreamToggleColor(), + handlers.initial.upstream, + handlers.onUpstream, + L("B05_Drainage_Layer_Upstream_Tip"), + ); +} diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Pipes.ts b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Pipes.ts index 8e67fcc9..0c34257b 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Pipes.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Pipes.ts @@ -32,6 +32,10 @@ export interface PipeEditor { deleteSelected(): boolean; /** 화면 좌표에 있는 마커 번호(없으면 null). 우클릭 메뉴가 무엇을 띄울지 정하는 데 쓴다. */ hitAt(view: ViewState, screenX: number, screenY: number): number | null; + /** 누가거리로 바로 옮긴다(종단 테이블에서 라인을 끌었을 때). */ + moveTo(index: number, chainageM: number): void; + /** 누가거리로 바로 넣는다(종단 테이블 우클릭). */ + addAtChainage(chainageM: number): void; /** 그 자리에 배관을 넣을 수 있는지(계획선에 충분히 가까운지)만 본다. */ canAddAt(view: ViewState, screenX: number, screenY: number): boolean; /** 계획선 위 그 자리에 배관을 넣는다. 노선에서 멀면 false. */ @@ -182,6 +186,23 @@ export function createPipeEditor( } return null; }, + moveTo(index, chainageM) { + const pipe = pipeList[index]; + if (!pipe) return; + pipe.chainage_m = Math.max(0, Math.min(totalChainage, chainageM)); + pipe.reason = "confirmed"; + sortPipes(); + onChange(); + onCommit(); + }, + addAtChainage(chainageM) { + const clamped = Math.max(0, Math.min(totalChainage, chainageM)); + pipeList.push({ chainage_m: clamped, reason: "confirmed" }); + sortPipes(); + selectedIndex = pipeList.findIndex((pipe) => Math.abs(pipe.chainage_m - clamped) < 1e-6); + onChange(); + onCommit(); + }, canAddAt(view, screenX, screenY) { const metric = screenToMetric(view, screenX, screenY); if (!metric) return false; diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Render.ts b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Render.ts new file mode 100644 index 00000000..59b8b9d2 --- /dev/null +++ b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Render.ts @@ -0,0 +1,146 @@ +/* ============================================================================= + * 배수유역도 캔버스 렌더러 (B05) + * + * 한 프레임에 무엇을 어떤 순서로 그릴지만 담는다. 상태는 갖지 않고 패널이 매 프레임 넘긴 + * 묶음(`DrainageScene`)만 본다 — 패널 본체(`_UI_Drainage_Panel.ts`)가 700줄 한계에 닿아 + * 분리했다. + * + * 쌓는 순서(아래 → 위) + * 세부유역 채움 · 전체 유역 외곽선 → 도엽 레이어 → 상류 세류 → 계획선 → + * 유입 강도 색칠 → 평균 흐름 화살표 → 유입 집중점 → 배관 마커 + * ========================================================================== */ + +import { + drawFilledRing, + drawPreparedLayer, + drawRidgeRing, + drawUpstreamLines, + routeLineColor, + ROUTE_LINE_WIDTH, + type Normalizer, + type PreparedLayer, + type ViewState, +} from "../B04_wf1_Surface/B04_wf1_Surface_UI_MapRender"; +import type { DetailBasin, VWorldMeta } from "../B04_wf1_Surface/B04_wf1_Surface_Api_Fetch"; +import { drawFlowArrows, type FlowArrow } from "../B04_wf1_Surface/B04_wf1_Surface_UI_FlowArrows"; +import { drawStrengthLine } from "../B04_wf1_Surface/B04_wf1_Surface_UI_FlowRamp"; +import type { RoutePoint } from "../B04_wf1_Surface/B04_wf1_Surface_UI_RouteSamples"; +import { + basinColor, + createMetricProjector, + drawHotspots, + DRAINAGE_LAYERS, + layerColor, + type DrainageLayer, +} from "./B05_wf2_Route_UI_Drainage_Parts"; +import type { PipeEditor } from "./B05_wf2_Route_UI_Drainage_Pipes"; + +export interface DrainageScene { + meta: VWorldMeta | null; + normalizer: Normalizer | null; + basins: ReadonlyArray; + selectedBasin: number | null; + mainBoundary: ReadonlyArray<[number, number]>; + preparedLayers: ReadonlyMap; + activeLayers: ReadonlySet; + routeLayer: PreparedLayer | null; + upstreamLines: ReadonlyArray>; + showUpstream: boolean; + strengthSamples: ReadonlyArray; + strength: Float64Array; + maxStrength: number; + showStrength: boolean; + flowArrows: ReadonlyArray; + arrowSpacingM: number; + showArrows: boolean; + hotspots: ReadonlyArray<{ chainage: number; area: number }>; + maxHotspotArea: number; + showHotspots: boolean; + pipeEditor: PipeEditor; + pipeColor: (chainage: number, position: number) => string; +} + +export function drawDrainageScene( + context: CanvasRenderingContext2D, + view: ViewState, + scene: DrainageScene, +): void { + const { meta, normalizer } = scene; + // 세부유역 채움을 가장 아래에 깔아 등고선·세류 판독을 가리지 않게 한다. + if (normalizer) { + scene.basins.forEach((basin) => { + const color = basinColor(basin.index); + drawFilledRing( + context, + { ring: basin.polygon_lonlat, label: String(basin.index) }, + normalizer, + view, + // 하나를 고르면 나머지는 옅게 물러난다. + scene.selectedBasin === null || scene.selectedBasin === basin.index + ? color + : color.replace(/0\.45\)$/, "0.18)"), + ); + }); + // 전체 유역 외곽선 = 분수령(능선). 해석 결과를 그대로 그린다 — 손으로 고치지 않는다. + if (scene.mainBoundary.length > 2) { + drawRidgeRing(context, scene.mainBoundary as Array<[number, number]>, normalizer, view); + } + } + // 등고선을 얇게 깔고 세류를 그 위에, 노선을 맨 위에 둔다. + DRAINAGE_LAYERS.forEach((layer) => { + if (!scene.activeLayers.has(layer)) return; + const prepared = scene.preparedLayers.get(layer); + if (!prepared) return; + context.lineWidth = layer === "도엽_등고선" ? 0.7 : 1.5; + context.strokeStyle = layerColor(layer); + drawPreparedLayer(context, prepared, view, "dot"); + }); + // 상류 세류선 강조 — 유역 채움 위, 흐름 화살표 아래. B04와 같은 공용 렌더러. + if (scene.showUpstream && normalizer && scene.upstreamLines.length > 0) { + drawUpstreamLines( + context, + scene.upstreamLines as Array>, + normalizer, + view, + ); + } + if (scene.routeLayer) { + context.lineWidth = ROUTE_LINE_WIDTH; + context.strokeStyle = routeLineColor(); + drawPreparedLayer(context, scene.routeLayer, view, "dot"); + } + if (!meta) { + scene.pipeEditor.draw(context, view, scene.pipeColor); + return; + } + const projector = createMetricProjector(meta, view); + // 유입 강도 색칠 — 계획선 위, 배관 마커 아래. 색띠는 B04 지도와 공용이다. + if (scene.showStrength && scene.strength.length > 0) { + drawStrengthLine(context, scene.strengthSamples, scene.strength, scene.maxStrength, (point) => + projector.toScreen(point.x, point.y), + ); + } + // 평균 흐름 화살표 — 좌표는 사업지 CRS(m)라 도엽 메타로 바로 화면에 옮긴다. + if (scene.showArrows && scene.flowArrows.length > 0) { + drawFlowArrows( + context, + scene.flowArrows as FlowArrow[], + scene.arrowSpacingM, + projector.pxPerMeter, + projector.toScreen, + view, + ); + } + // 유입 집중점 마커 — 강도 색칠 위, 배관 마커 아래. + if (scene.showHotspots && scene.hotspots.length > 0) { + drawHotspots( + context, + projector.toScreen, + scene.strengthSamples, + scene.hotspots, + scene.maxHotspotArea, + ); + } + // 배관(관 매설) 마커 — 계획선 위 최상단. + scene.pipeEditor.draw(context, view, scene.pipeColor); +} diff --git a/B05_wf2_Route/B05_wf2_Route_UI_IrregularStations.ts b/B05_wf2_Route/B05_wf2_Route_UI_IrregularStations.ts index d756732b..e75bf415 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_IrregularStations.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_IrregularStations.ts @@ -20,8 +20,14 @@ export interface IrregularStation { chainage_m: number; /** 구조물 설명 (당분간 자유 텍스트). */ structure: string; + /** 어디서 온 항목인가. `pipe`는 배수유역도의 관 매설 지점이 투영된 것이라 손으로 + * 지우거나 옮겨도 정본(`pipe_points.json`)을 거쳐야 한다(2026-08-01 사용자 지시). */ + origin?: "user" | "pipe"; } +/** 관 매설 지점이 구조물 목록에 실체화될 때 쓰는 이름. 향후 드롭다운으로 바꾼다. */ +export const PIPE_STRUCTURE_NAME = "배관"; + export interface IrregularStationsSection { root: HTMLElement; getStations: () => IrregularStation[]; @@ -29,6 +35,12 @@ export interface IrregularStationsSection { selectByChainage: (chainageM: number | null) => void; /** 외부(백엔드 복귀)에서 목록을 통째로 채운다(측점번호·잔여거리는 chainage로 역산). */ setStations: (seed: Array<{ chainage_m: number; structure: string }>) => void; + /** 배수유역도의 관 목록을 "배관" 구조물로 갈아 끼운다. 사용자가 손으로 넣은 항목은 건드리지 않는다. */ + setPipeStations: (chainages: ReadonlyArray) => void; + /** 누가거리로 항목을 찾아 지운다(종단 테이블 우클릭). 지웠으면 그 항목을 돌려준다. */ + removeByChainage: (chainageM: number) => IrregularStation | null; + /** 누가거리로 항목을 옮긴다(종단 테이블 라인 드래그). 옮겼으면 true. */ + moveByChainage: (fromChainageM: number, toChainageM: number) => boolean; clear: () => void; } @@ -126,6 +138,16 @@ export function createIrregularStationsSection( return station * (interval > 0 ? interval : 20) + remainder; } + /** 누가거리를 측점번호+잔여거리로 되돌린다(외부 주입·이동 공용). */ + function splitChainage(chainageM: number): { station: number; remainder: number } { + const interval = intervalMax(); + const station = Math.floor((chainageM + 1e-6) / interval); + return { + station, + remainder: Number((chainageM - station * interval).toFixed(3)), + }; + } + function syncButtons(): void { primary.textContent = editingId ? "수정" : "추가"; remove.disabled = editingId === null; @@ -191,6 +213,7 @@ export function createIrregularStationsSection( const index = stations.findIndex((entry) => entry.id === editingId); if (index >= 0) { stations[index] = { + ...stations[index], id: editingId, station, remainder: safeRemainder, @@ -205,6 +228,7 @@ export function createIrregularStationsSection( remainder: safeRemainder, chainage_m, structure, + origin: "user", }); } loadForm(null); @@ -246,23 +270,68 @@ export function createIrregularStationsSection( loadForm(target ?? null); }, setStations(seed) { - const interval = intervalMax(); stations.length = 0; seed.forEach((entry) => { - const stationNo = Math.floor((entry.chainage_m + 1e-6) / interval); - const remainder = Number((entry.chainage_m - stationNo * interval).toFixed(3)); + const { station, remainder } = splitChainage(entry.chainage_m); stations.push({ id: String(nextId++), - station: stationNo, + station, remainder, chainage_m: entry.chainage_m, structure: entry.structure, + origin: "user", }); }); loadForm(null); renderList(); callbacks.onChange([...stations]); }, + setPipeStations(chainages) { + // 배관 항목은 통째로 갈아 끼운다 — 정본은 배수유역도의 관 목록이다. + for (let index = stations.length - 1; index >= 0; index -= 1) { + if (stations[index].origin === "pipe") stations.splice(index, 1); + } + chainages.forEach((chainage) => { + const { station, remainder } = splitChainage(chainage); + stations.push({ + id: String(nextId++), + station, + remainder, + chainage_m: chainage, + structure: PIPE_STRUCTURE_NAME, + origin: "pipe", + }); + }); + if (editingId && !stations.some((entry) => entry.id === editingId)) loadForm(null); + renderList(); + callbacks.onChange([...stations]); + }, + removeByChainage(chainageM) { + const index = stations.findIndex((entry) => Math.abs(entry.chainage_m - chainageM) < 0.51); + if (index < 0) return null; + const [removed] = stations.splice(index, 1); + if (editingId === removed.id) loadForm(null); + renderList(); + callbacks.onChange([...stations]); + return removed; + }, + moveByChainage(fromChainageM, toChainageM) { + const index = stations.findIndex( + (entry) => Math.abs(entry.chainage_m - fromChainageM) < 0.51, + ); + if (index < 0) return false; + const { station, remainder } = splitChainage(toChainageM); + // 그 자리에서 고치지 않고 새 객체로 교체한다 — Page가 옛 위치의 계획고 편집을 정리해야 한다. + stations[index] = { + ...stations[index], + station, + remainder, + chainage_m: toChainageM, + }; + renderList(); + callbacks.onChange([...stations]); + return true; + }, clear() { stations.length = 0; loadForm(null); diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Page.ts b/B05_wf2_Route/B05_wf2_Route_UI_Page.ts index cc91571f..a4ee582a 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Page.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Page.ts @@ -18,7 +18,8 @@ import { import { confirmRoute, fetchLatestRoute, - routeLatestCacheKey, + readRouteLatestCache, + writeRouteLatestCache, solveRoute, updateContourInterval, type CirclePoint, @@ -187,6 +188,28 @@ export async function renderB05Route(root: HTMLElement): Promise { }, // [초기선 복원] 시 추가한 비정규 측점도 함께 지운다. () => panel.irregularStations.clear(), + { + // 관 매설 목록 ↔ 구조물 목록의 "배관" 항목을 한 방향으로 맞춘다. + // 정본은 배수유역도의 관 지점이며, 구조물 목록은 그것을 실체화한 것이다. + onPipesChanged: (chainages) => panel.irregularStations.setPipeStations(chainages), + onStructureMove: (from, to, station) => { + if (station.origin === "pipe") { + // 배관은 관 지점 정본을 거쳐야 세부유역까지 함께 다시 나뉜다. + profilePanel.drainage.movePipe(from, to); + return; + } + panel.irregularStations.moveByChainage(from, to); + }, + onStructureRemove: (station) => { + if (station.origin === "pipe") { + profilePanel.drainage.removePipe(station.chainage_m); + return; + } + panel.irregularStations.removeByChainage(station.chainage_m); + }, + onPipeAdd: (chainage) => profilePanel.drainage.addPipe(chainage), + onIrregularSelect: (station) => syncIrregularSelection(irregularStationId(station.id)), + }, ); /** @@ -251,29 +274,9 @@ export async function renderB05Route(root: HTMLElement): Promise { * 확정 이력이 있으면 매 진입마다 DB(latest) 조회 대신 브라우저 세션 캐시를 * 우선 사용해 응답속도를 높인다. 캐시 미스면 latest를 조회해 적재하고, * solve·확정 성공 시 신선한 값으로 갱신한다(세션 = 탭 단위, 탭 종료 시 소멸). */ - const latestCacheKey = routeLatestCacheKey(activeProjectId); - - function readLatestCache(): RouteLatestResponse | null { - try { - const raw = window.sessionStorage.getItem(latestCacheKey); - return raw ? (JSON.parse(raw) as RouteLatestResponse) : null; - } catch { - return null; - } - } - - function writeLatestCache(value: RouteLatestResponse): void { - try { - window.sessionStorage.setItem(latestCacheKey, JSON.stringify(value)); - } catch { - // 용량 초과 등 저장 실패 시 캐시를 비워 다음 진입은 DB 조회로 폴백한다. - try { - window.sessionStorage.removeItem(latestCacheKey); - } catch { - /* noop */ - } - } - } + const readLatestCache = (): RouteLatestResponse | null => readRouteLatestCache(activeProjectId); + const writeLatestCache = (value: RouteLatestResponse): void => + writeRouteLatestCache(activeProjectId, value); /** 캐시 우선 latest 로드. forceFresh=true(solve/확정 직후)는 항상 DB를 읽고 캐시를 갱신한다. */ async function loadLatest(forceFresh = false): Promise { @@ -555,6 +558,8 @@ export async function renderB05Route(root: HTMLElement): Promise { async function confirm(): Promise { if (!routeReady || stale) return; + // 관 매설 지점을 B04와 같은 저장소에 남긴다 — 저장 실패가 경로 확정을 막지는 않는다. + await profilePanel.drainage.savePipes().catch(() => 0); showLoadingOverlay(); try { // 종단 계획선 편집은 화면에서만 계산해 두었으므로 확정 직전에 영속화한다. diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Data.ts b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Data.ts new file mode 100644 index 00000000..375e39de --- /dev/null +++ b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Data.ts @@ -0,0 +1,78 @@ +/* ============================================================================= + * 종단면 패널 자료 어댑터 (B05) + * + * 저장된 종단면 자료(`longitudinal_sections.data`)를 화면이 쓰는 모양으로 바꾸는 순수 함수들. + * 상태를 갖지 않으며 DOM도 만들지 않는다 — 패널 본체가 700줄 한계에 닿아 분리했다. + * ========================================================================== */ + +import type { + DesignProfile, + LongitudinalSection, +} from "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch"; +import type { ProfileAlignment } from "./B05_wf2_Route_UI_Profile_Alignment"; + +export function readAlignment(data: LongitudinalSection): ProfileAlignment | null { + const candidate = data.profile_alignment as ProfileAlignment | undefined; + if (!candidate?.base_pvi?.length || !candidate.samples?.length) return null; + if (!Number.isFinite(candidate.policy?.default_curve_radius_m)) return null; + if (!candidate.stations || !candidate.segments || !candidate.curves) return null; + candidate.edits = { + station_offsets: candidate.edits?.station_offsets ?? {}, + curve_radii: candidate.edits?.curve_radii ?? {}, + }; + return candidate; +} + +/** 저장분이 구버전이라 편집을 붙일 수 없는 상태인가 (재계산 안내용). */ +export function hasLegacyAlignment(data: LongitudinalSection): boolean { + return Boolean(data.profile_alignment) && readAlignment(data) === null; +} + +/** 편집 결과를 종단면도 렌더러가 받는 계획선 형태로 감싼다. */ +export function toDesignProfile( + alignment: ProfileAlignment, + original: DesignProfile | undefined, +): DesignProfile { + const balance = alignment.balance; + return { + id: original?.id ?? "design_grade_line", + name: original?.name ?? "계획선", + basis: original?.basis ?? "station_alignment", + samples: alignment.samples, + balance_segments: [ + { + index: 0, + start_chainage_m: alignment.samples[0]?.chainage_m ?? 0, + end_chainage_m: alignment.samples[alignment.samples.length - 1]?.chainage_m ?? 0, + cut_area_m2: balance.cut_area_m2, + fill_area_m2: balance.fill_area_m2, + balance_error_m2: balance.net_area_m2, + }, + ], + summary: { + ...(original?.summary ?? { + max_grade_pct: 0, + vertical_curve_count: 0, + pvi_count: 0, + balance_segment_count: 1, + main_direction: "none", + suggested_elevation_offset_m: null, + warnings: [], + }), + cut_area_m2: balance.cut_area_m2, + fill_area_m2: balance.fill_area_m2, + balance_error_m2: balance.net_area_m2, + balanced: balance.within_tolerance, + }, + }; +} + +export function normalizedLongitudinal(data: LongitudinalSection): LongitudinalSection { + return { + ...data, + samples: data.samples.map((sample: LongitudinalSection["samples"][number]) => ({ + ...sample, + elevation_m: sample.elevation_m ?? sample.z ?? null, + })), + }; +} diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts index 243f0602..dc62e210 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts @@ -11,7 +11,6 @@ * ========================================================================== */ import type { - DesignProfile, LongitudinalSection, SectionDetailResponse, } from "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch"; @@ -23,6 +22,12 @@ import { LONG_PAD } from "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Sectio import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; import { createPanelResizer } from "@ui/ui_template_resizer"; import { createDrainagePanel } from "./B05_wf2_Route_UI_Drainage_Panel"; +import { + hasLegacyAlignment, + normalizedLongitudinal, + readAlignment, + toDesignProfile, +} from "./B05_wf2_Route_UI_Profile_Data"; import { createProgressCircle } from "@ui/ui_template_progress"; import { showToast } from "@ui/ui_template_elements"; import { saveProfileAlignment } from "./B05_wf2_Route_Api_Fetch"; @@ -80,72 +85,6 @@ const TABLE_ROW_COUNT = 12; * `default_curve_radius_m`가 없어 그대로 쓰면 계산 도중 터진다. 그런 데이터는 * 편집 기능을 끄고(지반선·계획선 차트만 표시) 재계산을 안내하는 편이 안전하다. */ -function readAlignment(data: LongitudinalSection): ProfileAlignment | null { - const candidate = data.profile_alignment as ProfileAlignment | undefined; - if (!candidate?.base_pvi?.length || !candidate.samples?.length) return null; - if (!Number.isFinite(candidate.policy?.default_curve_radius_m)) return null; - if (!candidate.stations || !candidate.segments || !candidate.curves) return null; - candidate.edits = { - station_offsets: candidate.edits?.station_offsets ?? {}, - curve_radii: candidate.edits?.curve_radii ?? {}, - }; - return candidate; -} - -/** 저장분이 구버전이라 편집을 붙일 수 없는 상태인가 (재계산 안내용). */ -function hasLegacyAlignment(data: LongitudinalSection): boolean { - return Boolean(data.profile_alignment) && readAlignment(data) === null; -} - -/** 편집 결과를 종단면도 렌더러가 받는 계획선 형태로 감싼다. */ -function toDesignProfile( - alignment: ProfileAlignment, - original: DesignProfile | undefined, -): DesignProfile { - const balance = alignment.balance; - return { - id: original?.id ?? "design_grade_line", - name: original?.name ?? "계획선", - basis: original?.basis ?? "station_alignment", - samples: alignment.samples, - balance_segments: [ - { - index: 0, - start_chainage_m: alignment.samples[0]?.chainage_m ?? 0, - end_chainage_m: alignment.samples[alignment.samples.length - 1]?.chainage_m ?? 0, - cut_area_m2: balance.cut_area_m2, - fill_area_m2: balance.fill_area_m2, - balance_error_m2: balance.net_area_m2, - }, - ], - summary: { - ...(original?.summary ?? { - max_grade_pct: 0, - vertical_curve_count: 0, - pvi_count: 0, - balance_segment_count: 1, - main_direction: "none", - suggested_elevation_offset_m: null, - warnings: [], - }), - cut_area_m2: balance.cut_area_m2, - fill_area_m2: balance.fill_area_m2, - balance_error_m2: balance.net_area_m2, - balanced: balance.within_tolerance, - }, - }; -} - -function normalizedLongitudinal(data: LongitudinalSection): LongitudinalSection { - return { - ...data, - samples: data.samples.map((sample) => ({ - ...sample, - elevation_m: sample.elevation_m ?? sample.z ?? null, - })), - }; -} - /** 측점 사이 여백 — 이웃 셀끼리 붙어 보이지 않게 띄운다. */ const CELL_GAP_PX = 2; @@ -230,6 +169,18 @@ function chainageMapper( return (chainage: number) => LONG_PAD.left + originOffset + (chainage / maxChainage) * plotWidth; } +/** `chainageMapper`의 역변환 — 구조물 라인을 끌 때 화면 x를 누가거리로 되돌린다. */ +function chainageInverter( + data: LongitudinalSection, + width: number, + originOffset: number, +): (px: number) => number { + const maxChainage = maxChainageOf(data); + const plotWidth = width - LONG_PAD.left - LONG_PAD.right - 2 * originOffset; + return (px: number) => + plotWidth > 0 ? ((px - LONG_PAD.left - originOffset) / plotWidth) * maxChainage : 0; +} + interface ProfileLayout { /** 캔버스 폭(px). 화면이 넓으면 폭맞춤으로, 좁으면 최소 폭으로. */ width: number; @@ -264,11 +215,26 @@ function computeProfileLayout( return { width, originOffset: spacing / 2, cellWidth: Math.max(1, spacing - CELL_GAP_PX) }; } +/** 종단 테이블 구조물 라인·배수유역도가 Page로 올려 보내는 알림. */ +export interface RouteProfilePanelCallbacks { + /** 관 매설 목록이 바뀜 — 구조물 목록의 "배관" 항목을 이 누가거리로 맞춘다. */ + onPipesChanged?: (chainages: number[]) => void; + /** 테이블에서 구조물 라인을 끌어 옮김. */ + onStructureMove?: (fromChainageM: number, toChainageM: number, station: IrregularStation) => void; + /** 테이블 우클릭으로 구조물(배관 포함)을 지움. */ + onStructureRemove?: (station: IrregularStation) => void; + /** 테이블 빈 자리 우클릭으로 배관을 넣음. */ + onPipeAdd?: (chainageM: number) => void; + /** 구조물 라인을 눌러 고름. */ + onIrregularSelect?: (station: IrregularStation) => void; +} + export function createRouteProfilePanel( projectId: string, onSelectStation: (stationId: string) => void, /** [초기선 복원] 클릭 시 함께 실행(비정규 측점 등 다른 조작값도 초기화하려고 Page가 넘긴다). */ onResetAll?: () => void, + callbacks?: RouteProfilePanelCallbacks, ) { const root = document.createElement("section"); root.className = "b05-route-profile"; @@ -292,7 +258,10 @@ export function createRouteProfilePanel( bodyWrap.append(body, progress.root); const content = document.createElement("div"); content.className = "b05-route-profile__content"; - const drainagePanel = createDrainagePanel(); + // 관 목록이 바뀌면 종단 테이블의 "배관" 구조물 라인도 같이 맞춘다(정본은 관 지점 파일). + const drainagePanel = createDrainagePanel({ + onPipesChanged: (chainages) => callbacks?.onPipesChanged?.(chainages), + }); content.append(bodyWrap, drainagePanel.root); // 위쪽 경계를 끌어 패널 높이를 조절한다. 늘어난 만큼은 그래프만 먹고 도면 테이블은 // 처음 잡힌 높이를 지킨다(사용자 지시) — 테이블 행이 늘었다 줄었다 하면 읽기 어려워서다. @@ -488,6 +457,15 @@ export function createRouteProfilePanel( // 값 열 계획고 직접 입력 → 규칙 측점과 동일한 station_offset 파이프라인. onAdjustStation: (chainage, delta) => base && applyEdits(adjustStation(base, store.edits(), chainage, delta)), + // 구조물 배치 라인 — 끌어서 옮기고 우클릭으로 배관을 넣거나 지운다. + structureLines: { + chainageAt: chainageInverter(longitudinal, width, layout.originOffset), + maxChainageM: maxChainageOf(longitudinal), + onMove: (from, to, station) => callbacks?.onStructureMove?.(from, to, station), + onRemove: (station) => callbacks?.onStructureRemove?.(station), + onAddPipe: (chainage) => callbacks?.onPipeAdd?.(chainage), + onSelect: (station) => callbacks?.onIrregularSelect?.(station), + }, }) : null; diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Structures.ts b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Structures.ts new file mode 100644 index 00000000..e0336a28 --- /dev/null +++ b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Structures.ts @@ -0,0 +1,128 @@ +/* ============================================================================= + * 종단 테이블 구조물 배치 라인 (B05) + * + * 구조물 측점을 테이블 위에 세로선으로 얹고, 그 선을 끌어 위치를 옮긴다. 우클릭하면 그 자리에 + * 배관을 넣거나 선을 지운다. + * + * 배관 선은 배수유역도의 관 매설 지점이 투영된 것이다(`origin: "pipe"`). 그래서 여기서 옮기면 + * 관 지점 정본(`pipe_points.json`)이 바뀌고 세부유역도 함께 다시 나뉜다 — 값을 두 곳에 따로 + * 쌓지 않기 위해서다(2026-08-01 사용자 지시). + * ========================================================================== */ + +import { createMapContextMenu } from "@ui/ui_template_context_menu"; +import { irregularLabel, type IrregularStation } from "./B05_wf2_Route_UI_IrregularStations"; + +/** 선을 잡았다고 볼 좌우 여유(px). 선 자체는 얇아 그대로는 집기 어렵다. */ +const GRAB_SLACK_PX = 6; +/** 이만큼(px) 이하로 움직였다 뗐으면 이동이 아니라 고르기로 본다. */ +const DRAG_SLOP_PX = 3; + +export interface StructureLineOptions { + /** 테이블에 얹을 구조물 측점 목록. */ + stations: ReadonlyArray; + /** chainage → x(px). 테이블·그래프와 같은 매핑을 쓴다. */ + x: (chainageM: number) => number; + /** x(px) → chainage. 선을 끌 때 역변환에 쓴다. */ + chainageAt: (px: number) => number; + /** 노선 총 연장(m). 이 범위를 벗어난 자리로는 옮기지 않는다. */ + maxChainageM: number; + /** 선을 옮겼을 때. 옛 누가거리와 새 누가거리를 넘긴다. */ + onMove: (fromChainageM: number, toChainageM: number, station: IrregularStation) => void; + /** 선을 지울 때. */ + onRemove: (station: IrregularStation) => void; + /** 빈 자리에서 우클릭해 배관을 넣을 때. */ + onAddPipe: (chainageM: number) => void; + /** 선을 눌렀을 때(끌지 않음) — 값 열 오버레이 선택과 맞춘다. */ + onSelect?: (station: IrregularStation) => void; +} + +/** + * 테이블 요소 위에 구조물 라인 레이어를 얹는다. 테이블은 매 그리기마다 새로 만들어지므로 + * 이 함수도 그때마다 다시 부른다 — 상태를 밖에 남기지 않는다. + */ +export function mountStructureLines(table: HTMLElement, options: StructureLineOptions): void { + const layer = document.createElement("div"); + layer.className = "b05-profile-table__structures"; + const menu = createMapContextMenu("b05-profile-table"); + + /** 끌고 있는 선. null이면 잡은 것이 없다. */ + let dragging: { station: IrregularStation; element: HTMLElement; startX: number } | null = null; + let moved = false; + + function clamp(chainageM: number): number { + return Math.min(Math.max(chainageM, 0), options.maxChainageM); + } + + options.stations.forEach((station) => { + const line = document.createElement("div"); + line.className = + "b05-profile-table__structure" + (station.origin === "pipe" ? " is-pipe" : " is-user"); + line.style.left = `${options.x(station.chainage_m)}px`; + line.title = `${irregularLabel(station)} · ${station.structure || "구조물"}`; + const label = document.createElement("span"); + label.className = "b05-profile-table__structure-label"; + label.textContent = station.structure || "구조물"; + line.append(label); + + line.addEventListener("pointerdown", (event) => { + // 이동은 좌클릭 전용 — 우클릭은 메뉴다(2026-08-01 사용자 지시). + if (event.button !== 0) return; + event.preventDefault(); + event.stopPropagation(); + menu.close(); + dragging = { station, element: line, startX: event.clientX }; + moved = false; + line.setPointerCapture(event.pointerId); + }); + line.addEventListener("pointermove", (event) => { + if (!dragging || dragging.station.id !== station.id) return; + if (Math.abs(event.clientX - dragging.startX) > DRAG_SLOP_PX) moved = true; + const rect = table.getBoundingClientRect(); + line.style.left = `${options.x(clamp(options.chainageAt(event.clientX - rect.left)))}px`; + }); + line.addEventListener("pointerup", (event) => { + if (!dragging || dragging.station.id !== station.id) return; + const rect = table.getBoundingClientRect(); + const next = clamp(options.chainageAt(event.clientX - rect.left)); + dragging = null; + if (!moved) { + // 끌지 않고 눌렀다 뗐으면 고르기다 — 원래 자리로 되돌린다. + line.style.left = `${options.x(station.chainage_m)}px`; + options.onSelect?.(station); + return; + } + options.onMove(station.chainage_m, Number(next.toFixed(2)), station); + }); + line.addEventListener("contextmenu", (event) => { + event.preventDefault(); + event.stopPropagation(); + const rect = table.getBoundingClientRect(); + menu.open(event.clientX - rect.left, event.clientY - rect.top, [ + [station.origin === "pipe" ? "배관 삭제" : "구조물 삭제", () => options.onRemove(station)], + ]); + }); + layer.append(line); + }); + + // 빈 자리 우클릭 — 그 누가거리에 배관을 넣는다. + table.addEventListener("contextmenu", (event) => { + if (menu.contains(event.target)) { + event.preventDefault(); + return; + } + const rect = table.getBoundingClientRect(); + const chainage = clamp(options.chainageAt(event.clientX - rect.left)); + event.preventDefault(); + menu.open(event.clientX - rect.left, event.clientY - rect.top, [ + ["배관 추가", () => options.onAddPipe(Number(chainage.toFixed(2)))], + ]); + }); + table.addEventListener("pointerdown", (event) => { + if (!menu.contains(event.target)) menu.close(); + }); + + table.append(layer, menu.element); +} + +/** 선을 잡았다고 볼 여유 — 스타일에서 선 폭을 정할 때 함께 쓴다. */ +export const STRUCTURE_GRAB_SLACK_PX = GRAB_SLACK_PX; diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Table.ts b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Table.ts index adbdfad8..cec12c87 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Table.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Table.ts @@ -19,6 +19,10 @@ import type { } from "./B05_wf2_Route_UI_Profile_Alignment"; import { stationLabel } from "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_Common"; import { irregularStationId, type IrregularStation } from "./B05_wf2_Route_UI_IrregularStations"; +import { + mountStructureLines, + type StructureLineOptions, +} from "./B05_wf2_Route_UI_Profile_Structures"; export interface ProfileTableOptions { alignment: ProfileAlignment; @@ -42,6 +46,8 @@ export interface ProfileTableOptions { onCurveRadiusChange: (curve: AlignmentCurve, radiusM: number | null) => void; /** 임의 chainage의 계획고를 delta만큼 조정(비정규 측점 값 열 직접 입력용). */ onAdjustStation?: (chainageM: number, deltaM: number) => void; + /** 구조물 배치 라인(끌어 이동·우클릭 추가/삭제). 없으면 라인을 얹지 않는다. */ + structureLines?: Omit; } interface StationRowSpec { @@ -535,6 +541,14 @@ export function createProfileTable(options: ProfileTableOptions): HTMLElement { ), ); } + // 구조물 배치 라인 — 값 열 오버레이 위에 얹어 언제든 잡을 수 있게 한다. + if (options.structureLines) { + mountStructureLines(table, { + ...options.structureLines, + stations: options.irregularStations ?? [], + x, + }); + } return table; } diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Style.css b/B05_wf2_Route/B05_wf2_Route_UI_Style.css index bf4b73cc..8d3fa43a 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Style.css +++ b/B05_wf2_Route/B05_wf2_Route_UI_Style.css @@ -685,6 +685,87 @@ /* ─── 선택된 비정규 측점 값 열 오버레이 (규칙 열과 같은 12행) ────────────── 세로 점선·구조물 태그 없이, 선택 시에만 값 열을 테이블 위에 겹쳐 보여준다. */ +/* ── 구조물 배치 라인 (끌어 이동 · 우클릭 추가/삭제) ─────────────────────── */ + +.b05-profile-table__structures { + position: absolute; + z-index: 5; + inset: 0; + /* 레이어 자체는 클릭을 흘려보내고, 선만 받는다 — 값 셀 조작을 막지 않는다. */ + pointer-events: none; +} + +.b05-profile-table__structure { + position: absolute; + top: 0; + bottom: 0; + width: 13px; + /* 선은 얇게 보이되 집는 폭은 넉넉히 — 가운데 2px만 색을 칠한다. */ + margin-left: -6px; + background: linear-gradient( + to right, + transparent 5px, + var(--b05-structure-color) 5px, + var(--b05-structure-color) 8px, + transparent 8px + ); + cursor: ew-resize; + pointer-events: auto; + touch-action: none; +} + +.b05-profile-table__structure.is-pipe { + --b05-structure-color: var(--map-pipe-user, rgb(22 163 74)); +} + +.b05-profile-table__structure.is-user { + --b05-structure-color: var(--color-royal-amethyst, rgb(109 40 217)); +} + +.b05-profile-table__structure-label { + position: absolute; + top: 2px; + left: 10px; + padding: 0 3px; + border-radius: 2px; + background: var(--color-surface-raised); + color: var(--b05-structure-color); + font-size: 10px; + white-space: nowrap; +} + +.b05-profile-table__context-menu { + position: absolute; + z-index: 7; + display: flex; + flex-direction: column; + min-width: 120px; + 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%); +} + +.b05-profile-table__context-menu[hidden] { + display: none; +} + +.b05-profile-table__context-menu-item { + padding: var(--spacing-4) var(--spacing-8); + border: 0; + border-radius: var(--radius-cards); + background: transparent; + color: var(--color-text-body); + font-size: 12px; + text-align: left; + cursor: pointer; +} + +.b05-profile-table__context-menu-item:hover { + background: var(--color-surface-sunken); +} + .b05-profile-table__irregular-col { position: absolute; /* 값 셀(0)·곡선(2) 위, sticky 행 이름표(5) **아래**로 둔다 — 스크롤로 값 열이 이름표까지 와도 @@ -1030,9 +1111,13 @@ } /* 유역 제원 목록 — 면적·유역표고·유하거리·관경(수식 확정 전까지 "미정"). */ +/* 유역 목록은 3행까지만 보이고 나머지는 스크롤한다(2026-08-01 사용자 지시). + 높이를 비율로 잡으면 유역 수와 무관하게 잘려 몇 개인지 가늠이 안 된다. */ .b05-drainage__basins { + --b05-basin-row: 34px; + display: flex; - max-height: 34%; + max-height: calc(3 * var(--b05-basin-row) + 2 * 2px + 2 * var(--spacing-8)); flex: 0 0 auto; flex-direction: column; gap: 2px; @@ -1041,8 +1126,22 @@ border-top: 1px solid var(--color-border); } +/* 관 개수·세부유역 수·종단 Z 출처 한 줄. */ +.b05-drainage__summary { + flex: 0 0 auto; + padding: var(--spacing-4) var(--spacing-8); + border-top: 1px solid var(--color-border); + color: var(--color-text-muted, var(--color-text-body)); + font-size: 12px; +} + +.b05-drainage__summary[hidden] { + display: none; +} + .b05-drainage__basin { display: flex; + min-height: var(--b05-basin-row); align-items: center; gap: var(--spacing-8); padding: var(--spacing-4) var(--spacing-8); diff --git a/common_util/common_util_drainage_detail.py b/common_util/common_util_drainage_detail.py index 1f927e92..d02dc870 100644 --- a/common_util/common_util_drainage_detail.py +++ b/common_util/common_util_drainage_detail.py @@ -31,6 +31,7 @@ from typing import Any import numpy as np +from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Analyze import find_inflow_hotspots 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 @@ -71,6 +72,8 @@ class DrainageDetail: upstream_lonlat: list[list[list[float]]] = field(default_factory=list) # 도로 1m 구간별 유입 면적(㎡) — [누가거리, 면적]. 계획선을 색으로 칠하는 데 쓴다. strength_profile: list[list[float]] = field(default_factory=list) + # 유입 집중점 — [누가거리, 유입면적, 구역번호, 구역 내 순위]. 관 자리를 판단하는 근거. + inflow_hotspots: list[list[float]] = field(default_factory=list) @dataclass @@ -157,6 +160,14 @@ def build_detail( arrow_spacing_m=routing.arrow_spacing_m, upstream_lonlat=read_upstream_lines(directory), strength_profile=build_strength_profile(routing), + inflow_hotspots=[ + [chainage, area, float(zone), float(rank)] + for chainage, area, zone, rank in find_inflow_hotspots( + routing.strength_curve, + [pipe.chainage_m for pipe in routing.base_pipes], + vertices[-1].chainage_m, + ) + ], ) if not pipes: return detail diff --git a/config/config_system.py b/config/config_system.py index 9468f0ba..45fed56f 100644 --- a/config/config_system.py +++ b/config/config_system.py @@ -301,11 +301,6 @@ DRAINAGE_DETAIL_FILENAME = "04_detailed_basins.geojson" DRAINAGE_EDITS_DIRNAME = "edits" DRAINAGE_PIPE_POINTS_FILENAME = "pipe_points.json" -# ── B05 전용 배수유역 사본 ── -# B04 산출물을 그대로 쓰면 B05 쪽 작업이 원본을 덮어쓴다. 프로젝트 저장소의 -# B05_wf2_Route/drainage/ 아래로 복사해 두고 B05는 사본만 읽는다. -DRAINAGE_B05_DIRNAME = "drainage" - # ── B05용 평균 흐름 화살표 ── # 셀 화살표는 1m라 축소하면 경향이 안 보인다. 이 크기의 블록으로 묶어 방향을 평균한다. DRAINAGE_ARROW_BLOCK_M = float(os.getenv("DRAINAGE_ARROW_BLOCK_M", "10.0")) diff --git a/main.py b/main.py index 3a2007e7..59c58bd9 100644 --- a/main.py +++ b/main.py @@ -36,7 +36,6 @@ from B04_wf1_Surface.B04_wf1_Surface_Router_GIS import tiles_router from B04_wf1_Surface.B04_wf1_Surface_Router_Inflow import router as b04_inflow_router from B04_wf1_Surface.B04_wf1_Surface_Router_Watershed import router as b04_watershed_router from B05_wf2_Route.B05_wf2_Route_Router import router as b05_route_router -from B05_wf2_Route.B05_wf2_Route_Router_Drainage import router as b05_drainage_router from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Router import router as b06_section_router from B07_wf4_DesignDetail.B07_wf4_DesignDetail_Router import router as b07_design_router from common_util.common_util_auth import require_company, verify_session @@ -354,7 +353,6 @@ 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) app.include_router(b06_section_router, dependencies=protected_with_company) app.include_router(b07_design_router, dependencies=protected_with_company) diff --git a/ui_template/ui_template_locale.ts b/ui_template/ui_template_locale.ts index fb4d7aff..ea135494 100644 --- a/ui_template/ui_template_locale.ts +++ b/ui_template/ui_template_locale.ts @@ -831,6 +831,16 @@ export const ui_locales = { ], B05_Drainage_Menu_Add: ["배관 추가", "Add culvert"], B05_Drainage_Menu_Delete: ["배관 삭제", "Remove culvert"], + B05_Drainage_Layer_Hotspots: ["집중유역", "Inflow hotspots"], + B05_Drainage_Layer_Hotspots_Tip: [ + "노선 위에서 물이 특히 많이 모이는 자리를 마커로 표시합니다. 관을 어디에 둘지 판단하는 근거입니다.", + "Marks the spots along the route that collect the most water — the basis for placing culverts.", + ], + /* {pipes}=관 개수, {basins}=세부유역 수, {source}=종단 Z 출처 */ + B05_Drainage_Summary: [ + "관 {pipes}개 · 세부유역 {basins}개 · 종단 Z {source}", + "{pipes} culverts · {basins} sub-basins · profile Z {source}", + ], B05_Drainage_Layer_Strength: ["흐름 강도", "Flow strength"], B05_Drainage_Layer_Strength_Tip: [ "노선 1m 구간마다 그 자리로 모이는 상류 면적을 색으로 칠합니다(B04 지도와 같은 색띠).",