feat(B04): 도로 유입 흐름 강도 표시 + 유입 집중점 마커, 사이드 패널 컨테이너 정리

흐름 강도 계산은 이미 trace_flow()가 원본 1m 격자에서 하고 있었고 03_road_routing.npz에
저장까지 되어 있었다. 이번 작업은 그 원본값을 밖으로 꺼내 화면에 그리는 것이다.

- 강도 곡선 출력 간격을 5m -> 1m로 바꿔 계산 원본을 그대로 내보낸다(구간 합, 평균 아님).
  구간 기준을 round -> floor로 바로잡았다. 도로 셀 누가거리가 전부 0.5 배수라
  numpy의 짝수 반올림이 짝수 칸에 3배를 몰아 2m 주기 빗살을 만들고 있었다
  (도로가 격자축과 나란한 구간은 홀수 칸이 전부 0이라 색이 점선으로 나왔다).
- find_inflow_hotspots(): 도로를 시점·기본 관(세류 교차)·종점으로 잘라 구역을 만들고
  구역마다 floor(길이 / 관 최대간격) + 3개를 뽑는다. 강도 최대점부터 고르되 고른 지점
  양 편측으로 관 최소간격만큼을 후보에서 빼 한쪽 쏠림을 막는다. 관 쪽 경계에서도 같은
  거리만큼 물러난다 - 그러지 않으면 그 관이 이미 받는 물 전부가 관 옆 칸에서 다시
  최대값으로 잡혀, 정작 관이 없어 보충이 필요한 자리가 뽑히지 못한다.
- GET /api/projects/{id}/drainage/road-inflow 신설(B04_wf1_Surface_Router_Inflow.py).
  저장된 road_slot으로 그 1m 구간에 귀속된 셀 마스크를 만들어 외곽선만 돌려준다.
  새 그래프 탐색 없음. npz는 mtime 키로 프로세스 캐시에 둔다.
- 응답에 schema_version을 넣어 형식이 바뀌면 옛 저장분을 무시하고 다시 계산하게 했다.
- 프론트: B04_wf1_Surface_UI_FlowStrength.ts 신설. 계획선 위에 1m 구간 로그 레인보우와
  유입 집중점 마커(크기=강도, 번호)를 얹고, 마커를 고르면 기여 셀 외곽선을 겹쳐 그린다.
  마커 안쪽만 선택 대상이며 팬은 가운데 버튼 전용이라는 규칙은 그대로 둔다.
  좌표 변환은 MapRender에 모았다(normalizedToScreen / lonLatToScreen / metricToScreen).
- 사이드 패널: 지면 필터·서피스·스무딩을 "지표면 분석" 한 컨테이너로 모으고 스무딩을
  드롭다운(적용/미적용)으로 바꿨다. 포인트·모델 표시 옵션은 "모델 표시 옵션" 하나로
  합치고 맨 아래에 점 크기·밀도를 [값][게이지] 형태로 놓았다.
- DRAINAGE_ROAD_WIDTH_M 주석에 설계 도로폭이 아니라 D8 대각 통과를 막는 해석용 굽기
  폭임을 명시했다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-01 16:16:15 +09:00
