refactor(B05): 유역 외곽선 편집 기능 제거
등고선을 전부 해석해 얻은 경계라 사람이 다시 그릴 이유가 없다(2026-08-01 사용자 지시).
남겨 두면 손으로 옮긴 외곽선과 1m 셀 라벨로 만든 세부유역이 어긋나 면적·관경의 근거가
깨진다. 외곽선은 이제 해석 결과를 그대로 표시만 한다.
제거 대상
- B05_wf2_Route_UI_Drainage_Boundary.ts (핸들 편집기)
- "유역선 편집" 버튼과 경로 확정 시 저장 여부 모달
- PUT /{project_id}/drainage/boundary 엔드포인트
- boundary_overrides 로드·저장·재부착 로직과 resample_boundary
- DRAINAGE_BOUNDARY_OVERRIDE_FILENAME / _HANDLE_SPACING_M / _MATCH_RADIUS_M
B05 저장소 모듈은 B04 산출물 사본 동기화만 남겨 245줄 → 55줄로 줄었다.
이미 저장돼 있던 boundary_overrides.json은 지우지 않았다(사용자 데이터라 그대로 둔다).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<Array<[number, number]>>;
|
||||
/** 도로 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 }) },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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] = []
|
||||
|
||||
@@ -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<LonLat>,
|
||||
overrides: ReadonlyArray<BoundaryOverrideEntry>,
|
||||
) => 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<LonLat>,
|
||||
nextOverrides: ReadonlyArray<BoundaryOverrideEntry>,
|
||||
): 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<RoutePoint>) => 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<ArrayBuffer> = 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<Array<[number, number]>>;
|
||||
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) {
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
confirmRoute,
|
||||
fetchLatestRoute,
|
||||
routeLatestCacheKey,
|
||||
saveDrainageBoundary,
|
||||
solveRoute,
|
||||
updateContourInterval,
|
||||
type CirclePoint,
|
||||
@@ -554,32 +553,8 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** 유역선을 손으로 고쳤으면 확정 전에 저장 여부를 묻는다. 승인해야만 옮긴 포인트를 남긴다. */
|
||||
async function saveBoundaryEditsIfWanted(): Promise<void> {
|
||||
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<void> {
|
||||
if (!routeReady || stale) return;
|
||||
await saveBoundaryEditsIfWanted();
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
// 종단 계획선 편집은 화면에서만 계산해 두었으므로 확정 직전에 영속화한다.
|
||||
|
||||
@@ -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라 축소하면 경향이 안 보인다. 이 크기의 블록으로 묶어 방향을 평균한다.
|
||||
|
||||
@@ -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: [
|
||||
"노선을 확정하면 배수유역도가 표시됩니다.",
|
||||
|
||||
Reference in New Issue
Block a user