feat(B04/B05): 저장분 자동 표시 + 평균 흐름 화살표 + 갈래별 토글
1. 재산정하지 않는 한 저장분을 그대로 쓴다
GET /drainage/primary-region 이 기본으로 영구저장소의 응답 캐시를
그대로 돌려준다(0.0004s). refresh=true 일 때만 다시 계산한다.
저장 배열에서 응답을 재조립하지 않고 **응답 자체**를 남긴다 —
재조립하면 원본과 어긋날 여지가 생긴다.
버튼 이름 배수유역 -> 배수유역 재산정. 지도를 열면 저장분이 자동으로 뜬다.
2. B05용 평균 흐름 화살표 (build_flow_arrows)
셀 화살표는 1m 라 도면 배율에서 경향이 안 보인다. 겹치지 않는 10m 블록으로
나눠 방향을 원형 평균하고, 40m 간격으로 솎아낸다.
- 세류선 셀과 도로 셀은 뺀다. 그 자리는 확정된 물길/노면을 따르는 값이라
사면 경향을 왜곡한다.
- 산술평균이 아니라 원형 평균(0도와 359도의 평균은 180도가 아니다).
평균 벡터 길이가 방향 일치도이므로 0.7 미만이면 그 블록은 버린다.
- 블록 유효 셀이 절반 미만이면 건너뛴다(가장자리 조각 방지).
실데이터 377개, 간격 40m 균일, 블록당 평균 98셀, 적 278 / 청 99.
config: ARROW_BLOCK_M / ARROW_SPACING_M / ARROW_MIN_COVERAGE / ARROW_MIN_AGREEMENT
3. 갈래별 하이드/쇼 버튼 4종
1차 유역 / 2차 유역 / 유역 방향 / 평균 흐름.
평균 화살표는 B04 에도 올려 형태를 확인할 수 있게 했다.
4. B05 는 페이지 진입 즉시 유역도를 올린다
B04 결과를 읽기만 해 0.1초면 끝나므로 버튼을 기다릴 이유가 없다.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -330,15 +330,26 @@ export interface WatershedAnalysis {
|
||||
strength_profile: Array<[number, number]>;
|
||||
/** 기본 관 매설 위치 — 도로 × 세류선 교차점. */
|
||||
pipes: WatershedPipe[];
|
||||
/** B05용 평균 흐름 화살표 — [lon, lat, 방위(도), 도로도달, 셀 수].
|
||||
* 세류·도로 셀을 뺀 10m 블록 평균이라 사면 경향만 남는다. */
|
||||
flow_arrows: Array<[number, number, number, boolean, number]>;
|
||||
/** 계산하지 않고 저장분을 그대로 돌려준 응답인지. */
|
||||
from_cache: boolean;
|
||||
/** 영구저장소에 남긴 검증용 GeoJSON 경로. */
|
||||
saved_to: string | null;
|
||||
}
|
||||
|
||||
export async function fetchWatershedAnalysis(projectId: string): Promise<WatershedAnalysis> {
|
||||
// 등고선 하강 방향 + 적색 확장 루프까지 도는 요청이라 수십 초가 걸린다.
|
||||
/** 배수유역 분석 결과를 받는다.
|
||||
*
|
||||
* `refresh`를 주지 않으면 영구저장소에 남은 결과를 그대로 받아 즉시 끝난다.
|
||||
* `refresh=true`면 처음부터 다시 계산하므로 수십 초가 걸린다. */
|
||||
export async function fetchWatershedAnalysis(
|
||||
projectId: string,
|
||||
refresh = false,
|
||||
): Promise<WatershedAnalysis> {
|
||||
return requestJson<WatershedAnalysis>(
|
||||
`/projects/${projectId}/drainage/primary-region`,
|
||||
`/projects/${projectId}/drainage/primary-region?refresh=${refresh}`,
|
||||
{ method: "GET" },
|
||||
API_ANALYSIS_TIMEOUT_MS,
|
||||
refresh ? API_ANALYSIS_TIMEOUT_MS : API_TIMEOUT_MS,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ B05에 남긴다.
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
@@ -38,6 +39,7 @@ from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Flow import (
|
||||
trace_flow,
|
||||
)
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Grid import (
|
||||
AZIMUTH_STEPS,
|
||||
GridSpec,
|
||||
TerrainGrid,
|
||||
build_contour_cloud,
|
||||
@@ -53,6 +55,10 @@ from common_util.common_util_route_geometry import (
|
||||
find_stream_crossings,
|
||||
)
|
||||
from config.config_system import (
|
||||
DRAINAGE_ARROW_BLOCK_M,
|
||||
DRAINAGE_ARROW_MIN_AGREEMENT,
|
||||
DRAINAGE_ARROW_MIN_COVERAGE,
|
||||
DRAINAGE_ARROW_SPACING_M,
|
||||
DRAINAGE_GRID_SIZE_M,
|
||||
DRAINAGE_INITIAL_RADIUS_M,
|
||||
DRAINAGE_PIPE_MIN_SPACING_M,
|
||||
@@ -126,6 +132,8 @@ class StagePreview:
|
||||
basin_area_m2: float = 0.0
|
||||
# 셀 → 도로 셀 귀속. B05가 세부유역을 나눌 때 이 배열이 있어야 한다.
|
||||
routing: Any = None
|
||||
# B05용 평균 흐름 화살표 — (x, y, 방위 라디안, 도로 도달, 셀 수).
|
||||
flow_arrows: list[tuple[float, float, float, bool, int]] = field(default_factory=list)
|
||||
# ⑧ 기본 관 매설 위치 — 도로 × 세류선 교차점.
|
||||
pipes: list[StructureCandidate] = field(default_factory=list)
|
||||
|
||||
@@ -184,6 +192,9 @@ def preview_stages(
|
||||
# ⑧ 기본 관 매설 위치 = 도로 × 세류선 교차점(너무 가까운 것은 하나로 합침).
|
||||
pipes = _base_pipes(vertices, stream_features)
|
||||
|
||||
# B05에 얹을 평균 흐름 화살표 — 셀 화살표는 도면 배율에서 안 보인다.
|
||||
flow_arrows = build_flow_arrows(analysis, analysis.flow)
|
||||
|
||||
logger.info(
|
||||
"배수유역: 단계 분석 %.1fs (확장 %d회, 최종 셀 %d개) — "
|
||||
"2차 유역 %.0f㎡, 기본 관 %d개, 강도 곡선 %d점",
|
||||
@@ -210,6 +221,7 @@ def preview_stages(
|
||||
basin_area_m2=int(red.sum()) * spec.cell_area_m2,
|
||||
routing=routing,
|
||||
pipes=pipes,
|
||||
flow_arrows=flow_arrows,
|
||||
)
|
||||
|
||||
|
||||
@@ -228,6 +240,70 @@ def _preview_strength(
|
||||
)
|
||||
|
||||
|
||||
def build_flow_arrows(analysis: Any, flow: Any) -> list[tuple[float, float, float, bool, int]]:
|
||||
"""셀 흐름을 블록 단위로 평균해 B05에 얹을 화살표를 뽑는다.
|
||||
|
||||
셀 화살표는 1m라 도면 배율에서 경향이 안 보인다. 겹치지 않는 블록으로 나눠 방향을
|
||||
평균하고, 화살표끼리 최소 간격을 두어 솎아낸다(2026-07-31 사용자 지시).
|
||||
|
||||
**세류선 셀과 도로 셀은 뺀다.** 그 자리 흐름은 지형 경사가 아니라 확정된 물길·노면을
|
||||
따르는 값이라 사면 경향을 왜곡한다.
|
||||
|
||||
방향 평균은 산술평균이 아니라 **원형 평균**으로 낸다(0°와 359°의 평균은 180°가 아니라
|
||||
0°다). 평균 벡터 길이가 일치도이므로, 블록 안 방향이 제각각이면 그 블록은 버린다.
|
||||
"""
|
||||
spec = analysis.spec
|
||||
rows, cols = spec.n_rows, spec.n_cols
|
||||
direction = flow.direction.reshape(rows, cols)
|
||||
usable = (
|
||||
analysis.domain
|
||||
& flow.analyzed.reshape(rows, cols)
|
||||
& (direction < AZIMUTH_STEPS) # 싱크·무효 제외
|
||||
& ~analysis.road.mask
|
||||
)
|
||||
if flow.burned is not None:
|
||||
usable &= ~flow.burned.reshape(rows, cols)
|
||||
if not usable.any():
|
||||
return []
|
||||
|
||||
block = max(1, int(round(DRAINAGE_ARROW_BLOCK_M / spec.cell_m)))
|
||||
stride = max(1, int(round(DRAINAGE_ARROW_SPACING_M / (block * spec.cell_m))))
|
||||
angle = direction.astype(np.float64) * (2.0 * math.pi / AZIMUTH_STEPS)
|
||||
reaches = flow.reaches_road.reshape(rows, cols)
|
||||
|
||||
arrows: list[tuple[float, float, float, bool, int]] = []
|
||||
for row0 in range(0, rows - block + 1, block * stride):
|
||||
for col0 in range(0, cols - block + 1, block * stride):
|
||||
window = usable[row0 : row0 + block, col0 : col0 + block]
|
||||
count = int(window.sum())
|
||||
if count < DRAINAGE_ARROW_MIN_COVERAGE * block * block:
|
||||
continue
|
||||
local = angle[row0 : row0 + block, col0 : col0 + block][window]
|
||||
mean_x = float(np.cos(local).mean())
|
||||
mean_y = float(np.sin(local).mean())
|
||||
agreement = math.hypot(mean_x, mean_y)
|
||||
if agreement < DRAINAGE_ARROW_MIN_AGREEMENT:
|
||||
continue # 방향이 제각각인 블록 — 평균이 경향을 대표하지 못한다
|
||||
centre_row = row0 + block / 2.0
|
||||
centre_col = col0 + block / 2.0
|
||||
arrows.append(
|
||||
(
|
||||
spec.x_min + centre_col * spec.cell_m,
|
||||
spec.y_max - centre_row * spec.cell_m,
|
||||
math.atan2(mean_y, mean_x),
|
||||
bool(reaches[row0 : row0 + block, col0 : col0 + block][window].mean() >= 0.5),
|
||||
count,
|
||||
)
|
||||
)
|
||||
logger.info(
|
||||
"배수유역: 평균 흐름 화살표 %d개 (블록 %.0fm, 간격 %.0fm, 세류·도로 셀 제외)",
|
||||
len(arrows),
|
||||
block * spec.cell_m,
|
||||
block * stride * spec.cell_m,
|
||||
)
|
||||
return arrows
|
||||
|
||||
|
||||
def _base_pipes(
|
||||
vertices: list[RouteVertex], stream_features: list[dict[str, Any]]
|
||||
) -> list[StructureCandidate]:
|
||||
|
||||
@@ -11,6 +11,7 @@ import asyncio
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
@@ -23,7 +24,11 @@ from shapely.geometry import Point, Polygon, box
|
||||
|
||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Analyze import preview_stages
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Export import write_grid_arrays, write_stage
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Export import (
|
||||
drainage_dir,
|
||||
write_grid_arrays,
|
||||
write_stage,
|
||||
)
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Grid import (
|
||||
AZIMUTH_INVALID,
|
||||
AZIMUTH_SINK,
|
||||
@@ -38,6 +43,7 @@ from common_util.common_util_route_geometry import (
|
||||
)
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from config.config_db import get_db_pool
|
||||
from config.config_system import DRAINAGE_RESPONSE_FILENAME
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/projects", tags=["B04 Surface Watershed"])
|
||||
@@ -172,14 +178,52 @@ async def _prepare(project_id: UUID) -> dict[str, Any] | JSONResponse:
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{project_id}/drainage/primary-region", response_model=None)
|
||||
async def get_primary_region(project_id: UUID) -> dict[str, Any] | JSONResponse:
|
||||
"""1차 배수유역 근거를 돌려준다 — 단계 검증용, TIN·흐름 계산은 하지 않는다.
|
||||
def _response_path(stored_path: str) -> Path:
|
||||
"""분석 응답 캐시 경로. 재산정하지 않는 한 이 파일을 그대로 돌려준다."""
|
||||
return drainage_dir(stored_path) / DRAINAGE_RESPONSE_FILENAME
|
||||
|
||||
도로 교차점 상류로 이어진 세류망, 제외된 하류망, 그 상류망을 반경 버퍼한 1차 영역,
|
||||
그 bbox로 잡은 격자 정보를 함께 준다. 같은 내용을 영구저장소에 GeoJSON으로도 남겨
|
||||
QGIS 등으로 직접 열어 대조할 수 있게 한다.
|
||||
|
||||
def _load_saved_response(stored_path: str) -> dict[str, Any] | None:
|
||||
path = _response_path(stored_path)
|
||||
if not path.exists():
|
||||
return None
|
||||
try:
|
||||
with path.open("r", encoding="utf-8") as file:
|
||||
return json.load(file)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
logger.warning("배수유역: 저장된 분석 응답을 읽지 못했습니다 (%s).", path)
|
||||
return None
|
||||
|
||||
|
||||
def _save_response(stored_path: str, payload: dict[str, Any]) -> None:
|
||||
path = _response_path(stored_path)
|
||||
try:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("w", encoding="utf-8") as file:
|
||||
json.dump(payload, file, ensure_ascii=False)
|
||||
except OSError:
|
||||
logger.warning("배수유역: 분석 응답을 저장하지 못했습니다 (%s).", path)
|
||||
|
||||
|
||||
@router.get("/{project_id}/drainage/primary-region", response_model=None)
|
||||
async def get_primary_region(
|
||||
project_id: UUID, refresh: bool = False
|
||||
) -> dict[str, Any] | JSONResponse:
|
||||
"""배수유역 분석 결과를 돌려준다.
|
||||
|
||||
기본은 **영구저장소에 남은 결과를 그대로** 준다 — 분석이 30초 걸리므로 화면을 열
|
||||
때마다 다시 돌릴 이유가 없다. `refresh=true`면 처음부터 다시 계산하고 덮어쓴다
|
||||
(2026-07-31 사용자 지시).
|
||||
"""
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection:
|
||||
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||
if not refresh:
|
||||
saved = _load_saved_response(stored_path)
|
||||
if saved is not None:
|
||||
logger.info("배수유역: 저장된 분석 결과를 그대로 돌려줍니다 (%s).", stored_path)
|
||||
return {**saved, "from_cache": True}
|
||||
|
||||
prepared = await _prepare(project_id)
|
||||
if isinstance(prepared, JSONResponse):
|
||||
return prepared
|
||||
@@ -248,6 +292,13 @@ async def get_primary_region(project_id: UUID) -> dict[str, Any] | JSONResponse:
|
||||
],
|
||||
# ⑧ 기본 관 매설 위치 — 도로 × 세류선 교차점.
|
||||
"pipes": [_candidate_payload(pipe, to_lonlat) for pipe in preview.pipes],
|
||||
# B05용 평균 흐름 화살표 — [lon, lat, 방위(도), 도로도달, 셀 수].
|
||||
# 세류·도로 셀을 뺀 블록 평균이라 사면 경향만 남는다.
|
||||
"flow_arrows": [
|
||||
[*to_lonlat(x, y), round(math.degrees(angle), 1), reaches, cells]
|
||||
for x, y, angle, reaches, cells in preview.flow_arrows
|
||||
],
|
||||
"from_cache": False,
|
||||
}
|
||||
# 단계 산출물을 영구저장소에 남긴다 — 기능을 붙일 때마다 여기에 단계가 하나씩 늘어난다.
|
||||
payload["saved_to"] = write_stage(
|
||||
|
||||
@@ -32,12 +32,18 @@ const ARROW_SPACING_PX = 22;
|
||||
const BASIN_RING_COLOR = "rgba(146, 64, 14, 0.95)";
|
||||
/** 기본 관 마커. */
|
||||
const PIPE_COLOR = "rgba(249, 115, 22, 0.95)";
|
||||
/** 평균 흐름 화살표 — 10m 블록 평균. 셀 화살표보다 크게 그려 경향을 읽는다. */
|
||||
const MEAN_ARROW_TO_ROAD = "rgba(153, 27, 27, 0.95)";
|
||||
const MEAN_ARROW_AWAY = "rgba(30, 64, 175, 0.95)";
|
||||
const MEAN_ARROW_PX = 14;
|
||||
const MEAN_ARROW_MAX_PX = 46;
|
||||
|
||||
/** 개별로 켜고 끌 수 있는 오버레이 갈래. */
|
||||
const PARTS = [
|
||||
{ key: "primary", label: "1차 유역", color: "#059669" },
|
||||
{ key: "basin", label: "2차 유역", color: "#92400e" },
|
||||
{ key: "flow", label: "유역 방향", color: "#2563eb" },
|
||||
{ key: "arrows", label: "평균 흐름", color: "#7c3aed" },
|
||||
] as const;
|
||||
type PartKey = (typeof PARTS)[number]["key"];
|
||||
|
||||
@@ -62,19 +68,26 @@ export function createWatershedOverlay(onChange: () => void): WatershedOverlay {
|
||||
let shown = false;
|
||||
let statusText = "";
|
||||
let flowCache: { source: string; bytes: Uint8Array } | null = null;
|
||||
let busy = false;
|
||||
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "b04-map__layer-button b04-map__layer-button--gis";
|
||||
button.textContent = "배수유역";
|
||||
button.textContent = "배수유역 재산정";
|
||||
button.style.setProperty("--b04-layer-color", "#dc2626");
|
||||
button.setAttribute("aria-pressed", "false");
|
||||
button.title =
|
||||
"계획 노선(B03 업로드)과 도엽 등고선·세류선으로 배수유역을 분석합니다. " +
|
||||
"30초 안팎이 걸리며 결과는 영구저장소에 남습니다.";
|
||||
"계획 노선(B03 업로드)과 도엽 등고선·세류선으로 배수유역을 처음부터 다시 분석합니다. " +
|
||||
"30초 안팎이 걸리며 결과는 영구저장소에 남습니다. " +
|
||||
"저장된 결과는 지도를 열 때 자동으로 표시되므로, 조건을 바꿨을 때만 누르면 됩니다.";
|
||||
|
||||
// 갈래별 표시 여부. 전체 토글(button)이 꺼져 있으면 이 값과 무관하게 아무것도 안 그린다.
|
||||
const shownParts: Record<PartKey, boolean> = { primary: true, basin: true, flow: true };
|
||||
const shownParts: Record<PartKey, boolean> = {
|
||||
primary: true,
|
||||
basin: true,
|
||||
flow: true,
|
||||
arrows: true,
|
||||
};
|
||||
const partButtons = PARTS.map((part) => {
|
||||
const element = document.createElement("button");
|
||||
element.type = "button";
|
||||
@@ -314,6 +327,60 @@ export function createWatershedOverlay(onChange: () => void): WatershedOverlay {
|
||||
context.restore();
|
||||
}
|
||||
|
||||
/** B05에 얹을 평균 흐름 화살표 — 10m 블록 평균이라 축소해도 경향이 읽힌다. */
|
||||
function drawFlowArrows(
|
||||
context: CanvasRenderingContext2D,
|
||||
map: Normalizer,
|
||||
view: ViewState,
|
||||
region: WatershedAnalysis,
|
||||
): void {
|
||||
const arrows = region.flow_arrows ?? [];
|
||||
if (arrows.length === 0) return;
|
||||
const ax = view.mapRect.width * view.scale;
|
||||
const bx = view.mapRect.x * view.scale + (view.width / 2) * (1 - view.scale) + view.offsetX;
|
||||
const ay = view.mapRect.height * view.scale;
|
||||
const by = view.mapRect.y * view.scale + (view.height / 2) * (1 - view.scale) + view.offsetY;
|
||||
// 블록 간격(m)이 화면에서 몇 px인지로 화살표 크기를 정한다 — 확대하면 같이 커진다.
|
||||
const pxPerLon = ax / map.lonRange;
|
||||
const spacingPx =
|
||||
arrows.length > 1
|
||||
? Math.abs(arrows[1][0] - arrows[0][0]) * pxPerLon || MEAN_ARROW_PX
|
||||
: MEAN_ARROW_PX;
|
||||
const size = Math.max(MEAN_ARROW_PX, Math.min(spacingPx * 0.8, MEAN_ARROW_MAX_PX));
|
||||
|
||||
context.save();
|
||||
context.lineCap = "round";
|
||||
arrows.forEach(([lon, lat, degrees, reaches]) => {
|
||||
const x = ((lon - map.lonMin) / map.lonRange) * ax + bx;
|
||||
const y = (1 - (lat - map.latMin) / map.latRange) * ay + by;
|
||||
if (x < -size || x > view.width + size || y < -size || y > view.height + size) return;
|
||||
const angle = (degrees * Math.PI) / 180;
|
||||
const unitX = Math.cos(angle);
|
||||
const unitY = Math.sin(angle);
|
||||
const reach = size / 2;
|
||||
const tipX = x + unitX * reach;
|
||||
const tipY = y + unitY * reach;
|
||||
const head = size * 0.32;
|
||||
// 배경 대비를 위해 흰 테두리를 깔고 그 위에 색을 얹는다.
|
||||
for (const [color, lineWidth] of [
|
||||
["rgba(255, 255, 255, 0.9)", size * 0.18 + 2] as const,
|
||||
[reaches ? MEAN_ARROW_TO_ROAD : MEAN_ARROW_AWAY, size * 0.18] as const,
|
||||
]) {
|
||||
context.strokeStyle = color;
|
||||
context.lineWidth = lineWidth;
|
||||
context.beginPath();
|
||||
context.moveTo(x - unitX * reach, y - unitY * reach);
|
||||
context.lineTo(tipX, tipY);
|
||||
context.moveTo(tipX, tipY);
|
||||
context.lineTo(tipX - (unitX + unitY * 0.7) * head, tipY - (unitY - unitX * 0.7) * head);
|
||||
context.moveTo(tipX, tipY);
|
||||
context.lineTo(tipX - (unitX - unitY * 0.7) * head, tipY - (unitY + unitX * 0.7) * head);
|
||||
context.stroke();
|
||||
}
|
||||
});
|
||||
context.restore();
|
||||
}
|
||||
|
||||
/** ⑦ 2차 전체 배수유역 외곽선(= 분수령)과 ⑧ 기본 관 위치. */
|
||||
function drawBasinAndPipes(
|
||||
context: CanvasRenderingContext2D,
|
||||
@@ -389,10 +456,38 @@ export function createWatershedOverlay(onChange: () => void): WatershedOverlay {
|
||||
return areaM2 >= 10000 ? `${(areaM2 / 10000).toFixed(2)}ha` : `${Math.round(areaM2)}㎡`;
|
||||
}
|
||||
|
||||
/** 켤 때마다 다시 요청한다 — config를 바꾸고 재시작했는데 캐시된 옛 결과가 나오면
|
||||
* 검증이 성립하지 않는다. 끌 때만 요청 없이 숨긴다. */
|
||||
async function toggle(): Promise<void> {
|
||||
if (!projectId) return;
|
||||
/** 분석 결과를 받아 화면에 올린다.
|
||||
*
|
||||
* `refresh=false`면 영구저장소에 남은 결과를 그대로 받아 즉시 끝나므로 지도를 열 때
|
||||
* 자동으로 부른다. `refresh=true`(재산정 버튼)면 처음부터 다시 계산한다. */
|
||||
async function loadAnalysis(refresh: boolean): Promise<void> {
|
||||
if (!projectId || busy) return;
|
||||
busy = true;
|
||||
button.disabled = true;
|
||||
statusText = refresh
|
||||
? "배수유역을 다시 분석하는 중… (30초 안팎)"
|
||||
: "저장된 배수유역을 불러오는 중…";
|
||||
onChange();
|
||||
try {
|
||||
analysis = await fetchWatershedAnalysis(projectId, refresh);
|
||||
shown = true;
|
||||
button.classList.add("is-active");
|
||||
button.setAttribute("aria-pressed", "true");
|
||||
statusText = regionSummary(analysis);
|
||||
} catch (error) {
|
||||
// 저장분이 없어 자동 조회가 실패한 경우는 오류가 아니다 — 재산정하면 된다.
|
||||
const message = error instanceof Error ? error.message : "배수유역을 불러오지 못했습니다.";
|
||||
statusText = refresh ? message : "";
|
||||
if (!refresh) analysis = null;
|
||||
} finally {
|
||||
busy = false;
|
||||
button.disabled = false;
|
||||
onChange();
|
||||
}
|
||||
}
|
||||
|
||||
// 재산정 버튼: 켜져 있으면 끄고, 꺼져 있으면 처음부터 다시 분석한다.
|
||||
button.addEventListener("click", () => {
|
||||
if (shown) {
|
||||
shown = false;
|
||||
button.classList.remove("is-active");
|
||||
@@ -401,24 +496,8 @@ export function createWatershedOverlay(onChange: () => void): WatershedOverlay {
|
||||
onChange();
|
||||
return;
|
||||
}
|
||||
button.disabled = true;
|
||||
statusText = "배수유역을 분석하는 중… (30초 안팎)";
|
||||
onChange();
|
||||
try {
|
||||
analysis = await fetchWatershedAnalysis(projectId);
|
||||
shown = true;
|
||||
button.classList.add("is-active");
|
||||
button.setAttribute("aria-pressed", "true");
|
||||
statusText = regionSummary(analysis);
|
||||
} catch (error) {
|
||||
statusText = error instanceof Error ? error.message : "배수유역 분석에 실패했습니다.";
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
onChange();
|
||||
}
|
||||
}
|
||||
|
||||
button.addEventListener("click", () => void toggle());
|
||||
void loadAnalysis(true);
|
||||
});
|
||||
|
||||
return {
|
||||
button,
|
||||
@@ -435,12 +514,15 @@ export function createWatershedOverlay(onChange: () => void): WatershedOverlay {
|
||||
},
|
||||
setProject(next: string) {
|
||||
projectId = next;
|
||||
// 저장분이 있으면 즉시 올린다 — 없으면 조용히 넘어가고, 재산정 버튼을 누르면 계산한다.
|
||||
void loadAnalysis(false);
|
||||
},
|
||||
draw(context, map, view) {
|
||||
if (!shown || !analysis) return;
|
||||
// 격자·화살표(유역 방향) → 1차 영역 → 2차 유역·관 순으로 아래에서 위로 쌓는다.
|
||||
if (shownParts.flow) drawGridCells(context, map, view, analysis);
|
||||
if (shownParts.primary) drawPrimaryRegion(context, map, view, analysis);
|
||||
if (shownParts.arrows) drawFlowArrows(context, map, view, analysis);
|
||||
if (shownParts.basin) drawBasinAndPipes(context, map, view, analysis);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -121,7 +121,7 @@ export function createDrainagePanel(): DrainagePanel {
|
||||
canvas.className = "b05-drainage__canvas";
|
||||
const status = document.createElement("span");
|
||||
status.className = "b05-drainage__status";
|
||||
status.textContent = "노선을 확정하면 배수유역 배경도가 표시됩니다.";
|
||||
status.textContent = "노선을 확정하면 배수유역도가 표시됩니다.";
|
||||
viewport.append(backgroundImage, canvas, status);
|
||||
// 유역 제원 목록(면적·표고·유하거리·관경). 관경 수식 미확정이라 당분간 "미정"으로 나온다.
|
||||
const basinList = document.createElement("div");
|
||||
@@ -311,7 +311,7 @@ export function createDrainagePanel(): DrainagePanel {
|
||||
if (!projectId) return;
|
||||
analyzeButton.disabled = true;
|
||||
status.hidden = false;
|
||||
status.textContent = "배수유역을 산정하는 중…";
|
||||
status.textContent = "세부유역을 산정하는 중…";
|
||||
try {
|
||||
const chainages = !auto && pipeEditor.pipes().length > 0 ? pipeEditor.chainages() : undefined;
|
||||
const response = await fetchDrainageBasins(projectId, chainages);
|
||||
@@ -333,7 +333,7 @@ export function createDrainagePanel(): DrainagePanel {
|
||||
scheduleDraw();
|
||||
} catch (error) {
|
||||
status.hidden = false;
|
||||
status.textContent = error instanceof Error ? error.message : "배수유역 산정에 실패했습니다.";
|
||||
status.textContent = error instanceof Error ? error.message : "세부유역 산정에 실패했습니다.";
|
||||
} finally {
|
||||
analyzeButton.disabled = false;
|
||||
}
|
||||
@@ -420,6 +420,8 @@ export function createDrainagePanel(): DrainagePanel {
|
||||
if (featureCount === 0) status.textContent = "도엽 레이어가 없습니다. B04에서 임포트하세요.";
|
||||
fitToRoute();
|
||||
scheduleDraw();
|
||||
// B04 분석 결과를 읽어 오는 것뿐이라 즉시 끝난다 — 페이지에 들어오면 바로 보여 준다.
|
||||
void analyze(true);
|
||||
} catch (error) {
|
||||
if (sequence !== loadSequence) return;
|
||||
status.hidden = false;
|
||||
|
||||
+14
-1
@@ -281,9 +281,22 @@ DRAINAGE_DITCH_SAMPLE_M = float(os.getenv("DRAINAGE_DITCH_SAMPLE_M", "1.0"))
|
||||
DRAINAGE_POLYGON_SIMPLIFY_M = float(os.getenv("DRAINAGE_POLYGON_SIMPLIFY_M", "2.0"))
|
||||
# 이 면적(㎡) 미만의 유역 조각은 버린다(격자 노이즈 제거).
|
||||
DRAINAGE_MIN_BASIN_AREA_M2 = float(os.getenv("DRAINAGE_MIN_BASIN_AREA_M2", "100.0"))
|
||||
# 격자 해석 결과 캐시 파일명. 프로젝트 저장소의 B05_wf2_Route/drainage/ 아래에 놓인다.
|
||||
# 격자 해석 결과 캐시 파일명. 프로젝트 저장소의 B04_wf1_Surface/drainage/ 아래에 놓인다.
|
||||
DRAINAGE_CACHE_DIRNAME = "drainage"
|
||||
DRAINAGE_CACHE_FILENAME = "watershed_grid.npz"
|
||||
# 분석 응답 자체를 그대로 담아 두는 파일. 재산정하지 않는 한 이걸 그대로 돌려준다 —
|
||||
# 저장 배열에서 응답을 다시 조립하면 원본과 어긋날 여지가 생긴다(2026-07-31 사용자 지시).
|
||||
DRAINAGE_RESPONSE_FILENAME = "00_watershed_response.json"
|
||||
|
||||
# ── B05용 평균 흐름 화살표 ──
|
||||
# 셀 화살표는 1m라 축소하면 경향이 안 보인다. 이 크기의 블록으로 묶어 방향을 평균한다.
|
||||
DRAINAGE_ARROW_BLOCK_M = float(os.getenv("DRAINAGE_ARROW_BLOCK_M", "10.0"))
|
||||
# 화살표끼리 최소 이 간격을 두고 솎아낸다. 촘촘하면 도면이 지저분해진다.
|
||||
DRAINAGE_ARROW_SPACING_M = float(os.getenv("DRAINAGE_ARROW_SPACING_M", "40.0"))
|
||||
# 블록 안에서 화살표를 낼 수 있는 셀이 이 비율 미만이면 건너뛴다(가장자리 조각 방지).
|
||||
DRAINAGE_ARROW_MIN_COVERAGE = float(os.getenv("DRAINAGE_ARROW_MIN_COVERAGE", "0.5"))
|
||||
# 방향 일치도 하한(원형 평균 결과 길이 0~1). 블록 안 방향이 제각각이면 평균이 무의미하므로 버린다.
|
||||
DRAINAGE_ARROW_MIN_AGREEMENT = float(os.getenv("DRAINAGE_ARROW_MIN_AGREEMENT", "0.7"))
|
||||
# 단계별 검증 산출물은 같은 폴더에 `{번호}_{단계}.geojson` + `manifest.json`으로 쌓인다.
|
||||
# 파일명 규칙은 B05_wf2_Route_Engine_Watershed_Export.STAGES가 유일한 정의처다.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user