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:
2026-08-01 22:46:34 +09:00
co-authored by Claude Opus 5
parent 8c37f2f6f3
commit e994330874
8 changed files with 15 additions and 557 deletions
+1 -18
View File
@@ -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
+3 -49
View File
@@ -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) {
-25
View File
@@ -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 {
// 종단 계획선 편집은 화면에서만 계산해 두었으므로 확정 직전에 영속화한다.
+2 -9
View File
@@ -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라 축소하면 경향이 안 보인다. 이 크기의 블록으로 묶어 방향을 평균한다.
-5
View File
@@ -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: [
"노선을 확정하면 배수유역도가 표시됩니다.",