auto: 2026-07-30 17:25 (EOMSANGDON-HOME)

This commit is contained in:
2026-07-30 17:25:13 +09:00
parent f72017a6ee
commit 12f1666506
8 changed files with 953 additions and 285 deletions
+4
View File
@@ -272,6 +272,8 @@ export interface DrainageCandidateResponse {
export interface DrainageBasin {
index: number;
chainage_m: number;
/** 관(배관) 매설 지점 좌표 — 계획선 위 마커 렌더용. */
outlet_lonlat: [number, number];
polygon_lonlat: Array<[number, number]>;
area_m2: number;
relief_m: number;
@@ -283,6 +285,8 @@ export interface DrainageBasinResponse {
status: string;
project_id: string;
route_id: number;
/** 산정에 실제 사용된 배관 지점 — 유역이 없는 관도 포함(마커 동기화용). */
pipes: DrainageCandidate[];
basins: DrainageBasin[];
}
@@ -19,7 +19,7 @@ from dataclasses import dataclass, field
from typing import Any
from shapely.geometry import LineString, Point, Polygon
from shapely.ops import nearest_points, substring, unary_union
from shapely.ops import substring, unary_union
from shapely.strtree import STRtree
from B05_wf2_Route.B05_wf2_Route_Engine_Drainage import (
@@ -27,6 +27,11 @@ from B05_wf2_Route.B05_wf2_Route_Engine_Drainage import (
_interpolate_vertex,
estimate_pipe_diameter_mm,
)
from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Assemble import (
_assemble_polygon,
_road_segment_coords,
)
from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Subdivide import subdivide_main_polygon
from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Trace import (
LOCAL_MAX_STEPS,
LOCAL_SEARCH_RADIUS_M,
@@ -49,8 +54,6 @@ MIN_BASIN_AREA_M2 = 100.0
STREAM_COVER_BUFFER_M = 20.0
# 도로 양끝에서 하류측으로 뻗는 절단 차단선 길이(m).
DOWNHILL_BARRIER_M = 800.0
# 폐합 등고선 기하 탐색: 분할선 경로와 등고선의 근접 허용 거리(m).
CLOSING_SEARCH_M = 30.0
# 유역이 상류망을 덮어야 하는 커버리지 목표(미달 시 버퍼 폴백).
CLOSING_COVERAGE_GOAL = 0.95
# 산측 판정: 도로 접선의 좌우 수직 오프셋(m)과 표고 조회 반경(m).
@@ -117,192 +120,6 @@ def _uphill_sign_at(vertices: list[Any], chainage_m: float, contour_index: Conto
return 1 if left_z > right_z else -1
def _road_segment_coords(
vertices: list[Any], start_m: float, end_m: float
) -> list[tuple[float, float]]:
"""분할점 사이 도로 구간의 평면 좌표열(유역 폴리곤의 하측 경계)."""
sx, sy, _ = _interpolate_vertex(vertices, start_m)
ex, ey, _ = _interpolate_vertex(vertices, end_m)
coords = [(sx, sy)]
coords.extend(
(vertex.x, vertex.y) for vertex in vertices if start_m < vertex.chainage_m < end_m
)
coords.append((ex, ey))
return coords
def _contour_arc(
line: Any,
p_from: Point,
p_to: Point,
road_line: LineString,
network_union: Any = None,
) -> list[tuple[float, float]]:
"""등고선에서 두 분할선 접점 사이 아크(상측 경계)를 뽑는다.
폐합 등고선은 두 방향 아크가 생기므로, 세류 상류망을 가로지르지 않고(계곡을
자르지 않고) 도로와도 교차하지 않는(=산측) 쪽을 고른다.
"""
t1, t2 = sorted((line.project(p_from), line.project(p_to)))
arcs = []
inner = substring(line, t1, t2)
if inner.geom_type == "LineString" and len(inner.coords) >= 2:
arcs.append(inner)
if getattr(line, "is_closed", False):
head = substring(line, t2, line.length)
tail = substring(line, 0.0, t1)
coords = list(head.coords) + list(tail.coords)[1:]
if len(coords) >= 2:
arcs.append(LineString(coords))
if not arcs:
return []
scored = []
for arc in arcs:
crosses_stream = bool(network_union is not None and arc.crosses(network_union))
crosses_road = arc.crosses(road_line)
midpoint = arc.interpolate(0.5, normalized=True)
scored.append((crosses_stream, crosses_road, -road_line.distance(midpoint), arc))
scored.sort(key=lambda item: (item[0], item[1], item[2]))
arc = scored[0][3]
coords = list(arc.coords)
if Point(coords[0]).distance(p_from) > Point(coords[-1]).distance(p_from):
coords.reverse()
return [(float(x), float(y)) for x, y in coords]
def _junction(
left: list[DividerStep],
right: list[DividerStep],
min_z: float | None = None,
) -> tuple[int, int, int] | None:
"""두 분할선이 같은 등고선 지오메트리를 밟은 폐합 지점(좌 idx, 우 idx, geom idx).
min_z(세류 상류망 최고 표고)가 있으면 그 **이상인 가장 낮은** 공통 등고선을 고른다 —
"세류로 영역을 지정한 뒤 가까운 등고선으로 바로 올려치면 안 된다"(2026-07-29 사용자
지시). 계곡 발원부를 넘긴 첫 등고선이 유역 상측 경계가 된다. 없으면 최고 공통 등고선.
"""
left_keys = {
(step.z, step.geom_index): position
for position, step in enumerate(left)
if step.geom_index >= 0
}
matches: list[tuple[float, int, int, int]] = []
for position, step in enumerate(right):
if step.geom_index < 0:
continue
left_position = left_keys.get((step.z, step.geom_index))
if left_position is None:
continue
matches.append((step.z, left_position, position, step.geom_index))
if not matches:
return None
if min_z is not None:
above = [match for match in matches if match[0] >= min_z]
if above:
best = min(above)
return best[1], best[2], best[3]
best = max(matches)
return best[1], best[2], best[3]
def _closing_contour(
left: list[DividerStep],
right: list[DividerStep],
contour_index: ContourIndex,
min_z: float,
) -> tuple[int, int, int, Point, Point] | None:
"""두 분할선 경로에 모두 근접한 등고선 중 min_z 이상 최저를 찾는다.
분할선이 같은 스텝에서 같은 지오메트리를 밟지 못해도(도엽 분할 등) 계곡 발원부
위를 지나는 폐합 등고선을 기하적으로 찾아낸다.
반환: (좌 절단 idx, 우 절단 idx, 등고선 geom idx, 좌 접점, 우 접점).
"""
if len(left) < 2 or len(right) < 2:
return None
left_line = LineString([step.point for step in left])
right_line = LineString([step.point for step in right])
shared = set(contour_index.query(left_line.buffer(CLOSING_SEARCH_M))) & set(
contour_index.query(right_line.buffer(CLOSING_SEARCH_M))
)
best: tuple[float, int] | None = None
for index in shared:
z = contour_index.zs[index]
if z < min_z:
continue
geom = contour_index.geoms[index]
if (
geom.distance(left_line) > CLOSING_SEARCH_M
or geom.distance(right_line) > CLOSING_SEARCH_M
):
continue
if best is None or z < best[0]:
best = (z, index)
if best is None:
return None
geom = contour_index.geoms[best[1]]
left_touch = nearest_points(geom, left_line)[0]
right_touch = nearest_points(geom, right_line)[0]
left_position = min(range(len(left)), key=lambda i: left[i].point.distance(left_touch))
right_position = min(range(len(right)), key=lambda i: right[i].point.distance(right_touch))
return left_position, right_position, best[1], left_touch, right_touch
def _assemble_polygon(
vertices: list[Any],
start_m: float,
end_m: float,
left: list[DividerStep],
right: list[DividerStep],
contour_index: ContourIndex,
road_line: LineString,
network_union: Any = None,
valley_top_z: float | None = None,
) -> Polygon | None:
"""도로 구간 + 우측 분할선 + 상측 등고선 아크 + 좌측 분할선으로 폴리곤을 폐합한다.
세류 유역(valley_top_z 지정)은 발원부 위를 지나는 폐합 등고선을 기하 탐색으로
먼저 찾고, 실패 시 같은 스텝 매칭(_junction)으로 폐합한다.
"""
ring = _road_segment_coords(vertices, start_m, end_m)
left_used, right_used, arc = left, right, []
closure = (
_closing_contour(left, right, contour_index, valley_top_z)
if valley_top_z is not None
else None
)
if closure is not None:
left_position, right_position, geom_index, left_touch, right_touch = closure
left_used = left[: left_position + 1]
right_used = right[: right_position + 1]
arc = _contour_arc(
contour_index.geoms[geom_index], right_touch, left_touch, road_line, network_union
)
else:
junction = _junction(left, right, min_z=valley_top_z)
if junction is not None:
left_position, right_position, geom_index = junction
left_used = left[: left_position + 1]
right_used = right[: right_position + 1]
arc = _contour_arc(
contour_index.geoms[geom_index],
right_used[-1].point,
left_used[-1].point,
road_line,
network_union,
)
ring.extend((step.point.x, step.point.y) for step in right_used[1:])
ring.extend(arc)
ring.extend((step.point.x, step.point.y) for step in reversed(left_used[1:]))
if len(ring) < 4:
return None
polygon = Polygon(ring).buffer(0)
if polygon.geom_type == "MultiPolygon":
polygon = max(polygon.geoms, key=lambda part: part.area)
if polygon.is_empty or polygon.geom_type != "Polygon":
return None
return polygon
def _clip_to_uphill(
polygon: Polygon,
road_line: LineString,
@@ -480,6 +297,150 @@ def _refine_road_edge(polygon: Polygon, road_line: LineString) -> Polygon:
return refined
def _main_watershed_polygon(
vertices: list[Any],
divides: list[float],
dividers: list[list[DividerStep]],
contour_index: ContourIndex,
road_line: LineString,
uphill_sign: int,
network_union: Any,
stream_lines: list[Any],
stream_features: list[dict[str, Any]],
outlet: Point,
) -> Polygon | None:
"""메인 배수유역 폴리곤 1회 산정 — f72017a 채택 산식 그대로.
불변 조건(2026-07-30 사용자 지시): 이 함수의 산식은 전체 유역 경계를 결정하므로
변경 금지. 세분화는 이 결과를 내부에서만 쪼갠다(`subdivide_main_polygon`).
"""
valley_top_z = contour_index.max_elevation_within(network_union.buffer(10.0))
# 개선 2안: 도로 양끝 물갈림점에서 능선 행진으로 경계를 직접 그린다.
polygon = None
if valley_top_z is not None:
polygon = _assemble_march_polygon(
vertices,
divides[0],
divides[-1],
contour_index,
road_line,
uphill_sign,
network_union,
stream_lines,
valley_top_z,
)
if polygon is None:
# 개선 1안(폴백): 도로변 스트립 + 세류 계곡 영역(등거리+등고선 체인) 합집합.
base = _assemble_polygon(
vertices,
divides[0],
divides[-1],
dividers[0],
dividers[-1],
contour_index,
road_line,
network_union,
valley_top_z,
)
valley = valley_region_polygon(network_union, stream_features, outlet, contour_index)
if base is None and valley is None:
return None
if base is not None and valley is not None:
merged = base.union(valley).buffer(0)
if merged.geom_type == "MultiPolygon":
merged = max(merged.geoms, key=lambda part: part.area)
polygon = merged if merged.geom_type == "Polygon" and not merged.is_empty else base
else:
polygon = base if base is not None else valley
coverage = network_union.intersection(polygon).length / max(network_union.length, 1.0)
if coverage < CLOSING_COVERAGE_GOAL:
# 계곡 영역이 못 덮은 상류망만 버퍼로 보정한다(최후 폴백).
covered = polygon.union(network_union.buffer(STREAM_COVER_BUFFER_M)).buffer(0)
if covered.geom_type == "MultiPolygon":
covered = max(covered.geoms, key=lambda part: part.area)
if covered.geom_type == "Polygon" and not covered.is_empty:
polygon = covered
# 마지막에 도로선으로 절단해 하류측을 제거한다 — 도로가 유역의 한쪽 경계.
polygon = _clip_to_uphill(polygon, road_line, uphill_sign, contour_index, network_union)
if polygon is None or polygon.area < MIN_BASIN_AREA_M2:
return None
return polygon
def _local_polygon(
vertices: list[Any],
divides: list[float],
dividers: list[list[DividerStep]],
position: int,
contour_index: ContourIndex,
road_line: LineString,
uphill_sign: int,
) -> Polygon | None:
"""세류 없는 관 구간의 소범위 유역 — 도로 상측 첫 능선까지(기존 경로 유지)."""
base = _assemble_polygon(
vertices,
divides[position],
divides[position + 1],
dividers[position],
dividers[position + 1],
contour_index,
road_line,
None,
None,
)
if base is None:
return None
return _clip_to_uphill(base, road_line, uphill_sign, contour_index, None)
def _basin_from_polygon(
polygon: Polygon,
candidate: StructureCandidate,
index: int,
flow_length: float,
contour_index: ContourIndex,
road_line: LineString,
vertices: list[Any],
simplify: bool = True,
) -> WatershedBasin:
"""확정된 유역 폴리곤에서 산출값(면적·표고차·유하장·관경)을 계산한다.
세분화 조각(simplify=False)은 단순화하지 않는다 — 조각별 독립 단순화는 공유
분할선 경계를 어긋나게 해 겹침·틈을 만든다(배타적 타일링 유지).
"""
outlet = Point(candidate.x, candidate.y)
outlet_z = contour_index.nearest_elevation(outlet, UPHILL_PROBE_RADIUS_M)
if outlet_z is None:
outlet_z = _interpolate_vertex(vertices, candidate.chainage_m)[2]
# 표고차는 등고선만으로 계산한다(표고점 미참조 — 사용자 지시).
top_z = contour_index.max_elevation_within(polygon) or outlet_z
boundary_line = polygon.simplify(5.0, preserve_topology=True) if simplify else polygon
if boundary_line.is_empty or boundary_line.geom_type != "Polygon":
boundary_line = polygon
# 도로변 경계는 단순화 없이 도로선 해상도를 유지한다.
boundary_line = _refine_road_edge(boundary_line, road_line)
boundary = [[float(x), float(y)] for x, y in boundary_line.exterior.coords]
if flow_length <= 0.0:
flow_length = max(
(math.dist((candidate.x, candidate.y), point) for point in boundary),
default=0.0,
)
basin = WatershedBasin(
index=index,
chainage_m=candidate.chainage_m,
outlet_x=candidate.x,
outlet_y=candidate.y,
boundary_xy=boundary,
area_m2=float(polygon.area),
relief_m=max(0.0, float(top_z) - float(outlet_z)),
flow_length_m=float(flow_length),
)
basin.pipe_diameter_mm = estimate_pipe_diameter_mm(
basin.area_m2, basin.relief_m, basin.flow_length_m
)
return basin
def build_watershed_basins(
vertices: list[Any],
candidates: list[StructureCandidate],
@@ -488,10 +449,11 @@ def build_watershed_basins(
elevation_keys: tuple[str, ...],
stream_features: list[dict[str, Any]] | None = None,
) -> list[WatershedBasin]:
"""관 지점 배치를 기준으로 메인 배수유역을 세분화해 산정한다.
"""메인 배수유역을 1회 산정하고 관 지점 기준으로 내부 세분화한다.
세류 교차 관("stream")은 상류망을 추적해 넓은 한계로, 세류 없는 관은 도로 상측
첫 능선까지 소범위 한계로 분할선을 올린다(작은 유역, 영역 선정 주의 — 사용자 지시).
메인 유역 경계는 관 개수와 무관하게 항상 동일하다(불변 조건 — 2026-07-30 사용자
지시). 세부유역 = 메인 폴리곤을 관 사이 분할선으로 쪼갠 조각(배타적, 합집합 =
메인). 세류 없는 관이 메인 범위 밖이면 소범위 유역을 별도 생성(기존 동작).
번호는 노선 시점에 가까운 순.
"""
if not candidates or len(vertices) < 2:
@@ -541,106 +503,70 @@ def build_watershed_basins(
)
dividers.append(steps)
basins: list[WatershedBasin] = []
# 관별 상류망은 1회만 추적한다(유하장 계산에도 사용).
networks: list[list[Any]] = []
flows: list[float] = []
for position, candidate in enumerate(ordered):
outlet = Point(candidate.x, candidate.y)
network: list[Any] = []
flow_length = 0.0
if is_stream[position] and stream_features:
network, flow_length = trace_upstream_network(
outlet, stream_features, road_line, signs[position]
Point(candidate.x, candidate.y), stream_features, road_line, signs[position]
)
# 계곡 발원부(상류망 최고 표고) — 유역 상측 폐합 등고선은 이보다 높아야 한다.
network_union = unary_union(network) if network else None
valley_top_z = (
contour_index.max_elevation_within(network_union.buffer(10.0))
if network_union is not None
else None
networks.append(network)
flows.append(flow_length)
main_polygon = None
combined = [line for network in networks for line in network]
first_stream = next((position for position in range(len(ordered)) if networks[position]), None)
if combined and first_stream is not None:
main_polygon = _main_watershed_polygon(
vertices,
divides,
dividers,
contour_index,
road_line,
majority,
unary_union(combined),
stream_lines,
stream_features or [],
Point(ordered[first_stream].x, ordered[first_stream].y),
)
# 개선 2안: 도로 양끝 물갈림점에서 능선 행진으로 경계를 직접 그린다.
polygon = None
if network_union is not None and valley_top_z is not None:
polygon = _assemble_march_polygon(
vertices,
divides[position],
divides[position + 1],
contour_index,
road_line,
signs[position],
network_union,
stream_lines,
valley_top_z,
)
pieces: list[Polygon | None] = [None] * len(ordered)
if main_polygon is not None:
divide_points = [
Point(*_interpolate_vertex(vertices, chainage)[:2]) for chainage in divides
]
pieces = subdivide_main_polygon(main_polygon, divide_points, dividers, road_line)
basins: list[WatershedBasin] = []
for position, candidate in enumerate(ordered):
polygon = pieces[position] if position < len(pieces) else None
from_subdivision = polygon is not None and len(ordered) > 1
if polygon is None:
# 개선 1안(폴백): 도로변 스트립 + 세류 계곡 영역(등거리+등고선 체인) 합집합.
base = _assemble_polygon(
vertices,
divides[position],
divides[position + 1],
dividers[position],
dividers[position + 1],
contour_index,
road_line,
network_union,
valley_top_z,
)
valley = (
valley_region_polygon(network_union, stream_features or [], outlet, contour_index)
if network_union is not None
else None
)
if base is None and valley is None:
if networks[position] and main_polygon is not None:
logger.warning(
"세류 관(%.0fm) 구간에 세부유역 조각이 없습니다 — 건너뜁니다.",
candidate.chainage_m,
)
continue
if base is not None and valley is not None:
merged = base.union(valley).buffer(0)
if merged.geom_type == "MultiPolygon":
merged = max(merged.geoms, key=lambda part: part.area)
polygon = merged if merged.geom_type == "Polygon" and not merged.is_empty else base
else:
polygon = base if base is not None else valley
if network_union is not None:
coverage = network_union.intersection(polygon).length / max(network_union.length, 1.0)
if coverage < CLOSING_COVERAGE_GOAL:
# 계곡 영역이 못 덮은 상류망만 버퍼로 보정한다(최후 폴백).
covered = polygon.union(network_union.buffer(STREAM_COVER_BUFFER_M)).buffer(0)
if covered.geom_type == "MultiPolygon":
covered = max(covered.geoms, key=lambda part: part.area)
if covered.geom_type == "Polygon" and not covered.is_empty:
polygon = covered
# 마지막에 도로선으로 절단해 하류측을 제거한다 — 도로가 유역의 한쪽 경계.
polygon = _clip_to_uphill(polygon, road_line, signs[position], contour_index, network_union)
# 메인 유역 밖(또는 상류망 없음) 관은 소범위 유역을 별도 생성한다.
polygon = _local_polygon(
vertices, divides, dividers, position, contour_index, road_line, signs[position]
)
if polygon is None or polygon.area < MIN_BASIN_AREA_M2:
continue
outlet_z = contour_index.nearest_elevation(outlet, UPHILL_PROBE_RADIUS_M)
if outlet_z is None:
outlet_z = _interpolate_vertex(vertices, candidate.chainage_m)[2]
# 표고차는 등고선만으로 계산한다(표고점 미참조 — 사용자 지시).
top_z = contour_index.max_elevation_within(polygon) or outlet_z
boundary_line = polygon.simplify(5.0, preserve_topology=True)
if boundary_line.is_empty or boundary_line.geom_type != "Polygon":
boundary_line = polygon
# 도로변 경계는 단순화 없이 도로선 해상도를 유지한다.
boundary_line = _refine_road_edge(boundary_line, road_line)
boundary = [[float(x), float(y)] for x, y in boundary_line.exterior.coords]
if flow_length <= 0.0:
flow_length = max(
(math.dist((candidate.x, candidate.y), point) for point in boundary),
default=0.0,
basins.append(
_basin_from_polygon(
polygon,
candidate,
len(basins) + 1,
flows[position],
contour_index,
road_line,
vertices,
simplify=not from_subdivision,
)
basin = WatershedBasin(
index=len(basins) + 1,
chainage_m=candidate.chainage_m,
outlet_x=candidate.x,
outlet_y=candidate.y,
boundary_xy=boundary,
area_m2=float(polygon.area),
relief_m=max(0.0, float(top_z) - float(outlet_z)),
flow_length_m=float(flow_length),
)
basin.pipe_diameter_mm = estimate_pipe_diameter_mm(
basin.area_m2, basin.relief_m, basin.flow_length_m
)
basins.append(basin)
return basins
@@ -0,0 +1,205 @@
"""배수유역 폴리곤 폐합 조립 — 도로 구간 + 분할선 + 등고선 아크 (700줄 분리, 2026-07-30).
`B05_wf2_Route_Engine_Drainage_Watershed.py`에서 산식 변경 없이 그대로 옮겨온
개선 1안(등거리+체인) 폐합 헬퍼 모음이다. 불변 조건: 산식 수정 금지(메인 유역
경계가 바뀐다 — 2026-07-30 사용자 지시).
"""
from __future__ import annotations
from typing import Any
from shapely.geometry import LineString, Point, Polygon
from shapely.ops import nearest_points, substring
from B05_wf2_Route.B05_wf2_Route_Engine_Drainage import _interpolate_vertex
from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Trace import ContourIndex, DividerStep
# 폐합 등고선 기하 탐색: 분할선 경로와 등고선의 근접 허용 거리(m).
CLOSING_SEARCH_M = 30.0
def _road_segment_coords(
vertices: list[Any], start_m: float, end_m: float
) -> list[tuple[float, float]]:
"""분할점 사이 도로 구간의 평면 좌표열(유역 폴리곤의 하측 경계)."""
sx, sy, _ = _interpolate_vertex(vertices, start_m)
ex, ey, _ = _interpolate_vertex(vertices, end_m)
coords = [(sx, sy)]
coords.extend(
(vertex.x, vertex.y) for vertex in vertices if start_m < vertex.chainage_m < end_m
)
coords.append((ex, ey))
return coords
def _contour_arc(
line: Any,
p_from: Point,
p_to: Point,
road_line: LineString,
network_union: Any = None,
) -> list[tuple[float, float]]:
"""등고선에서 두 분할선 접점 사이 아크(상측 경계)를 뽑는다.
폐합 등고선은 두 방향 아크가 생기므로, 세류 상류망을 가로지르지 않고(계곡을
자르지 않고) 도로와도 교차하지 않는(=산측) 쪽을 고른다.
"""
t1, t2 = sorted((line.project(p_from), line.project(p_to)))
arcs = []
inner = substring(line, t1, t2)
if inner.geom_type == "LineString" and len(inner.coords) >= 2:
arcs.append(inner)
if getattr(line, "is_closed", False):
head = substring(line, t2, line.length)
tail = substring(line, 0.0, t1)
coords = list(head.coords) + list(tail.coords)[1:]
if len(coords) >= 2:
arcs.append(LineString(coords))
if not arcs:
return []
scored = []
for arc in arcs:
crosses_stream = bool(network_union is not None and arc.crosses(network_union))
crosses_road = arc.crosses(road_line)
midpoint = arc.interpolate(0.5, normalized=True)
scored.append((crosses_stream, crosses_road, -road_line.distance(midpoint), arc))
scored.sort(key=lambda item: (item[0], item[1], item[2]))
arc = scored[0][3]
coords = list(arc.coords)
if Point(coords[0]).distance(p_from) > Point(coords[-1]).distance(p_from):
coords.reverse()
return [(float(x), float(y)) for x, y in coords]
def _junction(
left: list[DividerStep],
right: list[DividerStep],
min_z: float | None = None,
) -> tuple[int, int, int] | None:
"""두 분할선이 같은 등고선 지오메트리를 밟은 폐합 지점(좌 idx, 우 idx, geom idx).
min_z(세류 상류망 최고 표고)가 있으면 그 **이상인 가장 낮은** 공통 등고선을 고른다 —
"세류로 영역을 지정한 뒤 가까운 등고선으로 바로 올려치면 안 된다"(2026-07-29 사용자
지시). 계곡 발원부를 넘긴 첫 등고선이 유역 상측 경계가 된다. 없으면 최고 공통 등고선.
"""
left_keys = {
(step.z, step.geom_index): position
for position, step in enumerate(left)
if step.geom_index >= 0
}
matches: list[tuple[float, int, int, int]] = []
for position, step in enumerate(right):
if step.geom_index < 0:
continue
left_position = left_keys.get((step.z, step.geom_index))
if left_position is None:
continue
matches.append((step.z, left_position, position, step.geom_index))
if not matches:
return None
if min_z is not None:
above = [match for match in matches if match[0] >= min_z]
if above:
best = min(above)
return best[1], best[2], best[3]
best = max(matches)
return best[1], best[2], best[3]
def _closing_contour(
left: list[DividerStep],
right: list[DividerStep],
contour_index: ContourIndex,
min_z: float,
) -> tuple[int, int, int, Point, Point] | None:
"""두 분할선 경로에 모두 근접한 등고선 중 min_z 이상 최저를 찾는다.
분할선이 같은 스텝에서 같은 지오메트리를 밟지 못해도(도엽 분할 등) 계곡 발원부
위를 지나는 폐합 등고선을 기하적으로 찾아낸다.
반환: (좌 절단 idx, 우 절단 idx, 등고선 geom idx, 좌 접점, 우 접점).
"""
if len(left) < 2 or len(right) < 2:
return None
left_line = LineString([step.point for step in left])
right_line = LineString([step.point for step in right])
shared = set(contour_index.query(left_line.buffer(CLOSING_SEARCH_M))) & set(
contour_index.query(right_line.buffer(CLOSING_SEARCH_M))
)
best: tuple[float, int] | None = None
for index in shared:
z = contour_index.zs[index]
if z < min_z:
continue
geom = contour_index.geoms[index]
if (
geom.distance(left_line) > CLOSING_SEARCH_M
or geom.distance(right_line) > CLOSING_SEARCH_M
):
continue
if best is None or z < best[0]:
best = (z, index)
if best is None:
return None
geom = contour_index.geoms[best[1]]
left_touch = nearest_points(geom, left_line)[0]
right_touch = nearest_points(geom, right_line)[0]
left_position = min(range(len(left)), key=lambda i: left[i].point.distance(left_touch))
right_position = min(range(len(right)), key=lambda i: right[i].point.distance(right_touch))
return left_position, right_position, best[1], left_touch, right_touch
def _assemble_polygon(
vertices: list[Any],
start_m: float,
end_m: float,
left: list[DividerStep],
right: list[DividerStep],
contour_index: ContourIndex,
road_line: LineString,
network_union: Any = None,
valley_top_z: float | None = None,
) -> Polygon | None:
"""도로 구간 + 우측 분할선 + 상측 등고선 아크 + 좌측 분할선으로 폴리곤을 폐합한다.
세류 유역(valley_top_z 지정)은 발원부 위를 지나는 폐합 등고선을 기하 탐색으로
먼저 찾고, 실패 시 같은 스텝 매칭(_junction)으로 폐합한다.
"""
ring = _road_segment_coords(vertices, start_m, end_m)
left_used, right_used, arc = left, right, []
closure = (
_closing_contour(left, right, contour_index, valley_top_z)
if valley_top_z is not None
else None
)
if closure is not None:
left_position, right_position, geom_index, left_touch, right_touch = closure
left_used = left[: left_position + 1]
right_used = right[: right_position + 1]
arc = _contour_arc(
contour_index.geoms[geom_index], right_touch, left_touch, road_line, network_union
)
else:
junction = _junction(left, right, min_z=valley_top_z)
if junction is not None:
left_position, right_position, geom_index = junction
left_used = left[: left_position + 1]
right_used = right[: right_position + 1]
arc = _contour_arc(
contour_index.geoms[geom_index],
right_used[-1].point,
left_used[-1].point,
road_line,
network_union,
)
ring.extend((step.point.x, step.point.y) for step in right_used[1:])
ring.extend(arc)
ring.extend((step.point.x, step.point.y) for step in reversed(left_used[1:]))
if len(ring) < 4:
return None
polygon = Polygon(ring).buffer(0)
if polygon.geom_type == "MultiPolygon":
polygon = max(polygon.geoms, key=lambda part: part.area)
if polygon.is_empty or polygon.geom_type != "Polygon":
return None
return polygon
@@ -0,0 +1,171 @@
"""메인 배수유역 내부 세분화 — 분할선으로 폴리곤을 쪼갠다 (2026-07-30).
불변 조건(사용자 지시): 전체(메인) 배수유역 경계는 절대 변경하지 않는다.
세분화는 확정된 메인 유역 폴리곤을 관 사이 분할선(물갈림 고개에서 오르는
능선 근사선)으로 **내부에서만** 쪼개는 방식이다 — 외곽 재추적 금지.
따라서 세부유역은 서로 배타적이고 합집합은 항상 메인 유역과 동일하다.
"""
from __future__ import annotations
import logging
import math
from shapely.geometry import LineString, Point, Polygon
from shapely.ops import split as shapely_split
from shapely.ops import substring, unary_union
from B05_wf2_Route.B05_wf2_Route_Engine_Watershed_Trace import DividerStep
logger = logging.getLogger(__name__)
# 분할 절단선 연장: 도로 하류측(m)과 능선 너머(폴리곤 대각선 배수).
CUT_ROAD_TAIL_M = 40.0
CUT_RIDGE_EXTEND_RATIO = 1.5
def _cut_line(
road_point: Point,
steps: list[DividerStep],
road_line: LineString,
main_polygon: Polygon,
) -> LineString | None:
"""분할선 스텝을 절단선으로 확장한다 — 도로 하류측과 능선 너머까지 관통.
split()은 절단선이 폴리곤 경계를 완전히 넘어야 동작하므로 양끝을 연장한다.
스텝이 없으면(등고선 공백) 도로 법선 직선으로 폴백한다.
"""
bounds = main_polygon.bounds
reach = CUT_RIDGE_EXTEND_RATIO * math.hypot(bounds[2] - bounds[0], bounds[3] - bounds[1])
points = [(road_point.x, road_point.y)]
points.extend((step.point.x, step.point.y) for step in steps if step.geom_index >= 0)
if len(points) < 2:
# 폴백: 도로 접선의 법선 방향으로 폴리곤을 관통하는 직선.
t = road_line.project(road_point)
a = road_line.interpolate(max(0.0, t - 5.0))
b = road_line.interpolate(min(road_line.length, t + 5.0))
dx, dy = b.x - a.x, b.y - a.y
norm = math.hypot(dx, dy)
if norm < 1e-6:
return None
nx, ny = -dy / norm, dx / norm
head = (road_point.x + nx * reach, road_point.y + ny * reach)
tail = (road_point.x - nx * reach, road_point.y - ny * reach)
return LineString([tail, (road_point.x, road_point.y), head])
# 능선 너머 연장: 마지막 진행 방향 유지.
(px, py), (qx, qy) = points[-2], points[-1]
dx, dy = qx - px, qy - py
norm = math.hypot(dx, dy)
if norm >= 1e-6:
points.append((qx + dx / norm * reach, qy + dy / norm * reach))
# 도로 하류측 연장: 첫 스텝 → 도로점 방향을 그대로 지나쳐 내려간다.
(fx, fy) = points[1]
dx, dy = road_point.x - fx, road_point.y - fy
norm = math.hypot(dx, dy)
if norm >= 1e-6:
points.insert(
0,
(
road_point.x + dx / norm * CUT_ROAD_TAIL_M,
road_point.y + dy / norm * CUT_ROAD_TAIL_M,
),
)
return LineString(points)
def _interval_index(piece: Polygon, road_line: LineString, divide_ts: list[float]) -> int:
"""조각이 어느 관 구간(k)에 속하는지 — 구간 도로와 맞닿는 길이가 최대인 곳.
대표점 투영은 대형 계곡 조각에서 오판한다(상류로 길게 뻗은 조각의 대표점이
엉뚱한 구간에 떨어짐). 도로 접촉이 전혀 없는 조각만 대표점 투영으로 폴백.
"""
strip = piece.buffer(1.0)
best_k, best_length = -1, 0.0
for k in range(len(divide_ts) - 1):
segment = substring(road_line, divide_ts[k], divide_ts[k + 1])
if segment.is_empty:
continue
length = segment.intersection(strip).length
if length > best_length:
best_k, best_length = k, length
if best_k >= 0:
return best_k
t = road_line.project(piece.representative_point())
for k in range(len(divide_ts) - 1):
if divide_ts[k] <= t <= divide_ts[k + 1]:
return k
return 0 if t < divide_ts[0] else len(divide_ts) - 2
def subdivide_main_polygon(
main_polygon: Polygon,
divide_points: list[Point],
dividers: list[list[DividerStep]],
road_line: LineString,
) -> list[Polygon | None]:
"""메인 유역 폴리곤을 내부 분할선으로 쪼개 관 구간별 조각을 돌려준다.
반환 길이 = 관 개수(구간 수). 조각이 없는 구간은 None.
split() 기반이므로 조각들은 배타적이고 합집합 == 메인 폴리곤이 보장된다.
구간에 여러 조각이 잡히면(절단선 재진입) 모두 합쳐 가장 큰 폴리곤을 쓴다.
"""
interval_count = len(divide_points) - 1
if interval_count <= 1:
return [main_polygon]
pieces: list[Polygon] = [main_polygon]
for position in range(1, interval_count):
cut = _cut_line(divide_points[position], dividers[position], road_line, main_polygon)
if cut is None:
logger.warning("분할 절단선 생성 실패(구간 %d) — 해당 분할을 건너뜁니다.", position)
continue
next_pieces: list[Polygon] = []
for piece in pieces:
try:
parts = shapely_split(piece, cut)
except Exception: # noqa: BLE001 - 절단 실패 시 조각 유지
next_pieces.append(piece)
continue
split_parts = [
part
for part in getattr(parts, "geoms", [parts])
if part.geom_type == "Polygon" and not part.is_empty
]
next_pieces.extend(split_parts if split_parts else [piece])
pieces = next_pieces
divide_ts = [road_line.project(point) for point in divide_points]
assigned: list[list[Polygon]] = [[] for _ in range(interval_count)]
for piece in pieces:
assigned[_interval_index(piece, road_line, divide_ts)].append(piece)
# 구간별 대표 조각 = 최대 조각과 그에 붙는 조각들. 비연결 잔여 조각은 버리지
# 않고(합집합 불변 조건) 맞닿는 인접 구간으로 재배정한다.
result: list[Polygon | None] = []
leftovers: list[Polygon] = []
for group in assigned:
merged = _merge_touching(group)
result.append(merged[0] if merged else None)
leftovers.extend(merged[1:])
for extra in leftovers:
for position in sorted(
range(interval_count),
key=lambda k: extra.distance(result[k]) if result[k] is not None else math.inf,
):
base = result[position]
if base is None or not extra.touches(base):
continue
candidate = base.union(extra).buffer(0)
if candidate.geom_type == "Polygon":
result[position] = candidate
break
else:
logger.warning("세분화 잔여 조각(%.0f m²)을 재배정하지 못해 제외합니다.", extra.area)
return result
def _merge_touching(group: list[Polygon]) -> list[Polygon]:
"""조각 묶음을 서로 맞닿는 것끼리 합쳐 면적 내림차순으로 돌려준다."""
if not group:
return []
merged = unary_union(group).buffer(0)
parts = list(merged.geoms) if merged.geom_type == "MultiPolygon" else [merged]
parts = [part for part in parts if part.geom_type == "Polygon" and not part.is_empty]
return sorted(parts, key=lambda part: part.area, reverse=True)
@@ -206,10 +206,17 @@ async def post_drainage_basins(
"status": "success",
"project_id": str(project_id),
"route_id": prepared["route_id"],
# 계획선 위 배관(관 매설) 지점 — 유역이 없는 관도 마커로 표시해야 하므로 별도 목록.
"pipes": [
_candidate_payload(candidate, to_lonlat)
for candidate in sorted(candidates, key=lambda item: item.chainage_m)
],
"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),
@@ -24,6 +24,7 @@ import {
type DrainageBasin,
type RoutePoint,
} from "./B05_wf2_Route_Api_Fetch";
import { createPipeEditor } from "./B05_wf2_Route_UI_Drainage_Pipes";
// 배수유역도 패널 — 하단 종단 패널 안쪽 우측에 도킹되는 2단 사이드 패널.
// 하단 패널이 닫히면 이 패널도 함께 화면에서 사라진다(부모 안에 들어 있으므로 자동).
@@ -88,7 +89,24 @@ export function createDrainagePanel(): DrainagePanel {
analyzeButton.type = "button";
analyzeButton.className = "b05-drainage__analyze";
analyzeButton.textContent = "유역 산정";
header.append(analyzeButton);
// 배관 편집 토글 — 켜면 계획선 클릭으로 배관 추가, 마커 드래그로 이동.
const editButton = document.createElement("button");
editButton.type = "button";
editButton.className = "b05-drainage__analyze b05-drainage__tool";
editButton.textContent = "배관 편집";
editButton.setAttribute("aria-pressed", "false");
// 선택된 배관 삭제 — 편집 모드에서 마커를 선택해야 활성화된다.
const deleteButton = document.createElement("button");
deleteButton.type = "button";
deleteButton.className = "b05-drainage__analyze b05-drainage__tool";
deleteButton.textContent = "선택 삭제";
deleteButton.disabled = true;
// 자동 제안으로 되돌리기 — 편집한 배관 배치를 버리고 백엔드 자동 제안으로 재산정.
const autoButton = document.createElement("button");
autoButton.type = "button";
autoButton.className = "b05-drainage__analyze b05-drainage__tool";
autoButton.textContent = "자동 제안";
header.append(analyzeButton, editButton, deleteButton, autoButton);
const viewport = document.createElement("div");
viewport.className = "b05-drainage__viewport";
@@ -117,6 +135,12 @@ export function createDrainagePanel(): DrainagePanel {
let normalizer: Normalizer | null = null;
let basins: DrainageBasin[] = [];
let selectedBasin: number | null = null;
let editMode = false;
// 배관 편집기 — 마커 선택/추가/이동/삭제 시 재그리기와 버튼 상태만 갱신한다.
const pipeEditor = createPipeEditor(() => {
syncPipeSelection();
scheduleDraw();
});
// 유역 경계 외곽선 = 분수령(능선). 사용자 지시로 기본 표시.
let showRidge = true;
let scale = 1;
@@ -217,9 +241,38 @@ export function createDrainagePanel(): DrainagePanel {
context.strokeStyle = ROUTE_COLOR;
drawPreparedLayer(context, routeLayer, view, "dot");
}
// 배관(관 매설) 마커 — 계획선 위 최상단.
pipeEditor.draw(context, view, pipeColor);
updateImageTransform();
}
/** 현재 프레임 뷰 상태 (draw()와 동일 계산 — 포인터 히트 판정용). */
function currentView(): ViewState {
const rect = viewport.getBoundingClientRect();
const width = Math.max(1, Math.floor(rect.width));
const height = Math.max(1, Math.floor(rect.height));
return { width, height, scale, offsetX, offsetY, mapRect: computeMapRect(meta, width, height) };
}
/** 배관 마커 색 — 같은 누가거리 유역의 파스텔색(불투명). 유역이 없으면 회색. */
function pipeColor(chainage: number): string {
const basin = basins.find((item) => Math.abs(item.chainage_m - chainage) < 0.51);
if (!basin) return "#e5e7eb";
return BASIN_COLORS[(basin.index - 1) % BASIN_COLORS.length].replace(/0\.45\)$/, "1)");
}
/** 마커 선택 ↔ 유역 목록 선택 동기화 + 삭제 버튼 활성화. */
function syncPipeSelection(): void {
const index = pipeEditor.selected();
deleteButton.disabled = !editMode || index === null;
const pipe = index === null ? null : pipeEditor.pipes()[index];
const basin = pipe
? basins.find((item) => Math.abs(item.chainage_m - pipe.chainage_m) < 0.51)
: null;
selectedBasin = basin ? basin.index : null;
renderBasinList();
}
function scheduleDraw(): void {
if (frameHandle) return;
frameHandle = window.requestAnimationFrame(() => {
@@ -263,17 +316,27 @@ export function createDrainagePanel(): DrainagePanel {
return areaM2 >= 10000 ? `${(areaM2 / 10000).toFixed(2)}ha` : `${Math.round(areaM2)}㎡`;
}
/** 구조물 측점 후보 제안 + 유역 산정을 백엔드에 요청한다(계산은 전부 백엔드). */
async function analyze(): Promise<void> {
/** 구조물 측점 후보 제안 + 유역 산정을 백엔드에 요청한다(계산은 전부 백엔드).
* 편집된 배관이 있으면 그 누가거리로 확정 산정하고, auto=true면 자동 제안으로 되돌린다. */
async function analyze(auto = false): Promise<void> {
if (!projectId) return;
analyzeButton.disabled = true;
status.hidden = false;
status.textContent = "배수유역을 산정하는 중…";
try {
const response = await fetchDrainageBasins(projectId);
const chainages = !auto && pipeEditor.pipes().length > 0 ? pipeEditor.chainages() : undefined;
const response = await fetchDrainageBasins(projectId, chainages);
basins = response.basins;
// 실제 사용된 배관 목록으로 마커를 동기화한다(유역 없는 관 포함).
pipeEditor.setPipes(
(response.pipes ?? []).map((pipe) => ({
chainage_m: pipe.chainage_m,
reason: pipe.reason,
})),
);
selectedBasin = null;
renderBasinList();
syncPipeSelection();
status.hidden = basins.length > 0;
if (basins.length === 0) status.textContent = "산정된 배수유역이 없습니다.";
scheduleDraw();
@@ -286,6 +349,17 @@ export function createDrainagePanel(): DrainagePanel {
}
analyzeButton.addEventListener("click", () => void analyze());
editButton.addEventListener("click", () => {
editMode = !editMode;
editButton.classList.toggle("is-active", editMode);
editButton.setAttribute("aria-pressed", String(editMode));
syncPipeSelection();
});
deleteButton.addEventListener("click", () => void pipeEditor.deleteSelected());
autoButton.addEventListener("click", () => {
pipeEditor.setPipes([]);
void analyze(true);
});
/** 노선 전체가 보이도록 배율·중심을 맞춘다. 노선이 없으면 도엽 전체를 그대로 보여준다. */
function fitToRoute(): void {
@@ -351,6 +425,7 @@ export function createDrainagePanel(): DrainagePanel {
});
backgroundImage.src = `${getVWorldMapUrl(activeProjectId, "satellite")}&_t=${Date.now()}`;
if (routePoints.length > 1) routeLayer = prepareMetricPolyline(routePoints, nextMeta);
pipeEditor.setContext(nextMeta, routePoints);
status.hidden = featureCount > 0;
if (featureCount === 0) status.textContent = "도엽 레이어가 없습니다. B04에서 임포트하세요.";
fitToRoute();
@@ -382,16 +457,34 @@ export function createDrainagePanel(): DrainagePanel {
viewport.addEventListener("pointerdown", (event) => {
// 중간 버튼 기본 동작(페이지 자동 스크롤)이 지도 팬과 겹치지 않게 막는다.
if (event.button === 1) event.preventDefault();
const rect = viewport.getBoundingClientRect();
// 배관 마커 클릭/추가가 처리되면 지도 팬은 시작하지 않는다.
if (
pipeEditor.handleDown(
currentView(),
event.clientX - rect.left,
event.clientY - rect.top,
editMode,
)
) {
viewport.setPointerCapture(event.pointerId);
return;
}
dragStart = { x: event.clientX, y: event.clientY, offsetX, offsetY };
viewport.setPointerCapture(event.pointerId);
});
viewport.addEventListener("pointermove", (event) => {
const rect = viewport.getBoundingClientRect();
// 배관 드래그 중이면 마커 이동(계획선 스냅)만 처리한다.
if (pipeEditor.handleMove(currentView(), event.clientX - rect.left, event.clientY - rect.top))
return;
if (!dragStart) return;
offsetX = dragStart.offsetX + event.clientX - dragStart.x;
offsetY = dragStart.offsetY + event.clientY - dragStart.y;
scheduleDraw();
});
const stopDragging = (): void => {
pipeEditor.handleUp();
dragStart = null;
};
viewport.addEventListener("pointerup", stopDragging);
@@ -421,6 +514,7 @@ export function createDrainagePanel(): DrainagePanel {
setRoute(points) {
routePoints = points;
routeLayer = meta && points.length > 1 ? prepareMetricPolyline(points, meta) : null;
pipeEditor.setContext(meta, points);
if (routeLayer) fitToRoute();
scheduleDraw();
},
@@ -0,0 +1,246 @@
import type { VWorldMeta } from "../B04_wf1_Surface/B04_wf1_Surface_Api_Fetch";
import type { ViewState } from "../B04_wf1_Surface/B04_wf1_Surface_UI_MapRender";
// 배관(관 매설) 지점 편집기 — 배수유역 패널의 계획선 위 마커 표시·추가·이동·삭제.
// 마커 위치의 단일 소스는 누가거리(chainage)다. 화면 좌표는 매 프레임 노선
// 폴리라인(사업지 좌표계 m)을 따라 보간해 구하므로 확대/이동과 무관하게 정확하다.
/** 배관 지점 1개. reason: stream(세류 교차)/spacing(300m 보충)/confirmed(사용자 확정). */
export interface PipePoint {
chainage_m: number;
reason: string;
}
interface RoutePointLike {
x: number;
y: number;
chainage_m?: number;
}
/** 마커 히트 판정 반경(px)과 계획선 추가 클릭 허용 거리(px). */
const HIT_RADIUS_PX = 12;
const ADD_SNAP_PX = 14;
export interface PipeEditor {
setContext(meta: VWorldMeta | null, points: ReadonlyArray<RoutePointLike>): void;
setPipes(pipes: ReadonlyArray<PipePoint>): void;
pipes(): ReadonlyArray<PipePoint>;
chainages(): number[];
selected(): number | null;
select(index: number | null): void;
deleteSelected(): boolean;
/** 편집 상호작용. 처리했으면 true(패널은 지도 팬을 생략한다). */
handleDown(view: ViewState, screenX: number, screenY: number, editMode: boolean): boolean;
handleMove(view: ViewState, screenX: number, screenY: number): boolean;
handleUp(): boolean;
draw(
context: CanvasRenderingContext2D,
view: ViewState,
colorOf: (chainage: number, position: number) => string,
): void;
}
export function createPipeEditor(onChange: () => void): PipeEditor {
let meta: VWorldMeta | null = null;
let route: Array<{ x: number; y: number; chainage: number }> = [];
let totalChainage = 0;
let pipeList: PipePoint[] = [];
let selectedIndex: number | null = null;
let draggingIndex: number | null = null;
let dragMoved = false;
/** 화면 → 사업지 좌표계 m (MapRender affine의 역변환). */
function screenToMetric(
view: ViewState,
sx: number,
sy: number,
): { x: number; y: number } | null {
if (!meta) return null;
const ax = view.mapRect.width * view.scale;
const bx = view.mapRect.x * view.scale + (view.width / 2) * (1 - view.scale) + view.offsetX;
const ay = view.mapRect.height * view.scale;
const by = view.mapRect.y * view.scale + (view.height / 2) * (1 - view.scale) + view.offsetY;
if (!ax || !ay) return null;
const nx = (sx - bx) / ax;
const ny = (sy - by) / ay;
return {
x: meta.x_min + nx * (meta.width_meters || 1),
y: meta.y_min + (1 - ny) * (meta.height_meters || 1),
};
}
function metricToScreen(view: ViewState, x: number, y: number): { x: number; y: number } | null {
if (!meta) return null;
const nx = (x - meta.x_min) / (meta.width_meters || 1);
const ny = 1 - (y - meta.y_min) / (meta.height_meters || 1);
const ax = view.mapRect.width * view.scale;
const bx = view.mapRect.x * view.scale + (view.width / 2) * (1 - view.scale) + view.offsetX;
const ay = view.mapRect.height * view.scale;
const by = view.mapRect.y * view.scale + (view.height / 2) * (1 - view.scale) + view.offsetY;
return { x: nx * ax + bx, y: ny * ay + by };
}
/** 1m가 화면에서 몇 px인지 (거리 판정용). */
function pxPerMeter(view: ViewState): number {
if (!meta) return 1;
return (view.mapRect.width * view.scale) / (meta.width_meters || 1);
}
function chainageToXY(chainage: number): { x: number; y: number } | null {
if (route.length < 2) return null;
if (chainage <= route[0].chainage) return { x: route[0].x, y: route[0].y };
for (let i = 1; i < route.length; i += 1) {
const prev = route[i - 1];
const next = route[i];
if (chainage > next.chainage) continue;
const span = next.chainage - prev.chainage || 1;
const t = (chainage - prev.chainage) / span;
return { x: prev.x + (next.x - prev.x) * t, y: prev.y + (next.y - prev.y) * t };
}
const last = route[route.length - 1];
return { x: last.x, y: last.y };
}
/** 사업지 좌표에서 노선 최근접 지점의 누가거리와 이탈 거리(m). */
function nearestChainage(x: number, y: number): { chainage: number; distance: number } | null {
if (route.length < 2) return null;
let best: { chainage: number; distance: number } | null = null;
for (let i = 1; i < route.length; i += 1) {
const a = route[i - 1];
const b = route[i];
const dx = b.x - a.x;
const dy = b.y - a.y;
const lengthSq = dx * dx + dy * dy || 1;
const t = Math.max(0, Math.min(1, ((x - a.x) * dx + (y - a.y) * dy) / lengthSq));
const px = a.x + dx * t;
const py = a.y + dy * t;
const distance = Math.hypot(x - px, y - py);
const chainage = a.chainage + (b.chainage - a.chainage) * t;
if (!best || distance < best.distance) best = { chainage, distance };
}
return best;
}
function sortPipes(): void {
const selected = selectedIndex === null ? null : pipeList[selectedIndex];
pipeList.sort((a, b) => a.chainage_m - b.chainage_m);
selectedIndex = selected === null ? null : pipeList.indexOf(selected);
}
return {
setContext(nextMeta, points) {
meta = nextMeta;
let cumulative = 0;
route = points.map((point, index) => {
if (index > 0) {
const prev = points[index - 1];
cumulative += Math.hypot(point.x - prev.x, point.y - prev.y);
}
return { x: point.x, y: point.y, chainage: point.chainage_m ?? cumulative };
});
totalChainage = route.length > 0 ? route[route.length - 1].chainage : 0;
},
setPipes(pipes) {
pipeList = pipes.map((pipe) => ({ ...pipe }));
sortPipes();
selectedIndex = null;
draggingIndex = null;
},
pipes: () => pipeList,
chainages: () => pipeList.map((pipe) => Math.round(pipe.chainage_m * 100) / 100),
selected: () => selectedIndex,
select(index) {
selectedIndex = index;
},
deleteSelected() {
if (selectedIndex === null) return false;
pipeList.splice(selectedIndex, 1);
selectedIndex = null;
onChange();
return true;
},
handleDown(view, screenX, screenY, editMode) {
dragMoved = false;
// 마커 클릭: 선택 (편집 모드 여부 무관), 편집 모드면 드래그 시작.
for (let i = pipeList.length - 1; i >= 0; i -= 1) {
const xy = chainageToXY(pipeList[i].chainage_m);
if (!xy) continue;
const screen = metricToScreen(view, xy.x, xy.y);
if (!screen) continue;
if (Math.hypot(screenX - screen.x, screenY - screen.y) <= HIT_RADIUS_PX) {
selectedIndex = i;
if (editMode) draggingIndex = i;
onChange();
return true;
}
}
if (!editMode) return false;
// 계획선 클릭: 그 지점에 배관 추가.
const metric = screenToMetric(view, screenX, screenY);
if (!metric) return false;
const nearest = nearestChainage(metric.x, metric.y);
if (!nearest || nearest.distance * pxPerMeter(view) > ADD_SNAP_PX) return false;
pipeList.push({ chainage_m: nearest.chainage, reason: "confirmed" });
sortPipes();
selectedIndex = pipeList.findIndex(
(pipe) => Math.abs(pipe.chainage_m - nearest.chainage) < 1e-6,
);
draggingIndex = selectedIndex;
onChange();
return true;
},
handleMove(view, screenX, screenY) {
if (draggingIndex === null) return false;
const metric = screenToMetric(view, screenX, screenY);
if (!metric) return true;
const nearest = nearestChainage(metric.x, metric.y);
if (!nearest) return true;
const clamped = Math.max(0, Math.min(totalChainage, nearest.chainage));
pipeList[draggingIndex].chainage_m = clamped;
pipeList[draggingIndex].reason = "confirmed";
dragMoved = true;
onChange();
return true;
},
handleUp() {
if (draggingIndex === null) return false;
draggingIndex = null;
if (dragMoved) {
sortPipes();
onChange();
}
return true;
},
draw(context, view, colorOf) {
pipeList.forEach((pipe, position) => {
const xy = chainageToXY(pipe.chainage_m);
if (!xy) return;
const screen = metricToScreen(view, xy.x, xy.y);
if (!screen) return;
const isSelected = position === selectedIndex;
const radius = isSelected ? 9 : 7;
context.beginPath();
context.arc(screen.x, screen.y, radius, 0, Math.PI * 2);
context.fillStyle = colorOf(pipe.chainage_m, position);
context.fill();
context.lineWidth = isSelected ? 2.5 : 1.5;
context.strokeStyle = isSelected ? "#111827" : "#374151";
context.stroke();
context.fillStyle = "#111827";
context.font = "bold 10px sans-serif";
context.textAlign = "center";
context.textBaseline = "middle";
context.fillText(String(position + 1), screen.x, screen.y);
// 누가거리 라벨 — 마커 우상단.
context.font = "10px sans-serif";
context.textAlign = "left";
context.fillStyle = "#1f2937";
context.fillText(
`${pipe.chainage_m.toFixed(0)}m`,
screen.x + radius + 3,
screen.y - radius,
);
});
},
};
}
+15
View File
@@ -948,6 +948,21 @@
cursor: default;
}
/* 배관 편집 도구 버튼 — "유역 산정" 우측에 나란히(auto 마진 해제). */
.b05-drainage__tool {
margin-left: var(--spacing-4, 4px);
}
/* 배관 편집 토글 활성 상태. */
.b05-drainage__analyze.is-active {
background: color-mix(
in srgb,
var(--color-royal-amethyst, rgb(109 40 217)) 18%,
var(--color-surface)
);
border-color: var(--color-royal-amethyst, rgb(109 40 217));
}
/* 유역 제원 목록 — 면적·유역표고·유하거리·관경(수식 확정 전까지 "미정"). */
.b05-drainage__basins {
display: flex;