auto: 2026-08-01 08:55 (EOMSANGDON-HOME)
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
import * as THREE from "three";
|
||||
import type { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
||||
import type { SurfaceBounds } from "./B04_wf1_Surface_Api_Fetch";
|
||||
|
||||
export const SURFACE_CAMERA_FOV = 50;
|
||||
@@ -43,6 +45,129 @@ export function niceScaleDistance(roughMeters: number): number {
|
||||
return step * base;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
* 커서 기준 회전·줌 (B04 포인트클라우드/지형, B05 노선 뷰어 공용)
|
||||
*
|
||||
* OrbitControls 기본값은 화면 중앙(target)을 축으로 돌아서, 커서로 보고 있는 봉우리가
|
||||
* 화면 밖으로 밀려난다. 여기서는 커서 아래 지형 지점을 축으로 삼아 카메라와 target을
|
||||
* 함께 돌린다 — 축이 화면에 고정되므로 시점이 튀지 않는다.
|
||||
* 줌은 OrbitControls의 zoomToCursor로 같은 기준을 쓴다.
|
||||
* -------------------------------------------------------------------------- */
|
||||
|
||||
/** 극점을 넘어 화면이 뒤집히지 않도록 남기는 여유각(rad). */
|
||||
const POLAR_EPSILON = 0.02;
|
||||
|
||||
export interface CursorPivotOptions {
|
||||
camera: THREE.PerspectiveCamera;
|
||||
controls: OrbitControls;
|
||||
/** 포인터 이벤트를 받는 캔버스. */
|
||||
element: HTMLElement;
|
||||
/** 커서 아래에서 찾을 대상(지형 메시·포인트클라우드). 없으면 기존 축을 유지한다. */
|
||||
pickables: () => THREE.Object3D[];
|
||||
/** 마커 드래그 등 다른 조작이 잡고 있으면 회전을 넘긴다. */
|
||||
blocked?: () => boolean;
|
||||
}
|
||||
|
||||
/** 커서 기준 회전·줌을 붙이고, 해제 함수를 돌려준다. */
|
||||
export function bindCursorPivotControls(options: CursorPivotOptions): () => void {
|
||||
const { camera, controls, element } = options;
|
||||
// 회전은 여기서 직접 처리하므로 OrbitControls 쪽 회전은 끈다(팬·줌은 그대로 둔다).
|
||||
controls.enableRotate = false;
|
||||
controls.zoomToCursor = true;
|
||||
|
||||
const raycaster = new THREE.Raycaster();
|
||||
const pointer = new THREE.Vector2();
|
||||
const pivot = new THREE.Vector3();
|
||||
let pointerId: number | null = null;
|
||||
let lastX = 0;
|
||||
let lastY = 0;
|
||||
|
||||
/** 커서 아래 지형 지점. 못 찾으면 기존 target을 축으로 쓴다. */
|
||||
function pickPivot(event: PointerEvent): void {
|
||||
pivot.copy(controls.target);
|
||||
const targets = options.pickables();
|
||||
if (targets.length === 0) return;
|
||||
const rect = element.getBoundingClientRect();
|
||||
if (rect.width <= 0 || rect.height <= 0) return;
|
||||
pointer.set(
|
||||
((event.clientX - rect.left) / rect.width) * 2 - 1,
|
||||
-((event.clientY - rect.top) / rect.height) * 2 + 1,
|
||||
);
|
||||
raycaster.setFromCamera(pointer, camera);
|
||||
const hit = raycaster.intersectObjects(targets, true)[0];
|
||||
if (hit) pivot.copy(hit.point);
|
||||
}
|
||||
|
||||
function onPointerDown(event: PointerEvent): void {
|
||||
if (event.button !== 0 || pointerId !== null) return;
|
||||
if (options.blocked?.() || !controls.enabled) return;
|
||||
pickPivot(event);
|
||||
pointerId = event.pointerId;
|
||||
lastX = event.clientX;
|
||||
lastY = event.clientY;
|
||||
}
|
||||
|
||||
function onPointerMove(event: PointerEvent): void {
|
||||
if (pointerId !== event.pointerId) return;
|
||||
if (options.blocked?.()) {
|
||||
stop();
|
||||
return;
|
||||
}
|
||||
const height = Math.max(element.clientHeight, 1);
|
||||
// OrbitControls와 같은 감도: 화면 높이만큼 끌면 한 바퀴.
|
||||
const yaw = (2 * Math.PI * (event.clientX - lastX)) / height;
|
||||
const pitch = (2 * Math.PI * (event.clientY - lastY)) / height;
|
||||
lastX = event.clientX;
|
||||
lastY = event.clientY;
|
||||
if (yaw === 0 && pitch === 0) return;
|
||||
|
||||
const up = camera.up.clone().normalize();
|
||||
const cameraOffset = camera.position.clone().sub(pivot);
|
||||
const targetOffset = controls.target.clone().sub(pivot);
|
||||
// 수평 회전 — 화면 상하축(카메라 up) 기준.
|
||||
cameraOffset.applyAxisAngle(up, -yaw);
|
||||
targetOffset.applyAxisAngle(up, -yaw);
|
||||
// 수직 회전 — 시선의 오른쪽 축 기준. 극점을 넘으면 상하만 버린다.
|
||||
const viewDirection = cameraOffset.clone().sub(targetOffset);
|
||||
const right = viewDirection.clone().cross(up);
|
||||
if (right.lengthSq() > 1e-8) {
|
||||
right.normalize();
|
||||
const rotated = viewDirection.clone().applyAxisAngle(right, -pitch);
|
||||
const polar = rotated.angleTo(up);
|
||||
if (polar > POLAR_EPSILON && polar < Math.PI - POLAR_EPSILON) {
|
||||
cameraOffset.applyAxisAngle(right, -pitch);
|
||||
targetOffset.applyAxisAngle(right, -pitch);
|
||||
}
|
||||
}
|
||||
camera.position.copy(pivot).add(cameraOffset);
|
||||
controls.target.copy(pivot).add(targetOffset);
|
||||
camera.lookAt(controls.target);
|
||||
controls.update();
|
||||
}
|
||||
|
||||
function stop(): void {
|
||||
pointerId = null;
|
||||
}
|
||||
|
||||
function onPointerEnd(event: PointerEvent): void {
|
||||
if (pointerId === event.pointerId) stop();
|
||||
}
|
||||
|
||||
element.addEventListener("pointerdown", onPointerDown);
|
||||
element.addEventListener("pointermove", onPointerMove);
|
||||
element.addEventListener("pointerup", onPointerEnd);
|
||||
element.addEventListener("pointercancel", onPointerEnd);
|
||||
element.addEventListener("pointerleave", onPointerEnd);
|
||||
|
||||
return () => {
|
||||
element.removeEventListener("pointerdown", onPointerDown);
|
||||
element.removeEventListener("pointermove", onPointerMove);
|
||||
element.removeEventListener("pointerup", onPointerEnd);
|
||||
element.removeEventListener("pointercancel", onPointerEnd);
|
||||
element.removeEventListener("pointerleave", onPointerEnd);
|
||||
};
|
||||
}
|
||||
|
||||
export function bindSurfaceViewerTheme(
|
||||
applyBackground: (color: string | number) => void,
|
||||
): () => void {
|
||||
|
||||
@@ -21,6 +21,9 @@ export type GeoJsonCollection = {
|
||||
|
||||
export type MarkerKind = "dot" | "x";
|
||||
|
||||
/** 상류 세류망 강조 색 — 유역 판정의 기준선이라 가장 굵고 진하게 둔다. */
|
||||
const UPSTREAM_LINE_COLOR = "rgba(29, 78, 216, 0.95)";
|
||||
|
||||
/**
|
||||
* 사전 투영된 하나의 파트(선/링/점 묶음). 좌표는 정규화 맵 좌표(0~1) x,y 교차 배열.
|
||||
* weights: Douglas-Peucker 가중치(정점 제거 시 발생하는 최대 오차, 종횡비 보정 좌표계).
|
||||
@@ -522,6 +525,37 @@ export function drawFilledRing(
|
||||
context.fillText(entry.label, centerX, centerY);
|
||||
}
|
||||
|
||||
/** 상류 세류망 강조 — B04 분석 오버레이와 B05 배수유역도가 같은 굵기·색으로 그린다. */
|
||||
export function drawUpstreamLines(
|
||||
context: CanvasRenderingContext2D,
|
||||
lines: ReadonlyArray<ReadonlyArray<readonly [number, number]>>,
|
||||
normalizer: Normalizer,
|
||||
view: ViewState,
|
||||
): void {
|
||||
if (lines.length === 0) return;
|
||||
const affine = affineOf(view);
|
||||
context.save();
|
||||
context.setLineDash([]);
|
||||
context.lineWidth = 4;
|
||||
context.lineCap = "round";
|
||||
context.lineJoin = "round";
|
||||
context.strokeStyle = UPSTREAM_LINE_COLOR;
|
||||
lines.forEach((line) => {
|
||||
if (line.length < 2) return;
|
||||
context.beginPath();
|
||||
line.forEach(([lon, lat], index) => {
|
||||
const nx = (lon - normalizer.lonMin) / normalizer.lonRange;
|
||||
const ny = 1 - (lat - normalizer.latMin) / normalizer.latRange;
|
||||
const x = nx * affine.ax + affine.bx;
|
||||
const y = ny * affine.ay + affine.by;
|
||||
if (index === 0) context.moveTo(x, y);
|
||||
else context.lineTo(x, y);
|
||||
});
|
||||
context.stroke();
|
||||
});
|
||||
context.restore();
|
||||
}
|
||||
|
||||
/** 유역 경계(분수령=능선)를 능선 스타일(갈색 파선)로 강조해 그린다. */
|
||||
export function drawRidgeRing(
|
||||
context: CanvasRenderingContext2D,
|
||||
|
||||
@@ -116,6 +116,11 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
|
||||
empty.textContent = L("B04_Surface_Map_Empty");
|
||||
const status = document.createElement("span");
|
||||
status.className = "b04-map__status";
|
||||
// 지도 위 좌상단 문구 묶음 — 지도 상태와 배수유역 상태를 세로로 쌓는다.
|
||||
// 배경지도가 복잡해 글자가 묻히므로 각 문구에 배경 칩을 깐다(2026-08-01 사용자 지시).
|
||||
const statusStack = document.createElement("div");
|
||||
statusStack.className = "b04-map__status-stack";
|
||||
statusStack.append(status);
|
||||
const scaleBar = document.createElement("div");
|
||||
scaleBar.className = "b04-map__scale";
|
||||
const scaleText = document.createElement("span");
|
||||
@@ -124,7 +129,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
|
||||
...BACKGROUND_LAYERS.map((layer) => backgroundImages.get(layer)!),
|
||||
canvas,
|
||||
empty,
|
||||
status,
|
||||
statusStack,
|
||||
scaleBar,
|
||||
);
|
||||
root.append(header, viewport);
|
||||
@@ -232,8 +237,10 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
|
||||
const watershedButtons = document.createElement("div");
|
||||
watershedButtons.className = "b04-map__layer-buttons";
|
||||
watershedButtons.append(watershed.button, ...watershed.partButtons);
|
||||
watershedGroup.append(watershedTitle, watershedButtons, watershed.statusElement);
|
||||
watershedGroup.append(watershedTitle, watershedButtons);
|
||||
controls.insertBefore(watershedGroup, resetButton);
|
||||
// 안내·결과 문구는 컨트롤 줄이 아니라 지도 위에 얹는다 — 컨트롤 영역 세로 공간을 먹지 않는다.
|
||||
statusStack.append(watershed.statusElement);
|
||||
|
||||
function updateImageTransform(): void {
|
||||
backgroundImages.forEach((image) => {
|
||||
|
||||
@@ -603,40 +603,46 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.b04-map__empty,
|
||||
.b04-map__status {
|
||||
.b04-map__empty {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
inset: 50% auto auto 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.b04-map__empty {
|
||||
inset: 50% auto auto 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.b04-map__status {
|
||||
/* 지도 위 좌상단 문구 묶음 — 지도 상태와 배수유역 상태를 세로로 쌓는다.
|
||||
지도 조작을 가리지 않도록 포인터 이벤트는 통과시킨다. */
|
||||
.b04-map__status-stack {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: var(--spacing-12);
|
||||
left: var(--spacing-12);
|
||||
padding: var(--spacing-4) var(--spacing-8);
|
||||
border-radius: var(--radius-inputs);
|
||||
background: var(--color-surface-raised);
|
||||
display: flex;
|
||||
max-width: min(62%, 900px);
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-4);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 배수유역 전용 상태 줄 — 지도 자체 상태(.b04-map__status)와 칸을 나눠 쓰면
|
||||
나중에 끝난 쪽이 상대 문구를 지운다. 그래서 컨트롤 영역에 따로 둔다. */
|
||||
나중에 끝난 쪽이 상대 문구를 지운다. 그래서 줄을 따로 둔다.
|
||||
배경지도(위성·지적·등고선)가 복잡해 글자가 묻히므로 배경 칩을 깐다. */
|
||||
.b04-map__status,
|
||||
.b04-map__watershed-status {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: var(--spacing-4);
|
||||
padding: var(--spacing-4) var(--spacing-8);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-inputs);
|
||||
background: var(--color-surface-raised);
|
||||
color: var(--color-text-secondary);
|
||||
background: color-mix(in srgb, var(--color-surface-raised) 92%, transparent);
|
||||
color: var(--color-text-body);
|
||||
font-size: var(--text-caption);
|
||||
line-height: 1.5;
|
||||
word-break: keep-all;
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.b04-map__watershed-status[hidden] {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { PLYLoader } from "three/examples/jsm/loaders/PLYLoader.js";
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import type { SurfaceBounds, SurfaceModelSummary } from "./B04_wf1_Surface_Api_Fetch";
|
||||
import {
|
||||
bindCursorPivotControls,
|
||||
bindSurfaceViewerTheme,
|
||||
getTopFitDistance,
|
||||
niceScaleDistance,
|
||||
@@ -209,6 +210,13 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
|
||||
let terrainMesh: THREE.Object3D | null = null;
|
||||
const labelElements: HTMLDivElement[] = [];
|
||||
// 회전·줌 중심을 커서 아래 지형 지점으로 (포인트클라우드 뷰어·B05와 공용 유틸).
|
||||
const releaseCursorPivot = bindCursorPivotControls({
|
||||
camera,
|
||||
controls,
|
||||
element: renderer.domElement,
|
||||
pickables: () => (terrainMesh ? [terrainMesh] : []),
|
||||
});
|
||||
|
||||
function disposeObject(obj: THREE.Object3D) {
|
||||
obj.traverse((child) => {
|
||||
@@ -547,6 +555,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
clearMesh();
|
||||
clearContours();
|
||||
releaseTheme();
|
||||
releaseCursorPivot();
|
||||
controls.dispose();
|
||||
renderer.dispose();
|
||||
}
|
||||
@@ -675,6 +684,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
resizeObserver.disconnect();
|
||||
releaseTheme();
|
||||
releaseCursorPivot();
|
||||
clearMesh();
|
||||
clearContours();
|
||||
controls.dispose();
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as THREE from "three";
|
||||
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
||||
import type { SurfaceBounds, SurfacePointCloudSampleResponse } from "./B04_wf1_Surface_Api_Fetch";
|
||||
import {
|
||||
bindCursorPivotControls,
|
||||
bindSurfaceViewerTheme,
|
||||
getReferenceCenter,
|
||||
getTopFitDistance,
|
||||
@@ -120,6 +121,13 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer {
|
||||
scene.add(axes);
|
||||
|
||||
let pointsObject: THREE.Points | null = null;
|
||||
// 회전·줌 중심을 커서 아래 지점으로 (지형 뷰어·B05와 공용 유틸).
|
||||
const releaseCursorPivot = bindCursorPivotControls({
|
||||
camera,
|
||||
controls: orbit,
|
||||
element: canvas,
|
||||
pickables: () => (pointsObject ? [pointsObject] : []),
|
||||
});
|
||||
let currentData: SurfacePointCloudSampleResponse | null = null;
|
||||
let animationFrame = 0;
|
||||
let hasConnected = false;
|
||||
@@ -272,6 +280,7 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer {
|
||||
disposed = true;
|
||||
cancelAnimationFrame(animationFrame);
|
||||
releaseTheme();
|
||||
releaseCursorPivot();
|
||||
clearPoints();
|
||||
orbit.dispose();
|
||||
renderer.dispose();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { fetchWatershedAnalysis, type WatershedAnalysis } from "./B04_wf1_Surface_Api_Fetch";
|
||||
import { drawFlowArrows } from "./B04_wf1_Surface_UI_FlowArrows";
|
||||
import type { Normalizer, ViewState } from "./B04_wf1_Surface_UI_MapRender";
|
||||
import { drawUpstreamLines, type Normalizer, type ViewState } from "./B04_wf1_Surface_UI_MapRender";
|
||||
|
||||
/* =============================================================================
|
||||
* 배수유역 분석 오버레이 (B04 — 관리자 확인용)
|
||||
@@ -91,19 +91,22 @@ export function createWatershedOverlay(onChange: () => void): WatershedOverlay {
|
||||
"저장된 결과는 지도를 열 때 자동으로 표시되므로, 조건을 바꿨을 때만 누르면 됩니다.";
|
||||
|
||||
// 갈래별 표시 여부. 전체 토글(button)이 꺼져 있으면 이 값과 무관하게 아무것도 안 그린다.
|
||||
// 1차 유역·유역 방향은 격자가 지도를 덮어 판독을 방해하므로 기본 꺼짐(2026-08-01 사용자 지시).
|
||||
const shownParts: Record<PartKey, boolean> = {
|
||||
primary: true,
|
||||
primary: false,
|
||||
basin: true,
|
||||
flow: true,
|
||||
flow: false,
|
||||
arrows: true,
|
||||
};
|
||||
const partButtons = PARTS.map((part) => {
|
||||
const element = document.createElement("button");
|
||||
element.type = "button";
|
||||
element.className = "b04-map__layer-button b04-map__layer-button--gis is-active";
|
||||
const initialActive = shownParts[part.key];
|
||||
element.className =
|
||||
"b04-map__layer-button b04-map__layer-button--gis" + (initialActive ? " is-active" : "");
|
||||
element.textContent = part.label;
|
||||
element.style.setProperty("--b04-layer-color", part.color);
|
||||
element.setAttribute("aria-pressed", "true");
|
||||
element.setAttribute("aria-pressed", String(initialActive));
|
||||
element.addEventListener("click", () => {
|
||||
shownParts[part.key] = !shownParts[part.key];
|
||||
element.classList.toggle("is-active", shownParts[part.key]);
|
||||
@@ -328,12 +331,10 @@ export function createWatershedOverlay(onChange: () => void): WatershedOverlay {
|
||||
context.lineWidth = 2;
|
||||
context.strokeStyle = "rgba(120, 113, 108, 0.85)";
|
||||
region.downstream_lines.forEach((line) => strokeLonLat(context, line, map, view));
|
||||
// ④ 채택된 상류망 = 1차 영역의 기준선. 가장 굵게, 맨 위에.
|
||||
context.setLineDash([]);
|
||||
context.lineWidth = 4;
|
||||
context.strokeStyle = "rgba(29, 78, 216, 0.95)";
|
||||
region.upstream_lines.forEach((line) => strokeLonLat(context, line, map, view));
|
||||
context.restore();
|
||||
// ④ 채택된 상류망 = 1차 영역의 기준선. 가장 굵게, 맨 위에.
|
||||
// 그리기는 B05 배수유역도와 같은 공용 렌더러에 맡긴다.
|
||||
drawUpstreamLines(context, region.upstream_lines, map, view);
|
||||
}
|
||||
|
||||
/** B05에도 같이 쓰는 평균 흐름 화살표. 그리기는 공용 렌더러에 맡긴다. */
|
||||
|
||||
@@ -302,8 +302,16 @@ export interface DrainageBasinResponse {
|
||||
pipes: DrainageCandidate[];
|
||||
/** B04가 분석에 쓴 계획 노선 선형(lon/lat). */
|
||||
route_lonlat: Array<[number, number]>;
|
||||
/** 2차 전체 배수유역 외곽선 = 분수령. B04 산출물을 그대로 받는다. */
|
||||
/** 2차 전체 배수유역 외곽선 = 분수령. 편집 핸들 간격으로 다시 찍고 저장된 편집분이 반영된 값. */
|
||||
main_polygon_lonlat: Array<[number, number]>;
|
||||
/** 외곽선 편집 핸들 간격(m). */
|
||||
boundary_spacing_m: number;
|
||||
/** 저장돼 있던 외곽선 편집 포인트(원래 자리 base, 옮긴 자리 moved). */
|
||||
boundary_overrides: Array<{ base: [number, number]; moved: [number, number] }>;
|
||||
/** 새 유역 안쪽으로 들어가 버려진 편집 포인트 수. */
|
||||
boundary_dropped: number;
|
||||
/** 유역 안쪽 상류 세류망 — 하이라이트 토글용. */
|
||||
upstream_lonlat: Array<Array<[number, number]>>;
|
||||
/** B04 해석 격자 한 변(m). */
|
||||
grid_cell_m: number;
|
||||
/** 평균 흐름 화살표 — [x, y(사업지 CRS m), 방위(도), 도로도달, 셀 수]. */
|
||||
@@ -325,3 +333,14 @@ export async function fetchDrainageBasins(
|
||||
API_ANALYSIS_TIMEOUT_MS,
|
||||
);
|
||||
}
|
||||
|
||||
/** 사용자가 옮긴 유역 외곽선 포인트만 저장한다(종단 경로 확정 시 모달 승인 후 호출). */
|
||||
export async function saveDrainageBoundary(
|
||||
projectId: string,
|
||||
points: Array<{ base: [number, number]; moved: [number, number] }>,
|
||||
): Promise<{ status: string; saved: number }> {
|
||||
return requestJson<{ status: string; saved: number }>(
|
||||
`/projects/${projectId}/drainage/boundary`,
|
||||
{ method: "PUT", body: JSON.stringify({ points }) },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,10 +25,11 @@ from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Export import STAGES, drainage_dir
|
||||
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
|
||||
from B05_wf2_Route.B05_wf2_Route_Engine_Drainage import estimate_pipe_diameter_mm
|
||||
from B05_wf2_Route.B05_wf2_Route_Engine_Drainage_Store import b05_drainage_dir, sync_from_b04
|
||||
from common_util.common_util_route_geometry import (
|
||||
RouteVertex,
|
||||
StructureCandidate,
|
||||
@@ -62,6 +63,8 @@ class DrainageDetail:
|
||||
# B04가 계산해 둔 평균 흐름 화살표를 그대로 넘긴다 — B05는 다시 계산하지 않는다.
|
||||
flow_arrows: list[list[Any]] = field(default_factory=list)
|
||||
arrow_spacing_m: float = 0.0
|
||||
# 유역 안쪽 상류 세류망(WGS84 lon/lat 조각들). 화면 강조 표시용 — 계산에는 쓰지 않는다.
|
||||
upstream_lonlat: list[list[list[float]]] = field(default_factory=list)
|
||||
|
||||
|
||||
def build_drainage_detail(
|
||||
@@ -100,6 +103,7 @@ def build_drainage_detail(
|
||||
grid_cell_m=routing.spec.cell_m,
|
||||
flow_arrows=routing.flow_arrows,
|
||||
arrow_spacing_m=routing.arrow_spacing_m,
|
||||
upstream_lonlat=load_upstream_lines(stored_path),
|
||||
)
|
||||
if not pipes:
|
||||
return detail
|
||||
@@ -162,8 +166,13 @@ class RoadRouting:
|
||||
|
||||
|
||||
def load_road_routing(stored_path: str) -> RoadRouting | None:
|
||||
"""B04가 남긴 `03_road_routing` 산출물을 읽는다. 없으면 None."""
|
||||
directory = drainage_dir(stored_path)
|
||||
"""`03_road_routing` 산출물을 읽는다. 없으면 None.
|
||||
|
||||
읽는 대상은 B04 원본이 아니라 **B05 사본**이다. B04가 다시 해석했으면 사본을 먼저
|
||||
갱신한다 — 편집분(`boundary_overrides.json`)은 사본 갱신과 무관하게 남는다.
|
||||
"""
|
||||
sync_from_b04(stored_path)
|
||||
directory = b05_drainage_dir(stored_path)
|
||||
prefix = STAGES["road_routing"]
|
||||
array_path = directory / f"{prefix}_road_routing.npz"
|
||||
if not array_path.exists():
|
||||
@@ -247,6 +256,35 @@ def _read_geometry(path: Path, routing: RoadRouting) -> None:
|
||||
)
|
||||
|
||||
|
||||
def load_upstream_lines(stored_path: str) -> list[list[list[float]]]:
|
||||
"""`01_primary_region`에서 상류 세류망만 읽는다(화면 강조용).
|
||||
|
||||
유역 판정의 기준선이라 B04 오버레이에서도 같은 선을 굵게 그린다 — B05는 그 선을
|
||||
그대로 받아 표시만 한다.
|
||||
"""
|
||||
path = b05_drainage_dir(stored_path) / f"{STAGES['primary_region']}_primary_region.geojson"
|
||||
if not path.exists():
|
||||
return []
|
||||
try:
|
||||
with path.open("r", encoding="utf-8") as file:
|
||||
document = json.load(file)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
logger.warning("배수유역: 상류 세류망을 읽지 못했습니다 (%s).", path)
|
||||
return []
|
||||
lines: list[list[list[float]]] = []
|
||||
for feature in document.get("features", []):
|
||||
properties = feature.get("properties") or {}
|
||||
geometry = feature.get("geometry") or {}
|
||||
if properties.get("kind") != "upstream":
|
||||
continue
|
||||
coordinates = geometry.get("coordinates")
|
||||
if geometry.get("type") == "LineString" and coordinates:
|
||||
lines.append(coordinates)
|
||||
elif geometry.get("type") == "MultiLineString" and coordinates:
|
||||
lines.extend(part for part in coordinates if part)
|
||||
return lines
|
||||
|
||||
|
||||
# ── ⑨ 관 최소 개수 보충 ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
"""B05 전용 배수유역 저장소 (사본 관리 + 유역 외곽선 편집분 보존).
|
||||
|
||||
B04는 배수유역을 해석해 `B04_wf1_Surface/drainage/`에 남긴다. B05는 그 결과를 읽어 관을
|
||||
보충하고 세부유역을 나누는데, 같은 폴더를 그대로 쓰면 B05에서 손댄 내용이 B04 원본을
|
||||
덮어쓴다. 그래서 여기서 사본을 따로 둔다(2026-08-01 사용자 지시).
|
||||
|
||||
· `B05_wf2_Route/drainage/` — B04 산출물의 사본. B04가 다시 해석하면 자동으로 갱신된다.
|
||||
· `boundary_overrides.json` — 사용자가 옮긴 유역 외곽선 포인트만. 사본이 갱신돼도 남는다.
|
||||
|
||||
노선이 바뀌지 않으면 유역도 바뀌지 않는다. 노선이 바뀌어 B04가 재계산하면 사본은 새 결과로
|
||||
덮어쓰고, 저장해 둔 편집 포인트는 좌표 근접으로 새 외곽선에 다시 붙인다. 새 유역 **안쪽**으로
|
||||
들어간 포인트는 경계를 넓히는 의미가 없으므로 버린다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import shutil
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
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,
|
||||
DRAINAGE_BOUNDARY_HANDLE_SPACING_M,
|
||||
DRAINAGE_BOUNDARY_MATCH_RADIUS_M,
|
||||
DRAINAGE_BOUNDARY_OVERRIDE_FILENAME,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 위도 1도 ≈ 110540m, 경도 1도 ≈ 111320m·cos(위도). 30m 안팎의 근접 판정에는 충분하다.
|
||||
_METERS_PER_LAT_DEGREE = 110540.0
|
||||
_METERS_PER_LON_DEGREE = 111320.0
|
||||
|
||||
LonLatPoint = list[float]
|
||||
|
||||
|
||||
@dataclass
|
||||
class BoundaryOverride:
|
||||
"""사용자가 옮긴 외곽선 포인트 하나 — 원래 자리(base)와 옮긴 자리(moved)."""
|
||||
|
||||
base: tuple[float, float]
|
||||
moved: tuple[float, float]
|
||||
|
||||
|
||||
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 쪽이 더 최신이면(재해석) 그 파일만 덮어쓴다.
|
||||
`boundary_overrides.json`은 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
|
||||
|
||||
|
||||
def _overrides_path(stored_path: str) -> Path:
|
||||
return b05_drainage_dir(stored_path) / DRAINAGE_BOUNDARY_OVERRIDE_FILENAME
|
||||
|
||||
|
||||
def load_boundary_overrides(stored_path: str) -> list[BoundaryOverride]:
|
||||
"""저장된 외곽선 편집 포인트를 읽는다. 없으면 빈 목록."""
|
||||
path = _overrides_path(stored_path)
|
||||
if not path.exists():
|
||||
return []
|
||||
try:
|
||||
with path.open("r", encoding="utf-8") as file:
|
||||
document = json.load(file)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
logger.warning("배수유역: 외곽선 편집 파일을 읽지 못했습니다 (%s).", path)
|
||||
return []
|
||||
overrides: list[BoundaryOverride] = []
|
||||
for entry in (document or {}).get("points", []):
|
||||
base = _as_point(entry.get("base"))
|
||||
moved = _as_point(entry.get("moved"))
|
||||
if base and moved:
|
||||
overrides.append(BoundaryOverride(base=base, moved=moved))
|
||||
return overrides
|
||||
|
||||
|
||||
def save_boundary_overrides(stored_path: str, overrides: list[BoundaryOverride]) -> int:
|
||||
"""외곽선 편집 포인트를 저장한다. 저장된 개수를 돌려준다."""
|
||||
path = _overrides_path(stored_path)
|
||||
document = {
|
||||
"version": 1,
|
||||
"spacing_m": DRAINAGE_BOUNDARY_HANDLE_SPACING_M,
|
||||
"points": [{"base": list(item.base), "moved": list(item.moved)} for item in overrides],
|
||||
}
|
||||
try:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("w", encoding="utf-8") as file:
|
||||
json.dump(document, file, ensure_ascii=False)
|
||||
except OSError:
|
||||
logger.warning("배수유역: 외곽선 편집 저장 실패 (%s).", path)
|
||||
return 0
|
||||
logger.info("배수유역: 외곽선 편집 %d개 저장 — %s", len(overrides), path)
|
||||
return len(overrides)
|
||||
|
||||
|
||||
def parse_overrides(values: Any) -> list[BoundaryOverride]:
|
||||
"""프론트가 보낸 편집 포인트 목록을 정리한다(형식이 어긋난 항목은 버린다)."""
|
||||
if not isinstance(values, list):
|
||||
return []
|
||||
parsed: list[BoundaryOverride] = []
|
||||
for entry in values:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
base = _as_point(entry.get("base"))
|
||||
moved = _as_point(entry.get("moved"))
|
||||
if base and moved:
|
||||
parsed.append(BoundaryOverride(base=base, moved=moved))
|
||||
return parsed
|
||||
|
||||
|
||||
def _as_point(value: Any) -> tuple[float, float] | None:
|
||||
if not isinstance(value, (list, tuple)) or len(value) < 2:
|
||||
return None
|
||||
try:
|
||||
return (float(value[0]), float(value[1]))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def resample_boundary(
|
||||
polygon_lonlat: list[LonLatPoint], spacing_m: float = DRAINAGE_BOUNDARY_HANDLE_SPACING_M
|
||||
) -> list[LonLatPoint]:
|
||||
"""외곽선을 일정 간격(m)으로 다시 찍어 편집 핸들 목록을 만든다.
|
||||
|
||||
격자 경계라 원래 정점이 1m 간격으로 촘촘해 그대로 핸들로 쓸 수 없다.
|
||||
"""
|
||||
points = [p for p in polygon_lonlat if isinstance(p, (list, tuple)) and len(p) >= 2]
|
||||
if len(points) < 3 or spacing_m <= 0:
|
||||
return [list(p[:2]) for p in points]
|
||||
|
||||
# 폐합 고리로 다룬다 — 끝점이 시작점과 같으면 중복을 뺀다.
|
||||
ring = [(float(p[0]), float(p[1])) for p in points]
|
||||
if _distance_m(ring[0], ring[-1]) < 0.001:
|
||||
ring = ring[:-1]
|
||||
if len(ring) < 3:
|
||||
return [list(p) for p in ring]
|
||||
|
||||
handles: list[LonLatPoint] = [list(ring[0])]
|
||||
carried = 0.0
|
||||
for index in range(len(ring)):
|
||||
start = ring[index]
|
||||
end = ring[(index + 1) % len(ring)]
|
||||
segment = _distance_m(start, end)
|
||||
if segment <= 0:
|
||||
continue
|
||||
position = spacing_m - carried
|
||||
while position <= segment:
|
||||
ratio = position / segment
|
||||
handles.append(
|
||||
[
|
||||
start[0] + (end[0] - start[0]) * ratio,
|
||||
start[1] + (end[1] - start[1]) * ratio,
|
||||
]
|
||||
)
|
||||
position += spacing_m
|
||||
carried = (carried + segment) % spacing_m
|
||||
return handles
|
||||
|
||||
|
||||
def apply_boundary_overrides(
|
||||
handles: list[LonLatPoint], overrides: list[BoundaryOverride]
|
||||
) -> tuple[list[LonLatPoint], list[BoundaryOverride]]:
|
||||
"""저장된 편집 포인트를 새 핸들 목록에 다시 붙인다.
|
||||
|
||||
· 인덱스가 아니라 **좌표 근접**으로 맞춘다 — 재계산하면 핸들 수가 달라진다.
|
||||
· 옮긴 자리가 새 유역 **안쪽**이면 경계를 넓히지 않으므로 버린다.
|
||||
돌려주는 값은 (편집이 반영된 핸들, 살아남은 편집 목록).
|
||||
"""
|
||||
if not overrides or len(handles) < 3:
|
||||
return handles, list(overrides)
|
||||
|
||||
polygon = [(float(p[0]), float(p[1])) for p in handles]
|
||||
applied = [list(p) for p in handles]
|
||||
kept: list[BoundaryOverride] = []
|
||||
for override in overrides:
|
||||
if _point_in_polygon(override.moved, polygon):
|
||||
continue
|
||||
nearest = -1
|
||||
nearest_distance = DRAINAGE_BOUNDARY_MATCH_RADIUS_M
|
||||
for index, point in enumerate(polygon):
|
||||
distance = _distance_m(override.base, point)
|
||||
if distance <= nearest_distance:
|
||||
nearest = index
|
||||
nearest_distance = distance
|
||||
if nearest < 0:
|
||||
continue
|
||||
# 붙인 자리를 새 base로 잡아 둔다 — 다음 재계산에서도 같은 지점에 다시 붙는다.
|
||||
applied[nearest] = [override.moved[0], override.moved[1]]
|
||||
kept.append(BoundaryOverride(base=polygon[nearest], moved=override.moved))
|
||||
return applied, kept
|
||||
|
||||
|
||||
def _distance_m(a: tuple[float, float], b: tuple[float, float]) -> float:
|
||||
"""두 lon/lat 사이 거리(m) 근사. 수십 m 범위 판정에만 쓴다."""
|
||||
mean_lat = math.radians((a[1] + b[1]) / 2)
|
||||
dx = (b[0] - a[0]) * _METERS_PER_LON_DEGREE * math.cos(mean_lat)
|
||||
dy = (b[1] - a[1]) * _METERS_PER_LAT_DEGREE
|
||||
return math.hypot(dx, dy)
|
||||
|
||||
|
||||
def _point_in_polygon(point: tuple[float, float], polygon: list[tuple[float, float]]) -> bool:
|
||||
"""레이 캐스팅 내부 판정 (lon/lat 평면에서 그대로 계산)."""
|
||||
x, y = point
|
||||
inside = False
|
||||
count = len(polygon)
|
||||
for index in range(count):
|
||||
x1, y1 = polygon[index]
|
||||
x2, y2 = polygon[(index + 1) % count]
|
||||
if (y1 > y) != (y2 > y):
|
||||
crossing = x1 + (y - y1) * (x2 - x1) / (y2 - y1)
|
||||
if crossing > x:
|
||||
inside = not inside
|
||||
return inside
|
||||
@@ -18,6 +18,13 @@ from pyproj import Transformer
|
||||
|
||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||||
from B05_wf2_Route.B05_wf2_Route_Engine_Drainage_Basin import build_drainage_detail
|
||||
from B05_wf2_Route.B05_wf2_Route_Engine_Drainage_Store import (
|
||||
apply_boundary_overrides,
|
||||
load_boundary_overrides,
|
||||
parse_overrides,
|
||||
resample_boundary,
|
||||
save_boundary_overrides,
|
||||
)
|
||||
from B05_wf2_Route.B05_wf2_Route_Repository import (
|
||||
get_latest_route,
|
||||
get_route_points,
|
||||
@@ -25,6 +32,7 @@ from B05_wf2_Route.B05_wf2_Route_Repository import (
|
||||
)
|
||||
from common_util.common_util_route_geometry import StructureCandidate, build_route_vertices
|
||||
from config.config_db import get_db_pool
|
||||
from config.config_system import DRAINAGE_BOUNDARY_HANDLE_SPACING_M
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/projects", tags=["B05 Route Drainage"])
|
||||
@@ -104,17 +112,34 @@ async def post_drainage_basins(
|
||||
)
|
||||
|
||||
to_lonlat = prepared["to_lonlat"]
|
||||
# 2차 전체 유역 외곽선 — 편집 핸들 간격으로 다시 찍고 저장된 편집분을 얹는다.
|
||||
# 격자 경계라 원래 정점이 1m 간격이라 그대로는 손으로 잡을 수 없다.
|
||||
boundary = resample_boundary(detail.basin_lonlat)
|
||||
stored_overrides = load_boundary_overrides(prepared["stored_path"])
|
||||
boundary, kept = apply_boundary_overrides(boundary, stored_overrides)
|
||||
dropped = len(stored_overrides) - len(kept)
|
||||
if dropped > 0:
|
||||
# 새 유역 안쪽으로 들어갔거나 붙일 자리가 없어진 편집분은 파일에서도 지운다.
|
||||
save_boundary_overrides(prepared["stored_path"], kept)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"project_id": str(project_id),
|
||||
"route_id": prepared["route_id"],
|
||||
# B04가 남긴 그대로 — 계획도로선과 2차 전체 배수유역 외곽선.
|
||||
# B04가 남긴 그대로 — 계획도로선. 외곽선만 편집분을 반영해 내보낸다.
|
||||
"route_lonlat": detail.route_lonlat,
|
||||
"main_polygon_lonlat": detail.basin_lonlat,
|
||||
"main_polygon_lonlat": boundary,
|
||||
"boundary_spacing_m": DRAINAGE_BOUNDARY_HANDLE_SPACING_M,
|
||||
"boundary_overrides": [
|
||||
{"base": list(item.base), "moved": list(item.moved)} for item in kept
|
||||
],
|
||||
"boundary_dropped": dropped,
|
||||
"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,
|
||||
# 계획선 위 배관 지점 — 유역이 없는 관도 마커로 표시해야 하므로 별도 목록.
|
||||
"pipes": [_candidate_payload(pipe, to_lonlat) for pipe in detail.pipes],
|
||||
"basins": [
|
||||
@@ -134,6 +159,29 @@ async def post_drainage_basins(
|
||||
}
|
||||
|
||||
|
||||
@router.put("/{project_id}/drainage/boundary", response_model=None)
|
||||
async def put_drainage_boundary(
|
||||
project_id: UUID,
|
||||
payload: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any] | JSONResponse:
|
||||
"""사용자가 옮긴 유역 외곽선 포인트만 저장한다(종단 경로 확정 시 모달 승인 후 호출).
|
||||
|
||||
폴리곤 전체가 아니라 이동한 포인트만 남긴다 — 노선이 바뀌어 유역을 다시 계산해도
|
||||
좌표 근접으로 다시 붙일 수 있어야 하기 때문이다.
|
||||
"""
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection:
|
||||
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||
if not stored_path:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"status": "error", "message": "프로젝트 저장 경로가 없습니다."},
|
||||
)
|
||||
overrides = parse_overrides((payload or {}).get("points"))
|
||||
saved = save_boundary_overrides(stored_path, overrides)
|
||||
return {"status": "success", "project_id": str(project_id), "saved": saved}
|
||||
|
||||
|
||||
def _parse_chainages(values: list[Any]) -> list[float]:
|
||||
"""사용자가 확정·편집한 누가거리 목록을 숫자로 정리한다."""
|
||||
parsed: list[float] = []
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import type { Normalizer, ViewState } from "../B04_wf1_Surface/B04_wf1_Surface_UI_MapRender";
|
||||
|
||||
/* =============================================================================
|
||||
* 2차 전체 배수유역 외곽선 편집 (B05 배수유역도)
|
||||
*
|
||||
* 백엔드가 외곽선을 일정 간격(기본 20m)으로 다시 찍어 주면, 그 점들을 잡아 끌어
|
||||
* 유역 경계를 손으로 고친다. 옮긴 값은 **화면에만** 들고 있다가 종단 경로 확정 시
|
||||
* 사용자가 승인하면 옮긴 포인트만 저장한다(2026-08-01 사용자 지시).
|
||||
*
|
||||
* 저장 대상은 폴리곤 전체가 아니라 `{원래 자리, 옮긴 자리}` 짝이다 — 노선이 바뀌어
|
||||
* 유역을 다시 계산하면 점 개수가 달라지므로 좌표 근접으로 다시 붙여야 하기 때문이다.
|
||||
* ========================================================================== */
|
||||
|
||||
/** 편집 핸들 반경(px)과 잡을 수 있는 여유. */
|
||||
const HANDLE_RADIUS_PX = 4;
|
||||
const HANDLE_HIT_PX = 9;
|
||||
const HANDLE_COLOR = "rgba(146, 64, 14, 0.95)";
|
||||
const HANDLE_MOVED_COLOR = "rgba(220, 38, 38, 0.95)";
|
||||
/** 같은 자리로 볼 오차(도). 대략 0.1m 수준. */
|
||||
const SAME_POINT_EPSILON = 1e-6;
|
||||
|
||||
export type LonLat = [number, number];
|
||||
|
||||
export interface BoundaryOverrideEntry {
|
||||
/** 재계산된 외곽선 위의 원래 자리. 다음 계산에서 이 좌표로 다시 붙인다. */
|
||||
base: LonLat;
|
||||
/** 사용자가 옮긴 자리. */
|
||||
moved: LonLat;
|
||||
}
|
||||
|
||||
export interface BoundaryEditor {
|
||||
/** 서버가 준 외곽선(편집 반영분)과 저장돼 있던 편집 목록을 싣는다. */
|
||||
setBoundary: (
|
||||
points: ReadonlyArray<LonLat>,
|
||||
overrides: ReadonlyArray<BoundaryOverrideEntry>,
|
||||
) => void;
|
||||
/** 현재 화면에 그릴 외곽선. */
|
||||
points: () => LonLat[];
|
||||
/** 저장 대상 — 원래 자리와 다른 포인트만. */
|
||||
overrides: () => BoundaryOverrideEntry[];
|
||||
/** 이번 화면에서 사용자가 옮긴 것이 있는지(저장 여부를 물어볼 근거). */
|
||||
isDirty: () => boolean;
|
||||
markSaved: () => void;
|
||||
setEditMode: (on: boolean) => void;
|
||||
/** 편집 모드에서 핸들을 그린다. 외곽선 자체는 패널이 능선 스타일로 그린다. */
|
||||
draw: (context: CanvasRenderingContext2D, normalizer: Normalizer, view: ViewState) => void;
|
||||
/** 핸들을 잡았으면 true — 지도 팬을 시작하지 않는다. */
|
||||
handleDown: (normalizer: Normalizer, view: ViewState, x: number, y: number) => boolean;
|
||||
/** 드래그 중이면 true. */
|
||||
handleMove: (normalizer: Normalizer, view: ViewState, x: number, y: number) => boolean;
|
||||
handleUp: () => void;
|
||||
}
|
||||
|
||||
type Affine = { ax: number; bx: number; ay: number; by: number };
|
||||
|
||||
/** 지도 렌더러와 같은 화면 변환. (MapRender 내부 계산과 동일 식) */
|
||||
function affineOf(view: ViewState): Affine {
|
||||
return {
|
||||
ax: view.mapRect.width * view.scale,
|
||||
bx: view.mapRect.x * view.scale + (view.width / 2) * (1 - view.scale) + view.offsetX,
|
||||
ay: view.mapRect.height * view.scale,
|
||||
by: view.mapRect.y * view.scale + (view.height / 2) * (1 - view.scale) + view.offsetY,
|
||||
};
|
||||
}
|
||||
|
||||
function toScreen(point: LonLat, normalizer: Normalizer, affine: Affine): [number, number] {
|
||||
const nx = (point[0] - normalizer.lonMin) / normalizer.lonRange;
|
||||
const ny = 1 - (point[1] - normalizer.latMin) / normalizer.latRange;
|
||||
return [nx * affine.ax + affine.bx, ny * affine.ay + affine.by];
|
||||
}
|
||||
|
||||
function toLonLat(x: number, y: number, normalizer: Normalizer, affine: Affine): LonLat | null {
|
||||
if (!(affine.ax > 0) || !(affine.ay > 0)) return null;
|
||||
const nx = (x - affine.bx) / affine.ax;
|
||||
const ny = (y - affine.by) / affine.ay;
|
||||
return [
|
||||
normalizer.lonMin + nx * normalizer.lonRange,
|
||||
normalizer.latMin + (1 - ny) * normalizer.latRange,
|
||||
];
|
||||
}
|
||||
|
||||
function samePoint(a: LonLat, b: LonLat): boolean {
|
||||
return Math.abs(a[0] - b[0]) < SAME_POINT_EPSILON && Math.abs(a[1] - b[1]) < SAME_POINT_EPSILON;
|
||||
}
|
||||
|
||||
export function createBoundaryEditor(onChange: () => void): BoundaryEditor {
|
||||
// 화면에 그리는 현재 외곽선.
|
||||
let points: LonLat[] = [];
|
||||
// 같은 인덱스의 "원래 자리". 저장분이 있는 핸들은 서버가 준 base를 그대로 쓴다.
|
||||
let bases: LonLat[] = [];
|
||||
let editMode = false;
|
||||
let dragIndex: number | null = null;
|
||||
let dirty = false;
|
||||
|
||||
function setBoundary(
|
||||
nextPoints: ReadonlyArray<LonLat>,
|
||||
nextOverrides: ReadonlyArray<BoundaryOverrideEntry>,
|
||||
): void {
|
||||
points = nextPoints.map((point) => [point[0], point[1]] as LonLat);
|
||||
bases = points.map((point) => [point[0], point[1]] as LonLat);
|
||||
// 저장분이 반영된 자리는 원래 자리를 서버 값으로 되돌려 둔다 — 다음 재계산에서
|
||||
// 옮긴 자리가 아니라 외곽선 위 원래 자리로 다시 붙어야 하기 때문이다.
|
||||
nextOverrides.forEach((override) => {
|
||||
const index = points.findIndex((point) => samePoint(point, override.moved));
|
||||
if (index >= 0) bases[index] = [override.base[0], override.base[1]];
|
||||
});
|
||||
dragIndex = null;
|
||||
dirty = false;
|
||||
}
|
||||
|
||||
function overrides(): BoundaryOverrideEntry[] {
|
||||
const list: BoundaryOverrideEntry[] = [];
|
||||
points.forEach((point, index) => {
|
||||
const base = bases[index];
|
||||
if (!base || samePoint(point, base)) return;
|
||||
list.push({ base: [base[0], base[1]], moved: [point[0], point[1]] });
|
||||
});
|
||||
return list;
|
||||
}
|
||||
|
||||
function draw(context: CanvasRenderingContext2D, normalizer: Normalizer, view: ViewState): void {
|
||||
if (!editMode || points.length === 0) return;
|
||||
const affine = affineOf(view);
|
||||
context.save();
|
||||
context.lineWidth = 1.2;
|
||||
context.strokeStyle = "rgba(255, 255, 255, 0.9)";
|
||||
points.forEach((point, index) => {
|
||||
const [x, y] = toScreen(point, normalizer, affine);
|
||||
if (x < -20 || y < -20 || x > view.width + 20 || y > view.height + 20) return;
|
||||
const base = bases[index];
|
||||
context.beginPath();
|
||||
context.arc(x, y, HANDLE_RADIUS_PX, 0, Math.PI * 2);
|
||||
context.fillStyle = base && !samePoint(point, base) ? HANDLE_MOVED_COLOR : HANDLE_COLOR;
|
||||
context.fill();
|
||||
context.stroke();
|
||||
});
|
||||
context.restore();
|
||||
}
|
||||
|
||||
function handleDown(normalizer: Normalizer, view: ViewState, x: number, y: number): boolean {
|
||||
if (!editMode || points.length === 0) return false;
|
||||
const affine = affineOf(view);
|
||||
let nearest = -1;
|
||||
let nearestDistance = HANDLE_HIT_PX;
|
||||
points.forEach((point, index) => {
|
||||
const [px, py] = toScreen(point, normalizer, affine);
|
||||
const distance = Math.hypot(px - x, py - y);
|
||||
if (distance <= nearestDistance) {
|
||||
nearest = index;
|
||||
nearestDistance = distance;
|
||||
}
|
||||
});
|
||||
if (nearest < 0) return false;
|
||||
dragIndex = nearest;
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleMove(normalizer: Normalizer, view: ViewState, x: number, y: number): boolean {
|
||||
if (dragIndex === null) return false;
|
||||
const moved = toLonLat(x, y, normalizer, affineOf(view));
|
||||
if (!moved) return true;
|
||||
points[dragIndex] = moved;
|
||||
dirty = true;
|
||||
onChange();
|
||||
return true;
|
||||
}
|
||||
|
||||
return {
|
||||
setBoundary,
|
||||
points: () => points.map((point) => [point[0], point[1]] as LonLat),
|
||||
overrides,
|
||||
isDirty: () => dirty,
|
||||
markSaved: () => {
|
||||
dirty = false;
|
||||
},
|
||||
setEditMode(on: boolean) {
|
||||
editMode = on;
|
||||
dragIndex = null;
|
||||
onChange();
|
||||
},
|
||||
draw,
|
||||
handleDown,
|
||||
handleMove,
|
||||
handleUp() {
|
||||
dragIndex = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
drawFilledRing,
|
||||
drawPreparedLayer,
|
||||
drawRidgeRing,
|
||||
drawUpstreamLines,
|
||||
prepareLayer,
|
||||
prepareMetricPolyline,
|
||||
type GeoJsonCollection,
|
||||
@@ -25,6 +26,10 @@ import {
|
||||
type RoutePoint,
|
||||
} from "./B05_wf2_Route_Api_Fetch";
|
||||
import { drawFlowArrows, type FlowArrow } from "../B04_wf1_Surface/B04_wf1_Surface_UI_FlowArrows";
|
||||
import {
|
||||
createBoundaryEditor,
|
||||
type BoundaryOverrideEntry,
|
||||
} from "./B05_wf2_Route_UI_Drainage_Boundary";
|
||||
import { createPipeEditor } from "./B05_wf2_Route_UI_Drainage_Pipes";
|
||||
|
||||
// 배수유역도 패널 — 하단 종단 패널 안쪽 우측에 도킹되는 2단 사이드 패널.
|
||||
@@ -47,6 +52,10 @@ const LAYER_LABELS: Record<DrainageLayer, string> = {
|
||||
도엽_하천중심선: "세류",
|
||||
};
|
||||
|
||||
/** 도엽 레이어가 아닌 표시 토글의 띠 색 — 지도에 그려지는 선 색과 맞춘다. */
|
||||
const ARROW_TOGGLE_COLOR = "#7c3aed";
|
||||
const UPSTREAM_TOGGLE_COLOR = "#1d4ed8";
|
||||
|
||||
const ROUTE_COLOR = "#f97316";
|
||||
const COLLAPSED_KEY = "b05-route-drainage-collapsed";
|
||||
|
||||
@@ -68,6 +77,12 @@ export interface DrainagePanel {
|
||||
load: (projectId: string) => void;
|
||||
/** 확정된 노선 평면 선형(사업지 좌표계 m)을 지도 위에 겹친다. */
|
||||
setRoute: (points: ReadonlyArray<RoutePoint>) => void;
|
||||
/** 이번 화면에서 사용자가 유역선 포인트를 옮겼는지(경로 확정 시 저장 여부를 묻는 근거). */
|
||||
hasBoundaryEdits: () => boolean;
|
||||
/** 저장 대상 — 원래 자리와 옮긴 자리 짝. 폴리곤 전체가 아니다. */
|
||||
boundaryOverrides: () => BoundaryOverrideEntry[];
|
||||
/** 저장이 끝났음을 알린다(다시 묻지 않도록). */
|
||||
markBoundarySaved: () => void;
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
@@ -110,20 +125,16 @@ export function createDrainagePanel(): DrainagePanel {
|
||||
autoButton.type = "button";
|
||||
autoButton.className = "b05-drainage__analyze b05-drainage__tool";
|
||||
autoButton.textContent = "자동 제안";
|
||||
// 흐름 화살표 보기/숨기기 — 도면이 지저분해질 때 끄기 위한 토글.
|
||||
const arrowButton = document.createElement("button");
|
||||
arrowButton.type = "button";
|
||||
arrowButton.className = "b05-drainage__analyze b05-drainage__tool is-active";
|
||||
arrowButton.textContent = "흐름 화살표";
|
||||
arrowButton.title = "B04에서 산출한 평균 흐름 방향을 보이거나 숨깁니다.";
|
||||
arrowButton.setAttribute("aria-pressed", "true");
|
||||
arrowButton.addEventListener("click", () => {
|
||||
showArrows = !showArrows;
|
||||
arrowButton.classList.toggle("is-active", showArrows);
|
||||
arrowButton.setAttribute("aria-pressed", String(showArrows));
|
||||
scheduleDraw();
|
||||
});
|
||||
header.append(analyzeButton, editButton, deleteButton, autoButton, arrowButton);
|
||||
// 유역선 편집 토글 — 켜면 외곽선 위 핸들을 잡아 유역 경계를 손으로 고친다.
|
||||
const boundaryButton = document.createElement("button");
|
||||
boundaryButton.type = "button";
|
||||
boundaryButton.className = "b05-drainage__analyze b05-drainage__tool";
|
||||
boundaryButton.textContent = "유역선 편집";
|
||||
boundaryButton.title =
|
||||
"2차 전체 배수유역 외곽선 위 포인트를 끌어 경계를 고칩니다. " +
|
||||
"옮긴 값은 종단 경로 확정 시 저장 여부를 묻습니다.";
|
||||
boundaryButton.setAttribute("aria-pressed", "false");
|
||||
header.append(analyzeButton, editButton, deleteButton, autoButton, boundaryButton);
|
||||
|
||||
const viewport = document.createElement("div");
|
||||
viewport.className = "b05-drainage__viewport";
|
||||
@@ -165,6 +176,11 @@ export function createDrainagePanel(): DrainagePanel {
|
||||
let flowArrows: FlowArrow[] = [];
|
||||
let arrowSpacingM = 0;
|
||||
let showArrows = true;
|
||||
// 유역 안쪽 상류 세류망 — B04가 채택한 기준선을 그대로 받아 강조만 한다.
|
||||
let upstreamLines: Array<Array<[number, number]>> = [];
|
||||
let showUpstream = true;
|
||||
let boundaryMode = false;
|
||||
const boundaryEditor = createBoundaryEditor(() => scheduleDraw());
|
||||
let scale = 1;
|
||||
let offsetX = 0;
|
||||
let offsetY = 0;
|
||||
@@ -175,23 +191,59 @@ export function createDrainagePanel(): DrainagePanel {
|
||||
let canvasHeight = 0;
|
||||
let canvasDpr = 0;
|
||||
|
||||
DRAINAGE_LAYERS.forEach((layer) => {
|
||||
/** 제목 우측 표시 토글 — 등고선·세류·흐름 화살표·상류 세류가 모두 같은 양식을 쓴다. */
|
||||
function addLayerToggle(
|
||||
label: string,
|
||||
color: string,
|
||||
initial: boolean,
|
||||
onToggle: (next: boolean) => void,
|
||||
title?: string,
|
||||
): HTMLButtonElement {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "b05-drainage__layer-button is-active";
|
||||
button.textContent = LAYER_LABELS[layer];
|
||||
button.style.setProperty("--b05-layer-color", LAYER_COLORS[layer]);
|
||||
button.setAttribute("aria-pressed", "true");
|
||||
button.className = "b05-drainage__layer-button" + (initial ? " is-active" : "");
|
||||
button.textContent = label;
|
||||
button.style.setProperty("--b05-layer-color", color);
|
||||
button.setAttribute("aria-pressed", String(initial));
|
||||
if (title) button.title = title;
|
||||
let active = initial;
|
||||
button.addEventListener("click", () => {
|
||||
if (activeLayers.has(layer)) activeLayers.delete(layer);
|
||||
else activeLayers.add(layer);
|
||||
const isActive = activeLayers.has(layer);
|
||||
button.classList.toggle("is-active", isActive);
|
||||
button.setAttribute("aria-pressed", String(isActive));
|
||||
active = !active;
|
||||
button.classList.toggle("is-active", active);
|
||||
button.setAttribute("aria-pressed", String(active));
|
||||
onToggle(active);
|
||||
scheduleDraw();
|
||||
});
|
||||
layerButtons.append(button);
|
||||
return button;
|
||||
}
|
||||
|
||||
DRAINAGE_LAYERS.forEach((layer) => {
|
||||
addLayerToggle(LAYER_LABELS[layer], LAYER_COLORS[layer], true, (next) => {
|
||||
if (next) activeLayers.add(layer);
|
||||
else activeLayers.delete(layer);
|
||||
});
|
||||
});
|
||||
// 흐름 화살표 — 도면이 지저분해질 때 끄기 위한 토글(등고선·세류와 같은 줄·같은 양식).
|
||||
addLayerToggle(
|
||||
"흐름 화살표",
|
||||
ARROW_TOGGLE_COLOR,
|
||||
showArrows,
|
||||
(next) => {
|
||||
showArrows = next;
|
||||
},
|
||||
"B04에서 산출한 평균 흐름 방향을 보이거나 숨깁니다.",
|
||||
);
|
||||
// 상류 세류선 강조 — 유역 판정의 기준선이라 항상 같은 굵기·색으로 얹는다.
|
||||
addLayerToggle(
|
||||
"상류 세류",
|
||||
UPSTREAM_TOGGLE_COLOR,
|
||||
showUpstream,
|
||||
(next) => {
|
||||
showUpstream = next;
|
||||
},
|
||||
"유역 안쪽 상류 세류망을 굵게 강조합니다.",
|
||||
);
|
||||
|
||||
function updateImageTransform(): void {
|
||||
backgroundImage.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${scale})`;
|
||||
@@ -232,7 +284,9 @@ export function createDrainagePanel(): DrainagePanel {
|
||||
);
|
||||
});
|
||||
// 전체 유역 외곽선 = 분수령(능선). 세부유역 경계와 구분되게 파선 한 겹만 얹는다.
|
||||
if (mainBoundary.length > 2) drawRidgeRing(context, mainBoundary, normalizer, view);
|
||||
// 편집 중이면 사용자가 끌어 옮긴 외곽선을 그대로 보여 준다.
|
||||
const boundary = boundaryEditor.points();
|
||||
if (boundary.length > 2) drawRidgeRing(context, boundary, normalizer, view);
|
||||
}
|
||||
// 등고선을 얇게 깔고 세류를 그 위에, 노선을 맨 위에 둔다.
|
||||
DRAINAGE_LAYERS.forEach((layer) => {
|
||||
@@ -243,6 +297,11 @@ export function createDrainagePanel(): DrainagePanel {
|
||||
context.strokeStyle = LAYER_COLORS[layer];
|
||||
drawPreparedLayer(context, prepared, view, "dot");
|
||||
});
|
||||
// 상류 세류선 강조 — 유역 채움 위, 흐름 화살표 아래(2026-08-01 사용자 지시).
|
||||
// 그리기는 B04 오버레이와 같은 공용 렌더러를 쓴다.
|
||||
if (showUpstream && normalizer && upstreamLines.length > 0) {
|
||||
drawUpstreamLines(context, upstreamLines, normalizer, view);
|
||||
}
|
||||
if (routeLayer) {
|
||||
context.lineWidth = 2.4;
|
||||
context.strokeStyle = ROUTE_COLOR;
|
||||
@@ -269,6 +328,8 @@ export function createDrainagePanel(): DrainagePanel {
|
||||
view,
|
||||
);
|
||||
}
|
||||
// 유역선 편집 핸들 — 편집 모드에서만. 화살표 위, 배관 마커 아래.
|
||||
if (normalizer) boundaryEditor.draw(context, normalizer, view);
|
||||
// 배관(관 매설) 마커 — 계획선 위 최상단.
|
||||
pipeEditor.draw(context, view, pipeColor);
|
||||
updateImageTransform();
|
||||
@@ -356,6 +417,9 @@ export function createDrainagePanel(): DrainagePanel {
|
||||
const response = await fetchDrainageBasins(projectId, chainages);
|
||||
basins = response.basins;
|
||||
mainBoundary = response.main_polygon_lonlat ?? [];
|
||||
// 외곽선은 편집 핸들 간격으로 다시 찍힌 값이며, 저장된 편집분은 이미 반영돼 있다.
|
||||
boundaryEditor.setBoundary(mainBoundary, response.boundary_overrides ?? []);
|
||||
upstreamLines = (response.upstream_lonlat ?? []) as Array<Array<[number, number]>>;
|
||||
flowArrows = (response.flow_arrows ?? []) as FlowArrow[];
|
||||
arrowSpacingM = response.arrow_spacing_m ?? 0;
|
||||
// 계획도로선·2차 유역 외곽선은 B04 산출물을 그대로 받는다 — 여기서 다시 계산하지 않는다.
|
||||
@@ -392,6 +456,12 @@ export function createDrainagePanel(): DrainagePanel {
|
||||
pipeEditor.setPipes([]);
|
||||
void analyze(true);
|
||||
});
|
||||
boundaryButton.addEventListener("click", () => {
|
||||
boundaryMode = !boundaryMode;
|
||||
boundaryButton.classList.toggle("is-active", boundaryMode);
|
||||
boundaryButton.setAttribute("aria-pressed", String(boundaryMode));
|
||||
boundaryEditor.setEditMode(boundaryMode);
|
||||
});
|
||||
|
||||
/** 노선 전체가 보이도록 배율·중심을 맞춘다. 노선이 없으면 도엽 전체를 그대로 보여준다. */
|
||||
function fitToRoute(): void {
|
||||
@@ -491,6 +561,19 @@ export function createDrainagePanel(): DrainagePanel {
|
||||
// 중간 버튼 기본 동작(페이지 자동 스크롤)이 지도 팬과 겹치지 않게 막는다.
|
||||
if (event.button === 1) event.preventDefault();
|
||||
const rect = viewport.getBoundingClientRect();
|
||||
// 유역선 편집 중이면 외곽선 핸들을 먼저 본다 — 잡았으면 지도 팬을 시작하지 않는다.
|
||||
if (
|
||||
normalizer &&
|
||||
boundaryEditor.handleDown(
|
||||
normalizer,
|
||||
currentView(),
|
||||
event.clientX - rect.left,
|
||||
event.clientY - rect.top,
|
||||
)
|
||||
) {
|
||||
viewport.setPointerCapture(event.pointerId);
|
||||
return;
|
||||
}
|
||||
// 배관 마커 클릭/추가가 처리되면 지도 팬은 시작하지 않는다.
|
||||
if (
|
||||
pipeEditor.handleDown(
|
||||
@@ -508,6 +591,17 @@ export function createDrainagePanel(): DrainagePanel {
|
||||
});
|
||||
viewport.addEventListener("pointermove", (event) => {
|
||||
const rect = viewport.getBoundingClientRect();
|
||||
// 외곽선 핸들을 끌고 있으면 그것만 처리한다.
|
||||
if (
|
||||
normalizer &&
|
||||
boundaryEditor.handleMove(
|
||||
normalizer,
|
||||
currentView(),
|
||||
event.clientX - rect.left,
|
||||
event.clientY - rect.top,
|
||||
)
|
||||
)
|
||||
return;
|
||||
// 배관 드래그 중이면 마커 이동(계획선 스냅)만 처리한다.
|
||||
if (pipeEditor.handleMove(currentView(), event.clientX - rect.left, event.clientY - rect.top))
|
||||
return;
|
||||
@@ -517,6 +611,7 @@ export function createDrainagePanel(): DrainagePanel {
|
||||
scheduleDraw();
|
||||
});
|
||||
const stopDragging = (): void => {
|
||||
boundaryEditor.handleUp();
|
||||
pipeEditor.handleUp();
|
||||
dragStart = null;
|
||||
};
|
||||
@@ -551,6 +646,9 @@ export function createDrainagePanel(): DrainagePanel {
|
||||
if (routeLayer) fitToRoute();
|
||||
scheduleDraw();
|
||||
},
|
||||
hasBoundaryEdits: boundaryEditor.isDirty,
|
||||
boundaryOverrides: boundaryEditor.overrides,
|
||||
markBoundarySaved: boundaryEditor.markSaved,
|
||||
dispose() {
|
||||
loadSequence += 1;
|
||||
if (frameHandle) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
|
||||
import { hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_elements";
|
||||
import { createProgressCircle } from "@ui/ui_template_progress";
|
||||
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
|
||||
import { workflowSteps } from "../A00_Common/b_page_scaffold";
|
||||
import {
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
import {
|
||||
confirmRoute,
|
||||
fetchLatestRoute,
|
||||
saveDrainageBoundary,
|
||||
solveRoute,
|
||||
updateContourInterval,
|
||||
type CirclePoint,
|
||||
@@ -540,8 +542,32 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** 유역선을 손으로 고쳤으면 확정 전에 저장 여부를 묻는다. 승인해야만 옮긴 포인트를 남긴다. */
|
||||
async function saveBoundaryEditsIfWanted(): Promise<void> {
|
||||
const drainage = profilePanel.drainage;
|
||||
if (!drainage.hasBoundaryEdits()) return;
|
||||
const overrides = drainage.boundaryOverrides();
|
||||
const accepted = window.confirm(
|
||||
`배수유역 외곽선에서 옮긴 포인트 ${overrides.length}개를 저장할까요?\n` +
|
||||
"저장하면 노선이 바뀌어 유역을 다시 계산해도 옮긴 자리가 유지됩니다.",
|
||||
);
|
||||
if (!accepted) return;
|
||||
try {
|
||||
await saveDrainageBoundary(activeProjectId, overrides);
|
||||
drainage.markBoundarySaved();
|
||||
showToast("배수유역 외곽선 편집을 저장했습니다.", "success");
|
||||
} catch (error) {
|
||||
// 유역선 저장 실패가 경로 확정 자체를 막지는 않는다.
|
||||
showToast(
|
||||
error instanceof Error ? error.message : "배수유역 외곽선 저장에 실패했습니다.",
|
||||
"error",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirm(): Promise<void> {
|
||||
if (!routeReady || stale) return;
|
||||
await saveBoundaryEditsIfWanted();
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
// 종단 계획선 편집은 화면에서만 계산해 두었으므로 확정 직전에 영속화한다.
|
||||
@@ -572,43 +598,24 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
const [workflowState, models, latestResponse, sectionContext, configuredRoadWidths] =
|
||||
await Promise.all([
|
||||
fetchWorkflowState(activeProjectId),
|
||||
listSurfaceModels(activeProjectId),
|
||||
// 세션 캐시 우선(응답속도) — 최초 진입/캐시 미스 시에만 DB(latest)를 읽는다.
|
||||
loadLatest(),
|
||||
fetchSectionContext(activeProjectId),
|
||||
fetchRoadWidths(activeProjectId),
|
||||
]);
|
||||
roadWidths = configuredRoadWidths;
|
||||
confirmedSurface = models.models.find((model) => model.status === "CONFIRMED") ?? null;
|
||||
if (!confirmedSurface) {
|
||||
showToast("확정된 지표면 모델이 없습니다.", "error");
|
||||
} else {
|
||||
const cloud = await fetchSurfacePointCloud(
|
||||
activeProjectId,
|
||||
latestResponse.surface_params.source_filter,
|
||||
);
|
||||
panel.restore({
|
||||
stationInterval: sectionContext.defaults.station_interval_m,
|
||||
crossSampleInterval: sectionContext.defaults.cross_sample_interval_m,
|
||||
longSampleInterval: sectionContext.defaults.long_sample_interval_m,
|
||||
});
|
||||
restorePanel(latestResponse);
|
||||
await viewer.loadSurface(
|
||||
activeProjectId,
|
||||
confirmedSurface.id,
|
||||
latestResponse.surface_params.method,
|
||||
latestResponse.surface_params.smooth,
|
||||
latestResponse.surface_params.contour_interval_m,
|
||||
toBounds(cloud.bounds),
|
||||
);
|
||||
renderLatest(latestResponse);
|
||||
if (latestResponse.route) await restoreSections(latestResponse.route.id);
|
||||
/* ── 진입 로딩 ─────────────────────────────────────────────────────────
|
||||
* 전부 받아 놓고 한 번에 그리면 몇 초 동안 빈 화면만 보인다. 화면 틀을 먼저 띄우고
|
||||
* 자료가 끝나는 순서대로 채운다. 3D 지형이 가장 느리므로 맨 마지막에 올리고, 그동안
|
||||
* 3D 뷰포트에 공통 프로그레스 서클을 띄운다(2026-08-01 사용자 지시). */
|
||||
const LOAD_STEP_COUNT = 5;
|
||||
const progress = createProgressCircle({ label: "화면 틀을 준비하는 중…" });
|
||||
// 하단 종단 패널(z-index 3)보다 아래에 둔다 — 패널을 볼 때 서클이 방해하지 않는다.
|
||||
progress.root.classList.add("b05-route__progress");
|
||||
viewer.root.append(progress.root);
|
||||
let loadedSteps = 0;
|
||||
function advanceLoading(label: string): void {
|
||||
loadedSteps += 1;
|
||||
progress.set(Math.min(1, loadedSteps / LOAD_STEP_COUNT), label);
|
||||
}
|
||||
latest = latestResponse;
|
||||
restoring = false;
|
||||
|
||||
// ① 워크플로우 상태 — 화면 틀(레이아웃·단계바)을 세우는 데 필요한 최소 자료.
|
||||
const workflowState = await fetchWorkflowState(activeProjectId);
|
||||
advanceLoading("노선 정보를 불러오는 중…");
|
||||
|
||||
const mainContent = document.createElement("div");
|
||||
mainContent.className = "b05-route__main";
|
||||
@@ -626,4 +633,60 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
|
||||
});
|
||||
layout.root.classList.add("b05-route-layout");
|
||||
root.replaceChildren(layout.root);
|
||||
|
||||
try {
|
||||
// ② 좌측 폼·노선 설정값 — 도착하는 대로 폼과 3D 마커 복원에 쓴다.
|
||||
const [latestResponse, sectionContext, configuredRoadWidths] = await Promise.all([
|
||||
// 세션 캐시 우선(응답속도) — 최초 진입/캐시 미스 시에만 DB(latest)를 읽는다.
|
||||
loadLatest(),
|
||||
fetchSectionContext(activeProjectId),
|
||||
fetchRoadWidths(activeProjectId),
|
||||
]);
|
||||
roadWidths = configuredRoadWidths;
|
||||
panel.restore({
|
||||
stationInterval: sectionContext.defaults.station_interval_m,
|
||||
crossSampleInterval: sectionContext.defaults.cross_sample_interval_m,
|
||||
longSampleInterval: sectionContext.defaults.long_sample_interval_m,
|
||||
});
|
||||
restorePanel(latestResponse);
|
||||
renderLatest(latestResponse);
|
||||
latest = latestResponse;
|
||||
advanceLoading("확정 지표면 모델을 확인하는 중…");
|
||||
|
||||
// ③ 확정 지표면 모델 목록.
|
||||
const models = await listSurfaceModels(activeProjectId);
|
||||
confirmedSurface = models.models.find((model) => model.status === "CONFIRMED") ?? null;
|
||||
advanceLoading("종단면 자료를 불러오는 중…");
|
||||
|
||||
// ④ 종단면·횡단 자료 — 하단 패널을 3D보다 먼저 채운다.
|
||||
if (latestResponse.route) await restoreSections(latestResponse.route.id);
|
||||
advanceLoading("3D 지형을 불러오는 중…");
|
||||
|
||||
// ⑤ 3D 지형 — 가장 무거우므로 맨 마지막.
|
||||
if (!confirmedSurface) {
|
||||
showToast("확정된 지표면 모델이 없습니다.", "error");
|
||||
} else {
|
||||
const cloud = await fetchSurfacePointCloud(
|
||||
activeProjectId,
|
||||
latestResponse.surface_params.source_filter,
|
||||
);
|
||||
await viewer.loadSurface(
|
||||
activeProjectId,
|
||||
confirmedSurface.id,
|
||||
latestResponse.surface_params.method,
|
||||
latestResponse.surface_params.smooth,
|
||||
latestResponse.surface_params.contour_interval_m,
|
||||
toBounds(cloud.bounds),
|
||||
);
|
||||
// 지형이 올라온 뒤에야 마커·측점선이 지형 위에 놓인다.
|
||||
renderLatest(latestResponse);
|
||||
if (currentSectionDetail) renderStationLines(currentSectionDetail);
|
||||
}
|
||||
advanceLoading("");
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : "화면을 불러오지 못했습니다.", "error");
|
||||
} finally {
|
||||
progress.remove();
|
||||
restoring = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -637,6 +637,8 @@ export function createRouteProfilePanel(
|
||||
balanceBar.replaceChildren();
|
||||
body.replaceChildren(empty);
|
||||
},
|
||||
/** 배수유역도 패널 — 경로 확정 흐름에서 유역선 편집 저장 여부를 묻는 데 쓴다. */
|
||||
drainage: drainagePanel,
|
||||
dispose() {
|
||||
window.clearTimeout(resizeTimer);
|
||||
resizeObserver.disconnect();
|
||||
|
||||
@@ -167,19 +167,31 @@
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* 3D 뷰포트 안내 문구 — 우상단 텍스트만(배경·테두리 없음, 회색). 2026-08-01 사용자 지시.
|
||||
우측 세로 진행단계 오버레이와 겹치지 않도록 그 폭만큼 안쪽으로 들여 놓는다. */
|
||||
.b05-route__viewer-status {
|
||||
position: absolute;
|
||||
inset: var(--spacing-16) auto auto var(--spacing-16);
|
||||
z-index: 2;
|
||||
top: var(--spacing-16);
|
||||
right: calc(var(--wf-left-panel-width) / 2 + var(--spacing-48));
|
||||
left: auto;
|
||||
max-width: 420px;
|
||||
padding: var(--spacing-8) var(--spacing-16);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-inputs);
|
||||
background: var(--color-surface-raised);
|
||||
color: var(--color-text-body);
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-caption);
|
||||
text-align: right;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 진입 로딩 서클 — 3D 뷰포트 위쪽 가운데. 하단 종단 패널(z-index 3)보다 아래에 둬서
|
||||
패널을 볼 때 가리지 않는다(2026-08-01 사용자 지시). */
|
||||
.b05-route__progress {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: 18%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.b05-route__view-controls {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
@@ -870,24 +882,29 @@
|
||||
|
||||
.b05-drainage__layers {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
/* 표시 토글 버튼 — B04 지도 레이어 버튼(.b04-map__layer-button--gis)과 같은 양식을 쓴다.
|
||||
크기(패딩·글자)만 이 패널 기준을 유지한다(2026-08-01 사용자 지시). */
|
||||
.b05-drainage__layer-button {
|
||||
padding: 2px var(--spacing-8);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-inputs);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-muted);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
opacity: 0.55;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 켜진 레이어는 그 레이어의 선 색을 그대로 띠 색으로 써서 지도와 바로 대조된다. */
|
||||
/* 켜진 레이어는 그 레이어의 선 색을 테두리·글자·안쪽 링에 그대로 쓴다(지도와 바로 대조). */
|
||||
.b05-drainage__layer-button.is-active {
|
||||
border-color: var(--b05-layer-color, var(--color-border));
|
||||
box-shadow: inset 3px 0 0 var(--b05-layer-color, transparent);
|
||||
color: var(--color-text-body);
|
||||
box-shadow: inset 0 0 0 1px var(--b05-layer-color, transparent);
|
||||
color: var(--b05-layer-color, var(--color-text-body));
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.b05-drainage__viewport {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
||||
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
||||
import { PLYLoader } from "three/examples/jsm/loaders/PLYLoader.js";
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { bindCursorPivotControls } from "../B04_wf1_Surface/B04_wf1_Surface_UI_Camera";
|
||||
import {
|
||||
createRouteMarkers,
|
||||
sceneToModel,
|
||||
@@ -112,6 +113,15 @@ export function createRouteViewer(): RouteViewer {
|
||||
let draggingMarker = false;
|
||||
let lastDragPoint: { x: number; y: number; z: number } | null = null;
|
||||
const markers = createRouteMarkers(scene, () => bounds);
|
||||
// 회전·줌 중심을 커서 아래 지형 지점으로 (B04 뷰어들과 공용 유틸).
|
||||
// 마커를 잡고 있는 동안에는 회전을 넘겨 드래그 이동이 우선하게 한다.
|
||||
const releaseCursorPivot = bindCursorPivotControls({
|
||||
camera,
|
||||
controls,
|
||||
element: canvas,
|
||||
pickables: () => (terrain ? [terrain] : []),
|
||||
blocked: () => dragCandidate !== null || draggingMarker || movingSelected,
|
||||
});
|
||||
|
||||
function clearContours(): void {
|
||||
disposeObject(contours);
|
||||
@@ -370,6 +380,7 @@ export function createRouteViewer(): RouteViewer {
|
||||
canvas.removeEventListener("pointerup", handlePointerUp, true);
|
||||
canvas.removeEventListener("pointerleave", handlePointerExit, true);
|
||||
canvas.removeEventListener("pointercancel", handlePointerExit, true);
|
||||
releaseCursorPivot();
|
||||
markers.dispose();
|
||||
clearContours();
|
||||
disposeObject(terrain);
|
||||
|
||||
@@ -288,6 +288,18 @@ DRAINAGE_CACHE_FILENAME = "watershed_grid.npz"
|
||||
# 저장 배열에서 응답을 다시 조립하면 원본과 어긋날 여지가 생긴다(2026-07-31 사용자 지시).
|
||||
DRAINAGE_RESPONSE_FILENAME = "00_watershed_response.json"
|
||||
|
||||
# ── B05 전용 배수유역 사본 ──
|
||||
# B04 산출물을 그대로 쓰면 B05 편집이 원본을 덮어쓴다. 프로젝트 저장소의
|
||||
# B05_wf2_Route/drainage/ 아래로 복사해 두고 B05는 사본만 읽고 쓴다.
|
||||
DRAINAGE_B05_DIRNAME = "drainage"
|
||||
# 사용자가 옮긴 유역 외곽선 포인트만 담는 파일. B04 재계산으로 사본이 갱신돼도 남는다.
|
||||
DRAINAGE_BOUNDARY_OVERRIDE_FILENAME = "boundary_overrides.json"
|
||||
# 유역 외곽선 편집 핸들 간격(m). 화면에서 보고 조정할 값(2026-08-01 사용자 지시).
|
||||
DRAINAGE_BOUNDARY_HANDLE_SPACING_M = float(os.getenv("DRAINAGE_BOUNDARY_HANDLE_SPACING_M", "20.0"))
|
||||
# 재계산된 외곽선에 저장 포인트를 다시 붙일 때 허용하는 최대 거리(m).
|
||||
# 인덱스는 재계산으로 어긋나므로 좌표 근접으로만 맞춘다.
|
||||
DRAINAGE_BOUNDARY_MATCH_RADIUS_M = float(os.getenv("DRAINAGE_BOUNDARY_MATCH_RADIUS_M", "30.0"))
|
||||
|
||||
# ── B05용 평균 흐름 화살표 ──
|
||||
# 셀 화살표는 1m라 축소하면 경향이 안 보인다. 이 크기의 블록으로 묶어 방향을 평균한다.
|
||||
DRAINAGE_ARROW_BLOCK_M = float(os.getenv("DRAINAGE_ARROW_BLOCK_M", "10.0"))
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/* 공통 프로그레스 서클 — 뷰포트 위에 얹는 원형 진행 표시. */
|
||||
.ui-progress-circle {
|
||||
--ui-progress-size: 96px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.ui-progress-circle__dial {
|
||||
position: relative;
|
||||
width: var(--ui-progress-size);
|
||||
height: var(--ui-progress-size);
|
||||
}
|
||||
|
||||
.ui-progress-circle__svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
/* 12시 방향에서 시계 방향으로 채운다. */
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.ui-progress-circle__track {
|
||||
fill: none;
|
||||
stroke: color-mix(in srgb, var(--color-border) 70%, transparent);
|
||||
stroke-width: 8;
|
||||
}
|
||||
|
||||
.ui-progress-circle__bar {
|
||||
fill: none;
|
||||
stroke: var(--color-royal-amethyst);
|
||||
stroke-width: 8;
|
||||
stroke-linecap: round;
|
||||
transition: stroke-dashoffset var(--transition-base);
|
||||
}
|
||||
|
||||
/* 진행률을 모르는 구간 — 호 하나를 계속 돌린다. */
|
||||
.ui-progress-circle.is-indeterminate .ui-progress-circle__svg {
|
||||
animation: ui-progress-spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes ui-progress-spin {
|
||||
from {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(270deg);
|
||||
}
|
||||
}
|
||||
|
||||
.ui-progress-circle__percent {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-text-body);
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-body-sm);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.ui-progress-circle__label {
|
||||
max-width: 22ch;
|
||||
padding: var(--spacing-4) var(--spacing-8);
|
||||
border-radius: var(--radius-inputs);
|
||||
background: color-mix(in srgb, var(--color-surface-raised) 82%, transparent);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
text-align: center;
|
||||
word-break: keep-all;
|
||||
}
|
||||
|
||||
.ui-progress-circle__label:empty {
|
||||
display: none;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import "./ui_template_progress.css";
|
||||
|
||||
/* =============================================================================
|
||||
* ui_template_progress.ts
|
||||
* 공통 프로그레스 서클 (원형 진행 표시)
|
||||
*
|
||||
* 뷰포트 위에 얹어 "지금 무엇을 얼마나 불러왔는지"를 보여 주는 용도.
|
||||
* 진행률을 모르는 구간은 set()에 ratio를 주지 않으면 회전 애니메이션으로 표시한다.
|
||||
* 색상/치수는 theme.css 변수만 사용한다.
|
||||
* ========================================================================== */
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
/** viewBox 기준 반지름 — 실제 크기는 CSS(--ui-progress-size)로 정한다. */
|
||||
const RADIUS = 42;
|
||||
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
|
||||
|
||||
export interface ProgressCircleOptions {
|
||||
/** 지름(px). 기본 96. */
|
||||
size?: number;
|
||||
/** 서클 아래 안내 문구. */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface ProgressCircleHandle {
|
||||
root: HTMLDivElement;
|
||||
/** 진행률(0~1)과 문구 갱신. ratio가 null이면 진행률 미상(회전 표시). */
|
||||
set: (ratio: number | null, label?: string) => void;
|
||||
/** 화면에서 제거. */
|
||||
remove: () => void;
|
||||
}
|
||||
|
||||
export function createProgressCircle(options: ProgressCircleOptions = {}): ProgressCircleHandle {
|
||||
const root = document.createElement("div");
|
||||
root.className = "ui-progress-circle";
|
||||
root.setAttribute("role", "status");
|
||||
root.setAttribute("aria-live", "polite");
|
||||
if (options.size) root.style.setProperty("--ui-progress-size", `${options.size}px`);
|
||||
|
||||
const svg = document.createElementNS(SVG_NS, "svg");
|
||||
svg.setAttribute("class", "ui-progress-circle__svg");
|
||||
svg.setAttribute("viewBox", "0 0 100 100");
|
||||
const track = document.createElementNS(SVG_NS, "circle");
|
||||
track.setAttribute("class", "ui-progress-circle__track");
|
||||
track.setAttribute("cx", "50");
|
||||
track.setAttribute("cy", "50");
|
||||
track.setAttribute("r", String(RADIUS));
|
||||
const bar = document.createElementNS(SVG_NS, "circle");
|
||||
bar.setAttribute("class", "ui-progress-circle__bar");
|
||||
bar.setAttribute("cx", "50");
|
||||
bar.setAttribute("cy", "50");
|
||||
bar.setAttribute("r", String(RADIUS));
|
||||
bar.setAttribute("stroke-dasharray", String(CIRCUMFERENCE));
|
||||
bar.setAttribute("stroke-dashoffset", String(CIRCUMFERENCE));
|
||||
svg.append(track, bar);
|
||||
|
||||
const percent = document.createElement("span");
|
||||
percent.className = "ui-progress-circle__percent";
|
||||
const label = document.createElement("span");
|
||||
label.className = "ui-progress-circle__label";
|
||||
label.textContent = options.label ?? "";
|
||||
|
||||
const dial = document.createElement("div");
|
||||
dial.className = "ui-progress-circle__dial";
|
||||
dial.append(svg, percent);
|
||||
root.append(dial, label);
|
||||
|
||||
function set(ratio: number | null, nextLabel?: string): void {
|
||||
if (nextLabel !== undefined) label.textContent = nextLabel;
|
||||
if (ratio === null) {
|
||||
// 진행률 미상 — 4분의 1 호를 돌려 "돌아가는 중"만 알린다.
|
||||
root.classList.add("is-indeterminate");
|
||||
bar.setAttribute("stroke-dashoffset", String(CIRCUMFERENCE * 0.75));
|
||||
percent.textContent = "";
|
||||
return;
|
||||
}
|
||||
const clamped = Math.min(1, Math.max(0, ratio));
|
||||
root.classList.remove("is-indeterminate");
|
||||
bar.setAttribute("stroke-dashoffset", String(CIRCUMFERENCE * (1 - clamped)));
|
||||
percent.textContent = `${Math.round(clamped * 100)}%`;
|
||||
}
|
||||
|
||||
set(0, options.label);
|
||||
|
||||
return {
|
||||
root,
|
||||
set,
|
||||
remove: () => root.remove(),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user