diff --git a/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts b/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts index 5e4e11b4..77da381e 100644 --- a/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts +++ b/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts @@ -315,14 +315,8 @@ export interface DrainageBasinResponse { pipes: DrainageCandidate[]; /** B04가 분석에 쓴 계획 노선 선형(lon/lat). */ route_lonlat: Array<[number, number]>; - /** 2차 전체 배수유역 외곽선 = 분수령. 편집 핸들 간격으로 다시 찍고 저장된 편집분이 반영된 값. */ + /** 2차 전체 배수유역 외곽선 = 분수령. 해석 결과 그대로이며 손으로 고치지 않는다. */ main_polygon_lonlat: Array<[number, number]>; - /** 외곽선 편집 핸들 간격(m). */ - boundary_spacing_m: number; - /** 저장돼 있던 외곽선 편집 포인트(원래 자리 base, 옮긴 자리 moved). */ - boundary_overrides: Array<{ base: [number, number]; moved: [number, number] }>; - /** 새 유역 안쪽으로 들어가 버려진 편집 포인트 수. */ - boundary_dropped: number; /** 유역 안쪽 상류 세류망 — 하이라이트 토글용. */ upstream_lonlat: Array>; /** 도로 1m 구간별 유입 면적 — [누가거리, 면적]. 계획선 색칠에 쓴다(B04 지도와 같은 값). */ @@ -348,14 +342,3 @@ export async function fetchDrainageBasins( API_ANALYSIS_TIMEOUT_MS, ); } - -/** 사용자가 옮긴 유역 외곽선 포인트만 저장한다(종단 경로 확정 시 모달 승인 후 호출). */ -export async function saveDrainageBoundary( - projectId: string, - points: Array<{ base: [number, number]; moved: [number, number] }>, -): Promise<{ status: string; saved: number }> { - return requestJson<{ status: string; saved: number }>( - `/projects/${projectId}/drainage/boundary`, - { method: "PUT", body: JSON.stringify({ points }) }, - ); -} diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Store.py b/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Store.py index 1ee4e508..b850a2ae 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Store.py +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Store.py @@ -1,52 +1,27 @@ -"""B05 전용 배수유역 저장소 (사본 관리 + 유역 외곽선 편집분 보존). +"""B05 전용 배수유역 저장소 (B04 산출물 사본 관리). B04는 배수유역을 해석해 `B04_wf1_Surface/drainage/`에 남긴다. B05는 그 결과를 읽어 관을 -보충하고 세부유역을 나누는데, 같은 폴더를 그대로 쓰면 B05에서 손댄 내용이 B04 원본을 -덮어쓴다. 그래서 여기서 사본을 따로 둔다(2026-08-01 사용자 지시). +보충하고 세부유역을 나누는데, 같은 폴더를 그대로 쓰면 B05 쪽 작업이 B04 원본을 덮어쓴다. +그래서 여기서 사본을 따로 둔다(2026-08-01 사용자 지시). · `B05_wf2_Route/drainage/` — B04 산출물의 사본. B04가 다시 해석하면 자동으로 갱신된다. - · `boundary_overrides.json` — 사용자가 옮긴 유역 외곽선 포인트만. 사본이 갱신돼도 남는다. -노선이 바뀌지 않으면 유역도 바뀌지 않는다. 노선이 바뀌어 B04가 재계산하면 사본은 새 결과로 -덮어쓰고, 저장해 둔 편집 포인트는 좌표 근접으로 새 외곽선에 다시 붙인다. 새 유역 **안쪽**으로 -들어간 포인트는 경계를 넓히는 의미가 없으므로 버린다. +유역 외곽선을 손으로 고치는 기능은 없앴다 — 등고선을 전부 해석해 얻은 경계라 사람이 다시 +그릴 이유가 없다(2026-08-01 사용자 지시). 그래서 여기에는 편집분 보존 로직이 없다. """ from __future__ import annotations -import json import logging -import math import shutil -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_storage import resolve_stored_project_path -from config.config_system import ( - DRAINAGE_B05_DIRNAME, - DRAINAGE_BOUNDARY_HANDLE_SPACING_M, - DRAINAGE_BOUNDARY_MATCH_RADIUS_M, - DRAINAGE_BOUNDARY_OVERRIDE_FILENAME, -) +from config.config_system import DRAINAGE_B05_DIRNAME logger = logging.getLogger(__name__) -# 위도 1도 ≈ 110540m, 경도 1도 ≈ 111320m·cos(위도). 30m 안팎의 근접 판정에는 충분하다. -_METERS_PER_LAT_DEGREE = 110540.0 -_METERS_PER_LON_DEGREE = 111320.0 - -LonLatPoint = list[float] - - -@dataclass -class BoundaryOverride: - """사용자가 옮긴 외곽선 포인트 하나 — 원래 자리(base)와 옮긴 자리(moved).""" - - base: tuple[float, float] - moved: tuple[float, float] - def b05_drainage_dir(stored_path: str) -> Path: """B05 전용 배수유역 폴더. B04 원본과 분리된 사본이 여기 들어간다.""" @@ -57,7 +32,6 @@ def sync_from_b04(stored_path: str) -> bool: """B04 산출물을 B05 사본으로 맞춘다. 실제로 복사했으면 True. 사본이 없으면 초안으로 1회 복사하고, B04 쪽이 더 최신이면(재해석) 그 파일만 덮어쓴다. - `boundary_overrides.json`은 B04에 없는 파일이라 이 과정에서 손대지 않는다. """ source = drainage_dir(stored_path) if not source.is_dir(): @@ -80,166 +54,3 @@ def sync_from_b04(stored_path: str) -> bool: if copied: logger.info("배수유역: B05 사본 갱신 — %d개 파일 (%s)", copied, target) return copied > 0 - - -def _overrides_path(stored_path: str) -> Path: - return b05_drainage_dir(stored_path) / DRAINAGE_BOUNDARY_OVERRIDE_FILENAME - - -def load_boundary_overrides(stored_path: str) -> list[BoundaryOverride]: - """저장된 외곽선 편집 포인트를 읽는다. 없으면 빈 목록.""" - path = _overrides_path(stored_path) - 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 [] - overrides: list[BoundaryOverride] = [] - for entry in (document or {}).get("points", []): - base = _as_point(entry.get("base")) - moved = _as_point(entry.get("moved")) - if base and moved: - overrides.append(BoundaryOverride(base=base, moved=moved)) - return overrides - - -def save_boundary_overrides(stored_path: str, overrides: list[BoundaryOverride]) -> int: - """외곽선 편집 포인트를 저장한다. 저장된 개수를 돌려준다.""" - path = _overrides_path(stored_path) - document = { - "version": 1, - "spacing_m": DRAINAGE_BOUNDARY_HANDLE_SPACING_M, - "points": [{"base": list(item.base), "moved": list(item.moved)} for item in overrides], - } - try: - path.parent.mkdir(parents=True, exist_ok=True) - with path.open("w", encoding="utf-8") as file: - json.dump(document, file, ensure_ascii=False) - except OSError: - logger.warning("배수유역: 외곽선 편집 저장 실패 (%s).", path) - return 0 - logger.info("배수유역: 외곽선 편집 %d개 저장 — %s", len(overrides), path) - return len(overrides) - - -def parse_overrides(values: Any) -> list[BoundaryOverride]: - """프론트가 보낸 편집 포인트 목록을 정리한다(형식이 어긋난 항목은 버린다).""" - if not isinstance(values, list): - return [] - parsed: list[BoundaryOverride] = [] - for entry in values: - if not isinstance(entry, dict): - continue - base = _as_point(entry.get("base")) - moved = _as_point(entry.get("moved")) - if base and moved: - parsed.append(BoundaryOverride(base=base, moved=moved)) - return parsed - - -def _as_point(value: Any) -> tuple[float, float] | None: - if not isinstance(value, (list, tuple)) or len(value) < 2: - return None - try: - return (float(value[0]), float(value[1])) - except (TypeError, ValueError): - return None - - -def resample_boundary( - polygon_lonlat: list[LonLatPoint], spacing_m: float = DRAINAGE_BOUNDARY_HANDLE_SPACING_M -) -> list[LonLatPoint]: - """외곽선을 일정 간격(m)으로 다시 찍어 편집 핸들 목록을 만든다. - - 격자 경계라 원래 정점이 1m 간격으로 촘촘해 그대로 핸들로 쓸 수 없다. - """ - points = [p for p in polygon_lonlat if isinstance(p, (list, tuple)) and len(p) >= 2] - if len(points) < 3 or spacing_m <= 0: - return [list(p[:2]) for p in points] - - # 폐합 고리로 다룬다 — 끝점이 시작점과 같으면 중복을 뺀다. - ring = [(float(p[0]), float(p[1])) for p in points] - if _distance_m(ring[0], ring[-1]) < 0.001: - ring = ring[:-1] - if len(ring) < 3: - return [list(p) for p in ring] - - handles: list[LonLatPoint] = [list(ring[0])] - carried = 0.0 - for index in range(len(ring)): - start = ring[index] - end = ring[(index + 1) % len(ring)] - segment = _distance_m(start, end) - if segment <= 0: - continue - position = spacing_m - carried - while position <= segment: - ratio = position / segment - handles.append( - [ - start[0] + (end[0] - start[0]) * ratio, - start[1] + (end[1] - start[1]) * ratio, - ] - ) - position += spacing_m - carried = (carried + segment) % spacing_m - return handles - - -def apply_boundary_overrides( - handles: list[LonLatPoint], overrides: list[BoundaryOverride] -) -> tuple[list[LonLatPoint], list[BoundaryOverride]]: - """저장된 편집 포인트를 새 핸들 목록에 다시 붙인다. - - · 인덱스가 아니라 **좌표 근접**으로 맞춘다 — 재계산하면 핸들 수가 달라진다. - · 옮긴 자리가 새 유역 **안쪽**이면 경계를 넓히지 않으므로 버린다. - 돌려주는 값은 (편집이 반영된 핸들, 살아남은 편집 목록). - """ - if not overrides or len(handles) < 3: - return handles, list(overrides) - - polygon = [(float(p[0]), float(p[1])) for p in handles] - applied = [list(p) for p in handles] - kept: list[BoundaryOverride] = [] - for override in overrides: - if _point_in_polygon(override.moved, polygon): - continue - nearest = -1 - nearest_distance = DRAINAGE_BOUNDARY_MATCH_RADIUS_M - for index, point in enumerate(polygon): - distance = _distance_m(override.base, point) - if distance <= nearest_distance: - nearest = index - nearest_distance = distance - if nearest < 0: - continue - # 붙인 자리를 새 base로 잡아 둔다 — 다음 재계산에서도 같은 지점에 다시 붙는다. - applied[nearest] = [override.moved[0], override.moved[1]] - kept.append(BoundaryOverride(base=polygon[nearest], moved=override.moved)) - return applied, kept - - -def _distance_m(a: tuple[float, float], b: tuple[float, float]) -> float: - """두 lon/lat 사이 거리(m) 근사. 수십 m 범위 판정에만 쓴다.""" - mean_lat = math.radians((a[1] + b[1]) / 2) - dx = (b[0] - a[0]) * _METERS_PER_LON_DEGREE * math.cos(mean_lat) - dy = (b[1] - a[1]) * _METERS_PER_LAT_DEGREE - return math.hypot(dx, dy) - - -def _point_in_polygon(point: tuple[float, float], polygon: list[tuple[float, float]]) -> bool: - """레이 캐스팅 내부 판정 (lon/lat 평면에서 그대로 계산).""" - x, y = point - inside = False - count = len(polygon) - for index in range(count): - x1, y1 = polygon[index] - x2, y2 = polygon[(index + 1) % count] - if (y1 > y) != (y2 > y): - crossing = x1 + (y - y1) * (x2 - x1) / (y2 - y1) - if crossing > x: - inside = not inside - return inside diff --git a/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py b/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py index 27c1ff2d..9f67bdfc 100644 --- a/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py +++ b/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py @@ -15,19 +15,9 @@ from uuid import UUID from fastapi import APIRouter 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_Drainage_Basin import build_drainage_detail -from B05_wf2_Route.B05_wf2_Route_Engine_Drainage_Store import ( - apply_boundary_overrides, - load_boundary_overrides, - parse_overrides, - resample_boundary, - save_boundary_overrides, -) 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 logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/projects", tags=["B05 Route Drainage"]) @@ -92,16 +82,6 @@ async def post_drainage_basins( ) to_lonlat = prepared.to_lonlat - # 2차 전체 유역 외곽선 — 편집 핸들 간격으로 다시 찍고 저장된 편집분을 얹는다. - # 격자 경계라 원래 정점이 1m 간격이라 그대로는 손으로 잡을 수 없다. - boundary = resample_boundary(detail.basin_lonlat) - 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) - return { "status": "success", "project_id": str(project_id), @@ -110,12 +90,9 @@ async def post_drainage_basins( "z_source": prepared.z_source, # B04가 남긴 그대로 — 계획도로선. 외곽선만 편집분을 반영해 내보낸다. "route_lonlat": detail.route_lonlat, - "main_polygon_lonlat": boundary, - "boundary_spacing_m": DRAINAGE_BOUNDARY_HANDLE_SPACING_M, - "boundary_overrides": [ - {"base": list(item.base), "moved": list(item.moved)} for item in kept - ], - "boundary_dropped": dropped, + # 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, @@ -143,29 +120,6 @@ async def post_drainage_basins( } -@router.put("/{project_id}/drainage/boundary", response_model=None) -async def put_drainage_boundary( - project_id: UUID, - payload: dict[str, Any] | None = None, -) -> 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) - if not stored_path: - return JSONResponse( - status_code=404, - content={"status": "error", "message": "프로젝트 저장 경로가 없습니다."}, - ) - overrides = parse_overrides((payload or {}).get("points")) - saved = save_boundary_overrides(stored_path, overrides) - return {"status": "success", "project_id": str(project_id), "saved": saved} - - def _parse_chainages(values: list[Any]) -> list[float]: """사용자가 확정·편집한 누가거리 목록을 숫자로 정리한다.""" parsed: list[float] = [] diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Boundary.ts b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Boundary.ts deleted file mode 100644 index 706b6cfd..00000000 --- a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Boundary.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { themeColor } from "@ui/ui_template_palette"; -import { - haloColor, - type Normalizer, - type ViewState, -} from "../B04_wf1_Surface/B04_wf1_Surface_UI_MapRender"; - -/* ============================================================================= - * 2차 전체 배수유역 외곽선 편집 (B05 배수유역도) - * - * 백엔드가 외곽선을 일정 간격(기본 20m)으로 다시 찍어 주면, 그 점들을 잡아 끌어 - * 유역 경계를 손으로 고친다. 옮긴 값은 **화면에만** 들고 있다가 종단 경로 확정 시 - * 사용자가 승인하면 옮긴 포인트만 저장한다(2026-08-01 사용자 지시). - * - * 저장 대상은 폴리곤 전체가 아니라 `{원래 자리, 옮긴 자리}` 짝이다 — 노선이 바뀌어 - * 유역을 다시 계산하면 점 개수가 달라지므로 좌표 근접으로 다시 붙여야 하기 때문이다. - * ========================================================================== */ - -/** 편집 핸들 반경(px)과 잡을 수 있는 여유. */ -const HANDLE_RADIUS_PX = 4; -const HANDLE_HIT_PX = 9; -/* 색 값의 정의처는 `ui_template_theme.css`(`--map-*`)다 — 여기서 값을 새로 정하지 않는다. */ -const handleColor = (): string => themeColor("--map-boundary-handle", "rgba(146, 64, 14, 0.95)"); -const handleMovedColor = (): string => - themeColor("--map-boundary-handle-moved", "rgba(220, 38, 38, 0.95)"); -/** 같은 자리로 볼 오차(도). 대략 0.1m 수준. */ -const SAME_POINT_EPSILON = 1e-6; - -export type LonLat = [number, number]; - -export interface BoundaryOverrideEntry { - /** 재계산된 외곽선 위의 원래 자리. 다음 계산에서 이 좌표로 다시 붙인다. */ - base: LonLat; - /** 사용자가 옮긴 자리. */ - moved: LonLat; -} - -export interface BoundaryEditor { - /** 서버가 준 외곽선(편집 반영분)과 저장돼 있던 편집 목록을 싣는다. */ - setBoundary: ( - points: ReadonlyArray, - overrides: ReadonlyArray, - ) => void; - /** 현재 화면에 그릴 외곽선. */ - points: () => LonLat[]; - /** 저장 대상 — 원래 자리와 다른 포인트만. */ - overrides: () => BoundaryOverrideEntry[]; - /** 이번 화면에서 사용자가 옮긴 것이 있는지(저장 여부를 물어볼 근거). */ - isDirty: () => boolean; - markSaved: () => void; - setEditMode: (on: boolean) => void; - /** 편집 모드에서 핸들을 그린다. 외곽선 자체는 패널이 능선 스타일로 그린다. */ - draw: (context: CanvasRenderingContext2D, normalizer: Normalizer, view: ViewState) => void; - /** 핸들을 잡았으면 true — 지도 팬을 시작하지 않는다. */ - handleDown: (normalizer: Normalizer, view: ViewState, x: number, y: number) => boolean; - /** 드래그 중이면 true. */ - handleMove: (normalizer: Normalizer, view: ViewState, x: number, y: number) => boolean; - handleUp: () => void; -} - -type Affine = { ax: number; bx: number; ay: number; by: number }; - -/** 지도 렌더러와 같은 화면 변환. (MapRender 내부 계산과 동일 식) */ -function affineOf(view: ViewState): Affine { - return { - ax: view.mapRect.width * view.scale, - bx: view.mapRect.x * view.scale + (view.width / 2) * (1 - view.scale) + view.offsetX, - ay: view.mapRect.height * view.scale, - by: view.mapRect.y * view.scale + (view.height / 2) * (1 - view.scale) + view.offsetY, - }; -} - -function toScreen(point: LonLat, normalizer: Normalizer, affine: Affine): [number, number] { - const nx = (point[0] - normalizer.lonMin) / normalizer.lonRange; - const ny = 1 - (point[1] - normalizer.latMin) / normalizer.latRange; - return [nx * affine.ax + affine.bx, ny * affine.ay + affine.by]; -} - -function toLonLat(x: number, y: number, normalizer: Normalizer, affine: Affine): LonLat | null { - if (!(affine.ax > 0) || !(affine.ay > 0)) return null; - const nx = (x - affine.bx) / affine.ax; - const ny = (y - affine.by) / affine.ay; - return [ - normalizer.lonMin + nx * normalizer.lonRange, - normalizer.latMin + (1 - ny) * normalizer.latRange, - ]; -} - -function samePoint(a: LonLat, b: LonLat): boolean { - return Math.abs(a[0] - b[0]) < SAME_POINT_EPSILON && Math.abs(a[1] - b[1]) < SAME_POINT_EPSILON; -} - -export function createBoundaryEditor(onChange: () => void): BoundaryEditor { - // 화면에 그리는 현재 외곽선. - let points: LonLat[] = []; - // 같은 인덱스의 "원래 자리". 저장분이 있는 핸들은 서버가 준 base를 그대로 쓴다. - let bases: LonLat[] = []; - let editMode = false; - let dragIndex: number | null = null; - let dirty = false; - - function setBoundary( - nextPoints: ReadonlyArray, - nextOverrides: ReadonlyArray, - ): void { - points = nextPoints.map((point) => [point[0], point[1]] as LonLat); - bases = points.map((point) => [point[0], point[1]] as LonLat); - // 저장분이 반영된 자리는 원래 자리를 서버 값으로 되돌려 둔다 — 다음 재계산에서 - // 옮긴 자리가 아니라 외곽선 위 원래 자리로 다시 붙어야 하기 때문이다. - nextOverrides.forEach((override) => { - const index = points.findIndex((point) => samePoint(point, override.moved)); - if (index >= 0) bases[index] = [override.base[0], override.base[1]]; - }); - dragIndex = null; - dirty = false; - } - - function overrides(): BoundaryOverrideEntry[] { - const list: BoundaryOverrideEntry[] = []; - points.forEach((point, index) => { - const base = bases[index]; - if (!base || samePoint(point, base)) return; - list.push({ base: [base[0], base[1]], moved: [point[0], point[1]] }); - }); - return list; - } - - function draw(context: CanvasRenderingContext2D, normalizer: Normalizer, view: ViewState): void { - if (!editMode || points.length === 0) return; - const affine = affineOf(view); - context.save(); - context.lineWidth = 1.2; - context.strokeStyle = haloColor(); - points.forEach((point, index) => { - const [x, y] = toScreen(point, normalizer, affine); - if (x < -20 || y < -20 || x > view.width + 20 || y > view.height + 20) return; - const base = bases[index]; - context.beginPath(); - context.arc(x, y, HANDLE_RADIUS_PX, 0, Math.PI * 2); - context.fillStyle = base && !samePoint(point, base) ? handleMovedColor() : handleColor(); - context.fill(); - context.stroke(); - }); - context.restore(); - } - - function handleDown(normalizer: Normalizer, view: ViewState, x: number, y: number): boolean { - if (!editMode || points.length === 0) return false; - const affine = affineOf(view); - let nearest = -1; - let nearestDistance = HANDLE_HIT_PX; - points.forEach((point, index) => { - const [px, py] = toScreen(point, normalizer, affine); - const distance = Math.hypot(px - x, py - y); - if (distance <= nearestDistance) { - nearest = index; - nearestDistance = distance; - } - }); - if (nearest < 0) return false; - dragIndex = nearest; - return true; - } - - function handleMove(normalizer: Normalizer, view: ViewState, x: number, y: number): boolean { - if (dragIndex === null) return false; - const moved = toLonLat(x, y, normalizer, affineOf(view)); - if (!moved) return true; - points[dragIndex] = moved; - dirty = true; - onChange(); - return true; - } - - return { - setBoundary, - points: () => points.map((point) => [point[0], point[1]] as LonLat), - overrides, - isDirty: () => dirty, - markSaved: () => { - dirty = false; - }, - setEditMode(on: boolean) { - editMode = on; - dragIndex = null; - onChange(); - }, - draw, - handleDown, - handleMove, - handleUp() { - dragIndex = null; - }, - }; -} 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 44a1f6ba..ccd20c45 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts @@ -37,10 +37,6 @@ import { drawStrengthLine, } from "../B04_wf1_Surface/B04_wf1_Surface_UI_FlowRamp"; import { resampleRoute } from "../B04_wf1_Surface/B04_wf1_Surface_UI_RouteSamples"; -import { - createBoundaryEditor, - type BoundaryOverrideEntry, -} from "./B05_wf2_Route_UI_Drainage_Boundary"; import { createPipeEditor } from "./B05_wf2_Route_UI_Drainage_Pipes"; import { createProgressCircle } from "@ui/ui_template_progress"; import { createMapContextMenu } from "@ui/ui_template_context_menu"; @@ -79,12 +75,6 @@ export interface DrainagePanel { load: (projectId: string) => void; /** 확정된 노선 평면 선형(사업지 좌표계 m)을 지도 위에 겹친다. */ setRoute: (points: ReadonlyArray) => void; - /** 이번 화면에서 사용자가 유역선 포인트를 옮겼는지(경로 확정 시 저장 여부를 묻는 근거). */ - hasBoundaryEdits: () => boolean; - /** 저장 대상 — 원래 자리와 옮긴 자리 짝. 폴리곤 전체가 아니다. */ - boundaryOverrides: () => BoundaryOverrideEntry[]; - /** 저장이 끝났음을 알린다(다시 묻지 않도록). */ - markBoundarySaved: () => void; dispose: () => void; } @@ -120,14 +110,7 @@ export function createDrainagePanel(): DrainagePanel { autoButton.className = "b05-drainage__analyze b05-drainage__tool"; autoButton.textContent = L("B05_Drainage_Btn_Auto"); autoButton.title = L("B05_Drainage_Btn_Auto_Tip"); - // 유역선 편집 토글 — 켜면 외곽선 위 핸들을 잡아 유역 경계를 손으로 고친다. - const boundaryButton = document.createElement("button"); - boundaryButton.type = "button"; - boundaryButton.className = "b05-drainage__analyze b05-drainage__tool"; - boundaryButton.textContent = L("B05_Drainage_Btn_BoundaryEdit"); - boundaryButton.title = L("B05_Drainage_Btn_BoundaryEdit_Tip"); - boundaryButton.setAttribute("aria-pressed", "false"); - header.append(analyzeButton, deleteButton, autoButton, boundaryButton); + header.append(analyzeButton, deleteButton, autoButton); const viewport = document.createElement("div"); viewport.className = "b05-drainage__viewport"; @@ -202,8 +185,6 @@ export function createDrainagePanel(): DrainagePanel { let strength: Float64Array = new Float64Array(0); let maxStrength = 0; let showStrength = true; - let boundaryMode = false; - const boundaryEditor = createBoundaryEditor(() => scheduleDraw()); let scale = 1; let offsetX = 0; let offsetY = 0; @@ -310,9 +291,8 @@ export function createDrainagePanel(): DrainagePanel { ); }); // 전체 유역 외곽선 = 분수령(능선). 세부유역 경계와 구분되게 파선 한 겹만 얹는다. - // 편집 중이면 사용자가 끌어 옮긴 외곽선을 그대로 보여 준다. - const boundary = boundaryEditor.points(); - if (boundary.length > 2) drawRidgeRing(context, boundary, normalizer, view); + // 해석 결과를 그대로 그린다 — 손으로 고치지 않는다(2026-08-01 사용자 지시). + if (mainBoundary.length > 2) drawRidgeRing(context, mainBoundary, normalizer, view); } // 등고선을 얇게 깔고 세류를 그 위에, 노선을 맨 위에 둔다. DRAINAGE_LAYERS.forEach((layer) => { @@ -353,8 +333,6 @@ export function createDrainagePanel(): DrainagePanel { view, ); } - // 유역선 편집 핸들 — 편집 모드에서만. 화살표 위, 배관 마커 아래. - if (normalizer) boundaryEditor.draw(context, normalizer, view); // 배관(관 매설) 마커 — 계획선 위 최상단. pipeEditor.draw(context, view, pipeColor); updateImageTransform(); @@ -418,8 +396,6 @@ export function createDrainagePanel(): DrainagePanel { const response = await fetchDrainageBasins(projectId, chainages); basins = response.basins; mainBoundary = response.main_polygon_lonlat ?? []; - // 외곽선은 편집 핸들 간격으로 다시 찍힌 값이며, 저장된 편집분은 이미 반영돼 있다. - boundaryEditor.setBoundary(mainBoundary, response.boundary_overrides ?? []); upstreamLines = (response.upstream_lonlat ?? []) as Array>; flowArrows = (response.flow_arrows ?? []) as FlowArrow[]; arrowSpacingM = response.arrow_spacing_m ?? 0; @@ -458,12 +434,6 @@ export function createDrainagePanel(): DrainagePanel { pipeEditor.setPipes([]); void analyze(true); }); - boundaryButton.addEventListener("click", () => { - boundaryMode = !boundaryMode; - boundaryButton.classList.toggle("is-active", boundaryMode); - boundaryButton.setAttribute("aria-pressed", String(boundaryMode)); - boundaryEditor.setEditMode(boundaryMode); - }); /** 노선 전체가 보이도록 배율·중심을 맞춘다(초기 보기 = 도로 기준 줌인). * @@ -575,19 +545,6 @@ export function createDrainagePanel(): DrainagePanel { // 중간 버튼 기본 동작(페이지 자동 스크롤)이 지도 팬과 겹치지 않게 막는다. if (event.button === 1) event.preventDefault(); const rect = viewport.getBoundingClientRect(); - // 유역선 편집 중이면 외곽선 핸들을 먼저 본다 — 잡았으면 지도 팬을 시작하지 않는다. - if ( - normalizer && - boundaryEditor.handleDown( - normalizer, - currentView(), - event.clientX - rect.left, - event.clientY - rect.top, - ) - ) { - viewport.setPointerCapture(event.pointerId); - return; - } // 배관 마커 클릭/추가가 처리되면 지도 팬은 시작하지 않는다. if (pipeEditor.handleDown(currentView(), event.clientX - rect.left, event.clientY - rect.top)) { viewport.setPointerCapture(event.pointerId); @@ -603,17 +560,6 @@ export function createDrainagePanel(): DrainagePanel { }); viewport.addEventListener("pointermove", (event) => { const rect = viewport.getBoundingClientRect(); - // 외곽선 핸들을 끌고 있으면 그것만 처리한다. - if ( - normalizer && - boundaryEditor.handleMove( - normalizer, - currentView(), - event.clientX - rect.left, - event.clientY - rect.top, - ) - ) - return; // 배관 드래그 중이면 마커 이동(계획선 스냅)만 처리한다. if (pipeEditor.handleMove(currentView(), event.clientX - rect.left, event.clientY - rect.top)) return; @@ -623,7 +569,6 @@ export function createDrainagePanel(): DrainagePanel { scheduleDraw(); }); const stopDragging = (): void => { - boundaryEditor.handleUp(); pipeEditor.handleUp(); dragStart = null; viewport.style.removeProperty("cursor"); @@ -661,9 +606,6 @@ export function createDrainagePanel(): DrainagePanel { if (routeLayer) fitToRoute(); scheduleDraw(); }, - hasBoundaryEdits: boundaryEditor.isDirty, - boundaryOverrides: boundaryEditor.overrides, - markBoundarySaved: boundaryEditor.markSaved, dispose() { loadSequence += 1; if (frameHandle) { diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Page.ts b/B05_wf2_Route/B05_wf2_Route_UI_Page.ts index e1fec28a..cc91571f 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Page.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Page.ts @@ -19,7 +19,6 @@ import { confirmRoute, fetchLatestRoute, routeLatestCacheKey, - saveDrainageBoundary, solveRoute, updateContourInterval, type CirclePoint, @@ -554,32 +553,8 @@ export async function renderB05Route(root: HTMLElement): Promise { } } - /** 유역선을 손으로 고쳤으면 확정 전에 저장 여부를 묻는다. 승인해야만 옮긴 포인트를 남긴다. */ - async function saveBoundaryEditsIfWanted(): Promise { - const drainage = profilePanel.drainage; - if (!drainage.hasBoundaryEdits()) return; - const overrides = drainage.boundaryOverrides(); - const accepted = window.confirm( - `배수유역 외곽선에서 옮긴 포인트 ${overrides.length}개를 저장할까요?\n` + - "저장하면 노선이 바뀌어 유역을 다시 계산해도 옮긴 자리가 유지됩니다.", - ); - if (!accepted) return; - try { - await saveDrainageBoundary(activeProjectId, overrides); - drainage.markBoundarySaved(); - showToast("배수유역 외곽선 편집을 저장했습니다.", "success"); - } catch (error) { - // 유역선 저장 실패가 경로 확정 자체를 막지는 않는다. - showToast( - error instanceof Error ? error.message : "배수유역 외곽선 저장에 실패했습니다.", - "error", - ); - } - } - async function confirm(): Promise { if (!routeReady || stale) return; - await saveBoundaryEditsIfWanted(); showLoadingOverlay(); try { // 종단 계획선 편집은 화면에서만 계산해 두었으므로 확정 직전에 영속화한다. diff --git a/config/config_system.py b/config/config_system.py index 5a2bcfec..9468f0ba 100644 --- a/config/config_system.py +++ b/config/config_system.py @@ -302,16 +302,9 @@ DRAINAGE_EDITS_DIRNAME = "edits" DRAINAGE_PIPE_POINTS_FILENAME = "pipe_points.json" # ── B05 전용 배수유역 사본 ── -# B04 산출물을 그대로 쓰면 B05 편집이 원본을 덮어쓴다. 프로젝트 저장소의 -# B05_wf2_Route/drainage/ 아래로 복사해 두고 B05는 사본만 읽고 쓴다. +# B04 산출물을 그대로 쓰면 B05 쪽 작업이 원본을 덮어쓴다. 프로젝트 저장소의 +# B05_wf2_Route/drainage/ 아래로 복사해 두고 B05는 사본만 읽는다. DRAINAGE_B05_DIRNAME = "drainage" -# 사용자가 옮긴 유역 외곽선 포인트만 담는 파일. B04 재계산으로 사본이 갱신돼도 남는다. -DRAINAGE_BOUNDARY_OVERRIDE_FILENAME = "boundary_overrides.json" -# 유역 외곽선 편집 핸들 간격(m). 화면에서 보고 조정할 값(2026-08-01 사용자 지시). -DRAINAGE_BOUNDARY_HANDLE_SPACING_M = float(os.getenv("DRAINAGE_BOUNDARY_HANDLE_SPACING_M", "20.0")) -# 재계산된 외곽선에 저장 포인트를 다시 붙일 때 허용하는 최대 거리(m). -# 인덱스는 재계산으로 어긋나므로 좌표 근접으로만 맞춘다. -DRAINAGE_BOUNDARY_MATCH_RADIUS_M = float(os.getenv("DRAINAGE_BOUNDARY_MATCH_RADIUS_M", "30.0")) # ── B05용 평균 흐름 화살표 ── # 셀 화살표는 1m라 축소하면 경향이 안 보인다. 이 크기의 블록으로 묶어 방향을 평균한다. diff --git a/ui_template/ui_template_locale.ts b/ui_template/ui_template_locale.ts index 5b5f1e9c..fb4d7aff 100644 --- a/ui_template/ui_template_locale.ts +++ b/ui_template/ui_template_locale.ts @@ -836,11 +836,6 @@ export const ui_locales = { "노선 1m 구간마다 그 자리로 모이는 상류 면적을 색으로 칠합니다(B04 지도와 같은 색띠).", "Colors each 1m stretch of the route by the upstream area draining into it (same ramp as the B04 map).", ], - B05_Drainage_Btn_BoundaryEdit: ["유역선 편집", "Edit basin outline"], - B05_Drainage_Btn_BoundaryEdit_Tip: [ - "2차 전체 배수유역 외곽선 위 포인트를 끌어 경계를 고칩니다. 옮긴 값은 종단 경로 확정 시 저장 여부를 묻습니다.", - "Drag the points on the overall basin outline to correct it. You will be asked whether to save the moved points when the route is confirmed.", - ], B05_Drainage_ImageAlt: ["배경 위성지도", "Satellite basemap"], B05_Drainage_Status_NeedRoute: [ "노선을 확정하면 배수유역도가 표시됩니다.",