관 매설 지점을 기준으로 세부 배수유역을 나누는 기능을 B04 2D 지도에 추가한다.
경계는 B04 전처리 격자(03_road_routing.npz)의 road_slot — 1m 셀마다 물이 도달하는
도로 셀 — 을 담당 관으로 라벨링해 그 경계로 잡는다. 종단 Z는 경계를 긋지 않고
"도로 셀이 어느 관으로 흐르는가"만 정한다.
공용 승격 (B04 관리자 화면과 B05 사용자 화면이 같은 결과를 내야 함)
- common_util_drainage_detail.py: 관 보충(9)·세부유역 분할(10) 알고리즘
- common_util_drainage_context.py: 노선·종단 Z·좌표계 입력 준비
- common_util_drainage_pipes.py: 관 지점 정본 저장소(edits/pipe_points.json)
- common_util_route_profile.py: 종단 Z 해석기(계획고 > 경로 정점 > 지표면 > CSV)
- common_util_surface_sampler.py: B05 종횡단 sampler 이동
- B05 _prepare()의 노선 소스를 원청 계획노선 CSV로 정정(B04 격자와 누가거리 정합)
B04 신규 API
- GET /{project_id}/drainage/pipe-points 저장분 조회(없으면 자동 생성)
- POST /{project_id}/drainage/detail-basins 편집 중 목록으로 재분할(저장 안 함)
- PUT /{project_id}/drainage/pipe-points 모델 확정 시 관 지점·세부유역 커밋
B04 화면
- 관 마커 기본/자동/수동 색 구분, 계획선 스냅 드래그 이동
- 계획선 우클릭 "관 매설 추가" / 마커 우클릭 "관 매설 삭제"
- 표시 토글 2그룹(관 매설 / 세부 유역)을 유입 집중점과 분리
- "상세유역 분석" 버튼을 눌렀을 때만 재계산
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
70 lines
2.8 KiB
TypeScript
70 lines
2.8 KiB
TypeScript
/* =============================================================================
|
|
* 계획선 1m 재표본 (B04 2D 지도 공용)
|
|
*
|
|
* 백엔드는 도로 위 값을 전부 **누가거리 1m 구간**으로 준다(흐름 강도 곡선, 관 매설 지점,
|
|
* 유입 집중점). 화면에서 그 값을 계획선 위에 얹으려면 같은 규칙으로 다시 찍은 점 목록이
|
|
* 있어야 한다 — 오버레이마다 따로 찍으면 한 칸씩 밀려 색과 마커가 어긋난다.
|
|
* ========================================================================== */
|
|
|
|
export type RoutePoint = { x: number; y: number };
|
|
|
|
/** 계획선을 1m 간격으로 다시 찍는다. 배열 인덱스 = 누가거리(m). */
|
|
export function resampleRoute(points: ReadonlyArray<RoutePoint>): RoutePoint[] {
|
|
const samples: RoutePoint[] = [];
|
|
if (points.length < 2) return samples;
|
|
let carried = 0;
|
|
samples.push({ x: points[0].x, y: points[0].y });
|
|
for (let i = 1; i < points.length; i += 1) {
|
|
const from = points[i - 1];
|
|
const to = points[i];
|
|
const dx = to.x - from.x;
|
|
const dy = to.y - from.y;
|
|
const length = Math.hypot(dx, dy);
|
|
if (length <= 0) continue;
|
|
let travelled = 1 - carried;
|
|
while (travelled <= length) {
|
|
samples.push({
|
|
x: from.x + (dx * travelled) / length,
|
|
y: from.y + (dy * travelled) / length,
|
|
});
|
|
travelled += 1;
|
|
}
|
|
carried = (carried + length) % 1;
|
|
}
|
|
return samples;
|
|
}
|
|
|
|
/** 누가거리(m) 위치의 계획선 좌표. 범위를 벗어나면 양 끝으로 자른다. */
|
|
export function pointAtChainage(
|
|
samples: ReadonlyArray<RoutePoint>,
|
|
chainage: number,
|
|
): RoutePoint | null {
|
|
if (samples.length === 0) return null;
|
|
const index = Math.min(samples.length - 1, Math.max(0, Math.round(chainage)));
|
|
return samples[index];
|
|
}
|
|
|
|
/** 화면 좌표에서 가장 가까운 계획선 위치를 찾는다. (누가거리 m, 화면 거리 px).
|
|
|
|
* 관을 우클릭으로 추가하거나 끌어 옮길 때 "계획선 위"로 스냅하는 근거다. 1m 표본을 전부
|
|
* 훑되 화면 변환은 넘겨받은 함수에 맡긴다 — 배경지도 메타를 여기서 알 필요가 없다. */
|
|
export function nearestChainage(
|
|
samples: ReadonlyArray<RoutePoint>,
|
|
toScreen: (point: RoutePoint) => [number, number],
|
|
screenX: number,
|
|
screenY: number,
|
|
): { chainage: number; distance: number } | null {
|
|
if (samples.length === 0) return null;
|
|
let bestIndex = -1;
|
|
let bestDistance = Number.POSITIVE_INFINITY;
|
|
for (let index = 0; index < samples.length; index += 1) {
|
|
const [x, y] = toScreen(samples[index]);
|
|
const distance = Math.hypot(x - screenX, y - screenY);
|
|
if (distance < bestDistance) {
|
|
bestDistance = distance;
|
|
bestIndex = index;
|
|
}
|
|
}
|
|
return bestIndex < 0 ? null : { chainage: bestIndex, distance: bestDistance };
|
|
}
|