refactor(B04/B05): 배수유역 저장소·API 일원화 + 종단 테이블 구조물 라인 연동

데이터 흐름 문제
- B04 해석 산출물 7개(7.2MB)가 B05로 전량 복사되고 있었고, 관 지점 정본
  (pipe_points.json)은 B05가 아예 참조하지 않았다. B04에서 확정한 관이 B05에
  보이지 않고 진입할 때마다 자동 배치로 되돌아갔다.
- 사본은 "B05 편집이 원본을 덮어쓴다"를 막으려던 것인데, 외곽선 편집을 제거한
  뒤로 B05는 아무것도 저장하지 않아 존재 이유가 사라졌다.

일원화
- B05 전용 모듈 3개 삭제: Engine_Drainage_Store / Engine_Drainage_Basin /
  Router_Drainage. 두 화면이 B04_wf1_Surface/drainage/ 한 폴더를 직접 읽는다.
- POST /drainage/basins 폐기 → GET/PUT /drainage/pipe-points ·
  POST /drainage/detail-basins 한 벌로 통합. 응답에 route_lonlat ·
  main_polygon_lonlat · flow_arrows · upstream_lonlat · inflow_hotspots를 추가해
  B05가 그리는 데 필요한 값을 같은 응답으로 받는다.
- 경로 확정 시 관 지점을 B04와 같은 저장소에 커밋한다.

B05 화면
- 유역 폴리곤을 눌러 고르기(지도에서도 선택), [집중유역] 토글, 관 개수·유역 수·
  종단 Z 출처 요약 줄 추가.
- 관 마커는 좌클릭으로만 잡는다(우클릭은 메뉴, 가운데 버튼은 팬).
- 유역 목록은 3행까지 보이고 나머지는 스크롤.

종단 테이블 구조물 라인
- 관 매설 지점을 "배관" 구조물로 실체화(origin: "pipe"). 정본은 관 지점 파일이며
  목록은 그 투영이라, 어디서 옮기든 pipeEditor 한 경로로 전파된다.
- 테이블 위 세로선을 끌어 이동(배관은 세부유역까지 재산정), 우클릭으로 배관
  추가/구조물 삭제.

