From 4d9f8db33d4e550b685fc9ab60f634b8dc97c9f6 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 1 Aug 2026 16:16:15 +0900 Subject: [PATCH] =?UTF-8?q?feat(B04):=20=EB=8F=84=EB=A1=9C=20=EC=9C=A0?= =?UTF-8?q?=EC=9E=85=20=ED=9D=90=EB=A6=84=20=EA=B0=95=EB=8F=84=20=ED=91=9C?= =?UTF-8?q?=EC=8B=9C=20+=20=EC=9C=A0=EC=9E=85=20=EC=A7=91=EC=A4=91?= =?UTF-8?q?=EC=A0=90=20=EB=A7=88=EC=BB=A4,=20=EC=82=AC=EC=9D=B4=EB=93=9C?= =?UTF-8?q?=20=ED=8C=A8=EB=84=90=20=EC=BB=A8=ED=85=8C=EC=9D=B4=EB=84=88=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 흐름 강도 계산은 이미 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) --- B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts | 32 +- ...04_wf1_Surface_Engine_Watershed_Analyze.py | 88 +++- .../B04_wf1_Surface_Engine_Watershed_Flow.py | 12 +- .../B04_wf1_Surface_Router_Inflow.py | 169 ++++++++ .../B04_wf1_Surface_Router_Watershed.py | 22 +- .../B04_wf1_Surface_UI_FlowStrength.ts | 379 ++++++++++++++++++ .../B04_wf1_Surface_UI_MapRender.ts | 39 ++ .../B04_wf1_Surface_UI_MapViewer.ts | 69 +++- B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts | 63 ++- B04_wf1_Surface/B04_wf1_Surface_UI_Style.css | 61 ++- .../B04_wf1_Surface_UI_TerrainViewer.ts | 71 +++- B04_wf1_Surface/B04_wf1_Surface_UI_Viewer.ts | 87 ++-- .../B04_wf1_Surface_UI_Watershed.ts | 3 + config/config_system.py | 7 +- main.py | 2 + ui_template/ui_template_locale.ts | 34 +- ui_template/ui_template_theme.css | 10 + 17 files changed, 1061 insertions(+), 87 deletions(-) create mode 100644 B04_wf1_Surface/B04_wf1_Surface_Router_Inflow.py create mode 100644 B04_wf1_Surface/B04_wf1_Surface_UI_FlowStrength.ts diff --git a/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts b/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts index 4ccab99d..fc172ac1 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts @@ -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>; +} + +/** 취소는 지원하지 않는다(`requestJson`이 자체 타임아웃 신호를 쓴다) — 호출측에서 늦게 온 + * 응답을 버리는 방식으로 처리한다. */ +export async function fetchRoadInflow( + projectId: string, + chainageM: number, +): Promise { + return requestJson( + `/projects/${projectId}/drainage/road-inflow?chainage_m=${chainageM}`, + { method: "GET" }, + ); +} diff --git a/B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Analyze.py b/B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Analyze.py index 8955406c..16c4fd0f 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Analyze.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Analyze.py @@ -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). 그러면 짝수 칸에는 세 샘플({k−0.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]]: """응답용으로 강도 곡선을 일정 간격으로 줄인다(구간 합 유지). diff --git a/B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Flow.py b/B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Flow.py index 91ec8f0b..0f33e4ef 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Flow.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Engine_Watershed_Flow.py @@ -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 diff --git a/B04_wf1_Surface/B04_wf1_Surface_Router_Inflow.py b/B04_wf1_Surface/B04_wf1_Surface_Router_Inflow.py new file mode 100644 index 00000000..12df7db8 --- /dev/null +++ b/B04_wf1_Surface/B04_wf1_Surface_Router_Inflow.py @@ -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), + } diff --git a/B04_wf1_Surface/B04_wf1_Surface_Router_Watershed.py b/B04_wf1_Surface/B04_wf1_Surface_Router_Watershed.py index 068418e1..3bbd1ae1 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Router_Watershed.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Router_Watershed.py @@ -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, 방위(도), 도로도달, 셀 수]. diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_FlowStrength.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_FlowStrength.ts new file mode 100644 index 00000000..a26b209d --- /dev/null +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_FlowStrength.ts @@ -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, + hotspots: ReadonlyArray, + ) => void; + /** 계획선(사업지 좌표계 m)과 배경지도 메타. 둘이 있어야 화면 좌표를 낼 수 있다. */ + setRoute: (points: ReadonlyArray, 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 = []; + /** 누가거리 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> = []; + 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 { + 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>; + 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; + }, + }; +} diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_MapRender.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_MapRender.ts index a3bc4b08..4905c2ba 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_MapRender.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_MapRender.ts @@ -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; diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts index 207ed456..eb793158 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts @@ -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 = { /** 등고 라벨(계곡선 수치) 기본 표시 여부. */ 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 = { @@ -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(); }, }; } diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts index 2017353d..68e98a94 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts @@ -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 { 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 { 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 { 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, diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_Style.css b/B04_wf1_Surface/B04_wf1_Surface_UI_Style.css index 1d82ceb5..306ab65f 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_Style.css +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_Style.css @@ -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); } diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts index 4c79e4f0..d4812da4 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts @@ -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; diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_Viewer.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_Viewer.ts index f87bbfec..327e1172 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_Viewer.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_Viewer.ts @@ -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 = '밀도 100%'; - 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"); }, diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_Watershed.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_Watershed.ts index d669abc6..dfa9783c 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_Watershed.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_Watershed.ts @@ -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; diff --git a/config/config_system.py b/config/config_system.py index 90a58a4a..6b7f1954 100644 --- a/config/config_system.py +++ b/config/config_system.py @@ -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")) diff --git a/main.py b/main.py index 0f491da1..84c59609 100644 --- a/main.py +++ b/main.py @@ -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) diff --git a/ui_template/ui_template_locale.ts b/ui_template/ui_template_locale.ts index c91df5f3..b54c6258 100644 --- a/ui_template/ui_template_locale.ts +++ b/ui_template/ui_template_locale.ts @@ -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.", diff --git a/ui_template/ui_template_theme.css b/ui_template/ui_template_theme.css index 2822e644..684be527 100644 --- a/ui_template/ui_template_theme.css +++ b/ui_template/ui_template_theme.css @@ -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);