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:
2026-07-31 21:20:44 +09:00
co-authored by Claude Fable 5
parent 79fe2fb7c9
commit 2ddf81c86c
6 changed files with 276 additions and 41 deletions
+15 -4
View File
@@ -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(
+108 -26
View File
@@ -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
View File
@@ -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가 유일한 정의처다.