co-authored by Claude Opus 5
parent d14222242a
commit 4d9f8db33d
17 changed files with 1061 additions and 87 deletions
+31 -1
View File
@@ -372,8 +372,12 @@ export interface WatershedAnalysis {
/** 2차 전체 배수유역 외곽선(= 분수령). 적색 셀 전체의 외곽. */
basin_polygon_lonlat: Array<[number, number]>;
basin_area_m2: number;
/** 도로 위 흐름 강도 — [누가거리 m, 그 구간으로 모이는 상류 면적 ㎡]. */
/** 도로 위 흐름 강도 — [누가거리 m, 그 구간으로 모이는 상류 면적 ㎡].
* 1m 간격 **구간 합**이다(평균·리샘플이 아니라 계산 원본 그대로). */
strength_profile: Array<[number, number]>;
/** 유입 집중점 — [누가거리 m, 유입면적 ㎡, 구역 번호, 구역 내 순위].
* 구역은 시점·기본 관·종점으로 자른 구간이며, 구역마다 `floor(길이/관 최대간격)+1`개를 뽑는다. */
inflow_hotspots: Array<[number, number, number, number]>;
/** 기본 관 매설 위치 — 도로 × 세류선 교차점. */
pipes: WatershedPipe[];
/** B05용 평균 흐름 화살표 — [lon, lat, 방위(도), 도로도달, 셀 수].
@@ -401,3 +405,29 @@ export async function fetchWatershedAnalysis(
refresh ? API_ANALYSIS_TIMEOUT_MS : API_TIMEOUT_MS,
);
}
/** 도로 한 지점으로 들어오는 셀들의 외곽선(검토용). 계산이 아니라 저장된 귀속 배열 조회다. */
export interface RoadInflowResponse {
status: string;
chainage_m: number;
/** 기여 셀을 모은 도로 구간 길이(m). */
span_m: number;
cell_count: number;
area_m2: number;
/** 가장 먼 셀이 이 지점까지 흘러온 물길 길이(m). */
max_path_length_m: number;
/** 기여 셀 덩어리들의 바깥 링(WGS84). 큰 조각부터. */
rings_lonlat: Array<Array<[number, number]>>;
}
/** 취소는 지원하지 않는다(`requestJson`이 자체 타임아웃 신호를 쓴다) — 호출측에서 늦게 온
* 응답을 버리는 방식으로 처리한다. */
export async function fetchRoadInflow(
projectId: string,
chainageM: number,
): Promise<RoadInflowResponse> {
return requestJson<RoadInflowResponse>(
`/projects/${projectId}/drainage/road-inflow?chainage_m=${chainageM}`,
{ method: "GET" },
);
}
@@ -61,13 +61,21 @@ from config.config_system import (
DRAINAGE_ARROW_SPACING_M,
DRAINAGE_GRID_SIZE_M,
DRAINAGE_INITIAL_RADIUS_M,
DRAINAGE_PIPE_MAX_SPACING_M,
DRAINAGE_PIPE_MIN_SPACING_M,
)
logger = logging.getLogger(__name__)
# 강도 곡선 응답 간격(m). 도로 위 흐름 강도 표기는 이 간격으로 내보낸다.
_STRENGTH_OUTPUT_STEP_M = 5.0
# 1m = 계산 원본 그대로다. 5m로 줄이면 도로 색이 뭉개져 어느 자리에 물이 모이는지 못 읽는다
# (2026-08-01 사용자 지시: 단순화·평균 금지). 350m 노선이면 약 351점, 수 KB 수준이라 부담 없다.
_STRENGTH_OUTPUT_STEP_M = 1.0
# 구역마다 뽑을 유입 집중점 개수에 더하는 여유 몫.
# 개수 = floor(구역 길이 / 관 최대간격) + 이 값. 최대간격을 지키는 데 필요한 자리 수만 뽑으면
# 견줄 대상이 없어 그 자리가 최선인지 판단할 수 없다(2026-08-01 사용자 지시).
_HOTSPOT_EXTRA_PER_ZONE = 3
# ── ①~② 1차 배수유역 ────────────────────────────────────────────────────────
@@ -127,6 +135,8 @@ class StagePreview:
expand_added_cells: int = 0
# ⑥ 도로 위 흐름 강도 — (누가거리 m, 그 구간으로 모이는 상류 면적 ㎡).
strength_profile: list[tuple[float, float]] = field(default_factory=list)
# ⑥-1 유입 집중점 — (누가거리 m, 유입면적 ㎡, 구역 번호, 구역 내 순위). 화면 마커용.
inflow_hotspots: list[tuple[float, float, int, int]] = field(default_factory=list)
# ⑦ 2차 전체 배수유역 외곽선(= 분수령). 적색 셀 전체를 폴리곤화한 것.
basin_boundary_xy: list[tuple[float, float]] = field(default_factory=list)
basin_area_m2: float = 0.0
@@ -192,6 +202,11 @@ def preview_stages(
# ⑧ 기본 관 매설 위치 = 도로 × 세류선 교차점(너무 가까운 것은 하나로 합침).
pipes = _base_pipes(vertices, stream_features)
# ⑥-1 유입 집중점 — 기본 관으로 나눈 구역마다 물이 많이 모이는 자리를 뽑는다.
hotspots = find_inflow_hotspots(
strength_curve, [pipe.chainage_m for pipe in pipes], route_line.length
)
# B05에 얹을 평균 흐름 화살표 — 셀 화살표는 도면 배율에서 안 보인다.
flow_arrows = build_flow_arrows(analysis, analysis.flow)
@@ -217,6 +232,7 @@ def preview_stages(
expand_closed=expansion.closed,
expand_added_cells=expansion.added_cells,
strength_profile=_downsample_strength(strength_curve),
inflow_hotspots=hotspots,
basin_boundary_xy=basin_ring,
basin_area_m2=int(red.sum()) * spec.cell_area_m2,
routing=routing,
@@ -322,14 +338,80 @@ def _base_pipes(
def _strength_by_chainage(
road_chainage: np.ndarray, strength_area: np.ndarray, total_length: float
) -> np.ndarray:
"""도로 셀 강도를 1m 누가거리 구간으로 합산한 곡선(㎡/m 구간 합)."""
"""도로 셀 강도를 1m 누가거리 구간으로 합산한 곡선(㎡/m 구간 합).
구간 k는 **[k, k+1)** 이다. `round`를 쓰면 안 된다 — 도로 셀 누가거리는 노선을 0.5m
간격으로 샘플해 붙인 값이라 전부 0.5의 배수인데, numpy의 `round`는 정확히 .5인 값을
짝수 쪽으로 보낸다(0.5→0, 1.5→2). 그러면 짝수 칸에는 세 샘플({k0.5, k, k+0.5}),
홀수 칸에는 한 샘플만 들어가 강도가 2m 주기로 흔들리고, 도로가 격자축과 나란한 구간은
홀수 칸이 전부 0이 되어 화면에서 1m 간격 점선으로 보인다(2026-08-01 검증에서 발견).
"""
bins = max(1, int(np.ceil(total_length)) + 1)
if road_chainage.size == 0:
return np.zeros(bins)
index = np.clip(np.round(road_chainage).astype(np.int64), 0, bins - 1)
index = np.clip(np.floor(road_chainage).astype(np.int64), 0, bins - 1)
return np.bincount(index, weights=strength_area, minlength=bins)
def find_inflow_hotspots(
curve: np.ndarray,
pipe_chainages: list[float],
route_length_m: float,
) -> list[tuple[float, float, int, int]]:
"""도로 위 **유입 집중점**을 뽑는다 — (누가거리 m, 유입면적 ㎡, 구역 번호, 구역 내 순위).
왜 필요한가(2026-08-01 사용자 지시): 기본 관은 국가 수치지형도의 세류선이 도로를 가로지르는
자리라 근거가 확실하다. 그런데 관 **최대 간격**을 지키려면 세류가 없는 긴 구간에도 관을
넣어야 하는데, 이때 등간격으로 기계적으로 꽂는 대신 **물이 실제로 많이 모이는 자리**를
골라야 한다. 여기서 뽑은 지점이 그 후보이며, B05가 관을 옮겨도 세부유역을 다시 나누는
기준으로 쓸 수 있다.
규칙
· 구역 = 시점 → 기본 관들 → 종점 으로 자른 구간
· 구역별 개수 = `floor(구역 길이 / 관 최대간격) + 3`
(관 최대간격을 지키는 데 꼭 필요한 자리 수에 검토 여유 몫을 더한 것이다.
한 자리만 보여 주면 그 자리가 실제로 최선인지 견줄 대상이 없다 — 2026-08-01 사용자 지시)
· 고르는 법 = 강도가 가장 큰 1m 칸을 고르고 **그 지점 양 편측으로 관 최소간격만큼**을
후보에서 뺀 뒤 다음으로 큰 칸을 고른다. 빼지 않으면 같은 계곡의 이웃 칸들이 연달아
뽑혀 한쪽에 쏠린다.
· **기본 관 쪽 경계에서도 관 최소간격만큼 물러나 본다.** 강도 최대점은 원리상 세류가
도로를 가로지르는 자리, 곧 기본 관 자신이다. 관 옆 1m만 비워 두면 그 관이 받는 물
전부(전체 유역의 대부분)가 관에서 두어 걸음 떨어진 칸에서 다시 최대값으로 잡혀,
정작 관이 없어 보충이 필요한 자리는 뽑히지 못한다. 노선 시·종점은 관이 아니므로
물러나지 않는다.
· 강도 0인 칸은 뽑지 않는다(개수를 못 채워도 그대로 둔다).
"""
if curve.size == 0 or route_length_m <= 0:
return []
# 구역 경계 = 0, 기본 관들, 노선 끝. 순서·중복을 정리해 둔다.
edges = sorted({0.0, float(route_length_m), *(float(p) for p in pipe_chainages)})
exclusion = max(1, int(round(DRAINAGE_PIPE_MIN_SPACING_M)))
hotspots: list[tuple[float, float, int, int]] = []
for zone_index, (start_m, end_m) in enumerate(zip(edges, edges[1:])):
span = end_m - start_m
if span <= 0:
continue
wanted = int(span // DRAINAGE_PIPE_MAX_SPACING_M) + _HOTSPOT_EXTRA_PER_ZONE
# 이 구역에서 볼 곡선 조각. 관 쪽 경계는 최소간격만큼, 노선 끝 쪽은 한 칸만 물린다.
start_is_pipe = zone_index > 0
end_is_pipe = zone_index < len(edges) - 2
low = int(math.floor(start_m)) + (exclusion if start_is_pipe else 1)
high = int(math.ceil(end_m)) - (exclusion if end_is_pipe else 1)
high = min(high, curve.size - 1)
if high < low:
continue
window = curve[low : high + 1].copy()
for rank in range(wanted):
best = int(np.argmax(window))
if window[best] <= 0:
break # 남은 칸이 전부 0 — 뽑을 것이 없다
hotspots.append((float(low + best), float(window[best]), zone_index, rank))
# 고른 지점 양 편측으로 최소 간격만큼 지운다(한쪽 쏠림 방지).
window[max(0, best - exclusion) : best + exclusion + 1] = 0.0
hotspots.sort(key=lambda item: item[0])
return hotspots
def _downsample_strength(curve: np.ndarray) -> list[tuple[float, float]]:
"""응답용으로 강도 곡선을 일정 간격으로 줄인다(구간 합 유지).
@@ -527,8 +527,13 @@ def polygonize_labels(
spec: GridSpec,
labels: np.ndarray,
min_area_m2: float = DRAINAGE_MIN_BASIN_AREA_M2,
simplify_m: float = DRAINAGE_POLYGON_SIMPLIFY_M,
) -> dict[int, Polygon | MultiPolygon]:
"""라벨 격자를 라벨별 폴리곤으로 바꾼다. 음수 라벨은 배경으로 무시한다."""
"""라벨 격자를 라벨별 폴리곤으로 바꾼다. 음수 라벨은 배경으로 무시한다.
`simplify_m`은 격자 계단 경계를 줄여 응답 크기를 낮추는 값이다. 셀 경계를 눈으로 대조해야
하는 용도(유입 셀 확인 등)에서는 0을 줘서 원래 계단 그대로 받는다.
"""
label_grid = np.ascontiguousarray(labels.reshape(spec.n_rows, spec.n_cols), dtype=np.int32)
valid_mask = label_grid >= 0
if not valid_mask.any():
@@ -547,7 +552,10 @@ def polygonize_labels(
union = unary_union(parts)
if union.is_empty:
continue
simplified = union.simplify(DRAINAGE_POLYGON_SIMPLIFY_M, preserve_topology=True)
if simplify_m <= 0:
merged[label] = union
continue
simplified = union.simplify(simplify_m, preserve_topology=True)
merged[label] = simplified if not simplified.is_empty else union
return merged
@@ -0,0 +1,169 @@
"""도로 유입 셀 조회 API (B04 — 흐름 강도 검토용).
화면에서 **유입 집중점 마커**를 고르면, 그 지점으로 실제 물이 들어오는 셀들의 외곽선을
돌려준다. 계산은 하지 않는다 — 배수유역 분석이 이미 만들어 둔 `03_road_routing.npz`의
`road_slot`(셀 → 도달 도로 셀)을 읽어 해당 도로 셀에 귀속된 셀만 골라낼 뿐이다.
그래서 원본 격자(1m, 방향 지정 원본) 그대로이며 새로 근사하거나 평균 내지 않는다.
"""
from __future__ import annotations
import asyncio
import logging
from pathlib import Path
from typing import Any
from uuid import UUID
import numpy as np
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 B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Export import STAGES, drainage_dir
from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Flow import polygonize_labels
from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Grid import GridSpec
from B05_wf2_Route.B05_wf2_Route_Repository import get_surface_crs_epsg
from common_util.common_util_route_geometry import (
find_planned_route_file,
read_planned_route_csv,
)
from common_util.common_util_storage import resolve_stored_project_path
from config.config_db import get_db_pool
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/projects", tags=["B04 Surface Inflow"])
# 읽어 둔 배열을 프로세스 메모리에 들고 있는다. 마커를 누를 때마다 수십 MB npz를 다시 푸는
# 것을 막는다. 파일이 새로 쓰이면(재분석) mtime이 달라져 자동으로 버려진다.
_CACHE: dict[str, tuple[float, dict[str, Any]]] = {}
# 유입 셀 외곽선은 조각이 많을 수 있다. 화면에서 읽을 수 있는 수준까지만 보낸다.
_MAX_RINGS = 40
def _routing_path(stored_path: str) -> Path:
return drainage_dir(stored_path) / f"{STAGES['road_routing']}_road_routing.npz"
def _load_routing(stored_path: str) -> dict[str, Any] | None:
"""`03_road_routing.npz`를 읽어 필요한 배열만 남긴다(mtime 기준 캐시)."""
path = _routing_path(stored_path)
if not path.exists():
return None
stamp = path.stat().st_mtime
cached = _CACHE.get(stored_path)
if cached is not None and cached[0] == stamp:
return cached[1]
with np.load(path, allow_pickle=False) as data:
loaded = {
"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"]),
),
"road_slot": data["road_slot"],
"road_chainage": data["road_chainage"],
"path_length": data["path_length"],
}
_CACHE[stored_path] = (stamp, loaded)
logger.info("배수유역: 도로 귀속 배열을 읽었습니다 (%s).", path.name)
return loaded
async def _resolve_epsg(project_id: UUID, stored_path: str) -> str:
"""분석에 쓰인 좌표계를 그대로 되찾는다(노선 파일이 명시하면 그 값 우선)."""
pool = get_db_pool()
async with pool.acquire() as connection:
epsg = await get_surface_crs_epsg(connection, project_id, 0)
project_root = Path(resolve_stored_project_path(stored_path))
route_file = find_planned_route_file(project_root / "B03_FileInput" / "input")
planned = read_planned_route_csv(route_file) if route_file else None
source = (planned.epsg if planned else None) or epsg or 5186
return f"EPSG:{source}"
def _collect_inflow(
routing: dict[str, Any], chainage_m: float, span_m: float
) -> tuple[np.ndarray, Any]:
"""지정한 누가거리 구간의 도로 셀에 귀속된 셀 마스크와 외곽 폴리곤을 만든다."""
spec: GridSpec = routing["spec"]
road_slot: np.ndarray = routing["road_slot"]
road_chainage: np.ndarray = routing["road_chainage"]
half = max(span_m, spec.cell_m) / 2.0
slots = np.flatnonzero(np.abs(road_chainage - chainage_m) <= half)
if slots.size == 0:
return np.zeros(0, dtype=bool), None
# 슬롯 번호 조회표로 한 번에 거른다(도로 셀 수가 적어 표가 작다).
selected = np.zeros(road_chainage.size, dtype=bool)
selected[slots] = True
mask = (road_slot >= 0) & selected[np.maximum(road_slot, 0)]
if not mask.any():
return mask, None
labels = np.where(mask, 0, -1).astype(np.int32)
# 최소 면적 걸러내기와 단순화를 **둘 다 끈다**. 이 외곽선은 "어느 셀이 이 지점으로
# 들어오는가"를 눈으로 대조하는 용도라, 몇 셀이 빠지거나 경계가 2m 뭉개지면 표시된
# 면적·셀 수와 그림이 어긋난다.
polygons = polygonize_labels(spec, labels, min_area_m2=0.0, simplify_m=0.0)
return mask, polygons.get(0)
def _rings_lonlat(geometry: Any, to_lonlat: Transformer) -> list[list[list[float]]]:
"""폴리곤(멀티 포함)의 바깥 링들을 WGS84 좌표 배열로 바꾼다. 큰 조각부터."""
if geometry is None or geometry.is_empty:
return []
parts = list(geometry.geoms) if geometry.geom_type == "MultiPolygon" else [geometry]
parts.sort(key=lambda part: part.area, reverse=True)
rings: list[list[list[float]]] = []
for part in parts[:_MAX_RINGS]:
ring = [list(to_lonlat.transform(x, y)) for x, y in part.exterior.coords]
if len(ring) >= 4:
rings.append(ring)
return rings
@router.get("/{project_id}/drainage/road-inflow", response_model=None)
async def get_road_inflow(
project_id: UUID, chainage_m: float, span_m: float = 1.0
) -> dict[str, Any] | JSONResponse:
"""도로 위 한 지점(누가거리)으로 물이 들어오는 셀들의 외곽선을 돌려준다.
`span_m`은 그 지점을 중심으로 한 도로 구간 길이다. 기본 1m로, 화면에서 색을 칠하는
단위와 같다(2026-08-01 사용자 지시).
"""
pool = get_db_pool()
async with pool.acquire() as connection:
stored_path = await get_project_storage_relative_path(connection, project_id)
routing = await asyncio.to_thread(_load_routing, stored_path)
if routing is None:
return JSONResponse(
status_code=404,
content={
"status": "error",
"message": "배수유역 분석 결과가 없습니다. [유역 분석]을 먼저 실행하세요.",
},
)
mask, geometry = await asyncio.to_thread(_collect_inflow, routing, chainage_m, span_m)
spec: GridSpec = routing["spec"]
cell_count = int(mask.sum()) if mask.size else 0
path_length = routing["path_length"]
max_path = float(path_length[mask].max()) if cell_count else 0.0
epsg = await _resolve_epsg(project_id, stored_path)
to_lonlat = Transformer.from_crs(epsg, "EPSG:4326", always_xy=True)
return {
"status": "success",
"chainage_m": round(chainage_m, 2),
"span_m": span_m,
"cell_count": cell_count,
"area_m2": round(cell_count * spec.cell_area_m2, 1),
"max_path_length_m": round(max_path, 1),
"rings_lonlat": _rings_lonlat(geometry, to_lonlat),
}
@@ -52,6 +52,13 @@ router = APIRouter(prefix="/api/projects", tags=["B04 Surface Watershed"])
_CONTOUR_FILE = "도엽_등고선.geojson"
_STREAM_FILE = "도엽_하천중심선.geojson"
# 응답 형식 판(版). 응답에 항목을 더하거나 값의 의미를 바꾸면 이 값을 올린다 —
# 저장해 둔 옛 응답을 그대로 돌려주면 화면이 조용히 어긋나기 때문이다.
# 2 = 강도 곡선 간격 1m(구간 합) + 유입 집중점(inflow_hotspots) 추가 (2026-08-01)
# 3 = 강도 곡선 구간 기준을 round→floor로 바로잡고(2m 주기 빗살 제거),
# 유입 집중점이 기본 관 옆 최소간격 안쪽을 피하도록 수정 (2026-08-01)
RESPONSE_SCHEMA_VERSION = 3
def _sheet_dir(stored_path: str) -> Path:
return Path(resolve_stored_project_path(stored_path)) / "B04_wf1_Surface" / "processed"
@@ -189,10 +196,16 @@ def _load_saved_response(stored_path: str) -> dict[str, Any] | None:
return None
try:
with path.open("r", encoding="utf-8") as file:
return json.load(file)
saved = json.load(file)
except (OSError, json.JSONDecodeError):
logger.warning("배수유역: 저장된 분석 응답을 읽지 못했습니다 (%s).", path)
return None
# 응답 형식이 바뀌면 옛 저장분을 그대로 주면 안 된다 — 화면이 없는 항목을 그리려다
# 조용히 어긋난다(예: 강도 곡선 간격 5m→1m, 유입 집중점 신설). 다시 계산하게 둔다.
if saved.get("schema_version") != RESPONSE_SCHEMA_VERSION:
logger.info("배수유역: 저장분이 옛 형식이라 다시 분석합니다 (%s).", path)
return None
return saved
def _save_response(stored_path: str, payload: dict[str, Any]) -> None:
@@ -245,6 +258,7 @@ async def get_primary_region(
domain = preview.domain if preview.domain is not None else region.cell_mask
payload = {
"status": "success",
"schema_version": RESPONSE_SCHEMA_VERSION,
"project_id": str(project_id),
"route_source": prepared["route_source"],
"radius_m": region.radius_m,
@@ -290,6 +304,12 @@ async def get_primary_region(
"strength_profile": [
[round(chainage, 1), round(area, 1)] for chainage, area in preview.strength_profile
],
# ⑥-1 유입 집중점 — 기본 관으로 나눈 구역마다 물이 많이 모이는 자리.
# [누가거리 m, 유입면적 ㎡, 구역 번호, 구역 내 순위]. 화면 마커·하이라이트 대상이다.
"inflow_hotspots": [
[round(chainage, 1), round(area, 1), zone, rank]
for chainage, area, zone, rank in preview.inflow_hotspots
],
# ⑧ 기본 관 매설 위치 — 도로 × 세류선 교차점.
"pipes": [_candidate_payload(pipe, to_lonlat) for pipe in preview.pipes],
# B05용 평균 흐름 화살표 — [lon, lat, 방위(도), 도로도달, 셀 수].
@@ -0,0 +1,379 @@
/* =============================================================================
* 도로 유입 흐름 강도 오버레이 (B04 2D 지도)
*
* 계획선 위에 **1m 구간마다** 그 구간으로 모이는 상류 면적을 색으로 칠하고, 물이 특히
* 많이 모이는 자리(유입 집중점)를 마커로 찍는다. 마커를 고르면 그 지점으로 들어오는
* 셀들의 외곽선을 백엔드에서 받아 겹쳐 그린다.
*
* 계산은 전부 백엔드가 원본 격자(1m, 방향 지정 원본)에서 끝낸 값이다 — 여기서는 그리기만
* 한다. 색 단계는 로그 스케일이다. 계곡 한 지점이 사면보다 수백 배 크기 때문에 선형으로
* 칠하면 몇 점만 빨갛고 나머지는 전부 파랑으로 뭉친다(2026-08-01 사용자 지시).
* ========================================================================== */
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import { themeColor } from "@ui/ui_template_palette";
import { fetchRoadInflow, type VWorldMeta } from "./B04_wf1_Surface_Api_Fetch";
import {
haloColor,
lonLatToScreen,
metricToScreen,
type Normalizer,
type ViewState,
} from "./B04_wf1_Surface_UI_MapRender";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
/** 강도 색띠 — 파랑(적음) → 빨강(많음). 정의처는 `ui_template_theme.css`. */
const RAMP_TOKENS: ReadonlyArray<[name: string, fallback: string]> = [
["--map-flow-ramp-0", "#2563eb"],
["--map-flow-ramp-1", "#06b6d4"],
["--map-flow-ramp-2", "#22c55e"],
["--map-flow-ramp-3", "#eab308"],
["--map-flow-ramp-4", "#f97316"],
["--map-flow-ramp-5", "#dc2626"],
];
/** 강도 선 굵기(px). 계획선(2.4)보다 굵어야 그 위에 얹힌 것으로 읽힌다. */
const STRENGTH_LINE_WIDTH = 5;
/** 마커 반지름(px) — 강도 최소~최대. 작게 둬서 지도를 가리지 않는다(사용자 지시). */
const MARKER_MIN_RADIUS = 4;
const MARKER_MAX_RADIUS = 10;
/** 마커를 눌렀다고 볼 여유(px). */
const MARKER_HIT_SLACK = 4;
export type RoutePoint = { x: number; y: number };
export interface FlowStrengthOverlay {
/** 지도 헤더 버튼 줄에 넣을 토글. */
button: HTMLButtonElement;
visible: () => boolean;
/** 선택 요약 문구(없으면 빈 문자열). */
status: () => string;
/** 분석 결과를 받는다. 프로젝트가 바뀌면 `clear()`를 먼저 부른다. */
setData: (
profile: ReadonlyArray<readonly [number, number]>,
hotspots: ReadonlyArray<readonly [number, number, number, number]>,
) => void;
/** 계획선(사업지 좌표계 m)과 배경지도 메타. 둘이 있어야 화면 좌표를 낼 수 있다. */
setRoute: (points: ReadonlyArray<RoutePoint>, meta: VWorldMeta | null) => void;
setProject: (projectId: string) => void;
clear: () => void;
/** 마커를 눌렀으면 true. 누른 것이 없으면 선택을 풀고 false. */
handleClick: (normalizer: Normalizer | null, view: ViewState, x: number, y: number) => boolean;
draw: (context: CanvasRenderingContext2D, normalizer: Normalizer | null, view: ViewState) => void;
dispose: () => void;
}
/** `#rrggbb` → [r,g,b]. 색띠는 hex로만 정의한다(보간을 위해). */
function parseHex(color: string): [number, number, number] {
const hex = color.trim().replace("#", "");
const full =
hex.length === 3
? hex
.split("")
.map((c) => c + c)
.join("")
: hex;
const value = Number.parseInt(full.slice(0, 6), 16);
return [(value >> 16) & 255, (value >> 8) & 255, value & 255];
}
/** 0~1을 색띠 위에서 보간한다. */
function rampColor(t: number): string {
const clamped = Math.min(1, Math.max(0, t));
const last = RAMP_TOKENS.length - 1;
const position = clamped * last;
const low = Math.min(last, Math.floor(position));
const high = Math.min(last, low + 1);
const ratio = position - low;
const a = parseHex(themeColor(RAMP_TOKENS[low][0], RAMP_TOKENS[low][1]));
const b = parseHex(themeColor(RAMP_TOKENS[high][0], RAMP_TOKENS[high][1]));
const mix = (index: number): number => Math.round(a[index] + (b[index] - a[index]) * ratio);
return `rgb(${mix(0)}, ${mix(1)}, ${mix(2)})`;
}
/** 면적을 사람이 읽는 문구로. 1ha 이상은 ha로 줄인다. */
function formatArea(areaM2: number): string {
return areaM2 >= 10000 ? `${(areaM2 / 10000).toFixed(2)}ha` : `${Math.round(areaM2)}`;
}
export function createFlowStrengthOverlay(onChange: () => void): FlowStrengthOverlay {
const button = document.createElement("button");
button.type = "button";
// 기본 켜짐(2026-08-01 사용자 지시) — 분석 결과가 들어오면 바로 보이게 한다.
let shown = true;
button.className = "b04-map__layer-button b04-map__layer-button--gis is-active";
button.textContent = L("B04_Surface_Flow_Strength");
button.style.setProperty("--b04-layer-color", themeColor("--map-flow-ramp-5", "#dc2626"));
button.setAttribute("aria-pressed", "true");
button.title = L("B04_Surface_Flow_Strength_Tip");
button.addEventListener("click", () => {
shown = !shown;
button.classList.toggle("is-active", shown);
button.setAttribute("aria-pressed", String(shown));
onChange();
});
let projectId: string | null = null;
let meta: VWorldMeta | null = null;
let routePoints: ReadonlyArray<RoutePoint> = [];
/** 누가거리 1m 간격으로 다시 찍은 계획선 점 — 색칠·마커 위치의 기준. */
let samples: RoutePoint[] = [];
/** 인덱스 = 누가거리(m), 값 = 그 1m 구간의 유입면적(㎡). */
let strength: Float64Array = new Float64Array(0);
let maxStrength = 0;
let hotspots: Array<{ chainage: number; area: number; zone: number; rank: number }> = [];
let selected: number | null = null;
let selectionRings: Array<Array<[number, number]>> = [];
let statusText = "";
// 늦게 도착한 응답이 최신 선택을 덮어쓰지 않게 요청마다 번호를 매긴다.
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;
return Math.log1p(value) / Math.log1p(maxStrength);
}
function drawStrengthLine(context: CanvasRenderingContext2D, view: ViewState): void {
if (!meta || samples.length < 2 || strength.length === 0) return;
context.save();
context.lineWidth = STRENGTH_LINE_WIDTH;
context.lineCap = "round";
const limit = Math.min(strength.length, samples.length - 1);
for (let i = 0; i < limit; i += 1) {
const value = strength[i];
if (value <= 0) continue; // 유입 없는 구간은 계획선 원래 색을 그대로 둔다
const [x0, y0] = metricToScreen(meta, view, samples[i].x, samples[i].y);
const [x1, y1] = metricToScreen(meta, view, samples[i + 1].x, samples[i + 1].y);
context.strokeStyle = rampColor(normalize(value));
context.beginPath();
context.moveTo(x0, y0);
context.lineTo(x1, y1);
context.stroke();
}
context.restore();
}
function markerRadius(area: number): number {
return MARKER_MIN_RADIUS + (MARKER_MAX_RADIUS - MARKER_MIN_RADIUS) * normalize(area);
}
function markerScreen(view: ViewState, chainage: number): [number, number] | null {
if (!meta) return null;
const point = pointAt(chainage);
if (!point) return null;
return metricToScreen(meta, view, point.x, point.y);
}
function drawMarkers(context: CanvasRenderingContext2D, view: ViewState): void {
if (!meta || hotspots.length === 0) return;
context.save();
context.textAlign = "center";
context.textBaseline = "middle";
hotspots.forEach((spot, index) => {
const screen = markerScreen(view, spot.chainage);
if (!screen) return;
const [x, y] = screen;
const radius = markerRadius(spot.area);
context.beginPath();
context.arc(x, y, radius, 0, Math.PI * 2);
context.fillStyle = rampColor(normalize(spot.area));
context.fill();
context.lineWidth = index === selected ? 3 : 1.5;
context.strokeStyle =
index === selected ? themeColor("--map-marker-text", "#111827") : haloColor();
context.stroke();
// 번호는 마커가 충분히 클 때만 안에 넣는다 — 작은 원에 글자를 넣으면 뭉갠다.
context.font = "bold 9px sans-serif";
if (radius >= 8) {
context.fillStyle = haloColor();
context.fillText(String(index + 1), x, y);
} else {
context.fillStyle = themeColor("--map-marker-text", "#111827");
context.strokeStyle = haloColor();
context.lineWidth = 2.5;
context.strokeText(String(index + 1), x + radius + 6, y);
context.fillText(String(index + 1), x + radius + 6, y);
}
});
context.restore();
}
function drawSelection(
context: CanvasRenderingContext2D,
normalizer: Normalizer | null,
view: ViewState,
): void {
if (!normalizer || selectionRings.length === 0) return;
context.save();
context.lineWidth = 2.4;
context.lineJoin = "round";
context.strokeStyle = themeColor("--map-inflow-outline", "#dc2626");
selectionRings.forEach((ring) => {
context.beginPath();
ring.forEach(([lon, lat], index) => {
const [x, y] = lonLatToScreen(normalizer, view, lon, lat);
if (index === 0) context.moveTo(x, y);
else context.lineTo(x, y);
});
context.closePath();
context.stroke();
});
context.restore();
}
/** 선택한 마커의 기여 셀 외곽선을 받아 온다. */
async function loadSelection(index: number): Promise<void> {
const spot = hotspots[index];
if (!projectId || !spot) return;
const sequence = ++requestSequence;
statusText = L("B04_Surface_Flow_Inflow_Loading");
onChange();
try {
const response = await fetchRoadInflow(projectId, spot.chainage);
if (sequence !== requestSequence) return; // 더 최근 선택이 있다
selectionRings = response.rings_lonlat as Array<Array<[number, number]>>;
statusText = L("B04_Surface_Flow_Inflow_Summary")
.replace("{index}", String(index + 1))
.replace("{chainage}", spot.chainage.toFixed(1))
.replace("{area}", formatArea(response.area_m2))
.replace("{cells}", response.cell_count.toLocaleString())
.replace("{path}", response.max_path_length_m.toFixed(0));
} catch (error) {
if (sequence !== requestSequence) return;
selectionRings = [];
statusText = error instanceof Error ? error.message : L("B04_Surface_Flow_Inflow_Failed");
}
onChange();
}
return {
button,
visible: () => shown,
status: () => statusText,
setProject(nextProjectId) {
projectId = nextProjectId;
},
setData(profile, spots) {
const length = profile.reduce((max, [chainage]) => Math.max(max, chainage), 0);
strength = new Float64Array(Math.floor(length) + 1);
profile.forEach(([chainage, area]) => {
const index = Math.round(chainage);
if (index >= 0 && index < strength.length) strength[index] = area;
});
// 노선이 길면 점이 수만 개가 되므로 spread(Math.max(...))로 최대를 구하지 않는다.
maxStrength = strength.reduce((max, value) => (value > max ? value : max), 0);
hotspots = spots.map(([chainage, area, zone, rank]) => ({ chainage, area, zone, rank }));
selected = null;
selectionRings = [];
statusText = "";
// 진행 중인 유입 조회를 무효화한다 — 그러지 않으면 늦게 온 응답이 방금 지운 선택을
// 되살려 "고른 마커는 없는데 외곽선만 떠 있는" 상태가 된다.
requestSequence += 1;
},
setRoute(points, nextMeta) {
routePoints = points;
meta = nextMeta;
resample();
},
clear() {
strength = new Float64Array(0);
maxStrength = 0;
hotspots = [];
selected = null;
selectionRings = [];
statusText = "";
requestSequence += 1;
},
handleClick(_normalizer, view, x, y) {
if (!shown) return false;
// 마커가 없어도 남은 외곽선은 지워 준다 — 안 그러면 지울 방법이 없다.
if (hotspots.length === 0) {
if (selected === null && selectionRings.length === 0) return false;
selected = null;
selectionRings = [];
statusText = "";
requestSequence += 1;
onChange();
return false;
}
let hit: number | null = null;
let hitDistance = Number.POSITIVE_INFINITY;
hotspots.forEach((spot, index) => {
const screen = markerScreen(view, spot.chainage);
if (!screen) return;
const distance = Math.hypot(screen[0] - x, screen[1] - y);
if (distance <= markerRadius(spot.area) + MARKER_HIT_SLACK && distance < hitDistance) {
hit = index;
hitDistance = distance;
}
});
if (hit === null) {
// 마커 밖을 누르면 선택 해제. 도로 아무 데나 고를 수는 없다(사용자 지시).
if (selected === null && selectionRings.length === 0) return false;
selected = null;
selectionRings = [];
statusText = "";
requestSequence += 1;
onChange();
return false;
}
if (selected === hit) {
selected = null;
selectionRings = [];
statusText = "";
requestSequence += 1;
onChange();
return true;
}
selected = hit;
selectionRings = [];
void loadSelection(hit);
return true;
},
draw(context, normalizer, view) {
if (!shown) return;
drawStrengthLine(context, view);
drawSelection(context, normalizer, view);
drawMarkers(context, view);
},
dispose() {
requestSequence += 1;
},
};
}
@@ -403,6 +403,45 @@ function affineOf(view: ViewState): Affine {
};
}
/** 정규화 좌표(0~1) → 화면 px. 프레임마다 여러 번 부를 것이라면 `affineOf`를 한 번 잡아 쓴다. */
export function normalizedToScreen(
view: ViewState,
nx: number,
ny: number,
): [x: number, y: number] {
const affine = affineOf(view);
return [nx * affine.ax + affine.bx, ny * affine.ay + affine.by];
}
/** lon/lat → 화면 px. 마커를 찍거나 외곽선을 그릴 때 쓴다. */
export function lonLatToScreen(
normalizer: Normalizer,
view: ViewState,
lon: number,
lat: number,
): [x: number, y: number] {
return normalizedToScreen(
view,
(lon - normalizer.lonMin) / normalizer.lonRange,
1 - (lat - normalizer.latMin) / normalizer.latRange,
);
}
/**
* 사업지 좌표계(m) → 화면 px. `prepareMetricPolyline`과 **같은 가정**을 쓴다 —
* meta의 x/y 범위와 lon/lat 범위가 같은 사각형을 가리킨다는 것.
*/
export function metricToScreen(
meta: VWorldMeta,
view: ViewState,
x: number,
y: number,
): [x: number, y: number] {
const spanX = meta.width_meters || 1;
const spanY = meta.height_meters || 1;
return normalizedToScreen(view, (x - meta.x_min) / spanX, 1 - (y - meta.y_min) / spanY);
}
/** 시각적 무손실 LOD 허용 오차(화면 px). 이보다 작은 오차의 정점만 생략된다. */
const LOD_PX = 0.75;
@@ -10,6 +10,7 @@ import {
type VWorldMeta,
} from "./B04_wf1_Surface_Api_Fetch";
import { niceScaleDistance } from "./B04_wf1_Surface_UI_Camera";
import { createFlowStrengthOverlay } from "./B04_wf1_Surface_UI_FlowStrength";
import { createWatershedOverlay } from "./B04_wf1_Surface_UI_Watershed";
import {
computeMapRect,
@@ -28,6 +29,7 @@ import {
type PreparedLayer,
type ViewState,
} from "./B04_wf1_Surface_UI_MapRender";
import type { WatershedAnalysis } from "./B04_wf1_Surface_Api_Fetch";
export interface SurfaceMapViewer {
root: HTMLElement;
@@ -76,6 +78,9 @@ const GIS_DEFAULT_ON: Record<GisLayer, boolean> = {
/** 등고 라벨(계곡선 수치) 기본 표시 여부. */
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]> = {
@@ -156,6 +161,10 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
// 배경지도가 복잡해 글자가 묻히므로 각 문구에 배경 칩을 깐다.
const statusStack = document.createElement("div");
statusStack.className = "b04-map__status-stack";
// 흐름 강도 선택 요약 — 배수유역 상태 줄과 칸을 나눠 쓰면 서로 덮어쓴다.
const flowStatus = document.createElement("span");
flowStatus.className = "b04-map__watershed-status";
flowStatus.hidden = true;
// 객체 표시(지형지물 개수) 라벨은 우측 최하단으로 내린다 — 좌상단을 배수유역에 내주기 위함.
const statusCorner = document.createElement("div");
statusCorner.className = "b04-map__status-corner";
@@ -204,6 +213,8 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
let offsetX = 0;
let offsetY = 0;
let dragStart: { x: number; y: number; offsetX: number; offsetY: number } | null = null;
/** 좌버튼을 누른 자리. 뗄 때까지 이만큼 이하로 움직였으면 클릭으로 본다. */
let clickStart: { x: number; y: number } | null = null;
let loadSequence = 0;
// rAF 스로틀: 팬/줌 이벤트는 상태만 갱신하고 프레임당 1회만 드로잉한다.
// LOD(MapRender) 덕에 프레임 렌더 비용이 낮아 매 프레임 직접 렌더가 항상 완전한 화면을 보장한다.
@@ -300,18 +311,36 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
// 배수유역 분석 오버레이 — 계산은 백엔드가 하고 여기서는 겹쳐 그리기만 한다.
// 상태 문구는 오버레이 전용 줄에 쓴다 — 지도 자체 상태(레이어 로딩)와 같은 칸을 쓰면
// 나중에 끝난 쪽이 상대 문구를 지워 버린다.
const watershed = createWatershedOverlay(() => scheduleDraw());
// 분석이 끝나면 강도 곡선·유입 집중점을 흐름 강도 오버레이로 넘긴다.
// 이 콜백은 갈래 토글을 누를 때도 불리므로, **분석 결과가 실제로 바뀐 경우에만** 넘긴다.
// 매번 넘기면 고른 집중점과 유입 외곽선이 버튼 한 번에 소리 없이 지워진다.
let lastAnalysis: WatershedAnalysis | null = null;
const watershed = createWatershedOverlay(() => {
const analysis = watershed.analysis();
if (analysis !== lastAnalysis) {
lastAnalysis = analysis;
if (analysis) flowStrength.setData(analysis.strength_profile, analysis.inflow_hotspots ?? []);
else flowStrength.clear();
}
scheduleDraw();
});
const watershedGroup = document.createElement("div");
watershedGroup.className = "b04-map__control-group";
const watershedTitle = document.createElement("span");
watershedTitle.textContent = "배수유역";
const watershedButtons = document.createElement("div");
watershedButtons.className = "b04-map__layer-buttons";
watershedButtons.append(watershed.button, ...watershed.partButtons);
// 도로 유입 흐름 강도 — 배수유역 분석 결과를 받아 계획선 위에 색·마커로 얹는다.
const flowStrength = createFlowStrengthOverlay(() => {
flowStatus.textContent = flowStrength.status();
flowStatus.hidden = flowStatus.textContent === "";
scheduleDraw();
});
watershedButtons.append(watershed.button, ...watershed.partButtons, flowStrength.button);
watershedGroup.append(watershedTitle, watershedButtons);
controls.insertBefore(watershedGroup, resetButton);
// 안내·결과 문구는 컨트롤 줄이 아니라 지도 위에 얹는다 — 컨트롤 영역 세로 공간을 먹지 않는다.
statusStack.append(watershed.statusElement);
statusStack.append(watershed.statusElement, flowStatus);
function updateImageTransform(): void {
backgroundImages.forEach((image) => {
@@ -418,6 +447,8 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
context.strokeStyle = routeLineColor();
drawPreparedLayer(context, routeLayer, view, "dot");
}
// 흐름 강도(도로 색·유입 집중점 마커)는 계획선 위에 얹는다.
flowStrength.draw(context, normalizer, view);
updateImageTransform();
drawScaleBar(mapRect);
}
@@ -476,6 +507,8 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
normalizer = createNormalizer(nextMeta);
routeLayer =
planned.points.length > 1 ? prepareMetricPolyline(planned.points, nextMeta) : null;
// 흐름 강도는 계획선 위에 칠하므로 같은 점 목록·같은 메타를 쓴다(어긋나면 색이 밀린다).
flowStrength.setRoute(planned.points, nextMeta);
// 계획노선을 아직 올리지 않은 프로젝트에서는 켤 것이 없으니 버튼을 잠근다.
routeButton.disabled = routeLayer === null;
routeButton.title = routeLayer === null ? L("B04_Surface_Map_PlannedRouteEmpty") : "";
@@ -530,11 +563,36 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
// 팬은 가운데(휠) 버튼 전용이다. 좌버튼은 화면 위 객체를 고르는 데만 쓴다
// (좌버튼 드래그가 팬까지 겸하면 객체를 집으려다 지도가 딸려 움직인다 — 2026-08-01 사용자 지시).
// 가운데 버튼이 없는 터치·펜은 그대로 끌어서 팬 할 수 있게 둔다.
if (event.pointerType === "mouse" && event.button !== 1) return;
// 누른 자리를 기억해 두었다가 pointerup에서 "끌지 않고 눌렀다 뗐다"면 클릭으로 처리한다.
// 터치·펜은 팬도 겸하므로 여기서 반환하지 않고 기록만 남긴다 — 그래야 손가락으로도 마커를
// 고를 수 있다(끌었으면 이동량 판정에서 걸러진다).
if (event.button === 0) clickStart = { x: event.clientX, y: event.clientY };
if (event.pointerType === "mouse" && event.button !== 1) {
// 마우스 좌버튼은 선택 전용 — 팬을 시작하지 않는다.
return;
}
dragStart = { x: event.clientX, y: event.clientY, offsetX, offsetY };
viewport.style.cursor = "grabbing";
viewport.setPointerCapture(event.pointerId);
});
viewport.addEventListener("pointerup", (event) => {
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),
};
flowStrength.handleClick(normalizer, view, event.clientX - rect.left, event.clientY - rect.top);
});
viewport.addEventListener("pointermove", (event) => {
if (!dragStart) return;
offsetX = dragStart.offsetX + event.clientX - dragStart.x;
@@ -559,6 +617,8 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
routeBounds = nextRouteBounds ?? null;
watershed.reset();
watershed.setProject(projectId);
flowStrength.clear();
flowStrength.setProject(projectId);
void loadLayers();
},
dispose() {
@@ -568,6 +628,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
frameHandle = 0;
}
resizeObserver.disconnect();
flowStrength.dispose();
},
};
}
+47 -16
View File
@@ -56,16 +56,16 @@ function buildInfoLine(label: string, value: unknown): HTMLElement {
return row;
}
function buildSelectGroup(
title: string,
/** 라벨 한 줄 + 드롭다운. 지표면 분석 컨테이너 안의 항목 하나를 만든다. */
function buildSelectField(
label: string,
values: readonly string[],
defaultValue: string,
): { root: HTMLElement; select: HTMLSelectElement } {
const root = document.createElement("section");
root.className = "b04-surface__group";
const heading = document.createElement("h3");
heading.className = "b04-surface__panel-title";
heading.textContent = title;
const root = document.createElement("label");
root.className = "b04-surface__field";
const caption = document.createElement("span");
caption.textContent = label;
const select = document.createElement("select");
select.className = "b04-surface__select";
values.forEach((value) => {
@@ -75,10 +75,21 @@ function buildSelectGroup(
select.append(option);
});
select.value = defaultValue;
root.append(heading, select);
root.append(caption, select);
return { root, select };
}
/** 제목이 달린 사이드 패널 컨테이너. */
function buildGroup(title: string, ...children: HTMLElement[]): HTMLElement {
const root = document.createElement("section");
root.className = "b04-surface__group";
const heading = document.createElement("h3");
heading.className = "b04-surface__panel-title";
heading.textContent = title;
root.append(heading, ...children);
return root;
}
function getModelFilter(model: SurfaceModelSummary): string {
const configured = model.generation_params?.source_filter;
if (typeof configured === "string") return configured.toLowerCase();
@@ -115,8 +126,16 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
const statusBox = document.createElement("div");
statusBox.className = "b04-surface__status";
const filterGroup = buildSelectGroup("지면 필터 선택", SOURCE_FILTERS, DEFAULT_FILTER);
const methodGroup = buildSelectGroup("지표면 표현 선택", MODEL_METHODS, DEFAULT_METHOD);
const filterGroup = buildSelectField(
L("B04_Surface_Group_Filters"),
SOURCE_FILTERS,
DEFAULT_FILTER,
);
const methodGroup = buildSelectField(
L("B04_Surface_Group_Methods"),
MODEL_METHODS,
DEFAULT_METHOD,
);
const viewer = createSurfacePointCloudViewer();
const terrainViewer = createSurfaceTerrainViewer();
const mapViewer = createSurfaceMapViewer();
@@ -140,12 +159,12 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
viewer.setAxesVisible(false);
const confirmButton = createButton({
label: "모델 확정",
label: L("B04_Surface_Btn_Confirm"),
variant: "filled",
onClick: () => void onB04_Surface_Confirm_Click(),
});
const resetButton = createButton({
label: "초기화",
label: L("Common_Btn_Reset"),
variant: "ghost",
onClick: () => void onB04_Surface_Reset_Click(),
});
@@ -157,14 +176,26 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
inputTitle.textContent = L("B04_Surface_InputFiles");
inputGroup.append(inputTitle, inputSelect, inputInfo);
// 지면 필터 · 서피스 · 스무딩은 함께 모델 하나를 정하는 값이라 한 컨테이너에 모은다.
const analysisGroup = buildGroup(
L("B04_Surface_Group_Analysis"),
filterGroup.root,
methodGroup.root,
terrainViewer.smoothingField,
);
// 표시 옵션은 3D 모델 토글과 포인트 슬라이더를 한 칸에 둔다(슬라이더가 맨 아래).
const displayGroup = buildGroup(
L("B04_Surface_Group_Display"),
terrainViewer.optionsContent,
viewer.optionsContent,
);
const panel = document.createElement("div");
panel.className = "b04-surface__form";
panel.append(
inputGroup,
filterGroup.root,
methodGroup.root,
viewer.optionsGroup,
terrainViewer.optionsGroup,
analysisGroup,
displayGroup,
viewer.controlsGroup,
confirmButton,
resetButton,
+58 -3
View File
@@ -51,6 +51,20 @@
color: var(--color-text);
}
/* 라벨 한 줄 + 드롭다운 — 지표면 분석 컨테이너의 항목 한 칸. */
.b04-surface__field {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
font-size: var(--text-caption);
color: var(--color-text-secondary);
}
.b04-surface__field.is-disabled {
color: var(--color-text-muted);
opacity: 0.55;
}
.b04-surface__select {
width: 100%;
min-height: 38px;
@@ -344,9 +358,48 @@
opacity: 0.55;
}
/* 점 크기·밀도 — 컨테이너 맨 아래에 한 줄씩. 게이지 **앞**에 현재값을 적는다
(2026-08-01 사용자 지시). 값 칸 폭을 고정해 끌 때 게이지가 흔들리지 않게 한다. */
.b04-surface__group .viewer-options {
flex-direction: column;
align-items: stretch;
gap: var(--spacing-8);
padding: 0;
border-bottom: 0;
background: transparent;
}
/* `.viewer-options label`(0,1,1)이 display:inline-flex를 걸고 있어 클래스 하나(0,1,0)로는
못 이긴다. 같은 특이도 이상으로 올려야 3열 배치가 실제로 적용된다. */
.viewer-options .viewer-option-row {
display: grid;
grid-template-columns: auto 44px 1fr;
align-items: center;
gap: var(--spacing-8);
}
.viewer-option-name {
white-space: nowrap;
}
.viewer-options .viewer-option-row input[type="range"] {
width: 100%;
min-width: 0;
}
/* 3D 모델 표시 토글 + 상태 줄 묶음. */
.terrain-options-content {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
}
/* 축·서피스·등고선 토글 3개 + 등고선 간격 폼 1개 = 4칸.
스무딩이 "지표면 분석"으로 빠진 뒤에도 항목 수가 그대로라 4열을 유지한다.
좁은 폭에서는 칸이 뭉개지므로 최소 폭을 두고 넘치면 다음 줄로 접는다. */
.model-display-options {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-template-columns: repeat(auto-fit, minmax(72px, 1fr));
gap: var(--spacing-8);
width: 100%;
}
@@ -424,11 +477,13 @@
opacity: 0.6;
}
/* 게이지 앞 현재값 — 정의는 여기 하나뿐이다(위쪽에 있던 중복 정의를 합쳤다). */
.viewer-option-val {
font-variant-numeric: tabular-nums;
min-width: 38px;
display: inline-block;
min-width: 38px;
text-align: right;
color: var(--color-text);
font-variant-numeric: tabular-nums;
font-weight: var(--font-weight-semibold);
}
@@ -4,6 +4,7 @@ import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { PLYLoader } from "three/examples/jsm/loaders/PLYLoader.js";
import { API_BASE_URL } from "@config/config_frontend";
import { fetchCachedBytes, fetchCachedJson } from "../A00_Common/b_asset_cache";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import { createProgressCircle } from "@ui/ui_template_progress";
import type { SurfaceBounds, SurfaceModelSummary } from "./B04_wf1_Surface_Api_Fetch";
import {
@@ -16,9 +17,16 @@ import {
type SurfaceCameraState,
} from "./B04_wf1_Surface_UI_Camera";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
export interface SurfaceTerrainViewer {
root: HTMLElement;
optionsGroup: HTMLElement;
/** 표시 토글(축·서피스·등고선·간격) + 상태 줄. 페이지가 "모델 표시 옵션"에 넣는다. */
optionsContent: HTMLElement;
/** 스무딩 드롭다운 한 줄. 페이지가 "지표면 분석"에 넣는다. */
smoothingField: HTMLElement;
render: (projectId: string, models: readonly SurfaceModelSummary[]) => void;
setReferenceBounds: (bounds: SurfaceBounds) => void;
setSelection: (sourceFilter: string, method: string) => void;
@@ -62,13 +70,28 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
surfCheck.checked = true;
surfLabel.append(surfCheck, document.createTextNode(" 서피스"));
// Smooth Toggle (for tin/dtm)
// 스무딩(tin/dtm 전용) — 지면 필터·서피스와 함께 "지표면 분석" 컨테이너로 옮겼다.
// 주변 입력과 양식을 맞추려고 버튼이 아니라 드롭다운이다(2026-08-01 사용자 지시).
// 상태·재렌더 배선은 여기 그대로 두고, 페이지는 이 엘리먼트를 원하는 자리에 놓기만 한다.
const smoothLabel = document.createElement("label");
smoothLabel.className = "toggle-label toggle-button";
const smoothCheck = document.createElement("input");
smoothCheck.type = "checkbox";
smoothCheck.checked = true;
smoothLabel.append(smoothCheck, document.createTextNode(" 스무딩"));
smoothLabel.className = "b04-surface__field";
const smoothCaption = document.createElement("span");
smoothCaption.textContent = L("B04_Surface_Field_Smoothing");
const smoothSelect = document.createElement("select");
smoothSelect.className = "b04-surface__select";
(
[
["on", "B04_Surface_Smoothing_On"],
["off", "B04_Surface_Smoothing_Off"],
] as const
).forEach(([value, key]) => {
const option = document.createElement("option");
option.value = value;
option.textContent = L(key);
smoothSelect.append(option);
});
smoothSelect.value = "on";
smoothLabel.append(smoothCaption, smoothSelect);
// Contour Toggle
const contourLabel = document.createElement("label");
@@ -104,14 +127,12 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
const axesLabel = document.createElement("label");
axesLabel.className = "toggle-label toggle-button";
axesLabel.append(axesCheck, document.createTextNode(" 축"));
rightControls.append(axesLabel, surfLabel, smoothLabel, contourLabel, intervalForm);
rightControls.append(axesLabel, surfLabel, contourLabel, intervalForm);
const optionsGroup = document.createElement("section");
optionsGroup.className = "b04-surface__group";
const optionsTitle = document.createElement("h3");
optionsTitle.className = "b04-surface__panel-title";
optionsTitle.textContent = "모델 표시 옵션";
optionsGroup.append(optionsTitle, rightControls, statusSpan);
// 표시 토글 묶음 + 상태 줄. 컨테이너(제목)는 페이지가 만든다 — 포인트 옵션과 한 칸을 쓴다.
const optionsContent = document.createElement("div");
optionsContent.className = "terrain-options-content";
optionsContent.append(rightControls, statusSpan);
// 3D View container
const viewerArea = document.createElement("div");
@@ -317,10 +338,15 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
function syncSmoothingSupport(): void {
const supported = activeMethod === "tin" || activeMethod === "dtm";
smoothCheck.disabled = !supported;
smoothCheck.checked = supported && smoothPreferred;
smoothSelect.disabled = !supported;
smoothSelect.value = supported && smoothPreferred ? "on" : "off";
smoothLabel.classList.toggle("is-disabled", !supported);
smoothLabel.title = supported ? "" : "이 지표면 표현은 스무딩을 지원하지 않습니다.";
smoothLabel.title = supported ? "" : L("B04_Surface_Smoothing_Unsupported");
}
/** 지금 스무딩을 적용하는 상태인가. */
function smoothingOn(): boolean {
return !smoothSelect.disabled && smoothSelect.value === "on";
}
// Load mesh and contours
@@ -354,7 +380,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
}
const modelId = match.id;
const isSmooth = (activeMethod === "tin" || activeMethod === "dtm") && smoothCheck.checked;
const isSmooth = (activeMethod === "tin" || activeMethod === "dtm") && smoothingOn();
currentModelId = modelId;
currentModelSmooth = isSmooth;
const generation = ++loadGeneration;
@@ -641,8 +667,8 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
}
});
smoothCheck.addEventListener("change", () => {
smoothPreferred = smoothCheck.checked;
smoothSelect.addEventListener("change", () => {
smoothPreferred = smoothSelect.value === "on";
updateSelectedModel();
});
@@ -681,7 +707,8 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
return {
root,
optionsGroup,
optionsContent,
smoothingField: smoothLabel,
render(projectId, models) {
currentProjectId = projectId;
currentModelsList = models;
@@ -703,7 +730,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
axesVisibilityListener = listener;
},
isSmoothingEnabled() {
return !smoothCheck.disabled && smoothCheck.checked;
return smoothingOn();
},
setSmoothing(enabled) {
smoothPreferred = enabled;
+55 -32
View File
@@ -1,4 +1,5 @@
import { RENDER_OPTIONS } from "@config/config_frontend";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import { createProgressCircle } from "@ui/ui_template_progress";
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
@@ -16,10 +17,15 @@ import {
export type { SurfaceCameraState } from "./B04_wf1_Surface_UI_Camera";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
export interface SurfacePointCloudViewer {
root: HTMLElement;
controlsGroup: HTMLElement;
optionsGroup: HTMLElement;
/** 점 크기·밀도 슬라이더 묶음. 페이지가 "모델 표시 옵션" 컨테이너 맨 아래에 붙인다. */
optionsContent: HTMLElement;
statusSpan: HTMLElement;
/** 로딩 서클 표시. 문구를 주면 켜고, null이면 끈다. `render()` 시 자동으로 꺼진다. */
setLoading: (label: string | null) => void;
@@ -62,37 +68,55 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer {
controlsGroup.className = "b04-surface__group";
const controlsTitle = document.createElement("h3");
controlsTitle.className = "b04-surface__panel-title";
controlsTitle.textContent = "뷰어 시점 제어";
controlsTitle.textContent = L("B04_Surface_Group_ViewControls");
controlsGroup.append(controlsTitle, controls);
/** 슬라이더 한 줄 — `이름 [현재값] [게이지]`. 값이 게이지 앞에 온다(2026-08-01 사용자 지시). */
function buildSlider(
label: string,
attributes: { min: string; max: string; step: string; value: string },
): { root: HTMLLabelElement; input: HTMLInputElement; value: HTMLSpanElement } {
const root = document.createElement("label");
root.className = "viewer-option-row";
const name = document.createElement("span");
name.className = "viewer-option-name";
name.textContent = label;
const value = document.createElement("span");
value.className = "viewer-option-val";
const input = document.createElement("input");
input.type = "range";
input.min = attributes.min;
input.max = attributes.max;
input.step = attributes.step;
input.value = attributes.value;
root.append(name, value, input);
return { root, input, value };
}
const options = document.createElement("div");
options.className = "viewer-options";
const sizeLabel = document.createElement("label");
sizeLabel.textContent = "점 크기";
const sizeInput = document.createElement("input");
sizeInput.type = "range";
sizeInput.min = "0.1";
sizeInput.max = "2.5";
sizeInput.step = "0.01";
sizeInput.value = "0.42";
sizeLabel.append(sizeInput);
const densityLabel = document.createElement("label");
densityLabel.innerHTML = '밀도 <span class="viewer-option-val">100%</span>';
const densityInput = document.createElement("input");
densityInput.type = "range";
densityInput.min = "1";
densityInput.max = "10";
densityInput.step = "1";
densityInput.value = "10";
densityLabel.append(densityInput);
options.append(sizeLabel, densityLabel);
const size = buildSlider(L("B04_Surface_Opt_PointSize"), {
min: "0.1",
max: "2.5",
step: "0.01",
value: "0.42",
});
const density = buildSlider(L("B04_Surface_Opt_Density"), {
min: "1",
max: "10",
step: "1",
value: "10",
});
const sizeInput = size.input;
const densityInput = density.input;
options.append(size.root, density.root);
const optionsGroup = document.createElement("section");
optionsGroup.className = "b04-surface__group";
const optionsTitle = document.createElement("h3");
optionsTitle.className = "b04-surface__panel-title";
optionsTitle.textContent = "포인트 표시 옵션";
optionsGroup.append(optionsTitle, options);
/** 게이지 앞 숫자를 현재값으로 맞춘다. */
function syncOptionValues(): void {
size.value.textContent = Number(sizeInput.value).toFixed(2);
density.value.textContent = `${Number(densityInput.value) * 10}%`;
}
syncOptionValues();
const viewerArea = document.createElement("div");
viewerArea.className = "three-viewer";
@@ -303,12 +327,12 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer {
button.addEventListener("click", () => setCameraView(view));
});
sizeInput.addEventListener("input", () => {
syncOptionValues();
if (pointsObject)
(pointsObject.material as THREE.PointsMaterial).size = Number(sizeInput.value);
});
densityInput.addEventListener("input", () => {
const label = densityLabel.querySelector(".viewer-option-val");
if (label) label.textContent = `${Number(densityInput.value) * 10}%`;
syncOptionValues();
if (currentData) renderPointCloud(currentData);
});
orbit.addEventListener("change", emitCameraState);
@@ -317,7 +341,7 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer {
return {
root,
controlsGroup,
optionsGroup,
optionsContent: options,
statusSpan,
setLoading,
render(data) {
@@ -342,8 +366,7 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer {
axes.visible = false;
sizeInput.value = "0.42";
densityInput.value = "10";
const label = densityLabel.querySelector(".viewer-option-val");
if (label) label.textContent = "100%";
syncOptionValues();
if (currentData) renderPointCloud(currentData);
setCameraView("iso");
},
@@ -85,6 +85,8 @@ export interface WatershedOverlay {
visible: () => boolean;
/** 상태 문구(분석 요약 또는 오류). 없으면 빈 문자열. */
status: () => string;
/** 마지막으로 받은 분석 결과. 흐름 강도 오버레이가 강도 곡선·유입 집중점을 여기서 가져간다. */
analysis: () => WatershedAnalysis | null;
/** 프로젝트가 바뀌면 받아 둔 분석 결과를 버린다. */
reset: () => void;
/** 현재 프로젝트를 알려 준다. 지정 전에는 버튼이 아무 일도 하지 않는다. */
@@ -531,6 +533,7 @@ export function createWatershedOverlay(onChange: () => void): WatershedOverlay {
statusElement,
visible: () => shown && analysis !== null,
status: () => statusText,
analysis: () => analysis,
reset() {
analysis = null;
flowCache = null;
+6 -1
View File
@@ -258,7 +258,12 @@ DRAINAGE_RED_EXPAND_MAX_ROUNDS = int(os.getenv("DRAINAGE_RED_EXPAND_MAX_ROUNDS",
# 격자 셀 수 권장 상한. 넘으면 **경고만** 남기고 그대로 계산한다 — 격자 크기 자동 조절은
# 하지 않는다(2026-07-31 사용자 지시). 느리면 위 DRAINAGE_GRID_SIZE_M을 직접 올린다.
DRAINAGE_MAX_GRID_CELLS = int(os.getenv("DRAINAGE_MAX_GRID_CELLS", "16000000"))
# 도로 폭(m). 이 폭으로 노선을 격자에 구워 D8 흐름이 도로를 대각선으로 건너뛰지 못하게 한다.
# 해석용 **도로 굽기 폭**(m) — 설계 도로폭이 아니다.
#
# 설계 도로폭은 등급별로 따로 있다(B05 `/sections/road-widths`: 간선 3.0 / 지선 3.0 / 작업로 2.5m).
# 여기 값은 노선을 격자에 구울 때만 쓰는 해석 파라미터다. 1m 격자에서 도로를 1셀 선으로 구우면
# D8 대각 이동이 도로를 건너뛰어 상류 물이 도로를 지나쳐 버리므로, 3셀(=3m) 이상 두께가 필요하다.
# 그래서 설계폭과 연동하지 않고 4m로 둔다(2026-08-01 사용자 확인). 값을 바꾸면 재분석해야 한다.
DRAINAGE_ROAD_WIDTH_M = float(os.getenv("DRAINAGE_ROAD_WIDTH_M", "4.0"))
# 계획선 최저점보다 이만큼 아래인 등고선은 상류 기여가 불가능하므로 보간에서 제외한다.
DRAINAGE_CONTOUR_MARGIN_M = float(os.getenv("DRAINAGE_CONTOUR_MARGIN_M", "10.0"))
+2
View File
@@ -31,6 +31,7 @@ from B04_wf1_Surface.B04_wf1_Surface_Router import router as b04_surface_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
from B04_wf1_Surface.B04_wf1_Surface_Router_Inflow import router as b04_inflow_router
from B04_wf1_Surface.B04_wf1_Surface_Router_Watershed import router as b04_watershed_router
from B05_wf2_Route.B05_wf2_Route_Router import router as b05_route_router
from B05_wf2_Route.B05_wf2_Route_Router_Drainage import router as b05_drainage_router
@@ -275,6 +276,7 @@ app.include_router(b04_surface_router, dependencies=protected_with_company)
app.include_router(b04_surface_contour_router, dependencies=protected_with_company)
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(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)
+32 -2
View File
@@ -615,8 +615,22 @@ export const ui_locales = {
B04_Surface_Title: ["전처리", "Preprocess"],
B04_Surface_Field_InputId: ["입력 파일 ID", "Input File ID"],
B04_Surface_Field_InputId_Placeholder: ["예: 1", "e.g. 1"],
B04_Surface_Group_Filters: ["지면 필터 선택", "Ground Filters"],
B04_Surface_Group_Methods: ["지표면 표현 선택", "Surface Methods"],
/* ··
(2026-08-01 ). . */
B04_Surface_Group_Analysis: ["지표면 분석", "Surface Analysis"],
B04_Surface_Group_Filters: ["지면 필터", "Ground filter"],
B04_Surface_Group_Methods: ["서피스", "Surface"],
B04_Surface_Group_Display: ["모델 표시 옵션", "Model display options"],
B04_Surface_Group_ViewControls: ["뷰어 시점 제어", "Viewer camera"],
B04_Surface_Field_Smoothing: ["스무딩", "Smoothing"],
B04_Surface_Smoothing_On: ["적용", "On"],
B04_Surface_Smoothing_Off: ["미적용", "Off"],
B04_Surface_Smoothing_Unsupported: [
"이 지표면 표현은 스무딩을 지원하지 않습니다.",
"This surface method does not support smoothing.",
],
B04_Surface_Opt_PointSize: ["점 크기", "Point size"],
B04_Surface_Opt_Density: ["밀도", "Density"],
B04_Surface_Field_Force: ["기존 결과 무시하고 재계산", "Force recompute"],
B04_Surface_Btn_Analyze: ["지표면 분석 실행", "Run Surface Analysis"],
B04_Surface_Btn_Refresh: ["목록 새로고침", "Refresh"],
@@ -689,6 +703,22 @@ export const ui_locales = {
B04_Surface_Watershed_Origin_Cached: ["저장분", "Cached"],
/* {seconds}=재산정에 걸린 시간(초) */
B04_Surface_Watershed_Origin_Recomputed: ["재산정 {seconds}초", "Recomputed in {seconds}s"],
/* 도로 유입 흐름 강도 */
B04_Surface_Flow_Strength: ["흐름 강도", "Flow strength"],
B04_Surface_Flow_Strength_Tip: [
"도로 1m 구간마다 그 자리로 모이는 상류 면적을 색으로 칠하고, 물이 특히 많이 모이는 자리를 마커로 찍습니다. 마커를 누르면 그 지점으로 들어오는 셀들의 외곽선을 보여 줍니다.",
"Colors each 1m stretch of road by the upstream area draining into it, and marks the spots that collect the most. Click a marker to outline the cells that drain into it.",
],
B04_Surface_Flow_Inflow_Loading: ["유입 셀을 불러오는 중…", "Loading the contributing cells…"],
/* {index}=마커 번호, {chainage}=누가거리, {area}=유입면적, {cells}=셀 수, {path}=최장 유하장 */
B04_Surface_Flow_Inflow_Summary: [
"유입 집중점 {index} · 측점 {chainage}m — 유입면적 {area} · 셀 {cells}개 · 최장 유하장 {path}m",
"Hotspot {index} · Station {chainage}m — Area {area} · {cells} cells · Longest path {path}m",
],
B04_Surface_Flow_Inflow_Failed: [
"유입 셀을 불러오지 못했습니다.",
"Failed to load the contributing cells.",
],
B04_Surface_Map_PlannedRouteEmpty: [
"B03에서 계획노선 파일을 올리면 표시됩니다.",
"Shown after the planned route file is uploaded in B03.",
+10
View File
@@ -234,6 +234,16 @@
--map-flow-away-arrow: rgba(30, 64, 175, 0.95);
--map-flow-unknown-fill: rgba(120, 113, 108, 0.18); /* 표고가 없어 판정 못한 셀 */
/* 도로 유입 흐름 강도 색띠 적음(파랑) 많음(빨강). 로그 스케일로 보간한다.
보간을 하려면 hex여야 한다(rgba/색이름 금지). */
--map-flow-ramp-0: #2563eb;
--map-flow-ramp-1: #06b6d4;
--map-flow-ramp-2: #22c55e;
--map-flow-ramp-3: #eab308;
--map-flow-ramp-4: #f97316;
--map-flow-ramp-5: #dc2626;
--map-inflow-outline: #dc2626; /* 선택한 지점으로 들어오는 셀들의 외곽선 */
/* 유역 채움 파스텔 8종 유역 번호 순으로 돌려쓴다(사용자 지시: 파스텔톤).
알파 0.45는 비선택 유역을 0.18로 낮출 문자열로 치환하므로 형식을 바꾸지 . */
--map-basin-1: rgba(167, 216, 199, 0.45);