auto: 2026-08-01 08:55 (EOMSANGDON-HOME)

This commit is contained in:
2026-08-01 08:55:01 +09:00
parent 4aa211cead
commit 4501f3b19e
20 changed files with 1205 additions and 105 deletions
@@ -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) => {
+22 -16
View File
@@ -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();
+11 -10
View File
@@ -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에도 같이 쓰는 평균 흐름 화살표. 그리기는 공용 렌더러에 맡긴다. */
+20 -1
View File
@@ -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
+50 -2
View File
@@ -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;
},
};
}
+123 -25
View File
@@ -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) {
+99 -36
View File
@@ -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();
+27 -10
View File
@@ -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 {
+11
View File
@@ -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);
+12
View File
@@ -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"))
+78
View File
@@ -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;
}
+89
View File
@@ -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(),
};
}