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:
@@ -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). 그러면 짝수 칸에는 세 샘플({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]]:
|
||||
"""응답용으로 강도 곡선을 일정 간격으로 줄인다(구간 합 유지).
|
||||
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user