From 4501f3b19e7e06bcd4ac5a22db86bce696594e91 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 1 Aug 2026 08:55:01 +0900 Subject: [PATCH] auto: 2026-08-01 08:55 (EOMSANGDON-HOME) --- B04_wf1_Surface/B04_wf1_Surface_UI_Camera.ts | 125 +++++++++ .../B04_wf1_Surface_UI_MapRender.ts | 34 +++ .../B04_wf1_Surface_UI_MapViewer.ts | 11 +- B04_wf1_Surface/B04_wf1_Surface_UI_Style.css | 38 +-- .../B04_wf1_Surface_UI_TerrainViewer.ts | 10 + B04_wf1_Surface/B04_wf1_Surface_UI_Viewer.ts | 9 + .../B04_wf1_Surface_UI_Watershed.ts | 21 +- B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts | 21 +- .../B05_wf2_Route_Engine_Drainage_Basin.py | 44 +++- .../B05_wf2_Route_Engine_Drainage_Store.py | 245 ++++++++++++++++++ .../B05_wf2_Route_Router_Drainage.py | 52 +++- .../B05_wf2_Route_UI_Drainage_Boundary.ts | 188 ++++++++++++++ .../B05_wf2_Route_UI_Drainage_Panel.ts | 148 +++++++++-- B05_wf2_Route/B05_wf2_Route_UI_Page.ts | 135 +++++++--- .../B05_wf2_Route_UI_Profile_Panel.ts | 2 + B05_wf2_Route/B05_wf2_Route_UI_Style.css | 37 ++- B05_wf2_Route/B05_wf2_Route_UI_Viewer.ts | 11 + config/config_system.py | 12 + ui_template/ui_template_progress.css | 78 ++++++ ui_template/ui_template_progress.ts | 89 +++++++ 20 files changed, 1205 insertions(+), 105 deletions(-) create mode 100644 B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Store.py create mode 100644 B05_wf2_Route/B05_wf2_Route_UI_Drainage_Boundary.ts create mode 100644 ui_template/ui_template_progress.css create mode 100644 ui_template/ui_template_progress.ts diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_Camera.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_Camera.ts index 7b359fd7..b7a5eb0e 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_Camera.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_Camera.ts @@ -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 { diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_MapRender.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_MapRender.ts index ad2a9efe..077f95de 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_MapRender.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_MapRender.ts @@ -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>, + 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, diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts index 01ee066e..c23319a8 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts @@ -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) => { diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_Style.css b/B04_wf1_Surface/B04_wf1_Surface_UI_Style.css index 4261ba7a..d8036e11 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_Style.css +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_Style.css @@ -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] { diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts index 87a7f162..b5cde486 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts @@ -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(); diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_Viewer.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_Viewer.ts index a9ad9012..ae2c5c87 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_Viewer.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_Viewer.ts @@ -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(); diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_Watershed.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_Watershed.ts index 7bceeb65..fe074a60 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_Watershed.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_Watershed.ts @@ -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 = { - 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에도 같이 쓰는 평균 흐름 화살표. 그리기는 공용 렌더러에 맡긴다. */ diff --git a/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts b/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts index 30a3154a..e06feb17 100644 --- a/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts +++ b/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts @@ -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>; /** 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 }) }, + ); +} diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Basin.py b/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Basin.py index 50f5b630..4e0dbaef 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Basin.py +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Basin.py @@ -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 + + # ── ⑨ 관 최소 개수 보충 ───────────────────────────────────────────────────── diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Store.py b/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Store.py new file mode 100644 index 00000000..1ee4e508 --- /dev/null +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Store.py @@ -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 diff --git a/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py b/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py index 1b99378e..72e52e1e 100644 --- a/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py +++ b/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py @@ -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] = [] diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Boundary.ts b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Boundary.ts new file mode 100644 index 00000000..747930a3 --- /dev/null +++ b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Boundary.ts @@ -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, + overrides: ReadonlyArray, + ) => 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, + nextOverrides: ReadonlyArray, + ): 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; + }, + }; +} diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts index 4197183a..84187701 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts @@ -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 = { 도엽_하천중심선: "세류", }; +/** 도엽 레이어가 아닌 표시 토글의 띠 색 — 지도에 그려지는 선 색과 맞춘다. */ +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) => 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> = []; + 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>; 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) { diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Page.ts b/B05_wf2_Route/B05_wf2_Route_UI_Page.ts index 3f1ed77b..c83c7a60 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Page.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Page.ts @@ -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 { } } + /** 유역선을 손으로 고쳤으면 확정 전에 저장 여부를 묻는다. 승인해야만 옮긴 포인트를 남긴다. */ + async function saveBoundaryEditsIfWanted(): Promise { + 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 { if (!routeReady || stale) return; + await saveBoundaryEditsIfWanted(); showLoadingOverlay(); try { // 종단 계획선 편집은 화면에서만 계산해 두었으므로 확정 직전에 영속화한다. @@ -572,43 +598,24 @@ export async function renderB05Route(root: HTMLElement): Promise { } } - 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 { }); 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; + } } diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts index ac71c7b2..58f426c4 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts @@ -637,6 +637,8 @@ export function createRouteProfilePanel( balanceBar.replaceChildren(); body.replaceChildren(empty); }, + /** 배수유역도 패널 — 경로 확정 흐름에서 유역선 편집 저장 여부를 묻는 데 쓴다. */ + drainage: drainagePanel, dispose() { window.clearTimeout(resizeTimer); resizeObserver.disconnect(); diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Style.css b/B05_wf2_Route/B05_wf2_Route_UI_Style.css index 85d09e4d..9c066216 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Style.css +++ b/B05_wf2_Route/B05_wf2_Route_UI_Style.css @@ -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 { diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Viewer.ts b/B05_wf2_Route/B05_wf2_Route_UI_Viewer.ts index 5f3f5f47..06f00775 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Viewer.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Viewer.ts @@ -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); diff --git a/config/config_system.py b/config/config_system.py index 9679bc92..acba027c 100644 --- a/config/config_system.py +++ b/config/config_system.py @@ -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")) diff --git a/ui_template/ui_template_progress.css b/ui_template/ui_template_progress.css new file mode 100644 index 00000000..29f463bf --- /dev/null +++ b/ui_template/ui_template_progress.css @@ -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; +} diff --git a/ui_template/ui_template_progress.ts b/ui_template/ui_template_progress.ts new file mode 100644 index 00000000..b133fed5 --- /dev/null +++ b/ui_template/ui_template_progress.ts @@ -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(), + }; +}