정리
- B05_wf2_Route/drainage/ 사본과 debug/(삭제된 Subdivide 엔진 검증 스크립트) 삭제.
- config DRAINAGE_B05_DIRNAME 제거.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-01 23:37:17 +09:00
co-authored by Claude Opus 5
parent e994330874
commit b023698e38
21 changed files with 1043 additions and 576 deletions
@@ -468,6 +468,22 @@ export interface DetailBasinResponse {
pipe_points: DetailPipePoint[];
basins: DetailBasin[];
pipe_count: number;
/** 도로 1m 구간별 유입 면적 — [누가거리, 면적]. 계획선 색칠에 쓴다. */
strength_profile: Array<[number, number]>;
/** 유입 집중점 — [누가거리, 유입면적, 구역번호, 구역 내 순위]. */
inflow_hotspots: Array<[number, number, number, number]>;
/** B04가 분석에 쓴 계획 노선 선형(lon/lat). */
route_lonlat: Array<[number, number]>;
/** 2차 전체 배수유역 외곽선 = 분수령. 해석 결과 그대로. */
main_polygon_lonlat: Array<[number, number]>;
/** B04 해석 격자 한 변(m). */
grid_cell_m: number;
/** 평균 흐름 화살표 — [x, y(사업지 CRS m), 방위(도), 도로도달, 셀 수]. */
flow_arrows: Array<[number, number, number, boolean, number]>;
/** 화살표 사이 실제 간격(m). */
arrow_spacing_m: number;
/** 유역 안쪽 상류 세류망 — 하이라이트 토글용. */
upstream_lonlat: Array<Array<[number, number]>>;
}
/** 저장된 관 매설 지점과 그 세부유역. 저장분이 없으면 백엔드가 자동 생성해 돌려준다. */
@@ -95,6 +95,17 @@ def _payload(
"saved": saved,
# 도로 1m 구간별 유입 면적 — 계획선을 색으로 칠하는 데 쓴다(B05와 같은 값).
"strength_profile": detail.strength_profile,
# 유입 집중점 — 관을 어디에 둘지 판단하는 근거. 화면에서 토글로 켠다.
"inflow_hotspots": detail.inflow_hotspots,
# ── 아래는 B05 배수유역도가 그리는 데 필요한 값. B04 지도는 자체 오버레이가 있어
# 쓰지 않지만, 두 화면이 같은 응답을 받아야 결과가 갈리지 않는다(2026-08-01 일원화).
"route_lonlat": detail.route_lonlat,
# 2차 전체 유역 외곽선 = 분수령. 해석 결과 그대로(손으로 고치는 기능 없음).
"main_polygon_lonlat": detail.basin_lonlat,
"grid_cell_m": detail.grid_cell_m,
"flow_arrows": detail.flow_arrows,
"arrow_spacing_m": detail.arrow_spacing_m,
"upstream_lonlat": detail.upstream_lonlat,
"pipe_points": [
{
"chainage_m": round(pipe.chainage_m, 2),
+25 -70
View File
@@ -11,7 +11,7 @@
* - 오류 응답 형식 {status:"error", message:"..."}을 Error로 변환.
* ========================================================================== */
import { API_ANALYSIS_TIMEOUT_MS, API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend";
import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend";
/** 경로 제어점 (BP/EP/CP) */
export interface RoutePoint {
@@ -266,6 +266,30 @@ export const routeLatestCacheKey = (projectId: string): string => `b05:latest:${
/** 담아 둔 최신 경로 값을 버린다. B04에서 지표면을 다시 확정하면 옛 확정값이 남아
* B05가 이전 지형을 그리게 되므로, 확정 직후 이 값을 지운다. */
/** 세션 캐시에서 최신 경로 응답을 읽는다. 없거나 깨졌으면 null(다음 진입은 DB 조회). */
export function readRouteLatestCache(projectId: string): RouteLatestResponse | null {
try {
const raw = window.sessionStorage.getItem(routeLatestCacheKey(projectId));
return raw ? (JSON.parse(raw) as RouteLatestResponse) : null;
} catch {
return null;
}
}
/** 최신 경로 응답을 세션 캐시에 넣는다. 용량 초과 등으로 실패하면 캐시를 비운다. */
export function writeRouteLatestCache(projectId: string, value: RouteLatestResponse): void {
const key = routeLatestCacheKey(projectId);
try {
window.sessionStorage.setItem(key, JSON.stringify(value));
} catch {
try {
window.sessionStorage.removeItem(key);
} catch {
/* noop */
}
}
}
export function clearRouteLatestCache(projectId: string): void {
try {
window.sessionStorage.removeItem(routeLatestCacheKey(projectId));
@@ -273,72 +297,3 @@ export function clearRouteLatestCache(projectId: string): void {
/* 세션 접근 실패 시에는 다음 진입에서 DB를 읽게 되므로 그대로 둔다. */
}
}
/* ── 배수유역도 (B05_wf2_Route_Router_Drainage.py) ───────────────────────── */
/** 관 매설 구조물 측점 후보 1개. reason: stream=세류 교차, spacing=300m 보충. */
export interface DrainageCandidate {
chainage_m: number;
x: number;
y: number;
lon: number;
lat: number;
reason: "stream" | "spacing" | "confirmed";
stream_name: string | null;
}
export interface DrainageCandidateResponse {
status: string;
project_id: string;
route_id: number;
candidates: DrainageCandidate[];
}
/** 관 1개가 받는 세부 배수유역. 관경(pipe_diameter_mm)은 수식 미확정이라 당분간 항상 null이다. */
export interface DrainageBasin {
index: number;
chainage_m: number;
/** 관(배관) 매설 지점 좌표 — 계획선 위 마커 렌더용. */
outlet_lonlat: [number, number];
polygon_lonlat: Array<[number, number]>;
area_m2: number;
relief_m: number;
flow_length_m: number;
pipe_diameter_mm: number | null;
}
export interface DrainageBasinResponse {
status: string;
project_id: string;
route_id: number;
/** 산정에 실제 사용된 배관 지점 — 유역이 없는 관도 포함(마커 동기화용). */
pipes: DrainageCandidate[];
/** B04가 분석에 쓴 계획 노선 선형(lon/lat). */
route_lonlat: Array<[number, number]>;
/** 2차 전체 배수유역 외곽선 = 분수령. 해석 결과 그대로이며 손으로 고치지 않는다. */
main_polygon_lonlat: Array<[number, number]>;
/** 유역 안쪽 상류 세류망 — 하이라이트 토글용. */
upstream_lonlat: Array<Array<[number, number]>>;
/** 도로 1m 구간별 유입 면적 — [누가거리, 면적]. 계획선 색칠에 쓴다(B04 지도와 같은 값). */
strength_profile?: Array<[number, number]>;
/** B04 해석 격자 한 변(m). */
grid_cell_m: number;
/** 평균 흐름 화살표 — [x, y(사업지 CRS m), 방위(도), 도로도달, 셀 수]. */
flow_arrows: Array<[number, number, number, boolean, number]>;
/** 화살표 사이 실제 간격(m). */
arrow_spacing_m: number;
basins: DrainageBasin[];
}
/** chainages를 주면 그 위치로 확정 산정하고, 비우면 자동 제안분으로 산정한다. */
export async function fetchDrainageBasins(
projectId: string,
chainages?: number[],
): Promise<DrainageBasinResponse> {
// 격자 해석이 포함된 요청이라 캐시가 없으면 수십 초가 걸린다.
return requestJson<DrainageBasinResponse>(
`/projects/${projectId}/drainage/basins`,
{ method: "POST", body: JSON.stringify({ chainages: chainages ?? [] }) },
API_ANALYSIS_TIMEOUT_MS,
);
}
@@ -1,58 +0,0 @@
"""배수유역 세부 설계 — B05 저장소 어댑터.
계산은 하지 않는다. 관 보충(⑨)과 세부유역 분할(⑩)의 알고리즘은 B04 관리자 화면과
공용이므로 `common_util_drainage_detail`에 있고, 여기서는 **B05가 읽을 폴더만 정한다**
(2026-08-01 구조 개편).
읽는 대상은 B04 원본이 아니라 **B05 사본**이다. B04가 다시 해석했으면 사본을 먼저
갱신한다 — 편집분(`boundary_overrides.json`)은 사본 갱신과 무관하게 남는다.
"""
from __future__ import annotations
from pathlib import Path
from B05_wf2_Route.B05_wf2_Route_Engine_Drainage_Store import b05_drainage_dir, sync_from_b04
from common_util.common_util_drainage_detail import (
DrainageDetail,
RoadRouting,
WatershedBasin,
build_detail,
read_road_routing,
read_upstream_lines,
)
from common_util.common_util_route_geometry import RouteVertex
__all__ = [
"DrainageDetail",
"RoadRouting",
"WatershedBasin",
"build_drainage_detail",
"load_road_routing",
"load_upstream_lines",
]
def _synced_dir(stored_path: str) -> Path:
"""B04 원본을 B05 사본으로 맞춘 뒤 사본 폴더를 돌려준다."""
sync_from_b04(stored_path)
return b05_drainage_dir(stored_path)
def build_drainage_detail(
stored_path: str,
vertices: list[RouteVertex],
confirmed_chainages: list[float] | None = None,
) -> DrainageDetail | None:
"""B04 분석 결과(B05 사본)를 읽어 관을 보충하고 세부유역을 나눈다."""
return build_detail(_synced_dir(stored_path), vertices, confirmed_chainages)
def load_road_routing(stored_path: str) -> RoadRouting | None:
"""`03_road_routing` 산출물을 B05 사본에서 읽는다."""
return read_road_routing(_synced_dir(stored_path))
def load_upstream_lines(stored_path: str) -> list[list[list[float]]]:
"""상류 세류망을 B05 사본에서 읽는다(화면 강조용)."""
return read_upstream_lines(b05_drainage_dir(stored_path))
@@ -1,56 +0,0 @@
"""B05 전용 배수유역 저장소 (B04 산출물 사본 관리).
B04는 배수유역을 해석해 `B04_wf1_Surface/drainage/`에 남긴다. B05는 그 결과를 읽어 관을
보충하고 세부유역을 나누는데, 같은 폴더를 그대로 쓰면 B05 쪽 작업이 B04 원본을 덮어쓴다.
그래서 여기서 사본을 따로 둔다(2026-08-01 사용자 지시).
· `B05_wf2_Route/drainage/` — B04 산출물의 사본. B04가 다시 해석하면 자동으로 갱신된다.
유역 외곽선을 손으로 고치는 기능은 없앴다 — 등고선을 전부 해석해 얻은 경계라 사람이 다시
그릴 이유가 없다(2026-08-01 사용자 지시). 그래서 여기에는 편집분 보존 로직이 없다.
"""
from __future__ import annotations
import logging
import shutil
from pathlib import Path
from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Export import drainage_dir
from common_util.common_util_storage import resolve_stored_project_path
from config.config_system import DRAINAGE_B05_DIRNAME
logger = logging.getLogger(__name__)
def b05_drainage_dir(stored_path: str) -> Path:
"""B05 전용 배수유역 폴더. B04 원본과 분리된 사본이 여기 들어간다."""
return Path(resolve_stored_project_path(stored_path)) / "B05_wf2_Route" / DRAINAGE_B05_DIRNAME
def sync_from_b04(stored_path: str) -> bool:
"""B04 산출물을 B05 사본으로 맞춘다. 실제로 복사했으면 True.
사본이 없으면 초안으로 1회 복사하고, B04 쪽이 더 최신이면(재해석) 그 파일만 덮어쓴다.
"""
source = drainage_dir(stored_path)
if not source.is_dir():
return False
target = b05_drainage_dir(stored_path)
copied = 0
try:
target.mkdir(parents=True, exist_ok=True)
for item in source.iterdir():
if not item.is_file():
continue
destination = target / item.name
if destination.exists() and destination.stat().st_mtime >= item.stat().st_mtime:
continue
shutil.copy2(item, destination)
copied += 1
except OSError:
logger.warning("배수유역: B05 사본 갱신 실패 (%s%s)", source, target)
return False
if copied:
logger.info("배수유역: B05 사본 갱신 — %d개 파일 (%s)", copied, target)
return copied > 0
@@ -1,131 +0,0 @@
"""배수유역 세부 설계 API 라우터 (B05 — 일반 사용자용).
**분석하지 않는다.** B04가 미리 돌려 저장한 결과를 읽어 관을 보충하고 세부유역만 나눈다.
격자 해석은 30초가 걸려 일반 사용자를 붙잡아 두므로 여기서는 아예 돌리지 않는다
(2026-07-31 사용자 지시).
좌표는 사업지 CRS(m)에서 계산하고 응답만 WGS84로 바꿔 내보낸다.
"""
import asyncio
import logging
from typing import Any
from uuid import UUID
from fastapi import APIRouter
from fastapi.responses import JSONResponse
from B05_wf2_Route.B05_wf2_Route_Engine_Drainage_Basin import build_drainage_detail
from common_util.common_util_drainage_context import DrainageContext, load_drainage_context
from common_util.common_util_route_geometry import StructureCandidate
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/projects", tags=["B05 Route Drainage"])
def _candidate_payload(candidate: StructureCandidate, to_lonlat: Any) -> dict[str, Any]:
lon, lat = to_lonlat(candidate.x, candidate.y)
return {
"chainage_m": round(candidate.chainage_m, 2),
"x": candidate.x,
"y": candidate.y,
"lon": lon,
"lat": lat,
"reason": candidate.reason,
"stream_name": candidate.stream_name,
}
async def _prepare(project_id: UUID) -> DrainageContext | JSONResponse:
"""노선·종단 Z·좌표 변환기를 준비한다. 도엽 피처는 읽지 않는다(분석을 안 하므로).
노선 기준선은 원청 계획노선 CSV다 — B04 격자가 그 노선으로 도로 셀을 구웠기 때문이다.
종단 Z만 B05 계획고/경로 정점 우선으로 갈아 끼운다(공용 준비기가 판단).
"""
context, reason = await load_drainage_context(project_id)
if context is None:
return JSONResponse(status_code=404, content={"status": "error", "message": reason})
if context.route_id is None:
return JSONResponse(
status_code=404,
content={"status": "error", "message": "확정된 경로가 없습니다."},
)
return context
@router.post("/{project_id}/drainage/basins", response_model=None)
async def post_drainage_basins(
project_id: UUID,
payload: dict[str, Any] | None = None,
) -> dict[str, Any] | JSONResponse:
"""B04 분석 결과로 관을 보충하고 세부유역을 나눈다.
payload에 `chainages`(누가거리 목록)를 주면 그 위치로 관을 확정하고(사용자 편집),
없으면 B04의 기본 관에 최대 간격 규칙으로 최소 개수만 보충한다.
"""
prepared = await _prepare(project_id)
if isinstance(prepared, JSONResponse):
return prepared
raw = (payload or {}).get("chainages")
confirmed = _parse_chainages(raw) if isinstance(raw, list) else []
detail = await asyncio.to_thread(
build_drainage_detail, prepared.stored_path, prepared.vertices, confirmed
)
if detail is None:
return JSONResponse(
status_code=404,
content={
"status": "error",
"message": "배수유역 분석 결과가 없습니다. B04에서 먼저 분석을 실행하세요.",
},
)
to_lonlat = prepared.to_lonlat
return {
"status": "success",
"project_id": str(project_id),
"route_id": prepared.route_id,
# 종단 Z를 어디서 가져왔는지 — 관 담당 구간이 갈리는 근거라 화면에서 확인 가능해야 한다.
"z_source": prepared.z_source,
# B04가 남긴 그대로 — 계획도로선. 외곽선만 편집분을 반영해 내보낸다.
"route_lonlat": detail.route_lonlat,
# 2차 전체 유역 외곽선 — 해석 결과 그대로. 손으로 고치는 기능은 없앴다
# (등고선을 전부 해석한 결과라 수정할 이유가 없다 — 2026-08-01 사용자 지시).
"main_polygon_lonlat": detail.basin_lonlat,
"grid_cell_m": detail.grid_cell_m,
# 평균 흐름 화살표 — B04가 계산해 저장한 것을 그대로 넘긴다(사업지 CRS m).
"flow_arrows": detail.flow_arrows,
"arrow_spacing_m": detail.arrow_spacing_m,
# 유역 안쪽 상류 세류망 — 화면 강조 토글용(WGS84).
"upstream_lonlat": detail.upstream_lonlat,
# 도로 1m 구간별 유입 면적 — 계획선을 색으로 칠하는 데 쓴다(B04와 같은 값).
"strength_profile": detail.strength_profile,
# 계획선 위 배관 지점 — 유역이 없는 관도 마커로 표시해야 하므로 별도 목록.
"pipes": [_candidate_payload(pipe, to_lonlat) for pipe in detail.pipes],
"basins": [
{
"index": basin.index,
"chainage_m": round(basin.chainage_m, 2),
"outlet_lonlat": list(to_lonlat(basin.outlet_x, basin.outlet_y)),
"polygon_lonlat": [list(to_lonlat(x, y)) for x, y in basin.boundary_xy],
"area_m2": round(basin.area_m2, 1),
"relief_m": round(basin.relief_m, 2),
"flow_length_m": round(basin.flow_length_m, 1),
# 관경 수식 미확정 — None이면 프론트가 "미정"으로 표기한다.
"pipe_diameter_mm": basin.pipe_diameter_mm,
}
for basin in detail.basins
],
}
def _parse_chainages(values: list[Any]) -> list[float]:
"""사용자가 확정·편집한 누가거리 목록을 숫자로 정리한다."""
parsed: list[float] = []
for value in values:
try:
parsed.append(float(value))
except (TypeError, ValueError):
continue
return parsed
+226 -155
View File
@@ -4,62 +4,54 @@ import { themeColor } from "@ui/ui_template_palette";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import { fetchCachedSheetLayer } from "../A00_Common/b_asset_cache";
import {
computeDetailBasins,
fetchDetailPipePoints,
fetchVWorldMeta,
getVWorldMapUrl,
saveDetailPipePoints,
type DetailBasin,
type DetailBasinResponse,
type PipeSource,
type VWorldMeta,
} from "../B04_wf1_Surface/B04_wf1_Surface_Api_Fetch";
import {
computeMapRect,
computeRouteView,
createNormalizer,
drawFilledRing,
drawPreparedLayer,
drawRidgeRing,
drawUpstreamLines,
lonLatToScreen,
prepareLayer,
prepareMetricPolyline,
routeLineColor,
ROUTE_LINE_WIDTH,
type GeoJsonCollection,
type MapRect,
type Normalizer,
type PreparedLayer,
type ViewState,
} from "../B04_wf1_Surface/B04_wf1_Surface_UI_MapRender";
import {
fetchDrainageBasins,
type DrainageBasin,
type RoutePoint,
} from "./B05_wf2_Route_Api_Fetch";
import { drawFlowArrows, type FlowArrow } from "../B04_wf1_Surface/B04_wf1_Surface_UI_FlowArrows";
import {
buildStrengthArray,
drawStrengthLine,
} from "../B04_wf1_Surface/B04_wf1_Surface_UI_FlowRamp";
import { type RoutePoint } from "./B05_wf2_Route_Api_Fetch";
import type { FlowArrow } from "../B04_wf1_Surface/B04_wf1_Surface_UI_FlowArrows";
import { buildStrengthArray } from "../B04_wf1_Surface/B04_wf1_Surface_UI_FlowRamp";
import { resampleRoute } from "../B04_wf1_Surface/B04_wf1_Surface_UI_RouteSamples";
import { createPipeEditor } from "./B05_wf2_Route_UI_Drainage_Pipes";
import { createProgressCircle } from "@ui/ui_template_progress";
import { createMapContextMenu } from "@ui/ui_template_context_menu";
import { drawDrainageScene } from "./B05_wf2_Route_UI_Drainage_Render";
import {
addLayerToggle,
arrowToggleColor,
mountDrainageToggles,
basinColor,
DRAINAGE_LAYERS,
bindPipeContextMenu,
COLLAPSED_KEY,
DRAINAGE_LAYERS,
LAYER_LABEL_KEYS,
layerColor,
MAX_PANEL_WIDTH_RATIO,
MIN_PANEL_WIDTH,
createMetricProjector,
renderBasinRows,
satelliteToggleColor,
strengthToggleColor,
upstreamToggleColor,
pointInRing,
WIDTH_KEY,
type DrainageLayer,
} from "./B05_wf2_Route_UI_Drainage_Parts";
/** 이만큼(px) 이하로 움직였다 뗐으면 클릭으로 본다 — 손떨림으로 선택이 안 되는 일을 막는다. */
const CLICK_SLOP_PX = 4;
// 배수유역도 패널 — 하단 종단 패널 안쪽 우측에 도킹되는 2단 사이드 패널.
// 하단 패널이 닫히면 이 패널도 함께 화면에서 사라진다(부모 안에 들어 있으므로 자동).
// 지도는 B04에서 분리한 렌더 엔진(B04_wf1_Surface_UI_MapRender)을 그대로 재사용해
@@ -75,10 +67,24 @@ export interface DrainagePanel {
load: (projectId: string) => void;
/** 확정된 노선 평면 선형(사업지 좌표계 m)을 지도 위에 겹친다. */
setRoute: (points: ReadonlyArray<RoutePoint>) => void;
/** 현재 관 매설 누가거리 목록(종단 테이블의 "배관" 구조물 라인과 맞추는 데 쓴다). */
pipeChainages: () => number[];
/** 종단 테이블에서 배관 라인을 끌었을 때 — 그 자리로 옮기고 세부유역을 다시 나눈다. */
movePipe: (fromChainage: number, toChainage: number) => void;
/** 종단 테이블 우클릭으로 배관을 넣거나 지울 때. */
addPipe: (chainageM: number) => void;
removePipe: (chainageM: number) => void;
/** 경로 확정 시 관 매설 지점을 영구저장한다(B04 "모델 확정"과 같은 저장소). */
savePipes: () => Promise<number>;
dispose: () => void;
}
export function createDrainagePanel(): DrainagePanel {
export interface DrainagePanelCallbacks {
/** 관 목록이 바뀔 때마다 누가거리 목록을 넘긴다 — 종단 테이블 구조물 라인 동기화용. */
onPipesChanged?: (chainages: number[]) => void;
}
export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): DrainagePanel {
const root = document.createElement("aside");
root.className = "b05-drainage";
const panelHandle = createWorkflowPanelHandle("side");
@@ -136,6 +142,10 @@ export function createDrainagePanel(): DrainagePanel {
if (label !== null) progress.set(ratio, label);
}
// 유역 제원 목록(면적·표고·유하거리·관경). 관경 수식 미확정이라 당분간 "미정"으로 나온다.
// 관 개수·세부유역 수·종단 Z 출처 — 세부유역이 갈리는 근거라 목록 위에 한 줄로 남긴다.
const summary = document.createElement("div");
summary.className = "b05-drainage__summary";
summary.hidden = true;
const basinList = document.createElement("div");
basinList.className = "b05-drainage__basins";
basinList.hidden = true;
@@ -150,7 +160,7 @@ export function createDrainagePanel(): DrainagePanel {
max: () => (root.parentElement?.clientWidth ?? window.innerWidth) * MAX_PANEL_WIDTH_RATIO,
storageKey: WIDTH_KEY,
});
root.append(panelHandle.root, widthResizer.root, header, viewport, basinList);
root.append(panelHandle.root, widthResizer.root, header, viewport, summary, basinList);
let projectId: string | null = null;
let meta: VWorldMeta | null = null;
@@ -159,7 +169,7 @@ export function createDrainagePanel(): DrainagePanel {
let routeLayer: PreparedLayer | null = null;
let routePoints: ReadonlyArray<RoutePoint> = [];
let normalizer: Normalizer | null = null;
let basins: DrainageBasin[] = [];
let basins: DetailBasin[] = [];
let selectedBasin: number | null = null;
// 배관 편집기 — 마커 선택/추가/이동/삭제 시 재그리기와 버튼 상태만 갱신한다.
const pipeEditor = createPipeEditor(
@@ -177,6 +187,12 @@ export function createDrainagePanel(): DrainagePanel {
let flowArrows: FlowArrow[] = [];
let arrowSpacingM = 0;
let showArrows = true;
// 유입 집중점 — 관 자리를 판단하는 근거. 기본 꺼짐(마커가 관 마커와 겹쳐 읽기 어렵다).
let hotspots: Array<{ chainage: number; area: number }> = [];
let maxHotspotArea = 0;
let showHotspots = false;
/** 종단 Z 출처 — 세부유역이 갈리는 근거라 화면에서 확인 가능해야 한다. */
let zSource = "";
// 유역 안쪽 상류 세류망 — B04가 채택한 기준선을 그대로 받아 강조만 한다.
let upstreamLines: Array<Array<[number, number]>> = [];
let showUpstream = true;
@@ -189,68 +205,49 @@ export function createDrainagePanel(): DrainagePanel {
let offsetX = 0;
let offsetY = 0;
let dragStart: { x: number; y: number; offsetX: number; offsetY: number } | null = null;
/** 좌클릭을 시작한 자리 — 끌지 않고 뗐을 때만 유역 고르기로 본다. */
let basinClickStart: { x: number; y: number } | null = null;
/** 마커를 잡은 좌클릭 — 이 경우 유역 고르기로 넘기지 않는다. */
let pipeClickStart: { x: number; y: number } | null = null;
let frameHandle = 0;
let loadSequence = 0;
let canvasWidth = 0;
let canvasHeight = 0;
let canvasDpr = 0;
// 배경 위성사진 — 등고선 앞에 둔다(2026-08-01 사용자 지시). 사진이 어두워 유역 채움색이
// 묻힐 때 끄고 본다. 캔버스가 아니라 배경 이미지라 표시 여부만 직접 바꾼다.
addLayerToggle(
layerButtons,
L("B05_Drainage_Layer_Satellite"),
satelliteToggleColor(),
true,
(next) => {
mountDrainageToggles(layerButtons, {
initial: {
arrows: showArrows,
strength: showStrength,
hotspots: showHotspots,
upstream: showUpstream,
},
onSatellite: (next) => {
backgroundImage.hidden = !next;
scheduleDraw();
},
L("B05_Drainage_Layer_Satellite_Tip"),
);
DRAINAGE_LAYERS.forEach((layer) => {
addLayerToggle(layerButtons, L(LAYER_LABEL_KEYS[layer]), layerColor(layer), true, (next) => {
onSheetLayer: (layer, next) => {
if (next) activeLayers.add(layer);
else activeLayers.delete(layer);
scheduleDraw();
});
});
// 흐름 화살표 — 도면이 지저분해질 때 끄기 위한 토글(등고선·세류와 같은 줄·같은 양식).
addLayerToggle(
layerButtons,
L("B05_Drainage_Layer_Arrows"),
arrowToggleColor(),
showArrows,
(next) => {
},
onArrows: (next) => {
showArrows = next;
scheduleDraw();
},
L("B05_Drainage_Layer_Arrows_Tip"),
);
// 유입 강도 색칠 — 노선 1m 구간별 상류 면적. B04 지도와 같은 색띠를 쓴다.
addLayerToggle(
layerButtons,
L("B05_Drainage_Layer_Strength"),
strengthToggleColor(),
showStrength,
(next) => {
onStrength: (next) => {
showStrength = next;
scheduleDraw();
},
L("B05_Drainage_Layer_Strength_Tip"),
);
// 상류 세류선 강조 — 유역 판정의 기준선이라 항상 같은 굵기·색으로 얹는다.
addLayerToggle(
layerButtons,
L("B05_Drainage_Layer_Upstream"),
upstreamToggleColor(),
showUpstream,
(next) => {
onHotspots: (next) => {
showHotspots = next;
scheduleDraw();
},
onUpstream: (next) => {
showUpstream = next;
scheduleDraw();
},
L("B05_Drainage_Layer_Upstream_Tip"),
);
});
function updateImageTransform(): void {
backgroundImage.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${scale})`;
@@ -276,65 +273,30 @@ export function createDrainagePanel(): DrainagePanel {
context.clearRect(0, 0, width, height);
const mapRect: MapRect = computeMapRect(meta, width, height);
const view: ViewState = { width, height, scale, offsetX, offsetY, mapRect };
// 세부유역 채움을 가장 아래에 깔아 등고선·세류 판독을 가리지 않게 한다.
if (normalizer) {
basins.forEach((basin) => {
const color = basinColor(basin.index);
drawFilledRing(
context,
{ ring: basin.polygon_lonlat, label: String(basin.index) },
normalizer!,
view,
selectedBasin === null || selectedBasin === basin.index
? color
: color.replace(/0\.45\)$/, "0.18)"),
);
});
// 전체 유역 외곽선 = 분수령(능선). 세부유역 경계와 구분되게 파선 한 겹만 얹는다.
// 해석 결과를 그대로 그린다 — 손으로 고치지 않는다(2026-08-01 사용자 지시).
if (mainBoundary.length > 2) drawRidgeRing(context, mainBoundary, normalizer, view);
}
// 등고선을 얇게 깔고 세류를 그 위에, 노선을 맨 위에 둔다.
DRAINAGE_LAYERS.forEach((layer) => {
if (!activeLayers.has(layer)) return;
const prepared = preparedLayers.get(layer);
if (!prepared) return;
context.lineWidth = layer === "도엽_등고선" ? 0.7 : 1.5;
context.strokeStyle = layerColor(layer);
drawPreparedLayer(context, prepared, view, "dot");
drawDrainageScene(context, view, {
meta,
normalizer,
basins,
selectedBasin,
mainBoundary,
preparedLayers,
activeLayers,
routeLayer,
upstreamLines,
showUpstream,
strengthSamples,
strength,
maxStrength,
showStrength,
flowArrows,
arrowSpacingM,
showArrows,
hotspots,
maxHotspotArea,
showHotspots,
pipeEditor,
pipeColor,
});
// 상류 세류선 강조 — 유역 채움 위, 흐름 화살표 아래(2026-08-01 사용자 지시).
// 그리기는 B04 오버레이와 같은 공용 렌더러를 쓴다.
if (showUpstream && normalizer && upstreamLines.length > 0) {
drawUpstreamLines(context, upstreamLines, normalizer, view);
}
if (routeLayer) {
context.lineWidth = ROUTE_LINE_WIDTH;
context.strokeStyle = routeLineColor();
drawPreparedLayer(context, routeLayer, view, "dot");
}
// 유입 강도 색칠 — 계획선 위, 배관 마커 아래. 색띠는 B04 지도와 공용이다.
if (showStrength && meta && strength.length > 0) {
const { toScreen } = createMetricProjector(meta, view);
drawStrengthLine(context, strengthSamples, strength, maxStrength, (point) =>
toScreen(point.x, point.y),
);
}
// 평균 흐름 화살표 — 유역 채움 위, 배관 마커 아래. 좌표는 사업지 CRS(m)라
// 도엽 메타로 바로 화면에 옮긴다(배관 마커와 같은 변환).
if (showArrows && meta && flowArrows.length > 0) {
const projector = createMetricProjector(meta, view);
drawFlowArrows(
context,
flowArrows,
arrowSpacingM,
projector.pxPerMeter,
projector.toScreen,
view,
);
}
// 배관(관 매설) 마커 — 계획선 위 최상단.
pipeEditor.draw(context, view, pipeColor);
updateImageTransform();
}
@@ -383,8 +345,62 @@ export function createDrainagePanel(): DrainagePanel {
});
}
/** 구조물 측점 후보 제안 + 유역 산정을 백엔드에 요청한다(계산은 전부 백엔드).
* 편집된 배관이 있으면 그 누가거리로 확정 산정하고, auto=true면 자동 제안으로 되돌린다. */
/** 응답을 화면 상태로 옮긴다. B04 지도와 **같은 엔드포인트·같은 응답**을 쓴다
* — 두 화면이 다른 결과를 보이면 안 되기 때문이다(2026-08-01 일원화). */
function apply(response: DetailBasinResponse): void {
basins = response.basins;
mainBoundary = response.main_polygon_lonlat ?? [];
upstreamLines = (response.upstream_lonlat ?? []) as Array<Array<[number, number]>>;
flowArrows = (response.flow_arrows ?? []) as FlowArrow[];
arrowSpacingM = response.arrow_spacing_m ?? 0;
hotspots = (response.inflow_hotspots ?? []).map(([chainage, area]) => ({ chainage, area }));
zSource = response.z_source ?? "";
const built = buildStrengthArray(
(response.strength_profile ?? []) as ReadonlyArray<readonly [number, number]>,
);
strength = built.strength;
maxStrength = built.maximum;
maxHotspotArea = hotspots.reduce((max, spot) => (spot.area > max ? spot.area : max), 0);
// 실제 사용된 배관 목록으로 마커를 동기화한다(유역 없는 관 포함).
pipeEditor.setPipes(
response.pipe_points.map((pipe) => ({
chainage_m: pipe.chainage_m,
reason: pipe.source,
})),
);
selectedBasin = null;
renderBasinList();
syncPipeSelection();
summary.textContent = L("B05_Drainage_Summary")
.replace("{pipes}", String(pipeEditor.pipes().length))
.replace("{basins}", String(basins.length))
.replace("{source}", zSource || "-");
summary.hidden = basins.length === 0;
callbacks.onPipesChanged?.(pipeEditor.chainages());
}
/** 저장된 관 지점(없으면 자동 배치)을 불러온다. 화면에 들어올 때 1회. */
async function loadSaved(): Promise<void> {
if (!projectId) return;
status.hidden = false;
status.textContent = L("B05_Drainage_Status_Analyzing");
showProgress(null, L("B05_Drainage_Status_Analyzing"));
try {
apply(await fetchDetailPipePoints(projectId));
status.hidden = basins.length > 0;
if (basins.length === 0) status.textContent = L("B05_Drainage_Status_NoBasin");
scheduleDraw();
} catch (error) {
status.hidden = false;
status.textContent =
error instanceof Error ? error.message : L("B05_Drainage_Status_AnalyzeFailed");
} finally {
showProgress(null, null);
}
}
/** 세부유역을 다시 나눈다. `auto=true`면 편집분을 버리고 자동 배치로 되돌린다.
* 격자 해석은 하지 않으므로 즉시 끝난다. */
async function analyze(auto = false): Promise<void> {
if (!projectId) return;
analyzeButton.disabled = true;
@@ -392,29 +408,14 @@ export function createDrainagePanel(): DrainagePanel {
status.textContent = L("B05_Drainage_Status_Analyzing");
showProgress(null, L("B05_Drainage_Status_Analyzing"));
try {
const chainages = !auto && pipeEditor.pipes().length > 0 ? pipeEditor.chainages() : undefined;
const response = await fetchDrainageBasins(projectId, chainages);
basins = response.basins;
mainBoundary = response.main_polygon_lonlat ?? [];
upstreamLines = (response.upstream_lonlat ?? []) as Array<Array<[number, number]>>;
flowArrows = (response.flow_arrows ?? []) as FlowArrow[];
arrowSpacingM = response.arrow_spacing_m ?? 0;
const built = buildStrengthArray(
(response.strength_profile ?? []) as ReadonlyArray<readonly [number, number]>,
);
strength = built.strength;
maxStrength = built.maximum;
// 계획도로선·2차 유역 외곽선은 B04 산출물을 그대로 받는다 — 여기서 다시 계산하지 않는다.
// 실제 사용된 배관 목록으로 마커를 동기화한다(유역 없는 관 포함).
pipeEditor.setPipes(
(response.pipes ?? []).map((pipe) => ({
chainage_m: pipe.chainage_m,
reason: pipe.reason,
})),
);
selectedBasin = null;
renderBasinList();
syncPipeSelection();
const points =
!auto && pipeEditor.pipes().length > 0
? pipeEditor.pipes().map((pipe) => ({
chainage_m: pipe.chainage_m,
source: (pipe.reason || "user") as PipeSource,
}))
: [];
apply(await computeDetailBasins(projectId, points));
status.hidden = basins.length > 0;
if (basins.length === 0) status.textContent = L("B05_Drainage_Status_NoBasin");
scheduleDraw();
@@ -509,8 +510,9 @@ export function createDrainagePanel(): DrainagePanel {
fitToRoute();
scheduleDraw();
showProgress(2 / 3, L("B05_Drainage_Status_Analyzing"));
// B04 분석 결과를 읽어 오는 것뿐이라 즉시 끝난다 — 페이지에 들어오면 바로 보여 준다.
void analyze(true);
// B04가 확정해 둔 관 지점을 그대로 불러온다 — 저장분이 없으면 백엔드가 자동 배치를 준다.
// 두 화면이 같은 파일을 보므로 B04에서 옮긴 관이 여기서도 같은 자리에 있다.
void loadSaved();
} catch (error) {
if (sequence !== loadSequence) return;
status.hidden = false;
@@ -568,12 +570,49 @@ export function createDrainagePanel(): DrainagePanel {
offsetY = dragStart.offsetY + event.clientY - dragStart.y;
scheduleDraw();
});
/** 유역 폴리곤을 눌러 고른다. 유역 밖을 누르면 강조를 푼다. */
function pickBasinAt(x: number, y: number): void {
if (!normalizer) return;
const view = currentView();
let hit: number | null = null;
let smallest = Number.POSITIVE_INFINITY;
basins.forEach((basin) => {
if (basin.polygon_lonlat.length < 3) return;
const ring = basin.polygon_lonlat.map(([lon, lat]) =>
lonLatToScreen(normalizer as Normalizer, view, lon, lat),
);
if (!pointInRing(ring, x, y)) return;
// 겹치면 면적이 작은 쪽을 고른다(안쪽 조각 우선).
if (basin.area_m2 < smallest) {
smallest = basin.area_m2;
hit = basin.index;
}
});
if (hit === null && selectedBasin === null) return;
selectedBasin = hit === selectedBasin ? null : hit;
renderBasinList();
scheduleDraw();
}
const stopDragging = (): void => {
pipeEditor.handleUp();
dragStart = null;
viewport.style.removeProperty("cursor");
};
viewport.addEventListener("pointerup", stopDragging);
viewport.addEventListener("pointerup", (event) => {
const start = basinClickStart;
basinClickStart = null;
const dragged = pipeClickStart !== null;
pipeClickStart = null;
if (!dragged && start && event.button === 0) {
const rect = viewport.getBoundingClientRect();
// 끌었으면 지도 조작이지 고르기가 아니다.
if (Math.hypot(event.clientX - start.x, event.clientY - start.y) <= CLICK_SLOP_PX) {
pickBasinAt(event.clientX - rect.left, event.clientY - rect.top);
}
}
stopDragging();
});
viewport.addEventListener("pointercancel", stopDragging);
const resizeObserver = new ResizeObserver(scheduleDraw);
@@ -597,6 +636,38 @@ export function createDrainagePanel(): DrainagePanel {
projectId = nextProjectId;
void loadLayers();
},
pipeChainages: () => pipeEditor.chainages(),
movePipe(fromChainage, toChainage) {
const index = pipeEditor
.pipes()
.findIndex((pipe) => Math.abs(pipe.chainage_m - fromChainage) < 0.51);
if (index < 0) return;
pipeEditor.moveTo(index, toChainage);
},
addPipe(chainageM) {
pipeEditor.addAtChainage(chainageM);
},
removePipe(chainageM) {
const index = pipeEditor
.pipes()
.findIndex((pipe) => Math.abs(pipe.chainage_m - chainageM) < 0.51);
if (index < 0) return;
pipeEditor.select(index);
pipeEditor.deleteSelected();
},
async savePipes() {
if (!projectId) return 0;
const response = await saveDetailPipePoints(
projectId,
pipeEditor.pipes().map((pipe) => ({
chainage_m: pipe.chainage_m,
source: (pipe.reason || "user") as PipeSource,
})),
);
apply(response);
scheduleDraw();
return response.pipe_count;
},
setRoute(points) {
routePoints = points;
routeLayer = meta && points.length > 1 ? prepareMetricPolyline(points, meta) : null;
@@ -11,8 +11,9 @@ import { themeColor } from "@ui/ui_template_palette";
import { DRAINAGE_SHEET_LAYERS } from "../A00_Common/b_asset_cache";
import type { VWorldMeta } from "../B04_wf1_Surface/B04_wf1_Surface_Api_Fetch";
import type { ViewState } from "../B04_wf1_Surface/B04_wf1_Surface_UI_MapRender";
import { normalizeStrength, rampColor } from "../B04_wf1_Surface/B04_wf1_Surface_UI_FlowRamp";
import type { MapContextMenu } from "@ui/ui_template_context_menu";
import type { DrainageBasin } from "./B05_wf2_Route_Api_Fetch";
import type { DetailBasin } from "../B04_wf1_Surface/B04_wf1_Surface_Api_Fetch";
import type { PipeEditor } from "./B05_wf2_Route_UI_Drainage_Pipes";
function L(key: keyof typeof ui_locales): string {
@@ -42,6 +43,7 @@ export const LAYER_LABEL_KEYS: Record<DrainageLayer, keyof typeof ui_locales> =
export const arrowToggleColor = (): string => themeColor("--map-flow-arrow", "#7c3aed");
export const upstreamToggleColor = (): string => themeColor("--map-upstream-toggle", "#1d4ed8");
export const strengthToggleColor = (): string => themeColor("--map-flow-ramp-5", "#dc2626");
export const hotspotToggleColor = (): string => themeColor("--map-flow-ramp-4", "#f97316");
/** 위성사진은 선이 아니라 배경이라 맞출 선 색이 없다 — 중립 회색을 띠 색으로 쓴다. */
export const satelliteToggleColor = (): string => themeColor("--map-satellite-toggle", "#64748b");
@@ -106,7 +108,7 @@ export function addLayerToggle(
/** 유역 제원 목록을 다시 그린다. 항목을 누르면 `onPick`으로 번호를 돌려준다. */
export function renderBasinRows(
container: HTMLElement,
basins: ReadonlyArray<DrainageBasin>,
basins: ReadonlyArray<DetailBasin>,
selected: number | null,
onPick: (index: number) => void,
): void {
@@ -199,3 +201,108 @@ export function bindPipeContextMenu(
menu.open(x, y, [[L("B05_Drainage_Menu_Add"), () => void editor.addAt(view, x, y)]]);
});
}
/** 유입 집중점 마커 — 계획선 위에서 물이 특히 많이 모이는 자리. 크기·색은 유입면적 로그 스케일.
* B04 지도와 같은 색띠를 쓴다(같은 값을 다르게 보여 주면 안 된다). */
export function drawHotspots(
context: CanvasRenderingContext2D,
toScreen: (x: number, y: number) => [number, number],
samples: ReadonlyArray<{ x: number; y: number }>,
spots: ReadonlyArray<{ chainage: number; area: number }>,
maximum: number,
): void {
if (spots.length === 0 || samples.length === 0) return;
context.save();
spots.forEach((spot) => {
const index = Math.min(samples.length - 1, Math.max(0, Math.round(spot.chainage)));
const [x, y] = toScreen(samples[index].x, samples[index].y);
const ratio = normalizeStrength(spot.area, maximum);
const radius = 3 + 5 * ratio;
context.beginPath();
context.arc(x, y, radius, 0, Math.PI * 2);
context.fillStyle = rampColor(ratio);
context.fill();
context.lineWidth = 1.2;
context.strokeStyle = themeColor("--map-halo", "rgba(255, 255, 255, 0.9)");
context.stroke();
});
context.restore();
}
/** 화면 좌표 폴리곤 안에 점이 있는지(홀짝 규칙). 유역을 눌러 고를 때 쓴다. */
export function pointInRing(ring: ReadonlyArray<[number, number]>, x: number, y: number): boolean {
let inside = false;
for (let index = 0, previous = ring.length - 1; index < ring.length; previous = index++) {
const [xi, yi] = ring[index];
const [xj, yj] = ring[previous];
if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside;
}
return inside;
}
/** 표시 토글 한 줄을 통째로 만든다 — 위성/도엽/화살표/강도/집중점/상류세류 6종.
* 패널이 700줄 한계에 닿아 옮겼다. 상태는 갖지 않고 켜고 끌 때 넘겨받은 함수만 부른다. */
export function mountDrainageToggles(
container: HTMLElement,
handlers: {
initial: { arrows: boolean; strength: boolean; hotspots: boolean; upstream: boolean };
onSatellite: (next: boolean) => void;
onSheetLayer: (layer: DrainageLayer, next: boolean) => void;
onArrows: (next: boolean) => void;
onStrength: (next: boolean) => void;
onHotspots: (next: boolean) => void;
onUpstream: (next: boolean) => void;
},
): void {
// 배경 위성사진 — 등고선 앞에 둔다(2026-08-01 사용자 지시). 사진이 어두워 유역 채움색이
// 묻힐 때 끄고 본다. 캔버스가 아니라 배경 이미지라 표시 여부만 직접 바꾼다.
addLayerToggle(
container,
L("B05_Drainage_Layer_Satellite"),
satelliteToggleColor(),
true,
handlers.onSatellite,
L("B05_Drainage_Layer_Satellite_Tip"),
);
DRAINAGE_LAYERS.forEach((layer) => {
addLayerToggle(container, L(LAYER_LABEL_KEYS[layer]), layerColor(layer), true, (next) =>
handlers.onSheetLayer(layer, next),
);
});
// 흐름 화살표 — 도면이 지저분해질 때 끄기 위한 토글(등고선·세류와 같은 줄·같은 양식).
addLayerToggle(
container,
L("B05_Drainage_Layer_Arrows"),
arrowToggleColor(),
handlers.initial.arrows,
handlers.onArrows,
L("B05_Drainage_Layer_Arrows_Tip"),
);
// 유입 강도 색칠 — 노선 1m 구간별 상류 면적. B04 지도와 같은 색띠를 쓴다.
addLayerToggle(
container,
L("B05_Drainage_Layer_Strength"),
strengthToggleColor(),
handlers.initial.strength,
handlers.onStrength,
L("B05_Drainage_Layer_Strength_Tip"),
);
// 유입 집중점 — 관을 어디에 둘지 판단하는 근거. 관 마커와 겹쳐 읽기 어려우므로 기본 꺼짐.
addLayerToggle(
container,
L("B05_Drainage_Layer_Hotspots"),
hotspotToggleColor(),
handlers.initial.hotspots,
handlers.onHotspots,
L("B05_Drainage_Layer_Hotspots_Tip"),
);
// 상류 세류선 강조 — 유역 판정의 기준선이라 항상 같은 굵기·색으로 얹는다.
addLayerToggle(
container,
L("B05_Drainage_Layer_Upstream"),
upstreamToggleColor(),
handlers.initial.upstream,
handlers.onUpstream,
L("B05_Drainage_Layer_Upstream_Tip"),
);
}
@@ -32,6 +32,10 @@ export interface PipeEditor {
deleteSelected(): boolean;
/** 화면 좌표에 있는 마커 번호(없으면 null). 우클릭 메뉴가 무엇을 띄울지 정하는 데 쓴다. */
hitAt(view: ViewState, screenX: number, screenY: number): number | null;
/** 누가거리로 바로 옮긴다(종단 테이블에서 라인을 끌었을 때). */
moveTo(index: number, chainageM: number): void;
/** 누가거리로 바로 넣는다(종단 테이블 우클릭). */
addAtChainage(chainageM: number): void;
/** 그 자리에 배관을 넣을 수 있는지(계획선에 충분히 가까운지)만 본다. */
canAddAt(view: ViewState, screenX: number, screenY: number): boolean;
/** 계획선 위 그 자리에 배관을 넣는다. 노선에서 멀면 false. */
@@ -182,6 +186,23 @@ export function createPipeEditor(
}
return null;
},
moveTo(index, chainageM) {
const pipe = pipeList[index];
if (!pipe) return;
pipe.chainage_m = Math.max(0, Math.min(totalChainage, chainageM));
pipe.reason = "confirmed";
sortPipes();
onChange();
onCommit();
},
addAtChainage(chainageM) {
const clamped = Math.max(0, Math.min(totalChainage, chainageM));
pipeList.push({ chainage_m: clamped, reason: "confirmed" });
sortPipes();
selectedIndex = pipeList.findIndex((pipe) => Math.abs(pipe.chainage_m - clamped) < 1e-6);
onChange();
onCommit();
},
canAddAt(view, screenX, screenY) {
const metric = screenToMetric(view, screenX, screenY);
if (!metric) return false;
@@ -0,0 +1,146 @@
/* =============================================================================
* 배수유역도 캔버스 렌더러 (B05)
*
* 한 프레임에 무엇을 어떤 순서로 그릴지만 담는다. 상태는 갖지 않고 패널이 매 프레임 넘긴
* 묶음(`DrainageScene`)만 본다 — 패널 본체(`_UI_Drainage_Panel.ts`)가 700줄 한계에 닿아
* 분리했다.
*
* 쌓는 순서(아래 → 위)
* 세부유역 채움 · 전체 유역 외곽선 → 도엽 레이어 → 상류 세류 → 계획선 →
* 유입 강도 색칠 → 평균 흐름 화살표 → 유입 집중점 → 배관 마커
* ========================================================================== */
import {
drawFilledRing,
drawPreparedLayer,
drawRidgeRing,
drawUpstreamLines,
routeLineColor,
ROUTE_LINE_WIDTH,
type Normalizer,
type PreparedLayer,
type ViewState,
} from "../B04_wf1_Surface/B04_wf1_Surface_UI_MapRender";
import type { DetailBasin, VWorldMeta } from "../B04_wf1_Surface/B04_wf1_Surface_Api_Fetch";
import { drawFlowArrows, type FlowArrow } from "../B04_wf1_Surface/B04_wf1_Surface_UI_FlowArrows";
import { drawStrengthLine } from "../B04_wf1_Surface/B04_wf1_Surface_UI_FlowRamp";
import type { RoutePoint } from "../B04_wf1_Surface/B04_wf1_Surface_UI_RouteSamples";
import {
basinColor,
createMetricProjector,
drawHotspots,
DRAINAGE_LAYERS,
layerColor,
type DrainageLayer,
} from "./B05_wf2_Route_UI_Drainage_Parts";
import type { PipeEditor } from "./B05_wf2_Route_UI_Drainage_Pipes";
export interface DrainageScene {
meta: VWorldMeta | null;
normalizer: Normalizer | null;
basins: ReadonlyArray<DetailBasin>;
selectedBasin: number | null;
mainBoundary: ReadonlyArray<[number, number]>;
preparedLayers: ReadonlyMap<DrainageLayer, PreparedLayer>;
activeLayers: ReadonlySet<DrainageLayer>;
routeLayer: PreparedLayer | null;
upstreamLines: ReadonlyArray<Array<[number, number]>>;
showUpstream: boolean;
strengthSamples: ReadonlyArray<RoutePoint>;
strength: Float64Array;
maxStrength: number;
showStrength: boolean;
flowArrows: ReadonlyArray<FlowArrow>;
arrowSpacingM: number;
showArrows: boolean;
hotspots: ReadonlyArray<{ chainage: number; area: number }>;
maxHotspotArea: number;
showHotspots: boolean;
pipeEditor: PipeEditor;
pipeColor: (chainage: number, position: number) => string;
}
export function drawDrainageScene(
context: CanvasRenderingContext2D,
view: ViewState,
scene: DrainageScene,
): void {
const { meta, normalizer } = scene;
// 세부유역 채움을 가장 아래에 깔아 등고선·세류 판독을 가리지 않게 한다.
if (normalizer) {
scene.basins.forEach((basin) => {
const color = basinColor(basin.index);
drawFilledRing(
context,
{ ring: basin.polygon_lonlat, label: String(basin.index) },
normalizer,
view,
// 하나를 고르면 나머지는 옅게 물러난다.
scene.selectedBasin === null || scene.selectedBasin === basin.index
? color
: color.replace(/0\.45\)$/, "0.18)"),
);
});
// 전체 유역 외곽선 = 분수령(능선). 해석 결과를 그대로 그린다 — 손으로 고치지 않는다.
if (scene.mainBoundary.length > 2) {
drawRidgeRing(context, scene.mainBoundary as Array<[number, number]>, normalizer, view);
}
}
// 등고선을 얇게 깔고 세류를 그 위에, 노선을 맨 위에 둔다.
DRAINAGE_LAYERS.forEach((layer) => {
if (!scene.activeLayers.has(layer)) return;
const prepared = scene.preparedLayers.get(layer);
if (!prepared) return;
context.lineWidth = layer === "도엽_등고선" ? 0.7 : 1.5;
context.strokeStyle = layerColor(layer);
drawPreparedLayer(context, prepared, view, "dot");
});
// 상류 세류선 강조 — 유역 채움 위, 흐름 화살표 아래. B04와 같은 공용 렌더러.
if (scene.showUpstream && normalizer && scene.upstreamLines.length > 0) {
drawUpstreamLines(
context,
scene.upstreamLines as Array<Array<[number, number]>>,
normalizer,
view,
);
}
if (scene.routeLayer) {
context.lineWidth = ROUTE_LINE_WIDTH;
context.strokeStyle = routeLineColor();
drawPreparedLayer(context, scene.routeLayer, view, "dot");
}
if (!meta) {
scene.pipeEditor.draw(context, view, scene.pipeColor);
return;
}
const projector = createMetricProjector(meta, view);
// 유입 강도 색칠 — 계획선 위, 배관 마커 아래. 색띠는 B04 지도와 공용이다.
if (scene.showStrength && scene.strength.length > 0) {
drawStrengthLine(context, scene.strengthSamples, scene.strength, scene.maxStrength, (point) =>
projector.toScreen(point.x, point.y),
);
}
// 평균 흐름 화살표 — 좌표는 사업지 CRS(m)라 도엽 메타로 바로 화면에 옮긴다.
if (scene.showArrows && scene.flowArrows.length > 0) {
drawFlowArrows(
context,
scene.flowArrows as FlowArrow[],
scene.arrowSpacingM,
projector.pxPerMeter,
projector.toScreen,
view,
);
}
// 유입 집중점 마커 — 강도 색칠 위, 배관 마커 아래.
if (scene.showHotspots && scene.hotspots.length > 0) {
drawHotspots(
context,
projector.toScreen,
scene.strengthSamples,
scene.hotspots,
scene.maxHotspotArea,
);
}
// 배관(관 매설) 마커 — 계획선 위 최상단.
scene.pipeEditor.draw(context, view, scene.pipeColor);
}
@@ -20,8 +20,14 @@ export interface IrregularStation {
chainage_m: number;
/** 구조물 설명 (당분간 자유 텍스트). */
structure: string;
/** 어디서 온 항목인가. `pipe`는 배수유역도의 관 매설 지점이 투영된 것이라 손으로
* 지우거나 옮겨도 정본(`pipe_points.json`)을 거쳐야 한다(2026-08-01 사용자 지시). */
origin?: "user" | "pipe";
}
/** 관 매설 지점이 구조물 목록에 실체화될 때 쓰는 이름. 향후 드롭다운으로 바꾼다. */
export const PIPE_STRUCTURE_NAME = "배관";
export interface IrregularStationsSection {
root: HTMLElement;
getStations: () => IrregularStation[];
@@ -29,6 +35,12 @@ export interface IrregularStationsSection {
selectByChainage: (chainageM: number | null) => void;
/** 외부(백엔드 복귀)에서 목록을 통째로 채운다(측점번호·잔여거리는 chainage로 역산). */
setStations: (seed: Array<{ chainage_m: number; structure: string }>) => void;
/** 배수유역도의 관 목록을 "배관" 구조물로 갈아 끼운다. 사용자가 손으로 넣은 항목은 건드리지 않는다. */
setPipeStations: (chainages: ReadonlyArray<number>) => void;
/** 누가거리로 항목을 찾아 지운다(종단 테이블 우클릭). 지웠으면 그 항목을 돌려준다. */
removeByChainage: (chainageM: number) => IrregularStation | null;
/** 누가거리로 항목을 옮긴다(종단 테이블 라인 드래그). 옮겼으면 true. */
moveByChainage: (fromChainageM: number, toChainageM: number) => boolean;
clear: () => void;
}
@@ -126,6 +138,16 @@ export function createIrregularStationsSection(
return station * (interval > 0 ? interval : 20) + remainder;
}
/** 누가거리를 측점번호+잔여거리로 되돌린다(외부 주입·이동 공용). */
function splitChainage(chainageM: number): { station: number; remainder: number } {
const interval = intervalMax();
const station = Math.floor((chainageM + 1e-6) / interval);
return {
station,
remainder: Number((chainageM - station * interval).toFixed(3)),
};
}
function syncButtons(): void {
primary.textContent = editingId ? "수정" : "추가";
remove.disabled = editingId === null;
@@ -191,6 +213,7 @@ export function createIrregularStationsSection(
const index = stations.findIndex((entry) => entry.id === editingId);
if (index >= 0) {
stations[index] = {
...stations[index],
id: editingId,
station,
remainder: safeRemainder,
@@ -205,6 +228,7 @@ export function createIrregularStationsSection(
remainder: safeRemainder,
chainage_m,
structure,
origin: "user",
});
}
loadForm(null);
@@ -246,23 +270,68 @@ export function createIrregularStationsSection(
loadForm(target ?? null);
},
setStations(seed) {
const interval = intervalMax();
stations.length = 0;
seed.forEach((entry) => {
const stationNo = Math.floor((entry.chainage_m + 1e-6) / interval);
const remainder = Number((entry.chainage_m - stationNo * interval).toFixed(3));
const { station, remainder } = splitChainage(entry.chainage_m);
stations.push({
id: String(nextId++),
station: stationNo,
station,
remainder,
chainage_m: entry.chainage_m,
structure: entry.structure,
origin: "user",
});
});
loadForm(null);
renderList();
callbacks.onChange([...stations]);
},
setPipeStations(chainages) {
// 배관 항목은 통째로 갈아 끼운다 — 정본은 배수유역도의 관 목록이다.
for (let index = stations.length - 1; index >= 0; index -= 1) {
if (stations[index].origin === "pipe") stations.splice(index, 1);
}
chainages.forEach((chainage) => {
const { station, remainder } = splitChainage(chainage);
stations.push({
id: String(nextId++),
station,
remainder,
chainage_m: chainage,
structure: PIPE_STRUCTURE_NAME,
origin: "pipe",
});
});
if (editingId && !stations.some((entry) => entry.id === editingId)) loadForm(null);
renderList();
callbacks.onChange([...stations]);
},
removeByChainage(chainageM) {
const index = stations.findIndex((entry) => Math.abs(entry.chainage_m - chainageM) < 0.51);
if (index < 0) return null;
const [removed] = stations.splice(index, 1);
if (editingId === removed.id) loadForm(null);
renderList();
callbacks.onChange([...stations]);
return removed;
},
moveByChainage(fromChainageM, toChainageM) {
const index = stations.findIndex(
(entry) => Math.abs(entry.chainage_m - fromChainageM) < 0.51,
);
if (index < 0) return false;
const { station, remainder } = splitChainage(toChainageM);
// 그 자리에서 고치지 않고 새 객체로 교체한다 — Page가 옛 위치의 계획고 편집을 정리해야 한다.
stations[index] = {
...stations[index],
station,
remainder,
chainage_m: toChainageM,
};
renderList();
callbacks.onChange([...stations]);
return true;
},
clear() {
stations.length = 0;
loadForm(null);
+29 -24
View File
@@ -18,7 +18,8 @@ import {
import {
confirmRoute,
fetchLatestRoute,
routeLatestCacheKey,
readRouteLatestCache,
writeRouteLatestCache,
solveRoute,
updateContourInterval,
type CirclePoint,
@@ -187,6 +188,28 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
},
// [초기선 복원] 시 추가한 비정규 측점도 함께 지운다.
() => panel.irregularStations.clear(),
{
// 관 매설 목록 ↔ 구조물 목록의 "배관" 항목을 한 방향으로 맞춘다.
// 정본은 배수유역도의 관 지점이며, 구조물 목록은 그것을 실체화한 것이다.
onPipesChanged: (chainages) => panel.irregularStations.setPipeStations(chainages),
onStructureMove: (from, to, station) => {
if (station.origin === "pipe") {
// 배관은 관 지점 정본을 거쳐야 세부유역까지 함께 다시 나뉜다.
profilePanel.drainage.movePipe(from, to);
return;
}
panel.irregularStations.moveByChainage(from, to);
},
onStructureRemove: (station) => {
if (station.origin === "pipe") {
profilePanel.drainage.removePipe(station.chainage_m);
return;
}
panel.irregularStations.removeByChainage(station.chainage_m);
},
onPipeAdd: (chainage) => profilePanel.drainage.addPipe(chainage),
onIrregularSelect: (station) => syncIrregularSelection(irregularStationId(station.id)),
},
);
/**
@@ -251,29 +274,9 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
* DB(latest)
* . latest를 ,
* solve· ( = , ). */
const latestCacheKey = routeLatestCacheKey(activeProjectId);
function readLatestCache(): RouteLatestResponse | null {
try {
const raw = window.sessionStorage.getItem(latestCacheKey);
return raw ? (JSON.parse(raw) as RouteLatestResponse) : null;
} catch {
return null;
}
}
function writeLatestCache(value: RouteLatestResponse): void {
try {
window.sessionStorage.setItem(latestCacheKey, JSON.stringify(value));
} catch {
// 용량 초과 등 저장 실패 시 캐시를 비워 다음 진입은 DB 조회로 폴백한다.
try {
window.sessionStorage.removeItem(latestCacheKey);
} catch {
/* noop */
}
}
}
const readLatestCache = (): RouteLatestResponse | null => readRouteLatestCache(activeProjectId);
const writeLatestCache = (value: RouteLatestResponse): void =>
writeRouteLatestCache(activeProjectId, value);
/** 캐시 우선 latest 로드. forceFresh=true(solve/확정 직후)는 항상 DB를 읽고 캐시를 갱신한다. */
async function loadLatest(forceFresh = false): Promise<RouteLatestResponse> {
@@ -555,6 +558,8 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
async function confirm(): Promise<void> {
if (!routeReady || stale) return;
// 관 매설 지점을 B04와 같은 저장소에 남긴다 — 저장 실패가 경로 확정을 막지는 않는다.
await profilePanel.drainage.savePipes().catch(() => 0);
showLoadingOverlay();
try {
// 종단 계획선 편집은 화면에서만 계산해 두었으므로 확정 직전에 영속화한다.
@@ -0,0 +1,78 @@
/* =============================================================================
* (B05)
*
* (`longitudinal_sections.data`) .
* DOM도 700 .
* ========================================================================== */
import type {
DesignProfile,
LongitudinalSection,
} from "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch";
import type { ProfileAlignment } from "./B05_wf2_Route_UI_Profile_Alignment";
export function readAlignment(data: LongitudinalSection): ProfileAlignment | null {
const candidate = data.profile_alignment as ProfileAlignment | undefined;
if (!candidate?.base_pvi?.length || !candidate.samples?.length) return null;
if (!Number.isFinite(candidate.policy?.default_curve_radius_m)) return null;
if (!candidate.stations || !candidate.segments || !candidate.curves) return null;
candidate.edits = {
station_offsets: candidate.edits?.station_offsets ?? {},
curve_radii: candidate.edits?.curve_radii ?? {},
};
return candidate;
}
/** 저장분이 구버전이라 편집을 붙일 수 없는 상태인가 (재계산 안내용). */
export function hasLegacyAlignment(data: LongitudinalSection): boolean {
return Boolean(data.profile_alignment) && readAlignment(data) === null;
}
/** 편집 결과를 종단면도 렌더러가 받는 계획선 형태로 감싼다. */
export function toDesignProfile(
alignment: ProfileAlignment,
original: DesignProfile | undefined,
): DesignProfile {
const balance = alignment.balance;
return {
id: original?.id ?? "design_grade_line",
name: original?.name ?? "계획선",
basis: original?.basis ?? "station_alignment",
samples: alignment.samples,
balance_segments: [
{
index: 0,
start_chainage_m: alignment.samples[0]?.chainage_m ?? 0,
end_chainage_m: alignment.samples[alignment.samples.length - 1]?.chainage_m ?? 0,
cut_area_m2: balance.cut_area_m2,
fill_area_m2: balance.fill_area_m2,
balance_error_m2: balance.net_area_m2,
},
],
summary: {
...(original?.summary ?? {
max_grade_pct: 0,
vertical_curve_count: 0,
pvi_count: 0,
balance_segment_count: 1,
main_direction: "none",
suggested_elevation_offset_m: null,
warnings: [],
}),
cut_area_m2: balance.cut_area_m2,
fill_area_m2: balance.fill_area_m2,
balance_error_m2: balance.net_area_m2,
balanced: balance.within_tolerance,
},
};
}
export function normalizedLongitudinal(data: LongitudinalSection): LongitudinalSection {
return {
...data,
samples: data.samples.map((sample: LongitudinalSection["samples"][number]) => ({
...sample,
elevation_m: sample.elevation_m ?? sample.z ?? null,
})),
};
}
+46 -68
View File
@@ -11,7 +11,6 @@
* ========================================================================== */
import type {
DesignProfile,
LongitudinalSection,
SectionDetailResponse,
} from "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch";
@@ -23,6 +22,12 @@ import { LONG_PAD } from "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Sectio
import { createWorkflowPanelHandle } from "@ui/ui_template_overlay";
import { createPanelResizer } from "@ui/ui_template_resizer";
import { createDrainagePanel } from "./B05_wf2_Route_UI_Drainage_Panel";
import {
hasLegacyAlignment,
normalizedLongitudinal,
readAlignment,
toDesignProfile,
} from "./B05_wf2_Route_UI_Profile_Data";
import { createProgressCircle } from "@ui/ui_template_progress";
import { showToast } from "@ui/ui_template_elements";
import { saveProfileAlignment } from "./B05_wf2_Route_Api_Fetch";
@@ -80,72 +85,6 @@ const TABLE_ROW_COUNT = 12;
* `default_curve_radius_m` .
* (· ) .
*/
function readAlignment(data: LongitudinalSection): ProfileAlignment | null {
const candidate = data.profile_alignment as ProfileAlignment | undefined;
if (!candidate?.base_pvi?.length || !candidate.samples?.length) return null;
if (!Number.isFinite(candidate.policy?.default_curve_radius_m)) return null;
if (!candidate.stations || !candidate.segments || !candidate.curves) return null;
candidate.edits = {
station_offsets: candidate.edits?.station_offsets ?? {},
curve_radii: candidate.edits?.curve_radii ?? {},
};
return candidate;
}
/** 저장분이 구버전이라 편집을 붙일 수 없는 상태인가 (재계산 안내용). */
function hasLegacyAlignment(data: LongitudinalSection): boolean {
return Boolean(data.profile_alignment) && readAlignment(data) === null;
}
/** 편집 결과를 종단면도 렌더러가 받는 계획선 형태로 감싼다. */
function toDesignProfile(
alignment: ProfileAlignment,
original: DesignProfile | undefined,
): DesignProfile {
const balance = alignment.balance;
return {
id: original?.id ?? "design_grade_line",
name: original?.name ?? "계획선",
basis: original?.basis ?? "station_alignment",
samples: alignment.samples,
balance_segments: [
{
index: 0,
start_chainage_m: alignment.samples[0]?.chainage_m ?? 0,
end_chainage_m: alignment.samples[alignment.samples.length - 1]?.chainage_m ?? 0,
cut_area_m2: balance.cut_area_m2,
fill_area_m2: balance.fill_area_m2,
balance_error_m2: balance.net_area_m2,
},
],
summary: {
...(original?.summary ?? {
max_grade_pct: 0,
vertical_curve_count: 0,
pvi_count: 0,
balance_segment_count: 1,
main_direction: "none",
suggested_elevation_offset_m: null,
warnings: [],
}),
cut_area_m2: balance.cut_area_m2,
fill_area_m2: balance.fill_area_m2,
balance_error_m2: balance.net_area_m2,
balanced: balance.within_tolerance,
},
};
}
function normalizedLongitudinal(data: LongitudinalSection): LongitudinalSection {
return {
...data,
samples: data.samples.map((sample) => ({
...sample,
elevation_m: sample.elevation_m ?? sample.z ?? null,
})),
};
}
/** 측점 사이 여백 — 이웃 셀끼리 붙어 보이지 않게 띄운다. */
const CELL_GAP_PX = 2;
@@ -230,6 +169,18 @@ function chainageMapper(
return (chainage: number) => LONG_PAD.left + originOffset + (chainage / maxChainage) * plotWidth;
}
/** `chainageMapper`의 역변환 — 구조물 라인을 끌 때 화면 x를 누가거리로 되돌린다. */
function chainageInverter(
data: LongitudinalSection,
width: number,
originOffset: number,
): (px: number) => number {
const maxChainage = maxChainageOf(data);
const plotWidth = width - LONG_PAD.left - LONG_PAD.right - 2 * originOffset;
return (px: number) =>
plotWidth > 0 ? ((px - LONG_PAD.left - originOffset) / plotWidth) * maxChainage : 0;
}
interface ProfileLayout {
/** 캔버스 폭(px). 화면이 넓으면 폭맞춤으로, 좁으면 최소 폭으로. */
width: number;
@@ -264,11 +215,26 @@ function computeProfileLayout(
return { width, originOffset: spacing / 2, cellWidth: Math.max(1, spacing - CELL_GAP_PX) };
}
/** 종단 테이블 구조물 라인·배수유역도가 Page로 올려 보내는 알림. */
export interface RouteProfilePanelCallbacks {
/** 관 매설 목록이 바뀜 — 구조물 목록의 "배관" 항목을 이 누가거리로 맞춘다. */
onPipesChanged?: (chainages: number[]) => void;
/** 테이블에서 구조물 라인을 끌어 옮김. */
onStructureMove?: (fromChainageM: number, toChainageM: number, station: IrregularStation) => void;
/** 테이블 우클릭으로 구조물(배관 포함)을 지움. */
onStructureRemove?: (station: IrregularStation) => void;
/** 테이블 빈 자리 우클릭으로 배관을 넣음. */
onPipeAdd?: (chainageM: number) => void;
/** 구조물 라인을 눌러 고름. */
onIrregularSelect?: (station: IrregularStation) => void;
}
export function createRouteProfilePanel(
projectId: string,
onSelectStation: (stationId: string) => void,
/** [초기선 복원] 클릭 시 함께 실행(비정규 측점 등 다른 조작값도 초기화하려고 Page가 넘긴다). */
onResetAll?: () => void,
callbacks?: RouteProfilePanelCallbacks,
) {
const root = document.createElement("section");
root.className = "b05-route-profile";
@@ -292,7 +258,10 @@ export function createRouteProfilePanel(
bodyWrap.append(body, progress.root);
const content = document.createElement("div");
content.className = "b05-route-profile__content";
const drainagePanel = createDrainagePanel();
// 관 목록이 바뀌면 종단 테이블의 "배관" 구조물 라인도 같이 맞춘다(정본은 관 지점 파일).
const drainagePanel = createDrainagePanel({
onPipesChanged: (chainages) => callbacks?.onPipesChanged?.(chainages),
});
content.append(bodyWrap, drainagePanel.root);
// 위쪽 경계를 끌어 패널 높이를 조절한다. 늘어난 만큼은 그래프만 먹고 도면 테이블은
// 처음 잡힌 높이를 지킨다(사용자 지시) — 테이블 행이 늘었다 줄었다 하면 읽기 어려워서다.
@@ -488,6 +457,15 @@ export function createRouteProfilePanel(
// 값 열 계획고 직접 입력 → 규칙 측점과 동일한 station_offset 파이프라인.
onAdjustStation: (chainage, delta) =>
base && applyEdits(adjustStation(base, store.edits(), chainage, delta)),
// 구조물 배치 라인 — 끌어서 옮기고 우클릭으로 배관을 넣거나 지운다.
structureLines: {
chainageAt: chainageInverter(longitudinal, width, layout.originOffset),
maxChainageM: maxChainageOf(longitudinal),
onMove: (from, to, station) => callbacks?.onStructureMove?.(from, to, station),
onRemove: (station) => callbacks?.onStructureRemove?.(station),
onAddPipe: (chainage) => callbacks?.onPipeAdd?.(chainage),
onSelect: (station) => callbacks?.onIrregularSelect?.(station),
},
})
: null;
@@ -0,0 +1,128 @@
/* =============================================================================
* (B05)
*
* , .
* .
*
* (`origin: "pipe"`).
* (`pipe_points.json`)
* (2026-08-01 ).
* ========================================================================== */
import { createMapContextMenu } from "@ui/ui_template_context_menu";
import { irregularLabel, type IrregularStation } from "./B05_wf2_Route_UI_IrregularStations";
/** 선을 잡았다고 볼 좌우 여유(px). 선 자체는 얇아 그대로는 집기 어렵다. */
const GRAB_SLACK_PX = 6;
/** 이만큼(px) 이하로 움직였다 뗐으면 이동이 아니라 고르기로 본다. */
const DRAG_SLOP_PX = 3;
export interface StructureLineOptions {
/** 테이블에 얹을 구조물 측점 목록. */
stations: ReadonlyArray<IrregularStation>;
/** chainage → x(px). 테이블·그래프와 같은 매핑을 쓴다. */
x: (chainageM: number) => number;
/** x(px) → chainage. 선을 끌 때 역변환에 쓴다. */
chainageAt: (px: number) => number;
/** 노선 총 연장(m). 이 범위를 벗어난 자리로는 옮기지 않는다. */
maxChainageM: number;
/** 선을 옮겼을 때. 옛 누가거리와 새 누가거리를 넘긴다. */
onMove: (fromChainageM: number, toChainageM: number, station: IrregularStation) => void;
/** 선을 지울 때. */
onRemove: (station: IrregularStation) => void;
/** 빈 자리에서 우클릭해 배관을 넣을 때. */
onAddPipe: (chainageM: number) => void;
/** 선을 눌렀을 때(끌지 않음) — 값 열 오버레이 선택과 맞춘다. */
onSelect?: (station: IrregularStation) => void;
}
/**
* .
* .
*/
export function mountStructureLines(table: HTMLElement, options: StructureLineOptions): void {
const layer = document.createElement("div");
layer.className = "b05-profile-table__structures";
const menu = createMapContextMenu("b05-profile-table");
/** 끌고 있는 선. null이면 잡은 것이 없다. */
let dragging: { station: IrregularStation; element: HTMLElement; startX: number } | null = null;
let moved = false;
function clamp(chainageM: number): number {
return Math.min(Math.max(chainageM, 0), options.maxChainageM);
}
options.stations.forEach((station) => {
const line = document.createElement("div");
line.className =
"b05-profile-table__structure" + (station.origin === "pipe" ? " is-pipe" : " is-user");
line.style.left = `${options.x(station.chainage_m)}px`;
line.title = `${irregularLabel(station)} · ${station.structure || "구조물"}`;
const label = document.createElement("span");
label.className = "b05-profile-table__structure-label";
label.textContent = station.structure || "구조물";
line.append(label);
line.addEventListener("pointerdown", (event) => {
// 이동은 좌클릭 전용 — 우클릭은 메뉴다(2026-08-01 사용자 지시).
if (event.button !== 0) return;
event.preventDefault();
event.stopPropagation();
menu.close();
dragging = { station, element: line, startX: event.clientX };
moved = false;
line.setPointerCapture(event.pointerId);
});
line.addEventListener("pointermove", (event) => {
if (!dragging || dragging.station.id !== station.id) return;
if (Math.abs(event.clientX - dragging.startX) > DRAG_SLOP_PX) moved = true;
const rect = table.getBoundingClientRect();
line.style.left = `${options.x(clamp(options.chainageAt(event.clientX - rect.left)))}px`;
});
line.addEventListener("pointerup", (event) => {
if (!dragging || dragging.station.id !== station.id) return;
const rect = table.getBoundingClientRect();
const next = clamp(options.chainageAt(event.clientX - rect.left));
dragging = null;
if (!moved) {
// 끌지 않고 눌렀다 뗐으면 고르기다 — 원래 자리로 되돌린다.
line.style.left = `${options.x(station.chainage_m)}px`;
options.onSelect?.(station);
return;
}
options.onMove(station.chainage_m, Number(next.toFixed(2)), station);
});
line.addEventListener("contextmenu", (event) => {
event.preventDefault();
event.stopPropagation();
const rect = table.getBoundingClientRect();
menu.open(event.clientX - rect.left, event.clientY - rect.top, [
[station.origin === "pipe" ? "배관 삭제" : "구조물 삭제", () => options.onRemove(station)],
]);
});
layer.append(line);
});
// 빈 자리 우클릭 — 그 누가거리에 배관을 넣는다.
table.addEventListener("contextmenu", (event) => {
if (menu.contains(event.target)) {
event.preventDefault();
return;
}
const rect = table.getBoundingClientRect();
const chainage = clamp(options.chainageAt(event.clientX - rect.left));
event.preventDefault();
menu.open(event.clientX - rect.left, event.clientY - rect.top, [
["배관 추가", () => options.onAddPipe(Number(chainage.toFixed(2)))],
]);
});
table.addEventListener("pointerdown", (event) => {
if (!menu.contains(event.target)) menu.close();
});
table.append(layer, menu.element);
}
/** 선을 잡았다고 볼 여유 — 스타일에서 선 폭을 정할 때 함께 쓴다. */
export const STRUCTURE_GRAB_SLACK_PX = GRAB_SLACK_PX;
@@ -19,6 +19,10 @@ import type {
} from "./B05_wf2_Route_UI_Profile_Alignment";
import { stationLabel } from "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_Common";
import { irregularStationId, type IrregularStation } from "./B05_wf2_Route_UI_IrregularStations";
import {
mountStructureLines,
type StructureLineOptions,
} from "./B05_wf2_Route_UI_Profile_Structures";
export interface ProfileTableOptions {
alignment: ProfileAlignment;
@@ -42,6 +46,8 @@ export interface ProfileTableOptions {
onCurveRadiusChange: (curve: AlignmentCurve, radiusM: number | null) => void;
/** 임의 chainage의 계획고를 delta만큼 조정(비정규 측점 값 열 직접 입력용). */
onAdjustStation?: (chainageM: number, deltaM: number) => void;
/** 구조물 배치 라인(끌어 이동·우클릭 추가/삭제). 없으면 라인을 얹지 않는다. */
structureLines?: Omit<StructureLineOptions, "stations" | "x">;
}
interface StationRowSpec {
@@ -535,6 +541,14 @@ export function createProfileTable(options: ProfileTableOptions): HTMLElement {
),
);
}
// 구조물 배치 라인 — 값 열 오버레이 위에 얹어 언제든 잡을 수 있게 한다.
if (options.structureLines) {
mountStructureLines(table, {
...options.structureLines,
stations: options.irregularStations ?? [],
x,
});
}
return table;
}
+100 -1
View File
@@ -685,6 +685,87 @@
/* 선택된 비정규 측점 오버레이 (규칙 열과 같은 12행)
세로 점선·구조물 태그 없이, 선택 시에만 열을 테이블 위에 겹쳐 보여준다. */
/* ── 구조물 배치 라인 (끌어 이동 · 우클릭 추가/삭제) ─────────────────────── */
.b05-profile-table__structures {
position: absolute;
z-index: 5;
inset: 0;
/* 레이어 자체는 클릭을 흘려보내고, 선만 받는다 — 값 셀 조작을 막지 않는다. */
pointer-events: none;
}
.b05-profile-table__structure {
position: absolute;
top: 0;
bottom: 0;
width: 13px;
/* 선은 얇게 보이되 집는 폭은 넉넉히 — 가운데 2px만 색을 칠한다. */
margin-left: -6px;
background: linear-gradient(
to right,
transparent 5px,
var(--b05-structure-color) 5px,
var(--b05-structure-color) 8px,
transparent 8px
);
cursor: ew-resize;
pointer-events: auto;
touch-action: none;
}
.b05-profile-table__structure.is-pipe {
--b05-structure-color: var(--map-pipe-user, rgb(22 163 74));
}
.b05-profile-table__structure.is-user {
--b05-structure-color: var(--color-royal-amethyst, rgb(109 40 217));
}
.b05-profile-table__structure-label {
position: absolute;
top: 2px;
left: 10px;
padding: 0 3px;
border-radius: 2px;
background: var(--color-surface-raised);
color: var(--b05-structure-color);
font-size: 10px;
white-space: nowrap;
}
.b05-profile-table__context-menu {
position: absolute;
z-index: 7;
display: flex;
flex-direction: column;
min-width: 120px;
padding: var(--spacing-4);
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
background: var(--color-surface-raised);
box-shadow: 0 4px 16px rgb(0 0 0 / 25%);
}
.b05-profile-table__context-menu[hidden] {
display: none;
}
.b05-profile-table__context-menu-item {
padding: var(--spacing-4) var(--spacing-8);
border: 0;
border-radius: var(--radius-cards);
background: transparent;
color: var(--color-text-body);
font-size: 12px;
text-align: left;
cursor: pointer;
}
.b05-profile-table__context-menu-item:hover {
background: var(--color-surface-sunken);
}
.b05-profile-table__irregular-col {
position: absolute;
/* (0)·곡선(2) , sticky 이름표(5) **아래** 둔다 스크롤로 열이 이름표까지 와도
@@ -1030,9 +1111,13 @@
}
/* 유역 제원 목록 — 면적·유역표고·유하거리·관경(수식 확정 전까지 "미정"). */
/* 유역 목록은 3행까지만 보이고 나머지는 스크롤한다(2026-08-01 사용자 지시).
높이를 비율로 잡으면 유역 수와 무관하게 잘려 개인지 가늠이 된다. */
.b05-drainage__basins {
--b05-basin-row: 34px;
display: flex;
max-height: 34%;
max-height: calc(3 * var(--b05-basin-row) + 2 * 2px + 2 * var(--spacing-8));
flex: 0 0 auto;
flex-direction: column;
gap: 2px;
@@ -1041,8 +1126,22 @@
border-top: 1px solid var(--color-border);
}
/* 관 개수·세부유역 수·종단 Z 출처 한 줄. */
.b05-drainage__summary {
flex: 0 0 auto;
padding: var(--spacing-4) var(--spacing-8);
border-top: 1px solid var(--color-border);
color: var(--color-text-muted, var(--color-text-body));
font-size: 12px;
}
.b05-drainage__summary[hidden] {
display: none;
}
.b05-drainage__basin {
display: flex;
min-height: var(--b05-basin-row);
align-items: center;
gap: var(--spacing-8);
padding: var(--spacing-4) var(--spacing-8);
@@ -31,6 +31,7 @@ from typing import Any
import numpy as np
from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Analyze import find_inflow_hotspots
from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Export import STAGES
from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Flow import largest_ring, polygonize_labels
from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Grid import GridSpec
@@ -71,6 +72,8 @@ class DrainageDetail:
upstream_lonlat: list[list[list[float]]] = field(default_factory=list)
# 도로 1m 구간별 유입 면적(㎡) — [누가거리, 면적]. 계획선을 색으로 칠하는 데 쓴다.
strength_profile: list[list[float]] = field(default_factory=list)
# 유입 집중점 — [누가거리, 유입면적, 구역번호, 구역 내 순위]. 관 자리를 판단하는 근거.
inflow_hotspots: list[list[float]] = field(default_factory=list)
@dataclass
@@ -157,6 +160,14 @@ def build_detail(
arrow_spacing_m=routing.arrow_spacing_m,
upstream_lonlat=read_upstream_lines(directory),
strength_profile=build_strength_profile(routing),
inflow_hotspots=[
[chainage, area, float(zone), float(rank)]
for chainage, area, zone, rank in find_inflow_hotspots(
routing.strength_curve,
[pipe.chainage_m for pipe in routing.base_pipes],
vertices[-1].chainage_m,
)
],
)
if not pipes:
return detail
-5
View File
@@ -301,11 +301,6 @@ DRAINAGE_DETAIL_FILENAME = "04_detailed_basins.geojson"
DRAINAGE_EDITS_DIRNAME = "edits"
DRAINAGE_PIPE_POINTS_FILENAME = "pipe_points.json"
# ── B05 전용 배수유역 사본 ──
# B04 산출물을 그대로 쓰면 B05 쪽 작업이 원본을 덮어쓴다. 프로젝트 저장소의
# B05_wf2_Route/drainage/ 아래로 복사해 두고 B05는 사본만 읽는다.
DRAINAGE_B05_DIRNAME = "drainage"
# ── B05용 평균 흐름 화살표 ──
# 셀 화살표는 1m라 축소하면 경향이 안 보인다. 이 크기의 블록으로 묶어 방향을 평균한다.
DRAINAGE_ARROW_BLOCK_M = float(os.getenv("DRAINAGE_ARROW_BLOCK_M", "10.0"))
-2
View File
@@ -36,7 +36,6 @@ 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
from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Router import router as b06_section_router
from B07_wf4_DesignDetail.B07_wf4_DesignDetail_Router import router as b07_design_router
from common_util.common_util_auth import require_company, verify_session
@@ -354,7 +353,6 @@ app.include_router(b04_inflow_router, dependencies=protected_with_company)
app.include_router(b04_basins_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)
app.include_router(b06_section_router, dependencies=protected_with_company)
app.include_router(b07_design_router, dependencies=protected_with_company)
+10
View File
@@ -831,6 +831,16 @@ export const ui_locales = {
],
B05_Drainage_Menu_Add: ["배관 추가", "Add culvert"],
B05_Drainage_Menu_Delete: ["배관 삭제", "Remove culvert"],
B05_Drainage_Layer_Hotspots: ["집중유역", "Inflow hotspots"],
B05_Drainage_Layer_Hotspots_Tip: [
"노선 위에서 물이 특히 많이 모이는 자리를 마커로 표시합니다. 관을 어디에 둘지 판단하는 근거입니다.",
"Marks the spots along the route that collect the most water — the basis for placing culverts.",
],
/* {pipes}=관 개수, {basins}=세부유역 수, {source}=종단 Z 출처 */
B05_Drainage_Summary: [
"관 {pipes}개 · 세부유역 {basins}개 · 종단 Z {source}",
"{pipes} culverts · {basins} sub-basins · profile Z {source}",
],
B05_Drainage_Layer_Strength: ["흐름 강도", "Flow strength"],
B05_Drainage_Layer_Strength_Tip: [
"노선 1m 구간마다 그 자리로 모이는 상류 면적을 색으로 칠합니다(B04 지도와 같은 색띠).",