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:
@@ -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),
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 지도와 같은 색띠).",
|
||||
|
||||
Reference in New Issue
Block a user