feat(B04): 상세 배수유역 계산 — 관 매설 지점 편집 + 세부유역 분할
관 매설 지점을 기준으로 세부 배수유역을 나누는 기능을 B04 2D 지도에 추가한다.
경계는 B04 전처리 격자(03_road_routing.npz)의 road_slot — 1m 셀마다 물이 도달하는
도로 셀 — 을 담당 관으로 라벨링해 그 경계로 잡는다. 종단 Z는 경계를 긋지 않고
"도로 셀이 어느 관으로 흐르는가"만 정한다.
공용 승격 (B04 관리자 화면과 B05 사용자 화면이 같은 결과를 내야 함)
- common_util_drainage_detail.py: 관 보충(9)·세부유역 분할(10) 알고리즘
- common_util_drainage_context.py: 노선·종단 Z·좌표계 입력 준비
- common_util_drainage_pipes.py: 관 지점 정본 저장소(edits/pipe_points.json)
- common_util_route_profile.py: 종단 Z 해석기(계획고 > 경로 정점 > 지표면 > CSV)
- common_util_surface_sampler.py: B05 종횡단 sampler 이동
- B05 _prepare()의 노선 소스를 원청 계획노선 CSV로 정정(B04 격자와 누가거리 정합)
B04 신규 API
- GET /{project_id}/drainage/pipe-points 저장분 조회(없으면 자동 생성)
- POST /{project_id}/drainage/detail-basins 편집 중 목록으로 재분할(저장 안 함)
- PUT /{project_id}/drainage/pipe-points 모델 확정 시 관 지점·세부유역 커밋
B04 화면
- 관 마커 기본/자동/수동 색 구분, 계획선 스냅 드래그 이동
- 계획선 우클릭 "관 매설 추가" / 마커 우클릭 "관 매설 삭제"
- 표시 토글 2그룹(관 매설 / 세부 유역)을 유입 집중점과 분리
- "상세유역 분석" 버튼을 눌렀을 때만 재계산
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -431,3 +431,72 @@ export async function fetchRoadInflow(
|
||||
{ method: "GET" },
|
||||
);
|
||||
}
|
||||
|
||||
/* ── 상세 배수유역(관 매설 지점 + 세부유역 분할) ─────────────────────────── */
|
||||
|
||||
/** 관이 그 자리에 있는 이유. 백엔드 `common_util_drainage_pipes`가 정의처다. */
|
||||
export type PipeSource = "stream" | "spacing" | "user";
|
||||
|
||||
export interface DetailPipePoint {
|
||||
chainage_m: number;
|
||||
lonlat: [number, number];
|
||||
source: PipeSource;
|
||||
}
|
||||
|
||||
export interface DetailBasin {
|
||||
index: number;
|
||||
chainage_m: number;
|
||||
outlet_lonlat: [number, number];
|
||||
polygon_lonlat: Array<[number, number]>;
|
||||
area_m2: number;
|
||||
relief_m: number;
|
||||
flow_length_m: number;
|
||||
/** 관경 수식 미확정 — null이면 "미정"으로 표기한다. */
|
||||
pipe_diameter_mm: number | null;
|
||||
}
|
||||
|
||||
export interface DetailBasinResponse {
|
||||
status: string;
|
||||
project_id: string;
|
||||
/** 종단 Z 출처(design_profile / route_points / surface / csv). */
|
||||
z_source: string;
|
||||
route_length_m: number;
|
||||
max_spacing_m: number;
|
||||
min_spacing_m: number;
|
||||
/** 응답의 관 목록이 저장분에서 온 것인지. */
|
||||
saved: boolean;
|
||||
pipe_points: DetailPipePoint[];
|
||||
basins: DetailBasin[];
|
||||
pipe_count: number;
|
||||
}
|
||||
|
||||
/** 저장된 관 매설 지점과 그 세부유역. 저장분이 없으면 백엔드가 자동 생성해 돌려준다. */
|
||||
export async function fetchDetailPipePoints(projectId: string): Promise<DetailBasinResponse> {
|
||||
return requestJson<DetailBasinResponse>(`/projects/${projectId}/drainage/pipe-points`, {
|
||||
method: "GET",
|
||||
});
|
||||
}
|
||||
|
||||
/** 편집 중인 관 목록으로 세부유역을 다시 나눈다(저장하지 않는다).
|
||||
*
|
||||
* `points`를 비우면 저장분을 무시하고 기본 관 + 자동 보충으로 되돌린다. */
|
||||
export async function computeDetailBasins(
|
||||
projectId: string,
|
||||
points: Array<{ chainage_m: number; source: PipeSource }>,
|
||||
): Promise<DetailBasinResponse> {
|
||||
return requestJson<DetailBasinResponse>(`/projects/${projectId}/drainage/detail-basins`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ points }),
|
||||
});
|
||||
}
|
||||
|
||||
/** 관 매설 지점을 정본으로 확정하고 세부유역 산출물까지 남긴다(모델 확정 시점). */
|
||||
export async function saveDetailPipePoints(
|
||||
projectId: string,
|
||||
points: Array<{ chainage_m: number; source: PipeSource }>,
|
||||
): Promise<DetailBasinResponse> {
|
||||
return requestJson<DetailBasinResponse>(`/projects/${projectId}/drainage/pipe-points`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ points }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
"""상세 배수유역 API 라우터 (B04 — 관리자 검토용).
|
||||
|
||||
격자 해석은 하지 않는다. 배수유역 분석이 저장해 둔 `03_road_routing` 산출물을 읽어
|
||||
|
||||
· 기본 관(도로 × 상류 세류선 교차점) + 최대 간격 자동 보충으로 관 목록을 만들고
|
||||
· 계획노선 종단 Z로 노면 물이 어느 관으로 가는지 정해 세부유역을 나눈다
|
||||
|
||||
계산 알고리즘은 B05 사용자 화면과 공용이다(`common_util_drainage_detail`) — 같은 이름의
|
||||
버튼은 같은 결과를 내야 하기 때문이다(2026-08-01 사용자 지시).
|
||||
|
||||
편집분(관 지점)은 "이 모델 확정" 시점에만 파일로 남는다. 그 전까지는 화면 메모리에만 있고,
|
||||
확정 없이 나가면 저장된 값으로 되돌아온다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Export import drainage_dir
|
||||
from common_util.common_util_drainage_context import DrainageContext, load_drainage_context
|
||||
from common_util.common_util_drainage_detail import DrainageDetail, build_detail
|
||||
from common_util.common_util_drainage_pipes import (
|
||||
PIPE_SOURCE_USER,
|
||||
PipePoint,
|
||||
load_pipe_points,
|
||||
parse_pipe_points,
|
||||
route_signature,
|
||||
save_detail_basins,
|
||||
save_pipe_points,
|
||||
)
|
||||
from common_util.common_util_route_geometry import StructureCandidate
|
||||
from config.config_system import DRAINAGE_PIPE_MAX_SPACING_M, DRAINAGE_PIPE_MIN_SPACING_M
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/projects", tags=["B04 Surface Drainage Basins"])
|
||||
|
||||
_NO_ANALYSIS = "배수유역 분석 결과가 없습니다. [유역 분석]을 먼저 실행하세요."
|
||||
|
||||
|
||||
def _build(
|
||||
stored_path: str, context: DrainageContext, points: list[PipePoint] | None
|
||||
) -> tuple[DrainageDetail | None, list[PipePoint]]:
|
||||
"""세부유역을 계산하고, 그 결과에 쓰인 관 목록을 함께 돌려준다.
|
||||
|
||||
`points`가 없으면 B04 기본 관에 최대 간격 규칙으로 자동 보충한 목록이 만들어진다.
|
||||
있으면 그 자리를 그대로 쓰되, 관이 왜 거기 있는지(기본/자동/수동)는 넘겨받은 목록의
|
||||
표시를 유지한다 — 계산기는 확정 좌표만 보므로 그대로 두면 전부 "수동"이 된다.
|
||||
"""
|
||||
directory = drainage_dir(stored_path)
|
||||
chainages = [point.chainage_m for point in points] if points else None
|
||||
detail = build_detail(directory, context.vertices, chainages)
|
||||
if detail is None:
|
||||
return None, []
|
||||
if points:
|
||||
_retag(detail.pipes, points)
|
||||
return detail, [
|
||||
PipePoint(chainage_m=pipe.chainage_m, source=pipe.reason) for pipe in detail.pipes
|
||||
]
|
||||
|
||||
|
||||
def _retag(pipes: list[StructureCandidate], points: list[PipePoint]) -> None:
|
||||
"""확정 좌표로 되돌아온 관에 원래의 생성 사유를 다시 붙인다."""
|
||||
by_chainage = {round(point.chainage_m, 2): point.source for point in points}
|
||||
for pipe in pipes:
|
||||
source = by_chainage.get(round(pipe.chainage_m, 2))
|
||||
if source is None and points:
|
||||
nearest = min(points, key=lambda item: abs(item.chainage_m - pipe.chainage_m))
|
||||
source = nearest.source
|
||||
pipe.reason = source or PIPE_SOURCE_USER
|
||||
|
||||
|
||||
def _payload(
|
||||
project_id: UUID,
|
||||
context: DrainageContext,
|
||||
detail: DrainageDetail,
|
||||
points: list[PipePoint],
|
||||
saved: bool,
|
||||
) -> dict[str, Any]:
|
||||
"""관 목록과 세부유역을 화면 좌표(WGS84)로 정리한다."""
|
||||
to_lonlat = context.to_lonlat
|
||||
return {
|
||||
"status": "success",
|
||||
"project_id": str(project_id),
|
||||
# 종단 Z를 어디서 가져왔는지 — 관 담당 구간이 갈리는 근거라 화면에서 확인 가능해야 한다.
|
||||
"z_source": context.z_source,
|
||||
"route_length_m": round(context.vertices[-1].chainage_m, 2),
|
||||
"max_spacing_m": DRAINAGE_PIPE_MAX_SPACING_M,
|
||||
"min_spacing_m": DRAINAGE_PIPE_MIN_SPACING_M,
|
||||
"saved": saved,
|
||||
"pipe_points": [
|
||||
{
|
||||
"chainage_m": round(pipe.chainage_m, 2),
|
||||
"lonlat": list(to_lonlat(pipe.x, pipe.y)),
|
||||
"source": pipe.reason,
|
||||
}
|
||||
for pipe in detail.pipes
|
||||
],
|
||||
"basins": [
|
||||
{
|
||||
"index": basin.index,
|
||||
"chainage_m": round(basin.chainage_m, 2),
|
||||
"outlet_lonlat": list(to_lonlat(basin.outlet_x, basin.outlet_y)),
|
||||
"polygon_lonlat": [list(to_lonlat(x, y)) for x, y in basin.boundary_xy],
|
||||
"area_m2": round(basin.area_m2, 1),
|
||||
"relief_m": round(basin.relief_m, 2),
|
||||
"flow_length_m": round(basin.flow_length_m, 1),
|
||||
# 관경 수식 미확정 — None이면 프론트가 "미정"으로 표기한다.
|
||||
"pipe_diameter_mm": basin.pipe_diameter_mm,
|
||||
}
|
||||
for basin in detail.basins
|
||||
],
|
||||
"pipe_count": len(points),
|
||||
}
|
||||
|
||||
|
||||
def _basin_features(
|
||||
context: DrainageContext, detail: DrainageDetail, points: list[PipePoint]
|
||||
) -> list[dict[str, Any]]:
|
||||
"""세부유역을 저장용 GeoJSON 피처로 바꾼다(관 지점도 같은 파일에 함께 남긴다)."""
|
||||
to_lonlat = context.to_lonlat
|
||||
features: list[dict[str, Any]] = []
|
||||
for basin in detail.basins:
|
||||
ring = [list(to_lonlat(x, y)) for x, y in basin.boundary_xy]
|
||||
if len(ring) < 4:
|
||||
continue
|
||||
features.append(
|
||||
{
|
||||
"type": "Feature",
|
||||
"properties": {
|
||||
"kind": "detail_basin",
|
||||
"index": basin.index,
|
||||
"chainage_m": round(basin.chainage_m, 2),
|
||||
"area_m2": round(basin.area_m2, 1),
|
||||
"relief_m": round(basin.relief_m, 2),
|
||||
"flow_length_m": round(basin.flow_length_m, 1),
|
||||
"pipe_diameter_mm": basin.pipe_diameter_mm,
|
||||
},
|
||||
"geometry": {"type": "Polygon", "coordinates": [ring]},
|
||||
}
|
||||
)
|
||||
for pipe, point in zip(detail.pipes, points):
|
||||
features.append(
|
||||
{
|
||||
"type": "Feature",
|
||||
"properties": {
|
||||
"kind": "pipe",
|
||||
"chainage_m": round(point.chainage_m, 2),
|
||||
"source": point.source,
|
||||
},
|
||||
"geometry": {"type": "Point", "coordinates": list(to_lonlat(pipe.x, pipe.y))},
|
||||
}
|
||||
)
|
||||
return features
|
||||
|
||||
|
||||
async def _resolve(
|
||||
project_id: UUID, payload: dict[str, Any] | None, *, use_stored: bool
|
||||
) -> tuple[DrainageContext, DrainageDetail, list[PipePoint], bool] | JSONResponse:
|
||||
"""요청 본문 → 저장분 → 자동 생성 순으로 관 목록을 정하고 세부유역까지 계산한다."""
|
||||
context, reason = await load_drainage_context(project_id)
|
||||
if context is None:
|
||||
return JSONResponse(status_code=404, content={"status": "error", "message": reason})
|
||||
|
||||
requested = parse_pipe_points((payload or {}).get("points"))
|
||||
signature = route_signature(context.vertices)
|
||||
stored = load_pipe_points(context.stored_path, signature) if use_stored else None
|
||||
points = requested or stored or None
|
||||
|
||||
result = await asyncio.to_thread(_build, context.stored_path, context, points)
|
||||
detail, resolved = result
|
||||
if detail is None:
|
||||
return JSONResponse(status_code=404, content={"status": "error", "message": _NO_ANALYSIS})
|
||||
return context, detail, resolved, bool(stored) and not requested
|
||||
|
||||
|
||||
@router.get("/{project_id}/drainage/pipe-points", response_model=None)
|
||||
async def get_pipe_points(project_id: UUID) -> dict[str, Any] | JSONResponse:
|
||||
"""저장된 관 지점과 그 세부유역을 돌려준다. 저장분이 없으면 자동 생성한 목록."""
|
||||
resolved = await _resolve(project_id, None, use_stored=True)
|
||||
if isinstance(resolved, JSONResponse):
|
||||
return resolved
|
||||
context, detail, points, saved = resolved
|
||||
return _payload(project_id, context, detail, points, saved)
|
||||
|
||||
|
||||
@router.post("/{project_id}/drainage/detail-basins", response_model=None)
|
||||
async def post_detail_basins(
|
||||
project_id: UUID, payload: dict[str, Any] | None = None
|
||||
) -> dict[str, Any] | JSONResponse:
|
||||
"""화면에서 편집 중인 관 목록으로 세부유역을 다시 나눈다(저장하지 않는다).
|
||||
|
||||
`points`를 비우고 부르면 저장분을 무시하고 기본 관 + 자동 보충으로 되돌린다 —
|
||||
노선이 바뀌어 전체 분석을 다시 돌린 직후의 경로다.
|
||||
"""
|
||||
resolved = await _resolve(project_id, payload, use_stored=False)
|
||||
if isinstance(resolved, JSONResponse):
|
||||
return resolved
|
||||
context, detail, points, _ = resolved
|
||||
return _payload(project_id, context, detail, points, saved=False)
|
||||
|
||||
|
||||
@router.put("/{project_id}/drainage/pipe-points", response_model=None)
|
||||
async def put_pipe_points(
|
||||
project_id: UUID, payload: dict[str, Any] | None = None
|
||||
) -> dict[str, Any] | JSONResponse:
|
||||
"""관 지점을 정본으로 확정하고 세부유역 산출물까지 함께 남긴다(모델 확정 시점)."""
|
||||
resolved = await _resolve(project_id, payload, use_stored=True)
|
||||
if isinstance(resolved, JSONResponse):
|
||||
return resolved
|
||||
context, detail, points, _ = resolved
|
||||
|
||||
signature = route_signature(context.vertices)
|
||||
saved = await asyncio.to_thread(save_pipe_points, context.stored_path, signature, points)
|
||||
await asyncio.to_thread(
|
||||
save_detail_basins, context.stored_path, _basin_features(context, detail, points)
|
||||
)
|
||||
result = _payload(project_id, context, detail, points, saved=True)
|
||||
result["saved_count"] = saved
|
||||
return result
|
||||
@@ -0,0 +1,438 @@
|
||||
/* =============================================================================
|
||||
* 상세 배수유역 오버레이 (B04 2D 지도 — 관리자 검토용)
|
||||
*
|
||||
* 계획선 위 **관 매설 지점**을 편집하고, 그 관마다 물이 모이는 **세부유역**을 겹쳐 그린다.
|
||||
*
|
||||
* · 기본 관 = 도로 × 상류 세류선 교차점 (백엔드 분석 산출물)
|
||||
* · 자동 보충 = 관 최대 간격을 넘는 구간에 최소 개수로 채운 자리
|
||||
* · 수동 = 계획선 위에서 우클릭해 넣었거나, 끌어서 옮긴 관
|
||||
*
|
||||
* 경계는 여기서 긋지 않는다. 백엔드가 1m 격자 셀마다 "이 셀 물이 어느 도로 셀로 가는가"를
|
||||
* 이미 풀어 두었고, 도로 셀은 종단 내리막을 따라 담당 관으로 묶인다. 그래서 같은 관으로
|
||||
* 묶인 셀 덩어리의 바깥선이 곧 세부유역 경계다 — 화면은 그 폴리곤을 받아 칠하기만 한다.
|
||||
*
|
||||
* 재계산은 **버튼을 눌렀을 때만** 돈다(2026-08-01 사용자 지시). 편집 중에는 마커만 움직이고,
|
||||
* 확정 전에 화면을 떠나면 저장된 값으로 되돌아온다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||
import { themeColor } from "@ui/ui_template_palette";
|
||||
import {
|
||||
computeDetailBasins,
|
||||
fetchDetailPipePoints,
|
||||
saveDetailPipePoints,
|
||||
type DetailBasin,
|
||||
type PipeSource,
|
||||
type VWorldMeta,
|
||||
} from "./B04_wf1_Surface_Api_Fetch";
|
||||
import {
|
||||
haloColor,
|
||||
lonLatToScreen,
|
||||
metricToScreen,
|
||||
type Normalizer,
|
||||
type ViewState,
|
||||
} from "./B04_wf1_Surface_UI_MapRender";
|
||||
import {
|
||||
nearestChainage,
|
||||
pointAtChainage,
|
||||
resampleRoute,
|
||||
type RoutePoint,
|
||||
} from "./B04_wf1_Surface_UI_RouteSamples";
|
||||
|
||||
function L(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
}
|
||||
|
||||
/** 관 마커 반지름(px)과 잡기 여유(px). 유입 집중점 마커보다 조금 크게 둬서 구분된다. */
|
||||
const PIPE_RADIUS = 8;
|
||||
const PIPE_HIT_SLACK = 5;
|
||||
/** 계획선에서 이보다 멀면 "노선 위 우클릭"으로 보지 않는다(px). */
|
||||
const ROUTE_HIT_PX = 14;
|
||||
|
||||
/** 관 생성 사유별 색. 정의처는 `ui_template_theme.css`. */
|
||||
const PIPE_COLORS: Record<PipeSource, [token: string, fallback: string]> = {
|
||||
stream: ["--map-pipe-marker", "#f97316"],
|
||||
spacing: ["--map-pipe-auto", "#0ea5e9"],
|
||||
user: ["--map-pipe-user", "#16a34a"],
|
||||
};
|
||||
|
||||
/** 세부유역 채움 — 이웃끼리 구분만 되면 되므로 색상환을 균등 분할해 돌려 쓴다. */
|
||||
function basinColor(index: number, alpha: number): string {
|
||||
const hue = (index * 137.508) % 360; // 황금각 — 인접 유역이 비슷한 색으로 붙지 않는다
|
||||
return `hsla(${hue.toFixed(0)}, 70%, 55%, ${alpha})`;
|
||||
}
|
||||
|
||||
interface PipeMarker {
|
||||
chainage: number;
|
||||
source: PipeSource;
|
||||
}
|
||||
|
||||
export interface DetailBasinOverlay {
|
||||
/** 세부유역 재계산 버튼. */
|
||||
button: HTMLButtonElement;
|
||||
/** 표시 토글 — 관 매설 마커 / 세부 유역. 유입 집중점과 겹쳐 볼 수 있게 따로 둔다. */
|
||||
partButtons: HTMLButtonElement[];
|
||||
statusElement: HTMLElement;
|
||||
/** 우클릭 메뉴. 지도 뷰포트에 얹는다(뷰포트 기준 절대 위치). */
|
||||
menuElement: HTMLElement;
|
||||
setProject: (projectId: string) => void;
|
||||
/** 계획선(사업지 좌표계 m)과 배경지도 메타. 둘이 있어야 화면 좌표를 낼 수 있다. */
|
||||
setRoute: (points: ReadonlyArray<RoutePoint>, meta: VWorldMeta | null) => void;
|
||||
clear: () => void;
|
||||
/** 마커를 잡았으면 true(지도 팬 대신 마커 끌기로 넘어간다). */
|
||||
handlePointerDown: (view: ViewState, x: number, y: number) => boolean;
|
||||
handlePointerMove: (view: ViewState, x: number, y: number) => boolean;
|
||||
handlePointerUp: () => boolean;
|
||||
/** 우클릭 메뉴를 띄웠으면 true. */
|
||||
handleContextMenu: (view: ViewState, x: number, y: number) => boolean;
|
||||
/** 모델 확정 시점에 관 지점과 세부유역을 영구저장한다. */
|
||||
commit: () => Promise<number>;
|
||||
draw: (context: CanvasRenderingContext2D, normalizer: Normalizer | null, view: ViewState) => void;
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
export function createDetailBasinOverlay(onChange: () => void): DetailBasinOverlay {
|
||||
let projectId: string | null = null;
|
||||
let meta: VWorldMeta | null = null;
|
||||
let samples: RoutePoint[] = [];
|
||||
let pipes: PipeMarker[] = [];
|
||||
let basins: DetailBasin[] = [];
|
||||
let zSource = "";
|
||||
let minSpacing = 20;
|
||||
let busy = false;
|
||||
let dirty = false;
|
||||
/** 끌고 있는 마커 번호. null이면 잡은 것이 없다. */
|
||||
let dragging: number | null = null;
|
||||
let requestSequence = 0;
|
||||
|
||||
const statusElement = document.createElement("span");
|
||||
statusElement.className = "b04-map__watershed-status";
|
||||
statusElement.hidden = true;
|
||||
|
||||
const menuElement = document.createElement("div");
|
||||
menuElement.className = "b04-map__context-menu";
|
||||
menuElement.hidden = true;
|
||||
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "b04-map__layer-button b04-map__layer-button--gis";
|
||||
button.textContent = L("B04_Surface_Basin_Btn");
|
||||
button.style.setProperty("--b04-layer-color", themeColor("--map-pipe-user", "#16a34a"));
|
||||
button.title = L("B04_Surface_Basin_Btn_Tip");
|
||||
|
||||
const shownParts = { pipes: true, basins: true };
|
||||
const partButtons = (
|
||||
[
|
||||
["pipes", "B04_Surface_Basin_Part_Pipes", PIPE_COLORS.stream],
|
||||
["basins", "B04_Surface_Basin_Part_Basins", ["--map-pipe-user", "#16a34a"]],
|
||||
] as const
|
||||
).map(([key, labelKey, token]) => {
|
||||
const element = document.createElement("button");
|
||||
element.type = "button";
|
||||
element.className = "b04-map__layer-button b04-map__layer-button--gis is-active";
|
||||
element.textContent = L(labelKey);
|
||||
element.style.setProperty("--b04-layer-color", themeColor(token[0], token[1]));
|
||||
element.setAttribute("aria-pressed", "true");
|
||||
element.addEventListener("click", () => {
|
||||
shownParts[key] = !shownParts[key];
|
||||
element.classList.toggle("is-active", shownParts[key]);
|
||||
element.setAttribute("aria-pressed", String(shownParts[key]));
|
||||
onChange();
|
||||
});
|
||||
return element;
|
||||
});
|
||||
|
||||
function say(text: string): void {
|
||||
statusElement.textContent = text;
|
||||
statusElement.hidden = text === "";
|
||||
}
|
||||
|
||||
function summary(): string {
|
||||
const count = (source: PipeSource): number =>
|
||||
pipes.filter((pipe) => pipe.source === source).length;
|
||||
const line = L("B04_Surface_Basin_Summary")
|
||||
.replace("{pipes}", String(pipes.length))
|
||||
.replace("{stream}", String(count("stream")))
|
||||
.replace("{spacing}", String(count("spacing")))
|
||||
.replace("{user}", String(count("user")))
|
||||
.replace("{basins}", String(basins.length))
|
||||
.replace("{source}", zSource || "-");
|
||||
return dirty ? `${line} · ${L("B04_Surface_Basin_Edited")}` : line;
|
||||
}
|
||||
|
||||
function pipeScreen(view: ViewState, chainage: number): [number, number] | null {
|
||||
if (!meta) return null;
|
||||
const point = pointAtChainage(samples, chainage);
|
||||
return point ? metricToScreen(meta, view, point.x, point.y) : null;
|
||||
}
|
||||
|
||||
/** 마커 히트 판정. 가장 가까운 것 하나. */
|
||||
function hitPipe(view: ViewState, x: number, y: number): number | null {
|
||||
let hit: number | null = null;
|
||||
let best = Number.POSITIVE_INFINITY;
|
||||
pipes.forEach((pipe, index) => {
|
||||
const screen = pipeScreen(view, pipe.chainage);
|
||||
if (!screen) return;
|
||||
const distance = Math.hypot(screen[0] - x, screen[1] - y);
|
||||
if (distance <= PIPE_RADIUS + PIPE_HIT_SLACK && distance < best) {
|
||||
hit = index;
|
||||
best = distance;
|
||||
}
|
||||
});
|
||||
return hit;
|
||||
}
|
||||
|
||||
/** 화면 좌표를 계획선 위 누가거리로 스냅한다. 계획선에서 멀면 null. */
|
||||
function snap(view: ViewState, x: number, y: number): number | null {
|
||||
if (!meta) return null;
|
||||
const found = nearestChainage(
|
||||
samples,
|
||||
(point) => metricToScreen(meta as VWorldMeta, view, point.x, point.y),
|
||||
x,
|
||||
y,
|
||||
);
|
||||
return found && found.distance <= ROUTE_HIT_PX ? found.chainage : null;
|
||||
}
|
||||
|
||||
/** 관끼리 최소 간격을 지키는지 본다(자기 자신은 제외). */
|
||||
function tooClose(chainage: number, exceptIndex: number | null): boolean {
|
||||
return pipes.some(
|
||||
(pipe, index) =>
|
||||
index !== exceptIndex && Math.abs(pipe.chainage - chainage) < minSpacing - 1e-6,
|
||||
);
|
||||
}
|
||||
|
||||
function markEdited(): void {
|
||||
dirty = true;
|
||||
say(summary());
|
||||
onChange();
|
||||
}
|
||||
|
||||
function closeMenu(): void {
|
||||
menuElement.hidden = true;
|
||||
menuElement.replaceChildren();
|
||||
}
|
||||
|
||||
function openMenu(x: number, y: number, items: Array<[string, () => void]>): void {
|
||||
menuElement.replaceChildren();
|
||||
items.forEach(([label, action]) => {
|
||||
const entry = document.createElement("button");
|
||||
entry.type = "button";
|
||||
entry.className = "b04-map__context-menu-item";
|
||||
entry.textContent = label;
|
||||
entry.addEventListener("click", () => {
|
||||
closeMenu();
|
||||
action();
|
||||
});
|
||||
menuElement.append(entry);
|
||||
});
|
||||
menuElement.style.left = `${x}px`;
|
||||
menuElement.style.top = `${y}px`;
|
||||
menuElement.hidden = false;
|
||||
}
|
||||
|
||||
/** 응답을 화면 상태로 옮긴다. */
|
||||
function apply(response: Awaited<ReturnType<typeof fetchDetailPipePoints>>): void {
|
||||
pipes = response.pipe_points.map((point) => ({
|
||||
chainage: point.chainage_m,
|
||||
source: point.source,
|
||||
}));
|
||||
basins = response.basins;
|
||||
zSource = response.z_source;
|
||||
minSpacing = response.min_spacing_m;
|
||||
dirty = false;
|
||||
say(summary());
|
||||
}
|
||||
|
||||
/** 세부유역을 다시 나눈다. 편집 중인 관 목록을 그대로 보낸다. */
|
||||
async function recompute(): Promise<void> {
|
||||
if (!projectId || busy) return;
|
||||
busy = true;
|
||||
button.disabled = true;
|
||||
button.textContent = L("B04_Surface_Basin_Btn_Busy");
|
||||
const sequence = ++requestSequence;
|
||||
try {
|
||||
const response = await computeDetailBasins(
|
||||
projectId,
|
||||
pipes.map((pipe) => ({ chainage_m: pipe.chainage, source: pipe.source })),
|
||||
);
|
||||
if (sequence !== requestSequence) return;
|
||||
apply(response);
|
||||
} catch (error) {
|
||||
if (sequence !== requestSequence) return;
|
||||
say(error instanceof Error ? error.message : L("B04_Surface_Basin_Failed"));
|
||||
} finally {
|
||||
busy = false;
|
||||
button.disabled = false;
|
||||
button.textContent = L("B04_Surface_Basin_Btn");
|
||||
onChange();
|
||||
}
|
||||
}
|
||||
|
||||
/** 저장분(없으면 자동 생성분)을 불러온다. 지도를 열 때 조용히 돈다. */
|
||||
async function loadSaved(): Promise<void> {
|
||||
if (!projectId) return;
|
||||
const sequence = ++requestSequence;
|
||||
try {
|
||||
const response = await fetchDetailPipePoints(projectId);
|
||||
if (sequence !== requestSequence) return;
|
||||
apply(response);
|
||||
} catch {
|
||||
if (sequence !== requestSequence) return;
|
||||
// 배수유역 분석 전이면 여기서 실패하는 것이 정상이다 — 조용히 비워 둔다.
|
||||
pipes = [];
|
||||
basins = [];
|
||||
say("");
|
||||
}
|
||||
onChange();
|
||||
}
|
||||
|
||||
button.addEventListener("click", () => {
|
||||
void recompute();
|
||||
});
|
||||
|
||||
return {
|
||||
button,
|
||||
partButtons,
|
||||
statusElement,
|
||||
menuElement,
|
||||
setProject(next) {
|
||||
projectId = next;
|
||||
pipes = [];
|
||||
basins = [];
|
||||
dirty = false;
|
||||
say("");
|
||||
void loadSaved();
|
||||
},
|
||||
setRoute(points, nextMeta) {
|
||||
meta = nextMeta;
|
||||
samples = resampleRoute(points);
|
||||
},
|
||||
clear() {
|
||||
pipes = [];
|
||||
basins = [];
|
||||
zSource = "";
|
||||
dirty = false;
|
||||
dragging = null;
|
||||
requestSequence += 1;
|
||||
closeMenu();
|
||||
say("");
|
||||
},
|
||||
handlePointerDown(view, x, y) {
|
||||
closeMenu();
|
||||
if (!shownParts.pipes || pipes.length === 0) return false;
|
||||
const hit = hitPipe(view, x, y);
|
||||
if (hit === null) return false;
|
||||
dragging = hit;
|
||||
return true;
|
||||
},
|
||||
handlePointerMove(view, x, y) {
|
||||
if (dragging === null) return false;
|
||||
const chainage = snap(view, x, y);
|
||||
// 계획선에서 벗어난 위치는 무시한다 — 관은 늘 노선 위에 있어야 한다.
|
||||
if (chainage === null || tooClose(chainage, dragging)) return true;
|
||||
pipes[dragging] = { chainage, source: "user" };
|
||||
pipes.sort((left, right) => left.chainage - right.chainage);
|
||||
dragging = pipes.findIndex((pipe) => pipe.chainage === chainage);
|
||||
markEdited();
|
||||
return true;
|
||||
},
|
||||
handlePointerUp() {
|
||||
if (dragging === null) return false;
|
||||
dragging = null;
|
||||
return true;
|
||||
},
|
||||
handleContextMenu(view, x, y) {
|
||||
closeMenu();
|
||||
if (!shownParts.pipes) return false;
|
||||
const hit = hitPipe(view, x, y);
|
||||
if (hit !== null) {
|
||||
openMenu(x, y, [
|
||||
[
|
||||
L("B04_Surface_Basin_Menu_Delete"),
|
||||
() => {
|
||||
pipes.splice(hit, 1);
|
||||
markEdited();
|
||||
},
|
||||
],
|
||||
]);
|
||||
return true;
|
||||
}
|
||||
const chainage = snap(view, x, y);
|
||||
if (chainage === null) return false;
|
||||
openMenu(x, y, [
|
||||
[
|
||||
L("B04_Surface_Basin_Menu_Add"),
|
||||
() => {
|
||||
if (tooClose(chainage, null)) {
|
||||
say(L("B04_Surface_Basin_TooClose").replace("{min}", String(minSpacing)));
|
||||
onChange();
|
||||
return;
|
||||
}
|
||||
pipes.push({ chainage, source: "user" });
|
||||
pipes.sort((left, right) => left.chainage - right.chainage);
|
||||
markEdited();
|
||||
},
|
||||
],
|
||||
]);
|
||||
return true;
|
||||
},
|
||||
async commit() {
|
||||
if (!projectId) return 0;
|
||||
const response = await saveDetailPipePoints(
|
||||
projectId,
|
||||
pipes.map((pipe) => ({ chainage_m: pipe.chainage, source: pipe.source })),
|
||||
);
|
||||
apply(response);
|
||||
onChange();
|
||||
return response.pipe_count;
|
||||
},
|
||||
draw(context, normalizer, view) {
|
||||
if (shownParts.basins && normalizer && basins.length > 0) {
|
||||
context.save();
|
||||
context.lineJoin = "round";
|
||||
basins.forEach((basin, index) => {
|
||||
if (basin.polygon_lonlat.length < 3) return;
|
||||
context.beginPath();
|
||||
basin.polygon_lonlat.forEach(([lon, lat], order) => {
|
||||
const [px, py] = lonLatToScreen(normalizer, view, lon, lat);
|
||||
if (order === 0) context.moveTo(px, py);
|
||||
else context.lineTo(px, py);
|
||||
});
|
||||
context.closePath();
|
||||
context.fillStyle = basinColor(index, 0.22);
|
||||
context.fill();
|
||||
context.strokeStyle = basinColor(index, 0.95);
|
||||
context.lineWidth = 1.8;
|
||||
context.stroke();
|
||||
});
|
||||
context.restore();
|
||||
}
|
||||
if (!shownParts.pipes || pipes.length === 0) return;
|
||||
context.save();
|
||||
context.textAlign = "center";
|
||||
context.textBaseline = "middle";
|
||||
pipes.forEach((pipe, index) => {
|
||||
const screen = pipeScreen(view, pipe.chainage);
|
||||
if (!screen) return;
|
||||
const [x, y] = screen;
|
||||
const [token, fallback] = PIPE_COLORS[pipe.source];
|
||||
context.beginPath();
|
||||
context.arc(x, y, PIPE_RADIUS, 0, Math.PI * 2);
|
||||
context.fillStyle = themeColor(token, fallback);
|
||||
context.fill();
|
||||
context.lineWidth = index === dragging ? 3 : 1.6;
|
||||
context.strokeStyle = haloColor();
|
||||
context.stroke();
|
||||
context.font = "bold 10px sans-serif";
|
||||
context.fillStyle = themeColor("--map-marker-text", "#111827");
|
||||
context.fillText(String(index + 1), x, y);
|
||||
});
|
||||
context.restore();
|
||||
},
|
||||
dispose() {
|
||||
requestSequence += 1;
|
||||
closeMenu();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
type Normalizer,
|
||||
type ViewState,
|
||||
} from "./B04_wf1_Surface_UI_MapRender";
|
||||
import { pointAtChainage, resampleRoute, type RoutePoint } from "./B04_wf1_Surface_UI_RouteSamples";
|
||||
|
||||
function L(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
@@ -43,7 +44,7 @@ const MARKER_MAX_RADIUS = 10;
|
||||
/** 마커를 눌렀다고 볼 여유(px). */
|
||||
const MARKER_HIT_SLACK = 4;
|
||||
|
||||
export type RoutePoint = { x: number; y: number };
|
||||
export type { RoutePoint };
|
||||
|
||||
export interface FlowStrengthOverlay {
|
||||
/** 지도 헤더 버튼 줄에 넣을 토글. */
|
||||
@@ -131,38 +132,6 @@ export function createFlowStrengthOverlay(onChange: () => void): FlowStrengthOve
|
||||
// 늦게 도착한 응답이 최신 선택을 덮어쓰지 않게 요청마다 번호를 매긴다.
|
||||
let requestSequence = 0;
|
||||
|
||||
/** 계획선을 1m 간격으로 다시 찍는다. 강도 곡선의 인덱스와 그대로 맞물린다. */
|
||||
function resample(): void {
|
||||
samples = [];
|
||||
if (routePoints.length < 2) return;
|
||||
let carried = 0;
|
||||
samples.push({ x: routePoints[0].x, y: routePoints[0].y });
|
||||
for (let i = 1; i < routePoints.length; i += 1) {
|
||||
const from = routePoints[i - 1];
|
||||
const to = routePoints[i];
|
||||
const dx = to.x - from.x;
|
||||
const dy = to.y - from.y;
|
||||
const length = Math.hypot(dx, dy);
|
||||
if (length <= 0) continue;
|
||||
let travelled = 1 - carried;
|
||||
while (travelled <= length) {
|
||||
samples.push({
|
||||
x: from.x + (dx * travelled) / length,
|
||||
y: from.y + (dy * travelled) / length,
|
||||
});
|
||||
travelled += 1;
|
||||
}
|
||||
carried = (carried + length) % 1;
|
||||
}
|
||||
}
|
||||
|
||||
/** 누가거리(m) 위치의 계획선 좌표. 범위를 벗어나면 양 끝으로 자른다. */
|
||||
function pointAt(chainage: number): RoutePoint | null {
|
||||
if (samples.length === 0) return null;
|
||||
const index = Math.min(samples.length - 1, Math.max(0, Math.round(chainage)));
|
||||
return samples[index];
|
||||
}
|
||||
|
||||
/** 로그 스케일 정규화 — 계곡 한 점이 사면보다 수백 배라 선형으로는 못 읽는다. */
|
||||
function normalize(value: number): number {
|
||||
if (maxStrength <= 0 || value <= 0) return 0;
|
||||
@@ -195,7 +164,7 @@ export function createFlowStrengthOverlay(onChange: () => void): FlowStrengthOve
|
||||
|
||||
function markerScreen(view: ViewState, chainage: number): [number, number] | null {
|
||||
if (!meta) return null;
|
||||
const point = pointAt(chainage);
|
||||
const point = pointAtChainage(samples, chainage);
|
||||
if (!point) return null;
|
||||
return metricToScreen(meta, view, point.x, point.y);
|
||||
}
|
||||
@@ -309,7 +278,7 @@ export function createFlowStrengthOverlay(onChange: () => void): FlowStrengthOve
|
||||
setRoute(points, nextMeta) {
|
||||
routePoints = points;
|
||||
meta = nextMeta;
|
||||
resample();
|
||||
samples = resampleRoute(routePoints);
|
||||
},
|
||||
clear() {
|
||||
strength = new Float64Array(0);
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/* =============================================================================
|
||||
* 2D 지도 레이어 목록·기본값·색 (B04)
|
||||
*
|
||||
* 어떤 레이어를 다루는지, 처음에 무엇이 켜져 있는지, 무슨 색으로 그리는지만 모은다.
|
||||
* 지도 뷰어 본체(`B04_wf1_Surface_UI_MapViewer.ts`)가 700줄 한계에 닿아 분리했다.
|
||||
* 색 값의 정의처는 `ui_template_theme.css`(`--map-*`)이며 여기는 이름만 잇는다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { themeColor } from "@ui/ui_template_palette";
|
||||
|
||||
export const BACKGROUND_LAYERS = ["white", "satellite", "hybrid"] as const;
|
||||
// 유수방향은 화면에 쓰지 않기로 해 목록에서 뺐다(2026-08-01 사용자 지시).
|
||||
export const GIS_LAYERS = [
|
||||
"지적도",
|
||||
"행정구역_시군구",
|
||||
"행정구역_읍면동",
|
||||
"등고선",
|
||||
"도엽_등고선",
|
||||
"도엽_하천중심선",
|
||||
"도엽_표고점",
|
||||
"도엽_성절토",
|
||||
"도엽_옹벽석축",
|
||||
] as const;
|
||||
export type BackgroundLayer = (typeof BACKGROUND_LAYERS)[number];
|
||||
export type GisLayer = (typeof GIS_LAYERS)[number];
|
||||
|
||||
/* ── 처음 열었을 때 켜져 있을 레이어 (2026-08-01 사용자 지정) ──────────────
|
||||
* 지도가 복잡해지지 않도록 실제로 자주 보는 것만 켠 채로 시작한다.
|
||||
* 나머지는 버튼으로 그때그때 켠다. */
|
||||
export const BACKGROUND_DEFAULT_ON: Record<BackgroundLayer, boolean> = {
|
||||
white: true,
|
||||
satellite: true,
|
||||
hybrid: false,
|
||||
};
|
||||
|
||||
export const GIS_DEFAULT_ON: Record<GisLayer, boolean> = {
|
||||
지적도: false,
|
||||
행정구역_시군구: true,
|
||||
행정구역_읍면동: true,
|
||||
등고선: false,
|
||||
도엽_등고선: false,
|
||||
도엽_하천중심선: true,
|
||||
도엽_표고점: false,
|
||||
도엽_성절토: false,
|
||||
도엽_옹벽석축: false,
|
||||
};
|
||||
|
||||
/** 등고 라벨(계곡선 수치) 기본 표시 여부. */
|
||||
export const CONTOUR_LABEL_DEFAULT_ON = false;
|
||||
|
||||
/** 이만큼(px) 이하로 움직였다 뗐으면 클릭으로 본다 — 손떨림으로 선택이 안 되는 일을 막는다. */
|
||||
export const CLICK_SLOP_PX = 4;
|
||||
|
||||
/** 레이어별 색은 `ui_template_theme.css`의 `--map-*`가 정의처다. 여기는 이름만 잇는다.
|
||||
* (fallback 값은 CSS가 아직 안 붙은 첫 프레임 대비용 안전값) */
|
||||
const GIS_LAYER_COLOR_TOKENS: Record<GisLayer, [name: string, fallback: string]> = {
|
||||
지적도: ["--map-cadastral", "#f97316"],
|
||||
행정구역_시군구: ["--map-sigungu", "#7c3aed"],
|
||||
행정구역_읍면동: ["--map-eupmyeondong", "#22c55e"],
|
||||
등고선: ["--map-contour", "#fdba74"],
|
||||
도엽_등고선: ["--map-sheet-contour", "#a5b4fc"],
|
||||
도엽_하천중심선: ["--map-sheet-stream", "#2563eb"],
|
||||
도엽_표고점: ["--map-sheet-elev-point", "#f9a8d4"],
|
||||
도엽_성절토: ["--map-sheet-cutfill", "#f43f5e"],
|
||||
도엽_옹벽석축: ["--map-sheet-wall", "#0f766e"],
|
||||
};
|
||||
|
||||
export const gisLayerColor = (layer: GisLayer): string =>
|
||||
themeColor(...GIS_LAYER_COLOR_TOKENS[layer]);
|
||||
|
||||
/** 등고 라벨 표기 대상 레이어와 표고 속성 키 (gpkg=CTRLN_HG, 도엽=등고수치) */
|
||||
export const CONTOUR_LABEL_KEYS: Partial<Record<GisLayer, string[]>> = {
|
||||
등고선: ["CTRLN_HG"],
|
||||
도엽_등고선: ["등고수치"],
|
||||
};
|
||||
@@ -1,6 +1,5 @@
|
||||
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||
import { createProgressCircle } from "@ui/ui_template_progress";
|
||||
import { themeColor } from "@ui/ui_template_palette";
|
||||
import { fetchCachedSheetLayer } from "../A00_Common/b_asset_cache";
|
||||
import {
|
||||
fetchGisGeoJson,
|
||||
@@ -10,6 +9,19 @@ import {
|
||||
type VWorldMeta,
|
||||
} from "./B04_wf1_Surface_Api_Fetch";
|
||||
import { niceScaleDistance } from "./B04_wf1_Surface_UI_Camera";
|
||||
import { createDetailBasinOverlay } from "./B04_wf1_Surface_UI_Basins";
|
||||
import {
|
||||
BACKGROUND_DEFAULT_ON,
|
||||
BACKGROUND_LAYERS,
|
||||
CLICK_SLOP_PX,
|
||||
CONTOUR_LABEL_DEFAULT_ON,
|
||||
CONTOUR_LABEL_KEYS,
|
||||
GIS_DEFAULT_ON,
|
||||
GIS_LAYERS,
|
||||
gisLayerColor,
|
||||
type BackgroundLayer,
|
||||
type GisLayer,
|
||||
} from "./B04_wf1_Surface_UI_MapLayers";
|
||||
import { createFlowStrengthOverlay } from "./B04_wf1_Surface_UI_FlowStrength";
|
||||
import { createWatershedOverlay } from "./B04_wf1_Surface_UI_Watershed";
|
||||
import {
|
||||
@@ -35,74 +47,11 @@ export interface SurfaceMapViewer {
|
||||
root: HTMLElement;
|
||||
/** routeBounds: 계획노선 평면 범위 — 초기 화면을 도로 중심으로 맞추는 데 쓴다. */
|
||||
render: (projectId: string, routeBounds?: PlanBounds | null) => void;
|
||||
/** 관 매설 지점·세부유역을 영구저장한다. 저장한 관 개수를 돌려준다(모델 확정과 함께 호출). */
|
||||
commitDrainage: () => Promise<number>;
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
const BACKGROUND_LAYERS = ["white", "satellite", "hybrid"] as const;
|
||||
// 유수방향은 화면에 쓰지 않기로 해 목록에서 뺐다(2026-08-01 사용자 지시).
|
||||
const GIS_LAYERS = [
|
||||
"지적도",
|
||||
"행정구역_시군구",
|
||||
"행정구역_읍면동",
|
||||
"등고선",
|
||||
"도엽_등고선",
|
||||
"도엽_하천중심선",
|
||||
"도엽_표고점",
|
||||
"도엽_성절토",
|
||||
"도엽_옹벽석축",
|
||||
] as const;
|
||||
type BackgroundLayer = (typeof BACKGROUND_LAYERS)[number];
|
||||
type GisLayer = (typeof GIS_LAYERS)[number];
|
||||
|
||||
/* ── 처음 열었을 때 켜져 있을 레이어 (2026-08-01 사용자 지정) ──────────────
|
||||
* 지도가 복잡해지지 않도록 실제로 자주 보는 것만 켠 채로 시작한다.
|
||||
* 나머지는 버튼으로 그때그때 켠다. */
|
||||
const BACKGROUND_DEFAULT_ON: Record<BackgroundLayer, boolean> = {
|
||||
white: true,
|
||||
satellite: true,
|
||||
hybrid: false,
|
||||
};
|
||||
|
||||
const GIS_DEFAULT_ON: Record<GisLayer, boolean> = {
|
||||
지적도: false,
|
||||
행정구역_시군구: true,
|
||||
행정구역_읍면동: true,
|
||||
등고선: false,
|
||||
도엽_등고선: false,
|
||||
도엽_하천중심선: true,
|
||||
도엽_표고점: false,
|
||||
도엽_성절토: false,
|
||||
도엽_옹벽석축: false,
|
||||
};
|
||||
|
||||
/** 등고 라벨(계곡선 수치) 기본 표시 여부. */
|
||||
const CONTOUR_LABEL_DEFAULT_ON = false;
|
||||
|
||||
/** 이만큼(px) 이하로 움직였다 뗐으면 클릭으로 본다 — 손떨림으로 선택이 안 되는 일을 막는다. */
|
||||
const CLICK_SLOP_PX = 4;
|
||||
|
||||
/** 레이어별 색은 `ui_template_theme.css`의 `--map-*`가 정의처다. 여기는 이름만 잇는다.
|
||||
* (fallback 값은 CSS가 아직 안 붙은 첫 프레임 대비용 안전값) */
|
||||
const GIS_LAYER_COLOR_TOKENS: Record<GisLayer, [name: string, fallback: string]> = {
|
||||
지적도: ["--map-cadastral", "#f97316"],
|
||||
행정구역_시군구: ["--map-sigungu", "#7c3aed"],
|
||||
행정구역_읍면동: ["--map-eupmyeondong", "#22c55e"],
|
||||
등고선: ["--map-contour", "#fdba74"],
|
||||
도엽_등고선: ["--map-sheet-contour", "#a5b4fc"],
|
||||
도엽_하천중심선: ["--map-sheet-stream", "#2563eb"],
|
||||
도엽_표고점: ["--map-sheet-elev-point", "#f9a8d4"],
|
||||
도엽_성절토: ["--map-sheet-cutfill", "#f43f5e"],
|
||||
도엽_옹벽석축: ["--map-sheet-wall", "#0f766e"],
|
||||
};
|
||||
|
||||
const gisLayerColor = (layer: GisLayer): string => themeColor(...GIS_LAYER_COLOR_TOKENS[layer]);
|
||||
|
||||
// 등고 라벨 표기 대상 레이어와 표고 속성 키 (gpkg=CTRLN_HG, 도엽=등고수치)
|
||||
const CONTOUR_LABEL_KEYS: Partial<Record<GisLayer, string[]>> = {
|
||||
등고선: ["CTRLN_HG"],
|
||||
도엽_등고선: ["등고수치"],
|
||||
};
|
||||
|
||||
function L(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
}
|
||||
@@ -341,12 +290,24 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
|
||||
flowStatus.hidden = flowStatus.textContent === "";
|
||||
scheduleDraw();
|
||||
});
|
||||
watershedButtons.append(watershed.button, ...watershed.partButtons, flowStrength.button);
|
||||
// 상세 배수유역 — 관 매설 지점 편집과 세부유역 분할. 계산은 버튼을 눌렀을 때만 돈다.
|
||||
const detailBasins = createDetailBasinOverlay(() => {
|
||||
scheduleDraw();
|
||||
});
|
||||
watershedButtons.append(
|
||||
watershed.button,
|
||||
...watershed.partButtons,
|
||||
flowStrength.button,
|
||||
detailBasins.button,
|
||||
...detailBasins.partButtons,
|
||||
);
|
||||
watershedGroup.append(watershedTitle, watershedButtons);
|
||||
controls.insertBefore(watershedGroup, resetButton);
|
||||
// 안내·결과 문구는 컨트롤 줄이 아니라 지도 위에 얹는다 — 컨트롤 영역 세로 공간을 먹지 않는다.
|
||||
statusStack.append(watershed.statusElement, flowStatus);
|
||||
statusStack.append(watershed.statusElement, flowStatus, detailBasins.statusElement);
|
||||
statusTopRight.append(watershed.busyElement);
|
||||
// 우클릭 메뉴는 뷰포트 기준 절대 위치라 뷰포트 안에 넣는다.
|
||||
viewport.append(detailBasins.menuElement);
|
||||
|
||||
function updateImageTransform(): void {
|
||||
backgroundImages.forEach((image) => {
|
||||
@@ -455,6 +416,8 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
|
||||
}
|
||||
// 흐름 강도(도로 색·유입 집중점 마커)는 계획선 위에 얹는다.
|
||||
flowStrength.draw(context, normalizer, view);
|
||||
// 세부유역 채움과 관 마커는 맨 위 — 편집 대상이라 다른 레이어에 가려지면 집을 수 없다.
|
||||
detailBasins.draw(context, normalizer, view);
|
||||
updateImageTransform();
|
||||
drawScaleBar(mapRect);
|
||||
}
|
||||
@@ -515,6 +478,8 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
|
||||
planned.points.length > 1 ? prepareMetricPolyline(planned.points, nextMeta) : null;
|
||||
// 흐름 강도는 계획선 위에 칠하므로 같은 점 목록·같은 메타를 쓴다(어긋나면 색이 밀린다).
|
||||
flowStrength.setRoute(planned.points, nextMeta);
|
||||
// 관 마커도 같은 계획선 위에 스냅한다 — 목록이 다르면 마커가 노선을 벗어난다.
|
||||
detailBasins.setRoute(planned.points, nextMeta);
|
||||
// 계획노선을 아직 올리지 않은 프로젝트에서는 켤 것이 없으니 버튼을 잠근다.
|
||||
routeButton.disabled = routeLayer === null;
|
||||
routeButton.title = routeLayer === null ? L("B04_Surface_Map_PlannedRouteEmpty") : "";
|
||||
@@ -562,10 +527,43 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
|
||||
},
|
||||
{ passive: false },
|
||||
);
|
||||
/** 현재 화면 상태(팬·줌·지도 사각형). 마커 히트 판정과 그리기가 같은 값을 봐야 한다. */
|
||||
function currentView(rect: DOMRect): ViewState {
|
||||
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) };
|
||||
}
|
||||
|
||||
viewport.addEventListener("contextmenu", (event) => {
|
||||
const rect = viewport.getBoundingClientRect();
|
||||
const opened = detailBasins.handleContextMenu(
|
||||
currentView(rect),
|
||||
event.clientX - rect.left,
|
||||
event.clientY - rect.top,
|
||||
);
|
||||
// 계획선이나 관 마커 위에서만 전용 메뉴를 띄운다 — 그 밖은 브라우저 메뉴를 그대로 둔다.
|
||||
if (opened) event.preventDefault();
|
||||
});
|
||||
|
||||
viewport.addEventListener("pointerdown", (event) => {
|
||||
// 중간 버튼은 브라우저 기본 동작(페이지 자동 스크롤)이 지도 팬과 겹쳐 페이지 전체를
|
||||
// 흔들므로 기본 동작을 차단하고 지도 팬으로만 사용한다.
|
||||
if (event.button === 1) event.preventDefault();
|
||||
// 관 마커를 잡았으면 끌기로 넘어간다 — 지도 팬도, 마커 선택도 하지 않는다.
|
||||
if (event.button === 0) {
|
||||
const rect = viewport.getBoundingClientRect();
|
||||
if (
|
||||
detailBasins.handlePointerDown(
|
||||
currentView(rect),
|
||||
event.clientX - rect.left,
|
||||
event.clientY - rect.top,
|
||||
)
|
||||
) {
|
||||
clickStart = null;
|
||||
viewport.setPointerCapture(event.pointerId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// 팬은 가운데(휠) 버튼 전용이다. 좌버튼은 화면 위 객체를 고르는 데만 쓴다
|
||||
// (좌버튼 드래그가 팬까지 겸하면 객체를 집으려다 지도가 딸려 움직인다 — 2026-08-01 사용자 지시).
|
||||
// 가운데 버튼이 없는 터치·펜은 그대로 끌어서 팬 할 수 있게 둔다.
|
||||
@@ -582,24 +580,27 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
|
||||
viewport.setPointerCapture(event.pointerId);
|
||||
});
|
||||
viewport.addEventListener("pointerup", (event) => {
|
||||
// 관 마커를 끌던 중이었으면 그것으로 끝낸다 — 집중점 선택까지 겹쳐 일어나면 안 된다.
|
||||
if (detailBasins.handlePointerUp()) return;
|
||||
const start = clickStart;
|
||||
clickStart = null;
|
||||
if (!start || event.button !== 0) return;
|
||||
if (Math.hypot(event.clientX - start.x, event.clientY - start.y) > CLICK_SLOP_PX) return;
|
||||
const rect = viewport.getBoundingClientRect();
|
||||
const width = Math.max(1, Math.floor(rect.width));
|
||||
const height = Math.max(1, Math.floor(rect.height));
|
||||
const view: ViewState = {
|
||||
width,
|
||||
height,
|
||||
scale,
|
||||
offsetX,
|
||||
offsetY,
|
||||
mapRect: computeMapRect(meta, width, height),
|
||||
};
|
||||
const view = currentView(rect);
|
||||
flowStrength.handleClick(normalizer, view, event.clientX - rect.left, event.clientY - rect.top);
|
||||
});
|
||||
viewport.addEventListener("pointermove", (event) => {
|
||||
const rect = viewport.getBoundingClientRect();
|
||||
if (
|
||||
detailBasins.handlePointerMove(
|
||||
currentView(rect),
|
||||
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;
|
||||
@@ -625,8 +626,12 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
|
||||
watershed.setProject(projectId);
|
||||
flowStrength.clear();
|
||||
flowStrength.setProject(projectId);
|
||||
detailBasins.clear();
|
||||
detailBasins.setProject(projectId);
|
||||
void loadLayers();
|
||||
},
|
||||
/** 모델 확정과 함께 관 매설 지점·세부유역을 영구저장한다. */
|
||||
commitDrainage: () => detailBasins.commit(),
|
||||
dispose() {
|
||||
loadSequence += 1;
|
||||
if (frameHandle) {
|
||||
@@ -635,6 +640,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
|
||||
}
|
||||
resizeObserver.disconnect();
|
||||
flowStrength.dispose();
|
||||
detailBasins.dispose();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -402,6 +402,9 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
|
||||
smooth: terrainViewer.isSmoothingEnabled(),
|
||||
contour_interval_m: terrainViewer.getContourInterval(),
|
||||
});
|
||||
// 관 매설 지점·세부유역도 이때 함께 영구저장한다(2026-08-01 사용자 지시).
|
||||
// 배수유역 분석 전이면 저장할 것이 없으므로 실패해도 모델 확정은 그대로 둔다.
|
||||
await mapViewer.commitDrainage().catch(() => 0);
|
||||
// 확정본이 바뀌었으므로 브라우저가 담아 둔 옛 자료를 더 이상 쓰지 않게 한다.
|
||||
// 준비 표식을 지우면 아래 goToWorkflowStage가 준비 화면을 거쳐 새 자료를 담는다.
|
||||
clearPreloadMark();
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/* =============================================================================
|
||||
* 계획선 1m 재표본 (B04 2D 지도 공용)
|
||||
*
|
||||
* 백엔드는 도로 위 값을 전부 **누가거리 1m 구간**으로 준다(흐름 강도 곡선, 관 매설 지점,
|
||||
* 유입 집중점). 화면에서 그 값을 계획선 위에 얹으려면 같은 규칙으로 다시 찍은 점 목록이
|
||||
* 있어야 한다 — 오버레이마다 따로 찍으면 한 칸씩 밀려 색과 마커가 어긋난다.
|
||||
* ========================================================================== */
|
||||
|
||||
export type RoutePoint = { x: number; y: number };
|
||||
|
||||
/** 계획선을 1m 간격으로 다시 찍는다. 배열 인덱스 = 누가거리(m). */
|
||||
export function resampleRoute(points: ReadonlyArray<RoutePoint>): RoutePoint[] {
|
||||
const samples: RoutePoint[] = [];
|
||||
if (points.length < 2) return samples;
|
||||
let carried = 0;
|
||||
samples.push({ x: points[0].x, y: points[0].y });
|
||||
for (let i = 1; i < points.length; i += 1) {
|
||||
const from = points[i - 1];
|
||||
const to = points[i];
|
||||
const dx = to.x - from.x;
|
||||
const dy = to.y - from.y;
|
||||
const length = Math.hypot(dx, dy);
|
||||
if (length <= 0) continue;
|
||||
let travelled = 1 - carried;
|
||||
while (travelled <= length) {
|
||||
samples.push({
|
||||
x: from.x + (dx * travelled) / length,
|
||||
y: from.y + (dy * travelled) / length,
|
||||
});
|
||||
travelled += 1;
|
||||
}
|
||||
carried = (carried + length) % 1;
|
||||
}
|
||||
return samples;
|
||||
}
|
||||
|
||||
/** 누가거리(m) 위치의 계획선 좌표. 범위를 벗어나면 양 끝으로 자른다. */
|
||||
export function pointAtChainage(
|
||||
samples: ReadonlyArray<RoutePoint>,
|
||||
chainage: number,
|
||||
): RoutePoint | null {
|
||||
if (samples.length === 0) return null;
|
||||
const index = Math.min(samples.length - 1, Math.max(0, Math.round(chainage)));
|
||||
return samples[index];
|
||||
}
|
||||
|
||||
/** 화면 좌표에서 가장 가까운 계획선 위치를 찾는다. (누가거리 m, 화면 거리 px).
|
||||
|
||||
* 관을 우클릭으로 추가하거나 끌어 옮길 때 "계획선 위"로 스냅하는 근거다. 1m 표본을 전부
|
||||
* 훑되 화면 변환은 넘겨받은 함수에 맡긴다 — 배경지도 메타를 여기서 알 필요가 없다. */
|
||||
export function nearestChainage(
|
||||
samples: ReadonlyArray<RoutePoint>,
|
||||
toScreen: (point: RoutePoint) => [number, number],
|
||||
screenX: number,
|
||||
screenY: number,
|
||||
): { chainage: number; distance: number } | null {
|
||||
if (samples.length === 0) return null;
|
||||
let bestIndex = -1;
|
||||
let bestDistance = Number.POSITIVE_INFINITY;
|
||||
for (let index = 0; index < samples.length; index += 1) {
|
||||
const [x, y] = toScreen(samples[index]);
|
||||
const distance = Math.hypot(x - screenX, y - screenY);
|
||||
if (distance < bestDistance) {
|
||||
bestDistance = distance;
|
||||
bestIndex = index;
|
||||
}
|
||||
}
|
||||
return bestIndex < 0 ? null : { chainage: bestIndex, distance: bestDistance };
|
||||
}
|
||||
@@ -623,6 +623,39 @@
|
||||
box-shadow: inset 0 0 0 1px var(--b04-layer-color);
|
||||
}
|
||||
|
||||
/* 관 매설 우클릭 메뉴 — 지도 뷰포트 기준 절대 위치. */
|
||||
.b04-map__context-menu {
|
||||
position: absolute;
|
||||
z-index: 6;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 140px;
|
||||
padding: var(--spacing-4);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
background: var(--color-surface-raised);
|
||||
box-shadow: 0 4px 16px rgb(0 0 0 / 25%);
|
||||
}
|
||||
|
||||
.b04-map__context-menu[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.b04-map__context-menu-item {
|
||||
padding: var(--spacing-8) var(--spacing-12);
|
||||
border: 0;
|
||||
border-radius: var(--radius-cards);
|
||||
background: transparent;
|
||||
color: var(--color-text-body);
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.b04-map__context-menu-item:hover {
|
||||
background: var(--color-surface-sunken);
|
||||
}
|
||||
|
||||
.b04-map__viewport {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
"""배수 관경 산정.
|
||||
|
||||
유역을 나누는 최종 목적은 각 지점의 파이프 관경 결정이다. 유역 경사면에 100년 강우빈도를
|
||||
적용해 모이는 물의 양을 산정하고 그 유량으로 관경을 정한다.
|
||||
|
||||
노선 기하(정점·누가거리·세류 교차점)는 `common_util_route_geometry`로 옮겼다 — B04 분석과
|
||||
B05 세부 설계가 같은 표현을 써야 하기 때문이다(2026-07-31 구조 개편).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def estimate_pipe_diameter_mm(
|
||||
area_m2: float,
|
||||
relief_m: float,
|
||||
flow_length_m: float,
|
||||
rainfall_mm_per_hour: float | None = None,
|
||||
) -> float | None:
|
||||
"""유역 제원으로 배수 파이프 관경(mm)을 산정한다.
|
||||
|
||||
100년 강우빈도와 유역 경사면을 곱해 유출량을 구하고, 그 유량으로 관경을 정하는 것이
|
||||
목적이다. **수식은 아직 확정되지 않았다** — 사용자가 로직을 제공하면 여기를 채운다.
|
||||
그때까지는 None을 돌려 호출부가 "미정"으로 표기하게 한다.
|
||||
"""
|
||||
# TODO(사용자 로직 대기): 100년 강우강도 × 유역면적 × 유출계수 → 유량 Q → 관경 D 산정.
|
||||
_ = (area_m2, relief_m, flow_length_m, rainfall_mm_per_hour)
|
||||
return None
|
||||
@@ -1,70 +1,42 @@
|
||||
"""배수유역 세부 설계 (B05 — 일반 사용자용).
|
||||
"""배수유역 세부 설계 — B05 저장소 어댑터.
|
||||
|
||||
**분석은 하지 않는다.** B04가 미리 돌려 저장한 결과를 읽어, 사용자가 실제로 손대는 두 가지만
|
||||
처리한다(2026-07-31 사용자 지시).
|
||||
계산은 하지 않는다. 관 보충(⑨)과 세부유역 분할(⑩)의 알고리즘은 B04 관리자 화면과
|
||||
공용이므로 `common_util_drainage_detail`에 있고, 여기서는 **B05가 읽을 폴더만 정한다**
|
||||
(2026-08-01 구조 개편).
|
||||
|
||||
⑨ 관 간격이 최대치를 넘는 구간에 **최소 개수**로 관을 보충
|
||||
⑩ 측구 흐름으로 도로 셀 → 담당 관을 정하고, 셀이 도달한 도로 셀의 담당 관을 그대로
|
||||
그 셀의 유역 번호로 삼아 세부유역을 나눈다
|
||||
⑪ 사용자가 관을 옮기거나 추가하면 ⑩만 다시 돈다 — 격자 해석은 재사용한다
|
||||
|
||||
읽어 오는 것(`B04_wf1_Surface/drainage/`):
|
||||
· `03_road_routing.geojson` — 계획도로선 · 기본 배관 · 2차 전체 배수유역
|
||||
· `03_road_routing.npz` — 셀 → 도로 셀 귀속, 유하장, 강도, 도로 셀 제원, 셀 표고
|
||||
화살표(방향 코드)나 밴드 표고 같은 관리자 확인용 배열은 읽지 않는다 — 여기서는 필요 없고
|
||||
파일만 무거워진다.
|
||||
읽는 대상은 B04 원본이 아니라 **B05 사본**이다. B04가 다시 해석했으면 사본을 먼저
|
||||
갱신한다 — 편집분(`boundary_overrides.json`)은 사본 갱신과 무관하게 남는다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Export import STAGES
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Flow import largest_ring, polygonize_labels
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Grid import GridSpec
|
||||
from B05_wf2_Route.B05_wf2_Route_Engine_Drainage import estimate_pipe_diameter_mm
|
||||
from B05_wf2_Route.B05_wf2_Route_Engine_Drainage_Store import b05_drainage_dir, sync_from_b04
|
||||
from common_util.common_util_route_geometry import (
|
||||
RouteVertex,
|
||||
StructureCandidate,
|
||||
interpolate_vertex,
|
||||
is_uphill_at,
|
||||
)
|
||||
from config.config_system import (
|
||||
DRAINAGE_DITCH_SAMPLE_M,
|
||||
DRAINAGE_PIPE_MAX_SPACING_M,
|
||||
DRAINAGE_PIPE_MIN_SPACING_M,
|
||||
from common_util.common_util_drainage_detail import (
|
||||
DrainageDetail,
|
||||
RoadRouting,
|
||||
WatershedBasin,
|
||||
build_detail,
|
||||
read_road_routing,
|
||||
read_upstream_lines,
|
||||
)
|
||||
from common_util.common_util_route_geometry import RouteVertex
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 관 위치 선정 점수 배분 — 흐름 강도가 주, 종단 저점이 보조.
|
||||
_SCORE_WEIGHT_STRENGTH = 0.7
|
||||
_SCORE_WEIGHT_SAG = 0.3
|
||||
# 성토부(내리막)는 물이 노선 밖으로 빠지므로 관 위치로 덜 선호한다.
|
||||
_SCORE_FILL_PENALTY = 0.5
|
||||
__all__ = [
|
||||
"DrainageDetail",
|
||||
"RoadRouting",
|
||||
"WatershedBasin",
|
||||
"build_drainage_detail",
|
||||
"load_road_routing",
|
||||
"load_upstream_lines",
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class DrainageDetail:
|
||||
"""B05 산출물 — 화면에 그릴 기하와 세부유역."""
|
||||
|
||||
route_lonlat: list[list[float]] = field(default_factory=list)
|
||||
basin_lonlat: list[list[float]] = field(default_factory=list)
|
||||
pipes: list[StructureCandidate] = field(default_factory=list)
|
||||
basins: list[WatershedBasin] = field(default_factory=list)
|
||||
grid_cell_m: float = 1.0
|
||||
# B04가 계산해 둔 평균 흐름 화살표를 그대로 넘긴다 — B05는 다시 계산하지 않는다.
|
||||
flow_arrows: list[list[Any]] = field(default_factory=list)
|
||||
arrow_spacing_m: float = 0.0
|
||||
# 유역 안쪽 상류 세류망(WGS84 lon/lat 조각들). 화면 강조 표시용 — 계산에는 쓰지 않는다.
|
||||
upstream_lonlat: list[list[list[float]]] = field(default_factory=list)
|
||||
def _synced_dir(stored_path: str) -> Path:
|
||||
"""B04 원본을 B05 사본으로 맞춘 뒤 사본 폴더를 돌려준다."""
|
||||
sync_from_b04(stored_path)
|
||||
return b05_drainage_dir(stored_path)
|
||||
|
||||
|
||||
def build_drainage_detail(
|
||||
@@ -72,431 +44,15 @@ def build_drainage_detail(
|
||||
vertices: list[RouteVertex],
|
||||
confirmed_chainages: list[float] | None = None,
|
||||
) -> DrainageDetail | None:
|
||||
"""B04 분석 결과를 읽어 관을 보충하고 세부유역을 나눈다.
|
||||
|
||||
`confirmed_chainages`를 주면 그 위치를 관으로 확정하고(사용자 편집), 비우면 B04의
|
||||
기본 관에 최대 간격 규칙으로 최소 개수만 보충한다. 어느 쪽이든 격자 해석은 하지 않는다.
|
||||
"""
|
||||
routing = load_road_routing(stored_path)
|
||||
if routing is None or len(vertices) < 2:
|
||||
return None
|
||||
|
||||
if confirmed_chainages:
|
||||
pipes = _pipes_from_chainages(vertices, confirmed_chainages)
|
||||
else:
|
||||
# 저장분의 기본 관은 누가거리만 신뢰한다 — 좌표는 현재 노선 위로 다시 찍는다.
|
||||
base = [
|
||||
StructureCandidate(
|
||||
chainage_m=pipe.chainage_m,
|
||||
x=interpolate_vertex(vertices, pipe.chainage_m)[0],
|
||||
y=interpolate_vertex(vertices, pipe.chainage_m)[1],
|
||||
reason=pipe.reason,
|
||||
)
|
||||
for pipe in routing.base_pipes
|
||||
]
|
||||
pipes = place_pipes(vertices, base, routing.strength_curve)
|
||||
|
||||
detail = DrainageDetail(
|
||||
route_lonlat=routing.route_lonlat,
|
||||
basin_lonlat=routing.basin_lonlat,
|
||||
pipes=pipes,
|
||||
grid_cell_m=routing.spec.cell_m,
|
||||
flow_arrows=routing.flow_arrows,
|
||||
arrow_spacing_m=routing.arrow_spacing_m,
|
||||
upstream_lonlat=load_upstream_lines(stored_path),
|
||||
)
|
||||
if not pipes:
|
||||
return detail
|
||||
pipe_of_slot = assign_road_cells_to_pipes(vertices, pipes, routing.road_chainage)
|
||||
detail.basins = assemble_basins(routing, pipes, pipe_of_slot)
|
||||
logger.info(
|
||||
"배수유역: 세부 설계 — 관 %d개(기본 %d + 보충 %d), 세부유역 %d개",
|
||||
len(pipes),
|
||||
sum(1 for pipe in pipes if pipe.reason != "spacing"),
|
||||
sum(1 for pipe in pipes if pipe.reason == "spacing"),
|
||||
len(detail.basins),
|
||||
)
|
||||
return detail
|
||||
|
||||
|
||||
@dataclass
|
||||
class WatershedBasin:
|
||||
"""관 하나가 받는 세부 배수유역."""
|
||||
|
||||
index: int
|
||||
chainage_m: float
|
||||
outlet_x: float
|
||||
outlet_y: float
|
||||
boundary_xy: list[tuple[float, float]] = field(default_factory=list)
|
||||
area_m2: float = 0.0
|
||||
relief_m: float = 0.0
|
||||
flow_length_m: float = 0.0
|
||||
pipe_diameter_mm: float | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class RoadRouting:
|
||||
"""B04가 남긴 배수유역 분석 결과 — B05가 세부유역을 나누는 데 필요한 최소 묶음."""
|
||||
|
||||
spec: GridSpec
|
||||
# (R*C,) int32 — 셀이 물길을 따라 도달하는 도로 셀 슬롯(−1 = 미도달).
|
||||
road_slot: np.ndarray
|
||||
path_length: np.ndarray # (R*C,) float32 — 그 도로 셀까지 물길 길이(m)
|
||||
elevation: np.ndarray # (R*C,) float32 — 셀 표고(유역 낙차 계산용)
|
||||
road_cell_index: np.ndarray # (K,) int32 — 도로 셀의 평탄 인덱스
|
||||
road_chainage: np.ndarray # (K,) float64 — 도로 셀의 누가거리(m)
|
||||
strength: np.ndarray # (K,) int64 — 도로 셀별 상류 셀 수
|
||||
# 화면에 그대로 그릴 기하(WGS84 lon/lat).
|
||||
route_lonlat: list[list[float]] = field(default_factory=list)
|
||||
basin_lonlat: list[list[float]] = field(default_factory=list)
|
||||
base_pipes: list[StructureCandidate] = field(default_factory=list)
|
||||
# 평균 흐름 화살표 — [x, y, 방위(도), 도로도달, 셀 수]. B04가 계산해 둔 그대로.
|
||||
flow_arrows: list[list[Any]] = field(default_factory=list)
|
||||
arrow_spacing_m: float = 0.0
|
||||
|
||||
@property
|
||||
def strength_curve(self) -> np.ndarray:
|
||||
"""누가거리 1m 구간별 유입 면적(㎡) 곡선 — 관 보충 위치 점수의 근거."""
|
||||
if self.road_chainage.size == 0:
|
||||
return np.zeros(1)
|
||||
bins = max(1, int(np.ceil(self.road_chainage.max())) + 1)
|
||||
index = np.clip(np.round(self.road_chainage).astype(np.int64), 0, bins - 1)
|
||||
weights = self.strength.astype(np.float64) * self.spec.cell_area_m2
|
||||
return np.bincount(index, weights=weights, minlength=bins)
|
||||
"""B04 분석 결과(B05 사본)를 읽어 관을 보충하고 세부유역을 나눈다."""
|
||||
return build_detail(_synced_dir(stored_path), vertices, confirmed_chainages)
|
||||
|
||||
|
||||
def load_road_routing(stored_path: str) -> RoadRouting | None:
|
||||
"""`03_road_routing` 산출물을 읽는다. 없으면 None.
|
||||
|
||||
읽는 대상은 B04 원본이 아니라 **B05 사본**이다. B04가 다시 해석했으면 사본을 먼저
|
||||
갱신한다 — 편집분(`boundary_overrides.json`)은 사본 갱신과 무관하게 남는다.
|
||||
"""
|
||||
sync_from_b04(stored_path)
|
||||
directory = b05_drainage_dir(stored_path)
|
||||
prefix = STAGES["road_routing"]
|
||||
array_path = directory / f"{prefix}_road_routing.npz"
|
||||
if not array_path.exists():
|
||||
logger.warning("배수유역: B04 분석 결과가 없습니다 (%s).", array_path)
|
||||
return None
|
||||
try:
|
||||
with np.load(array_path, allow_pickle=False) as data:
|
||||
spec = GridSpec(
|
||||
x_min=float(data["x_min"]),
|
||||
y_max=float(data["y_max"]),
|
||||
cell_m=float(data["cell_m"]),
|
||||
n_rows=int(data["n_rows"]),
|
||||
n_cols=int(data["n_cols"]),
|
||||
)
|
||||
routing = RoadRouting(
|
||||
spec=spec,
|
||||
road_slot=data["road_slot"].reshape(-1),
|
||||
path_length=data["path_length"].reshape(-1),
|
||||
elevation=data["elevation"].reshape(-1),
|
||||
road_cell_index=data["road_cell_index"],
|
||||
road_chainage=data["road_chainage"],
|
||||
strength=data["strength"],
|
||||
)
|
||||
except (OSError, KeyError, ValueError):
|
||||
logger.warning("배수유역: B04 분석 결과를 읽지 못했습니다 (%s).", array_path)
|
||||
return None
|
||||
|
||||
_read_geometry(directory / f"{prefix}_road_routing.geojson", routing)
|
||||
logger.info(
|
||||
"배수유역: B04 결과 로드 — 격자 %d×%d, 도로 셀 %d, 기본 관 %d",
|
||||
spec.n_rows,
|
||||
spec.n_cols,
|
||||
routing.road_cell_index.size,
|
||||
len(routing.base_pipes),
|
||||
)
|
||||
return routing
|
||||
|
||||
|
||||
def _read_geometry(path: Path, routing: RoadRouting) -> None:
|
||||
"""계획도로선·2차 유역 외곽선·기본 관을 GeoJSON에서 읽어 채운다."""
|
||||
if not path.exists():
|
||||
logger.warning("배수유역: B04 기하 산출물이 없습니다 (%s).", path)
|
||||
return
|
||||
try:
|
||||
with path.open("r", encoding="utf-8") as file:
|
||||
document = json.load(file)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
logger.warning("배수유역: B04 기하 산출물을 읽지 못했습니다 (%s).", path)
|
||||
return
|
||||
routing.arrow_spacing_m = float(
|
||||
(document.get("properties") or {}).get("arrow_spacing_m") or 0.0
|
||||
)
|
||||
for feature in document.get("features", []):
|
||||
properties = feature.get("properties") or {}
|
||||
geometry = feature.get("geometry") or {}
|
||||
coordinates = geometry.get("coordinates")
|
||||
kind = properties.get("kind")
|
||||
if kind == "route" and geometry.get("type") == "LineString":
|
||||
routing.route_lonlat = coordinates
|
||||
elif kind == "basin_boundary" and geometry.get("type") == "Polygon" and coordinates:
|
||||
routing.basin_lonlat = coordinates[0]
|
||||
elif kind == "flow_arrow" and geometry.get("type") == "Point":
|
||||
# 화면이 미터로 그리므로 속성의 x·y를 쓴다(기하는 저장 규약상 lon/lat).
|
||||
routing.flow_arrows.append(
|
||||
[
|
||||
float(properties.get("x") or 0.0),
|
||||
float(properties.get("y") or 0.0),
|
||||
float(properties.get("azimuth_deg") or 0.0),
|
||||
bool(properties.get("reaches_road")),
|
||||
int(properties.get("cells") or 0),
|
||||
]
|
||||
)
|
||||
elif kind == "pipe" and geometry.get("type") == "Point":
|
||||
routing.base_pipes.append(
|
||||
StructureCandidate(
|
||||
chainage_m=float(properties.get("chainage_m") or 0.0),
|
||||
x=0.0,
|
||||
y=0.0,
|
||||
reason=str(properties.get("reason") or "stream"),
|
||||
)
|
||||
)
|
||||
"""`03_road_routing` 산출물을 B05 사본에서 읽는다."""
|
||||
return read_road_routing(_synced_dir(stored_path))
|
||||
|
||||
|
||||
def load_upstream_lines(stored_path: str) -> list[list[list[float]]]:
|
||||
"""`01_primary_region`에서 상류 세류망만 읽는다(화면 강조용).
|
||||
|
||||
유역 판정의 기준선이라 B04 오버레이에서도 같은 선을 굵게 그린다 — B05는 그 선을
|
||||
그대로 받아 표시만 한다.
|
||||
"""
|
||||
path = b05_drainage_dir(stored_path) / f"{STAGES['primary_region']}_primary_region.geojson"
|
||||
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 []
|
||||
lines: list[list[list[float]]] = []
|
||||
for feature in document.get("features", []):
|
||||
properties = feature.get("properties") or {}
|
||||
geometry = feature.get("geometry") or {}
|
||||
if properties.get("kind") != "upstream":
|
||||
continue
|
||||
coordinates = geometry.get("coordinates")
|
||||
if geometry.get("type") == "LineString" and coordinates:
|
||||
lines.append(coordinates)
|
||||
elif geometry.get("type") == "MultiLineString" and coordinates:
|
||||
lines.extend(part for part in coordinates if part)
|
||||
return lines
|
||||
|
||||
|
||||
# ── ⑨ 관 최소 개수 보충 ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def place_pipes(
|
||||
vertices: list[RouteVertex],
|
||||
base_pipes: list[StructureCandidate],
|
||||
strength_curve: np.ndarray,
|
||||
) -> list[StructureCandidate]:
|
||||
"""B04가 정한 기본 관(세류 교차점)에, 최대 간격을 넘는 구간만 최소 개수로 보충한다.
|
||||
|
||||
기본 관은 여기서 다시 찾지 않는다 — B04 산출물에 이미 들어 있다.
|
||||
"""
|
||||
total_length = vertices[-1].chainage_m
|
||||
base: list[StructureCandidate] = []
|
||||
for candidate in sorted(base_pipes, key=lambda item: item.chainage_m):
|
||||
if base and candidate.chainage_m - base[-1].chainage_m < DRAINAGE_PIPE_MIN_SPACING_M:
|
||||
continue
|
||||
base.append(candidate)
|
||||
|
||||
filled: list[StructureCandidate] = []
|
||||
previous = 0.0
|
||||
for candidate in [*base, None]:
|
||||
boundary = candidate.chainage_m if candidate else total_length
|
||||
filled.extend(_fill_gap(vertices, strength_curve, previous, boundary))
|
||||
if candidate:
|
||||
filled.append(candidate)
|
||||
previous = candidate.chainage_m
|
||||
else:
|
||||
previous = boundary
|
||||
filled.sort(key=lambda item: item.chainage_m)
|
||||
return filled
|
||||
|
||||
|
||||
def _fill_gap(
|
||||
vertices: list[RouteVertex],
|
||||
strength_curve: np.ndarray,
|
||||
start_m: float,
|
||||
end_m: float,
|
||||
) -> list[StructureCandidate]:
|
||||
"""[start, end] 구간에 최대 간격을 지키는 **최소 개수**의 관을 배치한다.
|
||||
|
||||
필요 개수 n은 구간 길이로 정해지고(ceil(L/max) − 1), 각 관은 등분 위치를 중심으로
|
||||
허용 여유(slack) 안에서만 움직인다. 그래서 개수는 늘지 않으면서도 흐름 강도가 크고
|
||||
종단이 낮은 지점으로 붙는다.
|
||||
"""
|
||||
span = end_m - start_m
|
||||
if span <= DRAINAGE_PIPE_MAX_SPACING_M:
|
||||
return []
|
||||
count = int(np.ceil(span / DRAINAGE_PIPE_MAX_SPACING_M)) - 1
|
||||
if count <= 0:
|
||||
return []
|
||||
spacing = span / (count + 1)
|
||||
slack = max(0.0, (DRAINAGE_PIPE_MAX_SPACING_M - spacing) / 2.0)
|
||||
placed: list[StructureCandidate] = []
|
||||
for order in range(1, count + 1):
|
||||
nominal = start_m + spacing * order
|
||||
low = max(start_m + DRAINAGE_PIPE_MIN_SPACING_M, nominal - slack)
|
||||
high = min(end_m - DRAINAGE_PIPE_MIN_SPACING_M, nominal + slack)
|
||||
chosen = _best_position(vertices, strength_curve, low, high, nominal)
|
||||
x, y, _ = interpolate_vertex(vertices, chosen)
|
||||
placed.append(StructureCandidate(chainage_m=chosen, x=x, y=y, reason="spacing"))
|
||||
return placed
|
||||
|
||||
|
||||
def _best_position(
|
||||
vertices: list[RouteVertex],
|
||||
strength_curve: np.ndarray,
|
||||
low_m: float,
|
||||
high_m: float,
|
||||
fallback_m: float,
|
||||
) -> float:
|
||||
"""허용 구간 안에서 흐름 강도가 크고 종단이 낮은 위치를 고른다."""
|
||||
if high_m <= low_m:
|
||||
return fallback_m
|
||||
positions = np.arange(low_m, high_m + 1.0, 1.0)
|
||||
if positions.size == 0:
|
||||
return fallback_m
|
||||
index = np.clip(np.round(positions).astype(np.int64), 0, strength_curve.size - 1)
|
||||
strength = strength_curve[index]
|
||||
heights = np.array([interpolate_vertex(vertices, float(p))[2] for p in positions])
|
||||
|
||||
strength_score = strength / strength.max() if strength.max() > 0 else np.zeros_like(strength)
|
||||
height_span = float(heights.max() - heights.min())
|
||||
sag_score = (
|
||||
(heights.max() - heights) / height_span if height_span > 1e-6 else np.zeros_like(heights)
|
||||
)
|
||||
score = _SCORE_WEIGHT_STRENGTH * strength_score + _SCORE_WEIGHT_SAG * sag_score
|
||||
for order, position in enumerate(positions):
|
||||
if not is_uphill_at(vertices, float(position)):
|
||||
score[order] *= _SCORE_FILL_PENALTY
|
||||
return float(positions[int(np.argmax(score))])
|
||||
|
||||
|
||||
def _pipes_from_chainages(
|
||||
vertices: list[RouteVertex], chainages: list[float]
|
||||
) -> list[StructureCandidate]:
|
||||
"""사용자가 확정·편집한 누가거리 목록을 관 후보로 되돌린다.
|
||||
|
||||
노선 밖 값은 시·종점으로 당긴다. 그대로 두면 마커는 끝점에 찍히는데 라벨만 −50m처럼
|
||||
나와 좌표와 표기가 어긋난다.
|
||||
"""
|
||||
total_length = vertices[-1].chainage_m
|
||||
clamped = {min(max(round(float(item), 2), 0.0), total_length) for item in chainages}
|
||||
pipes: list[StructureCandidate] = []
|
||||
for value in sorted(clamped):
|
||||
x, y, _ = interpolate_vertex(vertices, value)
|
||||
pipes.append(StructureCandidate(chainage_m=value, x=x, y=y, reason="confirmed"))
|
||||
return pipes
|
||||
|
||||
|
||||
# ── ⑩ 측구 흐름으로 도로 셀 → 담당 관 ───────────────────────────────────────
|
||||
|
||||
|
||||
def assign_road_cells_to_pipes(
|
||||
vertices: list[RouteVertex],
|
||||
pipes: list[StructureCandidate],
|
||||
road_chainage: np.ndarray,
|
||||
) -> np.ndarray:
|
||||
"""도로 셀마다 물이 실제로 흘러가는 담당 관 번호를 정한다.
|
||||
|
||||
노면 물은 측구를 타고 종단 내리막으로 흐르므로, 종단 계획선을 1차원 지형으로 보고
|
||||
같은 방식(내리막 추적 + 관에서 흡수)으로 푼다. 관이 없는 사그(저점)에 갇힌 구간은
|
||||
가장 가까운 관이 받는 것으로 본다.
|
||||
"""
|
||||
total_length = vertices[-1].chainage_m
|
||||
step = max(DRAINAGE_DITCH_SAMPLE_M, 0.5)
|
||||
stations = np.arange(0.0, total_length + step, step)
|
||||
heights = np.array([interpolate_vertex(vertices, float(s))[2] for s in stations])
|
||||
pipe_chainages = np.array([pipe.chainage_m for pipe in pipes])
|
||||
pipe_station = np.clip(np.round(pipe_chainages / step).astype(np.int64), 0, stations.size - 1)
|
||||
|
||||
# 앞뒤 이웃 중 더 낮은 쪽으로 흘려보낸다(양쪽 다 높으면 사그 = 제자리).
|
||||
back_z = np.full(stations.size, np.inf)
|
||||
back_z[1:] = heights[:-1]
|
||||
forward_z = np.full(stations.size, np.inf)
|
||||
forward_z[:-1] = heights[1:]
|
||||
go_back = (back_z < heights) & (back_z <= forward_z)
|
||||
go_forward = (forward_z < heights) & ~go_back
|
||||
receiver = np.arange(stations.size, dtype=np.int64)
|
||||
receiver[go_back] -= 1
|
||||
receiver[go_forward] += 1
|
||||
receiver[pipe_station] = pipe_station # 관은 물을 흡수한다
|
||||
|
||||
owner = np.full(stations.size, -1, dtype=np.int64)
|
||||
owner[pipe_station] = np.arange(pipe_chainages.size)
|
||||
jump = receiver
|
||||
for _ in range(40):
|
||||
next_jump = jump[jump]
|
||||
if np.array_equal(next_jump, jump):
|
||||
break
|
||||
jump = next_jump
|
||||
resolved = owner[jump]
|
||||
# 관 없는 사그에 갇힌 구간은 가장 가까운 관에 붙인다.
|
||||
orphan = resolved < 0
|
||||
if orphan.any() and pipe_chainages.size:
|
||||
nearest = np.abs(stations[orphan, None] - pipe_chainages[None, :]).argmin(axis=1)
|
||||
resolved[orphan] = nearest
|
||||
|
||||
slot_station = np.clip(np.round(road_chainage / step).astype(np.int64), 0, stations.size - 1)
|
||||
return resolved[slot_station].astype(np.int32)
|
||||
|
||||
|
||||
# ── ⑩ 세부유역 조립 ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def assemble_basins(
|
||||
solution: RoadRouting,
|
||||
pipes: list[StructureCandidate],
|
||||
pipe_of_slot: np.ndarray,
|
||||
) -> list[WatershedBasin]:
|
||||
"""셀이 도달한 도로 셀의 담당 관을 그대로 유역 번호로 삼아 세부유역을 만든다."""
|
||||
spec = solution.spec
|
||||
labels = np.full(spec.size, -1, dtype=np.int32)
|
||||
reached = solution.road_slot >= 0
|
||||
labels[reached] = pipe_of_slot[solution.road_slot[reached]]
|
||||
|
||||
polygons = polygonize_labels(spec, labels)
|
||||
cell_area = spec.cell_area_m2
|
||||
basins: list[WatershedBasin] = []
|
||||
for order, pipe in enumerate(pipes):
|
||||
member = labels == order
|
||||
count = int(member.sum())
|
||||
if count == 0:
|
||||
continue
|
||||
geometry = polygons.get(order)
|
||||
elevations = solution.elevation[member]
|
||||
highest = float(np.nanmax(elevations)) if np.isfinite(elevations).any() else 0.0
|
||||
outlet_z = _outlet_elevation(solution, order, pipe_of_slot)
|
||||
area = count * cell_area
|
||||
relief = max(0.0, highest - outlet_z)
|
||||
flow_length = float(solution.path_length[member].max())
|
||||
basins.append(
|
||||
WatershedBasin(
|
||||
index=len(basins) + 1,
|
||||
chainage_m=pipe.chainage_m,
|
||||
outlet_x=pipe.x,
|
||||
outlet_y=pipe.y,
|
||||
boundary_xy=largest_ring(geometry) if geometry is not None else [],
|
||||
area_m2=area,
|
||||
relief_m=relief,
|
||||
flow_length_m=flow_length,
|
||||
pipe_diameter_mm=estimate_pipe_diameter_mm(area, relief, flow_length),
|
||||
)
|
||||
)
|
||||
return basins
|
||||
|
||||
|
||||
def _outlet_elevation(solution: RoadRouting, pipe_order: int, pipe_of_slot: np.ndarray) -> float:
|
||||
"""관이 담당하는 도로 셀들의 최저 표고 = 유역 출구 표고."""
|
||||
slots = np.flatnonzero(pipe_of_slot == pipe_order)
|
||||
if slots.size == 0:
|
||||
return 0.0
|
||||
elevations = solution.elevation[solution.road_cell_index[slots]]
|
||||
finite = elevations[np.isfinite(elevations)]
|
||||
return float(finite.min()) if finite.size else 0.0
|
||||
"""상류 세류망을 B05 사본에서 읽는다(화면 강조용)."""
|
||||
return read_upstream_lines(b05_drainage_dir(stored_path))
|
||||
|
||||
@@ -17,8 +17,8 @@ from B05_wf2_Route.B05_wf2_Route_Engine_Sections_Core import (
|
||||
SectionGenerationOptions,
|
||||
generate_sections,
|
||||
)
|
||||
from B05_wf2_Route.B05_wf2_Route_Engine_Sections_Sampler import build_surface_sampler
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from common_util.common_util_surface_sampler import build_surface_sampler
|
||||
from config.config_system import FOREST_ROAD_PROFILE_CRITERIA
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -11,7 +11,7 @@ from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from B05_wf2_Route.B05_wf2_Route_Engine_Sections_Sampler import SurfaceElevationSampler
|
||||
from common_util.common_util_surface_sampler import SurfaceElevationSampler
|
||||
from config.config_system import (
|
||||
SECTION_CROSS_HALF_WIDTH_M,
|
||||
SECTION_CROSS_SAMPLE_INTERVAL_M,
|
||||
|
||||
@@ -14,7 +14,6 @@ from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import JSONResponse
|
||||
from pyproj import Transformer
|
||||
|
||||
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
|
||||
@@ -25,12 +24,8 @@ from B05_wf2_Route.B05_wf2_Route_Engine_Drainage_Store import (
|
||||
resample_boundary,
|
||||
save_boundary_overrides,
|
||||
)
|
||||
from B05_wf2_Route.B05_wf2_Route_Repository import (
|
||||
get_latest_route,
|
||||
get_route_points,
|
||||
get_surface_crs_epsg,
|
||||
)
|
||||
from common_util.common_util_route_geometry import StructureCandidate, build_route_vertices
|
||||
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
|
||||
|
||||
@@ -51,36 +46,21 @@ def _candidate_payload(candidate: StructureCandidate, to_lonlat: Any) -> dict[st
|
||||
}
|
||||
|
||||
|
||||
async def _prepare(project_id: UUID) -> 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)
|
||||
route = await get_latest_route(connection, project_id)
|
||||
if not route:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"status": "error", "message": "확정된 경로가 없습니다."},
|
||||
)
|
||||
points = await get_route_points(connection, int(route["id"]))
|
||||
surface_model_id = route.get("surface_model_id")
|
||||
epsg = await get_surface_crs_epsg(
|
||||
connection, project_id, int(surface_model_id) if surface_model_id else 0
|
||||
)
|
||||
async def _prepare(project_id: UUID) -> DrainageContext | JSONResponse:
|
||||
"""노선·종단 Z·좌표 변환기를 준비한다. 도엽 피처는 읽지 않는다(분석을 안 하므로).
|
||||
|
||||
vertices = build_route_vertices(points)
|
||||
if len(vertices) < 2:
|
||||
노선 기준선은 원청 계획노선 CSV다 — B04 격자가 그 노선으로 도로 셀을 구웠기 때문이다.
|
||||
종단 Z만 B05 계획고/경로 정점 우선으로 갈아 끼운다(공용 준비기가 판단).
|
||||
"""
|
||||
context, reason = await load_drainage_context(project_id)
|
||||
if context is None:
|
||||
return JSONResponse(status_code=404, content={"status": "error", "message": reason})
|
||||
if context.route_id is None:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"status": "error", "message": "노선 좌표가 부족합니다."},
|
||||
status_code=404,
|
||||
content={"status": "error", "message": "확정된 경로가 없습니다."},
|
||||
)
|
||||
transformer = Transformer.from_crs(f"EPSG:{epsg or 5186}", "EPSG:4326", always_xy=True)
|
||||
return {
|
||||
"route_id": int(route["id"]),
|
||||
"vertices": vertices,
|
||||
"stored_path": stored_path,
|
||||
"to_lonlat": lambda x, y: transformer.transform(x, y),
|
||||
}
|
||||
return context
|
||||
|
||||
|
||||
@router.post("/{project_id}/drainage/basins", response_model=None)
|
||||
@@ -100,7 +80,7 @@ async def post_drainage_basins(
|
||||
confirmed = _parse_chainages(raw) if isinstance(raw, list) else []
|
||||
|
||||
detail = await asyncio.to_thread(
|
||||
build_drainage_detail, prepared["stored_path"], prepared["vertices"], confirmed
|
||||
build_drainage_detail, prepared.stored_path, prepared.vertices, confirmed
|
||||
)
|
||||
if detail is None:
|
||||
return JSONResponse(
|
||||
@@ -111,21 +91,23 @@ async def post_drainage_basins(
|
||||
},
|
||||
)
|
||||
|
||||
to_lonlat = prepared["to_lonlat"]
|
||||
to_lonlat = prepared.to_lonlat
|
||||
# 2차 전체 유역 외곽선 — 편집 핸들 간격으로 다시 찍고 저장된 편집분을 얹는다.
|
||||
# 격자 경계라 원래 정점이 1m 간격이라 그대로는 손으로 잡을 수 없다.
|
||||
boundary = resample_boundary(detail.basin_lonlat)
|
||||
stored_overrides = load_boundary_overrides(prepared["stored_path"])
|
||||
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)
|
||||
save_boundary_overrides(prepared.stored_path, kept)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"project_id": str(project_id),
|
||||
"route_id": prepared["route_id"],
|
||||
"route_id": prepared.route_id,
|
||||
# 종단 Z를 어디서 가져왔는지 — 관 담당 구간이 갈리는 근거라 화면에서 확인 가능해야 한다.
|
||||
"z_source": prepared.z_source,
|
||||
# B04가 남긴 그대로 — 계획도로선. 외곽선만 편집분을 반영해 내보낸다.
|
||||
"route_lonlat": detail.route_lonlat,
|
||||
"main_polygon_lonlat": boundary,
|
||||
|
||||
@@ -208,11 +208,13 @@ class _SectionGeometry:
|
||||
# 절/성토 역할은 손대지 않는다. ground_at이 없으면 section_mode 기반 역할을 쓴다.
|
||||
if ground_at is not None:
|
||||
self.left_role = (
|
||||
"cut" if ground_at(self.left_extent) > self.road_z(self.left_extent) + 1e-3
|
||||
"cut"
|
||||
if ground_at(self.left_extent) > self.road_z(self.left_extent) + 1e-3
|
||||
else "fill"
|
||||
)
|
||||
self.right_role = (
|
||||
"cut" if ground_at(-self.right_extent) > self.road_z(-self.right_extent) + 1e-3
|
||||
"cut"
|
||||
if ground_at(-self.right_extent) > self.road_z(-self.right_extent) + 1e-3
|
||||
else "fill"
|
||||
)
|
||||
|
||||
@@ -583,42 +585,3 @@ def compute_cross_design(
|
||||
if preset_key == "rock" and rock_boundary_offset_m is not None:
|
||||
result["rock_boundary_offset_m"] = round(float(rock_boundary_offset_m), 4)
|
||||
return result
|
||||
|
||||
|
||||
def design_elevation_from_longitudinal(
|
||||
longitudinal: dict[str, Any], chainage_m: float
|
||||
) -> float | None:
|
||||
"""종단 계획선(design_profiles) 샘플을 chainage 기준 선형보간해 계획고를 구한다.
|
||||
|
||||
프론트 designElevationAt과 동일 규칙(범위 밖 양 끝값 클램프). 계획선이 없으면
|
||||
None을 반환해 지반고 폴백/오류 처리를 호출부에 맡긴다.
|
||||
"""
|
||||
profiles = longitudinal.get("design_profiles") if isinstance(longitudinal, dict) else None
|
||||
if not isinstance(profiles, list) or not profiles:
|
||||
return None
|
||||
samples = [
|
||||
s
|
||||
for s in profiles[0].get("samples", [])
|
||||
if isinstance(s.get("elevation_m"), (int, float))
|
||||
and isinstance(s.get("chainage_m"), (int, float))
|
||||
]
|
||||
if not samples:
|
||||
return None
|
||||
if chainage_m <= samples[0]["chainage_m"]:
|
||||
return float(samples[0]["elevation_m"])
|
||||
last = samples[-1]
|
||||
if chainage_m >= last["chainage_m"]:
|
||||
return float(last["elevation_m"])
|
||||
for index in range(1, len(samples)):
|
||||
previous = samples[index - 1]
|
||||
current = samples[index]
|
||||
if chainage_m > current["chainage_m"]:
|
||||
continue
|
||||
span = current["chainage_m"] - previous["chainage_m"]
|
||||
if span <= 0:
|
||||
return float(current["elevation_m"])
|
||||
ratio = (chainage_m - previous["chainage_m"]) / span
|
||||
return float(
|
||||
previous["elevation_m"] + (current["elevation_m"] - previous["elevation_m"]) * ratio
|
||||
)
|
||||
return float(last["elevation_m"])
|
||||
|
||||
@@ -19,10 +19,7 @@ from B05_wf2_Route.B05_wf2_Route_Engine_Sections import (
|
||||
)
|
||||
from B05_wf2_Route.B05_wf2_Route_Engine_Sections_Core import SectionGenerationOptions
|
||||
from B05_wf2_Route.B05_wf2_Route_Router_Confirm import _merge_uphill_overrides_into_longitudinal
|
||||
from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Engine_Design import (
|
||||
compute_cross_design,
|
||||
design_elevation_from_longitudinal,
|
||||
)
|
||||
from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Engine_Design import compute_cross_design
|
||||
from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Repository import (
|
||||
confirm_sections_for_route,
|
||||
count_cross_sections,
|
||||
@@ -56,6 +53,7 @@ from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Schema import (
|
||||
SectionSummaryResponse,
|
||||
)
|
||||
from common_util.common_util_auth import verify_session
|
||||
from common_util.common_util_route_profile import design_elevation_from_longitudinal
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from common_util.common_util_surface_confirmation import get_surface_confirmation_params
|
||||
from common_util.common_util_workflow_state import complete_stage, get_workflow_state
|
||||
@@ -115,9 +113,7 @@ async def get_forest_road_min_widths(project_id: UUID) -> dict[str, dict[str, fl
|
||||
|
||||
|
||||
# 아래 두 엔드포인트는 `/sections/{route_id}`(int)보다 먼저 선언해 라우팅 충돌을 막는다.
|
||||
@router.get(
|
||||
"/{project_id}/sections/company-standards", response_model=CompanyStandardListResponse
|
||||
)
|
||||
@router.get("/{project_id}/sections/company-standards", response_model=CompanyStandardListResponse)
|
||||
async def list_company_standards(
|
||||
project_id: UUID, session: dict[str, Any] = Depends(verify_session)
|
||||
) -> CompanyStandardListResponse:
|
||||
|
||||
@@ -15,9 +15,6 @@ import math
|
||||
from typing import Any
|
||||
from uuid import uuid5
|
||||
|
||||
from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Engine_Design import (
|
||||
design_elevation_from_longitudinal,
|
||||
)
|
||||
from B07_wf4_DesignDetail.B07_wf4_DesignDetail_Engine_Cad import (
|
||||
_ENTITY_NS,
|
||||
DESIGN_COLOR,
|
||||
@@ -48,6 +45,7 @@ from B07_wf4_DesignDetail.B07_wf4_DesignDetail_Engine_Template import (
|
||||
entities_bbox,
|
||||
frame_entities,
|
||||
)
|
||||
from common_util.common_util_route_profile import design_elevation_from_longitudinal
|
||||
|
||||
# 종단 전용 레이어: 그래프 축·격자(잠금 — 참조용, 편집 제외).
|
||||
LONG_GRID_LAYER_ID = "b07-long-grid"
|
||||
|
||||
@@ -13,10 +13,7 @@ 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_Sections import prune_stale_cross_files
|
||||
from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Engine_Design import (
|
||||
compute_cross_design,
|
||||
design_elevation_from_longitudinal,
|
||||
)
|
||||
from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Engine_Design import compute_cross_design
|
||||
from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Repository import (
|
||||
get_confirmed_route_context,
|
||||
get_cross_section_design,
|
||||
@@ -43,6 +40,7 @@ from B07_wf4_DesignDetail.B07_wf4_DesignDetail_Schema import (
|
||||
DesignDrawingListResponse,
|
||||
DesignDrawingResponse,
|
||||
)
|
||||
from common_util.common_util_route_profile import design_elevation_from_longitudinal
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from common_util.common_util_workflow_state import complete_stage, start_stage
|
||||
from config.config_db import get_db_pool
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
"""배수유역 세부 설계 입력 준비 (B04 관리자 화면 · B05 사용자 화면 공용).
|
||||
|
||||
두 화면이 같은 관 목록과 같은 세부유역을 보여 주려면 **입력이 한 글자도 달라선 안 된다**
|
||||
(2026-08-01 사용자 지시). 그래서 노선·종단 Z·좌표계를 여기 한 곳에서 만들어 양쪽에 넘긴다.
|
||||
|
||||
노선 기준선은 B05가 푼 최적 경로가 아니라 **B03이 업로드한 원청 계획노선 CSV**다. B04 격자
|
||||
해석이 그 노선으로 도로 셀을 구웠으므로, 다른 노선의 누가거리를 쓰면 도로 셀과 관 위치가
|
||||
어긋난다. 종단 Z만 상황에 따라 갈아 끼운다 → [[common_util_route_profile]].
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from pyproj import Transformer
|
||||
|
||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||||
from B05_wf2_Route.B05_wf2_Route_Repository import (
|
||||
get_latest_route,
|
||||
get_route_points,
|
||||
get_surface_crs_epsg,
|
||||
)
|
||||
from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Repository import get_longitudinal_section
|
||||
from common_util.common_util_route_geometry import (
|
||||
RouteVertex,
|
||||
build_route_vertices,
|
||||
find_planned_route_file,
|
||||
read_planned_route_csv,
|
||||
)
|
||||
from common_util.common_util_route_profile import Z_SOURCE_CSV, resolve_route_profile
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from common_util.common_util_surface_confirmation import get_surface_confirmation_params
|
||||
from common_util.common_util_surface_sampler import build_surface_sampler
|
||||
from config.config_db import get_db_pool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_INPUT_SUBDIR = Path("B03_FileInput") / "input"
|
||||
_MODELS_SUBDIR = Path("B04_wf1_Surface") / "models"
|
||||
|
||||
|
||||
@dataclass
|
||||
class DrainageContext:
|
||||
"""세부 설계 한 번에 필요한 입력 묶음."""
|
||||
|
||||
stored_path: str
|
||||
project_root: Path
|
||||
vertices: list[RouteVertex] = field(default_factory=list)
|
||||
z_source: str = Z_SOURCE_CSV
|
||||
epsg: int = 5186
|
||||
route_id: int | None = None
|
||||
to_lonlat: Callable[[float, float], tuple[float, float]] = lambda x, y: (x, y)
|
||||
|
||||
|
||||
async def load_drainage_context(project_id: UUID) -> tuple[DrainageContext | None, str]:
|
||||
"""노선·종단 Z·좌표계를 준비한다. 실패하면 (None, 사용자에게 보일 사유).
|
||||
|
||||
B05 확정 경로는 **있으면 쓰고 없으면 넘어간다** — B04는 WF1 화면이라 아직 경로가 없다.
|
||||
"""
|
||||
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 None, "프로젝트 저장 경로가 없습니다."
|
||||
route = await get_latest_route(connection, project_id)
|
||||
route_points: list[dict[str, Any]] = []
|
||||
longitudinal: dict[str, Any] | None = None
|
||||
if route:
|
||||
route_points = await get_route_points(connection, int(route["id"]))
|
||||
section = await get_longitudinal_section(connection, project_id, int(route["id"]))
|
||||
longitudinal = (section or {}).get("data")
|
||||
surface_model_id = (route or {}).get("surface_model_id")
|
||||
db_epsg = await get_surface_crs_epsg(
|
||||
connection, project_id, int(surface_model_id) if surface_model_id else 0
|
||||
)
|
||||
surface_params = await get_surface_confirmation_params(connection, str(project_id))
|
||||
|
||||
project_root = Path(resolve_stored_project_path(stored_path))
|
||||
planned = await asyncio.to_thread(_read_planned_route, project_root)
|
||||
if planned is None or len(planned.vertices) < 2:
|
||||
return None, "원청 계획노선 CSV를 읽지 못했습니다. B03에서 노선 파일을 확인하세요."
|
||||
|
||||
sampler = await asyncio.to_thread(_open_sampler, project_root, surface_params)
|
||||
vertices, z_source = await asyncio.to_thread(
|
||||
resolve_route_profile,
|
||||
planned.vertices,
|
||||
route_vertices=build_route_vertices(route_points) if route_points else None,
|
||||
longitudinal=longitudinal,
|
||||
sampler=sampler,
|
||||
)
|
||||
|
||||
epsg = int(planned.epsg or db_epsg or 5186)
|
||||
transformer = Transformer.from_crs(f"EPSG:{epsg}", "EPSG:4326", always_xy=True)
|
||||
return (
|
||||
DrainageContext(
|
||||
stored_path=stored_path,
|
||||
project_root=project_root,
|
||||
vertices=vertices,
|
||||
z_source=z_source,
|
||||
epsg=epsg,
|
||||
route_id=int(route["id"]) if route else None,
|
||||
to_lonlat=lambda x, y: transformer.transform(x, y),
|
||||
),
|
||||
"",
|
||||
)
|
||||
|
||||
|
||||
def _read_planned_route(project_root: Path):
|
||||
"""원청 계획노선 CSV를 찾아 읽는다(파일 접근이라 스레드에서 돈다)."""
|
||||
path = find_planned_route_file(project_root / _INPUT_SUBDIR)
|
||||
return read_planned_route_csv(path) if path else None
|
||||
|
||||
|
||||
def _open_sampler(project_root: Path, surface_params: dict[str, Any]):
|
||||
"""확정 지표면 표고 sampler를 연다. 모델이 없으면 None(종단 Z가 다른 경로로 폴백)."""
|
||||
try:
|
||||
return build_surface_sampler(
|
||||
project_root / _MODELS_SUBDIR,
|
||||
str(surface_params["source_filter"]),
|
||||
str(surface_params["method"]),
|
||||
bool(surface_params["smooth"]),
|
||||
)
|
||||
except (FileNotFoundError, ValueError, OSError) as exc:
|
||||
logger.warning("배수유역: 확정 지표면 sampler를 열지 못했습니다 — %s", exc)
|
||||
return None
|
||||
@@ -0,0 +1,517 @@
|
||||
"""배수유역 세부 설계 공용 엔진 (B04 관리자 화면 · B05 사용자 화면 공용).
|
||||
|
||||
**격자 해석은 하지 않는다.** B04 배수유역 분석이 미리 돌려 저장한 결과를 읽어, 사용자가
|
||||
실제로 손대는 두 가지만 처리한다(2026-07-31 사용자 지시).
|
||||
|
||||
⑨ 관 간격이 최대치를 넘는 구간에 **최소 개수**로 관을 보충
|
||||
⑩ 측구 흐름으로 도로 셀 → 담당 관을 정하고, 셀이 도달한 도로 셀의 담당 관을 그대로
|
||||
그 셀의 유역 번호로 삼아 세부유역을 나눈다
|
||||
⑪ 사용자가 관을 옮기거나 추가하면 ⑩만 다시 돈다 — 격자 해석은 재사용한다
|
||||
|
||||
읽어 오는 것(`{배수유역 폴더}/`):
|
||||
· `03_road_routing.geojson` — 계획도로선 · 기본 배관 · 2차 전체 배수유역
|
||||
· `03_road_routing.npz` — 셀 → 도로 셀 귀속, 유하장, 강도, 도로 셀 제원, 셀 표고
|
||||
화살표(방향 코드)나 밴드 표고 같은 관리자 확인용 배열은 읽지 않는다 — 여기서는 필요 없고
|
||||
파일만 무거워진다.
|
||||
|
||||
**왜 common_util인가**: B04(관리자 트러블슈팅)와 B05(일반 사용자)가 같은 이름의 버튼을
|
||||
누르면 같은 결과가 나와야 한다(2026-08-01 사용자 지시). 두 벌로 두면 언젠가 갈라진다.
|
||||
읽는 폴더만 다르므로 폴더를 인자로 받고, B04/B05 각자의 어댑터가 경로를 정한다.
|
||||
격자 산출물의 규격(`GridSpec`·`STAGES`·`polygonize_labels`)은 B04가 만든 것이므로
|
||||
정의처인 B04 엔진을 그대로 참조한다(역방향 참조 없음).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Export import STAGES
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Flow import largest_ring, polygonize_labels
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Grid import GridSpec
|
||||
from common_util.common_util_route_geometry import (
|
||||
RouteVertex,
|
||||
StructureCandidate,
|
||||
interpolate_vertex,
|
||||
is_uphill_at,
|
||||
)
|
||||
from config.config_system import (
|
||||
DRAINAGE_DITCH_SAMPLE_M,
|
||||
DRAINAGE_PIPE_MAX_SPACING_M,
|
||||
DRAINAGE_PIPE_MIN_SPACING_M,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 관 위치 선정 점수 배분 — 흐름 강도가 주, 종단 저점이 보조.
|
||||
_SCORE_WEIGHT_STRENGTH = 0.7
|
||||
_SCORE_WEIGHT_SAG = 0.3
|
||||
# 성토부(내리막)는 물이 노선 밖으로 빠지므로 관 위치로 덜 선호한다.
|
||||
_SCORE_FILL_PENALTY = 0.5
|
||||
|
||||
|
||||
@dataclass
|
||||
class DrainageDetail:
|
||||
"""세부 설계 산출물 — 화면에 그릴 기하와 세부유역."""
|
||||
|
||||
route_lonlat: list[list[float]] = field(default_factory=list)
|
||||
basin_lonlat: list[list[float]] = field(default_factory=list)
|
||||
pipes: list[StructureCandidate] = field(default_factory=list)
|
||||
basins: list[WatershedBasin] = field(default_factory=list)
|
||||
grid_cell_m: float = 1.0
|
||||
# B04가 계산해 둔 평균 흐름 화살표를 그대로 넘긴다 — 여기서 다시 계산하지 않는다.
|
||||
flow_arrows: list[list[Any]] = field(default_factory=list)
|
||||
arrow_spacing_m: float = 0.0
|
||||
# 유역 안쪽 상류 세류망(WGS84 lon/lat 조각들). 화면 강조 표시용 — 계산에는 쓰지 않는다.
|
||||
upstream_lonlat: list[list[list[float]]] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class WatershedBasin:
|
||||
"""관 하나가 받는 세부 배수유역."""
|
||||
|
||||
index: int
|
||||
chainage_m: float
|
||||
outlet_x: float
|
||||
outlet_y: float
|
||||
boundary_xy: list[tuple[float, float]] = field(default_factory=list)
|
||||
area_m2: float = 0.0
|
||||
relief_m: float = 0.0
|
||||
flow_length_m: float = 0.0
|
||||
pipe_diameter_mm: float | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class RoadRouting:
|
||||
"""B04가 남긴 배수유역 분석 결과 — 세부유역을 나누는 데 필요한 최소 묶음."""
|
||||
|
||||
spec: GridSpec
|
||||
# (R*C,) int32 — 셀이 물길을 따라 도달하는 도로 셀 슬롯(−1 = 미도달).
|
||||
road_slot: np.ndarray
|
||||
path_length: np.ndarray # (R*C,) float32 — 그 도로 셀까지 물길 길이(m)
|
||||
elevation: np.ndarray # (R*C,) float32 — 셀 표고(유역 낙차 계산용)
|
||||
road_cell_index: np.ndarray # (K,) int32 — 도로 셀의 평탄 인덱스
|
||||
road_chainage: np.ndarray # (K,) float64 — 도로 셀의 누가거리(m)
|
||||
strength: np.ndarray # (K,) int64 — 도로 셀별 상류 셀 수
|
||||
# 화면에 그대로 그릴 기하(WGS84 lon/lat).
|
||||
route_lonlat: list[list[float]] = field(default_factory=list)
|
||||
basin_lonlat: list[list[float]] = field(default_factory=list)
|
||||
base_pipes: list[StructureCandidate] = field(default_factory=list)
|
||||
# 평균 흐름 화살표 — [x, y, 방위(도), 도로도달, 셀 수]. B04가 계산해 둔 그대로.
|
||||
flow_arrows: list[list[Any]] = field(default_factory=list)
|
||||
arrow_spacing_m: float = 0.0
|
||||
|
||||
@property
|
||||
def strength_curve(self) -> np.ndarray:
|
||||
"""누가거리 1m 구간별 유입 면적(㎡) 곡선 — 관 보충 위치 점수의 근거."""
|
||||
if self.road_chainage.size == 0:
|
||||
return np.zeros(1)
|
||||
bins = max(1, int(np.ceil(self.road_chainage.max())) + 1)
|
||||
index = np.clip(np.round(self.road_chainage).astype(np.int64), 0, bins - 1)
|
||||
weights = self.strength.astype(np.float64) * self.spec.cell_area_m2
|
||||
return np.bincount(index, weights=weights, minlength=bins)
|
||||
|
||||
|
||||
def build_detail(
|
||||
directory: Path,
|
||||
vertices: list[RouteVertex],
|
||||
confirmed_chainages: list[float] | None = None,
|
||||
) -> DrainageDetail | None:
|
||||
"""B04 분석 결과를 읽어 관을 보충하고 세부유역을 나눈다.
|
||||
|
||||
`confirmed_chainages`를 주면 그 위치를 관으로 확정하고(사용자 편집), 비우면 B04의
|
||||
기본 관에 최대 간격 규칙으로 최소 개수만 보충한다. 어느 쪽이든 격자 해석은 하지 않는다.
|
||||
"""
|
||||
routing = read_road_routing(directory)
|
||||
if routing is None or len(vertices) < 2:
|
||||
return None
|
||||
|
||||
if confirmed_chainages:
|
||||
pipes = pipes_from_chainages(vertices, confirmed_chainages)
|
||||
else:
|
||||
# 저장분의 기본 관은 누가거리만 신뢰한다 — 좌표는 현재 노선 위로 다시 찍는다.
|
||||
base = [
|
||||
StructureCandidate(
|
||||
chainage_m=pipe.chainage_m,
|
||||
x=interpolate_vertex(vertices, pipe.chainage_m)[0],
|
||||
y=interpolate_vertex(vertices, pipe.chainage_m)[1],
|
||||
reason=pipe.reason,
|
||||
)
|
||||
for pipe in routing.base_pipes
|
||||
]
|
||||
pipes = place_pipes(vertices, base, routing.strength_curve)
|
||||
|
||||
detail = DrainageDetail(
|
||||
route_lonlat=routing.route_lonlat,
|
||||
basin_lonlat=routing.basin_lonlat,
|
||||
pipes=pipes,
|
||||
grid_cell_m=routing.spec.cell_m,
|
||||
flow_arrows=routing.flow_arrows,
|
||||
arrow_spacing_m=routing.arrow_spacing_m,
|
||||
upstream_lonlat=read_upstream_lines(directory),
|
||||
)
|
||||
if not pipes:
|
||||
return detail
|
||||
pipe_of_slot = assign_road_cells_to_pipes(vertices, pipes, routing.road_chainage)
|
||||
detail.basins = assemble_basins(routing, pipes, pipe_of_slot)
|
||||
logger.info(
|
||||
"배수유역: 세부 설계 — 관 %d개(기본 %d + 보충 %d), 세부유역 %d개",
|
||||
len(pipes),
|
||||
sum(1 for pipe in pipes if pipe.reason != "spacing"),
|
||||
sum(1 for pipe in pipes if pipe.reason == "spacing"),
|
||||
len(detail.basins),
|
||||
)
|
||||
return detail
|
||||
|
||||
|
||||
def read_road_routing(directory: Path) -> RoadRouting | None:
|
||||
"""`03_road_routing` 산출물을 읽는다. 없으면 None."""
|
||||
prefix = STAGES["road_routing"]
|
||||
array_path = directory / f"{prefix}_road_routing.npz"
|
||||
if not array_path.exists():
|
||||
logger.warning("배수유역: B04 분석 결과가 없습니다 (%s).", array_path)
|
||||
return None
|
||||
try:
|
||||
with np.load(array_path, allow_pickle=False) as data:
|
||||
spec = GridSpec(
|
||||
x_min=float(data["x_min"]),
|
||||
y_max=float(data["y_max"]),
|
||||
cell_m=float(data["cell_m"]),
|
||||
n_rows=int(data["n_rows"]),
|
||||
n_cols=int(data["n_cols"]),
|
||||
)
|
||||
routing = RoadRouting(
|
||||
spec=spec,
|
||||
road_slot=data["road_slot"].reshape(-1),
|
||||
path_length=data["path_length"].reshape(-1),
|
||||
elevation=data["elevation"].reshape(-1),
|
||||
road_cell_index=data["road_cell_index"],
|
||||
road_chainage=data["road_chainage"],
|
||||
strength=data["strength"],
|
||||
)
|
||||
except (OSError, KeyError, ValueError):
|
||||
logger.warning("배수유역: B04 분석 결과를 읽지 못했습니다 (%s).", array_path)
|
||||
return None
|
||||
|
||||
_read_geometry(directory / f"{prefix}_road_routing.geojson", routing)
|
||||
logger.info(
|
||||
"배수유역: B04 결과 로드 — 격자 %d×%d, 도로 셀 %d, 기본 관 %d",
|
||||
spec.n_rows,
|
||||
spec.n_cols,
|
||||
routing.road_cell_index.size,
|
||||
len(routing.base_pipes),
|
||||
)
|
||||
return routing
|
||||
|
||||
|
||||
def _read_geometry(path: Path, routing: RoadRouting) -> None:
|
||||
"""계획도로선·2차 유역 외곽선·기본 관을 GeoJSON에서 읽어 채운다."""
|
||||
if not path.exists():
|
||||
logger.warning("배수유역: B04 기하 산출물이 없습니다 (%s).", path)
|
||||
return
|
||||
try:
|
||||
with path.open("r", encoding="utf-8") as file:
|
||||
document = json.load(file)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
logger.warning("배수유역: B04 기하 산출물을 읽지 못했습니다 (%s).", path)
|
||||
return
|
||||
routing.arrow_spacing_m = float(
|
||||
(document.get("properties") or {}).get("arrow_spacing_m") or 0.0
|
||||
)
|
||||
for feature in document.get("features", []):
|
||||
properties = feature.get("properties") or {}
|
||||
geometry = feature.get("geometry") or {}
|
||||
coordinates = geometry.get("coordinates")
|
||||
kind = properties.get("kind")
|
||||
if kind == "route" and geometry.get("type") == "LineString":
|
||||
routing.route_lonlat = coordinates
|
||||
elif kind == "basin_boundary" and geometry.get("type") == "Polygon" and coordinates:
|
||||
routing.basin_lonlat = coordinates[0]
|
||||
elif kind == "flow_arrow" and geometry.get("type") == "Point":
|
||||
# 화면이 미터로 그리므로 속성의 x·y를 쓴다(기하는 저장 규약상 lon/lat).
|
||||
routing.flow_arrows.append(
|
||||
[
|
||||
float(properties.get("x") or 0.0),
|
||||
float(properties.get("y") or 0.0),
|
||||
float(properties.get("azimuth_deg") or 0.0),
|
||||
bool(properties.get("reaches_road")),
|
||||
int(properties.get("cells") or 0),
|
||||
]
|
||||
)
|
||||
elif kind == "pipe" and geometry.get("type") == "Point":
|
||||
routing.base_pipes.append(
|
||||
StructureCandidate(
|
||||
chainage_m=float(properties.get("chainage_m") or 0.0),
|
||||
x=0.0,
|
||||
y=0.0,
|
||||
reason=str(properties.get("reason") or "stream"),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def read_upstream_lines(directory: Path) -> list[list[list[float]]]:
|
||||
"""`01_primary_region`에서 상류 세류망만 읽는다(화면 강조용).
|
||||
|
||||
유역 판정의 기준선이라 B04 오버레이에서도 같은 선을 굵게 그린다 — B05는 그 선을
|
||||
그대로 받아 표시만 한다.
|
||||
"""
|
||||
path = directory / f"{STAGES['primary_region']}_primary_region.geojson"
|
||||
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 []
|
||||
lines: list[list[list[float]]] = []
|
||||
for feature in document.get("features", []):
|
||||
properties = feature.get("properties") or {}
|
||||
geometry = feature.get("geometry") or {}
|
||||
if properties.get("kind") != "upstream":
|
||||
continue
|
||||
coordinates = geometry.get("coordinates")
|
||||
if geometry.get("type") == "LineString" and coordinates:
|
||||
lines.append(coordinates)
|
||||
elif geometry.get("type") == "MultiLineString" and coordinates:
|
||||
lines.extend(part for part in coordinates if part)
|
||||
return lines
|
||||
|
||||
|
||||
# ── ⑨ 관 최소 개수 보충 ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def place_pipes(
|
||||
vertices: list[RouteVertex],
|
||||
base_pipes: list[StructureCandidate],
|
||||
strength_curve: np.ndarray,
|
||||
) -> list[StructureCandidate]:
|
||||
"""B04가 정한 기본 관(세류 교차점)에, 최대 간격을 넘는 구간만 최소 개수로 보충한다.
|
||||
|
||||
기본 관은 여기서 다시 찾지 않는다 — B04 산출물에 이미 들어 있다.
|
||||
"""
|
||||
total_length = vertices[-1].chainage_m
|
||||
base: list[StructureCandidate] = []
|
||||
for candidate in sorted(base_pipes, key=lambda item: item.chainage_m):
|
||||
if base and candidate.chainage_m - base[-1].chainage_m < DRAINAGE_PIPE_MIN_SPACING_M:
|
||||
continue
|
||||
base.append(candidate)
|
||||
|
||||
filled: list[StructureCandidate] = []
|
||||
previous = 0.0
|
||||
for candidate in [*base, None]:
|
||||
boundary = candidate.chainage_m if candidate else total_length
|
||||
filled.extend(_fill_gap(vertices, strength_curve, previous, boundary))
|
||||
if candidate:
|
||||
filled.append(candidate)
|
||||
previous = candidate.chainage_m
|
||||
else:
|
||||
previous = boundary
|
||||
filled.sort(key=lambda item: item.chainage_m)
|
||||
return filled
|
||||
|
||||
|
||||
def _fill_gap(
|
||||
vertices: list[RouteVertex],
|
||||
strength_curve: np.ndarray,
|
||||
start_m: float,
|
||||
end_m: float,
|
||||
) -> list[StructureCandidate]:
|
||||
"""[start, end] 구간에 최대 간격을 지키는 **최소 개수**의 관을 배치한다.
|
||||
|
||||
필요 개수 n은 구간 길이로 정해지고(ceil(L/max) − 1), 각 관은 등분 위치를 중심으로
|
||||
허용 여유(slack) 안에서만 움직인다. 그래서 개수는 늘지 않으면서도 흐름 강도가 크고
|
||||
종단이 낮은 지점으로 붙는다.
|
||||
"""
|
||||
span = end_m - start_m
|
||||
if span <= DRAINAGE_PIPE_MAX_SPACING_M:
|
||||
return []
|
||||
count = int(np.ceil(span / DRAINAGE_PIPE_MAX_SPACING_M)) - 1
|
||||
if count <= 0:
|
||||
return []
|
||||
spacing = span / (count + 1)
|
||||
slack = max(0.0, (DRAINAGE_PIPE_MAX_SPACING_M - spacing) / 2.0)
|
||||
placed: list[StructureCandidate] = []
|
||||
for order in range(1, count + 1):
|
||||
nominal = start_m + spacing * order
|
||||
low = max(start_m + DRAINAGE_PIPE_MIN_SPACING_M, nominal - slack)
|
||||
high = min(end_m - DRAINAGE_PIPE_MIN_SPACING_M, nominal + slack)
|
||||
chosen = _best_position(vertices, strength_curve, low, high, nominal)
|
||||
x, y, _ = interpolate_vertex(vertices, chosen)
|
||||
placed.append(StructureCandidate(chainage_m=chosen, x=x, y=y, reason="spacing"))
|
||||
return placed
|
||||
|
||||
|
||||
def _best_position(
|
||||
vertices: list[RouteVertex],
|
||||
strength_curve: np.ndarray,
|
||||
low_m: float,
|
||||
high_m: float,
|
||||
fallback_m: float,
|
||||
) -> float:
|
||||
"""허용 구간 안에서 흐름 강도가 크고 종단이 낮은 위치를 고른다."""
|
||||
if high_m <= low_m:
|
||||
return fallback_m
|
||||
positions = np.arange(low_m, high_m + 1.0, 1.0)
|
||||
if positions.size == 0:
|
||||
return fallback_m
|
||||
index = np.clip(np.round(positions).astype(np.int64), 0, strength_curve.size - 1)
|
||||
strength = strength_curve[index]
|
||||
heights = np.array([interpolate_vertex(vertices, float(p))[2] for p in positions])
|
||||
|
||||
strength_score = strength / strength.max() if strength.max() > 0 else np.zeros_like(strength)
|
||||
height_span = float(heights.max() - heights.min())
|
||||
sag_score = (
|
||||
(heights.max() - heights) / height_span if height_span > 1e-6 else np.zeros_like(heights)
|
||||
)
|
||||
score = _SCORE_WEIGHT_STRENGTH * strength_score + _SCORE_WEIGHT_SAG * sag_score
|
||||
for order, position in enumerate(positions):
|
||||
if not is_uphill_at(vertices, float(position)):
|
||||
score[order] *= _SCORE_FILL_PENALTY
|
||||
return float(positions[int(np.argmax(score))])
|
||||
|
||||
|
||||
def pipes_from_chainages(
|
||||
vertices: list[RouteVertex], chainages: list[float]
|
||||
) -> list[StructureCandidate]:
|
||||
"""사용자가 확정·편집한 누가거리 목록을 관 후보로 되돌린다.
|
||||
|
||||
노선 밖 값은 시·종점으로 당긴다. 그대로 두면 마커는 끝점에 찍히는데 라벨만 −50m처럼
|
||||
나와 좌표와 표기가 어긋난다.
|
||||
"""
|
||||
total_length = vertices[-1].chainage_m
|
||||
clamped = {min(max(round(float(item), 2), 0.0), total_length) for item in chainages}
|
||||
pipes: list[StructureCandidate] = []
|
||||
for value in sorted(clamped):
|
||||
x, y, _ = interpolate_vertex(vertices, value)
|
||||
pipes.append(StructureCandidate(chainage_m=value, x=x, y=y, reason="confirmed"))
|
||||
return pipes
|
||||
|
||||
|
||||
# ── ⑩ 측구 흐름으로 도로 셀 → 담당 관 ───────────────────────────────────────
|
||||
|
||||
|
||||
def assign_road_cells_to_pipes(
|
||||
vertices: list[RouteVertex],
|
||||
pipes: list[StructureCandidate],
|
||||
road_chainage: np.ndarray,
|
||||
) -> np.ndarray:
|
||||
"""도로 셀마다 물이 실제로 흘러가는 담당 관 번호를 정한다.
|
||||
|
||||
노면 물은 측구를 타고 종단 내리막으로 흐르므로, 종단 계획선을 1차원 지형으로 보고
|
||||
같은 방식(내리막 추적 + 관에서 흡수)으로 푼다. 관이 없는 사그(저점)에 갇힌 구간은
|
||||
가장 가까운 관이 받는 것으로 본다.
|
||||
"""
|
||||
total_length = vertices[-1].chainage_m
|
||||
step = max(DRAINAGE_DITCH_SAMPLE_M, 0.5)
|
||||
stations = np.arange(0.0, total_length + step, step)
|
||||
heights = np.array([interpolate_vertex(vertices, float(s))[2] for s in stations])
|
||||
pipe_chainages = np.array([pipe.chainage_m for pipe in pipes])
|
||||
pipe_station = np.clip(np.round(pipe_chainages / step).astype(np.int64), 0, stations.size - 1)
|
||||
|
||||
# 앞뒤 이웃 중 더 낮은 쪽으로 흘려보낸다(양쪽 다 높으면 사그 = 제자리).
|
||||
back_z = np.full(stations.size, np.inf)
|
||||
back_z[1:] = heights[:-1]
|
||||
forward_z = np.full(stations.size, np.inf)
|
||||
forward_z[:-1] = heights[1:]
|
||||
go_back = (back_z < heights) & (back_z <= forward_z)
|
||||
go_forward = (forward_z < heights) & ~go_back
|
||||
receiver = np.arange(stations.size, dtype=np.int64)
|
||||
receiver[go_back] -= 1
|
||||
receiver[go_forward] += 1
|
||||
receiver[pipe_station] = pipe_station # 관은 물을 흡수한다
|
||||
|
||||
owner = np.full(stations.size, -1, dtype=np.int64)
|
||||
owner[pipe_station] = np.arange(pipe_chainages.size)
|
||||
jump = receiver
|
||||
for _ in range(40):
|
||||
next_jump = jump[jump]
|
||||
if np.array_equal(next_jump, jump):
|
||||
break
|
||||
jump = next_jump
|
||||
resolved = owner[jump]
|
||||
# 관 없는 사그에 갇힌 구간은 가장 가까운 관에 붙인다.
|
||||
orphan = resolved < 0
|
||||
if orphan.any() and pipe_chainages.size:
|
||||
nearest = np.abs(stations[orphan, None] - pipe_chainages[None, :]).argmin(axis=1)
|
||||
resolved[orphan] = nearest
|
||||
|
||||
slot_station = np.clip(np.round(road_chainage / step).astype(np.int64), 0, stations.size - 1)
|
||||
return resolved[slot_station].astype(np.int32)
|
||||
|
||||
|
||||
# ── ⑩ 세부유역 조립 ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def assemble_basins(
|
||||
routing: RoadRouting,
|
||||
pipes: list[StructureCandidate],
|
||||
pipe_of_slot: np.ndarray,
|
||||
) -> list[WatershedBasin]:
|
||||
"""셀이 도달한 도로 셀의 담당 관을 그대로 유역 번호로 삼아 세부유역을 만든다."""
|
||||
spec = routing.spec
|
||||
labels = np.full(spec.size, -1, dtype=np.int32)
|
||||
reached = routing.road_slot >= 0
|
||||
labels[reached] = pipe_of_slot[routing.road_slot[reached]]
|
||||
|
||||
polygons = polygonize_labels(spec, labels)
|
||||
cell_area = spec.cell_area_m2
|
||||
basins: list[WatershedBasin] = []
|
||||
for order, pipe in enumerate(pipes):
|
||||
member = labels == order
|
||||
count = int(member.sum())
|
||||
if count == 0:
|
||||
continue
|
||||
geometry = polygons.get(order)
|
||||
elevations = routing.elevation[member]
|
||||
highest = float(np.nanmax(elevations)) if np.isfinite(elevations).any() else 0.0
|
||||
outlet_z = _outlet_elevation(routing, order, pipe_of_slot)
|
||||
area = count * cell_area
|
||||
relief = max(0.0, highest - outlet_z)
|
||||
flow_length = float(routing.path_length[member].max())
|
||||
basins.append(
|
||||
WatershedBasin(
|
||||
index=len(basins) + 1,
|
||||
chainage_m=pipe.chainage_m,
|
||||
outlet_x=pipe.x,
|
||||
outlet_y=pipe.y,
|
||||
boundary_xy=largest_ring(geometry) if geometry is not None else [],
|
||||
area_m2=area,
|
||||
relief_m=relief,
|
||||
flow_length_m=flow_length,
|
||||
pipe_diameter_mm=estimate_pipe_diameter_mm(area, relief, flow_length),
|
||||
)
|
||||
)
|
||||
return basins
|
||||
|
||||
|
||||
def _outlet_elevation(routing: RoadRouting, pipe_order: int, pipe_of_slot: np.ndarray) -> float:
|
||||
"""관이 담당하는 도로 셀들의 최저 표고 = 유역 출구 표고."""
|
||||
slots = np.flatnonzero(pipe_of_slot == pipe_order)
|
||||
if slots.size == 0:
|
||||
return 0.0
|
||||
elevations = routing.elevation[routing.road_cell_index[slots]]
|
||||
finite = elevations[np.isfinite(elevations)]
|
||||
return float(finite.min()) if finite.size else 0.0
|
||||
|
||||
|
||||
def estimate_pipe_diameter_mm(
|
||||
area_m2: float,
|
||||
relief_m: float,
|
||||
flow_length_m: float,
|
||||
rainfall_mm_per_hour: float | None = None,
|
||||
) -> float | None:
|
||||
"""유역 제원으로 배수 파이프 관경(mm)을 산정한다.
|
||||
|
||||
100년 강우빈도와 유역 경사면을 곱해 유출량을 구하고, 그 유량으로 관경을 정하는 것이
|
||||
목적이다. **수식은 아직 확정되지 않았다** — 사용자가 로직을 제공하면 여기를 채운다.
|
||||
그때까지는 None을 돌려 호출부가 "미정"으로 표기하게 한다.
|
||||
"""
|
||||
# TODO(사용자 로직 대기): 100년 강우강도 × 유역면적 × 유출계수 → 유량 Q → 관경 D 산정.
|
||||
_ = (area_m2, relief_m, flow_length_m, rainfall_mm_per_hour)
|
||||
return None
|
||||
@@ -0,0 +1,139 @@
|
||||
"""관 매설 지점 정본 저장소 (B04 관리자 화면 · B05 사용자 화면 공용).
|
||||
|
||||
해석 산출물(`01~03`)은 다시 돌리면 덮어써도 되지만, 사용자가 찍고 옮긴 관은 그러면 안 된다.
|
||||
그래서 편집분만 `{배수유역 폴더}/edits/pipe_points.json`에 따로 남기고 두 화면이 같은 파일을
|
||||
읽고 쓴다 — 관리자 화면에서 옮긴 관이 사용자 화면에서 다르게 보이면 안 되기 때문이다
|
||||
(2026-08-01 사용자 지시).
|
||||
|
||||
위치는 좌표가 아니라 **누가거리(chainage_m)** 로 저장한다. 지면 필터나 지표면 모델을 바꾸면
|
||||
종단 Z가 달라지지만 관이 놓인 자리는 그대로여야 하고, 그때는 세부유역만 다시 나누면 된다.
|
||||
노선 자체가 바뀌면(`route_signature` 불일치) 기준이 사라지므로 전량 버리고 다시 만든다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
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_json import atomic_write_json
|
||||
from common_util.common_util_route_geometry import RouteVertex
|
||||
from config.config_system import (
|
||||
DRAINAGE_DETAIL_FILENAME,
|
||||
DRAINAGE_EDITS_DIRNAME,
|
||||
DRAINAGE_PIPE_POINTS_FILENAME,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 관이 그 자리에 있는 이유. 화면 마커 모양과 "자동/수동" 구분이 여기에 달려 있다.
|
||||
PIPE_SOURCE_STREAM = "stream" # 기본 관 — 도로 × 상류 세류선 교차점
|
||||
PIPE_SOURCE_SPACING = "spacing" # 자동 보충 — 관 최대 간격 규칙
|
||||
PIPE_SOURCE_USER = "user" # 수동 — 사용자가 우클릭으로 추가하거나 옮긴 관
|
||||
_KNOWN_SOURCES = (PIPE_SOURCE_STREAM, PIPE_SOURCE_SPACING, PIPE_SOURCE_USER)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PipePoint:
|
||||
"""계획선 위 관 매설 지점 한 개."""
|
||||
|
||||
chainage_m: float
|
||||
source: str = PIPE_SOURCE_USER
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {"chainage_m": round(float(self.chainage_m), 2), "source": self.source}
|
||||
|
||||
|
||||
def edits_dir(stored_path: str) -> Path:
|
||||
return drainage_dir(stored_path) / DRAINAGE_EDITS_DIRNAME
|
||||
|
||||
|
||||
def pipe_points_path(stored_path: str) -> Path:
|
||||
return edits_dir(stored_path) / DRAINAGE_PIPE_POINTS_FILENAME
|
||||
|
||||
|
||||
def detail_basins_path(stored_path: str) -> Path:
|
||||
return drainage_dir(stored_path) / DRAINAGE_DETAIL_FILENAME
|
||||
|
||||
|
||||
def route_signature(vertices: list[RouteVertex]) -> str:
|
||||
"""노선이 바뀌었는지 판별할 지문. 정점 좌표를 0.01m로 끊어 해시한다.
|
||||
|
||||
연장만 보면 노선이 통째로 옮겨져도 같은 값이 나온다. 좌표를 다 넣되 소수점을 끊어
|
||||
부동소수 잡음으로 지문이 흔들리지 않게 한다.
|
||||
"""
|
||||
digest = hashlib.sha1(usedforsecurity=False)
|
||||
for vertex in vertices:
|
||||
digest.update(f"{vertex.x:.2f},{vertex.y:.2f};".encode())
|
||||
return f"{len(vertices)}-{digest.hexdigest()[:16]}"
|
||||
|
||||
|
||||
def load_pipe_points(stored_path: str, signature: str) -> list[PipePoint] | None:
|
||||
"""저장된 관 지점을 읽는다. 파일이 없거나 노선이 바뀌었으면 None(= 다시 만들어야 함)."""
|
||||
path = pipe_points_path(stored_path)
|
||||
if not path.exists():
|
||||
return None
|
||||
try:
|
||||
with path.open("r", encoding="utf-8") as file:
|
||||
document = json.load(file)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
logger.warning("배수유역: 관 지점 파일을 읽지 못했습니다 (%s).", path)
|
||||
return None
|
||||
stored_signature = str(document.get("route_signature") or "")
|
||||
if stored_signature != signature:
|
||||
logger.info("배수유역: 노선이 바뀌어 저장된 관 지점을 버립니다 (%s).", path.name)
|
||||
return None
|
||||
return parse_pipe_points(document.get("points"))
|
||||
|
||||
|
||||
def parse_pipe_points(values: Any) -> list[PipePoint]:
|
||||
"""외부에서 들어온 관 목록(파일·요청 본문)을 정리한다. 누가거리 오름차순."""
|
||||
if not isinstance(values, list):
|
||||
return []
|
||||
points: list[PipePoint] = []
|
||||
for item in values:
|
||||
if isinstance(item, (int, float)):
|
||||
points.append(PipePoint(chainage_m=float(item)))
|
||||
continue
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
chainage = item.get("chainage_m")
|
||||
if not isinstance(chainage, (int, float)):
|
||||
continue
|
||||
source = str(item.get("source") or PIPE_SOURCE_USER)
|
||||
points.append(
|
||||
PipePoint(
|
||||
chainage_m=float(chainage),
|
||||
source=source if source in _KNOWN_SOURCES else PIPE_SOURCE_USER,
|
||||
)
|
||||
)
|
||||
points.sort(key=lambda point: point.chainage_m)
|
||||
return points
|
||||
|
||||
|
||||
def save_pipe_points(stored_path: str, signature: str, points: list[PipePoint]) -> int:
|
||||
"""관 지점을 정본 파일에 쓴다. 저장된 개수를 돌려준다."""
|
||||
path = pipe_points_path(stored_path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
atomic_write_json(
|
||||
path,
|
||||
{
|
||||
"route_signature": signature,
|
||||
"points": [point.as_dict() for point in points],
|
||||
},
|
||||
)
|
||||
logger.info("배수유역: 관 지점 %d개를 저장했습니다 (%s).", len(points), path.name)
|
||||
return len(points)
|
||||
|
||||
|
||||
def save_detail_basins(stored_path: str, features: list[dict[str, Any]]) -> Path:
|
||||
"""세부유역을 GeoJSON으로 남긴다(파생물 — 관 지점만 있으면 언제든 다시 만든다)."""
|
||||
path = detail_basins_path(stored_path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
atomic_write_json(path, {"type": "FeatureCollection", "features": features})
|
||||
logger.info("배수유역: 세부유역 %d개를 저장했습니다 (%s).", len(features), path.name)
|
||||
return path
|
||||
@@ -0,0 +1,173 @@
|
||||
"""계획노선 종단 Z 해석 (공용).
|
||||
|
||||
배수유역 세부 설계는 **노면 물이 어느 쪽으로 흐르는가**로 관 담당 구간을 나눈다. 그래서
|
||||
노선 정점의 Z가 무엇이냐에 따라 결과가 통째로 달라진다. B04(관리자)와 B05(사용자)가 서로
|
||||
다른 Z를 쓰면 같은 프로젝트에서 세부유역이 갈라지므로, 어느 Z를 쓸지는 여기 한 곳에서만
|
||||
정한다(2026-08-01 사용자 지시).
|
||||
|
||||
우선순위
|
||||
① B05 종단 계획고(`longitudinal_sections.data.design_profiles`) — 사용자가 편집을 마친 정본
|
||||
② B05 경로 정점 Z(`route_points.z`) — 계획고 편집 전 단계
|
||||
③ 확정 지표면 모델 샘플링 — B05를 아직 지나지 않은 B04 시점의 기본값
|
||||
④ 원청 계획노선 CSV의 z — 위 셋이 모두 없을 때의 최후 폴백
|
||||
|
||||
①②는 B05가 푼 **최적 경로** 기하 위의 값이다. 원청 계획노선과 노선 자체가 다르면 같은
|
||||
누가거리라도 다른 지점이므로, 노선이 실질적으로 같을 때만 채택하고 아니면 ③으로 내려간다.
|
||||
|
||||
DB나 페이지 모듈은 여기서 건드리지 않는다 — 호출부(B04/B05 라우터)가 읽어서 넘긴다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
from dataclasses import replace
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from common_util.common_util_route_geometry import RouteVertex, interpolate_vertex
|
||||
from common_util.common_util_surface_sampler import SurfaceElevationSampler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 노선 동일성 판정 — 시·종점이 이 거리 안이고 연장 차이가 아래 비율 안이면 같은 노선으로 본다.
|
||||
# 최적 경로는 원청 노선을 따라가되 격자 해상도만큼 흔들리므로 여유를 둔다.
|
||||
ROUTE_MATCH_ENDPOINT_TOLERANCE_M = 20.0
|
||||
ROUTE_MATCH_LENGTH_TOLERANCE = 0.05
|
||||
|
||||
# Z 출처 표시 — 화면과 로그가 어느 종단을 쓴 결과인지 알 수 있어야 한다.
|
||||
Z_SOURCE_DESIGN = "design_profile"
|
||||
Z_SOURCE_ROUTE_POINTS = "route_points"
|
||||
Z_SOURCE_SURFACE = "surface"
|
||||
Z_SOURCE_CSV = "csv"
|
||||
|
||||
|
||||
def design_elevation_from_longitudinal(
|
||||
longitudinal: dict[str, Any], chainage_m: float
|
||||
) -> float | None:
|
||||
"""종단 계획선(design_profiles) 샘플을 chainage 기준 선형보간해 계획고를 구한다.
|
||||
|
||||
프론트 designElevationAt과 동일 규칙(범위 밖 양 끝값 클램프). 계획선이 없으면
|
||||
None을 반환해 지반고 폴백/오류 처리를 호출부에 맡긴다.
|
||||
"""
|
||||
profiles = longitudinal.get("design_profiles") if isinstance(longitudinal, dict) else None
|
||||
if not isinstance(profiles, list) or not profiles:
|
||||
return None
|
||||
samples = [
|
||||
s
|
||||
for s in profiles[0].get("samples", [])
|
||||
if isinstance(s.get("elevation_m"), (int, float))
|
||||
and isinstance(s.get("chainage_m"), (int, float))
|
||||
]
|
||||
if not samples:
|
||||
return None
|
||||
if chainage_m <= samples[0]["chainage_m"]:
|
||||
return float(samples[0]["elevation_m"])
|
||||
last = samples[-1]
|
||||
if chainage_m >= last["chainage_m"]:
|
||||
return float(last["elevation_m"])
|
||||
for index in range(1, len(samples)):
|
||||
previous = samples[index - 1]
|
||||
current = samples[index]
|
||||
if chainage_m > current["chainage_m"]:
|
||||
continue
|
||||
span = current["chainage_m"] - previous["chainage_m"]
|
||||
if span <= 0:
|
||||
return float(current["elevation_m"])
|
||||
ratio = (chainage_m - previous["chainage_m"]) / span
|
||||
return float(
|
||||
previous["elevation_m"] + (current["elevation_m"] - previous["elevation_m"]) * ratio
|
||||
)
|
||||
return float(last["elevation_m"])
|
||||
|
||||
|
||||
def routes_match(left: list[RouteVertex], right: list[RouteVertex]) -> bool:
|
||||
"""두 노선이 실질적으로 같은 노선인지 본다(시·종점 근접 + 연장 유사).
|
||||
|
||||
최적 경로는 원청 노선을 격자 위에서 다시 그은 것이라 정점이 하나도 겹치지 않을 수
|
||||
있다. 그래서 정점 대조가 아니라 끝점과 연장으로만 판단한다.
|
||||
"""
|
||||
if len(left) < 2 or len(right) < 2:
|
||||
return False
|
||||
start_gap = math.dist((left[0].x, left[0].y), (right[0].x, right[0].y))
|
||||
end_gap = math.dist((left[-1].x, left[-1].y), (right[-1].x, right[-1].y))
|
||||
if max(start_gap, end_gap) > ROUTE_MATCH_ENDPOINT_TOLERANCE_M:
|
||||
return False
|
||||
left_length = left[-1].chainage_m
|
||||
right_length = right[-1].chainage_m
|
||||
if left_length <= 0 or right_length <= 0:
|
||||
return False
|
||||
return abs(left_length - right_length) / left_length <= ROUTE_MATCH_LENGTH_TOLERANCE
|
||||
|
||||
|
||||
def resolve_route_profile(
|
||||
vertices: list[RouteVertex],
|
||||
*,
|
||||
route_vertices: list[RouteVertex] | None = None,
|
||||
longitudinal: dict[str, Any] | None = None,
|
||||
sampler: SurfaceElevationSampler | None = None,
|
||||
) -> tuple[list[RouteVertex], str]:
|
||||
"""계획노선 정점에 종단 Z를 채워 돌려준다. (정점 목록, Z 출처) 형태.
|
||||
|
||||
`vertices`는 원청 계획노선(B04·B05 공용 기준선)이고, `route_vertices`는 B05가 푼 최적
|
||||
경로다. 노선이 같을 때만 ①②를 쓰고, 아니면 ③(지표면 샘플링)으로 내려간다.
|
||||
"""
|
||||
if len(vertices) < 2:
|
||||
return vertices, Z_SOURCE_CSV
|
||||
|
||||
matched = bool(route_vertices) and routes_match(vertices, route_vertices or [])
|
||||
if matched and longitudinal:
|
||||
elevations = [
|
||||
design_elevation_from_longitudinal(longitudinal, vertex.chainage_m)
|
||||
for vertex in vertices
|
||||
]
|
||||
if all(value is not None for value in elevations):
|
||||
logger.info("노선 종단 Z: B05 계획고(design_profiles) 채택 — 정점 %d개", len(vertices))
|
||||
return (
|
||||
[replace(v, z=float(z)) for v, z in zip(vertices, elevations)],
|
||||
Z_SOURCE_DESIGN,
|
||||
)
|
||||
|
||||
if matched and route_vertices:
|
||||
logger.info("노선 종단 Z: B05 경로 정점(route_points) 채택 — 정점 %d개", len(vertices))
|
||||
return (
|
||||
[replace(v, z=interpolate_vertex(route_vertices, v.chainage_m)[2]) for v in vertices],
|
||||
Z_SOURCE_ROUTE_POINTS,
|
||||
)
|
||||
|
||||
if sampler is not None:
|
||||
sampled = _sample_z(vertices, sampler)
|
||||
if sampled is not None:
|
||||
logger.info("노선 종단 Z: 확정 지표면 샘플링 채택 — 정점 %d개", len(vertices))
|
||||
return sampled, Z_SOURCE_SURFACE
|
||||
|
||||
logger.info("노선 종단 Z: 원청 CSV z 유지 — 정점 %d개", len(vertices))
|
||||
return vertices, Z_SOURCE_CSV
|
||||
|
||||
|
||||
def _sample_z(
|
||||
vertices: list[RouteVertex], sampler: SurfaceElevationSampler
|
||||
) -> list[RouteVertex] | None:
|
||||
"""확정 지표면에서 노선 정점의 지반고를 뽑는다.
|
||||
|
||||
모델 밖으로 나간 정점은 유효한 이웃 정점 값으로 메운다 — 노선 한두 점이 DTM 가장자리를
|
||||
벗어났다고 종단 전체를 버리면 세부유역을 못 나눈다. 유효한 값이 하나도 없으면 None.
|
||||
"""
|
||||
xy = np.array([[vertex.x, vertex.y] for vertex in vertices], dtype=np.float64)
|
||||
try:
|
||||
z, valid = sampler.sample_xy(xy)
|
||||
except (ValueError, OSError) as exc:
|
||||
logger.warning("노선 종단 Z: 지표면 샘플링 실패 — %s", exc)
|
||||
return None
|
||||
if not bool(valid.any()):
|
||||
logger.warning("노선 종단 Z: 노선이 확정 지표면 범위 밖입니다.")
|
||||
return None
|
||||
if not bool(valid.all()):
|
||||
index = np.arange(z.size)
|
||||
known = index[valid]
|
||||
z = np.interp(index, known, z[valid])
|
||||
logger.info(
|
||||
"노선 종단 Z: 지표면 밖 정점 %d개를 이웃 값으로 메웠습니다.", int((~valid).sum())
|
||||
)
|
||||
return [replace(vertex, z=float(value)) for vertex, value in zip(vertices, z)]
|
||||
+5
-1
@@ -1,9 +1,13 @@
|
||||
"""B05 종횡단 계산용 지표면 표고 sampler.
|
||||
"""확정 지표면 모델 표고 sampler (공용).
|
||||
|
||||
종·횡단 생성기가 의존하는 최소 표고 조회 인터페이스와, 확정된 지표면 모델
|
||||
(B04_wf1_Surface/models)을 일괄 XY 표고 sampler로 여는 팩토리를 제공한다.
|
||||
DTM valid_mask를 footprint로 결합해 데이터가 없는 영역을 임의 표고로 메우지
|
||||
않는다.
|
||||
|
||||
B05 종횡단 전용이었으나 B04 배수유역 세부 설계도 같은 표고면을 써야 해서
|
||||
common_util로 옮겼다 — 두 화면의 종단 Z가 갈라지면 세부유역 경계가 달라진다
|
||||
(2026-08-01 구조 개편).
|
||||
"""
|
||||
|
||||
from collections.abc import Callable
|
||||
+10
-2
@@ -277,9 +277,9 @@ DRAINAGE_CONTOUR_CLIP_MARGIN_M = float(os.getenv("DRAINAGE_CONTOUR_CLIP_MARGIN_M
|
||||
# 평탄면 해소용 미세 경사(m/셀). 채움 후 흐름 방향이 없는 셀에 출구 쪽 경사를 만들어 준다.
|
||||
DRAINAGE_FLAT_EPSILON_M = float(os.getenv("DRAINAGE_FLAT_EPSILON_M", "0.001"))
|
||||
# 관 매설 최대 간격(m). 이 간격을 넘으면 흐름 강도가 가장 큰 지점에 관을 보충한다.
|
||||
DRAINAGE_PIPE_MAX_SPACING_M = float(os.getenv("DRAINAGE_PIPE_MAX_SPACING_M", "300.0"))
|
||||
DRAINAGE_PIPE_MAX_SPACING_M = float(os.getenv("DRAINAGE_PIPE_MAX_SPACING_M", "100.0"))
|
||||
# 관끼리 이보다 가까우면 같은 계곡으로 보고 하나로 합친다.
|
||||
DRAINAGE_PIPE_MIN_SPACING_M = float(os.getenv("DRAINAGE_PIPE_MIN_SPACING_M", "20.0"))
|
||||
DRAINAGE_PIPE_MIN_SPACING_M = float(os.getenv("DRAINAGE_PIPE_MIN_SPACING_M", "5.0"))
|
||||
# 측구 흐름(도로 셀 → 담당 관) 판정용 종단 계획선 샘플 간격(m).
|
||||
DRAINAGE_DITCH_SAMPLE_M = float(os.getenv("DRAINAGE_DITCH_SAMPLE_M", "1.0"))
|
||||
# 유역 폴리곤 단순화 허용오차(m). 격자 계단 경계를 매끄럽게 줄여 응답 크기를 낮춘다.
|
||||
@@ -292,6 +292,14 @@ DRAINAGE_CACHE_FILENAME = "watershed_grid.npz"
|
||||
# 분석 응답 자체를 그대로 담아 두는 파일. 재산정하지 않는 한 이걸 그대로 돌려준다 —
|
||||
# 저장 배열에서 응답을 다시 조립하면 원본과 어긋날 여지가 생긴다(2026-07-31 사용자 지시).
|
||||
DRAINAGE_RESPONSE_FILENAME = "00_watershed_response.json"
|
||||
# 세부유역 산출물. 관 목록이 정해져야 나오므로 해석 산출물(01~03)과 번호를 이어 붙인다.
|
||||
DRAINAGE_DETAIL_FILENAME = "04_detailed_basins.geojson"
|
||||
|
||||
# ── 관 매설 지점 편집분 ──
|
||||
# 해석 산출물(01~03)은 다시 돌리면 덮어써도 되지만 사용자가 찍은 관은 그러면 안 된다.
|
||||
# 같은 폴더 아래 편집분 전용 칸을 따로 두고 B04(관리자)·B05(사용자)가 같은 파일을 본다.
|
||||
DRAINAGE_EDITS_DIRNAME = "edits"
|
||||
DRAINAGE_PIPE_POINTS_FILENAME = "pipe_points.json"
|
||||
|
||||
# ── B05 전용 배수유역 사본 ──
|
||||
# B04 산출물을 그대로 쓰면 B05 편집이 원본을 덮어쓴다. 프로젝트 저장소의
|
||||
|
||||
@@ -29,6 +29,7 @@ from B01_Dashboard.B01_Dashboard_Router import router as b01_dashboard_router
|
||||
from B02_ProjRegister.B02_ProjRegister_Router import router as b02_proj_register_router
|
||||
from B03_FileInput.B03_FileInput_Router import router as b03_file_input_router
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Router import router as b04_surface_router
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Router_Basins import router as b04_basins_router
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Router_Contour import router as b04_surface_contour_router
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Router_GIS import router as b04_surface_gis_router
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Router_GIS import tiles_router
|
||||
@@ -350,6 +351,7 @@ app.include_router(b04_surface_contour_router, dependencies=protected_with_compa
|
||||
app.include_router(b04_surface_gis_router, dependencies=protected_with_company)
|
||||
app.include_router(b04_watershed_router, dependencies=protected_with_company)
|
||||
app.include_router(b04_inflow_router, dependencies=protected_with_company)
|
||||
app.include_router(b04_basins_router, dependencies=protected_with_company)
|
||||
app.include_router(tiles_router, dependencies=protected_with_company)
|
||||
app.include_router(b05_route_router, dependencies=protected_with_company)
|
||||
app.include_router(b05_drainage_router, dependencies=protected_with_company)
|
||||
|
||||
@@ -719,6 +719,38 @@ export const ui_locales = {
|
||||
"유입 셀을 불러오지 못했습니다.",
|
||||
"Failed to load the contributing cells.",
|
||||
],
|
||||
/* 상세 배수유역 (관 매설 지점 편집 + 세부유역 분할) */
|
||||
B04_Surface_Basin_Btn: ["상세유역 분석", "Detail basins"],
|
||||
B04_Surface_Basin_Btn_Busy: ["분석 중…", "Analyzing…"],
|
||||
B04_Surface_Basin_Btn_Tip: [
|
||||
"관 매설 지점을 기준으로 세부 배수유역을 나눕니다. 계획선 위에서 우클릭하면 관을 추가하고, 마커 위에서 우클릭하면 삭제합니다. 마커를 끌면 계획선을 따라 옮겨집니다.",
|
||||
"Splits the basin per culvert. Right-click the route to add a culvert, right-click a marker to remove it, and drag a marker to slide it along the route.",
|
||||
],
|
||||
B04_Surface_Basin_Part_Pipes: ["관 매설", "Culverts"],
|
||||
B04_Surface_Basin_Part_Basins: ["세부 유역", "Sub-basins"],
|
||||
B04_Surface_Basin_Menu_Add: ["관 매설 추가", "Add culvert"],
|
||||
B04_Surface_Basin_Menu_Delete: ["관 매설 삭제", "Remove culvert"],
|
||||
/* {pipes}=관 개수, {stream}=기본, {spacing}=자동 보충, {user}=수동, {basins}=세부유역 수, {source}=종단 Z 출처 */
|
||||
B04_Surface_Basin_Summary: [
|
||||
"관 {pipes}개(기본 {stream} / 자동 {spacing} / 수동 {user}) · 세부유역 {basins}개 · 종단 Z {source}",
|
||||
"{pipes} culverts ({stream} stream / {spacing} auto / {user} manual) · {basins} sub-basins · profile Z {source}",
|
||||
],
|
||||
B04_Surface_Basin_Edited: [
|
||||
"편집 중 — [상세유역 분석]을 눌러 다시 나눕니다.",
|
||||
"Edited — press [Detail basins] to re-split.",
|
||||
],
|
||||
B04_Surface_Basin_TooClose: [
|
||||
"관끼리 최소 간격 {min}m 안에는 넣을 수 없습니다.",
|
||||
"Culverts cannot be closer than {min}m.",
|
||||
],
|
||||
B04_Surface_Basin_Failed: [
|
||||
"상세유역을 계산하지 못했습니다.",
|
||||
"Failed to compute the sub-basins.",
|
||||
],
|
||||
B04_Surface_Basin_Saved: [
|
||||
"관 매설 지점 {count}개를 저장했습니다.",
|
||||
"Saved {count} culvert points.",
|
||||
],
|
||||
B04_Surface_Map_PlannedRouteEmpty: [
|
||||
"B03에서 계획노선 파일을 올리면 표시됩니다.",
|
||||
"Shown after the planned route file is uploaded in B03.",
|
||||
|
||||
@@ -218,7 +218,9 @@
|
||||
--map-upstream-toggle: #1d4ed8; /* 토글 버튼 색띠 */
|
||||
--map-satellite-toggle: #64748b; /* 배경사진은 선 색이 없어 중립 회색 */
|
||||
--map-pipe-orphan: #e5e7eb; /* 유역이 없는 배관 마커 */
|
||||
--map-pipe-marker: rgba(249, 115, 22, 0.95); /* B04 기본 관 마커 */
|
||||
--map-pipe-marker: rgba(249, 115, 22, 0.95); /* B04 기본 관 마커(세류 교차점) */
|
||||
--map-pipe-auto: rgba(14, 165, 233, 0.95); /* 최대 간격 규칙으로 자동 보충한 관 */
|
||||
--map-pipe-user: rgba(22, 163, 74, 0.95); /* 사용자가 넣거나 옮긴 관 */
|
||||
|
||||
/* 배수유역 오버레이(B04 지도) */
|
||||
--map-halo: rgba(255, 255, 255, 0.9); /* 선·글자 뒤에 까는 흰 테두리 */
|
||||
|
||||
Reference in New Issue
Block a user