merge: feature/B07-상세설계-기능-개발 → main (B04/B05 지도·배수유역 개선, B11 자료 준비 화면, 3D 조작감·성능 개선)

This commit is contained in:
2026-08-01 13:17:47 +09:00
73 changed files with 10254 additions and 515 deletions
+327
View File
@@ -0,0 +1,327 @@
/* =============================================================================
* 3D 자료 브라우저 보관함 (IndexedDB)
*
* 지표면 3D 파일과 등고선은 한 번 만들면 잘 바뀌지 않는데 용량이 크다. 매번 새로 받으면
* 페이지를 열 때마다 기다려야 하므로, 받은 것을 브라우저에 저장해 두고 다음부터는 그것을
* 곧바로 화면에 올린다. 저장본을 쓰는 동시에 뒤에서 서버에 "바뀐 것 있나"만 물어보고,
* 바뀌었으면 새로 받아 갱신한다(서버의 ETag 사용).
*
* 프로젝트가 바뀌면 이전 프로젝트 자료는 지운다 — 다른 프로젝트 데이터가 섞이면 안 된다.
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { fetchConfirmedSurface } from "../B04_wf1_Surface/B04_wf1_Surface_Api_Fetch";
const DB_NAME = "aislo-asset-cache";
// 담아 둔 자료의 형식이나 내용이 바뀌면 이 번호를 올린다 — 올리면 기존 보관분을 통째로 버린다.
// v2: 도엽 표시용 사본(잘라낸 자료)을 철회했다. 그 사본을 담고 있던 브라우저는 비워야 한다.
const DB_VERSION = 2;
const STORE = "assets";
export interface CachedAsset {
/** `${projectId}|${url}` */
key: string;
projectId: string;
url: string;
etag: string | null;
savedAt: number;
body: ArrayBuffer;
}
let dbPromise: Promise<IDBDatabase | null> | null = null;
function openDatabase(): Promise<IDBDatabase | null> {
if (dbPromise) return dbPromise;
dbPromise = new Promise((resolve) => {
if (!("indexedDB" in window)) {
resolve(null);
return;
}
const request = window.indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = () => {
const db = request.result;
// 번호가 올라가면 옛 보관분은 형식이나 내용이 다를 수 있으므로 통째로 버리고 새로 만든다.
if (db.objectStoreNames.contains(STORE)) db.deleteObjectStore(STORE);
const store = db.createObjectStore(STORE, { keyPath: "key" });
store.createIndex("projectId", "projectId", { unique: false });
};
request.onsuccess = () => resolve(request.result);
// 사생활 보호 모드 등으로 열리지 않으면 보관함 없이 동작한다(항상 새로 받는다).
request.onerror = () => resolve(null);
});
return dbPromise;
}
function runTransaction<T>(
mode: IDBTransactionMode,
work: (store: IDBObjectStore) => IDBRequest<T>,
): Promise<T | null> {
return openDatabase().then(
(db) =>
new Promise<T | null>((resolve) => {
if (!db) {
resolve(null);
return;
}
try {
const transaction = db.transaction(STORE, mode);
const request = work(transaction.objectStore(STORE));
request.onsuccess = () => resolve(request.result ?? null);
request.onerror = () => resolve(null);
} catch {
resolve(null);
}
}),
);
}
const cacheKey = (projectId: string, url: string): string => `${projectId}|${url}`;
async function readAsset(projectId: string, url: string): Promise<CachedAsset | null> {
return (await runTransaction<CachedAsset>("readonly", (store) =>
store.get(cacheKey(projectId, url)),
)) as CachedAsset | null;
}
async function writeAsset(asset: CachedAsset): Promise<void> {
await runTransaction("readwrite", (store) => store.put(asset) as IDBRequest<unknown>);
}
/** 다른 프로젝트 자료를 모두 지운다. B그룹 페이지에 들어올 때 호출한다. */
export async function purgeOtherProjects(projectId: string): Promise<void> {
const db = await openDatabase();
if (!db) return;
await new Promise<void>((resolve) => {
try {
const transaction = db.transaction(STORE, "readwrite");
const store = transaction.objectStore(STORE);
const cursorRequest = store.openCursor();
cursorRequest.onsuccess = () => {
const cursor = cursorRequest.result;
if (!cursor) return;
const value = cursor.value as CachedAsset;
if (value.projectId !== projectId) cursor.delete();
cursor.continue();
};
transaction.oncomplete = () => resolve();
transaction.onerror = () => resolve();
} catch {
resolve();
}
});
}
export interface CachedFetchOptions {
/** 내려받는 동안 진행률(0~1, 모르면 null)을 알려준다. 저장본을 쓰면 호출되지 않는다. */
onProgress?: (ratio: number | null) => void;
}
/** 네트워크에서 받아 보관함에 저장한다. */
async function downloadAndStore(
projectId: string,
url: string,
options: CachedFetchOptions,
): Promise<ArrayBuffer> {
const response = await fetch(url, { credentials: "include" });
if (!response.ok) throw new Error(`요청 실패: ${response.status}`);
const total = Number(response.headers.get("content-length") ?? 0);
const etag = response.headers.get("etag");
let body: ArrayBuffer;
if (response.body && options.onProgress) {
// 진행률을 보여 주기 위해 조각으로 읽는다.
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let received = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
received += value.length;
options.onProgress(total > 0 ? received / total : null);
}
const merged = new Uint8Array(received);
let offset = 0;
chunks.forEach((chunk) => {
merged.set(chunk, offset);
offset += chunk.length;
});
body = merged.buffer;
} else {
body = await response.arrayBuffer();
}
await writeAsset({
key: cacheKey(projectId, url),
projectId,
url,
etag,
savedAt: Date.now(),
body,
});
return body;
}
/** 저장본이 최신인지 뒤에서 확인하고, 바뀌었으면 새로 받아 저장한다. */
function revalidateInBackground(projectId: string, url: string, etag: string | null): void {
if (!etag) return;
void fetch(url, { credentials: "include", headers: { "If-None-Match": etag } })
.then(async (response) => {
if (response.status === 304 || !response.ok) return;
const body = await response.arrayBuffer();
await writeAsset({
key: cacheKey(projectId, url),
projectId,
url,
etag: response.headers.get("etag"),
savedAt: Date.now(),
body,
});
})
.catch(() => {
/* 오프라인 등으로 확인하지 못해도 저장본을 계속 쓴다. */
});
}
/** 저장본이 있으면 즉시 돌려주고 뒤에서 갱신 확인, 없으면 받아서 저장한 뒤 돌려준다. */
export async function fetchCachedBytes(
projectId: string,
url: string,
options: CachedFetchOptions = {},
): Promise<ArrayBuffer> {
const cached = await readAsset(projectId, url);
if (cached?.body) {
revalidateInBackground(projectId, url, cached.etag);
return cached.body;
}
return downloadAndStore(projectId, url, options);
}
/** JSON 자료용. 저장본을 쓰면 파싱만 하고 네트워크를 타지 않는다. */
export async function fetchCachedJson<T>(
projectId: string,
url: string,
options: CachedFetchOptions = {},
): Promise<T> {
const bytes = await fetchCachedBytes(projectId, url, options);
return JSON.parse(new TextDecoder().decode(bytes)) as T;
}
/** 배수유역도 배경으로 쓰는 도엽 레이어(유일한 정의처 — 준비화면과 B05 패널이 함께 쓴다). */
export const DRAINAGE_SHEET_LAYERS = ["도엽_등고선", "도엽_하천중심선"] as const;
/** 도엽 레이어(GeoJSON)를 보관함에서 먼저 찾는다.
*
* 서버는 프로젝트 주변만 잘라 좌표 자릿수를 줄인 표시용 사본을 ETag와 함께 내보낸다.
* 분석용 원본과는 별개 파일이므로, 담아 두었다가 그대로 다시 써도 화면이 어긋나지 않는다. */
export async function fetchCachedSheetLayer<T>(projectId: string, layer: string): Promise<T> {
return fetchCachedJson<T>(
projectId,
`${API_BASE_URL}/projects/${projectId}/geojson?layer=${encodeURIComponent(layer)}`,
);
}
/* ── 준비 화면 연동 ────────────────────────────────────────────────────────
* 무엇을 이미 준비했는지, 준비가 끝나면 어느 화면으로 보낼지를 탭 단위로 기억한다.
* 대시보드로 나갔다가 같은 프로젝트로 돌아오면 준비 화면을 건너뛴다(2026-08-01 사용자 지시).
*
* 표식은 프로젝트 번호가 아니라 **확정 구성(signature)** 이다. 관리자가 B04에서 다른
* 필터·표현으로 다시 확정하면 표식이 달라져 준비 화면이 한 번 더 돌고 새 자료를 담는다.
* 프로젝트 번호만 봤다면 옛 자료를 계속 쓰게 된다(2026-08-01 사용자 지시). */
const PRELOADED_SIGNATURE_KEY = "frd_preloaded_signature";
const PRELOAD_TARGET_KEY = "frd_preload_target";
const preloadStamp = (projectId: string, signature: string): string => `${projectId}|${signature}`;
export function isProjectPreloaded(projectId: string, signature: string): boolean {
try {
return (
window.sessionStorage.getItem(PRELOADED_SIGNATURE_KEY) === preloadStamp(projectId, signature)
);
} catch {
return false;
}
}
export function markProjectPreloaded(projectId: string, signature: string): void {
try {
window.sessionStorage.setItem(PRELOADED_SIGNATURE_KEY, preloadStamp(projectId, signature));
} catch {
/* 세션 저장 실패는 준비 화면이 한 번 더 뜨는 정도의 영향뿐이다. */
}
}
/** 담아 둔 표식을 지운다 — 확정이 바뀌어 자료를 다시 담아야 할 때 호출한다. */
export function clearPreloadMark(): void {
try {
window.sessionStorage.removeItem(PRELOADED_SIGNATURE_KEY);
} catch {
/* 지우지 못해도 다음 표식 비교에서 불일치로 걸러진다. */
}
}
export function setPreloadTarget(route: string): void {
try {
window.sessionStorage.setItem(PRELOAD_TARGET_KEY, route);
} catch {
/* 저장 실패 시 준비 화면이 기본 화면으로 보낸다. */
}
}
export function readPreloadTarget(): string | null {
try {
return window.sessionStorage.getItem(PRELOAD_TARGET_KEY);
} catch {
return null;
}
}
/** 준비 화면이 표시할 단계 안내. ratio가 null이면 진행률을 모른다는 뜻이다. */
export type PreloadReporter = (label: string, ratio: number | null) => void;
/** 확정된 지표면의 3D 파일과 그 등고선을 보관함에 채운다(준비 화면에서 호출).
*
* 사용자가 실제로 보는 것은 이 둘이라 이것만 챙긴다 — 포인트클라우드·배수유역은 제외
* (2026-08-01 사용자 지시). 이미 보관돼 있으면 거의 즉시 끝난다.
* 평활 여부·등고선 간격은 짐작하지 않고 확정 저장값을 그대로 쓴다 — 짐작하면 B04·B05가
* 서로 다른 파일을 받아 같은 지형을 두 번 내려받게 된다.
* 확정 지표면을 찾지 못하면 오류를 던져 준비 화면이 안내 문구를 띄우게 한다.
* 반환값은 담아 둔 구성의 signature — 호출측이 준비 표식으로 저장한다. */
export async function preloadSurfaceAssets(
projectId: string,
report: PreloadReporter = () => {},
): Promise<string> {
report("확정된 지표면을 확인하는 중…", null);
const confirmed = await fetchConfirmedSurface(projectId);
if (!confirmed.model_id) throw new Error("확정된 지표면 모델이 없습니다.");
const smooth = confirmed.smooth ?? false;
const interval = confirmed.contour_interval_m ?? 1.0;
const base = `${API_BASE_URL}/projects/${projectId}/surface/models/${confirmed.model_id}`;
report("3D 지표면을 준비하는 중…", 0);
await fetchCachedBytes(projectId, `${base}/preview?smooth=${smooth}`, {
onProgress: (ratio) => report("3D 지표면을 준비하는 중…", ratio),
});
report("등고선을 준비하는 중…", null);
await fetchCachedBytes(
projectId,
`${base}/contour?interval=${interval}&smooth=${smooth}&recalculate=false`,
{ onProgress: (ratio) => report("등고선을 준비하는 중…", ratio) },
);
// 배수유역도 배경(도엽 표시본·위성사진)도 함께 담는다 — 없으면 B05가 진입할 때마다 받는다.
// 이 자료가 없어도 화면은 뜨므로 실패해도 준비를 멈추지 않는다.
report("배경 지도를 준비하는 중…", null);
await Promise.all(
DRAINAGE_SHEET_LAYERS.map((layer) => fetchCachedSheetLayer(projectId, layer).catch(() => null)),
);
// 위성사진은 <img>로 표시하므로 보관함이 아니라 브라우저 자체 캐시를 데워 둔다.
await fetch(`${API_BASE_URL}/projects/${projectId}/vworld-map?layer_name=satellite`, {
credentials: "include",
}).catch(() => null);
report("준비 완료", 1);
return confirmed.signature;
}
+26 -1
View File
@@ -5,6 +5,8 @@ import {
type RoutePath,
} from "@config/config_frontend";
import type { WorkflowStage } from "@ui/ui_template_workflow_layout";
import { fetchConfirmedSurface } from "../B04_wf1_Surface/B04_wf1_Surface_Api_Fetch";
import { isProjectPreloaded, setPreloadTarget } from "./b_asset_cache";
import { navigateTo } from "./router";
export interface WorkflowState {
@@ -36,5 +38,28 @@ export async function fetchWorkflowState(projectId: string): Promise<WorkflowSta
export function goToWorkflowStage(projectId: string, route: RoutePath): void {
localStorage.setItem(CURRENT_PROJECT_ID_KEY, projectId);
navigateTo(route);
void routeAfterPreloadCheck(projectId, route);
}
/**
* 담아 둔 자료가 지금 확정본과 같은지 확인하고 화면을 정한다.
*
* 확정 구성을 묻는 요청은 수 KB라 이동할 때마다 물어도 부담이 없다. 담아 둔 표식과 다르면
* (관리자가 B04에서 다시 확정했거나, 새 브라우저이거나, 다른 프로젝트를 들렀다 온 경우)
* 준비 화면(B11)을 거쳐 최신 자료를 담고 원래 가려던 화면으로 넘어간다(2026-08-01 사용자 지시).
* 확인에 실패하면 준비 화면으로 보내 거기서 사유를 안내한다.
*/
async function routeAfterPreloadCheck(projectId: string, route: RoutePath): Promise<void> {
let signature: string | null = null;
try {
signature = (await fetchConfirmedSurface(projectId)).signature;
} catch {
signature = null;
}
if (signature && isProjectPreloaded(projectId, signature)) {
navigateTo(route);
return;
}
setPreloadTarget(route);
navigateTo(ROUTES.B11_LOADING);
}
+3
View File
@@ -55,6 +55,8 @@ const routeTable: Partial<Record<RoutePath, () => Promise<PageRenderer>>> = {
(await import("../B10_Payment/B10_Payment_UI_Page")).renderB10Payment,
[ROUTES.B11_STATUS]: async () =>
(await import("../B11_Status/B11_Status_UI_Page")).renderB11Status,
[ROUTES.B11_LOADING]: async () =>
(await import("../B11_Status/B11_Status_UI_Loading")).renderB11Loading,
};
/** 로그인 여부 (토큰 존재 확인) */
@@ -112,6 +114,7 @@ export async function renderCurrentRoute(outlet: HTMLElement): Promise<void> {
ROUTES.B09_WF6_ESTIMATION,
ROUTES.B10_PAYMENT,
ROUTES.B11_STATUS,
ROUTES.B11_LOADING,
];
if (workflowRoutes.includes(route)) {
+6 -1
View File
@@ -120,12 +120,17 @@ export async function finalizeUploadSession(
projectId: string,
sessionId: string,
totalChunks: number,
completeUpload: boolean,
): Promise<FileUploadResponse> {
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/finalize`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session_id: sessionId, total_chunks: totalChunks }),
body: JSON.stringify({
session_id: sessionId,
total_chunks: totalChunks,
complete_upload: completeUpload,
}),
});
return await readJsonOrThrow<FileUploadResponse>(response);
}
@@ -1,5 +1,6 @@
"""B03 원본 입력 파일 메타데이터 분석."""
import csv
import logging
import math
import re
@@ -269,10 +270,121 @@ def analyze_tif_metadata(path: str | Path) -> dict[str, Any]:
rasterio_logger.removeFilter(warning_filter)
_PLANNED_ROUTE_COLUMNS = ("route_name", "sequence", "x", "y", "z", "crs_epsg")
def _parse_route_integer(value: str, *, field: str, row_number: int) -> int:
normalized = value.strip()
if not re.fullmatch(r"[0-9]+", normalized):
raise ValueError(f"CSV {row_number}행의 {field} 값은 양의 정수여야 합니다.")
parsed = int(normalized)
if parsed <= 0:
raise ValueError(f"CSV {row_number}행의 {field} 값은 양의 정수여야 합니다.")
return parsed
def _parse_route_coordinate(value: str, *, field: str, row_number: int) -> float:
try:
parsed = float(value.strip())
except (AttributeError, ValueError) as exc:
raise ValueError(f"CSV {row_number}행의 {field} 값은 숫자여야 합니다.") from exc
if not math.isfinite(parsed):
raise ValueError(f"CSV {row_number}행의 {field} 값은 유한한 숫자여야 합니다.")
return parsed
def analyze_planned_route_csv(path: str | Path) -> dict[str, Any]:
"""원청 계획노선 CSV를 검증하고 경로 메타데이터를 반환한다."""
source = Path(path)
with source.open("r", encoding="utf-8-sig", newline="") as csv_file:
reader = csv.DictReader(csv_file)
if reader.fieldnames is None:
raise ValueError("계획노선 CSV 헤더를 찾을 수 없습니다.")
normalized_headers = [header.strip() for header in reader.fieldnames]
if len(set(normalized_headers)) != len(normalized_headers):
raise ValueError("계획노선 CSV 헤더에 중복된 열이 있습니다.")
header_map = dict(zip(normalized_headers, reader.fieldnames, strict=True))
missing = [column for column in _PLANNED_ROUTE_COLUMNS if column not in header_map]
if missing:
raise ValueError(f"계획노선 CSV 필수 열이 없습니다: {', '.join(missing)}")
route_name: str | None = None
crs_epsg: int | None = None
points: list[tuple[float, float, float]] = []
for expected_sequence, row in enumerate(reader, start=1):
row_number = expected_sequence + 1
current_name = (row.get(header_map["route_name"]) or "").strip()
if not current_name:
raise ValueError(f"CSV {row_number}행의 route_name 값이 비어 있습니다.")
if route_name is None:
route_name = current_name
elif current_name != route_name:
raise ValueError("계획노선 CSV에는 하나의 route_name만 사용할 수 있습니다.")
sequence = _parse_route_integer(
row.get(header_map["sequence"]) or "",
field="sequence",
row_number=row_number,
)
if sequence != expected_sequence:
raise ValueError(
f"CSV {row_number}행의 sequence는 {expected_sequence}이어야 합니다."
)
current_epsg = _parse_route_integer(
row.get(header_map["crs_epsg"]) or "",
field="crs_epsg",
row_number=row_number,
)
if crs_epsg is None:
crs_epsg = current_epsg
elif current_epsg != crs_epsg:
raise ValueError("계획노선 CSV의 crs_epsg는 모든 행에서 같아야 합니다.")
points.append(
tuple(
_parse_route_coordinate(
row.get(header_map[field]) or "",
field=field,
row_number=row_number,
)
for field in ("x", "y", "z")
)
)
if len(points) < 2:
raise ValueError("계획노선 CSV에는 좌표가 2개 이상 있어야 합니다.")
xs, ys, zs = zip(*points, strict=True)
return {
"file": source.name,
"extension": "csv",
"size_bytes": source.stat().st_size,
"purpose": "planned_route",
"route_name": route_name,
"point_count": len(points),
"epsg": crs_epsg,
"columns": list(_PLANNED_ROUTE_COLUMNS),
"bounds": {
"x_min": min(xs),
"x_max": max(xs),
"y_min": min(ys),
"y_max": max(ys),
"z_min": min(zs),
"z_max": max(zs),
},
"start_point": list(points[0]),
"end_point": list(points[-1]),
}
def analyze_input_metadata(path: str | Path) -> dict[str, Any]:
"""입력 파일 확장자에 맞는 B03 메타데이터 분석 함수를 호출한다."""
source = Path(path)
extension = source.suffix.lower()
if extension == ".csv":
return analyze_planned_route_csv(source)
if extension in {".las", ".laz"}:
return analyze_las_metadata(source)
if extension == ".prj":
+25
View File
@@ -61,6 +61,31 @@ async def create_input_file(
return int(input_file_id)
async def get_project_input_readiness(
connection: aiomysql.Connection,
project_id: UUID,
) -> tuple[set[str], int | None]:
"""현재 업로드 파일 유형과 최신 포인트클라우드 입력 ID를 반환한다."""
async with connection.cursor(aiomysql.DictCursor) as cursor:
await cursor.execute(
"""
SELECT id, LOWER(file_type) AS file_type
FROM input_files
WHERE project_id = %s AND status IN ('UPLOADED', 'PROCESSED')
ORDER BY id DESC
""",
(str(project_id),),
)
rows = await cursor.fetchall()
file_types = {str(row["file_type"]) for row in rows if row.get("file_type")}
point_cloud_id = next(
(int(row["id"]) for row in rows if str(row.get("file_type") or "") in {"las", "laz"}),
None,
)
return file_types, point_cloud_id
async def get_project_storage_relative_path(
connection: aiomysql.Connection,
project_id: UUID,
+92 -15
View File
@@ -1,6 +1,7 @@
"""B03 파일 입력 FastAPI 라우터."""
import asyncio
import json
import logging
from pathlib import Path
from typing import Any
@@ -25,6 +26,7 @@ from B03_FileInput.B03_FileInput_Engine_Analyze import analyze_input_metadata
from B03_FileInput.B03_FileInput_Repository import (
create_input_file,
create_upload_session,
get_project_input_readiness,
get_project_storage_relative_path,
get_upload_session,
list_completed_chunk_indexes,
@@ -60,13 +62,69 @@ from config.config_system import (
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/projects", tags=["B03 File Input"])
_REQUIRED_FILE_TYPES = frozenset({"csv", "prj", "tfw"})
_POINT_CLOUD_FILE_TYPES = frozenset({"las", "laz"})
def _total_chunks(size_bytes: int, chunk_size_bytes: int) -> int:
return max(1, (size_bytes + chunk_size_bytes - 1) // chunk_size_bytes)
def _is_point_cloud_result(result: UploadedFileResult) -> bool:
return result.file_type.lower() in {"las", "laz"}
return result.file_type.lower() in _POINT_CLOUD_FILE_TYPES
def _missing_required_file_types(file_types: set[str]) -> list[str]:
missing = sorted(_REQUIRED_FILE_TYPES - file_types)
if not file_types.intersection(_POINT_CLOUD_FILE_TYPES):
missing.append("las/laz")
return missing
def _require_complete_file_set(file_types: set[str]) -> None:
missing = _missing_required_file_types(file_types)
if missing:
raise ValueError(f"B03 필수 입력 파일이 없습니다: {', '.join(missing)}")
async def _complete_file_input_if_ready(
connection: aiomysql.Connection,
project_id: UUID,
) -> int:
file_types, point_cloud_input_id = await get_project_input_readiness(connection, project_id)
_require_complete_file_set(file_types)
if point_cloud_input_id is None:
raise ValueError("LAS 또는 LAZ 입력 파일을 찾을 수 없습니다.")
async with connection.cursor() as cursor:
await complete_stage(cursor, str(project_id), 0)
return point_cloud_input_id
def _write_stage_metadata(
stage_root: Path,
project_id: UUID,
results: list[UploadedFileResult],
) -> None:
metadata_path = stage_root / "metadata.json"
existing_files: list[dict[str, Any]] = []
if metadata_path.exists():
try:
payload = json.loads(metadata_path.read_text(encoding="utf-8"))
existing_files = list(payload.get("files") or [])
except (OSError, TypeError, ValueError):
logger.warning("B03 metadata.json을 읽지 못해 새로 작성합니다: %s", metadata_path)
merged = {
str(item.get("relative_path") or item.get("original_filename")): item
for item in existing_files
}
for result in results:
dumped = result.model_dump()
merged[result.relative_path] = dumped
atomic_write_json(
metadata_path,
{"project_id": str(project_id), "files": list(merged.values())},
)
def _schedule_background_task(coro: Any, *, task_name: str) -> None:
@@ -170,11 +228,31 @@ async def upload_project_files(
"message": "LAS 또는 LAZ 파일을 정확히 1개 포함해야 합니다.",
},
)
csv_count = sum(Path(filename).suffix.lower() == ".csv" for filename in filenames)
if csv_count != 1:
return JSONResponse(
status_code=400,
content={
"status": "error",
"message": "계획노선 CSV 파일을 정확히 1개 포함해야 합니다.",
},
)
request_file_types = {Path(filename).suffix.lower().lstrip(".") for filename in filenames}
missing_required = _missing_required_file_types(request_file_types)
if missing_required:
return JSONResponse(
status_code=400,
content={
"status": "error",
"message": f"B03 필수 입력 파일이 없습니다: {', '.join(missing_required)}",
},
)
pool = get_db_pool()
saved_paths: list[Path] = []
try:
results: list[UploadedFileResult] = []
point_cloud_input_id: int | None = None
async with pool.acquire() as connection:
stored_path = await get_project_storage_relative_path(connection, project_id)
@@ -219,18 +297,14 @@ async def upload_project_files(
metadata=metadata,
)
)
async with connection.cursor() as cursor:
await complete_stage(cursor, str(project_id), 0)
point_cloud_input_id = await _complete_file_input_if_ready(connection, project_id)
await connection.commit()
except Exception:
await connection.rollback()
raise
stage_root = project_root / "B03_FileInput"
atomic_write_json(
stage_root / "metadata.json",
{"project_id": str(project_id), "files": [result.model_dump() for result in results]},
)
_write_stage_metadata(stage_root, project_id, results)
workflow_path = project_root / "workflow.json"
if not workflow_path.exists():
atomic_write_json(workflow_path, load_project_workflow(project_root))
@@ -246,10 +320,11 @@ async def upload_project_files(
),
task_name=f"b03-upload-email-{project_id}",
)
if point_cloud_input_id is not None:
_schedule_background_task(
trigger_wf1_analysis_and_email(
project_id=project_id,
input_file_id=point_cloud_result.input_file_id,
input_file_id=point_cloud_input_id,
user_role=str(session["role"]),
),
task_name=f"b04-wf1-auto-{project_id}",
@@ -390,6 +465,7 @@ async def finalize_project_upload(
"""청크 업로드를 최종 병합하고 input_files 메타데이터를 기록한다."""
pool = get_db_pool()
final_path: Path | None = None
point_cloud_input_id: int | None = None
try:
async with pool.acquire() as connection:
session = await get_upload_session(
@@ -444,8 +520,11 @@ async def finalize_project_upload(
metadata=metadata,
)
await mark_upload_session_completed(connection, session_id=payload.session_id)
async with connection.cursor() as cursor:
await complete_stage(cursor, str(project_id), 0)
if payload.complete_upload:
point_cloud_input_id = await _complete_file_input_if_ready(
connection,
project_id,
)
await connection.commit()
except Exception:
await connection.rollback()
@@ -462,10 +541,7 @@ async def finalize_project_upload(
metadata=metadata,
)
stage_root = project_root / "B03_FileInput"
atomic_write_json(
stage_root / "metadata.json",
{"project_id": str(project_id), "files": [result.model_dump()]},
)
_write_stage_metadata(stage_root, project_id, [result])
if _is_point_cloud_result(result):
_schedule_background_task(
_send_upload_complete_notification(
@@ -474,10 +550,11 @@ async def finalize_project_upload(
),
task_name=f"b03-upload-email-{project_id}",
)
if point_cloud_input_id is not None:
_schedule_background_task(
trigger_wf1_analysis_and_email(
project_id=project_id,
input_file_id=result.input_file_id,
input_file_id=point_cloud_input_id,
user_role=str(session["role"]),
),
task_name=f"b04-wf1-auto-{project_id}",
+1
View File
@@ -89,6 +89,7 @@ class UploadFinalizeRequest(BaseModel):
session_id: str = Field(min_length=1, max_length=36)
total_chunks: int = Field(gt=0)
complete_upload: bool = True
class UploadStatusResponse(BaseModel):
+11 -5
View File
@@ -368,6 +368,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
async function uploadOneFile(
projectId: string,
state: FileSlotState,
completeUpload: boolean,
): Promise<UploadedFileResult[]> {
const file = state.file;
if (!file) return [];
@@ -421,7 +422,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
}
}
const response = await finalizeUploadSession(projectId, session, totalChunks);
const response = await finalizeUploadSession(projectId, session, totalChunks, completeUpload);
localStorage.removeItem(storageKey);
saveB03UploadedFile(projectId, {
slot: state.slot,
@@ -470,8 +471,11 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
pageError.textContent = "";
const uploaded: UploadedFileResult[] = [];
try {
for (const state of targetStates) {
uploaded.push(...(await uploadOneFile(activeProjectId, state)));
for (let index = 0; index < targetStates.length; index += 1) {
const state = targetStates[index];
uploaded.push(
...(await uploadOneFile(activeProjectId, state, index === targetStates.length - 1)),
);
}
renderUploadResults(uploaded);
showToast(L("B03_File_Upload_Success"), "success");
@@ -544,11 +548,13 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
uploadControlPanel.className = "b03-file__control-panel";
uploadControlPanel.append(subtitle, dropzone, resumeBanner, pageError, uploadButton, resultList);
const filesGroup = createCardGroup("", ["las_laz", "prj", "tfw", "tif"]); // 타이틀 공백으로 전달
const routeGroup = createCardGroup(L("B03_File_Group_Route"), ["csv"]);
routeGroup.classList.add("b03-file__group--route");
const filesGroup = createCardGroup(L("B03_File_Group_Terrain"), ["las_laz", "prj", "tfw", "tif"]);
const cardsContainer = document.createElement("div");
cardsContainer.className = "b03-file__control-panel b03-file__cards-container-panel";
cardsContainer.append(filesGroup);
cardsContainer.append(routeGroup, filesGroup);
const workflowState = activeProjectId
? await fetchWorkflowState(activeProjectId).catch(() => undefined)
+9
View File
@@ -136,6 +136,15 @@
gap: var(--spacing-24);
}
.b03-file__group--route .b03-file__group-content {
grid-template-columns: 1fr;
}
.b03-file__group--route .b03-file__card {
border-color: var(--color-royal-amethyst, #3e0079);
background: var(--color-mist-violet, #edecff);
}
/* Wiza 8px radius 카드 */
.b03-file__card {
min-height: 220px;
+8 -1
View File
@@ -1,6 +1,6 @@
import { ui_locales } from "@ui/ui_template_locale";
export type FileSlot = "las_laz" | "prj" | "tfw" | "tif" | "dxf";
export type FileSlot = "csv" | "las_laz" | "prj" | "tfw" | "tif" | "dxf";
export type UploadStatus = "pending" | "uploading" | "completed" | "failed";
export interface SlotConfig {
@@ -35,6 +35,13 @@ export interface StoredUploadSession {
}
const SLOT_CONFIGS: readonly SlotConfig[] = [
{
slot: "csv",
labelKey: "B03_File_Slot_PlannedRoute",
icon: "⌁",
extensions: [".csv"],
isRequired: true,
},
{
slot: "las_laz",
labelKey: "B03_File_Slot_PointCloud",
@@ -0,0 +1,58 @@
import tempfile
import unittest
from pathlib import Path
from B03_FileInput.B03_FileInput_Engine_Analyze import analyze_planned_route_csv
class PlannedRouteCsvTest(unittest.TestCase):
def analyze(self, content: str) -> dict:
with tempfile.TemporaryDirectory() as temporary_dir:
path = Path(temporary_dir) / "planned_route.csv"
path.write_text(content, encoding="utf-8")
return analyze_planned_route_csv(path)
def test_valid_route_returns_metadata(self) -> None:
metadata = self.analyze(
"route_name,sequence,x,y,z,crs_epsg\n"
"sample,1,183493.5,489290.335,544.659,5187\n"
"sample,2,183500.0,489300.0,545.0,5187\n"
)
self.assertEqual(metadata["purpose"], "planned_route")
self.assertEqual(metadata["route_name"], "sample")
self.assertEqual(metadata["point_count"], 2)
self.assertEqual(metadata["epsg"], 5187)
self.assertEqual(metadata["start_point"], [183493.5, 489290.335, 544.659])
def test_missing_header_is_rejected(self) -> None:
with self.assertRaisesRegex(ValueError, "필수 열"):
self.analyze("route_name,sequence,x,y,crs_epsg\nsample,1,183493.5,489290.335,5187\n")
def test_non_numeric_coordinate_is_rejected(self) -> None:
with self.assertRaisesRegex(ValueError, "x 값은 숫자"):
self.analyze(
"route_name,sequence,x,y,z,crs_epsg\n"
"sample,1,not-a-number,489290.335,544.659,5187\n"
"sample,2,183500.0,489300.0,545.0,5187\n"
)
def test_non_contiguous_sequence_is_rejected(self) -> None:
with self.assertRaisesRegex(ValueError, "sequence는 2"):
self.analyze(
"route_name,sequence,x,y,z,crs_epsg\n"
"sample,1,183493.5,489290.335,544.659,5187\n"
"sample,3,183500.0,489300.0,545.0,5187\n"
)
def test_mixed_epsg_is_rejected(self) -> None:
with self.assertRaisesRegex(ValueError, "모든 행에서 같아야"):
self.analyze(
"route_name,sequence,x,y,z,crs_epsg\n"
"sample,1,183493.5,489290.335,544.659,5187\n"
"sample,2,183500.0,489300.0,545.0,5186\n"
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,60 @@
import json
import tempfile
import unittest
from pathlib import Path
from uuid import UUID
from B03_FileInput.B03_FileInput_Router import (
_missing_required_file_types,
_write_stage_metadata,
)
from B03_FileInput.B03_FileInput_Schema import UploadedFileResult
class B03RouterHelperTest(unittest.TestCase):
def test_required_file_types_include_planned_route(self) -> None:
self.assertEqual(
_missing_required_file_types({"las", "prj", "tfw"}),
["csv"],
)
self.assertEqual(
_missing_required_file_types({"csv", "laz", "prj", "tfw"}),
[],
)
def test_stage_metadata_preserves_existing_files(self) -> None:
project_id = UUID("acb9170b-9ac8-49b3-82a0-51cfa32bb42d")
with tempfile.TemporaryDirectory() as temporary_dir:
stage_root = Path(temporary_dir)
(stage_root / "metadata.json").write_text(
json.dumps(
{
"project_id": str(project_id),
"files": [
{
"original_filename": "terrain.las",
"relative_path": "B03_FileInput/input/las/terrain.las",
}
],
}
),
encoding="utf-8",
)
route = UploadedFileResult(
input_file_id=100,
original_filename="planned_route.csv",
file_type="csv",
relative_path="B03_FileInput/input/csv/planned_route.csv",
size_bytes=1000,
metadata={"purpose": "planned_route", "epsg": 5187},
)
_write_stage_metadata(stage_root, project_id, [route])
payload = json.loads((stage_root / "metadata.json").read_text(encoding="utf-8"))
self.assertEqual(len(payload["files"]), 2)
self.assertEqual(payload["files"][1]["metadata"]["purpose"], "planned_route")
if __name__ == "__main__":
unittest.main()
+162 -4
View File
@@ -11,7 +11,7 @@
* - 오류 응답 형식 {status:"error", message:"..."}을 Error로 변환.
* ========================================================================== */
import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend";
import { API_ANALYSIS_TIMEOUT_MS, API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend";
/** 지표면 분석 실행 요청 (SurfaceAnalyzeRequest) */
export interface SurfaceAnalyzeRequest {
@@ -111,10 +111,42 @@ export interface SurfaceModelListResponse {
models: SurfaceModelSummary[];
}
/** 공통 fetch 헬퍼: 타임아웃 + 인증 헤더 + 오류 응답 변환. */
async function requestJson<T>(path: string, init: RequestInit): Promise<T> {
/** 확정 지표면 요약 (SurfaceConfirmedResponse).
* 포인트 배열 없이 확정 구성과 지형 가장자리만 담는다 — 진입 판정·준비화면·B05 공용. */
export interface SurfaceConfirmedResponse {
status: string;
project_id: string;
model_id: number | null;
source_filter: string | null;
method: string | null;
smooth: boolean | null;
contour_interval_m: number | null;
/** 확정 구성이 바뀌었는지 한 줄로 비교하기 위한 값. */
signature: string;
point_count: number | null;
bounds: {
x_min: number;
x_max: number;
y_min: number;
y_max: number;
z_min: number;
z_max: number;
} | null;
/** 계획노선(B03 CSV)의 평면 범위. 지도 초기 화면을 도로 중심으로 맞출 때 쓴다. */
route_bounds: { x_min: number; x_max: number; y_min: number; y_max: number } | null;
}
/** 공통 fetch 헬퍼: 타임아웃 + 인증 헤더 + 오류 응답 변환.
*
* `timeoutMs`를 주면 그 값으로 끊는다. 배수유역 격자 해석처럼 수십 초가 걸리는 요청은
* `API_ANALYSIS_TIMEOUT_MS`를 넘긴다 — 기본값으로 두면 계산 도중 abort 된다. */
async function requestJson<T>(
path: string,
init: RequestInit,
timeoutMs: number = API_TIMEOUT_MS,
): Promise<T> {
const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS);
const timeoutId = window.setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(`${API_BASE_URL}${path}`, {
...init,
@@ -130,6 +162,12 @@ async function requestJson<T>(path: string, init: RequestInit): Promise<T> {
throw new Error(payload.message ?? `HTTP ${response.status}`);
}
return payload;
} catch (error) {
// AbortError 원문("signal is aborted without reason")은 원인을 알 수 없으니 바꿔 준다.
if (error instanceof DOMException && error.name === "AbortError") {
throw new Error(`요청이 ${Math.round(timeoutMs / 1000)}초 안에 끝나지 않았습니다.`);
}
throw error;
} finally {
window.clearTimeout(timeoutId);
}
@@ -186,6 +224,14 @@ export async function fetchSurfacePointCloud(
);
}
/** 확정 지표면 구성 + 지형 가장자리만 조회한다(수 KB).
* 포인트클라우드 전체(수십 MB)를 받지 않고도 3D 좌표 환산에 필요한 값을 얻는다. */
export async function fetchConfirmedSurface(projectId: string): Promise<SurfaceConfirmedResponse> {
return requestJson<SurfaceConfirmedResponse>(`/projects/${projectId}/surface/confirmed`, {
method: "GET",
});
}
export async function fetchSurfaceGroundStats(
projectId: string,
): Promise<SurfaceGroundStatsResponse> {
@@ -230,3 +276,115 @@ export async function fetchGisGeoJson(projectId: string, layer: string): Promise
method: "GET",
});
}
/* ── 배수유역 분석 (B04_wf1_Surface_Router_Watershed.py) ────────────────────
* 관리자 확인용. 계획 노선(B03 CSV) + 도엽 등고선·세류선으로 유역을 끝까지 분석하고
* 결과를 영구저장소에 남긴다. 30초 안팎이 걸리므로 여기서 한 번만 돌린다.
* ------------------------------------------------------------------------ */
/** 관 매설 지점 1개. reason: stream=세류 교차, spacing=간격 보충, confirmed=사용자 확정. */
export interface WatershedPipe {
chainage_m: number;
x: number;
y: number;
lon: number;
lat: number;
reason: string;
stream_name: string | null;
}
/** 1차 배수유역 근거(단계 검증용). TIN·흐름 계산 없이 세류 상·하류 판정과 격자 범위만 준다. */
export interface WatershedAnalysis {
status: string;
project_id: string;
/** 분석에 쓴 계획 노선 파일명(B03 업로드). */
route_source: string;
radius_m: number;
/** 도로와 만난 세류선의 상류측 = 1차 영역의 기준선. */
upstream_lines: Array<Array<[number, number]>>;
/** 교차했으나 하류로 판정해 제외한 조각. 판정이 맞는지 눈으로 대조하는 용도. */
downstream_lines: Array<Array<[number, number]>>;
/** 상·하류 어느 망에도 이어지지 않아 제외한 세류 조각 수. */
no_contact_count: number;
/** 노선이 1차 영역 밖으로 나간 길이(m). 크면 반경을 올려야 한다는 신호. */
road_outside_m: number;
/** 1차 영역(상류 세류망 버퍼 합집합)의 외곽 링 목록. */
region_rings: Array<Array<[number, number]>>;
grid: {
cell_m: number;
rows: number;
cols: number;
/** bbox 전체 셀 수(참고값). */
bbox_cells: number;
/** 1차 영역에 걸쳐 실제로 생성된 셀 수. */
cells: number;
width_m: number;
height_m: number;
/** 격자 bbox 링. 화면은 이 사각형을 rows×cols로 나눠 셀 좌표를 얻는다. */
bbox_lonlat: Array<[number, number]>;
/** 실제 생성된 셀 구간 [행, 시작열, 끝열(포함)]. 낱개 셀 대신 구간으로 온다. */
row_spans: Array<[number, number, number]>;
};
/** 최외곽 적색 셀 주변 확장 결과. */
expansion: {
rounds: number;
/** 새로 추가한 셀에 적색이 없어 스스로 멈췄는가. */
closed: boolean;
added_cells: number;
/** 확장 전(1차 영역) 셀 수. */
initial_cells: number;
};
/** 셀별 흐름 방향과 도로 도달 여부. 등고선이 없어 판정을 못하면 null. */
flow: {
encoding: "base64-uint8";
/** 방위 분해능(32). 코드 0 = 화면 오른쪽, 시계방향 증가. */
azimuth_steps: number;
/** 제자리(더 낮은 이웃 없음)를 뜻하는 코드. */
sink_code: number;
/** 표고가 없어 판정 못한 셀 코드. */
invalid_code: number;
cells: number;
reaches_road: number;
no_road: number;
/** 격자에는 있으나 등고선 TIN 밖이라 표고가 없어 판정 못한 셀. */
unanalyzed: number;
/** 확정된 상류 세류망을 따라 흐름을 강제로 새긴 셀 수. */
burned: number;
outer_seeds: number;
interior_seeds: number;
/** 셀당 1바이트. 하위 6비트=32방위 코드(32=제자리, 33=무효), 0x80=도로 도달.
* 순서는 grid.row_spans를 행 → 구간 → 열 오름차순으로 훑은 순서와 같다. */
data: string;
} | null;
/** 2차 전체 배수유역 외곽선(= 분수령). 적색 셀 전체의 외곽. */
basin_polygon_lonlat: Array<[number, number]>;
basin_area_m2: number;
/** 도로 위 흐름 강도 — [누가거리 m, 그 구간으로 모이는 상류 면적 ㎡]. */
strength_profile: Array<[number, number]>;
/** 기본 관 매설 위치 — 도로 × 세류선 교차점. */
pipes: WatershedPipe[];
/** B05용 평균 흐름 화살표 — [lon, lat, 방위(도), 도로도달, 셀 수].
* 세류·도로 셀을 뺀 10m 블록 평균이라 사면 경향만 남는다. */
flow_arrows: Array<[number, number, number, boolean, number]>;
/** 화살표 사이 실제 간격(m). 화면이 화살표를 이보다 짧게 그려 서로 닿지 않게 한다. */
arrow_spacing_m: number;
/** 계산하지 않고 저장분을 그대로 돌려준 응답인지. */
from_cache: boolean;
/** 영구저장소에 남긴 검증용 GeoJSON 경로. */
saved_to: string | null;
}
/** 배수유역 분석 결과를 받는다.
*
* `refresh`를 주지 않으면 영구저장소에 남은 결과를 그대로 받아 즉시 끝난다.
* `refresh=true`면 처음부터 다시 계산하므로 수십 초가 걸린다. */
export async function fetchWatershedAnalysis(
projectId: string,
refresh = false,
): Promise<WatershedAnalysis> {
return requestJson<WatershedAnalysis>(
`/projects/${projectId}/drainage/primary-region?refresh=${refresh}`,
{ method: "GET" },
refresh ? API_ANALYSIS_TIMEOUT_MS : API_TIMEOUT_MS,
);
}
+37 -23
View File
@@ -232,14 +232,30 @@ def run_surface_analysis(
)
prj_path = prj_candidates[0] if prj_candidates else project_root / "result.prj"
bounds_dict_for_download = {
from B04_wf1_Surface.B04_wf1_Surface_Engine_Extent import (
download_extent,
map_meta_covers,
satellite_extent,
)
from B04_wf1_Surface.B04_wf1_Surface_Engine_GisVector import download_all_gis_vectors
from B04_wf1_Surface.B04_wf1_Surface_Engine_VWorld import (
download_vworld_satellite_map,
get_epsg_from_prj,
)
las_bounds_dict = {
"x": [float(bounds[0, 0]), float(bounds[0, 1])],
"y": [float(bounds[1, 0]), float(bounds[1, 1])],
"z": [float(bounds[2, 0]), float(bounds[2, 1])],
}
from B04_wf1_Surface.B04_wf1_Surface_Engine_GisVector import download_all_gis_vectors
from B04_wf1_Surface.B04_wf1_Surface_Engine_VWorld import download_vworld_satellite_map
project_epsg = "EPSG:5186"
if prj_path.exists():
project_epsg = get_epsg_from_prj(prj_path.read_text(encoding="utf-8", errors="ignore"))
# 국가 GIS 벡터는 라이다∪계획노선 범위로 받는다.
bounds_dict_for_download = download_extent(project_root, las_bounds_dict, project_epsg)
# 배경 지도(위성·하이브리드·백지도)는 계획노선이 걸치는 기준 도엽의 도곽 범위로 받는다
# — 수치지형도 도엽과 같은 눈금. 주변 도엽은 받지 않는다(2026-08-01 사용자 지시).
map_bounds_for_download = satellite_extent(project_root, las_bounds_dict, project_epsg)
# VWorld 지도 및 GIS 데이터 저장 위치는 B04_wf1_Surface/processed에 보관.
layers = [
@@ -249,13 +265,15 @@ def run_surface_analysis(
]
for item in layers:
meta_path = processed_dir / f"vworld_{item['layer'].lower()}_meta.json"
if not rebuild and meta_path.is_file():
# 파일이 있어도 계획노선·여유 셀이 바뀌어 범위를 못 덮으면 다시 받는다.
# (B03 업로드가 부르는 경로는 rebuild=False라, 존재 여부만 보면 영영 갱신되지 않는다.)
if not rebuild and map_meta_covers(meta_path, map_bounds_for_download):
continue
try:
step_started = time.monotonic()
download_vworld_satellite_map(
prj_path,
bounds_dict_for_download,
map_bounds_for_download,
processed_dir,
layer_name=item["layer"],
ext=item["ext"],
@@ -284,32 +302,26 @@ def run_surface_analysis(
except Exception as exc:
logger.warning("B04 국가 GIS 벡터 다운로드 실패: %s", exc)
# 3-3. 1:5,000 수치지형도 도엽 3x3(9매) 확보 → 프로젝트 영구저장소
# 3-3. 1:5,000 수치지형도 도엽 확보 → 프로젝트 영구저장소
# 기준은 계획노선 시점·종점 (같은 도엽이면 9매, 이웃 도엽에 걸치면 12매).
# (실패해도 분석은 계속 — 폴백은 수동 다운로드 + 인제스트)
_report(92, "download_maps", "수치지형도 도엽 확보 중")
try:
from pyproj import Transformer
from B04_wf1_Surface.B04_wf1_Surface_Engine_MapSheet import (
latlon_to_sheet5k,
neighbors_3x3,
)
from B04_wf1_Surface.B04_wf1_Surface_Engine_Extent import sheet_reference_points_wgs84
from B04_wf1_Surface.B04_wf1_Surface_Engine_MapSheet import neighbors_for_points
from B04_wf1_Surface.B04_wf1_Surface_Engine_SheetStore import (
ensure_sheets,
get_project_map_sheets_dir,
prune_sheets,
)
from B04_wf1_Surface.B04_wf1_Surface_Engine_VWorld import get_epsg_from_prj
src_epsg = "EPSG:5186"
if prj_path.exists():
src_epsg = get_epsg_from_prj(prj_path.read_text(encoding="utf-8", errors="ignore"))
transformer = Transformer.from_crs(src_epsg, "EPSG:4326", always_xy=True)
center_lon, center_lat = transformer.transform(
(bounds_dict_for_download["x"][0] + bounds_dict_for_download["x"][1]) / 2.0,
(bounds_dict_for_download["y"][0] + bounds_dict_for_download["y"][1]) / 2.0,
)
step_started = time.monotonic()
sheet_grid = neighbors_3x3(latlon_to_sheet5k(center_lat, center_lon))
# 도엽 기준은 계획노선 시점·종점 — 노선이 두 도엽에 걸치면 양쪽 주변까지 확보한다.
# 계획노선이 없으면 라이다 범위 중심으로 되돌아간다(2026-08-01 사용자 지시).
reference_points = sheet_reference_points_wgs84(
project_root, las_bounds_dict, project_epsg
)
sheet_grid = neighbors_for_points(reference_points)
sheet_store = get_project_map_sheets_dir(project_root)
sheet_result = ensure_sheets(sheet_store, sheet_grid)
if sheet_result["failed"]:
@@ -319,6 +331,8 @@ def run_surface_analysis(
len(sheet_result["available"]),
time.monotonic() - step_started,
)
# 선정에서 빠진 zip은 쓰이지 않으므로 정리한다(다른 지역 잔재 포함).
prune_sheets(sheet_store, sheet_grid)
# 확보된 도엽을 레이어별 병합 GeoJSON으로 산출 (기존 산출물 있으면 스킵)
if sheet_result["available"] and (
@@ -0,0 +1,204 @@
# B04_wf1_Surface_Engine_Extent.py
# 전처리에서 내려받을 범위와 기준 좌표를 정한다.
#
# 배경(2026-08-01 사용자 지시): 배경 지도·수치지형도 도엽의 기준을 라이다 범위 한가운데로
# 잡으면, 계획노선이 라이다 범위를 벗어날 때 배경과 도엽이 노선을 덮지 못한다.
# 계획노선(B03 CSV)의 시점·종점을 기준으로 삼고, 없을 때만 라이다 범위로 되돌린다.
from __future__ import annotations
import json
import logging
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
_ROUTE_CSV_GLOB = "B03_FileInput/input/csv/*.csv"
def read_planned_route(project_root: Path) -> dict[str, Any] | None:
"""B03 계획노선 CSV를 읽어 좌표계·범위·시점·종점을 돌려준다. 없거나 형식이 어긋나면 None."""
from B03_FileInput.B03_FileInput_Engine_Analyze import analyze_planned_route_csv
for csv_path in sorted(project_root.glob(_ROUTE_CSV_GLOB)):
try:
return analyze_planned_route_csv(csv_path)
except (OSError, ValueError) as exc:
logger.warning("B04 계획노선 CSV 해석 실패: %s (%s)", csv_path.name, exc)
return None
def _to_target_crs(
points: list[tuple[float, float]], source_epsg: int | None, target_epsg: str
) -> list[tuple[float, float]]:
"""계획노선 좌표를 라이다 좌표계로 옮긴다. 좌표계가 같거나 알 수 없으면 그대로 쓴다."""
if source_epsg is None:
return points
source = f"EPSG:{source_epsg}"
if source.upper() == target_epsg.upper():
return points
from pyproj import Transformer
transformer = Transformer.from_crs(source, target_epsg, always_xy=True)
return [transformer.transform(x, y) for x, y in points]
def download_extent(
project_root: Path,
las_bounds: dict[str, list[float]],
target_epsg: str,
) -> dict[str, list[float]]:
"""배경 지도가 반드시 덮어야 할 범위 = 라이다 범위 ∪ 계획노선 범위.
여유폭은 여기서 미터로 더하지 않는다. 내려받기 쪽에서 이 범위를 덮는 타일을 정한 뒤
바깥으로 `SURFACE_MAP_MARGIN_TILES` 겹만큼 주변 셀을 더 받는다(2026-08-01 사용자 지시).
las_bounds/반환값 모두 {"x": [최소, 최대], "y": [...], "z": [...]} 꼴(라이다 좌표계).
"""
x_min, x_max = float(las_bounds["x"][0]), float(las_bounds["x"][1])
y_min, y_max = float(las_bounds["y"][0]), float(las_bounds["y"][1])
route = read_planned_route(project_root)
if route:
bounds = route["bounds"]
corners = [
(float(bounds["x_min"]), float(bounds["y_min"])),
(float(bounds["x_max"]), float(bounds["y_max"])),
]
try:
moved = _to_target_crs(corners, route.get("epsg"), target_epsg)
except Exception as exc:
logger.warning("B04 계획노선 좌표 변환 실패 — 라이다 범위만 사용 (%s)", exc)
moved = []
for x, y in moved:
x_min, x_max = min(x_min, x), max(x_max, x)
y_min, y_max = min(y_min, y), max(y_max, y)
return {
"x": [x_min, x_max],
"y": [y_min, y_max],
"z": list(las_bounds.get("z", [0.0, 0.0])),
}
def satellite_extent(
project_root: Path,
las_bounds: dict[str, list[float]],
target_epsg: str,
) -> dict[str, list[float]]:
"""배경 지도를 확보할 범위 = 계획노선이 걸치는 **기준 도엽**의 도곽 범위.
수치지형도 도엽과 같은 눈금으로 맞춘다 — 도엽 1매면 1매 크기, 2~3매에 걸치면 그만큼.
주변 도엽은 받지 않는다(2026-08-01 사용자 지시).
도엽 번호를 얻지 못하면 라이다∪계획노선 범위로 되돌아간다.
"""
from pyproj import Transformer
from .B04_wf1_Surface_Engine_MapSheet import sheet5k_to_bounds, sheets_for_points
points = sheet_reference_points_wgs84(project_root, las_bounds, target_epsg)
sheets = sheets_for_points(points)
if not sheets:
return download_extent(project_root, las_bounds, target_epsg)
lon_min = lat_min = float("inf")
lon_max = lat_max = float("-inf")
for sheet_no in sheets:
s_lon_min, s_lat_min, s_lon_max, s_lat_max = sheet5k_to_bounds(sheet_no)
lon_min, lon_max = min(lon_min, s_lon_min), max(lon_max, s_lon_max)
lat_min, lat_max = min(lat_min, s_lat_min), max(lat_max, s_lat_max)
to_target = Transformer.from_crs("EPSG:4326", target_epsg, always_xy=True)
x0, y0 = to_target.transform(lon_min, lat_min)
x1, y1 = to_target.transform(lon_max, lat_max)
logger.info("B04 배경 지도 기준 도엽 %d매: %s", len(sheets), ", ".join(sheets))
return {
"x": [min(x0, x1), max(x0, x1)],
"y": [min(y0, y1), max(y0, y1)],
"z": list(las_bounds.get("z", [0.0, 0.0])),
}
def planned_route_bounds(project_root: Path, target_epsg: str) -> dict[str, float] | None:
"""계획노선(B03 CSV)의 평면 범위를 프로젝트 좌표계로 돌려준다. 없으면 None.
지도(2D) 초기 화면을 도로 기준으로 맞출 때 쓴다 — 화면 쪽은 도로 범위만 알면 된다.
"""
route = read_planned_route(project_root)
if not route:
return None
bounds = route["bounds"]
corners = [
(float(bounds["x_min"]), float(bounds["y_min"])),
(float(bounds["x_max"]), float(bounds["y_max"])),
]
try:
moved = _to_target_crs(corners, route.get("epsg"), target_epsg)
except Exception as exc:
logger.warning("B04 계획노선 범위 변환 실패 (%s)", exc)
return None
xs = [point[0] for point in moved]
ys = [point[1] for point in moved]
return {"x_min": min(xs), "x_max": max(xs), "y_min": min(ys), "y_max": max(ys)}
def project_epsg_from_prj(project_root: Path) -> str:
"""프로젝트 PRJ에서 좌표계를 읽는다. 없으면 중부원점(EPSG:5186)."""
from .B04_wf1_Surface_Engine_VWorld import get_epsg_from_prj
for prj_path in sorted(project_root.glob("B03_FileInput/**/*.prj")):
return get_epsg_from_prj(prj_path.read_text(encoding="utf-8", errors="ignore"))
return "EPSG:5186"
def map_meta_covers(meta_path: Path, extent: dict[str, list[float]]) -> bool:
"""저장된 배경 지도가 필요한 범위를 이미 덮고 있는가.
파일 존재 여부만 보면 계획노선이 바뀌거나 여유 셀 설정을 바꿔도 옛 사진을 계속 쓴다
(B03 업로드 → 전처리 경로는 `rebuild=False`라 더더욱 다시 받지 않는다, 2026-08-01).
"""
if not meta_path.is_file():
return False
try:
meta = json.loads(meta_path.read_text(encoding="utf-8"))
return (
float(meta["x_min"]) <= extent["x"][0]
and float(meta["x_max"]) >= extent["x"][1]
and float(meta["y_min"]) <= extent["y"][0]
and float(meta["y_max"]) >= extent["y"][1]
)
except (OSError, ValueError, KeyError, TypeError):
return False
def sheet_reference_points_wgs84(
project_root: Path,
las_bounds: dict[str, list[float]],
target_epsg: str,
) -> list[tuple[float, float]]:
"""도엽 선정 기준 좌표(위도, 경도) 목록.
① 계획노선 시점·종점 → ② 라이다 범위 중심(폴백).
시점과 종점이 서로 다른 도엽에 걸치면 호출측이 두 도엽의 주변을 모두 확보한다.
"""
from pyproj import Transformer
to_wgs84 = Transformer.from_crs(target_epsg, "EPSG:4326", always_xy=True)
route = read_planned_route(project_root)
if route:
ends = [
(float(route["start_point"][0]), float(route["start_point"][1])),
(float(route["end_point"][0]), float(route["end_point"][1])),
]
try:
moved = _to_target_crs(ends, route.get("epsg"), target_epsg)
return [(lat, lon) for lon, lat in (to_wgs84.transform(x, y) for x, y in moved)]
except Exception as exc:
logger.warning("B04 계획노선 기준 좌표 산출 실패 — 라이다 중심 사용 (%s)", exc)
center_x = (float(las_bounds["x"][0]) + float(las_bounds["x"][1])) / 2.0
center_y = (float(las_bounds["y"][0]) + float(las_bounds["y"][1])) / 2.0
lon, lat = to_wgs84.transform(center_x, center_y)
return [(lat, lon)]
@@ -88,3 +88,36 @@ def neighbors_3x3(sheet_no: str) -> list[str]:
latlon_to_sheet5k(lat_c + dr * SHEET5K_SIZE_DEG, lon_c + dc * SHEET5K_SIZE_DEG)
)
return result
def sheets_for_points(points: list[tuple[float, float]]) -> list[str]:
"""기준 좌표들이 속한 도엽번호만(주변 도엽 없음, 중복 제거).
계획노선 시점·종점이 같은 도엽이면 1매, 걸치면 2~3매가 된다.
배경 지도(위성사진)는 이 도엽 범위만 확보한다(2026-08-01 사용자 지시).
"""
ordered: list[str] = []
seen: set[str] = set()
for lat, lon in points:
sheet_no = latlon_to_sheet5k(lat, lon)
if sheet_no not in seen:
seen.add(sheet_no)
ordered.append(sheet_no)
return ordered
def neighbors_for_points(points: list[tuple[float, float]]) -> list[str]:
"""기준 좌표들이 속한 도엽 + 각각의 주변 8매를 합친 목록(중복 제거, 순서 유지).
계획노선 시점·종점이 같은 도엽이면 9매, 이웃한 두 도엽에 걸치면 12매가 된다
(3×3 두 벌이 한 줄을 공유하므로 3×4). 도엽 하나가 늘 때마다 병합 산출물도 늘어나므로
기준 좌표는 노선의 양 끝만 쓴다(2026-08-01 사용자 지시).
"""
ordered: list[str] = []
seen: set[str] = set()
for lat, lon in points:
for sheet_no in neighbors_3x3(latlon_to_sheet5k(lat, lon)):
if sheet_no not in seen:
seen.add(sheet_no)
ordered.append(sheet_no)
return ordered
@@ -18,6 +18,7 @@ import base64
import datetime
import http.cookiejar
import json
import logging
import re
import shutil
import tempfile
@@ -36,6 +37,8 @@ from config.config_system import (
from .B04_wf1_Surface_Engine_MapSheet import latlon_to_sheet5k, sheet5k_to_bounds
logger = logging.getLogger(__name__)
# 도곽선 레이어 코드 (수치지형도 v2.0 도엽본)
_SHEET_FRAME_CODE = "A0010000"
_SHEET_NO_RE = re.compile(r"(\d{8})")
@@ -227,6 +230,39 @@ def get_sheet_path(store_dir: str | Path, sheet_no: str) -> Path | None:
return path if path.exists() else None
def prune_sheets(store_dir: str | Path, keep_sheet_nos: list[str]) -> list[str]:
"""선정 도엽에 없는 zip을 지우고 지운 도엽번호를 돌려준다.
도엽 기준이 바뀌거나 다른 지역 파일이 섞여 들어오면 쓰지 않는 zip이 계속 쌓인다
(표본 프로젝트에서 30매 중 21매가 다른 지역 잔재였다, 2026-08-01).
병합에는 선정 도엽만 쓰이므로 산출물은 그대로다.
"""
store = Path(store_dir)
if not store.is_dir():
return []
keep = {str(sheet_no) for sheet_no in keep_sheet_nos}
index = _load_index(store)
removed: list[str] = []
for zip_path in sorted(store.glob("*.zip")):
match = _SHEET_NO_RE.fullmatch(zip_path.stem)
if not match or match.group(1) in keep:
continue
sheet_no = match.group(1)
try:
zip_path.unlink()
except OSError as exc:
logger.warning("도엽 정리: %s 삭제 실패 (%s)", zip_path.name, exc)
continue
index["sheets"].pop(sheet_no, None)
removed.append(sheet_no)
if removed:
_save_index(store, index)
logger.info("도엽 정리: 미사용 %d매 삭제 (%s)", len(removed), ", ".join(removed))
return removed
def missing_sheets(store_dir: str | Path, sheet_nos: list[str]) -> list[str]:
"""요청 도엽 중 영구저장소에 없는 번호 목록."""
store = Path(store_dir)
@@ -19,8 +19,14 @@ try:
VWORLD_API_KEY = getattr(
config_system, "VWORLD_API_KEY", "3DBD7306-7DBD-38BB-B292-267C5ED7AC6B"
)
SURFACE_MAP_MAX_TILES_PER_SIDE = getattr(config_system, "SURFACE_MAP_MAX_TILES_PER_SIDE", 12)
SURFACE_MAP_MAX_ZOOM = getattr(config_system, "SURFACE_MAP_MAX_ZOOM", 18)
SURFACE_MAP_MIN_ZOOM = getattr(config_system, "SURFACE_MAP_MIN_ZOOM", 14)
except ImportError:
VWORLD_API_KEY = "3DBD7306-7DBD-38BB-B292-267C5ED7AC6B"
SURFACE_MAP_MAX_TILES_PER_SIDE = 12
SURFACE_MAP_MAX_ZOOM = 18
SURFACE_MAP_MIN_ZOOM = 14
def get_epsg_from_prj(prj_content: str) -> str:
@@ -96,29 +102,22 @@ def download_vworld_satellite_map(
lon_min, lat_min = transformer.transform(x_min, y_min)
lon_max, lat_max = transformer.transform(x_max, y_max)
# 3. 지도 타일 크기 결정 (ZOOM 18 초고해상도 적용)
zoom = 18
# 영역을 포괄하는 좌상단 타일, 우하단 타일 인덱스 산출
x1, y1 = latlon_to_tile(lat_max, lon_min, zoom)
x2, y2 = latlon_to_tile(lat_min, lon_max, zoom)
# 타일 경계 마진 패딩
x_start = min(x1, x2) - 1
x_end = max(x1, x2) + 1
y_start = min(y1, y2) - 1
y_end = max(y1, y2) + 1
tile_w = x_end - x_start + 1
tile_h = y_end - y_start + 1
# 너무 많은 타일을 다운로드하여 IP 차단되는 것을 방지
if tile_w > 15:
tile_w = 15
x_end = x_start + 14
if tile_h > 15:
tile_h = 15
y_end = y_start + 14
# 3. 요청 범위를 그대로 덮는 타일 범위를 잡는다(주변으로 넓히지 않는다).
# 한 변 타일 수가 한도를 넘으면 zoom을 한 단계씩 낮춘다 — 도엽 1매를 zoom 18로 받으면
# 한 변이 19타일(4,864px)이라 파일이 지나치게 커진다(2026-08-01 사용자 지시).
zoom = SURFACE_MAP_MAX_ZOOM
while True:
x1, y1 = latlon_to_tile(lat_max, lon_min, zoom)
x2, y2 = latlon_to_tile(lat_min, lon_max, zoom)
x_start, x_end = min(x1, x2), max(x1, x2)
y_start, y_end = min(y1, y2), max(y1, y2)
tile_w = x_end - x_start + 1
tile_h = y_end - y_start + 1
if zoom <= SURFACE_MAP_MIN_ZOOM:
break
if max(tile_w, tile_h) <= SURFACE_MAP_MAX_TILES_PER_SIDE:
break
zoom -= 1
# 4. 개별 타일 다운로드 및 이미지 병합
map_img = Image.new("RGBA", (tile_w * 256, tile_h * 256))
@@ -0,0 +1,346 @@
"""배수유역 분석 오케스트레이터 (B04 — 관리자 확인용 전처리).
계획 노선(B03 업로드 파일) 도엽 등고선·세류선만으로 배수유역을 끝까지 분석해
영구저장소에 남긴다. 30 안팎이 걸리는 무거운 작업이라 여기서 번만 돌리고,
일반 사용자가 쓰는 B05는 결과를 읽어 쓰기만 한다(2026-07-31 사용자 지시).
도로 교차 세류망 상류측 추출 반경 버퍼 = 1 배수유역
도로 시작점 기준 격자 생성 (1 영역에 걸치는 셀만)
등고선 하강 방향 높은 등고 라인에서 낮은 등고 라인으로. 보간면을 쓰지 않으므로
가짜 웅덩이·평탄면이 원리적으로 생기지 않는다
세류망 흐름 새김 최외곽부터 사슬 추적 도로 도달 여부(/) 판정
최외곽 적색 주변 확장 새로 추가한 셀에 적색이 없을 때까지
도로 셀별 흐름 강도
2 전체 배수유역 외곽선
기본 매설 위치 (도로 × 세류선 교차점)
이후( 최소 개수 보충, 세부유역 분할) 사용자가 관을 옮길 있어야 하므로
B05에 남긴다.
"""
from __future__ import annotations
import logging
import math
import time
from dataclasses import dataclass, field
from typing import Any
import numpy as np
from shapely.geometry import LineString
from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Descent import ContourDescent
from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Expand import expand_by_red_boundary
from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Flow import (
FlowClassification,
RoadRaster,
largest_ring,
outer_boundary,
trace_flow,
)
from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Grid import (
AZIMUTH_STEPS,
GridSpec,
TerrainGrid,
build_contour_cloud,
route_elevation_floor,
)
from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Stream import (
PrimaryRegion,
build_primary_region,
)
from common_util.common_util_route_geometry import (
RouteVertex,
StructureCandidate,
find_stream_crossings,
)
from config.config_system import (
DRAINAGE_ARROW_BLOCK_M,
DRAINAGE_ARROW_MIN_AGREEMENT,
DRAINAGE_ARROW_MIN_COVERAGE,
DRAINAGE_ARROW_SPACING_M,
DRAINAGE_GRID_SIZE_M,
DRAINAGE_INITIAL_RADIUS_M,
DRAINAGE_PIPE_MIN_SPACING_M,
)
logger = logging.getLogger(__name__)
# 강도 곡선 응답 간격(m). 도로 위 흐름 강도 표기는 이 간격으로 내보낸다.
_STRENGTH_OUTPUT_STEP_M = 5.0
# ── ①~② 1차 배수유역 ────────────────────────────────────────────────────────
def resolve_primary_region(
vertices: list[RouteVertex],
route_line: LineString,
contour_features: list[dict[str, Any]],
stream_features: list[dict[str, Any]],
) -> PrimaryRegion | None:
"""도로 교차 세류선(상류측)과 노선을 반경 버퍼한 1차 배수유역과 격자 범위를 정한다.
·하류 판정에 등고선은 노선 주변만 있으면 된다(교차점이 전부 노선 위이므로).
도엽 전체를 읽으면 단계에서만 수십 초가 날아간다.
"""
floor = route_elevation_floor([vertex.z for vertex in vertices])
near_bounds = route_line.buffer(DRAINAGE_INITIAL_RADIUS_M * 2.0).bounds
cloud = build_contour_cloud(contour_features, floor, near_bounds)
if cloud.is_empty:
logger.warning("배수유역: 노선 주변에 등고선이 없어 1차 영역을 정할 수 없습니다.")
return None
return build_primary_region(
route_line, stream_features, cloud, DRAINAGE_INITIAL_RADIUS_M, DRAINAGE_GRID_SIZE_M
)
def preview_primary_region(
vertices: list[RouteVertex],
contour_features: list[dict[str, Any]],
stream_features: list[dict[str, Any]],
) -> PrimaryRegion | None:
"""단계 검증용 — TIN·흐름 계산 없이 1차 배수유역 근거만 뽑는다."""
if len(vertices) < 2:
return None
route_line = LineString([(vertex.x, vertex.y) for vertex in vertices])
return resolve_primary_region(vertices, route_line, contour_features, stream_features)
@dataclass
class StagePreview:
"""단계 검증 산출물 묶음. 기능을 붙일 때마다 여기에 항목이 하나씩 늘어난다.
확장을 거치면 격자와 해석 영역이 1 영역보다 커진다. 화면·저장은 `region.spec`
아니라 여기 `spec`/`domain` 봐야 한다.
"""
region: PrimaryRegion
spec: GridSpec | None = None
domain: np.ndarray | None = None
terrain: TerrainGrid | None = None
road: RoadRaster | None = None
flow: FlowClassification | None = None
descent: ContourDescent | None = None
expand_rounds: int = 0
expand_closed: bool = False
expand_added_cells: int = 0
# ⑥ 도로 위 흐름 강도 — (누가거리 m, 그 구간으로 모이는 상류 면적 ㎡).
strength_profile: list[tuple[float, float]] = field(default_factory=list)
# ⑦ 2차 전체 배수유역 외곽선(= 분수령). 적색 셀 전체를 폴리곤화한 것.
basin_boundary_xy: list[tuple[float, float]] = field(default_factory=list)
basin_area_m2: float = 0.0
# 셀 → 도로 셀 귀속. B05가 세부유역을 나눌 때 이 배열이 있어야 한다.
routing: Any = None
# B05용 평균 흐름 화살표 — (x, y, 방위 라디안, 도로 도달, 셀 수).
flow_arrows: list[tuple[float, float, float, bool, int]] = field(default_factory=list)
# ⑧ 기본 관 매설 위치 — 도로 × 세류선 교차점.
pipes: list[StructureCandidate] = field(default_factory=list)
def preview_stages(
vertices: list[RouteVertex],
contour_features: list[dict[str, Any]],
stream_features: list[dict[str, Any]],
) -> StagePreview | None:
"""지금까지 구현·검증된 단계를 순서대로 돌려 결과를 모은다.
현재 포함: 1 배수유역 격자 생성 **등고선 하강 방향** 도로 도달 판정
**최외곽 적색 주변 확장**.
보간면(TIN) 쓰지 않는다. 높은 등고 라인에서 낮은 등고 라인으로 내려가며 방향을
세우므로 가짜 웅덩이·평탄면이 원리적으로 생기지 않는다(2026-07-31 사용자 지시로 방식 교체).
최외곽에 적색이 남아 있으면 주변으로 넓혀 다시 분석하고, **새로 추가한 셀에
적색이 없으면** 멈춘다.
"""
if len(vertices) < 2:
return None
route_line = LineString([(vertex.x, vertex.y) for vertex in vertices])
region = resolve_primary_region(vertices, route_line, contour_features, stream_features)
if region is None:
return None
started = time.perf_counter()
floor = route_elevation_floor([vertex.z for vertex in vertices])
expansion = expand_by_red_boundary(
region.spec,
region.cell_mask,
contour_features,
route_line,
region.split.upstream,
floor,
)
if expansion is None:
logger.warning("배수유역: 등고선 하강 방향을 세우지 못해 흐름 판정을 건너뜁니다.")
return StagePreview(region=region)
analysis = expansion.analysis
spec = analysis.spec
red = analysis.flow.reaches_road & analysis.flow.analyzed
# ⑥ 흐름 강도 — 셀마다 물이 실제로 들어가는 도로 셀을 구해 도로 셀별로 센다.
# 색 판정은 세류 셀에서 멈추지만(거기서 도달이 확정되므로), 강도는 그 물이 세류를 타고
# 최종적으로 어느 도로 셀로 들어가는지를 봐야 하므로 도로만 흡수점으로 두고 다시 따라간다.
routing = trace_flow(analysis.terrain, analysis.road) if analysis.road.count else None
strength_curve = _preview_strength(analysis, routing, red, route_line.length)
# ⑦ 2차 전체 배수유역 외곽선 = 적색 셀 전체의 외곽.
boundary = outer_boundary(spec, red.reshape(spec.n_rows, spec.n_cols))
basin_ring = largest_ring(boundary) if boundary is not None else []
# ⑧ 기본 관 매설 위치 = 도로 × 세류선 교차점(너무 가까운 것은 하나로 합침).
pipes = _base_pipes(vertices, stream_features)
# B05에 얹을 평균 흐름 화살표 — 셀 화살표는 도면 배율에서 안 보인다.
flow_arrows = build_flow_arrows(analysis, analysis.flow)
logger.info(
"배수유역: 단계 분석 %.1fs (확장 %d회, 최종 셀 %d개) — "
"2차 유역 %.0f㎡, 기본 관 %d개, 강도 곡선 %d",
time.perf_counter() - started,
expansion.rounds,
spec.size,
int(red.sum()) * spec.cell_area_m2,
len(pipes),
int((strength_curve > 0).sum()),
)
return StagePreview(
region=region,
spec=spec,
domain=analysis.domain,
terrain=analysis.terrain,
road=analysis.road,
flow=analysis.flow,
descent=analysis.descent,
expand_rounds=expansion.rounds,
expand_closed=expansion.closed,
expand_added_cells=expansion.added_cells,
strength_profile=_downsample_strength(strength_curve),
basin_boundary_xy=basin_ring,
basin_area_m2=int(red.sum()) * spec.cell_area_m2,
routing=routing,
pipes=pipes,
flow_arrows=flow_arrows,
)
def _preview_strength(
analysis: Any, routing: Any, red: np.ndarray, route_length_m: float
) -> np.ndarray:
"""적색 셀이 실제로 들어가는 도로 셀을 세어 누가거리별 유입 면적 곡선을 만든다."""
road = analysis.road
if routing is None or road.count == 0:
return np.zeros(1)
slots = routing.road_slot
counted = red & (slots >= 0)
strength = np.bincount(slots[counted], minlength=road.count).astype(np.float64)
return _strength_by_chainage(
road.chainage, strength * analysis.spec.cell_area_m2, route_length_m
)
def build_flow_arrows(analysis: Any, flow: Any) -> list[tuple[float, float, float, bool, int]]:
"""셀 흐름을 블록 단위로 평균해 B05에 얹을 화살표를 뽑는다.
화살표는 1m라 도면 배율에서 경향이 보인다. 겹치지 않는 블록으로 나눠 방향을
평균하고, 화살표끼리 최소 간격을 두어 솎아낸다(2026-07-31 사용자 지시).
**세류선 셀과 도로 셀은 뺀다.** 자리 흐름은 지형 경사가 아니라 확정된 물길·노면을
따르는 값이라 사면 경향을 왜곡한다.
방향 평균은 산술평균이 아니라 **원형 평균**으로 낸다(0° 359° 평균은 180° 아니라
0°). 평균 벡터 길이가 일치도이므로, 블록 방향이 제각각이면 블록은 버린다.
"""
spec = analysis.spec
rows, cols = spec.n_rows, spec.n_cols
direction = flow.direction.reshape(rows, cols)
usable = (
analysis.domain
& flow.analyzed.reshape(rows, cols)
& (direction < AZIMUTH_STEPS) # 싱크·무효 제외
& ~analysis.road.mask
)
if flow.burned is not None:
usable &= ~flow.burned.reshape(rows, cols)
if not usable.any():
return []
block = max(1, int(round(DRAINAGE_ARROW_BLOCK_M / spec.cell_m)))
stride = max(1, int(round(DRAINAGE_ARROW_SPACING_M / (block * spec.cell_m))))
angle = direction.astype(np.float64) * (2.0 * math.pi / AZIMUTH_STEPS)
reaches = flow.reaches_road.reshape(rows, cols)
arrows: list[tuple[float, float, float, bool, int]] = []
for row0 in range(0, rows - block + 1, block * stride):
for col0 in range(0, cols - block + 1, block * stride):
window = usable[row0 : row0 + block, col0 : col0 + block]
count = int(window.sum())
if count < DRAINAGE_ARROW_MIN_COVERAGE * block * block:
continue
local = angle[row0 : row0 + block, col0 : col0 + block][window]
mean_x = float(np.cos(local).mean())
mean_y = float(np.sin(local).mean())
agreement = math.hypot(mean_x, mean_y)
if agreement < DRAINAGE_ARROW_MIN_AGREEMENT:
continue # 방향이 제각각인 블록 — 평균이 경향을 대표하지 못한다
centre_row = row0 + block / 2.0
centre_col = col0 + block / 2.0
arrows.append(
(
spec.x_min + centre_col * spec.cell_m,
spec.y_max - centre_row * spec.cell_m,
math.atan2(mean_y, mean_x),
bool(reaches[row0 : row0 + block, col0 : col0 + block][window].mean() >= 0.5),
count,
)
)
logger.info(
"배수유역: 평균 흐름 화살표 %d개 (블록 %.0fm, 간격 %.0fm, 세류·도로 셀 제외)",
len(arrows),
block * spec.cell_m,
block * stride * spec.cell_m,
)
return arrows
def _base_pipes(
vertices: list[RouteVertex], stream_features: list[dict[str, Any]]
) -> list[StructureCandidate]:
"""도로 × 세류선 교차점을 기본 관 위치로 삼는다. 300m 보충 배치는 다음 단계다."""
pipes: list[StructureCandidate] = []
for candidate in find_stream_crossings(vertices, stream_features):
if pipes and candidate.chainage_m - pipes[-1].chainage_m < DRAINAGE_PIPE_MIN_SPACING_M:
continue
pipes.append(candidate)
return pipes
# ── 흐름 강도 곡선 ──────────────────────────────────────────────────────────
def _strength_by_chainage(
road_chainage: np.ndarray, strength_area: np.ndarray, total_length: float
) -> np.ndarray:
"""도로 셀 강도를 1m 누가거리 구간으로 합산한 곡선(㎡/m 구간 합)."""
bins = max(1, int(np.ceil(total_length)) + 1)
if road_chainage.size == 0:
return np.zeros(bins)
index = np.clip(np.round(road_chainage).astype(np.int64), 0, bins - 1)
return np.bincount(index, weights=strength_area, minlength=bins)
def _downsample_strength(curve: np.ndarray) -> list[tuple[float, float]]:
"""응답용으로 강도 곡선을 일정 간격으로 줄인다(구간 합 유지).
끝자락을 잘라내면 종점 부근 유입 면적이 통째로 사라지므로 0으로 채워 맞춘다.
"""
step = max(1, int(_STRENGTH_OUTPUT_STEP_M))
if curve.size == 0:
return []
padding = (-curve.size) % step
padded = np.append(curve, np.zeros(padding)) if padding else curve
summed = padded.reshape(-1, step).sum(axis=1)
return [
(float(position * step), float(value)) for position, value in enumerate(summed) if value > 0
]
@@ -0,0 +1,265 @@
"""등고선 기반 흐름 방향 — 높은 등고 라인에서 낮은 등고 라인으로 내려가며 방향을 세운다.
기존 방식(등고선 TIN 보간 지표면 기울기 D8) 보간면이 만든 가짜 웅덩이와 평탄
삼각형 때문에 흐름이 중간에서 끊겼다. 실데이터에서 채움·평탄해소를 거치고도 싱크가 수천
남았고, 싱크 하나가 상류 유역 전체를 통째로 삼켰다.
여기서는 **보간면을 거치지 않는다.** 등고선을 격자에 직접 굽고, 셀마다 "내가 속한 등고
라인보다 낮은 등고 라인"이 어디인지를 찾아 그쪽으로 방향을 준다(2026-07-31 사용자 지시).
등고선을 격자에 굽는다 셀이 어느 표고의 라인 위인지 기록
셀마다 가장 가까운 등고 라인을 찾아 표고를 '밴드' 삼는다
표고가 높은 밴드부터 내려오며, 밴드에서 ** 낮은 등고 라인까지의 거리** 잰다
위치에너지 = 밴드 순위 × + 거리
수신 = 위치에너지가 낮은 8이웃 화살표 방향에 가장 가까운
위치에너지는 흐름을 따라 반드시 감소한다. 그래서 **순환도 웅덩이도 원리적으로 생기지
않는다** 채움이나 평탄면 해소가 아예 필요 없다.
덕분에 화면 화살표(32방위) 실제 추적 경로가 항상 같은 방향을 가리킨다. 예전에는
화살표는 기울기, 추적은 D8이라 서로 어긋나 눈으로 검증할 수가 없었다.
"""
from __future__ import annotations
import logging
import math
from dataclasses import dataclass
from typing import Any
import numpy as np
from rasterio.features import rasterize
from scipy.ndimage import distance_transform_edt
from shapely.geometry import shape
from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Grid import (
AZIMUTH_INVALID,
AZIMUTH_SINK,
AZIMUTH_STEPS,
GridSpec,
_feature_elevation,
grid_transform,
iter_linestrings,
)
from config.config_system import DRAINAGE_CONTOUR_MIN_LENGTH_M
logger = logging.getLogger(__name__)
# 8이웃 (행 증분, 열 증분).
_NEIGHBOURS = (
(-1, -1),
(-1, 0),
(-1, 1),
(0, -1),
(0, 1),
(1, -1),
(1, 0),
(1, 1),
)
@dataclass
class ContourDescent:
"""등고선에서 직접 세운 흐름 방향 격자."""
spec: GridSpec
band_elevation: np.ndarray # (R, C) float32 — 셀이 속한 등고 라인 표고, 무효는 NaN
valid: np.ndarray # (R, C) bool — 방향을 세운 셀
receiver: np.ndarray # (R*C,) int32 — 다음 셀, 최하단 밴드는 자기 자신
step_length: np.ndarray # (R*C,) float32
azimuth: np.ndarray # (R*C,) int16 — 32방위 코드(32=제자리, 33=무효)
levels: list[float] # 사용된 등고 표고(내림차순)
def rasterize_contours(
spec: GridSpec,
contour_features: list[dict[str, Any]],
elevation_floor_m: float | None = None,
) -> tuple[np.ndarray, list[float]]:
"""등고선을 격자에 굽는다. 셀마다 그 위를 지나는 등고 라인의 표고(없으면 NaN)."""
by_level: dict[float, list[Any]] = {}
for feature in contour_features:
geometry = feature.get("geometry")
if not geometry:
continue
elevation = _feature_elevation(feature.get("properties") or {})
if elevation is None:
continue
if elevation_floor_m is not None and elevation < elevation_floor_m:
continue
try:
parsed = shape(geometry)
except Exception: # noqa: BLE001
continue
for line in iter_linestrings(parsed):
if line.length < DRAINAGE_CONTOUR_MIN_LENGTH_M:
continue
by_level.setdefault(float(elevation), []).append(line)
burned = np.full((spec.n_rows, spec.n_cols), np.nan, dtype=np.float32)
levels = sorted(by_level, reverse=True)
transform = grid_transform(spec)
for elevation in levels:
stamp = rasterize(
[(line, 1) for line in by_level[elevation]],
out_shape=(spec.n_rows, spec.n_cols),
transform=transform,
fill=0,
dtype="uint8",
all_touched=True,
).astype(bool)
# 낮은 표고부터 덮어써야 겹치는 셀이 낮은 라인으로 남는다 — 물은 낮은 쪽으로 간다.
burned[stamp] = elevation
logger.info(
"배수유역: 등고 라인 %d단(%.0f~%.0fm)을 격자에 굽어 %d",
len(levels),
levels[-1] if levels else 0.0,
levels[0] if levels else 0.0,
int(np.isfinite(burned).sum()),
)
return burned, levels
def build_contour_descent(
spec: GridSpec,
contour_features: list[dict[str, Any]],
domain: np.ndarray | None = None,
elevation_floor_m: float | None = None,
) -> ContourDescent:
"""등고선만으로 셀별 흐름 방향을 세운다. 보간면을 만들지 않는다."""
rows, cols = spec.n_rows, spec.n_cols
burned, levels = rasterize_contours(spec, contour_features, elevation_floor_m)
empty = ContourDescent(
spec=spec,
band_elevation=np.full((rows, cols), np.nan, dtype=np.float32),
valid=np.zeros((rows, cols), dtype=bool),
receiver=np.arange(spec.size, dtype=np.int32),
step_length=np.zeros(spec.size, dtype=np.float32),
azimuth=np.full(spec.size, AZIMUTH_INVALID, dtype=np.int16),
levels=levels,
)
if len(levels) < 2:
logger.warning("배수유역: 등고 라인이 2단 미만이라 방향을 세울 수 없습니다.")
return empty
on_contour = np.isfinite(burned)
# ② 셀마다 가장 가까운 등고 라인의 표고 = 그 셀의 밴드.
_, (near_row, near_col) = distance_transform_edt(~on_contour, return_indices=True)
band_elevation = burned[near_row, near_col].astype(np.float32)
inside = domain if domain is not None else np.ones((rows, cols), dtype=bool)
band_elevation = np.where(inside, band_elevation, np.nan)
# ③④ 높은 밴드부터 내려오며 한 단 낮은 등고 라인까지의 거리와 목표 셀을 구한다.
distance = np.full((rows, cols), np.inf, dtype=np.float32)
target_row = np.zeros((rows, cols), dtype=np.int32)
target_col = np.zeros((rows, cols), dtype=np.int32)
band_rank = np.full((rows, cols), -1, dtype=np.int32)
for rank, elevation in enumerate(levels[:-1]):
members = inside & (band_elevation == elevation)
if not members.any():
continue
lower = on_contour & (burned < elevation)
if not lower.any():
continue
step_distance, (step_row, step_col) = distance_transform_edt(~lower, return_indices=True)
distance[members] = step_distance[members].astype(np.float32)
target_row[members] = step_row[members]
target_col[members] = step_col[members]
band_rank[members] = len(levels) - 1 - rank # 높을수록 큰 값
# 최하단 밴드는 더 내려갈 등고 라인이 없다. 무효로 빼지 않고 **정지 셀**로 남긴다 —
# 그래야 그 자리가 도로·세류선이면 적색으로 잡히고, 아니면 물이 고이는 지점으로 보인다.
lowest = inside & (band_elevation == levels[-1]) & (band_rank < 0)
if lowest.any():
distance[lowest] = 0.0
band_rank[lowest] = 0
valid = band_rank >= 0
if not valid.any():
logger.warning("배수유역: 하강 방향을 세운 셀이 없습니다.")
return empty
# 밴드가 하나 낮아지면 위치에너지가 반드시 떨어지도록 거리 최대치보다 큰 간격을 준다.
finite = distance[valid & np.isfinite(distance)]
span = (float(finite.max()) if finite.size else 1.0) + 2.0
distance[valid & ~np.isfinite(distance)] = 0.0
potential = np.where(valid, band_rank.astype(np.float64) * span + distance, np.inf)
receiver, step_length, azimuth = _route_by_potential(
spec, potential, valid, target_row, target_col
)
logger.info(
"배수유역: 등고선 하강 방향 %d셀 (밴드 %d단), 최하단 정지 %d",
int(valid.sum()),
int(band_rank[valid].max() - band_rank[valid].min() + 1),
int((azimuth == AZIMUTH_SINK).sum()),
)
return ContourDescent(
spec=spec,
band_elevation=np.where(valid, band_elevation, np.nan).astype(np.float32),
valid=valid,
receiver=receiver,
step_length=step_length,
azimuth=azimuth,
levels=levels,
)
def _route_by_potential(
spec: GridSpec,
potential: np.ndarray,
valid: np.ndarray,
target_row: np.ndarray,
target_col: np.ndarray,
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""위치에너지가 낮은 8이웃 중 **화살표 방향에 가장 가까운** 셀을 수신 셀로 고른다.
화살표는 "한 단 낮은 등고 라인 쪽" 가리키는 연속 방위이고, 수신 셀은 방위에 가장
가까운 이웃이다. 그래서 화면 화살표와 실제 추적 경로가 어긋나지 않는다.
위치에너지가 낮은 이웃만 후보로 두므로 순환이 생기지 않는다.
"""
rows, cols = spec.n_rows, spec.n_cols
grid_row, grid_col = np.meshgrid(np.arange(rows), np.arange(cols), indexing="ij")
# 목표(한 단 낮은 등고 라인 위의 셀)를 향하는 연속 방위.
aim_row = (target_row - grid_row).astype(np.float64)
aim_col = (target_col - grid_col).astype(np.float64)
aim_norm = np.hypot(aim_row, aim_col)
aim_norm[aim_norm == 0.0] = 1.0
aim_row /= aim_norm
aim_col /= aim_norm
padded = np.full((rows + 2, cols + 2), np.inf)
padded[1:-1, 1:-1] = potential
flat_index = np.arange(spec.size, dtype=np.int32).reshape(rows, cols)
padded_index = np.full((rows + 2, cols + 2), -1, dtype=np.int32)
padded_index[1:-1, 1:-1] = flat_index
best_score = np.full((rows, cols), -np.inf)
receiver = flat_index.copy()
step = np.zeros((rows, cols), dtype=np.float32)
centre = potential
for row_shift, col_shift in _NEIGHBOURS:
neighbour = padded[
1 + row_shift : 1 + row_shift + rows, 1 + col_shift : 1 + col_shift + cols
]
length = math.hypot(row_shift, col_shift)
# 방위 일치도(코사인 유사도)가 클수록 좋은 후보다.
score = (aim_row * row_shift + aim_col * col_shift) / length
better = valid & np.isfinite(neighbour) & (neighbour < centre) & (score > best_score)
if not better.any():
continue
best_score = np.where(better, score, best_score)
neighbour_index = padded_index[
1 + row_shift : 1 + row_shift + rows, 1 + col_shift : 1 + col_shift + cols
]
receiver = np.where(better, neighbour_index, receiver)
step = np.where(better, np.float32(length * spec.cell_m), step)
moved = receiver != flat_index
delta_row = (receiver // cols - flat_index // cols).astype(np.float64)
delta_col = (receiver % cols - flat_index % cols).astype(np.float64)
angle = np.arctan2(delta_row, delta_col)
code = np.rint(angle / (2.0 * math.pi / AZIMUTH_STEPS)).astype(np.int16) % AZIMUTH_STEPS
azimuth = np.where(moved, code, AZIMUTH_SINK)
azimuth = np.where(valid, azimuth, AZIMUTH_INVALID)
return receiver.reshape(-1), step.reshape(-1), azimuth.reshape(-1).astype(np.int16)
@@ -0,0 +1,255 @@
"""해석 영역 확장 — 최외곽의 **적색 셀** 주변으로 넓히며 다시 분석한다.
적색 셀이 해석 영역 최외곽에 있다는 것은 바깥에서 물이 흘러 들어온다는 뜻이다.
거기서 멈추면 유역이 잘린다. 반대로 최외곽이 전부 파랑이면 바깥 물은 도로로 오지
않으므로 필요가 없다.
현재 해석 영역의 최외곽 **적색** 것을 찾는다
주변으로 (설정 ) 넓힌다
넓힌 영역으로 흐름 방향·색을 다시 분석한다
**새로 추가된 셀에 적색이 하나도 없으면 종료** (2026-07-31 사용자 지시)
격자 bbox에 닿으면 격자 자체도 정수배로 넓힌다 도로 시작점 기준 격자점은 유지된다.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Any
import numpy as np
from shapely.geometry import LineString
from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Descent import (
ContourDescent,
build_contour_descent,
)
from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Flow import (
FlowClassification,
RoadRaster,
burn_stream_flow,
classify_flow,
outermost_cells,
rasterize_road,
)
from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Grid import GridSpec, TerrainGrid
from config.config_system import (
DRAINAGE_RED_EXPAND_BAND_M,
DRAINAGE_RED_EXPAND_MAX_ROUNDS,
DRAINAGE_ROAD_WIDTH_M,
)
logger = logging.getLogger(__name__)
@dataclass
class GridAnalysis:
"""한 회차 분석 결과 — 격자·해석 영역·방향·색까지 한 묶음."""
spec: GridSpec
domain: np.ndarray # (R, C) bool — 해석 대상 셀
descent: ContourDescent
terrain: TerrainGrid
road: RoadRaster
flow: FlowClassification
@dataclass
class RedExpansion:
"""확장 루프 결과."""
analysis: GridAnalysis
rounds: int # 실제로 넓힌 횟수 (0 = 처음부터 최외곽에 적색이 없었음)
closed: bool # 새로 추가한 셀에 적색이 없어 스스로 멈췄는가
added_cells: int # 확장으로 늘어난 셀 수
def analyze_domain(
spec: GridSpec,
domain: np.ndarray,
contour_features: list[dict[str, Any]],
route_line: LineString,
upstream_streams: list[LineString],
elevation_floor_m: float | None = None,
descent: ContourDescent | None = None,
) -> GridAnalysis | None:
"""주어진 격자·해석 영역에 대해 등고선 하강 방향과 도로 도달 색을 한 번 계산한다.
**하강 방향장은 해석 영역과 무관하다** 등고선 기하만으로 정해진다. 그래서 확장
회차마다 다시 계산하지 않고, 격자가 커졌을 때만 새로 만들어 넘겨받는다(`descent`).
해석 영역은 마지막에 마스크로만 씌운다.
"""
if descent is None or descent.spec != spec:
descent = build_contour_descent(spec, contour_features, None, elevation_floor_m)
valid = descent.valid & domain
if not valid.any():
return None
terrain = TerrainGrid(
spec=spec,
elevation=np.where(valid, descent.band_elevation, np.nan).astype(np.float32),
valid=valid,
receiver=descent.receiver,
step_length=descent.step_length,
)
road = rasterize_road(spec, route_line, DRAINAGE_ROAD_WIDTH_M)
terrain, burned = burn_stream_flow(terrain, road, upstream_streams)
flow = classify_flow(terrain, road, burned, azimuth=descent.azimuth)
return GridAnalysis(
spec=spec, domain=domain, descent=descent, terrain=terrain, road=road, flow=flow
)
def expand_by_red_boundary(
spec: GridSpec,
domain: np.ndarray,
contour_features: list[dict[str, Any]],
route_line: LineString,
upstream_streams: list[LineString],
elevation_floor_m: float | None = None,
band_m: float = DRAINAGE_RED_EXPAND_BAND_M,
max_rounds: int = DRAINAGE_RED_EXPAND_MAX_ROUNDS,
) -> RedExpansion | None:
"""최외곽 적색 셀 주변으로 넓히며, 새로 추가한 셀에 적색이 없을 때까지 반복한다."""
band_cells = max(1, int(round(band_m / spec.cell_m)))
# 1차 영역의 bbox는 영역에 딱 붙어 있어 첫 회차부터 격자를 넓혀야 한다. 미리 여유를
# 두면 방향장을 다시 만들지 않고 해석 영역만 넓히며 몇 회차를 돌 수 있다.
spec, domain = _pad_spec(spec, domain, band_cells * 2)
started_cells = int(domain.sum())
analysis = analyze_domain(
spec, domain, contour_features, route_line, upstream_streams, elevation_floor_m
)
if analysis is None:
return None
rounds = 0
closed = False
for attempt in range(max_rounds):
current = analysis.spec
reaches = analysis.flow.reaches_road.reshape(current.n_rows, current.n_cols)
rim_red = outermost_cells(analysis.domain) & reaches
if not rim_red.any():
closed = True # 최외곽이 전부 파랑 — 바깥 물은 도로로 오지 않는다
break
grown_spec, grown_domain, grown_rim = _grow_for_rim(
current, analysis.domain, rim_red, band_cells
)
widened = grown_domain | _dilate_by(grown_rim, band_cells)
added_mask = widened & ~grown_domain
if not added_mask.any():
closed = True
break
logger.info(
"배수유역: %d회차 확장 — 최외곽 적색 %d셀 주변 %.0fm, 셀 %d개 추가",
attempt + 1,
int(rim_red.sum()),
band_m,
int(added_mask.sum()),
)
widened_analysis = analyze_domain(
grown_spec,
widened,
contour_features,
route_line,
upstream_streams,
elevation_floor_m,
# 격자가 그대로면 방향장을 재사용한다 — 등고선 기하가 안 바뀌었으므로 결과는 같다.
descent=analysis.descent if grown_spec == current else None,
)
if widened_analysis is None:
break
analysis = widened_analysis
rounds += 1
added_reaches = widened_analysis.flow.reaches_road.reshape(
grown_spec.n_rows, grown_spec.n_cols
)
if not (added_mask & added_reaches).any():
closed = True # 새로 추가한 셀에 적색이 없다 — 여기까지가 유역이다
logger.info("배수유역: 새로 추가한 셀에 적색이 없어 확장을 멈춥니다.")
break
else:
logger.warning("배수유역: 확장 상한(%d회)에 도달했습니다.", max_rounds)
added = int(analysis.domain.sum()) - started_cells
logger.info(
"배수유역: 확장 %d회, 셀 %d%d (+%d), %s",
rounds,
started_cells,
int(analysis.domain.sum()),
added,
"닫힘" if closed else "미닫힘",
)
return RedExpansion(analysis=analysis, rounds=rounds, closed=closed, added_cells=added)
def _dilate_by(mask: np.ndarray, steps: int) -> np.ndarray:
"""8이웃 팽창을 `steps`회 반복한다(정사각 커널이라 반경 = steps 셀)."""
rows, cols = mask.shape
result = mask
for _ in range(steps):
padded = np.zeros((rows + 2, cols + 2), dtype=bool)
padded[1:-1, 1:-1] = result
grown = np.zeros_like(result)
for row_shift in (0, 1, 2):
for col_shift in (0, 1, 2):
grown |= padded[row_shift : row_shift + rows, col_shift : col_shift + cols]
result = grown
return result
def _pad_spec(spec: GridSpec, domain: np.ndarray, cells: int) -> tuple[GridSpec, np.ndarray]:
"""격자에 사방 여유를 두고 해석 영역 마스크를 그 안으로 옮겨 담는다."""
if cells <= 0:
return spec, domain
padded_spec = GridSpec(
x_min=spec.x_min - cells * spec.cell_m,
y_max=spec.y_max + cells * spec.cell_m,
cell_m=spec.cell_m,
n_rows=spec.n_rows + 2 * cells,
n_cols=spec.n_cols + 2 * cells,
)
padded = np.zeros((padded_spec.n_rows, padded_spec.n_cols), dtype=bool)
padded[cells : cells + spec.n_rows, cells : cells + spec.n_cols] = domain
return padded_spec, padded
def _grow_for_rim(
spec: GridSpec, domain: np.ndarray, rim: np.ndarray, band_cells: int
) -> tuple[GridSpec, np.ndarray, np.ndarray]:
"""적색 최외곽이 격자 bbox에 닿았으면 그 방향으로 격자를 넓히고 마스크를 옮겨 담는다.
격자는 정수배로만 넓히므로 도로 시작점 기준 격자점이 그대로 유지된다.
"""
north = band_cells if rim[0, :].any() else 0
south = band_cells if rim[-1, :].any() else 0
west = band_cells if rim[:, 0].any() else 0
east = band_cells if rim[:, -1].any() else 0
if not (north or south or west or east):
return spec, domain, rim
grown = GridSpec(
x_min=spec.x_min - west * spec.cell_m,
y_max=spec.y_max + north * spec.cell_m,
cell_m=spec.cell_m,
n_rows=spec.n_rows + north + south,
n_cols=spec.n_cols + west + east,
)
new_domain = np.zeros((grown.n_rows, grown.n_cols), dtype=bool)
new_rim = np.zeros_like(new_domain)
new_domain[north : north + spec.n_rows, west : west + spec.n_cols] = domain
new_rim[north : north + spec.n_rows, west : west + spec.n_cols] = rim
logger.info(
"배수유역: 격자 확대 %d×%d%d×%d (북%d%d%d%d 셀)",
spec.n_rows,
spec.n_cols,
grown.n_rows,
grown.n_cols,
north,
south,
west,
east,
)
return grown, new_domain, new_rim
@@ -0,0 +1,191 @@
"""배수유역 단계별 검증 산출물을 영구저장소에 남긴다.
기능을 하나씩 붙일 때마다 단계의 결과를 파일로 남겨 사람이 QGIS 등으로 직접 열어
대조할 있게 하는 것이 목적이다(2026-07-31 사용자 지시). 단계를 추가할 때는
`STAGES` 이름을 하나 넣고 `write_stage()` 호출하면 된다 파일명 규칙과 매니페스트
갱신은 여기서 일괄로 처리한다.
저장 위치: `storage/{회사}/{사용자}/{프로젝트}/B04_wf1_Surface/drainage/`
- `{단계번호}_{단계이름}.geojson` WGS84 FeatureCollection, 피처마다 `kind` 속성
- `manifest.json` 지금까지 남긴 단계 목록과 요약값
"""
from __future__ import annotations
import json
import logging
from collections.abc import Sequence
from datetime import datetime
from pathlib import Path
from typing import Any, Callable
import numpy as np
from shapely.geometry.base import BaseGeometry
from common_util.common_util_storage import resolve_stored_project_path
from config.config_system import DRAINAGE_CACHE_DIRNAME
logger = logging.getLogger(__name__)
# 단계 이름 → 파일 접두 번호. 순서대로 읽으면 파이프라인 진행 순서가 된다.
STAGES: dict[str, str] = {
"primary_region": "01",
"flow_direction": "02",
# B05가 읽어 세부유역을 나누는 데 필요한 최소 배열·기하. 화살표·표고는 넣지 않는다.
"road_routing": "03",
}
_MANIFEST_FILENAME = "manifest.json"
LonLat = Callable[[float, float], tuple[float, float]]
def drainage_dir(stored_path: str) -> Path:
return (
Path(resolve_stored_project_path(stored_path)) / "B04_wf1_Surface" / DRAINAGE_CACHE_DIRNAME
)
def write_stage(
stored_path: str,
stage: str,
layers: dict[str, Sequence[BaseGeometry | tuple[BaseGeometry, dict[str, Any]]]],
properties: dict[str, Any],
to_lonlat: LonLat,
) -> str | None:
"""한 단계의 기하 산출물을 GeoJSON으로 저장하고 매니페스트를 갱신한다.
`layers` {레이어이름: 사업지 CRS(m) 기하 목록}이며 레이어 이름이 피처 `kind` 속성이
된다. 기하 대신 `(기하, 속성dict)` 짝을 넣으면 속성이 피처에 함께 실린다.
좌표는 여기서 WGS84로 바꾼다 저장 파일은 어떤 도구로 열어도 바로 보여야 한다.
"""
prefix = STAGES.get(stage)
if prefix is None:
logger.warning("배수유역: 등록되지 않은 저장 단계 '%s' — 저장을 건너뜁니다.", stage)
return None
features: list[dict[str, Any]] = []
counts: dict[str, int] = {}
for kind, entries in layers.items():
for index, entry in enumerate(entries):
# 항목은 기하 하나이거나 (기하, 속성) 짝이다 — 관 누가거리처럼 붙일 값이 있을 때 쓴다.
geometry, extra = entry if isinstance(entry, tuple) else (entry, None)
feature = _to_feature(kind, index, geometry, to_lonlat, extra)
if feature is not None:
features.append(feature)
counts[kind] = len(entries)
filename = f"{prefix}_{stage}.geojson"
directory = drainage_dir(stored_path)
target = directory / filename
document = {
"type": "FeatureCollection",
"crs": {"type": "name", "properties": {"name": "urn:ogc:def:crs:OGC:1.3:CRS84"}},
"properties": {**properties, "counts": counts},
"features": features,
}
try:
directory.mkdir(parents=True, exist_ok=True)
with target.open("w", encoding="utf-8") as file:
json.dump(document, file, ensure_ascii=False)
except OSError:
logger.warning("배수유역: %s 저장 실패 (%s)", stage, target)
return None
_update_manifest(directory, stage, filename, {**properties, "counts": counts})
logger.info("배수유역: %s 저장 — %s (피처 %d개)", stage, target, len(features))
return str(target)
def _to_feature(
kind: str,
index: int,
geometry: BaseGeometry,
to_lonlat: LonLat,
extra: dict[str, Any] | None = None,
) -> dict[str, Any] | None:
coordinates = _to_lonlat_coords(geometry, to_lonlat)
if coordinates is None:
return None
return {
"type": "Feature",
"properties": {"kind": kind, "index": index, **(extra or {})},
"geometry": {"type": geometry.geom_type, "coordinates": coordinates},
}
def _to_lonlat_coords(geometry: BaseGeometry, to_lonlat: LonLat) -> Any:
"""shapely 기하를 WGS84 GeoJSON 좌표 배열로 바꾼다."""
if geometry.is_empty:
return None
kind = geometry.geom_type
if kind == "Point":
return list(to_lonlat(geometry.x, geometry.y))
if kind == "LineString":
return [list(to_lonlat(x, y)) for x, y in geometry.coords]
if kind == "Polygon":
return [
[list(to_lonlat(x, y)) for x, y in ring.coords]
for ring in (geometry.exterior, *geometry.interiors)
]
if kind in {"MultiPoint", "MultiLineString", "MultiPolygon", "GeometryCollection"}:
parts = [_to_lonlat_coords(part, to_lonlat) for part in geometry.geoms]
return [part for part in parts if part is not None]
return None
def write_grid_arrays(
stored_path: str, stage: str, spec: Any, arrays: dict[str, Any], summary: dict[str, Any]
) -> str | None:
"""격자 크기의 배열들을 `.npz`로 남긴다(셀 마스크·흐름 방향·도달 여부 등).
셀이 수십만 개라 GeoJSON 폴리곤으로는 남긴다. 격자 원점· 크기와 배열만 저장하면
어느 셀이 어떤 값이었는지 그대로 복원된다. 요약값은 manifest에도 기록한다.
"""
prefix = STAGES.get(stage)
if prefix is None or not arrays:
return None
directory = drainage_dir(stored_path)
target = directory / f"{prefix}_{stage}.npz"
try:
directory.mkdir(parents=True, exist_ok=True)
np.savez_compressed(
target,
x_min=spec.x_min,
y_max=spec.y_max,
cell_m=spec.cell_m,
n_rows=spec.n_rows,
n_cols=spec.n_cols,
**arrays,
)
except OSError:
logger.warning("배수유역: %s 배열 저장 실패 (%s)", stage, target)
return None
_update_manifest(directory, f"{stage}_arrays", target.name, summary)
logger.info("배수유역: %s 배열 저장 — %s (%s)", stage, target, ", ".join(arrays))
return str(target)
def _update_manifest(
directory: Path, stage: str, filename: str, properties: dict[str, Any]
) -> None:
"""지금까지 남긴 단계 목록을 한 파일에 모아 둔다 — 무엇이 저장돼 있는지 한눈에 본다."""
manifest_path = directory / _MANIFEST_FILENAME
manifest: dict[str, Any] = {}
if manifest_path.exists():
try:
with manifest_path.open("r", encoding="utf-8") as file:
loaded = json.load(file)
if isinstance(loaded, dict):
manifest = loaded
except (OSError, json.JSONDecodeError):
logger.warning("배수유역: manifest를 읽지 못해 새로 만듭니다 (%s).", manifest_path)
manifest[stage] = {
"file": filename,
"saved_at": datetime.now().isoformat(timespec="seconds"),
"properties": properties,
}
try:
with manifest_path.open("w", encoding="utf-8") as file:
json.dump(manifest, file, ensure_ascii=False, indent=2)
except OSError:
logger.warning("배수유역: manifest 저장 실패 (%s).", manifest_path)
@@ -0,0 +1,574 @@
"""배수유역 흐름 해석 — 도로 굽기 · 상류 추적(포인터 더블링) · 유역 폴리곤화.
핵심은 하나다: **물길을 따라가 도로에 닿는 셀만 유역이다.**
셀마다 D8 수신 셀을 따라가 종착점(root) 구하고, 종착점이 도로 셀이면 활성이다.
방식은 능선을 따로 찾지 않는다. 능선 너머 셀의 물은 다른 계곡으로 빠져 도로에
닿지 못하므로 자동으로 비활성이 되고, 경계선이 능선이다. 유역 안쪽 봉우리는
물이 결국 도로로 흘러 자동으로 포함된다.
종착점은 세부유역 라벨의 근거로도 그대로 쓴다 셀이 도달한 도로 셀이 정해지면
도로 셀을 담당하는 관이 셀의 유역 번호다. 배치가 바뀌어도 격자 해석을
다시 돌릴 필요 없이 "도로 셀 → 관" 대응만 다시 계산하면 된다.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Any
import numpy as np
from rasterio.features import rasterize, shapes
from scipy.spatial import cKDTree
from shapely.geometry import LineString, MultiPolygon, Polygon, shape
from shapely.ops import unary_union
from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Grid import (
AZIMUTH_STEPS,
ContourCloud,
GridSpec,
TerrainGrid,
build_cell_mask,
build_terrain_grid,
descent_azimuth,
expand_grid_spec,
grid_transform,
)
from config.config_system import (
DRAINAGE_EXPAND_STEP_M,
DRAINAGE_MAX_EXPAND_ROUNDS,
DRAINAGE_MIN_BASIN_AREA_M2,
DRAINAGE_POLYGON_SIMPLIFY_M,
DRAINAGE_ROAD_WIDTH_M,
)
logger = logging.getLogger(__name__)
# 포인터 더블링 반복 상한. 한 번에 경로 길이가 2배가 되므로 2^40 스텝이면 어떤 격자도 덮는다.
_MAX_DOUBLING_ROUNDS = 40
# 8이웃 (행 증분, 열 증분) — 최외곽 판정용. 거리는 쓰지 않는다.
_NEIGHBOR_SHIFTS = (
(-1, 0, 1.0),
(1, 0, 1.0),
(0, -1, 1.0),
(0, 1, 1.0),
(-1, -1, 1.0),
(-1, 1, 1.0),
(1, -1, 1.0),
(1, 1, 1.0),
)
@dataclass
class RoadRaster:
"""격자에 구운 도로. 도로 셀은 흐름을 흡수하는 종착점이 된다."""
mask: np.ndarray # (R, C) bool
cell_index: np.ndarray # (K,) int32 — 도로 셀의 평탄 인덱스
chainage: np.ndarray # (K,) float64 — 도로 셀의 누가거리(m)
slot_of_cell: np.ndarray # (R*C,) int32 — 도로 셀이면 K 내 위치, 아니면 −1
@property
def count(self) -> int:
return int(self.cell_index.size)
@dataclass
class FlowResult:
"""상류 추적 결과."""
root: np.ndarray # (R*C,) int32 — 흐름 종착 셀의 평탄 인덱스
road_slot: np.ndarray # (R*C,) int32 — 도달한 도로 셀 슬롯, 도달 못하면 −1
active: np.ndarray # (R, C) bool — 도로에 물이 닿는 셀
path_length: np.ndarray # (R*C,) float32 — 종착점까지 물길 길이(m)
strength: np.ndarray # (K,) int64 — 도로 셀별 상류 셀 수(흐름 강도)
# ── 도로 굽기 ───────────────────────────────────────────────────────────────
def rasterize_road(
spec: GridSpec,
route_line: LineString,
width_m: float = DRAINAGE_ROAD_WIDTH_M,
) -> RoadRaster:
"""노선을 노폭만큼 두껍게 격자에 굽고, 각 도로 셀에 누가거리를 붙인다.
폭을 주는 이유는 실제 노면이 물을 받기 때문이기도 하지만, 1 선으로 구우면 D8
대각 이동이 도로를 건너뛰어 상류 물이 도로를 지나쳐 버리기 때문이다. 3 이상 두께면
내리막 물길이 반드시 도로 셀을 번은 밟는다.
"""
half_width = max(width_m / 2.0, spec.cell_m)
burned = rasterize(
[(route_line.buffer(half_width), 1)],
out_shape=(spec.n_rows, spec.n_cols),
transform=grid_transform(spec),
fill=0,
dtype="uint8",
all_touched=True,
).astype(bool)
slot_of_cell = np.full(spec.size, -1, dtype=np.int32)
cell_index = np.flatnonzero(burned.reshape(-1)).astype(np.int32)
if cell_index.size == 0:
logger.warning("배수유역: 노선이 격자 범위 밖입니다 — 도로 셀 0개.")
return RoadRaster(burned, cell_index, np.zeros(0), slot_of_cell)
# 도로 셀 누가거리는 노선을 촘촘히 샘플링해 가장 가까운 샘플의 누가거리로 준다.
step = max(spec.cell_m / 2.0, 0.25)
positions = np.arange(0.0, route_line.length + step, step)
samples = np.array([list(route_line.interpolate(p).coords)[0] for p in positions])
rows = (cell_index // spec.n_cols).astype(np.float64)
cols = (cell_index % spec.n_cols).astype(np.float64)
centers = np.column_stack(
(
spec.x_min + (cols + 0.5) * spec.cell_m,
spec.y_max - (rows + 0.5) * spec.cell_m,
)
)
_, nearest = cKDTree(samples).query(centers)
chainage = np.minimum(positions[nearest], route_line.length)
slot_of_cell[cell_index] = np.arange(cell_index.size, dtype=np.int32)
return RoadRaster(burned, cell_index, chainage, slot_of_cell)
# ── 상류 추적 ───────────────────────────────────────────────────────────────
def trace_flow(terrain: TerrainGrid, road: RoadRaster) -> FlowResult:
"""모든 셀의 물길 종착점을 구하고 도로 도달 여부(=유역 포함 여부)를 판정한다.
포인터 더블링으로 번에 경로 길이를 2배씩 늘려 종착점을 찾는다. 채움·평탄해소를
거친 표고에서는 흐름을 따라 표고가 단조 감소하므로 순환이 없고, 반복은 항상 끝난다.
"""
spec = terrain.spec
receiver = terrain.receiver.astype(np.int32, copy=True)
step_length = terrain.step_length.astype(np.float32, copy=True)
# 도로 셀은 흐름을 흡수한다 — 물이 도로에 닿으면 거기서 끝난다.
receiver[road.cell_index] = road.cell_index
step_length[road.cell_index] = 0.0
jump = receiver
path_length = step_length
for _ in range(_MAX_DOUBLING_ROUNDS):
next_jump = jump[jump]
if np.array_equal(next_jump, jump):
break
path_length = path_length + path_length[jump]
jump = next_jump
road_slot = road.slot_of_cell[jump]
active_flat = road_slot >= 0
strength = (
np.bincount(road_slot[active_flat], minlength=max(road.count, 1)).astype(np.int64)
if road.count
else np.zeros(0, dtype=np.int64)
)
logger.info(
"배수유역: 활성 셀 %d / %d (도로 셀 %d)", int(active_flat.sum()), spec.size, road.count
)
return FlowResult(
root=jump,
road_slot=road_slot,
active=active_flat.reshape(spec.n_rows, spec.n_cols),
path_length=path_length,
strength=strength,
)
def burn_stream_flow(
terrain: TerrainGrid, road: RoadRaster, streams: list[LineString]
) -> tuple[TerrainGrid, np.ndarray]:
"""확정된 상류 세류망을 따라 격자 흐름 방향을 강제로 새긴다.
등고선 TIN 보간면은 실제 물골(thalweg) 그대로 재현하지 못한다. 그래서 세류선
셀인데도 D8이 사면으로 흘려보내 도로에 닿지 못하는 일이 생긴다. 세류선은 이미
"도로를 건너 하류로 빠지는 물길" 확정된 자료이므로, 셀의 흐름 방향은 추정할
것이 아니라 **그대로 따라야 한다**(2026-07-31 사용자 지시).
세류선 셀은 물길 방향의 다음 셀을 수신 셀로 삼는다. 그러면 세류선 셀은 물론,
세류선으로 흘러드는 사면 셀까지 전부 도로에 도달한다. 도로 셀은 흡수점이므로 수신 셀은
건드리지 않되, 세류 목록에는 포함한다.
**표고 유무를 따지지 않는다.** 세류선은 확정된 자료이므로 등고선 TIN 껍질 밖이라
표고가 없는 셀이라도 물이 지나간다는 사실은 변하지 않는다. 표고를 조건으로 걸면 그런
셀이 새김에서 빠져 파랑·회색으로 남는다.
돌려주는 : (흐름이 새겨진 지형, 세류선이 지나는 마스크).
"""
spec = terrain.spec
receiver = terrain.receiver.copy()
step_length = terrain.step_length.copy()
road_cells = road.mask.reshape(-1)
burned = np.zeros(spec.size, dtype=bool)
tails: list[int] = []
# 셀이 몇 갈래에 속하는지 — 하류 끝이 합류 지점인지 판단하는 근거.
visits = np.zeros(spec.size, dtype=np.int32)
for line in streams:
chain = _line_cell_chain(spec, line)
if not chain:
continue
# 사슬의 **모든** 셀을 세류 셀로 표시한다 — 마지막 셀도 물길 위다.
burned[chain] = True
np.add.at(visits, np.unique(chain), 1)
for current, following in zip(chain, chain[1:]):
if road_cells[current] or not _is_neighbour(spec, current, following):
continue
receiver[current] = following
step_length[current] = _cell_distance(spec, current, following)
tails.append(chain[-1])
# 하류 끝이 도로에도 닿지 않고 다른 갈래와도 겹치지 않으면 그 갈래는 떠 있는 것이다.
# 지류가 본류 중간에 합류하는 경우는 끝 셀을 두 갈래가 공유하므로 정상이다.
detached = sum(1 for tail in tails if not road_cells[tail] and visits[tail] < 2)
if detached:
logger.warning(
"배수유역: 세류망 %d갈래의 하류 끝이 도로·다른 세류 어디에도 닿지 않습니다.", detached
)
logger.info("배수유역: 세류망 셀 %d개 표시 (세류 %d갈래)", int(burned.sum()), len(streams))
return (
TerrainGrid(
spec=spec,
elevation=terrain.elevation,
valid=terrain.valid,
receiver=receiver,
step_length=step_length,
),
burned,
)
def _line_cell_chain(spec: GridSpec, line: LineString) -> list[int]:
"""선을 따라 지나가는 셀을 순서대로 뽑는다(연속 중복 제거)."""
step = max(spec.cell_m / 2.0, 0.1)
positions = np.arange(0.0, line.length + step, step)
chain: list[int] = []
for position in positions:
point = line.interpolate(float(position))
col = int((point.x - spec.x_min) // spec.cell_m)
row = int((spec.y_max - point.y) // spec.cell_m)
if not (0 <= row < spec.n_rows and 0 <= col < spec.n_cols):
continue
index = row * spec.n_cols + col
if not chain or chain[-1] != index:
chain.append(index)
return chain
def _is_neighbour(spec: GridSpec, first: int, second: int) -> bool:
row_delta = abs(first // spec.n_cols - second // spec.n_cols)
col_delta = abs(first % spec.n_cols - second % spec.n_cols)
return max(row_delta, col_delta) == 1
def _cell_distance(spec: GridSpec, first: int, second: int) -> float:
row_delta = abs(first // spec.n_cols - second // spec.n_cols)
col_delta = abs(first % spec.n_cols - second % spec.n_cols)
return spec.cell_m * float(np.hypot(row_delta, col_delta))
@dataclass
class FlowClassification:
"""셀별 흐름 방향과 도로 도달 여부 — 확장 없이 현재 격자만 본 결과."""
direction: np.ndarray # (R*C,) int16 — 32방위 코드(32=제자리, 33=무효)
reaches_road: np.ndarray # (R*C,) bool — 물길을 따라가면 도로에 닿는가
analyzed: np.ndarray # (R*C,) bool — 실제로 판정한 셀
outer_seeds: int # 최외곽에서 출발해 판정한 셀 수
interior_seeds: int # 최외곽 추적에 안 걸려 따로 출발시킨 내부 셀 수
burned: np.ndarray | None = None # (R*C,) bool — 세류망을 따라 흐름을 새긴 셀
def outermost_cells(domain: np.ndarray) -> np.ndarray:
"""해석 영역의 최외곽 셀 — 영역 밖(또는 격자 밖)에 8이웃이 하나라도 닿는 셀."""
padded = np.zeros((domain.shape[0] + 2, domain.shape[1] + 2), dtype=bool)
padded[1:-1, 1:-1] = domain
exposed = np.zeros_like(domain)
rows, cols = domain.shape
for row_shift, col_shift, _ in _NEIGHBOR_SHIFTS:
neighbour = padded[
1 + row_shift : 1 + row_shift + rows, 1 + col_shift : 1 + col_shift + cols
]
exposed |= ~neighbour
return exposed & domain
def classify_flow(
terrain: TerrainGrid,
road: RoadRaster,
burned: np.ndarray | None = None,
azimuth: np.ndarray | None = None,
) -> FlowClassification:
"""최외곽 셀부터 물길을 따라가며 도로 도달 여부를 판정한다.
물이 흐르는 순서 그대로 따라간다 셀이 각자 도로를 바라보는 아니라, 셀에서
출발해 화살표가 가리키는 다음 , 셀이 가리키는 그다음 셀로 사슬처럼 이어 간다
(2026-07-31 사용자 지시).
판정 순서는 다음과 같다(2026-07-31 사용자 지시):
**세류선과 겹치는 셀을 먼저 적색으로 확정한다.** 세류망은 이미 "도로를 건너 하류로
빠지는 물길"로 확정된 자료다. 표고가 있든 없든 물이 지나간다는 사실은 변하지 않으므로
추적 결과를 기다릴 이유가 없다.
최외곽 셀에서 출발해 사슬을 따라간다.
사슬이 도로 셀이나 세류 셀에 닿으면 사슬 전체가 적색, 싱크에서 멈추거나 해석 영역
밖으로 나가면 전체가 미도달(파랑 채움 + 백색 화살표)이다.
이미 색이 정해진 셀을 만나면 ** 셀의 색을 그대로 물려받고** 끝낸다. 판정된 셀은
다시 분석하지 않는다.
최외곽 추적에 걸린 내부 셀을 그다음에 따로 출발시킨다.
`azimuth` 주면 32방위 코드를 그대로 화살표로 쓴다(등고선 하강 방향). 주지 않으면
지표면 기울기에서 뽑는다. **화살표는 실제 수신 셀과 같은 방향이어야 한다** 어긋나면
화살표로 사슬을 따라가는 검증이 성립하지 않는다.
"""
spec = terrain.spec
valid = terrain.valid.reshape(-1)
receiver = terrain.receiver
# 도로 셀과 세류망 셀 둘 다 "여기 닿으면 적색"인 종결점이다.
stream_cells = np.zeros(spec.size, dtype=bool) if burned is None else burned
absorbing = (road.mask.reshape(-1) & valid) | stream_cells
if azimuth is None:
direction = descent_azimuth(spec, terrain.elevation, terrain.valid, receiver, burned)
else:
direction = _azimuth_with_burn(spec, azimuth, receiver, burned)
reaches = np.zeros(spec.size, dtype=bool)
# 0=미방문, 1=경로에 올라 있음, 2=판정 완료
state = np.zeros(spec.size, dtype=np.int8)
# ⓪ 세류선과 겹치는 셀을 먼저 적색으로 못박는다.
reaches[stream_cells] = True
state[stream_cells] = 2
stream_done = int(stream_cells.sum())
outer = np.flatnonzero(outermost_cells(terrain.valid))
remaining = np.flatnonzero(valid)
outer_done = _walk_from(outer, receiver, valid, absorbing, state, reaches)
interior_done = _walk_from(remaining, receiver, valid, absorbing, state, reaches)
analyzed = state == 2
logger.info(
"배수유역: 흐름 판정 %d셀 (세류 선확정 %d / 최외곽 출발 %d / 내부 보충 %d) — "
"도로 도달 %d, 미도달 %d",
int(analyzed.sum()),
stream_done,
outer_done,
interior_done,
int((reaches & analyzed).sum()),
int((~reaches & analyzed).sum()),
)
return FlowClassification(
direction=direction,
reaches_road=reaches,
analyzed=analyzed,
outer_seeds=outer_done,
interior_seeds=interior_done,
burned=burned,
)
def _azimuth_with_burn(
spec: GridSpec, azimuth: np.ndarray, receiver: np.ndarray, burned: np.ndarray | None
) -> np.ndarray:
"""세류망을 따라 흐름을 새긴 셀은 그 수신 셀 방향으로 화살표를 덮어쓴다."""
direction = azimuth.astype(np.int16, copy=True)
if burned is None or not burned.any():
return direction
index = np.arange(spec.size, dtype=np.int64)
moved = burned & (receiver != index)
if not moved.any():
return direction
row_delta = (receiver[moved] // spec.n_cols - index[moved] // spec.n_cols).astype(np.float64)
col_delta = (receiver[moved] % spec.n_cols - index[moved] % spec.n_cols).astype(np.float64)
angle = np.arctan2(row_delta, col_delta)
direction[moved] = (
np.rint(angle / (2.0 * np.pi / AZIMUTH_STEPS)).astype(np.int16) % AZIMUTH_STEPS
)
return direction
def _walk_from(
starts: np.ndarray,
receiver: np.ndarray,
valid: np.ndarray,
absorbing: np.ndarray,
state: np.ndarray,
reaches: np.ndarray,
) -> int:
"""출발 셀에서 물길 사슬을 따라가며 판정하고, 사슬 전체에 같은 색을 적는다.
`absorbing` 도로 셀과 확정된 세류망 여기 닿으면 사슬 전체가 도로 도달이다.
새로 판정한 수를 돌려준다.
"""
resolved = 0
path: list[int] = []
for start in starts.tolist():
if state[start] == 2:
continue
path.clear()
node = start
while True:
if state[node] == 2:
verdict = bool(reaches[node]) # 이미 색이 정해진 셀 — 그 색을 물려받는다
break
if state[node] == 1: # 방어: 채움·평탄해소 후에는 순환이 없어야 한다
verdict = False
break
state[node] = 1
path.append(node)
if absorbing[node]:
verdict = True # 도로 또는 세류망에 합류 — 여기서 하류로 빠진다
break
following = int(receiver[node])
if following == node:
verdict = False # 싱크에 갇힘
break
if not valid[following] and state[following] != 2:
# 해석 영역 밖으로 빠짐. 단, 이미 색이 정해진 셀(세류 선확정 등)이면 따라간다.
verdict = False
break
node = following
for visited in path:
reaches[visited] = verdict
state[visited] = 2
resolved += len(path)
return resolved
# ── 격자 확장 (별도 단계) ───────────────────────────────────────────────────
@dataclass
class ExpansionResult:
"""확장 루프 결과. 확장을 쓰지 않는 경로에서는 이 모듈을 부르지 않는다."""
spec: GridSpec
terrain: TerrainGrid
road: RoadRaster
flow: FlowResult
rounds: int # 실제로 넓힌 횟수 (0 = 처음부터 닫혀 있었음)
closed: bool # 경계 링이 전부 비활성이 되어 스스로 멈췄는가
def expand_until_closed(
spec: GridSpec,
cloud: ContourCloud,
route_line: LineString,
region_area: Any = None,
road_width_m: float = DRAINAGE_ROAD_WIDTH_M,
max_rounds: int = DRAINAGE_MAX_EXPAND_ROUNDS,
step_m: float = DRAINAGE_EXPAND_STEP_M,
) -> ExpansionResult:
"""활성 셀이 격자 최외곽에 닿은 방향으로만 넓히며 유역이 닫힐 때까지 반복한다.
** 계산 경로에서만 쓰는 별도 단계다**(2026-07-31 사용자 지시로 분리). 단계 검증
미리보기는 확장 없이 현재 격자만 본다 확장 로직 자체가 아직 검증 대상이기 때문이다.
종료 조건은 반경 상한이 아니라 **경계 전체가 비활성** 되는 것이다. 비활성 셀이
최외곽에 띠로 완성되면 바깥은 필요가 없다. `max_rounds` 무한 반복 방지용이다.
`region_area` 주면 확장된 격자에서도 영역에 걸치는 셀만 해석 대상으로 삼는다.
"""
terrain = road = flow = None
rounds = 0
closed = False
for attempt in range(max_rounds + 1):
domain = build_cell_mask(spec, region_area) if region_area is not None else None
terrain = build_terrain_grid(spec, cloud, domain)
road = rasterize_road(spec, route_line, road_width_m)
flow = trace_flow(terrain, road)
contact = border_contact(flow.active)
if not any(contact.values()):
closed = True
break
if attempt == max_rounds:
logger.warning(
"배수유역: 확장 상한(%d회) 도달 — 경계 %s가 아직 활성입니다.",
max_rounds,
[side for side, touched in contact.items() if touched],
)
break
widened = expand_grid_spec(spec, contact, step_m)
if widened == spec:
break
logger.info(
"배수유역: 경계 %s 활성 — %.0fm 확장 (%d회차)",
[side for side, touched in contact.items() if touched],
step_m,
attempt + 1,
)
spec = widened
rounds += 1
assert terrain is not None and road is not None and flow is not None
return ExpansionResult(
spec=spec, terrain=terrain, road=road, flow=flow, rounds=rounds, closed=closed
)
def border_contact(active: np.ndarray) -> dict[str, bool]:
"""활성 셀이 격자 최외곽에 닿은 방향. 전부 False면 유역이 능선 안에서 닫힌 것이다."""
return {
"north": bool(active[0, :].any()),
"south": bool(active[-1, :].any()),
"west": bool(active[:, 0].any()),
"east": bool(active[:, -1].any()),
}
# ── 폴리곤화 ────────────────────────────────────────────────────────────────
def polygonize_labels(
spec: GridSpec,
labels: np.ndarray,
min_area_m2: float = DRAINAGE_MIN_BASIN_AREA_M2,
) -> dict[int, Polygon | MultiPolygon]:
"""라벨 격자를 라벨별 폴리곤으로 바꾼다. 음수 라벨은 배경으로 무시한다."""
label_grid = np.ascontiguousarray(labels.reshape(spec.n_rows, spec.n_cols), dtype=np.int32)
valid_mask = label_grid >= 0
if not valid_mask.any():
return {}
collected: dict[int, list[Polygon]] = {}
for geometry, value in shapes(
label_grid, mask=valid_mask, transform=grid_transform(spec), connectivity=4
):
polygon = shape(geometry)
if polygon.is_empty or polygon.area < min_area_m2:
continue
collected.setdefault(int(value), []).append(polygon)
merged: dict[int, Polygon | MultiPolygon] = {}
for label, parts in collected.items():
union = unary_union(parts)
if union.is_empty:
continue
simplified = union.simplify(DRAINAGE_POLYGON_SIMPLIFY_M, preserve_topology=True)
merged[label] = simplified if not simplified.is_empty else union
return merged
def largest_ring(geometry: Polygon | MultiPolygon) -> list[tuple[float, float]]:
"""폴리곤(또는 멀티폴리곤)에서 가장 큰 조각의 외곽 링 좌표를 뽑는다."""
if geometry.is_empty:
return []
if geometry.geom_type == "MultiPolygon":
geometry = max(geometry.geoms, key=lambda part: part.area)
return [(float(x), float(y)) for x, y in geometry.exterior.coords]
def outer_boundary(
spec: GridSpec, active: np.ndarray, min_area_m2: float = DRAINAGE_MIN_BASIN_AREA_M2
) -> Polygon | MultiPolygon | None:
"""활성 셀 전체의 외곽 = 2차 전체 배수유역 경계.
비활성 셀이 격자 최외곽에 띠로 완성되면 활성 영역이 안에 닫힌다. 닫힌 영역의
바깥선이 분수령이므로 능선을 따로 그릴 필요가 없다.
"""
labels = np.where(active.reshape(-1), 0, -1).astype(np.int32)
polygons = polygonize_labels(spec, labels, min_area_m2)
return polygons.get(0)
@@ -0,0 +1,559 @@
"""배수유역 해석 격자 생성 — 등고선 TIN 보간 · 웅덩이 채움 · D8 물 방향.
지형 근거는 **도엽 등고선**뿐이다. 라이다 DEM은 노선 주변만 커버해 유역 산정에 필요한
상류 범위를 담지 못하므로 쓰지 않는다(2026-07-31 사용자 지시). 표고점도 쓰지 않는다.
처리 순서
계획선 최저점 아래 등고선·짧은 파편 제거
세류선을 노선 교차점에서 잘라 상류측만 남김
남은 세류선 + 노선을 반경 버퍼한 범위의 bbox로 격자 생성
등고선 정점 Delaunay TIN 선형보간으로 표고 산출
웅덩이 채움(형태학적 재구성) + 평탄면 미세경사 부여
D8(8방향 최급강하) 수신 인덱스 산출
없으면 등고선 TIN 특유의 가짜 웅덩이·평탄 삼각형에서 흐름이 끊겨 상류 추적이
도중에 멈춘다. 능선 탐지는 하지 않는다 흐름이 도로에 닿는지 여부만으로 유역이 정해진다.
"""
from __future__ import annotations
import logging
import math
from dataclasses import dataclass
from typing import Any
import numpy as np
from affine import Affine
from rasterio.features import rasterize
from rasterio.transform import from_origin
from scipy.interpolate import LinearNDInterpolator
from scipy.ndimage import distance_transform_edt
from shapely import segmentize
from shapely.geometry import LineString, shape
from shapely.geometry.base import BaseGeometry
from skimage.morphology import reconstruction
from config.config_system import (
DRAINAGE_CONTOUR_CLIP_MARGIN_M,
DRAINAGE_CONTOUR_MARGIN_M,
DRAINAGE_CONTOUR_MIN_LENGTH_M,
DRAINAGE_CONTOUR_RESAMPLE_M,
DRAINAGE_FLAT_EPSILON_M,
DRAINAGE_MAX_GRID_CELLS,
)
logger = logging.getLogger(__name__)
# 표고 속성 키: 도엽 등고선(등고수치)·gpkg 등고선(CTRLN_HG) 통합.
ELEVATION_KEYS = ("등고수치", "CTRLN_HG", "수치", "표고", "높이", "elevation", "ELEV")
# D8 이웃 (행 증분, 열 증분, 거리계수). 행은 아래로 증가(북 → 남).
_NEIGHBORS = (
(-1, 0, 1.0),
(1, 0, 1.0),
(0, -1, 1.0),
(0, 1, 1.0),
(-1, -1, math.sqrt(2.0)),
(-1, 1, math.sqrt(2.0)),
(1, -1, math.sqrt(2.0)),
(1, 1, math.sqrt(2.0)),
)
@dataclass(frozen=True)
class GridSpec:
"""해석 격자 기하. (0,0) 셀 중심이 (x_min + cell/2, y_max cell/2)에 놓인다."""
x_min: float
y_max: float
cell_m: float
n_rows: int
n_cols: int
@property
def size(self) -> int:
return self.n_rows * self.n_cols
@property
def cell_area_m2(self) -> float:
return self.cell_m * self.cell_m
def world_to_rc(self, x: np.ndarray, y: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
"""세계좌표(m)를 격자 행·열로 바꾼다. 범위를 벗어나면 −1을 돌려준다."""
col = np.floor((x - self.x_min) / self.cell_m).astype(np.int64)
row = np.floor((self.y_max - y) / self.cell_m).astype(np.int64)
outside = (col < 0) | (col >= self.n_cols) | (row < 0) | (row >= self.n_rows)
col[outside] = -1
row[outside] = -1
return row, col
def cell_centers_x(self) -> np.ndarray:
return self.x_min + (np.arange(self.n_cols, dtype=np.float64) + 0.5) * self.cell_m
def cell_centers_y(self) -> np.ndarray:
return self.y_max - (np.arange(self.n_rows, dtype=np.float64) + 0.5) * self.cell_m
@dataclass
class ContourCloud:
"""등고선에서 뽑은 정점 구름. TIN 보간과 세류 상·하류 판정에 함께 쓴다."""
xy: np.ndarray # (N, 2) float64
z: np.ndarray # (N,) float64
@property
def is_empty(self) -> bool:
return self.xy.shape[0] < 3
@dataclass
class TerrainGrid:
"""격자 지형 해석 결과."""
spec: GridSpec
elevation: np.ndarray # (R, C) float32 — 채움·평탄해소 후 표고, 무효 셀은 NaN
valid: np.ndarray # (R, C) bool — 등고선 TIN 내부 여부
receiver: np.ndarray # (R*C,) int32 — D8 수신 셀의 평탄 인덱스, 싱크는 자기 자신
step_length: np.ndarray # (R*C,) float32 — 수신 셀까지 거리(m), 싱크는 0
# ── ① 등고선 정리 ────────────────────────────────────────────────────────────
def _feature_elevation(properties: dict[str, Any]) -> float | None:
for key in ELEVATION_KEYS:
value = properties.get(key)
if value is None:
continue
try:
return float(value)
except (TypeError, ValueError):
continue
return None
def iter_linestrings(geometry: Any) -> list[LineString]:
if geometry.geom_type == "LineString":
return [geometry]
if geometry.geom_type in {"MultiLineString", "GeometryCollection"}:
lines: list[LineString] = []
for part in geometry.geoms:
lines.extend(iter_linestrings(part))
return lines
return []
def build_contour_cloud(
contour_features: list[dict[str, Any]],
elevation_floor_m: float | None = None,
clip_bounds: tuple[float, float, float, float] | None = None,
) -> ContourCloud:
"""등고선 피처를 표고가 붙은 정점 구름으로 바꾼다.
`elevation_floor_m` 아래 등고선은 계획선 최저점보다 낮아 상류 기여가 불가능하므로
버린다. 길이가 짧은 파편도 노이즈로 보고 버리되, 임계 이상인 봉우리 폐합 등고선은
남긴다(봉우리 표고가 사라지면 일대 흐름 방향이 통째로 틀어진다).
`clip_bounds`(x_min, y_min, x_max, y_max) 주면 등고선은 읽지 않는다. 도엽
전체 등고선을 물고 가면 TIN 삼각망 비용만 커지고 결과는 같다.
"""
xs: list[np.ndarray] = []
ys: list[np.ndarray] = []
zs: list[np.ndarray] = []
dropped_low = 0
dropped_short = 0
dropped_outside = 0
for feature in contour_features:
geometry = feature.get("geometry")
if not geometry:
continue
elevation = _feature_elevation(feature.get("properties") or {})
if elevation is None:
continue
if elevation_floor_m is not None and elevation < elevation_floor_m:
dropped_low += 1
continue
try:
parsed = shape(geometry)
except Exception: # noqa: BLE001 - 손상된 피처는 건너뛴다
continue
if clip_bounds is not None and _outside_bounds(parsed.bounds, clip_bounds):
dropped_outside += 1
continue
for line in iter_linestrings(parsed):
if line.length < DRAINAGE_CONTOUR_MIN_LENGTH_M:
dropped_short += 1
continue
coords = np.asarray(segmentize(line, DRAINAGE_CONTOUR_RESAMPLE_M).coords)
if coords.shape[0] < 2:
continue
xs.append(coords[:, 0])
ys.append(coords[:, 1])
zs.append(np.full(coords.shape[0], elevation, dtype=np.float64))
if not xs:
logger.warning(
"배수유역: 사용할 등고선이 없습니다(저지대 %d, 파편 %d, 범위밖 %d 제외).",
dropped_low,
dropped_short,
dropped_outside,
)
return ContourCloud(np.zeros((0, 2)), np.zeros(0))
xy = np.column_stack((np.concatenate(xs), np.concatenate(ys)))
z = np.concatenate(zs)
logger.info(
"배수유역: 등고선 정점 %d개 (저지대 %d, 파편 %d, 범위밖 %d 제외)",
xy.shape[0],
dropped_low,
dropped_short,
dropped_outside,
)
return ContourCloud(xy, z)
def _outside_bounds(
bounds: tuple[float, float, float, float], clip: tuple[float, float, float, float]
) -> bool:
return bounds[2] < clip[0] or bounds[0] > clip[2] or bounds[3] < clip[1] or bounds[1] > clip[3]
def grid_spec_from_bounds(
x_min: float,
y_min: float,
x_max: float,
y_max: float,
cell_m: float,
anchor_xy: tuple[float, float] | None = None,
) -> GridSpec:
"""범위를 덮는 격자를 만든다. `anchor_xy`를 주면 그 점에 셀 모서리를 맞춘다.
격자 원점은 **도로 시작점** 고정한다(2026-07-31 사용자 지시). bbox 좌상단에 맞추면
1 영역이 조금만 달라져도 격자가 통째로 밀려 이전 결과와 셀이 대응되지 않는다.
도로 시작점에 맞추면 반경·영역을 바꿔도 같은 자리의 셀은 같은 자리에 남는다.
격자 크기는 절대 자동으로 바꾸지 않는다 config 값이 그대로 쓰인다(사용자 지시).
수가 많으면 경고만 남기고 그대로 진행한다.
"""
if anchor_xy is not None:
anchor_x, anchor_y = anchor_xy
# 앵커에서 셀 정수배만큼 밖으로 나가 범위를 덮는다(넓어질 뿐 좁아지지 않는다).
x_min = anchor_x - math.ceil((anchor_x - x_min) / cell_m) * cell_m
y_max = anchor_y + math.ceil((y_max - anchor_y) / cell_m) * cell_m
n_cols = max(1, int(math.ceil((x_max - x_min) / cell_m)))
n_rows = max(1, int(math.ceil((y_max - y_min) / cell_m)))
if n_cols * n_rows > DRAINAGE_MAX_GRID_CELLS:
logger.warning(
"배수유역: 격자 %d×%d = %d셀 (%.0fm × %.0fm, 셀 %.2fm) — 권장 상한 %d셀 초과. "
"그대로 진행합니다. 느리면 DRAINAGE_GRID_SIZE_M을 올리세요.",
n_rows,
n_cols,
n_rows * n_cols,
n_cols * cell_m,
n_rows * cell_m,
cell_m,
DRAINAGE_MAX_GRID_CELLS,
)
return GridSpec(x_min=x_min, y_max=y_max, cell_m=cell_m, n_rows=n_rows, n_cols=n_cols)
def expand_grid_spec(spec: GridSpec, sides: dict[str, bool], step_m: float) -> GridSpec:
"""활성 셀이 닿은 방향으로만 격자를 넓힌다.
정수배로만 넓혀 격자 격자점(도로 시작점 기준) 그대로 유지되게 한다.
"""
steps = max(1, int(math.ceil(step_m / spec.cell_m)))
west = steps if sides.get("west") else 0
east = steps if sides.get("east") else 0
north = steps if sides.get("north") else 0
south = steps if sides.get("south") else 0
return GridSpec(
x_min=spec.x_min - west * spec.cell_m,
y_max=spec.y_max + north * spec.cell_m,
cell_m=spec.cell_m,
n_rows=spec.n_rows + north + south,
n_cols=spec.n_cols + west + east,
)
def grid_transform(spec: GridSpec) -> Affine:
"""rasterio 아핀 변환. 행 0이 북쪽(y_max)이다."""
return from_origin(spec.x_min, spec.y_max, spec.cell_m, spec.cell_m)
def build_cell_mask(spec: GridSpec, geometry: BaseGeometry) -> np.ndarray:
"""영역에 **조금이라도 걸치는** 셀만 True인 (rows, cols) 마스크.
`all_touched=True`라서 셀이 영역과 점만 스쳐도 생성 대상이 된다(사용자 지시).
"""
if geometry is None or geometry.is_empty:
return np.zeros((spec.n_rows, spec.n_cols), dtype=bool)
burned = rasterize(
[(geometry, 1)],
out_shape=(spec.n_rows, spec.n_cols),
transform=grid_transform(spec),
fill=0,
dtype="uint8",
all_touched=True,
)
return burned.astype(bool)
def mask_row_spans(mask: np.ndarray) -> list[tuple[int, int, int]]:
"""마스크를 행별 연속 구간 [행, 시작열, 끝열(포함)]으로 압축한다.
수십만 개를 그대로 내보낼 없으니 구간으로 줄인다 프론트는 구간만 받아
실제 사각형을 그린다.
"""
spans: list[tuple[int, int, int]] = []
for row in range(mask.shape[0]):
line = mask[row]
if not line.any():
continue
padded = np.concatenate(([False], line, [False]))
edges = np.flatnonzero(padded[1:] != padded[:-1])
for start, stop in zip(edges[0::2], edges[1::2]):
spans.append((row, int(start), int(stop) - 1))
return spans
# ── ④ TIN 보간 ──────────────────────────────────────────────────────────────
def interpolate_elevation(spec: GridSpec, cloud: ContourCloud) -> np.ndarray:
"""등고선 정점 Delaunay TIN으로 셀 표고를 선형보간한다. 외부는 NaN.
삼각망 비용은 정점 수에 비례한다. 격자 정점으로 만든 삼각형은 어차피 쓰이지 않으므로
격자 범위 + 여유만큼만 남기고 잘라낸다 결과 표고는 그대로고 속도만 는다.
"""
surface = np.full((spec.n_rows, spec.n_cols), np.nan, dtype=np.float32)
if cloud.is_empty:
return surface
margin = DRAINAGE_CONTOUR_CLIP_MARGIN_M
inside = (
(cloud.xy[:, 0] >= spec.x_min - margin)
& (cloud.xy[:, 0] <= spec.x_min + spec.n_cols * spec.cell_m + margin)
& (cloud.xy[:, 1] >= spec.y_max - spec.n_rows * spec.cell_m - margin)
& (cloud.xy[:, 1] <= spec.y_max + margin)
)
if inside.sum() < 3:
logger.warning("배수유역: 격자 범위 안에 등고선 정점이 없습니다.")
return surface
logger.info("배수유역: TIN 정점 %d개 사용 (전체 %d개)", int(inside.sum()), cloud.xy.shape[0])
interpolator = LinearNDInterpolator(cloud.xy[inside], cloud.z[inside])
xs = spec.cell_centers_x()
ys = spec.cell_centers_y()
# 행 묶음 단위로 평가해 (행×열) 좌표 배열을 한 번에 들고 있지 않게 한다.
chunk = max(1, int(4_000_000 // max(spec.n_cols, 1)))
for start in range(0, spec.n_rows, chunk):
stop = min(start + chunk, spec.n_rows)
grid_x, grid_y = np.meshgrid(xs, ys[start:stop])
surface[start:stop] = interpolator(grid_x, grid_y).astype(np.float32)
return surface
# ── ⑤ 웅덩이 채움 + 평탄면 해소 ─────────────────────────────────────────────
def condition_surface(
surface: np.ndarray, domain: np.ndarray | None = None
) -> tuple[np.ndarray, np.ndarray]:
"""가짜 웅덩이를 채우고 평탄면에 미세 경사를 준다.
등고선 TIN은 같은 표고 정점 3개로 이루어진 평탄 삼각형과 계단형 가짜 웅덩이를
필연적으로 만든다. 그대로 D8을 돌리면 흐름이 거기서 끊겨 상류 추적이 멈춘다.
채움은 형태학적 재구성(erosion)으로 한다. 배출구는 격자 최외곽과 유효 영역 경계(무효
셀에 맞닿은 유효 ) 둔다 그래야 유효 영역 전체가 하나의 평탄면으로 잠기지 않는다.
`domain` 주면 안쪽만 해석 대상으로 삼는다(1 영역에 걸쳐 실제 생성된 마스크).
"""
valid = np.isfinite(surface)
if domain is not None:
valid &= domain
if not valid.any():
return surface, valid
ceiling = float(np.nanmax(surface)) + 1000.0
mask = np.where(valid, surface, ceiling).astype(np.float32)
open_boundary = np.zeros_like(valid)
open_boundary[0, :] = True
open_boundary[-1, :] = True
open_boundary[:, 0] = True
open_boundary[:, -1] = True
open_boundary |= _dilate(~valid) & valid
open_boundary &= valid
if not open_boundary.any():
open_boundary = valid & _dilate(~valid)
seed = np.full_like(mask, ceiling)
seed[open_boundary] = mask[open_boundary]
filled = reconstruction(seed, mask, method="erosion", footprint=np.ones((3, 3), dtype=bool))
filled = filled.astype(np.float32)
# 채움 뒤 더 낮은 이웃이 없는 셀 = 평탄면. 가장 가까운 비평탄 셀 쪽으로 미세 경사를 준다.
flat = valid & ~_has_lower_neighbour(filled, valid)
if flat.any():
distance = distance_transform_edt(flat).astype(np.float32)
filled = filled + distance * np.float32(DRAINAGE_FLAT_EPSILON_M)
filled[~valid] = np.nan
return filled, valid
def _dilate(mask: np.ndarray) -> np.ndarray:
"""8이웃 1스텝 팽창(외부는 False)."""
padded = np.zeros((mask.shape[0] + 2, mask.shape[1] + 2), dtype=bool)
padded[1:-1, 1:-1] = mask
result = np.zeros_like(mask)
for row_shift in (0, 1, 2):
for col_shift in (0, 1, 2):
result |= padded[
row_shift : row_shift + mask.shape[0], col_shift : col_shift + mask.shape[1]
]
return result
def _has_lower_neighbour(surface: np.ndarray, valid: np.ndarray) -> np.ndarray:
"""8이웃 중 자기보다 낮은 셀이 하나라도 있는지. 무효 셀은 +∞로 보아 제외한다."""
rows, cols = surface.shape
padded = np.full((rows + 2, cols + 2), np.inf, dtype=np.float32)
padded[1:-1, 1:-1] = np.where(valid, surface, np.inf)
result = np.zeros((rows, cols), dtype=bool)
center = padded[1:-1, 1:-1]
for row_shift, col_shift, _ in _NEIGHBORS:
neighbour = padded[
1 + row_shift : 1 + row_shift + rows, 1 + col_shift : 1 + col_shift + cols
]
result |= neighbour < center
return result & valid
# ── ⑥ D8 물 방향 ────────────────────────────────────────────────────────────
def compute_receivers(
spec: GridSpec, surface: np.ndarray, valid: np.ndarray
) -> tuple[np.ndarray, np.ndarray]:
"""셀마다 8방향 최급강하 이웃(수신 셀)을 정한다.
돌려주는 `receiver` 평탄 인덱스(row * n_cols + col). 낮은 이웃이 없는 (싱크)
무효 셀은 자기 자신을 가리켜 흐름이 자리에서 멈춘다.
"""
rows, cols = spec.n_rows, spec.n_cols
padded = np.full((rows + 2, cols + 2), np.inf, dtype=np.float32)
padded[1:-1, 1:-1] = np.where(valid, surface, np.inf)
center = padded[1:-1, 1:-1]
flat_index = np.arange(rows * cols, dtype=np.int32).reshape(rows, cols)
padded_index = np.full((rows + 2, cols + 2), -1, dtype=np.int32)
padded_index[1:-1, 1:-1] = flat_index
best_slope = np.zeros((rows, cols), dtype=np.float32)
receiver = flat_index.copy()
step = np.zeros((rows, cols), dtype=np.float32)
for row_shift, col_shift, factor in _NEIGHBORS:
neighbour = padded[
1 + row_shift : 1 + row_shift + rows, 1 + col_shift : 1 + col_shift + cols
]
distance = np.float32(factor * spec.cell_m)
# 무효 셀끼리는 ∞−∞ = NaN이 되지만 아래 isfinite에서 걸러진다.
with np.errstate(invalid="ignore"):
slope = (center - neighbour) / distance
better = np.isfinite(slope) & (slope > best_slope)
if not better.any():
continue
best_slope = np.where(better, slope, best_slope)
neighbour_index = padded_index[
1 + row_shift : 1 + row_shift + rows, 1 + col_shift : 1 + col_shift + cols
]
receiver = np.where(better, neighbour_index, receiver)
step = np.where(better, distance, step)
return receiver.reshape(-1), step.reshape(-1)
# ── 오케스트레이션 ──────────────────────────────────────────────────────────
def build_terrain_grid(
spec: GridSpec, cloud: ContourCloud, domain: np.ndarray | None = None
) -> TerrainGrid:
"""격자 범위와 등고선 구름으로 지형 해석 격자를 만든다.
`domain` 실제 해석할 마스크(1 영역에 걸친 ). 주면 밖은 무효로 둔다.
"""
surface = interpolate_elevation(spec, cloud)
conditioned, valid = condition_surface(surface, domain)
receiver, step = compute_receivers(spec, conditioned, valid)
logger.info(
"배수유역: 격자 %d×%d (%.2fm), 해석 대상 셀 %d",
spec.n_rows,
spec.n_cols,
spec.cell_m,
int(valid.sum()),
)
return TerrainGrid(
spec=spec, elevation=conditioned, valid=valid, receiver=receiver, step_length=step
)
# 화살표 방위 분해능. 0 = 화면상 오른쪽(+열), 시계방향으로 증가(행이 아래로 증가하므로).
AZIMUTH_STEPS = 32
# 방위 코드 특수값.
AZIMUTH_SINK = AZIMUTH_STEPS # 32 = 제자리(더 낮은 이웃 없음)
AZIMUTH_INVALID = AZIMUTH_STEPS + 1 # 33 = 표고 없음(해석 불가)
def descent_azimuth(
spec: GridSpec,
surface: np.ndarray,
valid: np.ndarray,
receiver: np.ndarray,
forced: np.ndarray | None = None,
) -> np.ndarray:
"""셀별 물 흐름 방위를 32방위 코드로 낸다.
D8은 연결(도로 도달 판정)에는 충분하지만 화면에 8방위밖에 그린다. 실제 지표수는
지형 최급강하 방향으로 흐르고 방향은 연속값이므로, **표시는 지표면 기울기에서 뽑은
연속 방위를 32단계로 양자화** 보여 준다(2026-07-31 사용자 지시).
`forced`(세류망을 따라 흐름을 새긴 ) 기울기 대신 실제 수신 방향을 쓴다
셀들은 지형 추정이 아니라 확정된 물길을 따르기 때문이다. 기울기가 0 가까운 셀도
수신 방향으로 대체한다.
"""
rows, cols = spec.n_rows, spec.n_cols
filled = np.where(valid, surface, np.nan)
# np.gradient는 NaN이 번지므로 무효 셀을 주변 유효값으로 임시 대체한 뒤 기울기를 잡는다.
working = np.where(np.isfinite(filled), filled, np.nanmean(filled) if valid.any() else 0.0)
grad_row, grad_col = np.gradient(working.astype(np.float64), spec.cell_m)
# 내리막 방향 = 기울기 반대. 행은 아래로 증가하므로 화면 좌표와 부호가 같다.
move_row = -grad_row
move_col = -grad_col
magnitude = np.hypot(move_row, move_col)
index = np.arange(receiver.size, dtype=np.int64)
receiver_row = (receiver // cols - index // cols).reshape(rows, cols).astype(np.float64)
receiver_col = (receiver % cols - index % cols).reshape(rows, cols).astype(np.float64)
use_receiver = magnitude < 1e-9
if forced is not None:
use_receiver |= forced.reshape(rows, cols)
move_row = np.where(use_receiver, receiver_row, move_row)
move_col = np.where(use_receiver, receiver_col, move_col)
angle = np.arctan2(move_row, move_col)
code = np.rint(angle / (2.0 * math.pi / AZIMUTH_STEPS)).astype(np.int16) % AZIMUTH_STEPS
# 수신 셀이 자기 자신이거나 이동량이 없는 셀은 방향이 없다.
is_sink = (receiver.reshape(rows, cols) == index.reshape(rows, cols)) | (
(np.abs(move_row) < 1e-12) & (np.abs(move_col) < 1e-12)
)
code = np.where(is_sink, AZIMUTH_SINK, code)
# 세류망을 따라 흐름을 새긴 셀은 표고가 없어도 방향이 확정돼 있다.
known = valid if forced is None else (valid | forced.reshape(rows, cols))
code = np.where(known, code, AZIMUTH_INVALID)
return code.reshape(-1).astype(np.int16)
def route_elevation_floor(route_z_values: list[float]) -> float | None:
"""계획선 최저점에서 여유를 뺀 등고선 하한. 값이 없으면 None(필터 미적용)."""
finite = [value for value in route_z_values if math.isfinite(value) and value != 0.0]
if not finite:
return None
return min(finite) - DRAINAGE_CONTOUR_MARGIN_M
@@ -0,0 +1,344 @@
"""세류망 상·하류 분리와 1차 배수유역 산정.
도엽 하천중심선은 피처가 잘게 쪼개져 있고 지류가 본류 중간에 T자로 붙는다. 피처 단위로
자르면 상류망이 통째로 빠지므로, 노딩 도로 절단 끝점 그래프 확산으로 **이어진
전체** 잡는다(2026-07-31 사용자 지시).
여기서 정해진 1 배수유역의 bbox가 격자 해석 범위가 된다.
표고 해석·격자 생성은 `B04_wf1_Surface_Engine_Watershed_Grid.py` 맡는다.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from typing import Any
import numpy as np
from scipy.interpolate import LinearNDInterpolator
from scipy.spatial import cKDTree
from shapely.geometry import LineString, MultiPolygon, Polygon, shape
from shapely.ops import substring, unary_union
from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Grid import (
ContourCloud,
GridSpec,
build_cell_mask,
grid_spec_from_bounds,
iter_linestrings,
)
from config.config_system import DRAINAGE_GRID_SIZE_M
logger = logging.getLogger(__name__)
# ── 세류망 상·하류 분리 ─────────────────────────────────────────────────────
@dataclass
class StreamSplit:
"""세류망을 도로 교차점에서 상·하류로 가른 결과. 검증 화면이 그대로 그린다.
`upstream` **물이 흐르는 방향(상류 하류)으로 정렬** 있다. 마지막 좌표가 도로에
가까운 끝이다. 격자 흐름에 세류 방향을 새겨 넣을 순서를 그대로 쓴다.
"""
upstream: list[LineString] = field(default_factory=list) # 채택 — 1차 영역의 기준
downstream: list[LineString] = field(default_factory=list) # 도로 아래로 이어진 망
no_contact: int = 0 # 어느 쪽에도 이어지지 않아 제외한 조각 수
def split_streams_at_road(
route_line: LineString,
stream_features: list[dict[str, Any]],
cloud: ContourCloud,
) -> StreamSplit:
"""세류망을 도로에서 끊고, 교차점 상류측으로 **이어진 망 전체**를 채택한다.
도엽 하천중심선은 피처가 잘게 쪼개져 있고 지류가 본류 중간에 T자로 붙는다. 그래서
피처 단위로 보면 상류망이 통째로 빠진다. 순서를 이렇게 잡는다:
세류선끼리 `unary_union`으로 노딩 중간에서 만나는 지류도 연결로 인식된다
도로 교차점에서 잘라 ·하류 조각을 물리적으로 분리한다
끝점 그래프를 만들고, **도로 교차 노드는 통과하지 못하게** 막는다
도로에 접한 조각을 교차점 표고와 비교해 ·하류 씨앗으로 정한다
씨앗에서 퍼뜨려 이어진 전체를 채택 도로를 넘어가지 못하므로 ·하류가 섞이지 않는다
"""
lines: list[LineString] = []
for feature in stream_features:
geometry = feature.get("geometry")
if not geometry:
continue
try:
parsed = shape(geometry)
except Exception: # noqa: BLE001
continue
lines.extend(line for line in iter_linestrings(parsed) if line.length > 0)
if not lines:
return StreamSplit()
pieces, crossing_nodes = _cut_network_at_road(lines, route_line)
if not pieces:
return StreamSplit()
node_edges: dict[tuple[float, float], list[int]] = {}
ends: list[tuple[tuple[float, float], tuple[float, float]]] = []
for index, piece in enumerate(pieces):
head = _node_key(*piece.coords[0])
tail = _node_key(*piece.coords[-1])
ends.append((head, tail))
node_edges.setdefault(head, []).append(index)
node_edges.setdefault(tail, []).append(index)
sampler = ElevationSampler(cloud)
# 씨앗 조각 → 그 조각의 하류쪽 끝점(= 도로 교차 노드). 이 값이 물 흐름 방향의 기준이 된다.
upper_seeds: dict[int, tuple[float, float]] = {}
lower_seeds: dict[int, tuple[float, float]] = {}
for index, piece in enumerate(pieces):
touching = [node for node in ends[index] if node in crossing_nodes]
if not touching:
continue
heights = sampler.at(np.array(touching, dtype=np.float64))
crossing_node = touching[int(np.argmin(heights))]
if _mean_elevation(piece, sampler) > float(np.min(heights)):
upper_seeds[index] = crossing_node
else:
lower_seeds[index] = crossing_node
upstream_flow = _spread_network(upper_seeds, ends, node_edges, crossing_nodes)
downstream_flow = _spread_network(lower_seeds, ends, node_edges, crossing_nodes)
upstream = set(upstream_flow)
downstream = set(downstream_flow) - upstream
logger.info(
"배수유역: 세류 조각 %d개 → 상류망 %d개 채택 / 하류망 %d개 · 미연결 %d개 제외",
len(pieces),
len(upstream),
len(downstream),
len(pieces) - len(upstream) - len(downstream),
)
return StreamSplit(
# 상류망은 물 흐름 방향(상류 → 하류)으로 뒤집어 둔다 — 격자 흐름 새김에 그대로 쓴다.
upstream=[
_oriented(pieces[index], upstream_flow[index], ends[index])
for index in sorted(upstream)
],
downstream=[pieces[index] for index in sorted(downstream)],
no_contact=len(pieces) - len(upstream) - len(downstream),
)
def _oriented(
piece: LineString,
downstream_node: tuple[float, float],
piece_ends: tuple[tuple[float, float], tuple[float, float]],
) -> LineString:
"""조각을 하류쪽 끝이 마지막 좌표가 되도록 정렬한다."""
head, _tail = piece_ends
return LineString(list(piece.coords)[::-1]) if head == downstream_node else piece
def _cut_network_at_road(
lines: list[LineString], route_line: LineString
) -> tuple[list[LineString], set[tuple[float, float]]]:
"""세류망을 노딩한 뒤 도로 교차점에서 자르고, 그 교차 노드를 함께 돌려준다."""
noded = unary_union(lines)
pieces: list[LineString] = []
crossing_nodes: set[tuple[float, float]] = set()
for piece in iter_linestrings(noded):
if not piece.intersects(route_line):
pieces.append(piece)
continue
hits = _intersection_points(piece.intersection(route_line))
positions = sorted(
{
position
for position in (piece.project(point) for point in hits)
if 0.0 < position < piece.length
}
)
for point in hits:
crossing_nodes.add(_node_key(point.x, point.y))
if not positions:
# 끝점이 도로에 닿은 경우 — 자를 필요는 없고 그 끝점이 곧 교차 노드다.
pieces.append(piece)
continue
bounds = [0.0, *positions, piece.length]
for start, end in zip(bounds, bounds[1:]):
if end - start <= 0:
continue
cut = _substring(piece, start, end)
if cut is not None:
pieces.append(cut)
return pieces, crossing_nodes
def _spread_network(
seeds: dict[int, tuple[float, float]],
ends: list[tuple[tuple[float, float], tuple[float, float]]],
node_edges: dict[tuple[float, float], list[int]],
blocked: set[tuple[float, float]],
) -> dict[int, tuple[float, float]]:
"""씨앗 조각에서 끝점을 타고 퍼진다. 도로 교차 노드는 통과하지 않는다.
조각마다 **어느 끝점을 통해 도달했는지** 함께 기록한다. 끝점이 도로에 가까운
쪽이므로 조각의 하류 방향이다 세류망 전체의 흐름 방향이 번의 확산으로
같이 정해진다.
"""
downstream = dict(seeds)
queue = list(seeds)
while queue:
index = queue.pop()
for node in ends[index]:
if node in blocked:
continue
for neighbour in node_edges.get(node, ()):
if neighbour in downstream:
continue
downstream[neighbour] = node
queue.append(neighbour)
return downstream
def _node_key(x: float, y: float) -> tuple[float, float]:
"""끝점 일치 판정용 좌표 키. 노딩 후에도 부동소수 오차가 남아 mm로 반올림한다."""
return (round(float(x), 3), round(float(y), 3))
class ElevationSampler:
"""등고선 구름에서 임의 지점 표고를 읽는다 — 상·하류 판정 전용.
최근접 등고선 정점만 쓰면 오차가 등고선 간격(주곡선 5m)만큼 나서, 계곡 교차점 표고가
실제보다 등고선 위로 잡히고 상류 조각이 통째로 하류로 오판된다. TIN 선형보간을
1차로 쓰고, TIN (볼록껍질 외부) 최근접 정점으로 메운다.
"""
def __init__(self, cloud: ContourCloud) -> None:
self._z = cloud.z
if cloud.is_empty:
self._interpolator = None
self._tree = None
return
self._interpolator = LinearNDInterpolator(cloud.xy, cloud.z)
self._tree = cKDTree(cloud.xy)
def at(self, xy: np.ndarray) -> np.ndarray:
"""(N, 2) 좌표의 표고 (N,)."""
if self._interpolator is None or self._tree is None:
return np.zeros(xy.shape[0])
values = np.asarray(self._interpolator(xy), dtype=np.float64)
missing = ~np.isfinite(values)
if missing.any():
_, indices = self._tree.query(xy[missing])
values[missing] = self._z[indices]
return values
def _intersection_points(geometry: Any) -> list[Any]:
if geometry.is_empty:
return []
if geometry.geom_type == "Point":
return [geometry]
if geometry.geom_type in {"MultiPoint", "GeometryCollection", "MultiLineString"}:
points: list[Any] = []
for part in geometry.geoms:
points.extend(_intersection_points(part))
return points
if geometry.geom_type == "LineString":
return [geometry.interpolate(0.5, normalized=True)]
return []
def _substring(line: LineString, start: float, end: float) -> LineString | None:
"""선형 위 [start, end] 구간을 잘라낸다."""
piece = substring(line, start, end)
if piece.is_empty or piece.geom_type != "LineString" or piece.length <= 0:
return None
return piece
def _mean_elevation(line: LineString, sampler: ElevationSampler) -> float:
"""선을 10m 간격으로 훑은 평균 표고."""
samples = max(2, int(line.length // 10.0) + 1)
positions = np.linspace(0.0, line.length, samples)
points = np.array([list(line.interpolate(position).coords)[0] for position in positions])
return float(np.mean(sampler.at(points)))
# ── 1차 배수유역 ────────────────────────────────────────────────────────────
@dataclass
class PrimaryRegion:
"""1차 배수유역과 그 안에 생성된 격자. 검증 화면이 이 내용을 그대로 그린다."""
split: StreamSplit
# 상류 세류망 + 노선을 반경 버퍼해 합친 영역.
area: Polygon | MultiPolygon | None
spec: GridSpec
radius_m: float
# 1차 영역에 조금이라도 걸쳐 실제로 생성된 셀 (rows, cols) bool 마스크.
cell_mask: np.ndarray | None = None
# 1차 영역 밖으로 나간 노선 길이(m). 그 구간 사면은 해석에서 빠진다는 경고 지표.
road_outside_m: float = 0.0
@property
def active_cells(self) -> int:
return 0 if self.cell_mask is None else int(self.cell_mask.sum())
def build_primary_region(
route_line: LineString,
stream_features: list[dict[str, Any]],
cloud: ContourCloud,
radius_m: float,
cell_m: float = DRAINAGE_GRID_SIZE_M,
) -> PrimaryRegion:
"""**상류 세류망 + 계획 노선**을 반경 버퍼한 범위 = 1차 배수유역, 그 안에 격자를 생성한다.
노선 버퍼는 세류 교차가 없는 구간의 도로도 격자 안에 들어오게 한다 그래야 구간
사면이 유역으로 잡힌다. 상류 세류망 선정이 정확해진 다시 포함했다(2026-07-31 사용자 지시).
격자는 bbox를 통째로 채우지 않는다. **도로 시작점에 모서리를 맞춘 , 1 영역에
조금이라도 걸치는 셀만** 생성한다(2026-07-31 사용자 지시). bbox 전체를 쓰면 영역
셀이 대부분이라 의미가 없고, 원점을 bbox 좌상단에 두면 영역이 조금만 변해도 격자가
통째로 밀려 이전 결과와 셀이 대응되지 않는다.
노선이 영역 밖으로 나가는 길이는 따로 재서 남긴다 구간은 도로 셀이 격자에
없어 유역이 잡히지 않으므로 반경을 올릴지 판단하는 근거가 된다.
"""
split = split_streams_at_road(route_line, stream_features, cloud)
geometries = [route_line.buffer(radius_m)]
geometries.extend(line.buffer(radius_m) for line in split.upstream)
if not split.upstream:
logger.warning("배수유역: 상류 세류망이 없어 노선 버퍼만으로 1차 영역을 잡습니다.")
area = unary_union(geometries)
x_min, y_min, x_max, y_max = area.bounds
road_start = route_line.coords[0]
spec = grid_spec_from_bounds(
x_min, y_min, x_max, y_max, cell_m, anchor_xy=(float(road_start[0]), float(road_start[1]))
)
cell_mask = build_cell_mask(spec, area)
outside = route_line.difference(area)
road_outside_m = float(outside.length) if not outside.is_empty else 0.0
active = int(cell_mask.sum())
logger.info(
"배수유역: 1차 영역 %.0f㎡ → 격자 %d×%d (%.2fm, 도로 시점 기준) 중 %d셀 생성 "
"(bbox %d셀의 %.0f%%), 노선 이탈 %.0fm/%.0fm",
area.area,
spec.n_rows,
spec.n_cols,
spec.cell_m,
active,
spec.size,
100.0 * active / max(spec.size, 1),
road_outside_m,
route_line.length,
)
return PrimaryRegion(
split=split,
area=area,
spec=spec,
radius_m=radius_m,
cell_mask=cell_mask,
road_outside_m=road_outside_m,
)
+95 -5
View File
@@ -9,8 +9,8 @@ from uuid import UUID
import aiomysql
import numpy as np
from fastapi import APIRouter, Depends
from fastapi.responses import FileResponse, JSONResponse
from fastapi import APIRouter, Depends, Request
from fastapi.responses import JSONResponse, Response
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from B04_wf1_Surface.B04_wf1_Surface_Engine import (
@@ -18,6 +18,10 @@ from B04_wf1_Surface.B04_wf1_Surface_Engine import (
cache_ground_points,
run_surface_analysis,
)
from B04_wf1_Surface.B04_wf1_Surface_Engine_Extent import (
planned_route_bounds,
project_epsg_from_prj,
)
from B04_wf1_Surface.B04_wf1_Surface_Repository import (
clear_confirmed_surface_models,
get_input_file,
@@ -28,6 +32,7 @@ from B04_wf1_Surface.B04_wf1_Surface_Repository import (
from B04_wf1_Surface.B04_wf1_Surface_Schema import (
SurfaceAnalyzeRequest,
SurfaceAnalyzeResponse,
SurfaceConfirmedResponse,
SurfaceConfirmRequest,
SurfaceConfirmResponse,
SurfaceGroundStatsResponse,
@@ -39,9 +44,13 @@ from B04_wf1_Surface.B04_wf1_Surface_Schema import (
)
from B04_wf1_Surface.B04_wf1_Surface_Service import confirm_surface_selection
from common_util.common_util_auth import require_system_admin
from common_util.common_util_http_cache import cached_file_response
from common_util.common_util_json import atomic_write_json
from common_util.common_util_storage import resolve_stored_project_path
from common_util.common_util_surface_confirmation import surface_confirmation_defaults
from common_util.common_util_surface_confirmation import (
get_surface_confirmation_params,
surface_confirmation_defaults,
)
from common_util.common_util_workflow_state import (
fail_stage,
start_stage,
@@ -367,6 +376,85 @@ async def get_surface_point_cloud(
)
@router.get("/{project_id}/surface/confirmed", response_model=SurfaceConfirmedResponse)
async def get_confirmed_surface(project_id: UUID) -> SurfaceConfirmedResponse | JSONResponse:
"""확정 지표면 구성과 지형 가장자리만 반환한다(포인트 배열 없음).
B05 3D 배치·B11 준비화면·진입 판정이 모두 응답 하나를 기준으로 삼는다.
구성이 바뀌면 signature가 달라지므로 프론트가 담아 자료의 갱신 여부를 판단할 있다.
"""
pool = get_db_pool()
try:
async with pool.acquire() as connection:
stored_path = await get_project_storage_relative_path(connection, project_id)
models = await list_surface_models(connection, project_id)
params = await get_surface_confirmation_params(connection, str(project_id))
confirmed = next((model for model in models if model["status"] == "CONFIRMED"), None)
source_filter = params.get("source_filter")
# 가장자리는 B05가 3D 마커 좌표를 환산할 때 쓰므로, 기존 포인트클라우드 응답과
# 같은 파일(확정 필터의 지면 포인트)에서 읽어 값이 어긋나지 않게 한다.
processed_dir = Path(resolve_stored_project_path(stored_path)) / "B04_wf1_Surface"
processed_dir = processed_dir / "processed"
source_path = processed_dir / "structured.npz"
if source_filter:
filtered = processed_dir / f"ground_points_{source_filter}.npz"
if filtered.is_file():
source_path = filtered
bounds_payload: dict[str, float] | None = None
point_count: int | None = None
if source_path.is_file():
with np.load(source_path) as stored:
bounds = np.asarray(stored["bounds"], dtype=np.float64)
if "point_count" in stored:
point_count = int(stored["point_count"])
bounds_payload = {
"x_min": float(bounds[0, 0]),
"x_max": float(bounds[0, 1]),
"y_min": float(bounds[1, 0]),
"y_max": float(bounds[1, 1]),
"z_min": float(bounds[2, 0]),
"z_max": float(bounds[2, 1]),
}
# 지도(2D) 초기 화면을 도로 기준으로 맞추기 위한 계획노선 범위(없으면 None).
project_root = processed_dir.parent.parent
route_bounds = planned_route_bounds(project_root, project_epsg_from_prj(project_root))
signature = "|".join(
str(value)
for value in (
confirmed["id"] if confirmed else "none",
source_filter,
params.get("method"),
params.get("smooth"),
params.get("contour_interval_m"),
)
)
return SurfaceConfirmedResponse(
project_id=str(project_id),
model_id=int(confirmed["id"]) if confirmed else None,
source_filter=source_filter,
method=params.get("method"),
smooth=params.get("smooth"),
contour_interval_m=params.get("contour_interval_m"),
signature=signature,
point_count=point_count,
bounds=bounds_payload,
route_bounds=route_bounds,
)
except LookupError as exc:
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
except Exception:
logger.exception("B04 확정 지표면 요약 조회 실패: project_id=%s", project_id)
return JSONResponse(
status_code=500,
content={"status": "error", "message": "확정 지표면 정보를 불러오지 못했습니다."},
)
@router.get("/{project_id}/surface/ground-stats", response_model=SurfaceGroundStatsResponse)
async def get_surface_ground_stats(project_id: UUID) -> SurfaceGroundStatsResponse | JSONResponse:
"""manifest에서 필터별 지면 포인트 통계를 반환한다."""
@@ -512,10 +600,11 @@ async def get_wf1_analysis_status(project_id: UUID) -> dict:
@router.get("/{project_id}/surface/models/{model_id}/preview", response_model=None)
async def get_surface_model_preview(
request: Request,
project_id: UUID,
model_id: int,
smooth: bool = False,
) -> FileResponse | JSONResponse:
) -> Response | JSONResponse:
"""지표면 모델의 3D 프리뷰 파일(GLB/PLY)을 반환한다."""
pool = get_db_pool()
try:
@@ -569,7 +658,8 @@ async def get_surface_model_preview(
elif ext == "ply":
media_type = "application/ply"
return FileResponse(preview_path, media_type=media_type, filename=preview_filename)
# 브라우저가 이미 같은 파일을 갖고 있으면 304만 돌려준다(새로고침이 빨라진다).
return cached_file_response(request, preview_path, media_type, preview_filename)
except Exception:
logger.exception(
@@ -10,8 +10,8 @@ from pathlib import Path
from uuid import UUID
import numpy as np
from fastapi import APIRouter
from fastapi.responses import FileResponse, JSONResponse
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse, Response
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from B04_wf1_Surface.B04_wf1_Surface_Engine_Contour import (
@@ -19,6 +19,7 @@ from B04_wf1_Surface.B04_wf1_Surface_Engine_Contour import (
extract_contours,
)
from common_util.common_util_atomic import atomic_write_bytes
from common_util.common_util_http_cache import cached_file_response
from common_util.common_util_storage import resolve_stored_project_path
from config.config_db import get_db_pool
from config.config_system import SURFACE_CONTOUR_GRID_RESOLUTION_M
@@ -50,12 +51,13 @@ def _is_contour_cache_current(contour_path: Path, model_path: Path) -> bool:
@router.get("/{project_id}/surface/models/{model_id}/contour", response_model=None)
async def get_surface_model_contour(
request: Request,
project_id: UUID,
model_id: int,
interval: float = 1.0,
smooth: bool = False,
recalculate: bool = False,
) -> FileResponse | JSONResponse:
) -> Response | JSONResponse:
"""지표면 모델의 등고선 JSON 파일을 반환한다."""
pool = get_db_pool()
try:
@@ -176,7 +178,8 @@ async def get_surface_model_contour(
},
)
return FileResponse(contour_path, media_type="application/json", filename=contour_filename)
# 브라우저가 이미 같은 등고선 파일을 갖고 있으면 304만 돌려준다(새로고침이 빨라진다).
return cached_file_response(request, contour_path, "application/json", contour_filename)
except Exception:
logger.exception(
+20 -8
View File
@@ -5,10 +5,11 @@ from pathlib import Path
from typing import Any
from uuid import UUID
from fastapi import APIRouter, HTTPException, Response
from fastapi.responses import FileResponse, JSONResponse
from fastapi import APIRouter, HTTPException, Request, Response
from fastapi.responses import JSONResponse
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from common_util.common_util_http_cache import cached_file_response
from common_util.common_util_storage import resolve_stored_project_path
from config.config_db import get_db_pool
@@ -58,9 +59,9 @@ async def get_vworld_meta(
# VWorld 맵 API
@router.get("/{project_id}/vworld-map", response_model=None)
async def get_vworld_map(
project_id: UUID, layer_name: str = "satellite"
) -> FileResponse | JSONResponse:
"""배경 지도 레이어 PNG 이미지를 반환합니다."""
project_id: UUID, request: Request, layer_name: str = "satellite"
) -> Response | JSONResponse:
"""배경 지도 레이어 PNG 이미지를 반환합니다(ETag — 바뀌지 않았으면 304)."""
pool = get_db_pool()
try:
async with pool.acquire() as connection:
@@ -86,15 +87,21 @@ async def get_vworld_map(
"message": f"VWorld {layer_name} 지도가 존재하지 않습니다.",
},
)
return FileResponse(map_path, media_type="image/png")
return cached_file_response(request, map_path, "image/png")
except Exception as exc:
return JSONResponse(status_code=500, content={"status": "error", "message": str(exc)})
# GeoJSON 조회 API
@router.get("/{project_id}/geojson", response_model=None)
async def get_project_geojson(project_id: UUID, layer: str) -> dict[str, Any] | JSONResponse:
"""저장된 프로젝트의 특정 GeoJSON 레이어 데이터를 반환합니다."""
async def get_project_geojson(
project_id: UUID, layer: str, request: Request
) -> dict[str, Any] | Response | JSONResponse:
"""저장된 프로젝트의 특정 GeoJSON 레이어 데이터를 반환합니다.
도엽 레이어는 분석용 원본 대신 **표시용 사본**(프로젝트 주변만 잘라 좌표를 줄인 )
파일 그대로 내보낸다. 원본을 매번 읽어 재직렬화하면 등고선 장에 2초가 든다.
"""
pool = get_db_pool()
try:
async with pool.acquire() as connection:
@@ -140,6 +147,11 @@ async def get_project_geojson(project_id: UUID, layer: str) -> dict[str, Any] |
},
)
if layer in _SHEET_GEOJSON_FILES:
# 도엽 산출물은 자르거나 줄이지 않고 파일 그대로 보낸다(2026-08-01 사용자 지시).
# 재직렬화만 건너뛰어도 요청당 2초가 사라지고, ETag로 두 번째부터는 304가 된다.
return cached_file_response(request, filepath, "application/geo+json")
if layer == "등고선":
simplified_filepath = target_dir / "등고선_bounds_simplified.geojson"
if simplified_filepath.exists():
@@ -0,0 +1,549 @@
"""배수유역 분석 API 라우터 (B04 — 관리자 확인용).
계획 노선(B03 업로드 CSV) 도엽 등고선·세류선으로 배수유역을 끝까지 분석하고, 결과를
`storage/{프로젝트}/B04_wf1_Surface/drainage/` 남긴다. 30 안팎이 걸리므로 여기서 번만
돌리고, 일반 사용자가 쓰는 B05는 저장분을 읽어 쓴다(2026-07-31 사용자 지시).
좌표는 사업지 CRS(m)에서 계산하고 응답만 WGS84로 바꿔 내보낸다.
"""
import asyncio
import base64
import json
import logging
import math
from pathlib import Path
from typing import Any
from uuid import UUID
import numpy as np
from fastapi import APIRouter
from fastapi.responses import JSONResponse
from pyproj import Transformer
from shapely.geometry import Point, Polygon, box
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Analyze import preview_stages
from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Export import (
drainage_dir,
write_grid_arrays,
write_stage,
)
from B04_wf1_Surface.B04_wf1_Surface_Engine_Watershed_Grid import (
AZIMUTH_INVALID,
AZIMUTH_SINK,
AZIMUTH_STEPS,
mask_row_spans,
)
from B05_wf2_Route.B05_wf2_Route_Repository import get_surface_crs_epsg
from common_util.common_util_route_geometry import (
StructureCandidate,
find_planned_route_file,
read_planned_route_csv,
)
from common_util.common_util_storage import resolve_stored_project_path
from config.config_db import get_db_pool
from config.config_system import DRAINAGE_ARROW_SPACING_M, DRAINAGE_RESPONSE_FILENAME
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/projects", tags=["B04 Surface Watershed"])
# 도엽 레이어 파일명 (B04 전처리 산출물과 같은 위치)
_CONTOUR_FILE = "도엽_등고선.geojson"
_STREAM_FILE = "도엽_하천중심선.geojson"
def _sheet_dir(stored_path: str) -> Path:
return Path(resolve_stored_project_path(stored_path)) / "B04_wf1_Surface" / "processed"
def _route_input_dir(stored_path: str) -> Path:
"""B03 업로드 폴더 — 계획 노선 파일이 여기 들어온다."""
return Path(resolve_stored_project_path(stored_path)) / "B03_FileInput" / "input"
def _load_features(directory: Path, filename: str) -> list[dict[str, Any]]:
"""도엽 GeoJSON을 읽어 피처 목록만 돌려준다. 없으면 빈 목록."""
path = directory / filename
if not path.exists():
return []
try:
with path.open("r", encoding="utf-8") as file:
data = json.load(file)
except (OSError, json.JSONDecodeError):
logger.warning("도엽 GeoJSON을 읽지 못했습니다: %s", path)
return []
features = data.get("features")
return features if isinstance(features, list) else []
def _reproject_features(
features: list[dict[str, Any]],
transformer: Transformer | None,
) -> list[dict[str, Any]]:
"""WGS84 도엽 좌표를 사업지 CRS(m)로 바꾼다. 거리·면적을 미터로 계산하기 위함."""
if transformer is None:
return features
converted: list[dict[str, Any]] = []
for feature in features:
geometry = feature.get("geometry")
if not geometry:
continue
coordinates = _map_coordinates(geometry.get("coordinates"), transformer)
if coordinates is None:
continue
converted.append(
{
"type": "Feature",
"properties": feature.get("properties") or {},
"geometry": {"type": geometry.get("type"), "coordinates": coordinates},
}
)
return converted
def _map_coordinates(coordinates: Any, transformer: Transformer) -> Any:
"""중첩 좌표 배열을 재귀적으로 변환한다."""
if not isinstance(coordinates, list) or not coordinates:
return None
first = coordinates[0]
if isinstance(first, (int, float)):
x, y = transformer.transform(float(coordinates[0]), float(coordinates[1]))
return [x, y]
mapped = [_map_coordinates(item, transformer) for item in coordinates]
return [item for item in mapped if item is not None]
def _candidate_payload(
candidate: StructureCandidate,
to_lonlat: Any,
) -> dict[str, Any]:
lon, lat = to_lonlat(candidate.x, candidate.y)
return {
"chainage_m": round(candidate.chainage_m, 2),
"x": candidate.x,
"y": candidate.y,
"lon": lon,
"lat": lat,
"reason": candidate.reason,
"stream_name": candidate.stream_name,
}
async def _prepare(project_id: UUID) -> dict[str, Any] | JSONResponse:
"""계획 노선 파일과 도엽 피처, 좌표 변환기를 준비한다.
노선은 **B03에 업로드된 계획 노선 파일**에서 읽는다 B05의 확정 경로가 아니다.
배수유역 분석은 노선 설계보다 먼저 끝나 있어야 하기 때문이다(2026-07-31 사용자 지시).
"""
pool = get_db_pool()
async with pool.acquire() as connection:
stored_path = await get_project_storage_relative_path(connection, project_id)
epsg = await get_surface_crs_epsg(connection, project_id, 0)
route_file = find_planned_route_file(_route_input_dir(stored_path))
if route_file is None:
return JSONResponse(
status_code=404,
content={"status": "error", "message": "계획 노선 파일이 업로드되지 않았습니다."},
)
planned = read_planned_route_csv(route_file)
if planned is None or len(planned.vertices) < 2:
return JSONResponse(
status_code=400,
content={
"status": "error",
"message": f"계획 노선 파일을 읽지 못했습니다: {route_file.name}",
},
)
# 노선 파일이 CRS를 명시하면 그 값을 따른다. 도엽 재투영도 같은 좌표계로 맞춘다.
source_crs = f"EPSG:{planned.epsg or epsg or 5186}"
to_lonlat_transformer = Transformer.from_crs(source_crs, "EPSG:4326", always_xy=True)
to_metric_transformer = Transformer.from_crs("EPSG:4326", source_crs, always_xy=True)
directory = _sheet_dir(stored_path)
streams = _reproject_features(_load_features(directory, _STREAM_FILE), to_metric_transformer)
contour_features = _reproject_features(
_load_features(directory, _CONTOUR_FILE), to_metric_transformer
)
return {
"route_source": route_file.name,
"vertices": planned.vertices,
"route_line": planned.line,
"streams": streams,
"contours": contour_features,
"stored_path": stored_path,
"to_lonlat": lambda x, y: to_lonlat_transformer.transform(x, y),
}
def _response_path(stored_path: str) -> Path:
"""분석 응답 캐시 경로. 재산정하지 않는 한 이 파일을 그대로 돌려준다."""
return drainage_dir(stored_path) / DRAINAGE_RESPONSE_FILENAME
def _load_saved_response(stored_path: str) -> dict[str, Any] | None:
path = _response_path(stored_path)
if not path.exists():
return None
try:
with path.open("r", encoding="utf-8") as file:
return json.load(file)
except (OSError, json.JSONDecodeError):
logger.warning("배수유역: 저장된 분석 응답을 읽지 못했습니다 (%s).", path)
return None
def _save_response(stored_path: str, payload: dict[str, Any]) -> None:
path = _response_path(stored_path)
try:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8") as file:
json.dump(payload, file, ensure_ascii=False)
except OSError:
logger.warning("배수유역: 분석 응답을 저장하지 못했습니다 (%s).", path)
@router.get("/{project_id}/drainage/primary-region", response_model=None)
async def get_primary_region(
project_id: UUID, refresh: bool = False
) -> dict[str, Any] | JSONResponse:
"""배수유역 분석 결과를 돌려준다.
기본은 **영구저장소에 남은 결과를 그대로** 준다 분석이 30 걸리므로 화면을
때마다 다시 돌릴 이유가 없다. `refresh=true` 처음부터 다시 계산하고 덮어쓴다
(2026-07-31 사용자 지시).
"""
pool = get_db_pool()
async with pool.acquire() as connection:
stored_path = await get_project_storage_relative_path(connection, project_id)
if not refresh:
saved = _load_saved_response(stored_path)
if saved is not None:
logger.info("배수유역: 저장된 분석 결과를 그대로 돌려줍니다 (%s).", stored_path)
return {**saved, "from_cache": True}
prepared = await _prepare(project_id)
if isinstance(prepared, JSONResponse):
return prepared
preview = await asyncio.to_thread(
preview_stages,
prepared["vertices"],
prepared["contours"],
prepared["streams"],
)
if preview is None:
return JSONResponse(
status_code=400,
content={"status": "error", "message": "1차 배수유역을 정할 등고선이 없습니다."},
)
region = preview.region
to_lonlat = prepared["to_lonlat"]
# 확장을 거치면 격자·해석 영역이 1차 영역보다 커진다. 최종본을 써야 화면과 어긋나지 않는다.
spec = preview.spec or region.spec
domain = preview.domain if preview.domain is not None else region.cell_mask
payload = {
"status": "success",
"project_id": str(project_id),
"route_source": prepared["route_source"],
"radius_m": region.radius_m,
# 채택된 상류 세류망 = 1차 영역의 기준선.
"upstream_lines": [_line_lonlat(line, to_lonlat) for line in region.split.upstream],
# 도로 아래로 이어진 하류망 — 판정이 맞는지 눈으로 대조하기 위해 함께 준다.
"downstream_lines": [_line_lonlat(line, to_lonlat) for line in region.split.downstream],
"no_contact_count": region.split.no_contact,
# 노선이 1차 영역 밖으로 나간 길이(m). 크면 반경을 올려야 한다는 신호.
"road_outside_m": round(region.road_outside_m, 1),
# 1차 영역(버퍼 합집합) 외곽 링 목록.
"region_rings": _polygon_rings(region.area, to_lonlat),
"grid": {
"cell_m": spec.cell_m,
"rows": spec.n_rows,
"cols": spec.n_cols,
# bbox 전체 셀 수와, 해석 영역에 실제로 생성된 셀 수(확장 반영).
"bbox_cells": spec.size,
"cells": int(domain.sum()) if domain is not None else 0,
"width_m": round(spec.n_cols * spec.cell_m, 1),
"height_m": round(spec.n_rows * spec.cell_m, 1),
# 격자 bbox 링. 프론트는 이 사각형을 rows×cols로 나눠 행·열 좌표를 얻는다.
"bbox_lonlat": _grid_bbox_lonlat(spec, to_lonlat),
# 실제 생성된 셀을 행별 연속 구간 [행, 시작열, 끝열]으로 압축해 보낸다.
# 셀을 낱개로 보내면 수십만 건이라 응답이 감당되지 않는다.
"row_spans": [list(span) for span in mask_row_spans(domain)]
if domain is not None
else [],
},
# 최외곽 적색 셀 주변 확장 결과.
"expansion": {
"rounds": preview.expand_rounds,
"closed": preview.expand_closed,
"added_cells": preview.expand_added_cells,
"initial_cells": region.active_cells,
},
# 셀별 흐름 방향과 도로 도달 여부. row_spans 순서(행 → 구간 → 열 오름차순)로 1바이트씩.
"flow": _flow_payload(preview, domain),
# ⑦ 2차 전체 배수유역 외곽선(= 분수령)과 면적.
"basin_polygon_lonlat": [list(to_lonlat(x, y)) for x, y in preview.basin_boundary_xy],
"basin_area_m2": round(preview.basin_area_m2, 1),
# ⑥ 도로 위 흐름 강도 — [누가거리 m, 그 구간으로 모이는 상류 면적 ㎡].
"strength_profile": [
[round(chainage, 1), round(area, 1)] for chainage, area in preview.strength_profile
],
# ⑧ 기본 관 매설 위치 — 도로 × 세류선 교차점.
"pipes": [_candidate_payload(pipe, to_lonlat) for pipe in preview.pipes],
# B05용 평균 흐름 화살표 — [lon, lat, 방위(도), 도로도달, 셀 수].
# 세류·도로 셀을 뺀 블록 평균이라 사면 경향만 남는다.
"flow_arrows": [
[*to_lonlat(x, y), round(math.degrees(angle), 1), reaches, cells]
for x, y, angle, reaches, cells in preview.flow_arrows
],
# 화살표 간격(m). 화면이 화살표 크기를 정할 때 쓴다 — 서로 닿지 않게 이 값보다 짧게 그린다.
"arrow_spacing_m": DRAINAGE_ARROW_SPACING_M,
"from_cache": False,
}
# 단계 산출물을 영구저장소에 남긴다 — 기능을 붙일 때마다 여기에 단계가 하나씩 늘어난다.
payload["saved_to"] = write_stage(
prepared["stored_path"],
"primary_region",
{
"primary_region": _as_polygons(region.area),
"upstream": region.split.upstream,
"downstream": region.split.downstream,
"route": [prepared["route_line"]],
"grid_bbox": [_grid_bbox_polygon(spec)],
# ⑦ 2차 전체 배수유역 외곽선, ⑧ 기본 관 위치(누가거리·근거 포함).
"basin_boundary": _boundary_geometry(preview.basin_boundary_xy),
"pipe": [
(
Point(pipe.x, pipe.y),
{
"chainage_m": round(pipe.chainage_m, 2),
"reason": pipe.reason,
"stream_name": pipe.stream_name,
},
)
for pipe in preview.pipes
],
},
{
"radius_m": region.radius_m,
"road_outside_m": payload["road_outside_m"],
"no_contact_count": region.split.no_contact,
"basin_area_m2": payload["basin_area_m2"],
"pipe_count": len(preview.pipes),
# 기하(bbox·셀 구간)는 파일 본문에 있으므로 요약 수치만 남긴다.
"grid": {
key: value
for key, value in payload["grid"].items()
if key not in {"bbox_lonlat", "row_spans"}
},
},
to_lonlat,
)
_write_stage_arrays(prepared["stored_path"], preview, domain, spec)
_write_road_routing(prepared["stored_path"], preview, spec, prepared["route_line"], to_lonlat)
# 응답 자체를 캐시로 남긴다 — 다음 조회는 배열을 재조립하지 않고 이 파일을 그대로 준다.
_save_response(prepared["stored_path"], payload)
return payload
def _write_road_routing(
stored_path: str, preview: Any, spec: Any, route_line: Any, to_lonlat: Any
) -> None:
"""B05가 세부유역을 나눌 때 쓸 최소 산출물을 남긴다.
B05는 일반 사용자용이라 가벼워야 한다. 화살표(방향 코드)·밴드 표고 같은 확인용 배열은
빼고, ** 도로 귀속** 도로 제원만 담는다. 여기에 표고를 함께 넣는 이유는
유역 낙차를 내려면 표고가 필요해서다(2026-07-31 사용자 지시).
"""
routing = preview.routing
road = preview.road
if routing is None or road is None or road.count == 0:
return
write_grid_arrays(
stored_path,
"road_routing",
spec,
{
"road_slot": routing.road_slot,
"path_length": routing.path_length,
"strength": routing.strength,
"road_cell_index": road.cell_index,
"road_chainage": road.chainage,
"elevation": preview.terrain.elevation.reshape(-1),
},
{
"road_cells": road.count,
"reached_cells": int((routing.road_slot >= 0).sum()),
"basin_area_m2": round(preview.basin_area_m2, 1),
"pipe_count": len(preview.pipes),
},
)
# B05가 그대로 그릴 기하 — 계획도로선 · 기본 배관 · 2차 전체 배수유역, 이 셋뿐이다.
write_stage(
stored_path,
"road_routing",
{
"route": [route_line],
"basin_boundary": _boundary_geometry(preview.basin_boundary_xy),
"pipe": [
(
Point(pipe.x, pipe.y),
{"chainage_m": round(pipe.chainage_m, 2), "reason": pipe.reason},
)
for pipe in preview.pipes
],
# 평균 흐름 화살표 — B05도 같은 그림을 그려야 하므로 여기 함께 남긴다.
"flow_arrow": [
(
Point(x, y),
{
# B05 화면은 사업지 CRS(m)로 그리므로 미터 좌표도 함께 남긴다.
"x": round(x, 2),
"y": round(y, 2),
"azimuth_deg": round(math.degrees(angle), 1),
"reaches_road": reaches,
"cells": cells,
},
)
for x, y, angle, reaches, cells in preview.flow_arrows
],
},
{
"basin_area_m2": round(preview.basin_area_m2, 1),
"pipe_count": len(preview.pipes),
"route_length_m": round(route_line.length, 1),
"arrow_count": len(preview.flow_arrows),
"arrow_spacing_m": DRAINAGE_ARROW_SPACING_M,
},
to_lonlat,
)
def _write_stage_arrays(stored_path: str, preview: Any, domain: Any, spec: Any) -> None:
"""격자 규모 배열(셀 마스크·흐름 방향·도달 여부)을 단계별 `.npz`로 남긴다."""
if domain is not None:
write_grid_arrays(
stored_path,
"primary_region",
spec,
{"mask": domain},
{
"cells": int(domain.sum()),
"bbox_cells": spec.size,
"expand_rounds": preview.expand_rounds,
"expand_closed": preview.expand_closed,
},
)
flow = preview.flow
if flow is None:
return
arrays = {
"direction": flow.direction.reshape(spec.n_rows, spec.n_cols),
"reaches_road": flow.reaches_road.reshape(spec.n_rows, spec.n_cols),
"analyzed": flow.analyzed.reshape(spec.n_rows, spec.n_cols),
# 사후 진단용 — 수신 셀이 있어야 사슬을 다시 따라가 볼 수 있다.
"receiver": preview.terrain.receiver.reshape(spec.n_rows, spec.n_cols),
}
if flow.burned is not None:
arrays["burned"] = flow.burned.reshape(spec.n_rows, spec.n_cols)
if preview.descent is not None:
arrays["band_elevation"] = preview.descent.band_elevation
# ⑥ 흐름 강도 곡선 — 기하가 아니라 수치 곡선이라 GeoJSON이 아닌 여기에 함께 담는다.
if preview.strength_profile:
curve = np.asarray(preview.strength_profile, dtype=np.float64)
arrays["strength_chainage_m"] = curve[:, 0]
arrays["strength_area_m2"] = curve[:, 1]
write_grid_arrays(
stored_path,
"flow_direction",
spec,
arrays,
{
"azimuth_steps": AZIMUTH_STEPS,
"sink_code": AZIMUTH_SINK,
"invalid_code": AZIMUTH_INVALID,
"analyzed": int(flow.analyzed.sum()),
"reaches_road": int((flow.reaches_road & flow.analyzed).sum()),
"no_road": int((~flow.reaches_road & flow.analyzed).sum()),
"burned": 0 if flow.burned is None else int(flow.burned.sum()),
"outer_seeds": flow.outer_seeds,
"interior_seeds": flow.interior_seeds,
"strength_points": len(preview.strength_profile),
"strength_total_m2": round(sum(area for _, area in preview.strength_profile), 1),
},
)
def _flow_payload(preview: Any, domain: Any) -> dict[str, Any] | None:
"""셀별 흐름 방향·도로 도달 여부를 바이트 배열로 압축한다.
셀이 수십만 개라 JSON 객체로는 보낸다. 하나당 1바이트로 줄이고 base64로 싣는다:
하위 6비트(0x3F) = 32방위 코드(0~31, 0=화면 오른쪽·시계방향), 32=제자리, 33=표고 없음
최상위 비트(0x80) = 도로 도달(적색). 꺼져 있으면 미도달(백색 화살표).
바이트 순서는 `grid.row_spans` 구간 오름차순으로 훑은 순서와 같다.
"""
flow = preview.flow
if flow is None or domain is None:
return None
order = np.flatnonzero(domain.reshape(-1))
analyzed = flow.analyzed[order]
reaches = flow.reaches_road[order]
packed = np.clip(flow.direction[order], 0, AZIMUTH_INVALID).astype(np.uint8)
packed |= np.where(reaches, 0x80, 0).astype(np.uint8)
burned = flow.burned
return {
"encoding": "base64-uint8",
"azimuth_steps": AZIMUTH_STEPS,
"sink_code": AZIMUTH_SINK,
"invalid_code": AZIMUTH_INVALID,
"cells": int(order.size),
"reaches_road": int((reaches & analyzed).sum()),
"no_road": int((~reaches & analyzed).sum()),
# 격자에는 있으나 등고선 TIN 밖이라 표고가 없어 판정 못한 셀.
"unanalyzed": int((~analyzed).sum()),
# 확정된 상류 세류망을 따라 흐름을 강제로 새긴 셀 수.
"burned": 0 if burned is None else int(burned[order].sum()),
"outer_seeds": flow.outer_seeds,
"interior_seeds": flow.interior_seeds,
"data": base64.b64encode(packed.tobytes()).decode("ascii"),
}
def _boundary_geometry(ring: list[tuple[float, float]]) -> list[Any]:
"""2차 유역 외곽 링을 저장용 폴리곤으로 만든다(정점 3개 미만이면 비운다)."""
return [Polygon(ring)] if len(ring) >= 4 else []
def _as_polygons(geometry: Any) -> list[Any]:
if geometry is None or geometry.is_empty:
return []
return list(geometry.geoms) if geometry.geom_type == "MultiPolygon" else [geometry]
def _grid_bbox_polygon(spec: Any) -> Polygon:
x_max = spec.x_min + spec.n_cols * spec.cell_m
y_min = spec.y_max - spec.n_rows * spec.cell_m
return box(spec.x_min, y_min, x_max, spec.y_max)
def _line_lonlat(line: Any, to_lonlat: Any) -> list[list[float]]:
return [list(to_lonlat(x, y)) for x, y in line.coords]
def _polygon_rings(geometry: Any, to_lonlat: Any) -> list[list[list[float]]]:
"""폴리곤/멀티폴리곤의 외곽 링만 뽑아 lonlat으로 바꾼다."""
if geometry is None or geometry.is_empty:
return []
parts = geometry.geoms if geometry.geom_type == "MultiPolygon" else [geometry]
return [[list(to_lonlat(x, y)) for x, y in part.exterior.coords] for part in parts]
def _grid_bbox_lonlat(spec: Any, to_lonlat: Any) -> list[list[float]]:
x_min = spec.x_min
x_max = spec.x_min + spec.n_cols * spec.cell_m
y_max = spec.y_max
y_min = spec.y_max - spec.n_rows * spec.cell_m
corners = ((x_min, y_min), (x_min, y_max), (x_max, y_max), (x_max, y_min), (x_min, y_min))
return [list(to_lonlat(x, y)) for x, y in corners]
+21
View File
@@ -110,6 +110,27 @@ class SurfacePointCloudSampleResponse(BaseModel):
rgb: list[list[int]] | None = None
class SurfaceConfirmedResponse(BaseModel):
"""확정 지표면 요약 — 화면 진입 판정·준비화면·B05가 공통으로 쓰는 단일 출처.
포인트 배열 없이 확정값과 지형 가장자리만 담아 KB로 유지한다.
signature는 확정 구성이 바뀌었는지 프론트가 줄로 비교하기 위한 값이다.
"""
status: str = "success"
project_id: str
model_id: int | None = None
source_filter: str | None = None
method: str | None = None
smooth: bool | None = None
contour_interval_m: float | None = None
signature: str
point_count: int | None = None
bounds: dict[str, float] | None = None
# 계획노선(B03 CSV)의 평면 범위. 지도 초기 화면을 도로 기준으로 맞출 때 쓴다.
route_bounds: dict[str, float] | None = None
class SurfaceGroundStatsResponse(BaseModel):
"""필터별 지면 포인트 통계 응답."""
@@ -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,213 @@ export function niceScaleDistance(roughMeters: number): number {
return step * base;
}
/* -----------------------------------------------------------------------------
* · (B04 /, B05 )
*
* OrbitControls (target) ,
* . target을
* .
* OrbitControls의 zoomToCursor로 .
* -------------------------------------------------------------------------- */
/** 극점을 넘어 화면이 뒤집히지 않도록 남기는 여유각(rad). */
const POLAR_EPSILON = 0.02;
/** 포인트클라우드 클릭 허용 반경 — 카메라 거리에 비례(멀수록 점이 성기게 보인다). */
const POINT_PICK_RATIO = 0.01;
/** 휠 한 칸당 배율. 휠을 위로 올리면 이 값의 역수만큼 멀어진다(2026-08-01 사용자 지시). */
const ZOOM_STEP = 0.9;
/** 회전 중심 구슬의 화면상 크기 비율(카메라 거리 대비). 멀어져도 같은 크기로 보인다. */
const PIVOT_MARKER_RATIO = 0.012;
const PIVOT_MARKER_COLOR = 0xf59e0b;
export interface CursorPivotOptions {
camera: THREE.PerspectiveCamera;
controls: OrbitControls;
/** 포인터 이벤트를 받는 캔버스. */
element: HTMLElement;
/** 커서 아래에서 찾을 대상(지형 메시·포인트클라우드). 없으면 기존 축을 유지한다. */
pickables: () => THREE.Object3D[];
/** 마커 드래그 등 다른 조작이 잡고 있으면 회전을 넘긴다. */
blocked?: () => boolean;
/** 회전 중심 구슬을 띄울 장면. 주지 않으면 구슬을 만들지 않는다. */
scene?: THREE.Scene;
}
/** 커서 기준 회전·줌을 붙이고, 해제 함수를 돌려준다. */
export function bindCursorPivotControls(options: CursorPivotOptions): () => void {
const { camera, controls, element } = options;
// 회전·줌 모두 여기서 직접 처리한다(OrbitControls에는 휠 방향을 뒤집는 설정이 없다).
controls.enableRotate = false;
controls.enableZoom = false;
// 가운데 버튼 드래그 = 화면 이동(전역 공통). 기본값(DOLLY)은 휠 줌과 겹쳐 쓸모가 없다.
controls.mouseButtons.MIDDLE = THREE.MOUSE.PAN;
// 회전 중심 구슬 — 돌리는 동안에만 보인다. 화면상 크기는 거리와 무관하게 일정하다.
const pivotMarker = options.scene
? new THREE.Mesh(
new THREE.SphereGeometry(1, 16, 12),
new THREE.MeshBasicMaterial({
color: PIVOT_MARKER_COLOR,
// 지형에 묻혀 안 보이면 축을 확인할 수 없으므로 항상 위에 그린다.
depthTest: false,
transparent: true,
opacity: 0.9,
}),
)
: null;
if (pivotMarker && options.scene) {
pivotMarker.visible = false;
pivotMarker.renderOrder = 999;
options.scene.add(pivotMarker);
}
/** 구슬을 현재 축 위치·크기로 맞춘다. */
function syncPivotMarker(): void {
if (!pivotMarker || !pivotMarker.visible) return;
pivotMarker.position.copy(pivot);
const distance = camera.position.distanceTo(pivot);
pivotMarker.scale.setScalar(Math.max(distance * PIVOT_MARKER_RATIO, 0.01));
}
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
const pivot = new THREE.Vector3();
const viewDirection = new THREE.Vector3();
const fallbackPlane = new THREE.Plane();
let pointerId: number | null = null;
let lastX = 0;
let lastY = 0;
/** .
*
* , · target을
* ** ** ( ). */
function pickPivot(event: { clientX: number; clientY: number }): void {
pivot.copy(controls.target);
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);
// 포인트클라우드는 점 사이가 비어 있어 정확히 맞히기 어렵다 — 거리에 비례한 허용 반경을 준다.
raycaster.params.Points.threshold =
camera.position.distanceTo(controls.target) * POINT_PICK_RATIO;
const hit = raycaster.intersectObjects(options.pickables(), true)[0];
if (hit) {
pivot.copy(hit.point);
return;
}
camera.getWorldDirection(viewDirection);
fallbackPlane.setFromNormalAndCoplanarPoint(viewDirection, controls.target);
raycaster.ray.intersectPlane(fallbackPlane, pivot);
}
function onPointerDown(event: PointerEvent): void {
// 가운데 버튼은 브라우저 기본 동작(페이지 자동 스크롤)이 화면 이동과 겹치므로 막는다.
if (event.button === 1) event.preventDefault();
if (event.button !== 0 || pointerId !== null) return;
if (options.blocked?.() || !controls.enabled) return;
pickPivot(event);
pointerId = event.pointerId;
lastX = event.clientX;
lastY = event.clientY;
if (pivotMarker) {
pivotMarker.visible = true;
syncPivotMarker();
}
}
/** .
* ( ). */
function onWheel(event: WheelEvent): void {
if (!controls.enabled || options.blocked?.()) return;
event.preventDefault();
pickPivot(event);
const factor = event.deltaY < 0 ? 1 / ZOOM_STEP : ZOOM_STEP;
const cameraOffset = camera.position.clone().sub(pivot).multiplyScalar(factor);
const targetOffset = controls.target.clone().sub(pivot).multiplyScalar(factor);
// 축에 너무 가까워지면 시점이 뒤집히므로 최소 거리를 남긴다.
if (cameraOffset.length() < 0.5 && factor < 1) return;
camera.position.copy(pivot).add(cameraOffset);
controls.target.copy(pivot).add(targetOffset);
camera.lookAt(controls.target);
controls.update();
syncPivotMarker();
}
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);
// 수직 회전 — 시선의 오른쪽 축 기준. 마우스를 내리면 위에서 내려다보는 방향(2026-08-01
// 사용자 지시로 상하 반전). 극점을 넘으면 상하 성분만 버린다.
const eyeDirection = cameraOffset.clone().sub(targetOffset);
const right = eyeDirection.clone().cross(up);
if (right.lengthSq() > 1e-8) {
right.normalize();
const rotated = eyeDirection.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();
syncPivotMarker();
}
function stop(): void {
pointerId = null;
if (pivotMarker) pivotMarker.visible = false;
}
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);
element.addEventListener("wheel", onWheel, { passive: false });
return () => {
element.removeEventListener("pointerdown", onPointerDown);
element.removeEventListener("pointermove", onPointerMove);
element.removeEventListener("pointerup", onPointerEnd);
element.removeEventListener("pointercancel", onPointerEnd);
element.removeEventListener("pointerleave", onPointerEnd);
element.removeEventListener("wheel", onWheel);
if (pivotMarker) {
pivotMarker.removeFromParent();
pivotMarker.geometry.dispose();
(pivotMarker.material as THREE.Material).dispose();
}
};
}
export function bindSurfaceViewerTheme(
applyBackground: (color: string | number) => void,
): () => void {
@@ -0,0 +1,88 @@
/* =============================================================================
* (B04·B05 )
*
* 10m . (1m)
* B05는 .
*
* ** **
* .
*
* (B04는 lon/lat , B05는 CRS ).
* `project` .
* ========================================================================== */
/** 화살표 1개 — [가로, 세로, 방위(도), 도로 도달, 셀 수]. 앞 두 값의 좌표계는 호출부가 정한다. */
export type FlowArrow = [number, number, number, boolean, number];
/** 화살표 길이를 간격의 몇 배로 할지. 1보다 작아야 서로 닿지 않는다. */
const LENGTH_RATIO = 0.55;
/** 선 두께를 길이의 몇 배로 할지. */
const WIDTH_RATIO = 0.07;
/** 이보다 짧으면 방향이 안 읽히므로 그리지 않는다(px). */
const MIN_LENGTH_PX = 9;
/** 화면을 가득 채우지 않도록 두는 상한(px). */
const MAX_LENGTH_PX = 40;
const TO_ROAD_COLOR = "rgba(153, 27, 27, 0.95)";
const AWAY_COLOR = "rgba(30, 64, 175, 0.95)";
const HALO_COLOR = "rgba(255, 255, 255, 0.9)";
/** 화살표 좌표를 캔버스 픽셀로 옮기는 함수. */
export type ArrowProjector = (a: number, b: number) => readonly [number, number];
/**
* .
*
* `spacingM` (m), `pxPerMeter` 1m가 px인지.
* · .
*/
export function drawFlowArrows(
context: CanvasRenderingContext2D,
arrows: ReadonlyArray<FlowArrow>,
spacingM: number,
pxPerMeter: number,
project: ArrowProjector,
canvas: { readonly width: number; readonly height: number },
): void {
if (arrows.length === 0 || spacingM <= 0 || pxPerMeter <= 0) return;
const length = Math.min(spacingM * pxPerMeter * LENGTH_RATIO, MAX_LENGTH_PX);
if (length < MIN_LENGTH_PX) return;
const reach = length / 2;
const head = length * 0.26;
const width = Math.max(0.8, length * WIDTH_RATIO);
context.save();
context.lineCap = "round";
context.lineJoin = "round";
context.setLineDash([]);
arrows.forEach(([a, b, degrees, reaches]) => {
const [x, y] = project(a, b);
if (x < -length || x > canvas.width + length) return;
if (y < -length || y > canvas.height + length) return;
const angle = (degrees * Math.PI) / 180;
const unitX = Math.cos(angle);
const unitY = Math.sin(angle);
const tailX = x - unitX * reach;
const tailY = y - unitY * reach;
const tipX = x + unitX * reach;
const tipY = y + unitY * reach;
// 어두운 배경·채움색 위에서도 읽히도록 흰 테두리를 한 겹 깔고 그 위에 색을 얹는다.
for (const [color, lineWidth] of [
[HALO_COLOR, width + 1.4] as const,
[reaches ? TO_ROAD_COLOR : AWAY_COLOR, width] as const,
]) {
context.strokeStyle = color;
context.lineWidth = lineWidth;
context.beginPath();
context.moveTo(tailX, tailY);
context.lineTo(tipX, tipY);
context.moveTo(tipX, tipY);
context.lineTo(tipX - (unitX + unitY * 0.65) * head, tipY - (unitY - unitX * 0.65) * head);
context.moveTo(tipX, tipY);
context.lineTo(tipX - (unitX - unitY * 0.65) * head, tipY - (unitY + unitX * 0.65) * head);
context.stroke();
}
});
context.restore();
}
@@ -0,0 +1,628 @@
import type { VWorldMeta } from "./B04_wf1_Surface_Api_Fetch";
// 2D 지도 벡터 레이어 렌더 엔진.
// GeoJSON 좌표를 로드 시 1회만 정규화 맵 좌표(0~1)로 사전 투영해 두고,
// 매 프레임에는 뷰포트·scale·offset을 합친 어파인 변환만 적용한다.
// 정규화 좌표라 뷰포트 리사이즈 시에도 재투영이 필요 없다. 원본 GeoJSON은 변형하지 않는다.
export type GeoJsonGeometry = {
type: string;
coordinates: unknown;
};
export type GeoJsonFeature = {
geometry?: GeoJsonGeometry | null;
properties?: Record<string, unknown> | null;
};
export type GeoJsonCollection = {
features?: GeoJsonFeature[];
};
export type MarkerKind = "dot" | "x";
/** 상류 세류망 강조 색 — 유역 판정의 기준선이라 가장 굵고 진하게 둔다. */
const UPSTREAM_LINE_COLOR = "rgba(29, 78, 216, 0.95)";
/**
* (// ). (0~1) x,y .
* weights: Douglas-Peucker ( , ).
* "화면 오차 < LOD_PX가 되는 정점" LOD를 .
* line GeoJSON은 .
*/
type PreparedPart = {
coords: Float64Array;
closed: boolean;
weights: Float64Array | null;
};
/** 사전 투영된 피처 1개. bbox는 정규화 좌표 기준이며 컬링에 사용한다. */
type PreparedFeature = {
kind: "line" | "point";
parts: PreparedPart[];
minX: number;
minY: number;
maxX: number;
maxY: number;
/** 등고 라벨 앵커(정규화 좌표). 라벨 대상이 아니면 labelText가 null. */
labelAnchorX: number;
labelAnchorY: number;
labelText: string | null;
};
export type PreparedLayer = {
features: PreparedFeature[];
};
/** lon/lat → 정규화 맵 좌표 변환 계수. meta에만 의존한다. aspect는 지도 종횡비(w/h). */
export type Normalizer = {
lonMin: number;
latMin: number;
lonRange: number;
latRange: number;
aspect: number;
};
/** 뷰포트 안에서 지도 이미지가 차지하는 사각형(기존 getMapRect와 동일 계산). */
export type MapRect = {
x: number;
y: number;
width: number;
height: number;
};
/** 프레임 단위 뷰 상태. */
export type ViewState = {
width: number;
height: number;
scale: number;
offsetX: number;
offsetY: number;
mapRect: MapRect;
};
export function createNormalizer(meta: VWorldMeta): Normalizer {
return {
lonMin: meta.lon_min,
latMin: meta.lat_min,
lonRange: meta.lon_max - meta.lon_min || 1,
latRange: meta.lat_max - meta.lat_min || 1,
aspect: meta.width_meters / Math.max(meta.height_meters, 1),
};
}
export function computeMapRect(meta: VWorldMeta | null, width: number, height: number): MapRect {
if (!meta) return { x: 0, y: 0, width, height };
const mapRatio = meta.width_meters / Math.max(meta.height_meters, 1);
const viewportRatio = width / Math.max(height, 1);
const mapWidth = mapRatio > viewportRatio ? width : height * mapRatio;
const mapHeight = mapRatio > viewportRatio ? width / mapRatio : height;
return {
x: (width - mapWidth) / 2,
y: (height - mapHeight) / 2,
width: mapWidth,
height: mapHeight,
};
}
/** (m). B04 B05
* (2026-08-01 지시: 도로 , + 200m까지). */
export const ROUTE_VIEW_MARGIN_M = 200;
/** 평면 좌표(m) 범위. */
export interface PlanBounds {
x_min: number;
x_max: number;
y_min: number;
y_max: number;
}
/**
* + · ( ).
*
* B04 B05 .
* ( ).
*/
export function computeRouteView(
meta: VWorldMeta | null,
route: PlanBounds | null,
viewportWidth: number,
viewportHeight: number,
marginM: number = ROUTE_VIEW_MARGIN_M,
): { scale: number; offsetX: number; offsetY: number } {
if (!meta || !route) return { scale: 1, offsetX: 0, offsetY: 0 };
const mapRect = computeMapRect(meta, viewportWidth, viewportHeight);
const wantWidth = Math.max(route.x_max - route.x_min, 1) + marginM * 2;
const wantHeight = Math.max(route.y_max - route.y_min, 1) + marginM * 2;
const scale = Math.max(
Math.min(meta.width_meters / wantWidth, meta.height_meters / wantHeight),
1,
);
const centerX = (route.x_min + route.x_max) / 2;
const centerY = (route.y_min + route.y_max) / 2;
const baseX = mapRect.x + ((centerX - meta.x_min) / meta.width_meters) * mapRect.width;
const baseY = mapRect.y + (1 - (centerY - meta.y_min) / meta.height_meters) * mapRect.height;
return {
scale,
offsetX: -(baseX - viewportWidth / 2) * scale,
offsetY: -(baseY - viewportHeight / 2) * scale,
};
}
function isPoint(value: unknown): value is [number, number] {
return Array.isArray(value) && typeof value[0] === "number" && typeof value[1] === "number";
}
/** lon/lat 배열 → 정규화 좌표 Float64Array. 유효 정점이 없으면 null. */
function projectRing(ring: unknown, normalizer: Normalizer): Float64Array | null {
if (!Array.isArray(ring) || ring.length === 0) return null;
const coords = new Float64Array(ring.length * 2);
let count = 0;
for (const point of ring) {
if (!isPoint(point)) continue;
coords[count * 2] = (point[0] - normalizer.lonMin) / normalizer.lonRange;
coords[count * 2 + 1] = 1 - (point[1] - normalizer.latMin) / normalizer.latRange;
count += 1;
}
if (count === 0) return null;
return count * 2 === coords.length ? coords : coords.slice(0, count * 2);
}
function collectParts(
geometry: GeoJsonGeometry,
normalizer: Normalizer,
parts: PreparedPart[],
): "line" | "point" {
const coordinates = geometry.coordinates;
if (!Array.isArray(coordinates)) return "line";
const push = (ring: unknown, closed: boolean): void => {
const projected = projectRing(ring, normalizer);
if (projected) parts.push({ coords: projected, closed, weights: null });
};
switch (geometry.type) {
case "Point":
push([coordinates], false);
return "point";
case "MultiPoint":
push(coordinates, false);
return "point";
case "LineString":
push(coordinates, false);
return "line";
case "MultiLineString":
for (const line of coordinates) push(line, false);
return "line";
case "Polygon":
for (const ring of coordinates) push(ring, true);
return "line";
case "MultiPolygon":
for (const polygon of coordinates) {
if (!Array.isArray(polygon)) continue;
for (const ring of polygon) push(ring, true);
}
return "line";
default:
return "line";
}
}
/**
* Douglas-Peucker (, ).
* weights[i] = "허용 오차가 이 값보다 크면 정점 i를 버려도 되는" .
* (cap) .
* y축은 1/aspect로 .
*/
function computeDpWeights(coords: Float64Array, aspect: number): Float64Array {
const n = coords.length / 2;
const weights = new Float64Array(n);
weights[0] = Infinity;
weights[n - 1] = Infinity;
if (n <= 2) return weights;
const stack: number[] = [0, n - 1];
const caps: number[] = [Infinity];
while (stack.length) {
const last = stack.pop()!;
const first = stack.pop()!;
const cap = caps.pop()!;
if (last - first < 2) continue;
const ax = coords[first * 2];
const ay = coords[first * 2 + 1] / aspect;
const bx = coords[last * 2];
const by = coords[last * 2 + 1] / aspect;
const dx = bx - ax;
const dy = by - ay;
const len = Math.sqrt(dx * dx + dy * dy);
let maxDist = -1;
let maxIndex = -1;
for (let i = first + 1; i < last; i += 1) {
const px = coords[i * 2] - ax;
const py = coords[i * 2 + 1] / aspect - ay;
const dist = len === 0 ? Math.sqrt(px * px + py * py) : Math.abs(px * dy - py * dx) / len;
if (dist > maxDist) {
maxDist = dist;
maxIndex = i;
}
}
const weight = Math.min(maxDist, cap);
weights[maxIndex] = weight;
stack.push(first, maxIndex, maxIndex, last);
caps.push(weight, weight);
}
return weights;
}
/** 등고 라벨 앵커: LineString/MultiLineString 첫 파트의 중앙 정점 (기존 동작 유지). */
function labelAnchorOf(geometry: GeoJsonGeometry, normalizer: Normalizer): [number, number] | null {
const coords = geometry.coordinates;
if (!Array.isArray(coords)) return null;
const line =
geometry.type === "LineString"
? coords
: geometry.type === "MultiLineString"
? coords[0]
: null;
if (!Array.isArray(line) || line.length === 0) return null;
const mid = line[Math.floor(line.length / 2)];
if (!isPoint(mid)) return null;
return [
(mid[0] - normalizer.lonMin) / normalizer.lonRange,
1 - (mid[1] - normalizer.latMin) / normalizer.latRange,
];
}
/**
* GeoJSON 1 .
* labelKeys가 (25m ) · .
*/
export function prepareLayer(
collection: GeoJsonCollection | undefined,
normalizer: Normalizer,
labelKeys?: string[],
): PreparedLayer {
const features: PreparedFeature[] = [];
for (const feature of collection?.features ?? []) {
if (!feature.geometry) continue;
const parts: PreparedPart[] = [];
const kind = collectParts(feature.geometry, normalizer, parts);
if (parts.length === 0) continue;
if (kind === "line") {
for (const part of parts) {
if (part.coords.length < 6) continue;
part.weights = computeDpWeights(part.coords, normalizer.aspect);
}
}
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const part of parts) {
const coords = part.coords;
for (let i = 0; i < coords.length; i += 2) {
const x = coords[i];
const y = coords[i + 1];
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
}
let labelText: string | null = null;
let labelAnchorX = 0;
let labelAnchorY = 0;
if (labelKeys && labelKeys.length > 0) {
const raw = labelKeys.map((key) => feature.properties?.[key]).find((value) => value != null);
const elevation = typeof raw === "number" ? raw : Number(raw);
// 계곡선(25m 배수)만 라벨 — 전체 표기 시 화면이 숫자로 뒤덮이는 것 방지
if (Number.isFinite(elevation) && elevation % 25 === 0) {
const anchor = labelAnchorOf(feature.geometry, normalizer);
if (anchor) {
labelText = String(elevation);
labelAnchorX = anchor[0];
labelAnchorY = anchor[1];
}
}
}
features.push({ kind, parts, minX, minY, maxX, maxY, labelAnchorX, labelAnchorY, labelText });
}
return { features };
}
/**
* (m) .
* meta의 x/y lon/lat , GeoJSON과
* .
*/
export function prepareMetricPolyline(
points: ReadonlyArray<{ x: number; y: number }>,
meta: VWorldMeta,
): PreparedLayer {
if (points.length < 2) return { features: [] };
const widthMeters = meta.width_meters || 1;
const heightMeters = meta.height_meters || 1;
const coords = new Float64Array(points.length * 2);
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
points.forEach((point, index) => {
const nx = (point.x - meta.x_min) / widthMeters;
const ny = 1 - (point.y - meta.y_min) / heightMeters;
coords[index * 2] = nx;
coords[index * 2 + 1] = ny;
if (nx < minX) minX = nx;
if (nx > maxX) maxX = nx;
if (ny < minY) minY = ny;
if (ny > maxY) maxY = ny;
});
return {
features: [
{
kind: "line",
parts: [{ coords, closed: false, weights: null }],
minX,
minY,
maxX,
maxY,
labelAnchorX: 0,
labelAnchorY: 0,
labelText: null,
},
],
};
}
/**
* 1 .
* base = mapRect.x + norm * mapRect.width
* screen = center + (base - center) * scale + offset
* = norm * (mapRect.width * scale) + (mapRect.x * scale + center * (1 - scale) + offset)
*/
type Affine = { ax: number; bx: number; ay: number; by: number };
function affineOf(view: ViewState): Affine {
const centerX = view.width / 2;
const centerY = view.height / 2;
return {
ax: view.mapRect.width * view.scale,
bx: view.mapRect.x * view.scale + centerX * (1 - view.scale) + view.offsetX,
ay: view.mapRect.height * view.scale,
by: view.mapRect.y * view.scale + centerY * (1 - view.scale) + view.offsetY,
};
}
/** 시각적 무손실 LOD 허용 오차(화면 px). 이보다 작은 오차의 정점만 생략된다. */
const LOD_PX = 0.75;
function drawLineParts(
context: CanvasRenderingContext2D,
feature: PreparedFeature,
affine: Affine,
): void {
// affine.ax = 정규화 1.0당 화면 px — 종횡비 보정 좌표계의 거리를 px로 바꾸는 계수.
const tolerance = LOD_PX / affine.ax;
for (const part of feature.parts) {
const coords = part.coords;
if (coords.length < 4) continue;
const weights = part.weights;
// 현재 줌에서 화면 오차 LOD_PX 미만인 정점만 생략 (끝점은 weight=∞라 항상 유지).
// 정점 사이 보간은 하지 않는다 — 원본 데이터의 형상 그대로 표시 (2026-07-28 사용자 지시).
context.beginPath();
let started = false;
for (let i = 0; i < coords.length; i += 2) {
if (weights && weights[i / 2] < tolerance) continue;
const x = coords[i] * affine.ax + affine.bx;
const y = coords[i + 1] * affine.ay + affine.by;
if (started) context.lineTo(x, y);
else {
context.moveTo(x, y);
started = true;
}
}
if (!started) continue;
if (part.closed) context.closePath();
context.stroke();
}
}
function drawPointParts(
context: CanvasRenderingContext2D,
feature: PreparedFeature,
affine: Affine,
marker: MarkerKind,
): void {
for (const part of feature.parts) {
const coords = part.coords;
for (let i = 0; i < coords.length; i += 2) {
const x = coords[i] * affine.ax + affine.bx;
const y = coords[i + 1] * affine.ay + affine.by;
if (marker === "x") {
// 표고점: 조금 굵고 큰 X 마커
const arm = 4;
const prevWidth = context.lineWidth;
context.lineWidth = 2;
context.beginPath();
context.moveTo(x - arm, y - arm);
context.lineTo(x + arm, y + arm);
context.moveTo(x - arm, y + arm);
context.lineTo(x + arm, y - arm);
context.stroke();
context.lineWidth = prevWidth;
continue;
}
context.beginPath();
context.arc(x, y, 2, 0, Math.PI * 2);
context.fillStyle = context.strokeStyle;
context.fill();
}
}
}
/** 컬링 여백: 선 굵기·X 마커 팔 길이·라벨 폭을 감안한 화면 밖 판정 마진(px). */
const CULL_MARGIN = 32;
function isVisible(feature: PreparedFeature, affine: Affine, view: ViewState): boolean {
const margin = CULL_MARGIN;
const minX = feature.minX * affine.ax + affine.bx;
const maxX = feature.maxX * affine.ax + affine.bx;
const minY = feature.minY * affine.ay + affine.by;
const maxY = feature.maxY * affine.ay + affine.by;
return !(
maxX < -margin ||
minX > view.width + margin ||
maxY < -margin ||
minY > view.height + margin
);
}
/** 레이어 1개를 그린다. context의 lineWidth/strokeStyle은 호출부에서 설정한다. */
export function drawPreparedLayer(
context: CanvasRenderingContext2D,
layer: PreparedLayer,
view: ViewState,
marker: MarkerKind,
): void {
const affine = affineOf(view);
for (const feature of layer.features) {
if (!isVisible(feature, affine, view)) continue;
if (feature.kind === "point") drawPointParts(context, feature, affine, marker);
else drawLineParts(context, feature, affine);
}
}
/** 사전 계산된 계곡선 라벨을 그린다. 폰트·정렬은 호출부에서 설정한다. */
export function drawPreparedLabels(
context: CanvasRenderingContext2D,
layer: PreparedLayer,
view: ViewState,
color: string,
): void {
const affine = affineOf(view);
const margin = CULL_MARGIN;
for (const feature of layer.features) {
if (feature.labelText === null) continue;
const x = feature.labelAnchorX * affine.ax + affine.bx;
const y = feature.labelAnchorY * affine.ay + affine.by;
if (x < -margin || x > view.width + margin) continue;
if (y < -margin || y > view.height + margin) continue;
context.lineWidth = 3;
context.strokeStyle = "rgba(255, 255, 255, 0.9)";
context.strokeText(feature.labelText, x, y);
context.fillStyle = color;
context.fillText(feature.labelText, x, y);
}
}
/** 채움 폴리곤 오버레이(배수유역 등). 좌표는 lon/lat 링 1개. */
export type FilledRing = {
ring: ReadonlyArray<readonly [number, number]>;
/** 면적 중심에 얹을 번호. 없으면 라벨을 그리지 않는다. */
label?: string;
};
/**
* lon/lat + + .
* ( ) .
*/
export function drawFilledRing(
context: CanvasRenderingContext2D,
entry: FilledRing,
normalizer: Normalizer,
view: ViewState,
color: string,
): void {
if (entry.ring.length < 3) return;
const affine = affineOf(view);
let sumX = 0;
let sumY = 0;
context.beginPath();
entry.ring.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;
sumX += x;
sumY += y;
if (index === 0) context.moveTo(x, y);
else context.lineTo(x, y);
});
context.closePath();
context.fillStyle = color;
context.fill();
context.strokeStyle = color;
context.lineWidth = 1.6;
context.stroke();
if (!entry.label) return;
// 면적 중심(정점 평균)에 번호를 원형 배지로 얹는다.
const centerX = sumX / entry.ring.length;
const centerY = sumY / entry.ring.length;
context.beginPath();
context.arc(centerX, centerY, 11, 0, Math.PI * 2);
context.fillStyle = color;
context.fill();
context.strokeStyle = "rgba(255, 255, 255, 0.9)";
context.lineWidth = 1.5;
context.stroke();
context.font = "600 12px sans-serif";
context.textAlign = "center";
context.textBaseline = "middle";
context.fillStyle = "#1f2937";
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,
ring: ReadonlyArray<readonly [number, number]>,
normalizer: Normalizer,
view: ViewState,
): void {
if (ring.length < 3) return;
const affine = affineOf(view);
context.beginPath();
ring.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.closePath();
context.save();
context.strokeStyle = "#92400e";
context.lineWidth = 1.8;
context.setLineDash([7, 4]);
context.stroke();
context.restore();
}
+216 -243
View File
@@ -1,34 +1,38 @@
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import { createProgressCircle } from "@ui/ui_template_progress";
import { fetchCachedSheetLayer } from "../A00_Common/b_asset_cache";
import {
fetchGisGeoJson,
fetchVWorldMeta,
getVWorldMapUrl,
type SurfaceBounds,
type VWorldMeta,
} from "./B04_wf1_Surface_Api_Fetch";
import { niceScaleDistance } from "./B04_wf1_Surface_UI_Camera";
import { createWatershedOverlay } from "./B04_wf1_Surface_UI_Watershed";
import {
computeMapRect,
computeRouteView,
createNormalizer,
drawPreparedLabels,
drawPreparedLayer,
prepareLayer,
type GeoJsonCollection,
type MapRect,
type Normalizer,
type PlanBounds,
type PreparedLayer,
type ViewState,
} from "./B04_wf1_Surface_UI_MapRender";
export interface SurfaceMapViewer {
root: HTMLElement;
render: (projectId: string, referenceBounds?: SurfaceBounds) => void;
/** routeBounds: 계획노선 평면 범위 — 초기 화면을 도로 중심으로 맞추는 데 쓴다. */
render: (projectId: string, routeBounds?: PlanBounds | null) => void;
dispose: () => void;
}
type GeoJsonGeometry = {
type: string;
coordinates: unknown;
};
type GeoJsonFeature = {
geometry?: GeoJsonGeometry | null;
properties?: Record<string, unknown> | null;
};
type GeoJsonCollection = {
features?: GeoJsonFeature[];
};
const BACKGROUND_LAYERS = ["white", "satellite", "hybrid"] as const;
// 유수방향은 화면에 쓰지 않기로 해 목록에서 뺐다(2026-08-01 사용자 지시).
const GIS_LAYERS = [
"지적도",
"행정구역_시군구",
@@ -39,11 +43,34 @@ const GIS_LAYERS = [
"도엽_표고점",
"도엽_성절토",
"도엽_옹벽석축",
"도엽_유수방향",
] as const;
type BackgroundLayer = (typeof BACKGROUND_LAYERS)[number];
type GisLayer = (typeof GIS_LAYERS)[number];
/* (2026-08-01 )
* .
* . */
const BACKGROUND_DEFAULT_ON: Record<BackgroundLayer, boolean> = {
white: true,
satellite: true,
hybrid: false,
};
const GIS_DEFAULT_ON: Record<GisLayer, boolean> = {
지적도: false,
행정구역_시군구: true,
행정구역_읍면동: true,
등고선: false,
도엽_등고선: false,
도엽_하천중심선: true,
도엽_표고점: false,
도엽_성절토: false,
도엽_옹벽석축: false,
};
/** 등고 라벨(계곡선 수치) 기본 표시 여부. */
const CONTOUR_LABEL_DEFAULT_ON = false;
const GIS_LAYER_COLORS: Record<GisLayer, string> = {
: "#f97316",
_시군구: "#7c3aed",
@@ -54,7 +81,6 @@ const GIS_LAYER_COLORS: Record<GisLayer, string> = {
_표고점: "#f9a8d4",
_성절토: "#f43f5e",
_옹벽석축: "#0f766e",
_유수방향: "#0891b2",
};
// 등고 라벨 표기 대상 레이어와 표고 속성 키 (gpkg=CTRLN_HG, 도엽=등고수치)
@@ -117,32 +143,59 @@ 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");
scaleBar.append(scaleText);
// 지도 정중앙 로딩 서클 — 도엽 레이어가 10종이라 다 받을 때까지 화면이 비어 보인다.
const progress = createProgressCircle({ overlay: true });
progress.root.hidden = true;
viewport.append(
...BACKGROUND_LAYERS.map((layer) => backgroundImages.get(layer)!),
canvas,
empty,
status,
statusStack,
scaleBar,
progress.root,
);
/** 진행률(0~1, 모르면 null)과 문구. label이 null이면 서클을 감춘다. */
function showProgress(ratio: number | null, label: string | null): void {
progress.root.hidden = label === null;
if (label !== null) progress.set(ratio, label);
}
root.append(header, viewport);
let currentProjectId: string | null = null;
let referenceBounds: SurfaceBounds | null = null;
let meta: VWorldMeta | null = null;
const geoJsonLayers = new Map<GisLayer, GeoJsonCollection>();
const activeBackgrounds = new Set<BackgroundLayer>(BACKGROUND_LAYERS);
// gpkg 등고선은 기본 꺼짐(도엽 등고선이 기본 표기), 등고 라벨은 기본 켜짐 (2026-07-26 사용자 지시)
const activeGisLayers = new Set<GisLayer>(GIS_LAYERS.filter((layer) => layer !== "등고선"));
let showContourLabels = true;
// 초기 화면 기준이 되는 계획노선 범위(B03 CSV). 없으면 배경 전체를 보여준다.
let routeBounds: PlanBounds | null = null;
// 배수유역 오버레이가 lon/lat을 화면 좌표로 옮길 때 쓴다. 레이어 로드 시 1회 만든다.
let normalizer: Normalizer | null = null;
// 사전 투영된 렌더용 레이어. 원본 GeoJSON은 변형하지 않으며 투영 후에는 참조를 잡아두지 않는다.
const preparedLayers = new Map<GisLayer, PreparedLayer>();
const activeBackgrounds = new Set<BackgroundLayer>(
BACKGROUND_LAYERS.filter((layer) => BACKGROUND_DEFAULT_ON[layer]),
);
const activeGisLayers = new Set<GisLayer>(GIS_LAYERS.filter((layer) => GIS_DEFAULT_ON[layer]));
let showContourLabels = CONTOUR_LABEL_DEFAULT_ON;
let scale = 1;
let offsetX = 0;
let offsetY = 0;
let dragStart: { x: number; y: number; offsetX: number; offsetY: number } | null = null;
let loadSequence = 0;
// rAF 스로틀: 팬/줌 이벤트는 상태만 갱신하고 프레임당 1회만 드로잉한다.
// LOD(MapRender) 덕에 프레임 렌더 비용이 낮아 매 프레임 직접 렌더가 항상 완전한 화면을 보장한다.
let frameHandle = 0;
// 캔버스 버퍼는 크기가 실제로 변할 때만 재할당한다(재할당 시 내용이 지워지므로 매 프레임 금지).
let canvasWidth = 0;
let canvasHeight = 0;
let canvasDpr = 0;
function makeLayerButton<T extends string>(
label: string,
@@ -190,7 +243,6 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
도엽_표고점: L("B04_Surface_Map_SheetElevPoint"),
도엽_성절토: L("B04_Surface_Map_SheetCutFill"),
도엽_옹벽석축: L("B04_Surface_Map_SheetWall"),
도엽_유수방향: L("B04_Surface_Map_SheetFlowDir"),
};
GIS_LAYERS.forEach((layer) => {
gisButtons.append(
@@ -198,20 +250,37 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
);
});
// 등고 라벨 보기/숨기기 (기본 켜짐 — 등고선·도엽 등고선의 계곡선 수치 표기)
// 등고 라벨 보기/숨기기 (등고선·도엽 등고선의 계곡선 수치 표기)
const contourLabelButton = document.createElement("button");
contourLabelButton.type = "button";
contourLabelButton.className = "b04-map__layer-button is-active";
contourLabelButton.className =
"b04-map__layer-button" + (CONTOUR_LABEL_DEFAULT_ON ? " is-active" : "");
contourLabelButton.textContent = L("B04_Surface_Map_ContourLabel");
contourLabelButton.setAttribute("aria-pressed", "true");
contourLabelButton.setAttribute("aria-pressed", String(CONTOUR_LABEL_DEFAULT_ON));
contourLabelButton.addEventListener("click", () => {
showContourLabels = !showContourLabels;
contourLabelButton.classList.toggle("is-active", showContourLabels);
contourLabelButton.setAttribute("aria-pressed", String(showContourLabels));
drawVectorLayer();
scheduleDraw();
});
gisButtons.append(contourLabelButton);
// 배수유역 분석 오버레이 — 계산은 백엔드가 하고 여기서는 겹쳐 그리기만 한다.
// 상태 문구는 오버레이 전용 줄에 쓴다 — 지도 자체 상태(레이어 로딩)와 같은 칸을 쓰면
// 나중에 끝난 쪽이 상대 문구를 지워 버린다.
const watershed = createWatershedOverlay(() => scheduleDraw());
const watershedGroup = document.createElement("div");
watershedGroup.className = "b04-map__control-group";
const watershedTitle = document.createElement("span");
watershedTitle.textContent = "배수유역";
const watershedButtons = document.createElement("div");
watershedButtons.className = "b04-map__layer-buttons";
watershedButtons.append(watershed.button, ...watershed.partButtons);
watershedGroup.append(watershedTitle, watershedButtons);
controls.insertBefore(watershedGroup, resetButton);
// 안내·결과 문구는 컨트롤 줄이 아니라 지도 위에 얹는다 — 컨트롤 영역 세로 공간을 먹지 않는다.
statusStack.append(watershed.statusElement);
function updateImageTransform(): void {
backgroundImages.forEach((image) => {
image.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${scale})`;
@@ -223,203 +292,39 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
image.hidden = !activeBackgrounds.has(layer);
});
empty.hidden = activeBackgrounds.size > 0 || activeGisLayers.size > 0;
drawVectorLayer();
scheduleDraw();
}
function fitReferenceBounds(): void {
if (!meta || !referenceBounds) return;
/** + 200m (B05 ).
*
* 3D() 2D()
* , (2026-08-01 ).
* . */
function fitRouteView(): void {
const rect = viewport.getBoundingClientRect();
const width = Math.max(rect.width, 1);
const height = Math.max(rect.height, 1);
const mapRect = getMapRect(width, height);
const referenceWidth = Math.max(referenceBounds.x_max - referenceBounds.x_min, 1);
const referenceHeight = Math.max(referenceBounds.y_max - referenceBounds.y_min, 1);
scale =
Math.min(meta.width_meters / referenceWidth, meta.height_meters / referenceHeight) * 0.9;
const centerX = (referenceBounds.x_min + referenceBounds.x_max) / 2;
const centerY = (referenceBounds.y_min + referenceBounds.y_max) / 2;
const baseX = mapRect.x + ((centerX - meta.x_min) / meta.width_meters) * mapRect.width;
const baseY = mapRect.y + (1 - (centerY - meta.y_min) / meta.height_meters) * mapRect.height;
offsetX = -(baseX - width / 2) * scale;
offsetY = -(baseY - height / 2) * scale;
const view = computeRouteView(
meta,
routeBounds,
Math.max(rect.width, 1),
Math.max(rect.height, 1),
);
scale = view.scale;
offsetX = view.offsetX;
offsetY = view.offsetY;
}
function resetView(): void {
scale = 1;
offsetX = 0;
offsetY = 0;
fitReferenceBounds();
fitRouteView();
updateImageTransform();
drawVectorLayer();
scheduleDraw();
}
function getMapRect(width: number, height: number): DOMRect {
if (!meta) return new DOMRect(0, 0, width, height);
const mapRatio = meta.width_meters / Math.max(meta.height_meters, 1);
const viewportRatio = width / Math.max(height, 1);
const mapWidth = mapRatio > viewportRatio ? width : height * mapRatio;
const mapHeight = mapRatio > viewportRatio ? width / mapRatio : height;
return new DOMRect((width - mapWidth) / 2, (height - mapHeight) / 2, mapWidth, mapHeight);
}
function toCanvasPoint(
lon: number,
lat: number,
width: number,
height: number,
): [number, number] {
if (!meta) return [0, 0];
const mapRect = getMapRect(width, height);
const lonRange = meta.lon_max - meta.lon_min || 1;
const latRange = meta.lat_max - meta.lat_min || 1;
const baseX = mapRect.x + ((lon - meta.lon_min) / lonRange) * mapRect.width;
const baseY = mapRect.y + mapRect.height * (1 - (lat - meta.lat_min) / latRange);
const centerX = width / 2;
const centerY = height / 2;
return [
centerX + (baseX - centerX) * scale + offsetX,
centerY + (baseY - centerY) * scale + offsetY,
];
}
function drawRing(
context: CanvasRenderingContext2D,
ring: unknown,
width: number,
height: number,
closed: boolean,
): void {
if (!Array.isArray(ring) || ring.length === 0) return;
const points = ring.filter(
(point): point is [number, number] =>
Array.isArray(point) && typeof point[0] === "number" && typeof point[1] === "number",
);
if (points.length === 0) return;
context.beginPath();
points.forEach(([lon, lat], index) => {
const [x, y] = toCanvasPoint(lon, lat, width, height);
if (index === 0) context.moveTo(x, y);
else context.lineTo(x, y);
});
if (closed) context.closePath();
context.stroke();
}
function drawPoint(
context: CanvasRenderingContext2D,
coordinates: unknown,
width: number,
height: number,
marker: "dot" | "x" = "dot",
): void {
if (
!Array.isArray(coordinates) ||
typeof coordinates[0] !== "number" ||
typeof coordinates[1] !== "number"
) {
return;
}
const [x, y] = toCanvasPoint(coordinates[0], coordinates[1], width, height);
if (marker === "x") {
// 표고점: 조금 굵고 큰 X 마커
const arm = 4;
const prevWidth = context.lineWidth;
context.lineWidth = 2;
context.beginPath();
context.moveTo(x - arm, y - arm);
context.lineTo(x + arm, y + arm);
context.moveTo(x - arm, y + arm);
context.lineTo(x + arm, y - arm);
context.stroke();
context.lineWidth = prevWidth;
return;
}
context.beginPath();
context.arc(x, y, 2, 0, Math.PI * 2);
context.fillStyle = context.strokeStyle;
context.fill();
}
function contourLabelAnchor(geometry: GeoJsonGeometry): [number, number] | null {
const coords = geometry.coordinates;
if (!Array.isArray(coords)) return null;
const line =
geometry.type === "LineString"
? coords
: geometry.type === "MultiLineString"
? coords[0]
: null;
if (!Array.isArray(line) || line.length === 0) return null;
const mid = line[Math.floor(line.length / 2)];
if (!Array.isArray(mid) || typeof mid[0] !== "number" || typeof mid[1] !== "number") {
return null;
}
return [mid[0], mid[1]];
}
function drawContourLabels(
context: CanvasRenderingContext2D,
width: number,
height: number,
): void {
context.font = "600 13px sans-serif";
context.textAlign = "center";
context.textBaseline = "middle";
(Object.keys(CONTOUR_LABEL_KEYS) as GisLayer[]).forEach((layer) => {
if (!activeGisLayers.has(layer)) return;
const keys = CONTOUR_LABEL_KEYS[layer] ?? [];
geoJsonLayers.get(layer)?.features?.forEach((feature) => {
if (!feature.geometry) return;
const raw = keys.map((key) => feature.properties?.[key]).find((value) => value != null);
const elevation = typeof raw === "number" ? raw : Number(raw);
// 계곡선(25m 배수)만 라벨 — 전체 표기 시 화면이 숫자로 뒤덮이는 것 방지
if (!Number.isFinite(elevation) || elevation % 25 !== 0) return;
const anchor = contourLabelAnchor(feature.geometry);
if (!anchor) return;
const [x, y] = toCanvasPoint(anchor[0], anchor[1], width, height);
context.lineWidth = 3;
context.strokeStyle = "rgba(255, 255, 255, 0.9)";
context.strokeText(String(elevation), x, y);
context.fillStyle = GIS_LAYER_COLORS[layer];
context.fillText(String(elevation), x, y);
});
});
}
function drawGeometry(
context: CanvasRenderingContext2D,
geometry: GeoJsonGeometry,
width: number,
height: number,
marker: "dot" | "x" = "dot",
): void {
const coordinates = geometry.coordinates;
if (!Array.isArray(coordinates)) return;
if (geometry.type === "Point") {
drawPoint(context, coordinates, width, height, marker);
} else if (geometry.type === "MultiPoint") {
coordinates.forEach((point) => drawPoint(context, point, width, height, marker));
} else if (geometry.type === "LineString") {
drawRing(context, coordinates, width, height, false);
} else if (geometry.type === "MultiLineString") {
coordinates.forEach((line) => drawRing(context, line, width, height, false));
} else if (geometry.type === "Polygon") {
coordinates.forEach((ring) => drawRing(context, ring, width, height, true));
} else if (geometry.type === "MultiPolygon") {
coordinates.forEach((polygon) => {
if (Array.isArray(polygon)) {
polygon.forEach((ring) => drawRing(context, ring, width, height, true));
}
});
}
}
function drawScaleBar(width: number, height: number): void {
if (!meta || width <= 0) {
function drawScaleBar(mapRect: MapRect): void {
if (!meta || mapRect.width <= 0) {
scaleBar.hidden = true;
return;
}
const metersPerPixel = meta.width_meters / getMapRect(width, height).width / scale;
const metersPerPixel = meta.width_meters / mapRect.width / scale;
const meters = niceScaleDistance(100 * metersPerPixel);
const pixels = meters / metersPerPixel;
scaleBar.hidden = false;
@@ -427,34 +332,65 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
scaleText.textContent = meters >= 1000 ? `${meters / 1000} km` : `${meters} m`;
}
// 등고선(전국 gpkg·도엽)은 선 수가 많아 가장 아래에 얇게 깔아 다른 레이어 판독을 방해하지 않게 한다.
const isContourLayer = (layer: GisLayer): boolean =>
layer === "등고선" || layer === "도엽_등고선";
const DRAW_ORDER = [...GIS_LAYERS].sort((a, b) =>
isContourLayer(a) ? -1 : isContourLayer(b) ? 1 : 0,
);
function drawVectorLayer(): void {
const rect = viewport.getBoundingClientRect();
const width = Math.max(1, Math.floor(rect.width));
const height = Math.max(1, Math.floor(rect.height));
const dpr = window.devicePixelRatio || 1;
canvas.width = Math.floor(width * dpr);
canvas.height = Math.floor(height * dpr);
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
// 버퍼 재할당은 캔버스 내용을 지우므로 크기가 실제로 변할 때만 수행한다.
if (width !== canvasWidth || height !== canvasHeight || dpr !== canvasDpr) {
canvasWidth = width;
canvasHeight = height;
canvasDpr = dpr;
canvas.width = Math.floor(width * dpr);
canvas.height = Math.floor(height * dpr);
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
}
const context = canvas.getContext("2d");
if (!context) return;
context.setTransform(dpr, 0, 0, dpr, 0, 0);
context.clearRect(0, 0, width, height);
// 등고선(전국 gpkg·도엽)은 선 수가 많아 가장 아래에 얇게 깔아 다른 레이어 판독을 방해하지 않게 한다.
const isContour = (layer: GisLayer): boolean => layer === "등고선" || layer === "도엽_등고선";
const drawOrder = [...GIS_LAYERS].sort((a, b) => (isContour(a) ? -1 : isContour(b) ? 1 : 0));
drawOrder.forEach((layer) => {
const mapRect = computeMapRect(meta, width, height);
const view: ViewState = { width, height, scale, offsetX, offsetY, mapRect };
DRAW_ORDER.forEach((layer) => {
if (!activeGisLayers.has(layer)) return;
context.lineWidth = isContour(layer) ? 0.7 : 1.5;
const prepared = preparedLayers.get(layer);
if (!prepared) return;
context.lineWidth = isContourLayer(layer) ? 0.7 : 1.5;
context.strokeStyle = GIS_LAYER_COLORS[layer];
const marker = layer === "도엽_표고점" ? "x" : "dot";
geoJsonLayers.get(layer)?.features?.forEach((feature) => {
if (feature.geometry) drawGeometry(context, feature.geometry, width, height, marker);
});
drawPreparedLayer(context, prepared, view, layer === "도엽_표고점" ? "x" : "dot");
});
if (showContourLabels) drawContourLabels(context, width, height);
if (showContourLabels) {
context.font = "600 13px sans-serif";
context.textAlign = "center";
context.textBaseline = "middle";
(Object.keys(CONTOUR_LABEL_KEYS) as GisLayer[]).forEach((layer) => {
if (!activeGisLayers.has(layer)) return;
const prepared = preparedLayers.get(layer);
if (prepared) drawPreparedLabels(context, prepared, view, GIS_LAYER_COLORS[layer]);
});
}
// 배수유역 오버레이는 GIS 레이어 위에 얹는다 — 격자·화살표가 등고선을 덮어야 읽힌다.
if (normalizer) watershed.draw(context, normalizer, view);
updateImageTransform();
drawScaleBar(width, height);
drawScaleBar(mapRect);
}
/** 팬/줌 등 연속 이벤트에서는 프레임당 1회만 실제 드로잉이 일어나게 한다. */
function scheduleDraw(): void {
if (frameHandle) return;
frameHandle = window.requestAnimationFrame(() => {
frameHandle = 0;
drawVectorLayer();
});
}
async function loadLayers(): Promise<void> {
@@ -463,42 +399,59 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
const sequence = ++loadSequence;
backgroundImages.forEach((image) => image.removeAttribute("src"));
meta = null;
geoJsonLayers.clear();
preparedLayers.clear();
resetView();
status.textContent = L("B04_Surface_Map_Loading");
showProgress(0, L("B04_Surface_Map_Loading"));
try {
const nextMeta = await fetchVWorldMeta(projectId, "satellite");
// 레이어가 끝나는 대로 진행률을 올린다 — 10종을 다 받을 때까지 화면이 비어 있어서다.
let done = 0;
const loadedLayers = await Promise.all(
GIS_LAYERS.map(async (layer) => {
try {
const data = (await fetchGisGeoJson(projectId, layer)) as GeoJsonCollection;
// 도엽 레이어는 표시용 사본이라 보관함에 담아 두고 새로고침 때 그대로 쓴다.
const data = (
layer.startsWith("도엽_")
? await fetchCachedSheetLayer<GeoJsonCollection>(projectId, layer)
: await fetchGisGeoJson(projectId, layer)
) as GeoJsonCollection;
return [layer, data] as const;
} catch {
return [layer, null] as const;
} finally {
done += 1;
if (sequence === loadSequence) {
showProgress(done / GIS_LAYERS.length, `도엽 레이어 ${done}/${GIS_LAYERS.length}`);
}
}
}),
);
if (sequence !== loadSequence) return;
meta = nextMeta;
// 좌표 변환은 여기서 1회만 수행하고, 이후 프레임은 사전 투영 결과만 사용한다.
normalizer = createNormalizer(nextMeta);
let featureCount = 0;
loadedLayers.forEach(([layer, data]) => {
if (data) geoJsonLayers.set(layer, data);
if (!data) return;
featureCount += data.features?.length ?? 0;
preparedLayers.set(layer, prepareLayer(data, normalizer!, CONTOUR_LABEL_KEYS[layer]));
});
BACKGROUND_LAYERS.forEach((layer) => {
backgroundImages.get(layer)!.src = `${getVWorldMapUrl(projectId, layer)}&_t=${Date.now()}`;
// 주소에 시각을 붙이면 브라우저가 매번 다시 받는다. 서버가 ETag를 주므로 그대로 쓴다.
backgroundImages.get(layer)!.src = getVWorldMapUrl(projectId, layer);
});
const featureCount = [...geoJsonLayers.values()].reduce(
(sum, collection) => sum + (collection.features?.length ?? 0),
0,
);
status.textContent = L("B04_Surface_Map_Features").replace(
"{count}",
featureCount.toLocaleString(),
);
resetView();
syncLayerVisibility();
showProgress(null, null);
} catch (error) {
if (sequence !== loadSequence) return;
status.textContent = error instanceof Error ? error.message : L("B04_Surface_Map_LoadFailed");
showProgress(null, null);
}
}
@@ -507,12 +460,25 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
"wheel",
(event) => {
event.preventDefault();
const prevScale = scale;
scale = Math.min(8, Math.max(0.5, scale * (event.deltaY < 0 ? 1.15 : 0.87)));
drawVectorLayer();
// 마우스 커서 아래 지점이 줌 전후로 같은 화면 위치에 머물도록 offset 보정.
// screen = center + (base - center)·scale + offset 이므로,
// 커서 고정 조건을 풀면 offset' = (cursor - center)·(1 - r) + offset·r (r = scale'/scale).
const ratio = scale / prevScale;
const rect = viewport.getBoundingClientRect();
const cursorX = event.clientX - rect.left - rect.width / 2;
const cursorY = event.clientY - rect.top - rect.height / 2;
offsetX = cursorX * (1 - ratio) + offsetX * ratio;
offsetY = cursorY * (1 - ratio) + offsetY * ratio;
scheduleDraw();
},
{ passive: false },
);
viewport.addEventListener("pointerdown", (event) => {
// 중간 버튼은 브라우저 기본 동작(페이지 자동 스크롤)이 지도 팬과 겹쳐 페이지 전체를
// 흔들므로 기본 동작을 차단하고 지도 팬으로만 사용한다.
if (event.button === 1) event.preventDefault();
dragStart = { x: event.clientX, y: event.clientY, offsetX, offsetY };
viewport.setPointerCapture(event.pointerId);
});
@@ -520,7 +486,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
if (!dragStart) return;
offsetX = dragStart.offsetX + event.clientX - dragStart.x;
offsetY = dragStart.offsetY + event.clientY - dragStart.y;
drawVectorLayer();
scheduleDraw();
});
const stopDragging = (): void => {
dragStart = null;
@@ -528,18 +494,25 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
viewport.addEventListener("pointerup", stopDragging);
viewport.addEventListener("pointercancel", stopDragging);
const resizeObserver = new ResizeObserver(drawVectorLayer);
const resizeObserver = new ResizeObserver(scheduleDraw);
resizeObserver.observe(viewport);
return {
root,
render(projectId, nextReferenceBounds) {
// 초기 화면은 계획노선 기준이다(라이다 범위는 쓰지 않는다).
render(projectId, nextRouteBounds) {
currentProjectId = projectId;
referenceBounds = nextReferenceBounds ?? null;
routeBounds = nextRouteBounds ?? null;
watershed.reset();
watershed.setProject(projectId);
void loadLayers();
},
dispose() {
loadSequence += 1;
if (frameHandle) {
window.cancelAnimationFrame(frameHandle);
frameHandle = 0;
}
resizeObserver.disconnect();
},
};
+27 -3
View File
@@ -9,6 +9,8 @@ import {
} from "@ui/ui_template_elements";
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
import { fetchDashboardMe } from "../B01_Dashboard/B01_Dashboard_Api_Fetch";
import { clearPreloadMark, purgeOtherProjects } from "../A00_Common/b_asset_cache";
import { clearRouteLatestCache } from "../B05_wf2_Route/B05_wf2_Route_Api_Fetch";
import { workflowSteps } from "../A00_Common/b_page_scaffold";
import {
fetchWorkflowState,
@@ -18,6 +20,7 @@ import {
} from "../A00_Common/b_workflow_nav";
import {
confirmSurfaceModel,
fetchConfirmedSurface,
fetchSurfacePointCloud,
fetchSurfaceStatus,
listSurfaceInputFiles,
@@ -85,6 +88,8 @@ function getModelFilter(model: SurfaceModelSummary): string {
export async function renderB04Surface(root: HTMLElement): Promise<void> {
const guardedProjectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
// 새로고침으로 바로 들어온 경우에도 다른 프로젝트 자료는 보관함에서 지운다.
if (guardedProjectId) void purgeOtherProjects(guardedProjectId);
if (guardedProjectId) {
const user = await fetchDashboardMe();
if (user.role !== "SYSTEM_ADMIN") {
@@ -300,6 +305,7 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
const projectId = getProjectId();
if (!projectId) return;
showLoadingOverlay();
viewer.setLoading("포인트 데이터 로딩 중…");
try {
pointCloud = await fetchSurfacePointCloud(projectId, filterGroup.select.value);
terrainViewer.setReferenceBounds(pointCloud.bounds);
@@ -316,23 +322,37 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
}
async function loadProjectData(projectId: string): Promise<void> {
const [inputs, status, modelResponse] = await Promise.all([
const [inputs, status, modelResponse, confirmed] = await Promise.all([
listSurfaceInputFiles(projectId),
fetchSurfaceStatus(projectId),
listSurfaceModels(projectId),
// 확정본 구성(필터·표현·평활·등고선 간격)을 그대로 시작값으로 쓴다. 여기서 바꿔도
// DB에는 저장하지 않는다 — 관리자 확인용이라 사용자 설정을 건드리지 않는다(2026-08-01).
fetchConfirmedSurface(projectId),
]);
models = modelResponse.models;
// 확정본과 같은 조합에서 시작해야 B05와 같은 파일을 보고, 보관함도 한 벌만 쓴다.
// 확정 이력이 없을 때만 개발 기본값(csf·dtm)으로 둔다.
if (confirmed.model_id) {
if (confirmed.source_filter) filterGroup.select.value = confirmed.source_filter;
if (confirmed.method) methodGroup.select.value = confirmed.method;
terrainViewer.setSmoothing(confirmed.smooth ?? false);
}
if (confirmed.contour_interval_m)
terrainViewer.setContourInterval(confirmed.contour_interval_m);
renderInputFiles(inputs.files);
renderStatus(status);
viewer.setLoading("포인트 데이터 로딩 중…");
try {
pointCloud = await fetchSurfacePointCloud(projectId, filterGroup.select.value);
terrainViewer.setReferenceBounds(pointCloud.bounds);
viewer.render(pointCloud);
mapViewer.render(projectId, pointCloud.bounds);
// 지도(2D)는 계획노선 기준으로 연다 — 라이다 범위와 다루는 범위가 다르다.
mapViewer.render(projectId, confirmed.route_bounds);
} catch {
pointCloud = null;
viewer.render(null);
mapViewer.render(projectId);
mapViewer.render(projectId, confirmed.route_bounds);
}
renderInputInfo();
updateSelectedModel();
@@ -351,6 +371,10 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
smooth: terrainViewer.isSmoothingEnabled(),
contour_interval_m: terrainViewer.getContourInterval(),
});
// 확정본이 바뀌었으므로 브라우저가 담아 둔 옛 자료를 더 이상 쓰지 않게 한다.
// 준비 표식을 지우면 아래 goToWorkflowStage가 준비 화면을 거쳐 새 자료를 담는다.
clearPreloadMark();
clearRouteLatestCache(projectId);
showToast(
L("B04_Surface_Confirm_Success")
.replace("{filter}", filterGroup.select.value)
+34 -9
View File
@@ -603,25 +603,50 @@
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);
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;
padding: var(--spacing-4) var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-inputs);
background: var(--color-surface-raised);
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] {
display: none;
}
@media (max-width: 760px) {
@@ -3,8 +3,11 @@ 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 { fetchCachedBytes, fetchCachedJson } from "../A00_Common/b_asset_cache";
import { createProgressCircle } from "@ui/ui_template_progress";
import type { SurfaceBounds, SurfaceModelSummary } from "./B04_wf1_Surface_Api_Fetch";
import {
bindCursorPivotControls,
bindSurfaceViewerTheme,
getTopFitDistance,
niceScaleDistance,
@@ -23,7 +26,11 @@ export interface SurfaceTerrainViewer {
onCameraChange: (listener: (state: SurfaceCameraState) => void) => void;
onAxesVisibilityChange: (listener: (visible: boolean) => void) => void;
isSmoothingEnabled: () => boolean;
/** 스무딩 시작값을 정한다(확정본 저장값). 다시 그리지는 않는다. */
setSmoothing: (enabled: boolean) => void;
getContourInterval: () => number;
/** 등고선 간격 시작값을 정한다(사용자가 B05에서 저장한 값). 다시 그리지는 않는다. */
setContourInterval: (interval: number) => void;
resetOptions: () => void;
dispose: () => void;
}
@@ -167,6 +174,17 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
legendBar.append(maxValSpan, gradientDiv, minValSpan);
viewerArea.append(legendBar);
// 뷰포트 정중앙 로딩 서클 — 메쉬 파일은 수십 MB라 내려받는 동안 화면이 비어 보인다.
const progress = createProgressCircle({ overlay: true });
progress.root.hidden = true;
viewerArea.append(progress.root);
/** 진행률(0~1, 모르면 null)과 문구를 표시한다. label이 null이면 서클을 감춘다. */
function showProgress(ratio: number | null, label: string | null): void {
progress.root.hidden = label === null;
if (label !== null) progress.set(ratio, label);
}
// Three.js context variables
let currentProjectId = "";
let currentModelsList: readonly SurfaceModelSummary[] = [];
@@ -209,6 +227,16 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
let terrainMesh: THREE.Object3D | null = null;
const labelElements: HTMLDivElement[] = [];
// 라벨 목록이 바뀌거나 표시 옵션을 껐다 켰을 때는 카메라가 그대로여도 다시 배치해야 한다.
let labelsDirty = true;
// 회전·줌 중심을 커서 아래 지형 지점으로 (포인트클라우드 뷰어·B05와 공용 유틸).
const releaseCursorPivot = bindCursorPivotControls({
camera,
controls,
element: renderer.domElement,
pickables: () => (terrainMesh ? [terrainMesh] : []),
scene,
});
function disposeObject(obj: THREE.Object3D) {
obj.traverse((child) => {
@@ -239,6 +267,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
}
labelElements.forEach((el) => el.remove());
labelElements.length = 0;
labelsDirty = true;
legendBar.style.display = "none";
}
@@ -303,6 +332,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
currentModelId = null;
scaleBar.hidden = true;
statusSpan.textContent = "모델 조회 중...";
showProgress(null, "모델 조회 중…");
// 1. Find matching model in list
// model_type is TIN / DTM / NURBS / Implicit / Meshfree (we match activeMethod)
@@ -319,6 +349,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
if (!match) {
statusSpan.textContent = "일치하는 완성된 모델을 찾을 수 없습니다.";
showProgress(null, null);
return;
}
@@ -329,39 +360,39 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
const generation = ++loadGeneration;
statusSpan.textContent = "3D 메쉬 파일 다운로드 중...";
showProgress(0, "3D 메쉬 내려받는 중…");
const previewUrl = `${API_BASE_URL}/projects/${currentProjectId}/surface/models/${modelId}/preview?smooth=${isSmooth}`;
try {
// 브라우저 보관함에 있으면 그대로 쓰고, 없을 때만 내려받는다(새로고침이 빨라진다).
const buffer = await fetchCachedBytes(currentProjectId, previewUrl, {
onProgress: (ratio) => {
if (generation !== loadGeneration) return;
showProgress(ratio, "3D 메쉬 내려받는 중…");
},
});
if (generation !== loadGeneration) return;
if (activeMethod === "meshfree") {
new PLYLoader().load(
previewUrl,
async (geometry) => {
if (generation !== loadGeneration) {
geometry.dispose();
return;
}
geometry.computeBoundingSphere();
const material = new THREE.PointsMaterial({
size: 0.35,
vertexColors: geometry.hasAttribute("color"),
sizeAttenuation: true,
});
const points = new THREE.Points(geometry, material);
points.visible = surfCheck.checked;
terrainMesh = points;
scene.add(points);
fitCamera(points);
await loadSelectedContours(modelId, isSmooth);
},
undefined,
() => {
if (generation !== loadGeneration) return;
statusSpan.textContent = "3D 파일 로드에 실패했습니다.";
},
);
const geometry = new PLYLoader().parse(buffer);
geometry.computeBoundingSphere();
const material = new THREE.PointsMaterial({
size: 0.35,
vertexColors: geometry.hasAttribute("color"),
sizeAttenuation: true,
});
const points = new THREE.Points(geometry, material);
points.visible = surfCheck.checked;
terrainMesh = points;
scene.add(points);
fitCamera(points);
showProgress(1, "등고선을 그리는 중…");
await loadSelectedContours(modelId, isSmooth);
showProgress(null, null);
} else {
new GLTFLoader().load(
previewUrl,
new GLTFLoader().parse(
buffer,
"",
async (gltf) => {
if (generation !== loadGeneration) {
disposeObject(gltf.scene);
@@ -377,17 +408,20 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
terrainMesh = gltf.scene;
scene.add(gltf.scene);
fitCamera(gltf.scene);
showProgress(1, "등고선을 그리는 중…");
await loadSelectedContours(modelId, isSmooth);
showProgress(null, null);
},
undefined,
() => {
if (generation !== loadGeneration) return;
statusSpan.textContent = "3D 메쉬 파일이 없거나 로드할 수 없습니다.";
showProgress(null, null);
},
);
}
} catch (e) {
statusSpan.textContent = "에러 발생";
statusSpan.textContent = "3D 파일 로드에 실패했습니다.";
showProgress(null, null);
}
}
@@ -401,9 +435,8 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
const contourUrl = `${API_BASE_URL}/projects/${projectId}/surface/models/${modelId}/contour?interval=${interval}&smooth=${isSmooth}&recalculate=${recalculate}`;
try {
const res = await fetch(contourUrl, { cache: "no-store" });
if (!res.ok) throw new Error("등고선 조회 실패");
const data = await res.json();
// 등고선도 보관함에서 먼저 찾는다 — 같은 파일을 새로고침마다 다시 내려받지 않는다.
const data = await fetchCachedJson<any>(projectId, contourUrl);
if (
currentProjectId !== projectId ||
currentModelId !== modelId ||
@@ -433,6 +466,10 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
let minH = Infinity;
let maxH = -Infinity;
// 등고선 한 가닥마다 3D 객체를 만들면 수백 개가 되어 그리기가 느려진다.
// 주곡선·보조곡선 두 덩어리로 합쳐 객체 2개만 만든다(2026-08-01).
const majorPoints: THREE.Vector3[] = [];
const minorPoints: THREE.Vector3[] = [];
data.contours.forEach((c: any) => {
if (c.level < minH) minH = c.level;
@@ -441,22 +478,11 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
const points = transform(c.coordinates);
if (points.length < 2) return;
const linePoints: THREE.Vector3[] = [];
for (let i = 0; i < points.length - 1; i++) {
linePoints.push(points[i], points[i + 1]);
}
const geometry = new THREE.BufferGeometry().setFromPoints(linePoints);
const isMajor = c.level % (interval * 5) === 0;
const material = new THREE.LineBasicMaterial({
color: isMajor ? 0xd97706 : 0xf59e0b,
linewidth: isMajor ? 2 : 1,
transparent: true,
opacity: 0.8,
});
const segments = new THREE.LineSegments(geometry, material);
contourGroup.add(segments);
const bucket = isMajor ? majorPoints : minorPoints;
for (let i = 0; i < points.length - 1; i++) {
bucket.push(points[i], points[i + 1]);
}
if (isMajor && points.length > 4) {
const labelPos = points[Math.floor(points.length / 2)];
@@ -495,9 +521,25 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
viewerArea.appendChild(labelDiv);
labelElements.push(labelDiv);
labelsDirty = true;
}
});
// 합쳐 둔 점들을 주곡선·보조곡선 각각 한 덩어리로 올린다.
[
{ points: minorPoints, color: 0xf59e0b },
{ points: majorPoints, color: 0xd97706 },
].forEach(({ points, color }) => {
if (points.length === 0) return;
const geometry = new THREE.BufferGeometry().setFromPoints(points);
const material = new THREE.LineBasicMaterial({
color,
transparent: true,
opacity: 0.8,
});
contourGroup.add(new THREE.LineSegments(geometry, material));
});
if (minH !== Infinity && maxH !== -Infinity) {
const nearestMin10 = Math.round(minH / 10) * 10;
const nearestMax10 = Math.round(maxH / 10) * 10;
@@ -538,6 +580,8 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
// Animation render loop
let animationFrameId = 0;
let hasConnected = false;
// 라벨 재계산 여부 판단용 — 직전 프레임의 카메라 자세.
const cameraMatrixSnapshot = new THREE.Matrix4();
function animate() {
if (!root.isConnected) {
if (!hasConnected) {
@@ -547,6 +591,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
clearMesh();
clearContours();
releaseTheme();
releaseCursorPivot();
controls.dispose();
renderer.dispose();
}
@@ -569,12 +614,16 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
scaleBar.hidden = true;
}
// Update labels position
labelElements.forEach((label) => {
if (typeof (label as any).__updateLabelPos === "function") {
(label as any).__updateLabelPos();
}
});
// 등고 라벨 위치 — 화면이 실제로 움직였을 때만 다시 계산한다(매 프레임 재계산은 낭비).
if (labelsDirty || !cameraMatrixSnapshot.equals(camera.matrixWorldInverse)) {
labelsDirty = false;
cameraMatrixSnapshot.copy(camera.matrixWorldInverse);
labelElements.forEach((label) => {
if (typeof (label as any).__updateLabelPos === "function") {
(label as any).__updateLabelPos();
}
});
}
renderer.render(scene, camera);
animationFrameId = requestAnimationFrame(animate);
@@ -602,6 +651,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
labelElements.forEach((el) => {
el.style.display = contourCheck.checked ? "block" : "none";
});
labelsDirty = true;
});
intervalForm.addEventListener("submit", async (e) => {
@@ -655,6 +705,13 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
isSmoothingEnabled() {
return !smoothCheck.disabled && smoothCheck.checked;
},
setSmoothing(enabled) {
smoothPreferred = enabled;
syncSmoothingSupport();
},
setContourInterval(interval) {
if (Number.isFinite(interval) && interval > 0) intervalInput.value = String(interval);
},
getContourInterval() {
return Number.parseFloat(intervalInput.value);
},
@@ -675,6 +732,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
cancelAnimationFrame(animationFrameId);
resizeObserver.disconnect();
releaseTheme();
releaseCursorPivot();
clearMesh();
clearContours();
controls.dispose();
@@ -1,8 +1,10 @@
import { RENDER_OPTIONS } from "@config/config_frontend";
import { createProgressCircle } from "@ui/ui_template_progress";
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,
@@ -19,6 +21,8 @@ export interface SurfacePointCloudViewer {
controlsGroup: HTMLElement;
optionsGroup: HTMLElement;
statusSpan: HTMLElement;
/** 로딩 서클 표시. 문구를 주면 켜고, null이면 끈다. `render()` 시 자동으로 꺼진다. */
setLoading: (label: string | null) => void;
render: (data: SurfacePointCloudSampleResponse | null) => void;
setAxesVisible: (visible: boolean) => void;
applyCameraState: (state: SurfaceCameraState) => void;
@@ -100,6 +104,15 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer {
scaleBar.append(scaleText);
viewerArea.append(canvas, scaleBar, statusSpan);
root.append(viewerArea);
// 뷰포트 정중앙 로딩 서클 — 지도·그래프·다른 3D 뷰어와 같은 공통 컴포넌트.
const progress = createProgressCircle({ overlay: true });
progress.root.hidden = true;
viewerArea.append(progress.root);
function setLoading(label: string | null): void {
progress.root.hidden = label === null;
if (label !== null) progress.set(null, label);
}
const renderer = new THREE.WebGLRenderer({
canvas,
@@ -120,6 +133,14 @@ 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] : []),
scene,
});
let currentData: SurfacePointCloudSampleResponse | null = null;
let animationFrame = 0;
let hasConnected = false;
@@ -272,6 +293,7 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer {
disposed = true;
cancelAnimationFrame(animationFrame);
releaseTheme();
releaseCursorPivot();
clearPoints();
orbit.dispose();
renderer.dispose();
@@ -297,7 +319,9 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer {
controlsGroup,
optionsGroup,
statusSpan,
setLoading,
render(data) {
setLoading(null);
currentData = data;
clearPoints();
if (!data) {
@@ -0,0 +1,522 @@
import { fetchWatershedAnalysis, type WatershedAnalysis } from "./B04_wf1_Surface_Api_Fetch";
import { drawFlowArrows } from "./B04_wf1_Surface_UI_FlowArrows";
import { drawUpstreamLines, type Normalizer, type ViewState } from "./B04_wf1_Surface_UI_MapRender";
/* =============================================================================
* (B04 )
*
* 2D .
* . 30 .
*
*
* · 1 ,
* · / + /
* · ( ) · ( ) · 1 ( )
* · 2 ( ) ·
* ========================================================================== */
// 해석 격자 셀 선 — 등고선·세류 위에 얹으므로 흰색으로 둔다.
const GRID_LINE_COLOR = "rgba(255, 255, 255, 0.55)";
// 흐름 판정 색 — 도로로 물이 오는 셀은 적색, 오지 않는 셀은 파랑 채움 + 백색 화살표.
const FLOW_TO_ROAD_FILL = "rgba(220, 38, 38, 0.28)";
const FLOW_TO_ROAD_LINE = "rgba(153, 27, 27, 0.95)";
const FLOW_AWAY_FILL = "rgba(37, 99, 235, 0.22)";
const FLOW_AWAY_LINE = "rgba(255, 255, 255, 0.95)";
/** 등고선 TIN 밖이라 표고가 없어 판정하지 못한 셀 — 미도달(파랑)과 구분한다. */
const FLOW_UNKNOWN_FILL = "rgba(120, 113, 108, 0.18)";
/** 화살표가 이보다 작으면 뭉개져 읽히지 않으므로 채움색만 남긴다(px). */
const ARROW_MIN_PX = 7;
/** (px). 1m 1~2px .
* . */
const ARROW_SPACING_PX = 22;
/** 2차 전체 배수유역 외곽선 = 분수령. */
const BASIN_RING_COLOR = "rgba(146, 64, 14, 0.95)";
/** 기본 관 마커. */
const PIPE_COLOR = "rgba(249, 115, 22, 0.95)";
/** 개별로 켜고 끌 수 있는 오버레이 갈래. */
const PARTS = [
{ key: "primary", label: "1차 유역", color: "#059669" },
{ key: "basin", label: "2차 유역", color: "#92400e" },
{ key: "flow", label: "유역 방향", color: "#2563eb" },
{ key: "arrows", label: "평균 흐름", color: "#7c3aed" },
] as const;
type PartKey = (typeof PARTS)[number]["key"];
export interface WatershedOverlay {
/** 분석 실행 + 전체 토글 버튼. 지도 헤더의 GIS 버튼 줄에 넣는다. */
button: HTMLButtonElement;
/** 갈래별 표시 토글 버튼(1차 유역 / 2차 유역 / 유역 방향 / 평균 흐름). */
partButtons: HTMLButtonElement[];
/** 배수유역 전용 상태 줄. 지도 자체 상태(레이어 로딩 등)와 섞이면 서로 덮어쓴다. */
statusElement: HTMLElement;
/** 켜져 있는지. draw() 호출 전에 확인한다. */
visible: () => boolean;
/** 상태 문구(분석 요약 또는 오류). 없으면 빈 문자열. */
status: () => string;
/** 프로젝트가 바뀌면 받아 둔 분석 결과를 버린다. */
reset: () => void;
/** 현재 프로젝트를 알려 준다. 지정 전에는 버튼이 아무 일도 하지 않는다. */
setProject: (projectId: string) => void;
draw: (context: CanvasRenderingContext2D, map: Normalizer, view: ViewState) => void;
}
export function createWatershedOverlay(onChange: () => void): WatershedOverlay {
let analysis: WatershedAnalysis | null = null;
let shown = false;
let statusText = "";
let flowCache: { source: string; bytes: Uint8Array } | null = null;
let busy = false;
const statusElement = document.createElement("span");
statusElement.className = "b04-map__watershed-status";
statusElement.hidden = true;
/** 상태 줄을 갱신한다. 빈 문자열이면 줄 자체를 숨긴다. */
function say(text: string): void {
statusText = text;
statusElement.textContent = text;
statusElement.hidden = text === "";
}
const button = document.createElement("button");
button.type = "button";
button.className = "b04-map__layer-button b04-map__layer-button--gis";
button.textContent = "유역 분석";
button.style.setProperty("--b04-layer-color", "#dc2626");
button.setAttribute("aria-pressed", "false");
button.title =
"계획 노선(B03 업로드)과 도엽 등고선·세류선으로 배수유역을 처음부터 다시 분석합니다. " +
"30초 안팎이 걸리며 결과는 영구저장소에 남습니다. " +
"저장된 결과는 지도를 열 때 자동으로 표시되므로, 조건을 바꿨을 때만 누르면 됩니다.";
// 갈래별 표시 여부. 전체 토글(button)이 꺼져 있으면 이 값과 무관하게 아무것도 안 그린다.
// 1차 유역·유역 방향은 격자가 지도를 덮어 판독을 방해하므로 기본 꺼짐(2026-08-01 사용자 지시).
const shownParts: Record<PartKey, boolean> = {
primary: false,
basin: true,
flow: false,
arrows: true,
};
const partButtons = PARTS.map((part) => {
const element = document.createElement("button");
element.type = "button";
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", String(initialActive));
element.addEventListener("click", () => {
shownParts[part.key] = !shownParts[part.key];
element.classList.toggle("is-active", shownParts[part.key]);
element.setAttribute("aria-pressed", String(shownParts[part.key]));
onChange();
});
return element;
});
let projectId: string | null = null;
function strokeLonLat(
context: CanvasRenderingContext2D,
line: ReadonlyArray<readonly [number, number]>,
map: Normalizer,
view: ViewState,
): void {
if (line.length < 2) return;
const ax = view.mapRect.width * view.scale;
const bx = view.mapRect.x * view.scale + (view.width / 2) * (1 - view.scale) + view.offsetX;
const ay = view.mapRect.height * view.scale;
const by = view.mapRect.y * view.scale + (view.height / 2) * (1 - view.scale) + view.offsetY;
context.beginPath();
line.forEach(([lon, lat], index) => {
const x = ((lon - map.lonMin) / map.lonRange) * ax + bx;
const y = (1 - (lat - map.latMin) / map.latRange) * ay + by;
if (index === 0) context.moveTo(x, y);
else context.lineTo(x, y);
});
context.stroke();
}
/** 흐름 방향 바이트를 셀 순서대로 디코드한다(캐시 — 매 프레임 다시 풀지 않는다). */
function flowBytes(region: WatershedAnalysis): Uint8Array | null {
if (!region.flow) return null;
if (flowCache?.source === region.flow.data) return flowCache.bytes;
const binary = atob(region.flow.data);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
flowCache = { source: region.flow.data, bytes };
return bytes;
}
/** 1 .
*
* bbox (row_spans) .
* , ·
* . . */
function drawGridCells(
context: CanvasRenderingContext2D,
map: Normalizer,
view: ViewState,
region: WatershedAnalysis,
): void {
const ring = region.grid.bbox_lonlat;
if (ring.length < 4) return;
const lons = ring.map(([lon]) => lon);
const lats = ring.map(([, lat]) => lat);
const ax = view.mapRect.width * view.scale;
const bx = view.mapRect.x * view.scale + (view.width / 2) * (1 - view.scale) + view.offsetX;
const ay = view.mapRect.height * view.scale;
const by = view.mapRect.y * view.scale + (view.height / 2) * (1 - view.scale) + view.offsetY;
const left = ((Math.min(...lons) - map.lonMin) / map.lonRange) * ax + bx;
const right = ((Math.max(...lons) - map.lonMin) / map.lonRange) * ax + bx;
const top = (1 - (Math.max(...lats) - map.latMin) / map.latRange) * ay + by;
const bottom = (1 - (Math.min(...lats) - map.latMin) / map.latRange) * ay + by;
const { rows, cols, row_spans: spans } = region.grid;
const cellW = (right - left) / Math.max(cols, 1);
const cellH = (bottom - top) / Math.max(rows, 1);
const cellPx = Math.min(Math.abs(cellW), Math.abs(cellH));
const bytes = flowBytes(region);
// 1m 격자를 도엽 전체 배율로 보면 셀이 1~2px라 셀마다 화살표를 그리면 아무것도 안 보인다.
// 화면에서 대략 ARROW_SPACING_PX 간격이 되도록 셀을 건너뛰며 표본만 그린다.
const stride = Math.max(1, Math.ceil(ARROW_SPACING_PX / Math.max(cellPx, 0.01)));
const arrowPx = cellPx * stride;
context.save();
context.setLineDash([]);
context.lineCap = "round";
let cursor = 0; // row_spans를 훑은 순서 = 흐름 바이트 순서
spans.forEach(([row, colStart, colEnd]) => {
const count = colEnd - colStart + 1;
const base = cursor;
cursor += count;
const y = top + cellH * row;
if (y + cellH < -40 || y > view.height + 40) return;
const x = left + cellW * colStart;
const width = cellW * count;
if (x + width < -40 || x > view.width + 40) return;
if (!bytes) {
// 흐름 판정 전 — 격자만 흰 선으로 보여 준다.
if (cellPx >= 2) {
context.strokeStyle = GRID_LINE_COLOR;
context.lineWidth = 0.5;
context.beginPath();
for (let col = colStart; col <= colEnd; col += 1) {
context.rect(left + cellW * col, y, cellW, cellH);
}
context.stroke();
} else {
context.fillStyle = "rgba(255, 255, 255, 0.2)";
context.fillRect(x, y, width, cellH);
}
return;
}
const sink = region.flow?.sink_code ?? 32;
const invalid = region.flow?.invalid_code ?? 33;
const steps = region.flow?.azimuth_steps ?? 32;
for (let offset = 0; offset < count; offset += 1) {
const col = colStart + offset;
// 화살표는 표본만 그린다 — 격자가 촘촘하면 셀마다 그려 봐야 뭉개져서 안 보인다.
const sampled = row % stride === 0 && col % stride === 0;
drawFlowCell(
context,
bytes[base + offset],
left + cellW * col,
y,
cellW,
cellH,
cellPx,
sampled ? arrowPx : 0,
{ sink, invalid, steps },
);
}
});
context.restore();
}
/** 셀 하나 — 도달 여부로 칠하고, 충분히 크면 32방위 흐름 화살표를 얹는다. */
function drawFlowCell(
context: CanvasRenderingContext2D,
code: number,
x: number,
y: number,
cellW: number,
cellH: number,
cellPx: number,
arrowPx: number,
codes: { sink: number; invalid: number; steps: number },
): void {
const azimuth = code & 0x3f;
const reaches = (code & 0x80) !== 0;
// 표고가 없어 판정 못한 셀 — 미도달(파랑 채움)과 구분해야 오독이 없다.
const unanalyzed = azimuth === codes.invalid;
context.fillStyle = unanalyzed
? FLOW_UNKNOWN_FILL
: reaches
? FLOW_TO_ROAD_FILL
: FLOW_AWAY_FILL;
context.fillRect(x, y, cellW, cellH);
if (cellPx >= 2) {
context.strokeStyle = GRID_LINE_COLOR;
context.lineWidth = 0.5;
context.strokeRect(x, y, cellW, cellH);
}
// arrowPx = 0 이면 표본에서 빠진 셀이라 채움만 하고 끝낸다.
if (arrowPx < ARROW_MIN_PX || unanalyzed) return;
const stroke = reaches ? FLOW_TO_ROAD_LINE : FLOW_AWAY_LINE;
const midX = x + cellW / 2;
const midY = y + cellH / 2;
if (azimuth === codes.sink) {
// 제자리(싱크) — 방향이 없으므로 점으로 표시한다.
context.fillStyle = stroke;
context.beginPath();
context.arc(midX, midY, Math.max(1, arrowPx * 0.12), 0, Math.PI * 2);
context.fill();
return;
}
// 코드 0 = 화면 오른쪽(+x), 시계방향(캔버스 y는 아래가 +).
const angle = (azimuth * 2 * Math.PI) / codes.steps;
const unitX = Math.cos(angle);
const unitY = Math.sin(angle);
const reach = arrowPx * 0.38;
const tipX = midX + unitX * reach;
const tipY = midY + unitY * reach;
// 표본 화살표는 채움색 위에서도 읽혀야 하므로 흰 테두리를 한 겹 깔고 그 위에 그린다.
const width = Math.max(1, arrowPx * 0.08);
const head = arrowPx * 0.18;
const stem: [number, number][] = [
[midX - unitX * reach, midY - unitY * reach],
[tipX, tipY],
];
for (const [color, lineWidth] of [
["rgba(255, 255, 255, 0.85)", width + 1.6] as const,
[stroke, width] as const,
]) {
context.strokeStyle = color;
context.lineWidth = lineWidth;
context.beginPath();
context.moveTo(stem[0][0], stem[0][1]);
context.lineTo(stem[1][0], stem[1][1]);
// 촉 — 진행 방향 기준 좌우로 짧게 접는다.
context.moveTo(tipX, tipY);
context.lineTo(tipX - (unitX + unitY * 0.7) * head, tipY - (unitY - unitX * 0.7) * head);
context.moveTo(tipX, tipY);
context.lineTo(tipX - (unitX - unitY * 0.7) * head, tipY - (unitY + unitX * 0.7) * head);
context.stroke();
}
}
/** 1차 배수유역 근거를 겹쳐 그린다 — 단계 검증용. */
function drawPrimaryRegion(
context: CanvasRenderingContext2D,
map: Normalizer,
view: ViewState,
region: WatershedAnalysis,
): void {
context.save();
// 1차 배수유역 = 상류 세류망의 반경 버퍼 합집합. (격자는 draw()에서 먼저 깔았다)
context.setLineDash([]);
context.lineWidth = 2;
context.strokeStyle = "rgba(5, 150, 105, 0.95)";
context.fillStyle = "rgba(16, 185, 129, 0.12)";
region.region_rings.forEach((ring) => {
strokeLonLat(context, ring, map, view);
context.fill();
});
// ③ 도로 아래로 이어진 하류망 — 판정이 맞는지 대조하도록 회색 파선으로 남긴다.
context.setLineDash([6, 5]);
context.lineWidth = 2;
context.strokeStyle = "rgba(120, 113, 108, 0.85)";
region.downstream_lines.forEach((line) => strokeLonLat(context, line, map, view));
context.restore();
// ④ 채택된 상류망 = 1차 영역의 기준선. 가장 굵게, 맨 위에.
// 그리기는 B05 배수유역도와 같은 공용 렌더러에 맡긴다.
drawUpstreamLines(context, region.upstream_lines, map, view);
}
/** B05에도 같이 쓰는 평균 흐름 화살표. 그리기는 공용 렌더러에 맡긴다. */
function drawMeanArrows(
context: CanvasRenderingContext2D,
map: Normalizer,
view: ViewState,
region: WatershedAnalysis,
): void {
// 격자 bbox의 경도 폭과 실폭(m)으로 1m당 픽셀을 환산한다.
const lons = region.grid.bbox_lonlat.map(([lon]) => lon);
const spanLon = Math.max(...lons) - Math.min(...lons);
if (!(spanLon > 0) || !(region.grid.width_m > 0)) return;
const ax = view.mapRect.width * view.scale;
const pxPerMeter = ((spanLon / map.lonRange) * ax) / region.grid.width_m;
const bx = view.mapRect.x * view.scale + (view.width / 2) * (1 - view.scale) + view.offsetX;
const ay = view.mapRect.height * view.scale;
const by = view.mapRect.y * view.scale + (view.height / 2) * (1 - view.scale) + view.offsetY;
drawFlowArrows(
context,
region.flow_arrows ?? [],
region.arrow_spacing_m ?? 0,
pxPerMeter,
(lon, lat) => [
((lon - map.lonMin) / map.lonRange) * ax + bx,
(1 - (lat - map.latMin) / map.latRange) * ay + by,
],
view,
);
}
/** ⑦ 2차 전체 배수유역 외곽선(= 분수령)과 ⑧ 기본 관 위치. */
function drawBasinAndPipes(
context: CanvasRenderingContext2D,
map: Normalizer,
view: ViewState,
region: WatershedAnalysis,
): void {
context.save();
if (region.basin_polygon_lonlat.length > 2) {
context.setLineDash([8, 5]);
context.lineWidth = 2.5;
context.strokeStyle = BASIN_RING_COLOR;
strokeLonLat(context, region.basin_polygon_lonlat, map, view);
}
context.setLineDash([]);
const ax = view.mapRect.width * view.scale;
const bx = view.mapRect.x * view.scale + (view.width / 2) * (1 - view.scale) + view.offsetX;
const ay = view.mapRect.height * view.scale;
const by = view.mapRect.y * view.scale + (view.height / 2) * (1 - view.scale) + view.offsetY;
region.pipes.forEach((pipe, index) => {
const x = ((pipe.lon - map.lonMin) / map.lonRange) * ax + bx;
const y = (1 - (pipe.lat - map.latMin) / map.latRange) * ay + by;
context.beginPath();
context.arc(x, y, 7, 0, Math.PI * 2);
context.fillStyle = PIPE_COLOR;
context.fill();
context.lineWidth = 1.5;
context.strokeStyle = "#111827";
context.stroke();
context.fillStyle = "#111827";
context.font = "bold 10px sans-serif";
context.textAlign = "center";
context.textBaseline = "middle";
context.fillText(String(index + 1), x, y);
});
context.restore();
}
function regionSummary(region: WatershedAnalysis): string {
const cells = region.grid.cells.toLocaleString();
const outside =
region.road_outside_m > 0 ? ` · 노선 이탈 ${Math.round(region.road_outside_m)}m` : "";
const unknown =
region.flow && region.flow.unanalyzed > 0
? ` / 표고없음 ${region.flow.unanalyzed.toLocaleString()}(회)`
: "";
const burned =
region.flow && region.flow.burned > 0
? ` · 세류망 새김 ${region.flow.burned.toLocaleString()}`
: "";
const flow = region.flow
? ` · 흐름 도로도달 ${region.flow.reaches_road.toLocaleString()}(적) / ` +
`미도달 ${region.flow.no_road.toLocaleString()}(청)${unknown}, ` +
`최외곽 출발 ${region.flow.outer_seeds.toLocaleString()} + ` +
`내부 보충 ${region.flow.interior_seeds.toLocaleString()}${burned}`
: " · 흐름 판정 없음";
const expansion = region.expansion
? ` · 확장 ${region.expansion.rounds}` +
`(${region.expansion.initial_cells.toLocaleString()}${cells}셀, ` +
`${region.expansion.closed ? "닫힘" : "상한 도달"})`
: "";
const basin = region.basin_area_m2
? ` · 2차 유역 ${formatArea(region.basin_area_m2)}, 기본 관 ${region.pipes.length}`
: "";
return (
`1차 영역(반경 ${region.radius_m}m): 상류망 ${region.upstream_lines.length}조각 채택 / ` +
`하류망 ${region.downstream_lines.length}조각·미연결 ${region.no_contact_count}개 제외 · ` +
`격자 ${region.grid.cell_m}m(도로 시점 기준) 셀 ${cells}${outside}${expansion}${basin}${flow}`
);
}
function formatArea(areaM2: number): string {
return areaM2 >= 10000 ? `${(areaM2 / 10000).toFixed(2)}ha` : `${Math.round(areaM2)}`;
}
/** .
* `refresh=false`
* . `refresh=true`( ) . */
async function loadAnalysis(refresh: boolean): Promise<void> {
if (!projectId || busy) return;
busy = true;
button.disabled = true;
button.textContent = refresh ? "분석 중…" : "불러오는 중…";
say(
refresh
? "배수유역을 처음부터 다시 분석하는 중입니다. 30초 안팎 걸립니다…"
: "저장된 배수유역 분석을 불러오는 중…",
);
const started = performance.now();
try {
analysis = await fetchWatershedAnalysis(projectId, refresh);
shown = true;
button.classList.add("is-active");
button.setAttribute("aria-pressed", "true");
const seconds = ((performance.now() - started) / 1000).toFixed(1);
const origin = analysis.from_cache ? "저장분" : `재산정 ${seconds}`;
say(`[${origin}] ${regionSummary(analysis)}`);
} catch (error) {
analysis = null;
shown = false;
button.classList.remove("is-active");
button.setAttribute("aria-pressed", "false");
const message = error instanceof Error ? error.message : "배수유역을 불러오지 못했습니다.";
// 저장분이 아직 없는 것은 오류가 아니다 — 무엇을 눌러야 하는지 알려 준다.
say(
refresh
? `유역 분석 실패: ${message}`
: "저장된 배수유역 분석이 없습니다. [유역 분석]을 누르세요.",
);
} finally {
busy = false;
button.disabled = false;
button.textContent = "유역 분석";
onChange();
}
}
// 재산정 버튼은 언제나 처음부터 다시 분석한다.
// (저장분을 자동으로 띄우게 바꾼 뒤로 이 버튼이 표시 토글로 먼저 걸려, 눌러도 아무 일이
// 없는 것처럼 보였다 — 2026-08-01. 보이기/숨기기는 갈래별 버튼이 맡는다.)
button.addEventListener("click", () => {
void loadAnalysis(true);
});
return {
button,
partButtons,
statusElement,
visible: () => shown && analysis !== null,
status: () => statusText,
reset() {
analysis = null;
flowCache = null;
shown = false;
say("");
button.classList.remove("is-active");
button.setAttribute("aria-pressed", "false");
},
setProject(next: string) {
projectId = next;
// 저장분이 있으면 즉시 올린다 — 없으면 조용히 넘어가고, 재산정 버튼을 누르면 계산한다.
void loadAnalysis(false);
},
draw(context, map, view) {
if (!shown || !analysis) return;
// 격자·화살표(유역 방향) → 1차 영역 → 2차 유역·관 순으로 아래에서 위로 쌓는다.
if (shownParts.flow) drawGridCells(context, map, view, analysis);
if (shownParts.primary) drawPrimaryRegion(context, map, view, analysis);
if (shownParts.arrows) drawMeanArrows(context, map, view, analysis);
if (shownParts.basin) drawBasinAndPipes(context, map, view, analysis);
},
};
}
+114 -4
View File
@@ -11,7 +11,7 @@
* - {status:"error", message:"..."} Error로 .
* ========================================================================== */
import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend";
import { API_ANALYSIS_TIMEOUT_MS, API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend";
/** 경로 제어점 (BP/EP/CP) */
export interface RoutePoint {
@@ -141,10 +141,17 @@ export interface RouteLatestResponse {
} | null;
}
/** 공통 fetch 헬퍼: 타임아웃 + 인증 헤더 + 오류 응답 변환. */
async function requestJson<T>(path: string, init: RequestInit): Promise<T> {
/** fetch : + + .
*
* `timeoutMs` .
* `API_ANALYSIS_TIMEOUT_MS` abort . */
async function requestJson<T>(
path: string,
init: RequestInit,
timeoutMs: number = API_TIMEOUT_MS,
): Promise<T> {
const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS);
const timeoutId = window.setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(`${API_BASE_URL}${path}`, {
...init,
@@ -160,6 +167,12 @@ async function requestJson<T>(path: string, init: RequestInit): Promise<T> {
throw new Error(payload.message ?? `HTTP ${response.status}`);
}
return payload;
} catch (error) {
// AbortError 원문("signal is aborted without reason")은 원인을 알 수 없으니 바꿔 준다.
if (error instanceof DOMException && error.name === "AbortError") {
throw new Error(`요청이 ${Math.round(timeoutMs / 1000)}초 안에 끝나지 않았습니다.`);
}
throw error;
} finally {
window.clearTimeout(timeoutId);
}
@@ -247,3 +260,100 @@ export async function fetchLatestRoute(projectId: string): Promise<RouteLatestRe
method: "GET",
});
}
/** B05가 최신 경로·확정 설정값을 탭 세션에 담아 둘 때 쓰는 키(유일한 정의처). */
export const routeLatestCacheKey = (projectId: string): string => `b05:latest:${projectId}`;
/** . B04
* B05가 , . */
export function clearRouteLatestCache(projectId: string): void {
try {
window.sessionStorage.removeItem(routeLatestCacheKey(projectId));
} catch {
/* 세션 접근 실패 시에는 다음 진입에서 DB를 읽게 되므로 그대로 둔다. */
}
}
/* ── 배수유역도 (B05_wf2_Route_Router_Drainage.py) ───────────────────────── */
/** 관 매설 구조물 측점 후보 1개. reason: stream=세류 교차, spacing=300m 보충. */
export interface DrainageCandidate {
chainage_m: number;
x: number;
y: number;
lon: number;
lat: number;
reason: "stream" | "spacing" | "confirmed";
stream_name: string | null;
}
export interface DrainageCandidateResponse {
status: string;
project_id: string;
route_id: number;
candidates: DrainageCandidate[];
}
/** 관 1개가 받는 세부 배수유역. 관경(pipe_diameter_mm)은 수식 미확정이라 당분간 항상 null이다. */
export interface DrainageBasin {
index: number;
chainage_m: number;
/** 관(배관) 매설 지점 좌표 — 계획선 위 마커 렌더용. */
outlet_lonlat: [number, number];
polygon_lonlat: Array<[number, number]>;
area_m2: number;
relief_m: number;
flow_length_m: number;
pipe_diameter_mm: number | null;
}
export interface DrainageBasinResponse {
status: string;
project_id: string;
route_id: number;
/** 산정에 실제 사용된 배관 지점 — 유역이 없는 관도 포함(마커 동기화용). */
pipes: DrainageCandidate[];
/** B04가 분석에 쓴 계획 노선 선형(lon/lat). */
route_lonlat: Array<[number, number]>;
/** 2차 전체 배수유역 외곽선 = 분수령. 편집 핸들 간격으로 다시 찍고 저장된 편집분이 반영된 값. */
main_polygon_lonlat: Array<[number, number]>;
/** 외곽선 편집 핸들 간격(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), 방위(도), 도로도달, 셀 수]. */
flow_arrows: Array<[number, number, number, boolean, number]>;
/** 화살표 사이 실제 간격(m). */
arrow_spacing_m: number;
basins: DrainageBasin[];
}
/** chainages를 주면 그 위치로 확정 산정하고, 비우면 자동 제안분으로 산정한다. */
export async function fetchDrainageBasins(
projectId: string,
chainages?: number[],
): Promise<DrainageBasinResponse> {
// 격자 해석이 포함된 요청이라 캐시가 없으면 수십 초가 걸린다.
return requestJson<DrainageBasinResponse>(
`/projects/${projectId}/drainage/basins`,
{ method: "POST", body: JSON.stringify({ chainages: chainages ?? [] }) },
API_ANALYSIS_TIMEOUT_MS,
);
}
/** 사용자가 옮긴 유역 외곽선 포인트만 저장한다(종단 경로 확정 시 모달 승인 후 호출). */
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 }) },
);
}
@@ -0,0 +1,27 @@
"""배수 관경 산정.
유역을 나누는 최종 목적은 지점의 파이프 관경 결정이다. 유역 경사면에 100 강우빈도를
적용해 모이는 물의 양을 산정하고 유량으로 관경을 정한다.
노선 기하(정점·누가거리·세류 교차점) `common_util_route_geometry` 옮겼다 B04 분석과
B05 세부 설계가 같은 표현을 써야 하기 때문이다(2026-07-31 구조 개편).
"""
from __future__ import annotations
def estimate_pipe_diameter_mm(
area_m2: float,
relief_m: float,
flow_length_m: float,
rainfall_mm_per_hour: float | None = None,
) -> float | None:
"""유역 제원으로 배수 파이프 관경(mm)을 산정한다.
100 강우빈도와 유역 경사면을 곱해 유출량을 구하고, 유량으로 관경을 정하는 것이
목적이다. **수식은 아직 확정되지 않았다** 사용자가 로직을 제공하면 여기를 채운다.
그때까지는 None을 돌려 호출부가 "미정"으로 표기하게 한다.
"""
# TODO(사용자 로직 대기): 100년 강우강도 × 유역면적 × 유출계수 → 유량 Q → 관경 D 산정.
_ = (area_m2, relief_m, flow_length_m, rainfall_mm_per_hour)
return None
@@ -0,0 +1,502 @@
"""배수유역 세부 설계 (B05 — 일반 사용자용).
**분석은 하지 않는다.** B04가 미리 돌려 저장한 결과를 읽어, 사용자가 실제로 손대는 가지만
처리한다(2026-07-31 사용자 지시).
간격이 최대치를 넘는 구간에 **최소 개수** 관을 보충
측구 흐름으로 도로 담당 관을 정하고, 셀이 도달한 도로 셀의 담당 관을 그대로
셀의 유역 번호로 삼아 세부유역을 나눈다
사용자가 관을 옮기거나 추가하면 다시 돈다 격자 해석은 재사용한다
읽어 오는 (`B04_wf1_Surface/drainage/`):
· `03_road_routing.geojson` 계획도로선 · 기본 배관 · 2 전체 배수유역
· `03_road_routing.npz` 도로 귀속, 유하장, 강도, 도로 제원, 표고
화살표(방향 코드) 밴드 표고 같은 관리자 확인용 배열은 읽지 않는다 여기서는 필요 없고
파일만 무거워진다.
"""
from __future__ import annotations
import json
import logging
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import numpy as np
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,
interpolate_vertex,
is_uphill_at,
)
from config.config_system import (
DRAINAGE_DITCH_SAMPLE_M,
DRAINAGE_PIPE_MAX_SPACING_M,
DRAINAGE_PIPE_MIN_SPACING_M,
)
logger = logging.getLogger(__name__)
# 관 위치 선정 점수 배분 — 흐름 강도가 주, 종단 저점이 보조.
_SCORE_WEIGHT_STRENGTH = 0.7
_SCORE_WEIGHT_SAG = 0.3
# 성토부(내리막)는 물이 노선 밖으로 빠지므로 관 위치로 덜 선호한다.
_SCORE_FILL_PENALTY = 0.5
@dataclass
class DrainageDetail:
"""B05 산출물 — 화면에 그릴 기하와 세부유역."""
route_lonlat: list[list[float]] = field(default_factory=list)
basin_lonlat: list[list[float]] = field(default_factory=list)
pipes: list[StructureCandidate] = field(default_factory=list)
basins: list[WatershedBasin] = field(default_factory=list)
grid_cell_m: float = 1.0
# 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(
stored_path: str,
vertices: list[RouteVertex],
confirmed_chainages: list[float] | None = None,
) -> DrainageDetail | None:
"""B04 분석 결과를 읽어 관을 보충하고 세부유역을 나눈다.
`confirmed_chainages` 주면 위치를 관으로 확정하고(사용자 편집), 비우면 B04의
기본 관에 최대 간격 규칙으로 최소 개수만 보충한다. 어느 쪽이든 격자 해석은 하지 않는다.
"""
routing = load_road_routing(stored_path)
if routing is None or len(vertices) < 2:
return None
if confirmed_chainages:
pipes = _pipes_from_chainages(vertices, confirmed_chainages)
else:
# 저장분의 기본 관은 누가거리만 신뢰한다 — 좌표는 현재 노선 위로 다시 찍는다.
base = [
StructureCandidate(
chainage_m=pipe.chainage_m,
x=interpolate_vertex(vertices, pipe.chainage_m)[0],
y=interpolate_vertex(vertices, pipe.chainage_m)[1],
reason=pipe.reason,
)
for pipe in routing.base_pipes
]
pipes = place_pipes(vertices, base, routing.strength_curve)
detail = DrainageDetail(
route_lonlat=routing.route_lonlat,
basin_lonlat=routing.basin_lonlat,
pipes=pipes,
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
pipe_of_slot = assign_road_cells_to_pipes(vertices, pipes, routing.road_chainage)
detail.basins = assemble_basins(routing, pipes, pipe_of_slot)
logger.info(
"배수유역: 세부 설계 — 관 %d개(기본 %d + 보충 %d), 세부유역 %d",
len(pipes),
sum(1 for pipe in pipes if pipe.reason != "spacing"),
sum(1 for pipe in pipes if pipe.reason == "spacing"),
len(detail.basins),
)
return detail
@dataclass
class WatershedBasin:
"""관 하나가 받는 세부 배수유역."""
index: int
chainage_m: float
outlet_x: float
outlet_y: float
boundary_xy: list[tuple[float, float]] = field(default_factory=list)
area_m2: float = 0.0
relief_m: float = 0.0
flow_length_m: float = 0.0
pipe_diameter_mm: float | None = None
@dataclass
class RoadRouting:
"""B04가 남긴 배수유역 분석 결과 — B05가 세부유역을 나누는 데 필요한 최소 묶음."""
spec: GridSpec
# (R*C,) int32 — 셀이 물길을 따라 도달하는 도로 셀 슬롯(−1 = 미도달).
road_slot: np.ndarray
path_length: np.ndarray # (R*C,) float32 — 그 도로 셀까지 물길 길이(m)
elevation: np.ndarray # (R*C,) float32 — 셀 표고(유역 낙차 계산용)
road_cell_index: np.ndarray # (K,) int32 — 도로 셀의 평탄 인덱스
road_chainage: np.ndarray # (K,) float64 — 도로 셀의 누가거리(m)
strength: np.ndarray # (K,) int64 — 도로 셀별 상류 셀 수
# 화면에 그대로 그릴 기하(WGS84 lon/lat).
route_lonlat: list[list[float]] = field(default_factory=list)
basin_lonlat: list[list[float]] = field(default_factory=list)
base_pipes: list[StructureCandidate] = field(default_factory=list)
# 평균 흐름 화살표 — [x, y, 방위(도), 도로도달, 셀 수]. B04가 계산해 둔 그대로.
flow_arrows: list[list[Any]] = field(default_factory=list)
arrow_spacing_m: float = 0.0
@property
def strength_curve(self) -> np.ndarray:
"""누가거리 1m 구간별 유입 면적(㎡) 곡선 — 관 보충 위치 점수의 근거."""
if self.road_chainage.size == 0:
return np.zeros(1)
bins = max(1, int(np.ceil(self.road_chainage.max())) + 1)
index = np.clip(np.round(self.road_chainage).astype(np.int64), 0, bins - 1)
weights = self.strength.astype(np.float64) * self.spec.cell_area_m2
return np.bincount(index, weights=weights, minlength=bins)
def load_road_routing(stored_path: str) -> RoadRouting | None:
"""`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():
logger.warning("배수유역: B04 분석 결과가 없습니다 (%s).", array_path)
return None
try:
with np.load(array_path, allow_pickle=False) as data:
spec = GridSpec(
x_min=float(data["x_min"]),
y_max=float(data["y_max"]),
cell_m=float(data["cell_m"]),
n_rows=int(data["n_rows"]),
n_cols=int(data["n_cols"]),
)
routing = RoadRouting(
spec=spec,
road_slot=data["road_slot"].reshape(-1),
path_length=data["path_length"].reshape(-1),
elevation=data["elevation"].reshape(-1),
road_cell_index=data["road_cell_index"],
road_chainage=data["road_chainage"],
strength=data["strength"],
)
except (OSError, KeyError, ValueError):
logger.warning("배수유역: B04 분석 결과를 읽지 못했습니다 (%s).", array_path)
return None
_read_geometry(directory / f"{prefix}_road_routing.geojson", routing)
logger.info(
"배수유역: B04 결과 로드 — 격자 %d×%d, 도로 셀 %d, 기본 관 %d",
spec.n_rows,
spec.n_cols,
routing.road_cell_index.size,
len(routing.base_pipes),
)
return routing
def _read_geometry(path: Path, routing: RoadRouting) -> None:
"""계획도로선·2차 유역 외곽선·기본 관을 GeoJSON에서 읽어 채운다."""
if not path.exists():
logger.warning("배수유역: B04 기하 산출물이 없습니다 (%s).", path)
return
try:
with path.open("r", encoding="utf-8") as file:
document = json.load(file)
except (OSError, json.JSONDecodeError):
logger.warning("배수유역: B04 기하 산출물을 읽지 못했습니다 (%s).", path)
return
routing.arrow_spacing_m = float(
(document.get("properties") or {}).get("arrow_spacing_m") or 0.0
)
for feature in document.get("features", []):
properties = feature.get("properties") or {}
geometry = feature.get("geometry") or {}
coordinates = geometry.get("coordinates")
kind = properties.get("kind")
if kind == "route" and geometry.get("type") == "LineString":
routing.route_lonlat = coordinates
elif kind == "basin_boundary" and geometry.get("type") == "Polygon" and coordinates:
routing.basin_lonlat = coordinates[0]
elif kind == "flow_arrow" and geometry.get("type") == "Point":
# 화면이 미터로 그리므로 속성의 x·y를 쓴다(기하는 저장 규약상 lon/lat).
routing.flow_arrows.append(
[
float(properties.get("x") or 0.0),
float(properties.get("y") or 0.0),
float(properties.get("azimuth_deg") or 0.0),
bool(properties.get("reaches_road")),
int(properties.get("cells") or 0),
]
)
elif kind == "pipe" and geometry.get("type") == "Point":
routing.base_pipes.append(
StructureCandidate(
chainage_m=float(properties.get("chainage_m") or 0.0),
x=0.0,
y=0.0,
reason=str(properties.get("reason") or "stream"),
)
)
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
# ── ⑨ 관 최소 개수 보충 ─────────────────────────────────────────────────────
def place_pipes(
vertices: list[RouteVertex],
base_pipes: list[StructureCandidate],
strength_curve: np.ndarray,
) -> list[StructureCandidate]:
"""B04가 정한 기본 관(세류 교차점)에, 최대 간격을 넘는 구간만 최소 개수로 보충한다.
기본 관은 여기서 다시 찾지 않는다 B04 산출물에 이미 들어 있다.
"""
total_length = vertices[-1].chainage_m
base: list[StructureCandidate] = []
for candidate in sorted(base_pipes, key=lambda item: item.chainage_m):
if base and candidate.chainage_m - base[-1].chainage_m < DRAINAGE_PIPE_MIN_SPACING_M:
continue
base.append(candidate)
filled: list[StructureCandidate] = []
previous = 0.0
for candidate in [*base, None]:
boundary = candidate.chainage_m if candidate else total_length
filled.extend(_fill_gap(vertices, strength_curve, previous, boundary))
if candidate:
filled.append(candidate)
previous = candidate.chainage_m
else:
previous = boundary
filled.sort(key=lambda item: item.chainage_m)
return filled
def _fill_gap(
vertices: list[RouteVertex],
strength_curve: np.ndarray,
start_m: float,
end_m: float,
) -> list[StructureCandidate]:
"""[start, end] 구간에 최대 간격을 지키는 **최소 개수**의 관을 배치한다.
필요 개수 n은 구간 길이로 정해지고(ceil(L/max) 1), 관은 등분 위치를 중심으로
허용 여유(slack) 안에서만 움직인다. 그래서 개수는 늘지 않으면서도 흐름 강도가 크고
종단이 낮은 지점으로 붙는다.
"""
span = end_m - start_m
if span <= DRAINAGE_PIPE_MAX_SPACING_M:
return []
count = int(np.ceil(span / DRAINAGE_PIPE_MAX_SPACING_M)) - 1
if count <= 0:
return []
spacing = span / (count + 1)
slack = max(0.0, (DRAINAGE_PIPE_MAX_SPACING_M - spacing) / 2.0)
placed: list[StructureCandidate] = []
for order in range(1, count + 1):
nominal = start_m + spacing * order
low = max(start_m + DRAINAGE_PIPE_MIN_SPACING_M, nominal - slack)
high = min(end_m - DRAINAGE_PIPE_MIN_SPACING_M, nominal + slack)
chosen = _best_position(vertices, strength_curve, low, high, nominal)
x, y, _ = interpolate_vertex(vertices, chosen)
placed.append(StructureCandidate(chainage_m=chosen, x=x, y=y, reason="spacing"))
return placed
def _best_position(
vertices: list[RouteVertex],
strength_curve: np.ndarray,
low_m: float,
high_m: float,
fallback_m: float,
) -> float:
"""허용 구간 안에서 흐름 강도가 크고 종단이 낮은 위치를 고른다."""
if high_m <= low_m:
return fallback_m
positions = np.arange(low_m, high_m + 1.0, 1.0)
if positions.size == 0:
return fallback_m
index = np.clip(np.round(positions).astype(np.int64), 0, strength_curve.size - 1)
strength = strength_curve[index]
heights = np.array([interpolate_vertex(vertices, float(p))[2] for p in positions])
strength_score = strength / strength.max() if strength.max() > 0 else np.zeros_like(strength)
height_span = float(heights.max() - heights.min())
sag_score = (
(heights.max() - heights) / height_span if height_span > 1e-6 else np.zeros_like(heights)
)
score = _SCORE_WEIGHT_STRENGTH * strength_score + _SCORE_WEIGHT_SAG * sag_score
for order, position in enumerate(positions):
if not is_uphill_at(vertices, float(position)):
score[order] *= _SCORE_FILL_PENALTY
return float(positions[int(np.argmax(score))])
def _pipes_from_chainages(
vertices: list[RouteVertex], chainages: list[float]
) -> list[StructureCandidate]:
"""사용자가 확정·편집한 누가거리 목록을 관 후보로 되돌린다.
노선 값은 ·종점으로 당긴다. 그대로 두면 마커는 끝점에 찍히는데 라벨만 50m처럼
나와 좌표와 표기가 어긋난다.
"""
total_length = vertices[-1].chainage_m
clamped = {min(max(round(float(item), 2), 0.0), total_length) for item in chainages}
pipes: list[StructureCandidate] = []
for value in sorted(clamped):
x, y, _ = interpolate_vertex(vertices, value)
pipes.append(StructureCandidate(chainage_m=value, x=x, y=y, reason="confirmed"))
return pipes
# ── ⑩ 측구 흐름으로 도로 셀 → 담당 관 ───────────────────────────────────────
def assign_road_cells_to_pipes(
vertices: list[RouteVertex],
pipes: list[StructureCandidate],
road_chainage: np.ndarray,
) -> np.ndarray:
"""도로 셀마다 물이 실제로 흘러가는 담당 관 번호를 정한다.
노면 물은 측구를 타고 종단 내리막으로 흐르므로, 종단 계획선을 1차원 지형으로 보고
같은 방식(내리막 추적 + 관에서 흡수)으로 푼다. 관이 없는 사그(저점) 갇힌 구간은
가장 가까운 관이 받는 것으로 본다.
"""
total_length = vertices[-1].chainage_m
step = max(DRAINAGE_DITCH_SAMPLE_M, 0.5)
stations = np.arange(0.0, total_length + step, step)
heights = np.array([interpolate_vertex(vertices, float(s))[2] for s in stations])
pipe_chainages = np.array([pipe.chainage_m for pipe in pipes])
pipe_station = np.clip(np.round(pipe_chainages / step).astype(np.int64), 0, stations.size - 1)
# 앞뒤 이웃 중 더 낮은 쪽으로 흘려보낸다(양쪽 다 높으면 사그 = 제자리).
back_z = np.full(stations.size, np.inf)
back_z[1:] = heights[:-1]
forward_z = np.full(stations.size, np.inf)
forward_z[:-1] = heights[1:]
go_back = (back_z < heights) & (back_z <= forward_z)
go_forward = (forward_z < heights) & ~go_back
receiver = np.arange(stations.size, dtype=np.int64)
receiver[go_back] -= 1
receiver[go_forward] += 1
receiver[pipe_station] = pipe_station # 관은 물을 흡수한다
owner = np.full(stations.size, -1, dtype=np.int64)
owner[pipe_station] = np.arange(pipe_chainages.size)
jump = receiver
for _ in range(40):
next_jump = jump[jump]
if np.array_equal(next_jump, jump):
break
jump = next_jump
resolved = owner[jump]
# 관 없는 사그에 갇힌 구간은 가장 가까운 관에 붙인다.
orphan = resolved < 0
if orphan.any() and pipe_chainages.size:
nearest = np.abs(stations[orphan, None] - pipe_chainages[None, :]).argmin(axis=1)
resolved[orphan] = nearest
slot_station = np.clip(np.round(road_chainage / step).astype(np.int64), 0, stations.size - 1)
return resolved[slot_station].astype(np.int32)
# ── ⑩ 세부유역 조립 ────────────────────────────────────────────────────────
def assemble_basins(
solution: RoadRouting,
pipes: list[StructureCandidate],
pipe_of_slot: np.ndarray,
) -> list[WatershedBasin]:
"""셀이 도달한 도로 셀의 담당 관을 그대로 유역 번호로 삼아 세부유역을 만든다."""
spec = solution.spec
labels = np.full(spec.size, -1, dtype=np.int32)
reached = solution.road_slot >= 0
labels[reached] = pipe_of_slot[solution.road_slot[reached]]
polygons = polygonize_labels(spec, labels)
cell_area = spec.cell_area_m2
basins: list[WatershedBasin] = []
for order, pipe in enumerate(pipes):
member = labels == order
count = int(member.sum())
if count == 0:
continue
geometry = polygons.get(order)
elevations = solution.elevation[member]
highest = float(np.nanmax(elevations)) if np.isfinite(elevations).any() else 0.0
outlet_z = _outlet_elevation(solution, order, pipe_of_slot)
area = count * cell_area
relief = max(0.0, highest - outlet_z)
flow_length = float(solution.path_length[member].max())
basins.append(
WatershedBasin(
index=len(basins) + 1,
chainage_m=pipe.chainage_m,
outlet_x=pipe.x,
outlet_y=pipe.y,
boundary_xy=largest_ring(geometry) if geometry is not None else [],
area_m2=area,
relief_m=relief,
flow_length_m=flow_length,
pipe_diameter_mm=estimate_pipe_diameter_mm(area, relief, flow_length),
)
)
return basins
def _outlet_elevation(solution: RoadRouting, pipe_order: int, pipe_of_slot: np.ndarray) -> float:
"""관이 담당하는 도로 셀들의 최저 표고 = 유역 출구 표고."""
slots = np.flatnonzero(pipe_of_slot == pipe_order)
if slots.size == 0:
return 0.0
elevations = solution.elevation[solution.road_cell_index[slots]]
finite = elevations[np.isfinite(elevations)]
return float(finite.min()) if finite.size else 0.0
@@ -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
@@ -0,0 +1,193 @@
"""배수유역 세부 설계 API 라우터 (B05 — 일반 사용자용).
**분석하지 않는다.** B04가 미리 돌려 저장한 결과를 읽어 관을 보충하고 세부유역만 나눈다.
격자 해석은 30초가 걸려 일반 사용자를 붙잡아 두므로 여기서는 아예 돌리지 않는다
(2026-07-31 사용자 지시).
좌표는 사업지 CRS(m)에서 계산하고 응답만 WGS84로 바꿔 내보낸다.
"""
import asyncio
import logging
from typing import Any
from uuid import UUID
from fastapi import APIRouter
from fastapi.responses import JSONResponse
from 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,
get_surface_crs_epsg,
)
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"])
def _candidate_payload(candidate: StructureCandidate, to_lonlat: Any) -> dict[str, Any]:
lon, lat = to_lonlat(candidate.x, candidate.y)
return {
"chainage_m": round(candidate.chainage_m, 2),
"x": candidate.x,
"y": candidate.y,
"lon": lon,
"lat": lat,
"reason": candidate.reason,
"stream_name": candidate.stream_name,
}
async def _prepare(project_id: UUID) -> 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)
route = await get_latest_route(connection, project_id)
if not route:
return JSONResponse(
status_code=404,
content={"status": "error", "message": "확정된 경로가 없습니다."},
)
points = await get_route_points(connection, int(route["id"]))
surface_model_id = route.get("surface_model_id")
epsg = await get_surface_crs_epsg(
connection, project_id, int(surface_model_id) if surface_model_id else 0
)
vertices = build_route_vertices(points)
if len(vertices) < 2:
return JSONResponse(
status_code=400,
content={"status": "error", "message": "노선 좌표가 부족합니다."},
)
transformer = Transformer.from_crs(f"EPSG:{epsg or 5186}", "EPSG:4326", always_xy=True)
return {
"route_id": int(route["id"]),
"vertices": vertices,
"stored_path": stored_path,
"to_lonlat": lambda x, y: transformer.transform(x, y),
}
@router.post("/{project_id}/drainage/basins", response_model=None)
async def post_drainage_basins(
project_id: UUID,
payload: dict[str, Any] | None = None,
) -> dict[str, Any] | JSONResponse:
"""B04 분석 결과로 관을 보충하고 세부유역을 나눈다.
payload에 `chainages`(누가거리 목록) 주면 위치로 관을 확정하고(사용자 편집),
없으면 B04의 기본 관에 최대 간격 규칙으로 최소 개수만 보충한다.
"""
prepared = await _prepare(project_id)
if isinstance(prepared, JSONResponse):
return prepared
raw = (payload or {}).get("chainages")
confirmed = _parse_chainages(raw) if isinstance(raw, list) else []
detail = await asyncio.to_thread(
build_drainage_detail, prepared["stored_path"], prepared["vertices"], confirmed
)
if detail is None:
return JSONResponse(
status_code=404,
content={
"status": "error",
"message": "배수유역 분석 결과가 없습니다. B04에서 먼저 분석을 실행하세요.",
},
)
to_lonlat = prepared["to_lonlat"]
# 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가 남긴 그대로 — 계획도로선. 외곽선만 편집분을 반영해 내보낸다.
"route_lonlat": detail.route_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": [
{
"index": basin.index,
"chainage_m": round(basin.chainage_m, 2),
"outlet_lonlat": list(to_lonlat(basin.outlet_x, basin.outlet_y)),
"polygon_lonlat": [list(to_lonlat(x, y)) for x, y in basin.boundary_xy],
"area_m2": round(basin.area_m2, 1),
"relief_m": round(basin.relief_m, 2),
"flow_length_m": round(basin.flow_length_m, 1),
# 관경 수식 미확정 — None이면 프론트가 "미정"으로 표기한다.
"pipe_diameter_mm": basin.pipe_diameter_mm,
}
for basin in detail.basins
],
}
@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] = []
for value in values:
try:
parsed.append(float(value))
except (TypeError, ValueError):
continue
return parsed
@@ -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;
},
};
}
@@ -0,0 +1,681 @@
import { createWorkflowPanelHandle } from "@ui/ui_template_overlay";
import { DRAINAGE_SHEET_LAYERS, fetchCachedSheetLayer } from "../A00_Common/b_asset_cache";
import {
fetchVWorldMeta,
getVWorldMapUrl,
type VWorldMeta,
} from "../B04_wf1_Surface/B04_wf1_Surface_Api_Fetch";
import {
computeMapRect,
computeRouteView,
createNormalizer,
drawFilledRing,
drawPreparedLayer,
drawRidgeRing,
drawUpstreamLines,
prepareLayer,
prepareMetricPolyline,
type GeoJsonCollection,
type MapRect,
type Normalizer,
type PreparedLayer,
type ViewState,
} from "../B04_wf1_Surface/B04_wf1_Surface_UI_MapRender";
import {
fetchDrainageBasins,
type DrainageBasin,
type RoutePoint,
} from "./B05_wf2_Route_Api_Fetch";
import { drawFlowArrows, type FlowArrow } from "../B04_wf1_Surface/B04_wf1_Surface_UI_FlowArrows";
import {
createBoundaryEditor,
type BoundaryOverrideEntry,
} from "./B05_wf2_Route_UI_Drainage_Boundary";
import { createPipeEditor } from "./B05_wf2_Route_UI_Drainage_Pipes";
import { createProgressCircle } from "@ui/ui_template_progress";
// 배수유역도 패널 — 하단 종단 패널 안쪽 우측에 도킹되는 2단 사이드 패널.
// 하단 패널이 닫히면 이 패널도 함께 화면에서 사라진다(부모 안에 들어 있으므로 자동).
// 지도는 B04에서 분리한 렌더 엔진(B04_wf1_Surface_UI_MapRender)을 그대로 재사용해
// 사전 투영·LOD·뷰포트 컬링·커서 중심 줌 동작을 동일하게 얻는다.
/** . 3D ( ).
* (2026-07-31). */
const DRAINAGE_LAYERS = DRAINAGE_SHEET_LAYERS;
type DrainageLayer = (typeof DRAINAGE_LAYERS)[number];
const LAYER_COLORS: Record<DrainageLayer, string> = {
_등고선: "#a5b4fc",
_하천중심선: "#2563eb",
};
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";
/** 유역 오버레이 파스텔 색상. 번호 순으로 돌려쓴다(사용자 지시: 파스텔톤). */
const BASIN_COLORS = [
"rgba(167, 216, 199, 0.45)",
"rgba(247, 208, 168, 0.45)",
"rgba(186, 199, 240, 0.45)",
"rgba(241, 183, 199, 0.45)",
"rgba(214, 226, 168, 0.45)",
"rgba(202, 186, 227, 0.45)",
"rgba(168, 214, 232, 0.45)",
"rgba(240, 219, 168, 0.45)",
] as const;
export interface DrainagePanel {
root: HTMLElement;
/** 프로젝트가 정해지면 배경지도·도엽 레이어를 불러온다. */
load: (projectId: string) => void;
/** 확정된 노선 평면 선형(사업지 좌표계 m)을 지도 위에 겹친다. */
setRoute: (points: ReadonlyArray<RoutePoint>) => void;
/** 이번 화면에서 사용자가 유역선 포인트를 옮겼는지(경로 확정 시 저장 여부를 묻는 근거). */
hasBoundaryEdits: () => boolean;
/** 저장 대상 — 원래 자리와 옮긴 자리 짝. 폴리곤 전체가 아니다. */
boundaryOverrides: () => BoundaryOverrideEntry[];
/** 저장이 끝났음을 알린다(다시 묻지 않도록). */
markBoundarySaved: () => void;
dispose: () => void;
}
export function createDrainagePanel(): DrainagePanel {
const root = document.createElement("aside");
root.className = "b05-drainage";
const panelHandle = createWorkflowPanelHandle("side");
const header = document.createElement("div");
header.className = "b05-drainage__header";
const title = document.createElement("h3");
title.textContent = "배수유역도";
const layerButtons = document.createElement("div");
layerButtons.className = "b05-drainage__layers";
header.append(title, layerButtons);
// 세부유역 산정 — B04가 미리 분석해 둔 결과를 읽어 관을 보충하고 세부유역만 나눈다.
// 격자 해석은 하지 않으므로 즉시 끝난다.
const analyzeButton = document.createElement("button");
analyzeButton.type = "button";
analyzeButton.className = "b05-drainage__analyze";
analyzeButton.textContent = "세부유역 산정";
analyzeButton.title =
"B04에서 분석해 둔 배수유역을 불러와 관을 보충하고 세부유역을 나눕니다. " +
"분석 결과가 없으면 B04에서 먼저 실행해야 합니다.";
// 배관 편집 토글 — 켜면 계획선 클릭으로 배관 추가, 마커 드래그로 이동.
const editButton = document.createElement("button");
editButton.type = "button";
editButton.className = "b05-drainage__analyze b05-drainage__tool";
editButton.textContent = "배관 편집";
editButton.setAttribute("aria-pressed", "false");
// 선택된 배관 삭제 — 편집 모드에서 마커를 선택해야 활성화된다.
const deleteButton = document.createElement("button");
deleteButton.type = "button";
deleteButton.className = "b05-drainage__analyze b05-drainage__tool";
deleteButton.textContent = "선택 삭제";
deleteButton.disabled = true;
// 자동 제안으로 되돌리기 — 편집한 배관 배치를 버리고 백엔드 자동 제안으로 재산정.
const autoButton = document.createElement("button");
autoButton.type = "button";
autoButton.className = "b05-drainage__analyze b05-drainage__tool";
autoButton.textContent = "자동 제안";
// 유역선 편집 토글 — 켜면 외곽선 위 핸들을 잡아 유역 경계를 손으로 고친다.
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";
const backgroundImage = document.createElement("img");
backgroundImage.className = "b05-drainage__image";
backgroundImage.alt = "배경 위성지도";
backgroundImage.draggable = false;
const canvas = document.createElement("canvas");
canvas.className = "b05-drainage__canvas";
const status = document.createElement("span");
status.className = "b05-drainage__status";
status.textContent = "노선을 확정하면 배수유역도가 표시됩니다.";
// 지도 정중앙 로딩 서클 — 배경도·도엽 레이어·유역 산정이 끝날 때까지 화면이 비어 보인다.
const progress = createProgressCircle({ overlay: true });
progress.root.hidden = true;
viewport.append(backgroundImage, canvas, status, progress.root);
/** 진행률(0~1, 모르면 null)과 문구. label이 null이면 서클을 감춘다. */
function showProgress(ratio: number | null, label: string | null): void {
progress.root.hidden = label === null;
if (label !== null) progress.set(ratio, label);
}
// 유역 제원 목록(면적·표고·유하거리·관경). 관경 수식 미확정이라 당분간 "미정"으로 나온다.
const basinList = document.createElement("div");
basinList.className = "b05-drainage__basins";
basinList.hidden = true;
root.append(panelHandle.root, header, viewport, basinList);
let projectId: string | null = null;
let meta: VWorldMeta | null = null;
const preparedLayers = new Map<DrainageLayer, PreparedLayer>();
const activeLayers = new Set<DrainageLayer>(DRAINAGE_LAYERS);
let routeLayer: PreparedLayer | null = null;
let routePoints: ReadonlyArray<RoutePoint> = [];
let normalizer: Normalizer | null = null;
let basins: DrainageBasin[] = [];
let selectedBasin: number | null = null;
let editMode = false;
// 배관 편집기 — 마커 선택/추가/이동/삭제 시 재그리기와 버튼 상태만 갱신한다.
const pipeEditor = createPipeEditor(() => {
syncPipeSelection();
scheduleDraw();
});
// 2차 전체 배수유역 외곽선 = 분수령. 별도 "능선" 레이어를 두지 않고 이 선 하나로 표시한다
// (전체 유역 외곽선과 능선이 같은 선이므로 — 2026-07-31 사용자 지시).
let mainBoundary: Array<[number, number]> = [];
// 평균 흐름 화살표 — B04가 계산해 둔 것을 그대로 받아 그린다(여기서 계산하지 않는다).
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;
let dragStart: { x: number; y: number; offsetX: number; offsetY: number } | null = null;
let frameHandle = 0;
let loadSequence = 0;
let canvasWidth = 0;
let canvasHeight = 0;
let canvasDpr = 0;
/** 제목 우측 표시 토글 — 등고선·세류·흐름 화살표·상류 세류가 모두 같은 양식을 쓴다. */
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" + (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", () => {
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})`;
}
function draw(): void {
const rect = viewport.getBoundingClientRect();
const width = Math.max(1, Math.floor(rect.width));
const height = Math.max(1, Math.floor(rect.height));
const dpr = window.devicePixelRatio || 1;
if (width !== canvasWidth || height !== canvasHeight || dpr !== canvasDpr) {
canvasWidth = width;
canvasHeight = height;
canvasDpr = dpr;
canvas.width = Math.floor(width * dpr);
canvas.height = Math.floor(height * dpr);
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
}
const context = canvas.getContext("2d");
if (!context) return;
context.setTransform(dpr, 0, 0, dpr, 0, 0);
context.clearRect(0, 0, width, height);
const mapRect: MapRect = computeMapRect(meta, width, height);
const view: ViewState = { width, height, scale, offsetX, offsetY, mapRect };
// 세부유역 채움을 가장 아래에 깔아 등고선·세류 판독을 가리지 않게 한다.
if (normalizer) {
basins.forEach((basin) => {
const color = BASIN_COLORS[(basin.index - 1) % BASIN_COLORS.length];
drawFilledRing(
context,
{ ring: basin.polygon_lonlat, label: String(basin.index) },
normalizer!,
view,
selectedBasin === null || selectedBasin === basin.index
? color
: color.replace(/0\.45\)$/, "0.18)"),
);
});
// 전체 유역 외곽선 = 분수령(능선). 세부유역 경계와 구분되게 파선 한 겹만 얹는다.
// 편집 중이면 사용자가 끌어 옮긴 외곽선을 그대로 보여 준다.
const boundary = boundaryEditor.points();
if (boundary.length > 2) drawRidgeRing(context, boundary, normalizer, view);
}
// 등고선을 얇게 깔고 세류를 그 위에, 노선을 맨 위에 둔다.
DRAINAGE_LAYERS.forEach((layer) => {
if (!activeLayers.has(layer)) return;
const prepared = preparedLayers.get(layer);
if (!prepared) return;
context.lineWidth = layer === "도엽_등고선" ? 0.7 : 1.5;
context.strokeStyle = 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;
drawPreparedLayer(context, routeLayer, view, "dot");
}
// 평균 흐름 화살표 — 유역 채움 위, 배관 마커 아래. 좌표는 사업지 CRS(m)라
// 도엽 메타로 바로 화면에 옮긴다(배관 마커와 같은 변환).
if (showArrows && meta && flowArrows.length > 0) {
const spanX = meta.width_meters || 1;
const spanY = meta.height_meters || 1;
const pxPerMeter = (view.mapRect.width * view.scale) / spanX;
const ax = view.mapRect.width * view.scale;
const bx = view.mapRect.x * view.scale + (width / 2) * (1 - view.scale) + view.offsetX;
const ay = view.mapRect.height * view.scale;
const by = view.mapRect.y * view.scale + (height / 2) * (1 - view.scale) + view.offsetY;
const originX = meta.x_min;
const originY = meta.y_min;
drawFlowArrows(
context,
flowArrows,
arrowSpacingM,
pxPerMeter,
(x, y) => [((x - originX) / spanX) * ax + bx, (1 - (y - originY) / spanY) * ay + by],
view,
);
}
// 유역선 편집 핸들 — 편집 모드에서만. 화살표 위, 배관 마커 아래.
if (normalizer) boundaryEditor.draw(context, normalizer, view);
// 배관(관 매설) 마커 — 계획선 위 최상단.
pipeEditor.draw(context, view, pipeColor);
updateImageTransform();
}
/** 현재 프레임 뷰 상태 (draw()와 동일 계산 — 배관 마커 포인터 히트 판정용). */
function currentView(): ViewState {
const rect = viewport.getBoundingClientRect();
const width = Math.max(1, Math.floor(rect.width));
const height = Math.max(1, Math.floor(rect.height));
return { width, height, scale, offsetX, offsetY, mapRect: computeMapRect(meta, width, height) };
}
/** 배관 마커 색 — 같은 누가거리 유역의 파스텔색(불투명). 유역이 없으면 회색. */
function pipeColor(chainage: number): string {
const basin = basins.find((item) => Math.abs(item.chainage_m - chainage) < 0.51);
if (!basin) return "#e5e7eb";
return BASIN_COLORS[(basin.index - 1) % BASIN_COLORS.length].replace(/0\.45\)$/, "1)");
}
/** 마커 선택 ↔ 유역 목록 선택 동기화 + 삭제 버튼 활성화. */
function syncPipeSelection(): void {
const index = pipeEditor.selected();
deleteButton.disabled = !editMode || index === null;
const pipe = index === null ? null : pipeEditor.pipes()[index];
const basin = pipe
? basins.find((item) => Math.abs(item.chainage_m - pipe.chainage_m) < 0.51)
: null;
selectedBasin = basin ? basin.index : null;
renderBasinList();
}
function scheduleDraw(): void {
if (frameHandle) return;
frameHandle = window.requestAnimationFrame(() => {
frameHandle = 0;
draw();
});
}
/** 유역 제원 목록을 다시 그린다. 항목을 누르면 해당 유역만 진하게 강조한다. */
function renderBasinList(): void {
basinList.textContent = "";
basinList.hidden = basins.length === 0;
basins.forEach((basin) => {
const row = document.createElement("button");
row.type = "button";
row.className = "b05-drainage__basin" + (selectedBasin === basin.index ? " is-selected" : "");
const badge = document.createElement("span");
badge.className = "b05-drainage__basin-index";
badge.textContent = String(basin.index);
badge.style.background = BASIN_COLORS[(basin.index - 1) % BASIN_COLORS.length];
const metrics = document.createElement("span");
metrics.className = "b05-drainage__basin-metrics";
// 관경은 수식 미확정이라 백엔드가 null을 주며, 확정 전까지 "미정"으로 표기한다.
const pipe =
basin.pipe_diameter_mm === null ? "미정" : `Ø${Math.round(basin.pipe_diameter_mm)}mm`;
metrics.textContent =
`면적 ${formatArea(basin.area_m2)} · 표고 ${basin.relief_m.toFixed(1)}m · ` +
`유하 ${Math.round(basin.flow_length_m)}m · 관경 ${pipe}`;
row.title = `측점 누가거리 ${basin.chainage_m.toFixed(1)}m`;
row.append(badge, metrics);
row.addEventListener("click", () => {
selectedBasin = selectedBasin === basin.index ? null : basin.index;
renderBasinList();
scheduleDraw();
});
basinList.append(row);
});
}
function formatArea(areaM2: number): string {
return areaM2 >= 10000 ? `${(areaM2 / 10000).toFixed(2)}ha` : `${Math.round(areaM2)}`;
}
/** + ( ).
* , auto=true면 . */
async function analyze(auto = false): Promise<void> {
if (!projectId) return;
analyzeButton.disabled = true;
status.hidden = false;
status.textContent = "세부유역을 산정하는 중…";
showProgress(null, "세부유역을 산정하는 중…");
try {
const chainages = !auto && pipeEditor.pipes().length > 0 ? pipeEditor.chainages() : undefined;
const response = await fetchDrainageBasins(projectId, chainages);
basins = response.basins;
mainBoundary = response.main_polygon_lonlat ?? [];
// 외곽선은 편집 핸들 간격으로 다시 찍힌 값이며, 저장된 편집분은 이미 반영돼 있다.
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 산출물을 그대로 받는다 — 여기서 다시 계산하지 않는다.
// 실제 사용된 배관 목록으로 마커를 동기화한다(유역 없는 관 포함).
pipeEditor.setPipes(
(response.pipes ?? []).map((pipe) => ({
chainage_m: pipe.chainage_m,
reason: pipe.reason,
})),
);
selectedBasin = null;
renderBasinList();
syncPipeSelection();
status.hidden = basins.length > 0;
if (basins.length === 0) status.textContent = "산정된 배수유역이 없습니다.";
scheduleDraw();
} catch (error) {
status.hidden = false;
status.textContent = error instanceof Error ? error.message : "세부유역 산정에 실패했습니다.";
} finally {
analyzeButton.disabled = false;
showProgress(null, null);
}
}
analyzeButton.addEventListener("click", () => void analyze());
editButton.addEventListener("click", () => {
editMode = !editMode;
editButton.classList.toggle("is-active", editMode);
editButton.setAttribute("aria-pressed", String(editMode));
syncPipeSelection();
});
deleteButton.addEventListener("click", () => void pipeEditor.deleteSelected());
autoButton.addEventListener("click", () => {
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);
});
/** · ( = ).
*
* B05는
* (2026-08-01 ). .
* . */
function fitToRoute(): void {
scale = 1;
offsetX = 0;
offsetY = 0;
if (!meta || routePoints.length < 2) return;
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
routePoints.forEach((point) => {
if (point.x < minX) minX = point.x;
if (point.x > maxX) maxX = point.x;
if (point.y < minY) minY = point.y;
if (point.y > maxY) maxY = point.y;
});
const rect = viewport.getBoundingClientRect();
const view = computeRouteView(
meta,
{ x_min: minX, x_max: maxX, y_min: minY, y_max: maxY },
Math.max(rect.width, 1),
Math.max(rect.height, 1),
);
scale = view.scale;
offsetX = view.offsetX;
offsetY = view.offsetY;
}
async function loadLayers(): Promise<void> {
if (!projectId) return;
const activeProjectId = projectId;
const sequence = ++loadSequence;
meta = null;
preparedLayers.clear();
backgroundImage.removeAttribute("src");
status.hidden = false;
status.textContent = "배경도를 불러오는 중…";
showProgress(0, "배경도를 불러오는 중…");
try {
const nextMeta = await fetchVWorldMeta(activeProjectId, "satellite");
showProgress(1 / 3, "도엽 레이어를 불러오는 중…");
const loaded = await Promise.all(
DRAINAGE_LAYERS.map(async (layer) => {
try {
// 도엽 레이어는 표시용 사본이라 보관함에 담아 두고 새로고침 때 그대로 쓴다.
const data = await fetchCachedSheetLayer<GeoJsonCollection>(activeProjectId, layer);
return [layer, data] as const;
} catch {
return [layer, null] as const;
}
}),
);
if (sequence !== loadSequence) return;
meta = nextMeta;
normalizer = createNormalizer(nextMeta);
let featureCount = 0;
loaded.forEach(([layer, data]) => {
if (!data) return;
featureCount += data.features?.length ?? 0;
preparedLayers.set(layer, prepareLayer(data, normalizer!));
});
// 주소에 시각을 붙이면 브라우저가 매번 다시 받는다. 서버가 ETag를 주므로 그대로 쓴다.
backgroundImage.src = getVWorldMapUrl(activeProjectId, "satellite");
if (routePoints.length > 1) routeLayer = prepareMetricPolyline(routePoints, nextMeta);
pipeEditor.setContext(nextMeta, routePoints);
status.hidden = featureCount > 0;
if (featureCount === 0) status.textContent = "도엽 레이어가 없습니다. B04에서 임포트하세요.";
fitToRoute();
scheduleDraw();
showProgress(2 / 3, "세부유역을 산정하는 중…");
// B04 분석 결과를 읽어 오는 것뿐이라 즉시 끝난다 — 페이지에 들어오면 바로 보여 준다.
void analyze(true);
} catch (error) {
if (sequence !== loadSequence) return;
status.hidden = false;
status.textContent = error instanceof Error ? error.message : "배경도를 불러오지 못했습니다.";
showProgress(null, null);
}
}
viewport.addEventListener(
"wheel",
(event) => {
event.preventDefault();
const prevScale = scale;
scale = Math.min(16, Math.max(0.5, scale * (event.deltaY < 0 ? 1.15 : 0.87)));
// 커서 아래 지점을 고정한 채 확대/축소 (B04 지도와 동일 동작).
const ratio = scale / prevScale;
const rect = viewport.getBoundingClientRect();
const cursorX = event.clientX - rect.left - rect.width / 2;
const cursorY = event.clientY - rect.top - rect.height / 2;
offsetX = cursorX * (1 - ratio) + offsetX * ratio;
offsetY = cursorY * (1 - ratio) + offsetY * ratio;
scheduleDraw();
},
{ passive: false },
);
viewport.addEventListener("pointerdown", (event) => {
// 중간 버튼 기본 동작(페이지 자동 스크롤)이 지도 팬과 겹치지 않게 막는다.
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(
currentView(),
event.clientX - rect.left,
event.clientY - rect.top,
editMode,
)
) {
viewport.setPointerCapture(event.pointerId);
return;
}
dragStart = { x: event.clientX, y: event.clientY, offsetX, offsetY };
viewport.setPointerCapture(event.pointerId);
});
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;
if (!dragStart) return;
offsetX = dragStart.offsetX + event.clientX - dragStart.x;
offsetY = dragStart.offsetY + event.clientY - dragStart.y;
scheduleDraw();
});
const stopDragging = (): void => {
boundaryEditor.handleUp();
pipeEditor.handleUp();
dragStart = null;
};
viewport.addEventListener("pointerup", stopDragging);
viewport.addEventListener("pointercancel", stopDragging);
const resizeObserver = new ResizeObserver(scheduleDraw);
resizeObserver.observe(viewport);
function setCollapsed(collapsed: boolean): void {
root.classList.toggle("is-collapsed", collapsed);
panelHandle.setOpen(!collapsed);
sessionStorage.setItem(COLLAPSED_KEY, String(collapsed));
if (!collapsed) scheduleDraw();
}
panelHandle.root.addEventListener("click", () =>
setCollapsed(!root.classList.contains("is-collapsed")),
);
setCollapsed(sessionStorage.getItem(COLLAPSED_KEY) !== "false");
return {
root,
load(nextProjectId: string) {
if (projectId === nextProjectId && meta) return;
projectId = nextProjectId;
void loadLayers();
},
setRoute(points) {
routePoints = points;
routeLayer = meta && points.length > 1 ? prepareMetricPolyline(points, meta) : null;
pipeEditor.setContext(meta, points);
if (routeLayer) fitToRoute();
scheduleDraw();
},
hasBoundaryEdits: boundaryEditor.isDirty,
boundaryOverrides: boundaryEditor.overrides,
markBoundarySaved: boundaryEditor.markSaved,
dispose() {
loadSequence += 1;
if (frameHandle) {
window.cancelAnimationFrame(frameHandle);
frameHandle = 0;
}
resizeObserver.disconnect();
},
};
}
@@ -0,0 +1,246 @@
import type { VWorldMeta } from "../B04_wf1_Surface/B04_wf1_Surface_Api_Fetch";
import type { ViewState } from "../B04_wf1_Surface/B04_wf1_Surface_UI_MapRender";
// 배관(관 매설) 지점 편집기 — 배수유역 패널의 계획선 위 마커 표시·추가·이동·삭제.
// 마커 위치의 단일 소스는 누가거리(chainage)다. 화면 좌표는 매 프레임 노선
// 폴리라인(사업지 좌표계 m)을 따라 보간해 구하므로 확대/이동과 무관하게 정확하다.
/** 배관 지점 1개. reason: stream(세류 교차)/spacing(300m 보충)/confirmed(사용자 확정). */
export interface PipePoint {
chainage_m: number;
reason: string;
}
interface RoutePointLike {
x: number;
y: number;
chainage_m?: number;
}
/** 마커 히트 판정 반경(px)과 계획선 추가 클릭 허용 거리(px). */
const HIT_RADIUS_PX = 12;
const ADD_SNAP_PX = 14;
export interface PipeEditor {
setContext(meta: VWorldMeta | null, points: ReadonlyArray<RoutePointLike>): void;
setPipes(pipes: ReadonlyArray<PipePoint>): void;
pipes(): ReadonlyArray<PipePoint>;
chainages(): number[];
selected(): number | null;
select(index: number | null): void;
deleteSelected(): boolean;
/** 편집 상호작용. 처리했으면 true(패널은 지도 팬을 생략한다). */
handleDown(view: ViewState, screenX: number, screenY: number, editMode: boolean): boolean;
handleMove(view: ViewState, screenX: number, screenY: number): boolean;
handleUp(): boolean;
draw(
context: CanvasRenderingContext2D,
view: ViewState,
colorOf: (chainage: number, position: number) => string,
): void;
}
export function createPipeEditor(onChange: () => void): PipeEditor {
let meta: VWorldMeta | null = null;
let route: Array<{ x: number; y: number; chainage: number }> = [];
let totalChainage = 0;
let pipeList: PipePoint[] = [];
let selectedIndex: number | null = null;
let draggingIndex: number | null = null;
let dragMoved = false;
/** 화면 → 사업지 좌표계 m (MapRender affine의 역변환). */
function screenToMetric(
view: ViewState,
sx: number,
sy: number,
): { x: number; y: number } | null {
if (!meta) return null;
const ax = view.mapRect.width * view.scale;
const bx = view.mapRect.x * view.scale + (view.width / 2) * (1 - view.scale) + view.offsetX;
const ay = view.mapRect.height * view.scale;
const by = view.mapRect.y * view.scale + (view.height / 2) * (1 - view.scale) + view.offsetY;
if (!ax || !ay) return null;
const nx = (sx - bx) / ax;
const ny = (sy - by) / ay;
return {
x: meta.x_min + nx * (meta.width_meters || 1),
y: meta.y_min + (1 - ny) * (meta.height_meters || 1),
};
}
function metricToScreen(view: ViewState, x: number, y: number): { x: number; y: number } | null {
if (!meta) return null;
const nx = (x - meta.x_min) / (meta.width_meters || 1);
const ny = 1 - (y - meta.y_min) / (meta.height_meters || 1);
const ax = view.mapRect.width * view.scale;
const bx = view.mapRect.x * view.scale + (view.width / 2) * (1 - view.scale) + view.offsetX;
const ay = view.mapRect.height * view.scale;
const by = view.mapRect.y * view.scale + (view.height / 2) * (1 - view.scale) + view.offsetY;
return { x: nx * ax + bx, y: ny * ay + by };
}
/** 1m가 화면에서 몇 px인지 (거리 판정용). */
function pxPerMeter(view: ViewState): number {
if (!meta) return 1;
return (view.mapRect.width * view.scale) / (meta.width_meters || 1);
}
function chainageToXY(chainage: number): { x: number; y: number } | null {
if (route.length < 2) return null;
if (chainage <= route[0].chainage) return { x: route[0].x, y: route[0].y };
for (let i = 1; i < route.length; i += 1) {
const prev = route[i - 1];
const next = route[i];
if (chainage > next.chainage) continue;
const span = next.chainage - prev.chainage || 1;
const t = (chainage - prev.chainage) / span;
return { x: prev.x + (next.x - prev.x) * t, y: prev.y + (next.y - prev.y) * t };
}
const last = route[route.length - 1];
return { x: last.x, y: last.y };
}
/** 사업지 좌표에서 노선 최근접 지점의 누가거리와 이탈 거리(m). */
function nearestChainage(x: number, y: number): { chainage: number; distance: number } | null {
if (route.length < 2) return null;
let best: { chainage: number; distance: number } | null = null;
for (let i = 1; i < route.length; i += 1) {
const a = route[i - 1];
const b = route[i];
const dx = b.x - a.x;
const dy = b.y - a.y;
const lengthSq = dx * dx + dy * dy || 1;
const t = Math.max(0, Math.min(1, ((x - a.x) * dx + (y - a.y) * dy) / lengthSq));
const px = a.x + dx * t;
const py = a.y + dy * t;
const distance = Math.hypot(x - px, y - py);
const chainage = a.chainage + (b.chainage - a.chainage) * t;
if (!best || distance < best.distance) best = { chainage, distance };
}
return best;
}
function sortPipes(): void {
const selected = selectedIndex === null ? null : pipeList[selectedIndex];
pipeList.sort((a, b) => a.chainage_m - b.chainage_m);
selectedIndex = selected === null ? null : pipeList.indexOf(selected);
}
return {
setContext(nextMeta, points) {
meta = nextMeta;
let cumulative = 0;
route = points.map((point, index) => {
if (index > 0) {
const prev = points[index - 1];
cumulative += Math.hypot(point.x - prev.x, point.y - prev.y);
}
return { x: point.x, y: point.y, chainage: point.chainage_m ?? cumulative };
});
totalChainage = route.length > 0 ? route[route.length - 1].chainage : 0;
},
setPipes(pipes) {
pipeList = pipes.map((pipe) => ({ ...pipe }));
sortPipes();
selectedIndex = null;
draggingIndex = null;
},
pipes: () => pipeList,
chainages: () => pipeList.map((pipe) => Math.round(pipe.chainage_m * 100) / 100),
selected: () => selectedIndex,
select(index) {
selectedIndex = index;
},
deleteSelected() {
if (selectedIndex === null) return false;
pipeList.splice(selectedIndex, 1);
selectedIndex = null;
onChange();
return true;
},
handleDown(view, screenX, screenY, editMode) {
dragMoved = false;
// 마커 클릭: 선택 (편집 모드 여부 무관), 편집 모드면 드래그 시작.
for (let i = pipeList.length - 1; i >= 0; i -= 1) {
const xy = chainageToXY(pipeList[i].chainage_m);
if (!xy) continue;
const screen = metricToScreen(view, xy.x, xy.y);
if (!screen) continue;
if (Math.hypot(screenX - screen.x, screenY - screen.y) <= HIT_RADIUS_PX) {
selectedIndex = i;
if (editMode) draggingIndex = i;
onChange();
return true;
}
}
if (!editMode) return false;
// 계획선 클릭: 그 지점에 배관 추가.
const metric = screenToMetric(view, screenX, screenY);
if (!metric) return false;
const nearest = nearestChainage(metric.x, metric.y);
if (!nearest || nearest.distance * pxPerMeter(view) > ADD_SNAP_PX) return false;
pipeList.push({ chainage_m: nearest.chainage, reason: "confirmed" });
sortPipes();
selectedIndex = pipeList.findIndex(
(pipe) => Math.abs(pipe.chainage_m - nearest.chainage) < 1e-6,
);
draggingIndex = selectedIndex;
onChange();
return true;
},
handleMove(view, screenX, screenY) {
if (draggingIndex === null) return false;
const metric = screenToMetric(view, screenX, screenY);
if (!metric) return true;
const nearest = nearestChainage(metric.x, metric.y);
if (!nearest) return true;
const clamped = Math.max(0, Math.min(totalChainage, nearest.chainage));
pipeList[draggingIndex].chainage_m = clamped;
pipeList[draggingIndex].reason = "confirmed";
dragMoved = true;
onChange();
return true;
},
handleUp() {
if (draggingIndex === null) return false;
draggingIndex = null;
if (dragMoved) {
sortPipes();
onChange();
}
return true;
},
draw(context, view, colorOf) {
pipeList.forEach((pipe, position) => {
const xy = chainageToXY(pipe.chainage_m);
if (!xy) return;
const screen = metricToScreen(view, xy.x, xy.y);
if (!screen) return;
const isSelected = position === selectedIndex;
const radius = isSelected ? 9 : 7;
context.beginPath();
context.arc(screen.x, screen.y, radius, 0, Math.PI * 2);
context.fillStyle = colorOf(pipe.chainage_m, position);
context.fill();
context.lineWidth = isSelected ? 2.5 : 1.5;
context.strokeStyle = isSelected ? "#111827" : "#374151";
context.stroke();
context.fillStyle = "#111827";
context.font = "bold 10px sans-serif";
context.textAlign = "center";
context.textBaseline = "middle";
context.fillText(String(position + 1), screen.x, screen.y);
// 누가거리 라벨 — 마커 우상단.
context.font = "10px sans-serif";
context.textAlign = "left";
context.fillStyle = "#1f2937";
context.fillText(
`${pipe.chainage_m.toFixed(0)}m`,
screen.x + radius + 3,
screen.y - radius,
);
});
},
};
}
+108 -38
View File
@@ -1,5 +1,7 @@
import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
import { hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_elements";
import { purgeOtherProjects } from "../A00_Common/b_asset_cache";
import { createProgressCircle } from "@ui/ui_template_progress";
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
import { workflowSteps } from "../A00_Common/b_page_scaffold";
import {
@@ -8,13 +10,15 @@ import {
WORKFLOW_STEP_ROUTES,
} from "../A00_Common/b_workflow_nav";
import {
fetchSurfacePointCloud,
fetchConfirmedSurface,
listSurfaceModels,
type SurfaceModelSummary,
} from "../B04_wf1_Surface/B04_wf1_Surface_Api_Fetch";
import {
confirmRoute,
fetchLatestRoute,
routeLatestCacheKey,
saveDrainageBoundary,
solveRoute,
updateContourInterval,
type CirclePoint,
@@ -167,6 +171,8 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
return;
}
const activeProjectId: string = projectId;
// 새로고침으로 바로 들어온 경우에도 다른 프로젝트 자료는 보관함에서 지운다.
void purgeOtherProjects(activeProjectId);
const viewer = createRouteViewer();
const profilePanel = createRouteProfilePanel(
@@ -241,7 +247,7 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
* DB(latest)
* . latest를 ,
* solve· ( = , ). */
const latestCacheKey = `b05:latest:${activeProjectId}`;
const latestCacheKey = routeLatestCacheKey(activeProjectId);
function readLatestCache(): RouteLatestResponse | null {
try {
@@ -407,15 +413,18 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
async function restoreSections(routeId: number): Promise<void> {
let detail: SectionDetailResponse;
profilePanel.setLoading("종단면 자료를 불러오는 중…");
try {
detail = await fetchSectionDetail(activeProjectId, routeId);
} catch {
// 종횡단 데이터 자체가 없는 경우(생성 실패·최초 진입)는 빈 안내로 둔다.
currentSectionDetail = null;
profilePanel.setLoading(null);
profilePanel.clear();
viewer.renderStationLines([], 0);
return;
}
profilePanel.setLoading(null);
try {
renderSections(detail, routeId);
// 복귀/최초 진입 시(클라이언트 목록이 비어 있을 때만) 확정된 비정규 측점을 사이드바에 복원한다.
@@ -447,6 +456,7 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
routeReady = Boolean(next.route && next.route_points.length > 1);
stale = false;
panel.setStale(false);
profilePanel.setRoutePolyline(next.route_points ?? []);
if (next.route) {
const stored = next.route.algorithm_params ?? {};
viewer.markers.renderRoute(
@@ -539,8 +549,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 {
// 종단 계획선 편집은 화면에서만 계산해 두었으므로 확정 직전에 영속화한다.
@@ -571,43 +605,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;
// 3D 뷰포트 정중앙. 하단 종단 패널(z-index 3)보다 아래라 패널에 가려지는 것은 무방하다
// (2026-08-01 사용자 지시).
const progress = createProgressCircle({ label: "화면 틀을 준비하는 중…", overlay: true });
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";
@@ -625,4 +640,59 @@ 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 {
// 지형 가장자리만 필요하다 — 포인트클라우드 전체(수십 MB)는 받지 않는다.
const confirmed = await fetchConfirmedSurface(activeProjectId);
if (!confirmed.bounds) throw new Error("지표면 범위 정보를 찾을 수 없습니다.");
await viewer.loadSurface(
activeProjectId,
confirmedSurface.id,
latestResponse.surface_params.method,
latestResponse.surface_params.smooth,
latestResponse.surface_params.contour_interval_m,
toBounds(confirmed.bounds),
);
// 지형이 올라온 뒤에야 마커·측점선이 지형 위에 놓인다.
renderLatest(latestResponse);
if (currentSectionDetail) renderStationLines(currentSectionDetail);
}
advanceLoading("");
} catch (error) {
showToast(error instanceof Error ? error.message : "화면을 불러오지 못했습니다.", "error");
} finally {
progress.remove();
restoring = false;
}
}
@@ -21,6 +21,8 @@ import {
} from "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Longitudinal";
import { LONG_PAD } from "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_Common";
import { createWorkflowPanelHandle } from "@ui/ui_template_overlay";
import { createDrainagePanel } from "./B05_wf2_Route_UI_Drainage_Panel";
import { createProgressCircle } from "@ui/ui_template_progress";
import { showToast } from "@ui/ui_template_elements";
import { saveProfileAlignment } from "./B05_wf2_Route_Api_Fetch";
import type {
@@ -50,6 +52,9 @@ import {
} from "./B05_wf2_Route_UI_IrregularStations";
import type { SectionStation } from "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch";
import "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css";
// SVG 차트 색상(.b06-chart__*)의 정의처는 _Style_Cross.css다. 이걸 빼면 B05로 바로 진입했을 때
// 배경 rect가 브라우저 기본 fill(검정)로 그려진다 — B06을 먼저 방문해야 정상으로 보이던 원인.
import "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_Cross.css";
const COLLAPSED_KEY = "b05-route-profile-collapsed";
/** 정보 라인을 뺀 본문 세로를 그래프 40% : 테이블 60%로 나눈다(4:6, 6이 테이블). */
@@ -266,7 +271,21 @@ export function createRouteProfilePanel(
empty.className = "b05-route-profile__empty";
empty.textContent = "최적 경로를 계산하면 종단면도가 표시됩니다.";
body.append(empty);
root.append(panelHandle.root, balanceBar, body);
// 종단면 본문 + 우측 배수유역 패널을 나란히 놓는 2단 구성.
// 배수유역 패널이 이 안에 있으므로 하단 패널을 접으면 함께 사라진다(사용자 지시).
// 그래프 영역 정중앙 로딩 서클 — 종단면 자료가 도착할 때까지 빈 안내만 보인다.
// body는 그릴 때마다 자식이 통째로 교체되므로 서클은 감싸는 칸에 둔다.
const progress = createProgressCircle({ overlay: true });
progress.root.hidden = true;
const bodyWrap = document.createElement("div");
bodyWrap.className = "b05-route-profile__body-wrap";
bodyWrap.append(body, progress.root);
const content = document.createElement("div");
content.className = "b05-route-profile__content";
const drainagePanel = createDrainagePanel();
content.append(bodyWrap, drainagePanel.root);
root.append(panelHandle.root, balanceBar, content);
drainagePanel.load(projectId);
let detail: SectionDetailResponse | null = null;
let selectedStationId: string | null = null;
@@ -580,6 +599,10 @@ export function createRouteProfilePanel(
selectedStationId = stationId;
draw();
},
/** 확정된 노선 평면 선형을 우측 배수유역 지도에 겹친다(사업지 좌표계 m). */
setRoutePolyline(points: ReadonlyArray<{ x: number; y: number }>) {
drainagePanel.setRoute(points);
},
/** 비정규 측점 목록을 반영해 그래프(세로선+라벨)·테이블(주석)을 다시 그린다. */
setIrregularStations(stations: IrregularStation[]) {
irregularStations = stations;
@@ -622,6 +645,13 @@ export function createRouteProfilePanel(
balanceBar.replaceChildren();
body.replaceChildren(empty);
},
/** 배수유역도 패널 — 경로 확정 흐름에서 유역선 편집 저장 여부를 묻는 데 쓴다. */
drainage: drainagePanel,
/** 그래프 영역 로딩 서클. 문구를 주면 켜고 null이면 끈다. */
setLoading(label: string | null) {
progress.root.hidden = label === null;
if (label !== null) progress.set(null, label);
},
dispose() {
window.clearTimeout(resizeTimer);
resizeObserver.disconnect();
@@ -374,6 +374,7 @@ function buildSelectedColumn(
interval: number,
display: { station: number; cumulative: number },
onAdjustStation?: (chainageM: number, deltaM: number) => void,
isIrregular = false,
): HTMLElement {
const plan = interpolateSample(alignment.samples, chainage, "elevation_m");
const ground = interpolateSample(alignment.samples, chainage, "ground_elevation_m");
@@ -419,7 +420,10 @@ function buildSelectedColumn(
{ value: { text: curve ? curve.l_m.toFixed(2) : "" }, modifier: "" }, // 곡선 L
{ value: { text: curve ? curve.r_m.toFixed(1) : "" }, modifier: "" }, // 곡선 R
];
const column = element("div", "b05-profile-table__irregular-col");
const column = element(
"div",
`b05-profile-table__irregular-col${isIrregular ? " is-floating" : ""}`,
);
column.style.left = `${centerX}px`;
column.style.width = `${cellWidth}px`;
rows.forEach((row) => {
@@ -501,15 +505,20 @@ export function createProfileTable(options: ProfileTableOptions): HTMLElement {
? alignment.stations.findIndex((row) => row.station_id === options.selectedStationId)
: -1;
if (selectedIrregular) {
const centerX = x(selectedIrregular.chainage_m);
// 비정규 측점은 측점 격자와 무관한 위치라 오버레이가 좌우 이웃 셀을 반씩 덮어 값이 잘려
// 보인다. 겹치는 규칙 측점 셀을 숨겨 하이라이트 창이 깨끗한 자리에 뜨게 한다.
hideCoveredCells(table, centers, centerX, cellWidth);
table.append(
buildSelectedColumn(
selectedIrregular.chainage_m,
alignment,
x(selectedIrregular.chainage_m),
centerX,
cellWidth,
stationInterval,
display,
options.onAdjustStation,
true,
),
);
} else if (selectedRegularIndex >= 0) {
@@ -522,8 +531,33 @@ export function createProfileTable(options: ProfileTableOptions): HTMLElement {
stationInterval,
display,
options.onAdjustStation,
false,
),
);
}
return table;
}
/**
* .
* `cellWidth` , `cellWidth`
* . .
*/
function hideCoveredCells(
table: HTMLElement,
centers: readonly number[],
columnCenter: number,
cellWidth: number,
): void {
const covered = new Set<number>();
centers.forEach((center, index) => {
if (Math.abs(center - columnCenter) < cellWidth) covered.add(index);
});
if (covered.size === 0) return;
table.querySelectorAll<HTMLElement>(".b05-profile-table__row").forEach((row) => {
const cells = row.querySelectorAll<HTMLElement>(
".b05-profile-table__cell, .b05-profile-table__curve",
);
covered.forEach((index) => cells[index]?.classList.add("is-covered"));
});
}
+263 -12
View File
@@ -16,6 +16,7 @@
}
.b05-route__main {
position: relative;
display: flex;
flex-direction: column;
width: 100%;
@@ -35,28 +36,52 @@
}
/* 하단 종단 패널: 그래프 + 12행 도면 테이블을 담기 위해 화면 높이의 60% 차지한다.
3D 뷰포트를 밀어내지 않고 위를 덮는 오버레이로 띄운다 패널을 여닫아도 3D
크기가 변하지 않아 카메라 시점과 렌더 비용이 그대로 유지된다.
접기 핸들로 언제든 내릴 있어 3D 뷰를 전체 영역으로 있다. */
.b05-route-profile {
position: relative;
position: absolute;
z-index: 3;
right: 0;
bottom: 0;
left: 0;
display: flex;
flex: 0 0 60vh;
flex: 0 0 60dvh;
height: 60vh;
height: 60dvh;
flex-direction: column;
min-height: 0;
overflow: visible;
border-top: 1px solid var(--color-border);
background: var(--color-surface-raised);
transition: flex-basis var(--transition-fast);
transition: height var(--transition-fast);
}
.b05-route-profile.is-collapsed {
flex-basis: 0;
height: 0;
min-height: 0;
}
/* 종단면 본문 + 우측 배수유역 패널의 2단 가로 배치. */
.b05-route-profile__content {
display: flex;
flex: 1 1 auto;
min-height: 0;
min-width: 0;
}
/* 그래프 본문 + 로딩 서클을 겹치기 위한 칸. body는 그릴 때마다 자식이 교체된다. */
.b05-route-profile__body-wrap {
position: relative;
display: flex;
flex: 1 1 auto;
min-width: 0;
min-height: 0;
}
.b05-route-profile__body {
box-sizing: border-box;
flex: 1 1 auto;
min-width: 0;
min-height: 0;
overflow-x: scroll;
overflow-y: hidden;
@@ -110,6 +135,7 @@
transform: translateY(-50%);
}
.b05-route-profile.is-collapsed .b05-route-profile__body-wrap,
.b05-route-profile.is-collapsed .b05-route-profile__body,
.b05-route-profile.is-collapsed .b05-route-profile__balance {
display: none;
@@ -151,23 +177,27 @@
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;
}
/* 뷰셋·표시 토글 버튼 묶음 좌상단(2026-08-01 사용자 지시).
안내 문구가 우상단으로 옮겨져 좌상단이 비었다. */
.b05-route__view-controls {
position: absolute;
z-index: 2;
top: 58px;
top: var(--spacing-16);
left: var(--spacing-16);
display: flex;
align-items: center;
@@ -663,6 +693,22 @@
pointer-events: none;
}
/* 비정규(구조물) 측점은 측점 격자와 무관한 위치에 떠서, 이웃 셀을 가린 자리에 뜬다.
있는 창임이 드러나게 그림자·굵은 테두리로 구분한다(규칙 측점은 격자와 일치해 불필요). */
.b05-profile-table__irregular-col.is-floating {
border-inline-width: 2px;
box-shadow:
0 0 0 1px var(--color-surface-raised),
0 4px 12px rgb(0 0 0 / 28%);
}
/* 하이라이트 창이 덮은 자리의 규칙 측점 값이 잘려 읽을 없으므로 숨긴다.
자리(레이아웃) 유지해야 세로 구분선 격자가 끊기지 않으므로 visibility로 감춘다. */
.b05-profile-table__cell.is-covered,
.b05-profile-table__curve.is-covered {
visibility: hidden;
}
.b05-profile-table__irregular-col-cell {
display: flex;
flex: 1 1 0;
@@ -784,3 +830,208 @@
color: color-mix(in srgb, var(--color-royal-amethyst, rgb(139 92 246)) 85%, var(--color-text));
opacity: 0.95;
}
/* ─── 배수유역도 패널 (하단 종단 패널 안쪽 우측 2단 사이드 패널) ──────────── */
.b05-drainage {
position: relative;
display: flex;
width: 38%;
min-width: 320px;
max-width: 640px;
flex: 0 0 auto;
flex-direction: column;
min-height: 0;
border-left: 1px solid var(--color-border);
background: var(--color-surface-raised);
transition: width var(--transition-fast);
}
/* 접으면 폭만 0으로 줄고, 좌측 가장자리 핸들은 남아 다시 펼 수 있다. */
.b05-drainage.is-collapsed {
width: 0;
min-width: 0;
}
.b05-drainage.is-collapsed .b05-drainage__header,
.b05-drainage.is-collapsed .b05-drainage__viewport {
display: none;
}
/* 좌측 가장자리 세로 중앙 핸들 — 공용 side 핸들을 패널 왼쪽 밖으로 내보낸다. */
.b05-drainage .ui-workflow-overlay__handle--side {
z-index: 2;
right: auto;
left: calc(-1 * var(--spacing-24));
border-right: 0;
border-radius: var(--radius-buttons) 0 0 var(--radius-buttons);
}
.b05-drainage__header {
display: flex;
flex: 0 0 auto;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-8);
padding: var(--spacing-8) calc(var(--spacing-8) + var(--spacing-4));
border-bottom: 1px solid var(--color-border);
}
.b05-drainage__header h3 {
margin: 0;
color: var(--color-text);
font-size: var(--text-body-sm);
}
.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-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 0 0 0 1px var(--b05-layer-color, transparent);
color: var(--b05-layer-color, var(--color-text-body));
opacity: 1;
}
.b05-drainage__viewport {
position: relative;
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
background: var(--color-surface);
cursor: grab;
touch-action: none;
}
.b05-drainage__viewport:active {
cursor: grabbing;
}
.b05-drainage__image,
.b05-drainage__canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.b05-drainage__image {
object-fit: contain;
transform-origin: center;
user-select: none;
pointer-events: none;
}
.b05-drainage__canvas {
pointer-events: none;
}
.b05-drainage__status {
position: absolute;
inset: var(--spacing-8) var(--spacing-8) auto var(--spacing-8);
color: var(--color-text-secondary);
font-size: var(--text-caption);
pointer-events: none;
}
.b05-drainage__analyze {
margin-left: auto;
padding: 2px var(--spacing-8);
border: 1px solid
color-mix(in srgb, var(--color-royal-amethyst, rgb(109 40 217)) 55%, transparent);
border-radius: var(--radius-inputs);
background: var(--color-surface);
color: var(--color-text-body);
font-size: var(--text-caption);
cursor: pointer;
}
.b05-drainage__analyze:disabled {
opacity: 0.45;
cursor: default;
}
/* 배관 편집 도구 버튼 — "유역 산정" 우측에 나란히(auto 마진 해제). */
.b05-drainage__tool {
margin-left: var(--spacing-4, 4px);
}
/* 배관 편집 토글 활성 상태. */
.b05-drainage__analyze.is-active {
background: color-mix(
in srgb,
var(--color-royal-amethyst, rgb(109 40 217)) 18%,
var(--color-surface)
);
border-color: var(--color-royal-amethyst, rgb(109 40 217));
}
/* 유역 제원 목록 — 면적·유역표고·유하거리·관경(수식 확정 전까지 "미정"). */
.b05-drainage__basins {
display: flex;
max-height: 34%;
flex: 0 0 auto;
flex-direction: column;
gap: 2px;
overflow-y: auto;
padding: var(--spacing-8);
border-top: 1px solid var(--color-border);
}
.b05-drainage__basin {
display: flex;
align-items: center;
gap: var(--spacing-8);
padding: var(--spacing-4) var(--spacing-8);
border: 1px solid transparent;
border-radius: var(--radius-inputs);
background: none;
color: var(--color-text-body);
font-size: var(--text-caption);
text-align: left;
cursor: pointer;
}
.b05-drainage__basin:hover,
.b05-drainage__basin.is-selected {
border-color: var(--color-border);
background: var(--color-surface);
}
/* 지도 위 서클 번호와 같은 파스텔 색을 써서 목록 항목과 유역을 눈으로 잇는다. */
.b05-drainage__basin-index {
display: inline-flex;
width: 20px;
height: 20px;
flex: 0 0 auto;
align-items: center;
justify-content: center;
border: 1px solid var(--color-border);
border-radius: 50%;
color: #1f2937;
font-size: 11px;
font-weight: 600;
}
.b05-drainage__basin-metrics {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
+49 -32
View File
@@ -3,6 +3,8 @@ 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 { fetchCachedBytes, fetchCachedJson } from "../A00_Common/b_asset_cache";
import { bindCursorPivotControls } from "../B04_wf1_Surface/B04_wf1_Surface_UI_Camera";
import {
createRouteMarkers,
sceneToModel,
@@ -112,6 +114,16 @@ 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,
scene,
});
function clearContours(): void {
disposeObject(contours);
@@ -151,35 +163,42 @@ export function createRouteViewer(): RouteViewer {
async function reloadContours(interval: number): Promise<void> {
if (!current || !bounds) return;
current.interval = interval;
const response = await fetch(
`${API_BASE_URL}/projects/${current.projectId}/surface/models/${current.modelId}/contour?interval=${interval}&smooth=${current.smooth}`,
{ credentials: "include", cache: "no-store" },
);
if (!response.ok) throw new Error("등고선 조회에 실패했습니다.");
const data = (await response.json()) as {
// 등고선도 보관함에서 먼저 찾는다 — 같은 파일을 새로고침마다 다시 내려받지 않는다.
const data = await fetchCachedJson<{
contours: Array<{ level: number; coordinates: [number, number, number][] }>;
};
}>(
current.projectId,
`${API_BASE_URL}/projects/${current.projectId}/surface/models/${current.modelId}/contour?interval=${interval}&smooth=${current.smooth}`,
);
clearContours();
// 등고선 한 가닥마다 3D 객체를 만들면 수백 개가 된다. 주곡선·보조곡선 두 덩어리로 합친다.
const majorPoints: THREE.Vector3[] = [];
const minorPoints: THREE.Vector3[] = [];
const cx = (bounds.x[0] + bounds.x[1]) / 2;
const cy = (bounds.y[0] + bounds.y[1]) / 2;
const cz = (bounds.z[0] + bounds.z[1]) / 2;
data.contours.forEach((contour) => {
const points = contour.coordinates.map(([x, y, z]) => {
const cx = (bounds!.x[0] + bounds!.x[1]) / 2;
const cy = (bounds!.y[0] + bounds!.y[1]) / 2;
const cz = (bounds!.z[0] + bounds!.z[1]) / 2;
return new THREE.Vector3(x - cx, z - cz + 0.15, -(y - cy));
});
if (points.length > 1) {
contours.add(
new THREE.Line(
new THREE.BufferGeometry().setFromPoints(points),
new THREE.LineBasicMaterial({
color: contour.level % (interval * 5) === 0 ? 0xd97706 : 0xf59e0b,
transparent: true,
opacity: 0.75,
}),
),
);
const points = contour.coordinates.map(
([x, y, z]) => new THREE.Vector3(x - cx, z - cz + 0.15, -(y - cy)),
);
if (points.length < 2) return;
const bucket = contour.level % (interval * 5) === 0 ? majorPoints : minorPoints;
for (let index = 0; index < points.length - 1; index += 1) {
bucket.push(points[index], points[index + 1]);
}
});
[
{ points: minorPoints, color: 0xf59e0b },
{ points: majorPoints, color: 0xd97706 },
].forEach(({ points, color }) => {
if (points.length === 0) return;
contours.add(
new THREE.LineSegments(
new THREE.BufferGeometry().setFromPoints(points),
new THREE.LineBasicMaterial({ color, transparent: true, opacity: 0.75 }),
),
);
});
}
function terrainPoint(
@@ -321,17 +340,14 @@ export function createRouteViewer(): RouteViewer {
disposeObject(terrain);
}
const url = `${API_BASE_URL}/projects/${projectId}/surface/models/${modelId}/preview?smooth=${smooth}`;
// 브라우저 보관함에 있으면 그대로 쓰고, 없을 때만 내려받는다(새로고침이 빨라진다).
const buffer = await fetchCachedBytes(projectId, url);
terrain = await new Promise<THREE.Object3D>((resolve, reject) => {
if (method === "meshfree") {
new PLYLoader().load(
url,
(geometry) =>
resolve(new THREE.Points(geometry, new THREE.PointsMaterial({ size: 0.35 }))),
undefined,
reject,
);
const geometry = new PLYLoader().parse(buffer);
resolve(new THREE.Points(geometry, new THREE.PointsMaterial({ size: 0.35 })));
} else {
new GLTFLoader().load(url, (gltf) => resolve(gltf.scene), undefined, reject);
new GLTFLoader().parse(buffer, "", (gltf) => resolve(gltf.scene), reject);
}
});
terrain.traverse((child) => {
@@ -370,6 +386,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);
@@ -27,12 +27,26 @@ import { DEFAULT_TEXT_OPTIONS, type DrawController } from './DrawController';
*
* To convert between the 2 coordinate systems, you need the screenOffset and screenScale
*/
// Batch-mode stroke decimation: skip chain segments shorter than this (screen px)
const BATCH_LOD_PX = 0.5;
// Batch-mode text smaller than this (screen px) is unreadable — skip drawing it
const BATCH_MIN_TEXT_PX = 2;
export class ScreenCanvasDrawController implements DrawController {
private screenOffset: Point = new Point(0, 0);
private screenScale = 1;
private screenMouseLocation: Point;
private canvasSize: Point = new Point(100, 100);
// Style-run batching (static scene rendering): consecutive stroke calls
// with the same style are collected into one Path2D and stroked once.
private batching = false;
private batchPath: Path2D | null = null;
private batchKey: string | null = null;
private batchStyle: { color: string; lineWidth: number; dash: number[] } | null = null;
private batchLastX = Number.NaN;
private batchLastY = Number.NaN;
constructor(private context: CanvasRenderingContext2D) {
this.screenMouseLocation = new Point(this.canvasSize.x / 2, this.canvasSize.y / 2);
this.setScreenOffset(new Point(0, 0)); // User expects mathematical coordinates, where y axis goes up, but canvas y axis goes down
@@ -221,6 +235,18 @@ export class ScreenCanvasDrawController implements DrawController {
lineWidth: number,
dash: number[] = []
) {
if (this.batching) {
const effectiveWidth = isHighlighted ? lineWidth + 1 : lineWidth;
const effectiveDash = isSelected ? [5, 5] : dash;
const key = `${color}|${effectiveWidth}|${effectiveDash.join(',')}`;
if (key !== this.batchKey) {
this.flushBatch();
this.batchKey = key;
this.batchStyle = { color, lineWidth: effectiveWidth, dash: effectiveDash };
}
return;
}
this.context.strokeStyle = color;
this.context.lineWidth = lineWidth;
this.context.setLineDash(dash);
@@ -234,14 +260,75 @@ export class ScreenCanvasDrawController implements DrawController {
}
}
/**
* Start style-run batching: consecutive stroke calls sharing a style are
* accumulated into a single Path2D and stroked once (with sub-pixel
* segment decimation). Used while rendering the static scene cache.
*/
public beginBatch() {
this.flushBatch();
this.batching = true;
this.batchKey = null;
this.batchStyle = null;
}
public endBatch() {
this.flushBatch();
this.batching = false;
this.batchKey = null;
this.batchStyle = null;
}
private flushBatch() {
if (this.batchPath && this.batchStyle) {
this.context.strokeStyle = this.batchStyle.color;
this.context.lineWidth = this.batchStyle.lineWidth;
this.context.setLineDash(this.batchStyle.dash);
// Round caps/joins replace the per-segment endpoint dots drawn in
// the unbatched path (see _drawRoundedEndpoint)
this.context.lineCap = 'round';
this.context.lineJoin = 'round';
this.context.stroke(this.batchPath);
this.context.lineCap = 'butt';
this.context.lineJoin = 'miter';
}
this.batchPath = null;
this.batchLastX = Number.NaN;
this.batchLastY = Number.NaN;
}
public setFillStyles(fillColor: string) {
this.context.fillStyle = fillColor;
}
/**
* Temporarily redirect all draw calls to another 2d context (eg an
* offscreen canvas used as static scene cache), reusing the current
* offset/scale/canvasSize without touching state or react triggers.
*/
public withContext(temporaryContext: CanvasRenderingContext2D, renderFunction: () => void) {
const originalContext = this.context;
this.context = temporaryContext;
try {
renderFunction();
} finally {
this.context = originalContext;
}
}
/**
* Blit a pre-rendered bitmap (static scene cache) onto the canvas at a
* pixel offset. Used while panning to avoid re-stroking every entity.
*/
public blitImage(source: CanvasImageSource, dx: number, dy: number) {
this.context.drawImage(source, dx, dy);
}
public clear() {
if (this.canvasSize === null) return;
if (!this.context) return;
if (this.batching) this.flushBatch();
this.context.fillStyle = CANVAS_BACKGROUND_COLOR;
this.context.fillRect(0, 0, this.canvasSize?.x, this.canvasSize?.y);
@@ -282,6 +369,33 @@ export class ScreenCanvasDrawController implements DrawController {
* @param screenEndPoint
*/
public drawLineScreen(screenStartPoint: Point, screenEndPoint: Point): void {
if (this.batching) {
const startX = screenStartPoint.x;
const startY = this.canvasSize.y - screenStartPoint.y;
const endX = screenEndPoint.x;
const endY = this.canvasSize.y - screenEndPoint.y;
if (!this.batchPath) this.batchPath = new Path2D();
// Chain break: start is not where the previous segment ended
const chainBroken =
Math.abs(startX - this.batchLastX) > BATCH_LOD_PX ||
Math.abs(startY - this.batchLastY) > BATCH_LOD_PX;
if (chainBroken || Number.isNaN(this.batchLastX)) {
this.batchPath.moveTo(startX, startY);
this.batchLastX = startX;
this.batchLastY = startY;
}
const isTinyStep =
Math.abs(endX - this.batchLastX) < BATCH_LOD_PX &&
Math.abs(endY - this.batchLastY) < BATCH_LOD_PX;
// Decimate sub-pixel steps inside a chain; isolated segments always draw
if (!isTinyStep || chainBroken) {
this.batchPath.lineTo(endX, endY);
this.batchLastX = endX;
this.batchLastY = endY;
}
return;
}
this.context.beginPath();
this.context.moveTo(screenStartPoint.x, this.canvasSize.y - screenStartPoint.y);
this.context.lineTo(screenEndPoint.x, this.canvasSize.y - screenEndPoint.y);
@@ -334,6 +448,22 @@ export class ScreenCanvasDrawController implements DrawController {
endAngle: number,
counterClockWise: boolean
) {
if (this.batching) {
if (screenRadius < BATCH_LOD_PX) return; // invisible at this zoom
if (!this.batchPath) this.batchPath = new Path2D();
const centerX = screenCenterPoint.x;
const centerY = this.canvasSize.y - screenCenterPoint.y;
this.batchPath.moveTo(
centerX + screenRadius * Math.cos(startAngle),
centerY + screenRadius * Math.sin(startAngle)
);
this.batchPath.arc(centerX, centerY, screenRadius, startAngle, endAngle, counterClockWise);
// Arc end becomes the new chain tail
this.batchLastX = centerX + screenRadius * Math.cos(endAngle);
this.batchLastY = centerY + screenRadius * Math.sin(endAngle);
return;
}
this.context.beginPath();
this.context.arc(
screenCenterPoint.x,
@@ -411,6 +541,10 @@ export class ScreenCanvasDrawController implements DrawController {
...DEFAULT_TEXT_OPTIONS,
...options,
};
if (this.batching) {
if (opts.fontSize < BATCH_MIN_TEXT_PX) return; // unreadable at this zoom
this.flushBatch(); // keep draw order: strokes so far go under this text
}
this.context.save();
this.context.translate(basePoint.x, this.canvasSize.y - basePoint.y);
const angle = getAngleWithXAxis(
@@ -443,6 +577,7 @@ export class ScreenCanvasDrawController implements DrawController {
height: number,
angle: number
): void {
if (this.batching) this.flushBatch();
const [screenBasePoint, screenDimensions] = this.worldsToTargets([
new Point(xMin, yMin),
new Point(width, height),
@@ -493,6 +628,7 @@ export class ScreenCanvasDrawController implements DrawController {
* @param color
*/
public fillRectScreen(xMin: number, yMin: number, width: number, height: number, color: string) {
if (this.batching) this.flushBatch();
// TODO see if we need to replace this with a call to fillPolygon
this.context.fillStyle = color;
this.context.fillRect(xMin, this.canvasSize.y - yMin, width, height);
@@ -503,6 +639,7 @@ export class ScreenCanvasDrawController implements DrawController {
* @param points
*/
public fillPolygon(...points: Point[]) {
if (this.batching) this.flushBatch();
const screenPoints = points.map(this.worldToTarget.bind(this));
this.context.beginPath();
screenPoints.forEach((screenPoint, index) => {
@@ -1,8 +1,8 @@
import {
getAngleGuideOriginPoint,
getAngleStep,
getEntities,
getHoveredSnapPoints,
getLayerById,
getScreenCanvasDrawController,
getShouldDrawHelpers,
setAngleGuideEntities,
@@ -11,6 +11,7 @@ import {
} from '../state.ts';
import { HOVERED_SNAP_POINT_TIME, SNAP_POINT_DISTANCE } from '../App.consts.ts';
import { getDrawHelpers } from './get-draw-guides.ts';
import { queryEntitiesNearPoint } from './spatial-index.ts';
import { compact } from 'es-toolkit';
/**
@@ -19,9 +20,16 @@ import { compact } from 'es-toolkit';
export function calculateAngleGuidesAndSnapPoints() {
const angleStep = getAngleStep();
const screenCanvasDrawController = getScreenCanvasDrawController();
const entities = getEntities();
const screenScale = screenCanvasDrawController.getScreenScale();
const worldMouseLocation = screenCanvasDrawController.getWorldMouseLocation();
// 스냅 후보: 공간 인덱스로 마우스 주변만 조회 (전 엔티티 O(n²) 교차 계산 제거),
// 잠금 레이어(b07-frame 등 참조용)는 스냅 대상에서 제외한다.
const maxSnapDistance = SNAP_POINT_DISTANCE / screenScale;
const entities = queryEntitiesNearPoint(
worldMouseLocation.x,
worldMouseLocation.y,
maxSnapDistance * 2,
).filter(entity => !getLayerById(entity.layerId)?.isLocked);
const hoveredSnapPoints = getHoveredSnapPoints();
const eligibleHoveredSnapPoints = hoveredSnapPoints.filter(
@@ -39,7 +47,7 @@ export function calculateAngleGuidesAndSnapPoints() {
compact([getAngleGuideOriginPoint(), ...eligibleHoveredPoints]),
worldMouseLocation,
angleStep,
SNAP_POINT_DISTANCE / screenScale,
maxSnapDistance,
);
setAngleGuideEntities(angleGuides);
setSnapPoint(entitySnapPoint);
@@ -4,12 +4,12 @@ import {type SnapPoint, SnapPointType} from '../App.types';
import type {DrawController} from '../drawControllers/DrawController';
import type {ScreenCanvasDrawController} from '../drawControllers/screenCanvas.drawController';
import type {Entity} from '../entities/Entity';
import {getLayers, isEntityHighlighted, isEntitySelected} from '../state';
import {getLayerById, isEntityHighlighted, isEntitySelected} from '../state';
import {toast} from 'react-toastify';
export function drawEntities(drawController: DrawController, entities: Entity[]) {
for (const entity of entities) {
const layer = getLayers().find((layer) => layer.id === entity.layerId);
const layer = getLayerById(entity.layerId);
if (!layer) {
toast.error(`Failed to find layer for entity: ${entity?.id}`);
console.error('Failed to find layer for entity: ', entity);
@@ -14,6 +14,7 @@ import {
getDebugEntities,
getEntities,
getGhostHelperEntities,
getHighlightedEntityIds,
getHoveredSnapPoints,
getInputController,
getShouldDrawCursor,
@@ -21,13 +22,31 @@ import {
getSnapPointOnAngleGuide,
} from '../state';
import type { ScreenCanvasDrawController } from '../drawControllers/screenCanvas.drawController';
import { drawScene } from './scene-cache';
/**
* Hover highlight is excluded from the static scene cache (it changes every
* mouse move) re-draw the few highlighted entities on top of the blit.
*/
function drawHighlightedEntities(drawController: ScreenCanvasDrawController) {
const highlightedIds = getHighlightedEntityIds();
if (!highlightedIds.length) return;
const idSet = new Set(highlightedIds);
drawEntities(
drawController,
getEntities().filter(entity => idSet.has(entity.id)),
);
}
export function draw(drawController: ScreenCanvasDrawController) {
drawController.clear();
// Static scene (all entities): cached bitmap blit, rebuilt only when needed.
drawScene(drawController, performance.now());
drawHighlightedEntities(drawController);
drawHelpers(drawController, getAngleGuideEntities());
drawEntities(drawController, getGhostHelperEntities());
drawEntities(drawController, getEntities());
drawDebugEntities(drawController, getDebugEntities());
const { snapPoint: closestSnapPoint } = getClosestSnapPoint(
@@ -0,0 +1,143 @@
import type { ScreenCanvasDrawController } from '../drawControllers/screenCanvas.drawController';
import {
getGridEnabled,
getHighlightedEntityIds,
setHighlightedEntityIds,
} from '../state';
import { drawEntities } from './draw-functions';
import { getSceneVersion } from './scene-version';
import { queryEntitiesInBox } from './spatial-index';
/**
* Static scene cache: all entities are rendered once into an offscreen canvas.
* While panning, the cached bitmap is blitted at a pixel offset instead of
* re-stroking every entity each frame. The cache is rebuilt when the scene
* version bumps (entities/layers/selection changed), when zoom or canvas size
* changes, or after the pan offset has settled for PAN_SETTLE_MS.
*
* Highlight is intentionally NOT baked into the cache (it changes on every
* mouse move) draw.ts re-draws highlighted entities on top each frame.
*/
const PAN_SETTLE_MS = 120;
interface RenderedParams {
version: number;
scale: number;
offsetX: number;
offsetY: number;
sizeX: number;
sizeY: number;
}
let offscreenCanvas: HTMLCanvasElement | null = null;
let rendered: RenderedParams | null = null;
let prevOffsetX = Number.NaN;
let prevOffsetY = Number.NaN;
let lastOffsetChangeAt = 0;
export const scenePerf = {
sceneRebuilds: 0,
lastRebuildMs: 0,
avgFrameMs: 0,
};
(window as unknown as Record<string, unknown>).__aisloCadPerf = scenePerf;
export function invalidateSceneCache(): void {
rendered = null;
}
/**
* Viewport culling: only entities whose bbox intersects the current view
* (expanded by one viewport on each side, so short pans stay covered by the
* blit before the settle-rebuild) are rendered into the scene cache.
*/
function sceneEntitiesForViewport(drawController: ScreenCanvasDrawController) {
const size = drawController.getCanvasSize();
const scale = drawController.getScreenScale();
const offset = drawController.getScreenOffset();
const viewWidth = size.x / scale;
const viewHeight = size.y / scale;
return queryEntitiesInBox(
offset.x - viewWidth,
offset.y - viewHeight,
offset.x + 2 * viewWidth,
offset.y + 2 * viewHeight
);
}
function rebuildScene(drawController: ScreenCanvasDrawController): void {
const size = drawController.getCanvasSize();
if (!offscreenCanvas) {
offscreenCanvas = document.createElement('canvas');
}
if (offscreenCanvas.width !== size.x || offscreenCanvas.height !== size.y) {
offscreenCanvas.width = Math.max(1, size.x);
offscreenCanvas.height = Math.max(1, size.y);
}
const offscreenContext = offscreenCanvas.getContext('2d');
if (!offscreenContext) return;
const startedAt = performance.now();
// Exclude the (rapidly changing) hover highlight from the baked bitmap.
const savedHighlight = getHighlightedEntityIds();
if (savedHighlight.length) setHighlightedEntityIds([]);
drawController.withContext(offscreenContext, () => {
drawController.clear();
// Style-run batching + sub-pixel decimation: one stroke per style run
drawController.beginBatch();
drawEntities(drawController, sceneEntitiesForViewport(drawController));
drawController.endBatch();
});
if (savedHighlight.length) setHighlightedEntityIds(savedHighlight);
scenePerf.lastRebuildMs = performance.now() - startedAt;
scenePerf.sceneRebuilds++;
const offset = drawController.getScreenOffset();
rendered = {
version: getSceneVersion(),
scale: drawController.getScreenScale(),
offsetX: offset.x,
offsetY: offset.y,
sizeX: size.x,
sizeY: size.y,
};
}
/**
* Draw the static scene: rebuild the cache when needed, otherwise blit the
* cached bitmap (shifted by the pan delta). Called once per frame by draw().
*/
export function drawScene(drawController: ScreenCanvasDrawController, now: number): void {
const size = drawController.getCanvasSize();
const scale = drawController.getScreenScale();
const offset = drawController.getScreenOffset();
if (offset.x !== prevOffsetX || offset.y !== prevOffsetY) {
lastOffsetChangeAt = now;
prevOffsetX = offset.x;
prevOffsetY = offset.y;
}
const paramsChanged =
!rendered ||
rendered.version !== getSceneVersion() ||
rendered.scale !== scale ||
rendered.sizeX !== size.x ||
rendered.sizeY !== size.y;
const offsetChanged =
!!rendered && (rendered.offsetX !== offset.x || rendered.offsetY !== offset.y);
// Grid lines are screen-fixed (drawn in clear()), so blitting a shifted
// bitmap would drag the grid along — always re-render while grid is on.
if (paramsChanged || getGridEnabled() || (offsetChanged && now - lastOffsetChangeAt >= PAN_SETTLE_MS)) {
rebuildScene(drawController);
}
if (!offscreenCanvas || !rendered) return;
// screenX = (worldX - offsetX) * scale, canvasY is y-flipped afterwards:
// content shifts left when offset.x grows, down when offset.y grows.
const dx = (rendered.offsetX - offset.x) * scale;
const dy = (offset.y - rendered.offsetY) * scale;
drawController.blitImage(offscreenCanvas, dx, dy);
}
@@ -0,0 +1,13 @@
/**
* Scene version counter incremented whenever content that is baked into the
* cached static scene bitmap changes (entities, layers, selection, grid).
* Kept dependency-free so both state.ts and scene-cache.ts can import it
* without a cycle.
*/
let sceneVersion = 0;
export const bumpSceneVersion = (): void => {
sceneVersion++;
};
export const getSceneVersion = (): number => sceneVersion;
@@ -0,0 +1,140 @@
import type { Entity } from '../entities/Entity';
import { getEntities } from '../state';
import { getSceneVersion } from './scene-version';
/**
* Uniform-grid spatial index over top-level entity bounding boxes.
* Rebuilt lazily whenever the scene version changes (entity edits bump it).
* Queries return entities in original array order so z-order is preserved.
*/
const GRID_CELLS_PER_AXIS = 64;
interface IndexedEntity {
entity: Entity;
minX: number;
minY: number;
maxX: number;
maxY: number;
}
let indexVersion = -1;
let indexedEntities: IndexedEntity[] = [];
let unindexedEntities: Entity[] = []; // bbox unavailable — always included in results
let cells: Map<number, number[]> = new Map();
let cellSize = 1;
let gridMinX = 0;
let gridMinY = 0;
let gridCols = 1;
function cellRange(min: number, max: number, gridMin: number): [number, number] {
return [Math.floor((min - gridMin) / cellSize), Math.floor((max - gridMin) / cellSize)];
}
function ensureIndex(): void {
const version = getSceneVersion();
if (version === indexVersion) return;
indexedEntities = [];
unindexedEntities = [];
cells = new Map();
const entities = getEntities();
let minX = Number.POSITIVE_INFINITY;
let minY = Number.POSITIVE_INFINITY;
let maxX = Number.NEGATIVE_INFINITY;
let maxY = Number.NEGATIVE_INFINITY;
for (const entity of entities) {
let box: { xmin: number; ymin: number; xmax: number; ymax: number } | null = null;
try {
box = entity.getBoundingBox();
} catch {
box = null;
}
if (
!box ||
!Number.isFinite(box.xmin) ||
!Number.isFinite(box.ymin) ||
!Number.isFinite(box.xmax) ||
!Number.isFinite(box.ymax)
) {
unindexedEntities.push(entity);
continue;
}
indexedEntities.push({
entity,
minX: box.xmin,
minY: box.ymin,
maxX: box.xmax,
maxY: box.ymax,
});
if (box.xmin < minX) minX = box.xmin;
if (box.ymin < minY) minY = box.ymin;
if (box.xmax > maxX) maxX = box.xmax;
if (box.ymax > maxY) maxY = box.ymax;
}
if (indexedEntities.length) {
const extent = Math.max(maxX - minX, maxY - minY, 1e-9);
cellSize = extent / GRID_CELLS_PER_AXIS;
gridMinX = minX;
gridMinY = minY;
gridCols = GRID_CELLS_PER_AXIS + 2;
indexedEntities.forEach((item, index) => {
const [cx0, cx1] = cellRange(item.minX, item.maxX, gridMinX);
const [cy0, cy1] = cellRange(item.minY, item.maxY, gridMinY);
for (let cy = cy0; cy <= cy1; cy++) {
for (let cx = cx0; cx <= cx1; cx++) {
const key = cy * gridCols + cx;
const bucket = cells.get(key);
if (bucket) bucket.push(index);
else cells.set(key, [index]);
}
}
});
}
indexVersion = version;
}
/** 뷰포트/사각 영역과 bbox가 겹치는 엔티티 (원본 배열 순서 유지). */
export function queryEntitiesInBox(
minX: number,
minY: number,
maxX: number,
maxY: number
): Entity[] {
ensureIndex();
if (!indexedEntities.length) return [...unindexedEntities];
const seen = new Set<number>();
const [cx0, cx1] = cellRange(minX, maxX, gridMinX);
const [cy0, cy1] = cellRange(minY, maxY, gridMinY);
for (let cy = cy0; cy <= cy1; cy++) {
for (let cx = cx0; cx <= cx1; cx++) {
const bucket = cells.get(cy * gridCols + cx);
if (!bucket) continue;
for (const index of bucket) seen.add(index);
}
}
const result: Entity[] = [];
let unindexedCursor = 0;
for (let index = 0; index < indexedEntities.length; index++) {
if (!seen.has(index)) continue;
const item = indexedEntities[index];
if (item.maxX < minX || item.minX > maxX || item.maxY < minY || item.minY > maxY) continue;
result.push(item.entity);
}
// bbox 불명 엔티티는 항상 포함 (뒤에 붙여도 소수라 시각 영향 없음)
for (; unindexedCursor < unindexedEntities.length; unindexedCursor++) {
result.push(unindexedEntities[unindexedCursor]);
}
return result;
}
/** 점 주변 반경 후보 엔티티 (스냅·호버용). */
export function queryEntitiesNearPoint(x: number, y: number, radius: number): Entity[] {
return queryEntitiesInBox(x - radius, y - radius, x + radius, y + radius);
}
+29 -7
View File
@@ -7,6 +7,8 @@ import App from './App.tsx';
import { ScreenCanvasDrawController } from './drawControllers/screenCanvas.drawController';
import { draw } from './helpers/draw';
import { findClosestEntity } from './helpers/find-closest-entity';
import { scenePerf } from './helpers/scene-cache';
import { queryEntitiesNearPoint } from './helpers/spatial-index';
import { getNewLayer } from './helpers/get-new-layer.ts';
import { trackHoveredSnapPoint } from './helpers/track-hovered-snap-points';
import { InputController } from './inputController/input-controller.ts';
@@ -14,7 +16,6 @@ import { registerAisloDrawingBridge } from './integration/aislo-drawing-bridge.t
import {
getActiveToolActor,
getCanvas,
getEntities,
getHoveredSnapPoints,
getLastDrawTimestamp,
getScreenCanvasDrawController,
@@ -40,6 +41,13 @@ ReactDOM.createRoot(document.getElementById('root') as HTMLDivElement).render(
</React.StrictMode>
);
// Hover highlight throttle: the closest-entity scan is O(entities) so it runs
// at most every HOVER_THROTTLE_MS and only when the mouse actually moved.
const HOVER_THROTTLE_MS = 30;
let lastHoverCheckAt = 0;
let lastHoverMouseX = Number.NaN;
let lastHoverMouseY = Number.NaN;
function startDrawLoop(
screenCanvasDrawController: ScreenCanvasDrawController,
timestamp: DOMHighResTimeStamp
@@ -48,6 +56,7 @@ function startDrawLoop(
const elapsedTime = timestamp - lastDrawTimestamp;
setLastDrawTimestamp(timestamp);
scenePerf.avgFrameMs = scenePerf.avgFrameMs * 0.9 + elapsedTime * 0.1;
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
const activeToolSnapshot: MachineSnapshot<any, any, any, any, any, any, any, any> | undefined =
@@ -70,13 +79,26 @@ function startDrawLoop(
if (!screenCanvasDrawController) {
throw new Error('getScreenCanvasDrawController() returned null');
}
const { distance, entity: closestEntity } = findClosestEntity(
screenCanvasDrawController.getWorldMouseLocation(),
getEntities()
);
const mouseLocation = screenCanvasDrawController.getScreenMouseLocation();
const mouseMoved = mouseLocation.x !== lastHoverMouseX || mouseLocation.y !== lastHoverMouseY;
if (mouseMoved && timestamp - lastHoverCheckAt >= HOVER_THROTTLE_MS) {
lastHoverCheckAt = timestamp;
lastHoverMouseX = mouseLocation.x;
lastHoverMouseY = mouseLocation.y;
const worldMouseLocation = screenCanvasDrawController.getWorldMouseLocation();
const { distance, entity: closestEntity } = findClosestEntity(
worldMouseLocation,
// 공간 인덱스로 후보를 좁혀 O(전체) 스캔 제거
queryEntitiesNearPoint(
worldMouseLocation.x,
worldMouseLocation.y,
HIGHLIGHT_ENTITY_DISTANCE
)
);
if (distance < HIGHLIGHT_ENTITY_DISTANCE) {
setHighlightedEntityIds([closestEntity.id]);
if (distance < HIGHLIGHT_ENTITY_DISTANCE) {
setHighlightedEntityIds([closestEntity.id]);
}
}
}
+23 -4
View File
@@ -12,6 +12,7 @@ import {
} from './App.types';
import type { ScreenCanvasDrawController } from './drawControllers/screenCanvas.drawController';
import type { Entity } from './entities/Entity';
import { bumpSceneVersion } from './helpers/scene-version';
import { createStack, StateVariable, type UndoState } from './helpers/undo-stack';
import type { InputController } from './inputController/input-controller.ts'; // state variables
@@ -41,11 +42,13 @@ let entities: Entity[] = [];
* Entities that are highlighted: when the mouse is close to an entity
*/
let highlightedEntityIds: string[] = [];
let highlightedEntityIdSet: Set<string> = new Set();
/**
* Entities that are selected by the user by clicking on them with the select tool or by selecting them with a selection rectangle
*/
let selectedEntityIds: string[] = [];
let selectedEntityIdSet: Set<string> = new Set();
/**
* Whether to draw the cursor or not
@@ -162,6 +165,12 @@ let layers: Layer[] = [
*/
let activeLayerId: string = layers[0].id;
/**
* layerId Layer lookup, kept in sync with `layers` (drawEntities runs this
* lookup once per entity per frame a linear find() was a hot spot)
*/
let layersById: Map<string, Layer> = new Map(layers.map((layer) => [layer.id, layer]));
let snapEnabled = true;
let gridEnabled = false;
@@ -207,16 +216,18 @@ export const getInputController = (): InputController => {
};
export const getSelectedEntities = (): Entity[] => {
return entities.filter((e) => selectedEntityIds.includes(e.id));
return entities.filter((e) => selectedEntityIdSet.has(e.id));
};
export const getNotSelectedEntities = (): Entity[] => {
return entities.filter((e) => !selectedEntityIds.includes(e.id));
return entities.filter((e) => !selectedEntityIdSet.has(e.id));
};
export const isEntitySelected = (entity: Entity) => selectedEntityIds.includes(entity.id);
export const isEntityHighlighted = (entity: Entity) => highlightedEntityIds.includes(entity.id);
export const isEntitySelected = (entity: Entity) => selectedEntityIdSet.has(entity.id);
export const isEntityHighlighted = (entity: Entity) => highlightedEntityIdSet.has(entity.id);
export const getHighlightedEntityIds = () => highlightedEntityIds;
export const getLayers = () => {
return layers;
};
export const getLayerById = (layerId: string): Layer | undefined => layersById.get(layerId);
export const getActiveLayerId = (): string => {
return activeLayerId;
};
@@ -279,15 +290,19 @@ export const setEntities = (newEntities: Entity[], trackInUndoStack = false) =>
trackUndoState(StateVariable.entities, newEntities);
}
entities = newEntities;
bumpSceneVersion();
if (trackInUndoStack) {
window.dispatchEvent(new CustomEvent(HtmlEvent.DRAWING_CHANGED));
}
};
export const setHighlightedEntityIds = (newEntityIds: string[]) => {
highlightedEntityIds = newEntityIds;
highlightedEntityIdSet = new Set(newEntityIds);
};
export const setSelectedEntityIds = (newEntityIds: string[]) => {
selectedEntityIds = newEntityIds;
selectedEntityIdSet = new Set(newEntityIds);
bumpSceneVersion(); // selection style (dashed) is baked into the scene cache
window.dispatchEvent(new CustomEvent(HtmlEvent.UPDATE_STATE));
};
export const setShouldDrawCursor = (newValue: boolean) => {
@@ -374,6 +389,8 @@ export const setActiveTextStyle = (
};
export const setLayers = (newLayers: Layer[], triggerReact = true) => {
layers = newLayers;
layersById = new Map(newLayers.map((layer) => [layer.id, layer]));
bumpSceneVersion(); // layer visibility/lock affects what the scene cache shows
if (triggerReact) {
triggerReactUpdate(StateVariable.layers);
@@ -398,6 +415,7 @@ export const setSnapEnabled = (enabled: boolean) => {
};
export const setGridEnabled = (enabled: boolean) => {
gridEnabled = enabled;
bumpSceneVersion();
window.dispatchEvent(new CustomEvent(HtmlEvent.UPDATE_STATE));
};
export const setDesignMeta = (newMeta: DesignMeta | null) => {
@@ -462,6 +480,7 @@ function updateStates(undoState: UndoState) {
switch (variable) {
case StateVariable.entities:
entities = value;
bumpSceneVersion();
break;
}
}
+94
View File
@@ -0,0 +1,94 @@
import { CURRENT_PROJECT_ID_KEY, ROUTES, type RoutePath } from "@config/config_frontend";
import { createButton } from "@ui/ui_template_elements";
import { createGeneralLayout } from "@ui/ui_template_general_layout";
import { t } from "@ui/ui_template_locale";
import { createProgressCircle } from "@ui/ui_template_progress";
import {
markProjectPreloaded,
preloadSurfaceAssets,
purgeOtherProjects,
readPreloadTarget,
} from "../A00_Common/b_asset_cache";
import { navigateTo } from "../A00_Common/router";
import "./B11_Status_UI_Style.css";
/* =============================================================================
* (B11)
*
* , 3D
* . B04·B05가 .
* .
*
* ( · ) .
* ========================================================================== */
/** 준비에 실패한 채 그냥 넘어갔을 때 남기는 표식 — 어떤 확정 구성과도 일치하지 않는다. */
const PRELOAD_SKIPPED_SIGNATURE = "skipped";
export async function renderB11Loading(root: HTMLElement): Promise<void> {
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
const target = (readPreloadTarget() ?? ROUTES.B03_FILE_INPUT) as RoutePath;
const progress = createProgressCircle({ label: t("B11_Loading_Start"), size: 96 });
const message = document.createElement("p");
message.className = "b11-loading__message";
message.textContent = t("B11_Loading_Message");
const actions = document.createElement("div");
actions.className = "b11-loading__actions";
const body = document.createElement("div");
body.className = "b11-loading__body";
body.append(progress.root, message, actions);
const layout = createGeneralLayout({
pageClass: "b11-status",
title: t("B11_Loading_Title"),
subtitle: t("B11_Loading_Subtitle"),
content: [body],
});
root.replaceChildren(layout.root);
if (!projectId) {
showFailure(t("B11_Loading_NoProject"));
return;
}
try {
// 다른 프로젝트 자료가 남아 있으면 지운다 — 프로젝트끼리 섞이면 안 된다.
await purgeOtherProjects(projectId);
const signature = await preloadSurfaceAssets(projectId, (label, ratio) => {
progress.set(ratio, label);
});
// 표식은 담아 둔 구성 그대로 남긴다 — 확정이 바뀌면 다음 진입에서 다시 준비한다.
markProjectPreloaded(projectId, signature);
navigateTo(target);
} catch (error) {
const detail = error instanceof Error ? ` (${error.message})` : "";
showFailure(`${t("B11_Loading_Failed")}${detail}`);
}
function showFailure(text: string): void {
progress.root.hidden = true;
message.textContent = text;
message.classList.add("b11-loading__message--error");
actions.replaceChildren(
createButton({
label: t("B11_Loading_Btn_Continue"),
variant: "ghost",
// 준비를 못 했어도 같은 세션에서 다시 붙잡지 않도록 표시해 둔다.
// 확정 구성을 모르는 상태이므로 전용 표식을 남긴다 — 확정이 정상화되면 표식이
// 어긋나 준비 화면이 다시 뜬다.
onClick: () => {
if (projectId) markProjectPreloaded(projectId, PRELOAD_SKIPPED_SIGNATURE);
navigateTo(target);
},
}),
createButton({
label: t("B11_Loading_Btn_Dashboard"),
variant: "filled",
onClick: () => navigateTo(ROUTES.B01_ACCOUNT),
}),
);
}
}
+34
View File
@@ -26,6 +26,40 @@
color: var(--color-text-body);
}
/* 자료 준비 화면 — 서클과 안내 문구를 가운데 모아 둔다. */
.b11-loading__body {
display: flex;
min-height: 320px;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--spacing-24);
padding: var(--spacing-24);
}
.b11-loading__message {
max-width: 46ch;
margin: 0;
color: var(--color-text-secondary);
font-size: var(--text-body-sm);
line-height: 1.6;
text-align: center;
word-break: keep-all;
}
.b11-loading__message--error {
color: var(--color-danger);
}
.b11-loading__actions {
display: flex;
gap: var(--spacing-8);
}
.b11-loading__actions:empty {
display: none;
}
@media (max-width: 860px) {
.b11-status__flow {
grid-template-columns: 1fr 1fr;
+39
View File
@@ -0,0 +1,39 @@
"""파일 응답에 브라우저 캐시 검증(ETag)을 붙이는 공통 유틸.
3D 프리뷰·등고선처럼 만들면 바뀌지 않는 파일은, 브라우저가 이미 받아 것을
그대로 쓰게 해야 새로고침이 빠르다. 그렇다고 무조건 캐시를 믿게 두면 모델을 다시 만들었을
파일이 계속 보인다.
그래서 파일의 수정시각·크기로 ETag를 만들어 보내고, 브라우저가 같은 ETag를 들고 오면
본문 없이 304 돌려준다. 파일이 바뀌면 ETag가 저절로 달라져 파일을 받는다.
"""
from __future__ import annotations
from pathlib import Path
from fastapi import Request
from fastapi.responses import FileResponse, Response
# 항상 서버에 물어보되(변경 감지), 안 바뀌었으면 본문을 다시 받지 않는다.
CACHE_CONTROL = "private, max-age=0, must-revalidate"
def file_etag(path: Path) -> str:
"""파일 수정시각·크기로 만든 ETag. 파일이 바뀌면 값이 달라진다."""
stat = path.stat()
return f'"{int(stat.st_mtime)}-{stat.st_size}"'
def cached_file_response(
request: Request,
path: Path,
media_type: str,
filename: str | None = None,
) -> Response:
"""ETag를 붙인 파일 응답. 브라우저가 가진 것과 같으면 304만 돌려준다."""
etag = file_etag(path)
headers = {"ETag": etag, "Cache-Control": CACHE_CONTROL}
if request.headers.get("if-none-match") == etag:
return Response(status_code=304, headers=headers)
return FileResponse(path, media_type=media_type, filename=filename, headers=headers)
+255
View File
@@ -0,0 +1,255 @@
"""계획 노선 기하 공용 유틸 — 정점·누가거리·세류 교차점.
배수유역 분석(B04) 편집·세부유역(B05) 같은 노선 표현을 써야 하므로 여기 곳에만
정의한다. 어느 한쪽 페이지 폴더에 두면 반대 방향 import가 생긴다.
노선 원천은 가지다.
· B03에 업로드된 **계획 노선 파일**(CSV) 배수유역 분석의 입력
· DB `route_points` B05에서 탐색·확정한 노선
같은 `RouteVertex` 목록으로 바꿔 아래 함수들이 그대로 받는다.
"""
from __future__ import annotations
import csv
import logging
import math
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from shapely.geometry import LineString, Point, shape
logger = logging.getLogger(__name__)
# 계획 노선 CSV 열 이름 후보. B03이 여러 형식을 받게 되므로 흔한 표기를 모두 받아 준다.
_X_KEYS = ("x", "X", "", "easting", "EASTING")
_Y_KEYS = ("y", "Y", "", "northing", "NORTHING")
_Z_KEYS = ("z", "Z", "표고", "elevation", "ELEV")
_ORDER_KEYS = ("sequence", "order", "seq", "no", "번호")
_EPSG_KEYS = ("crs_epsg", "epsg", "EPSG")
@dataclass
class RouteVertex:
"""노선 폴리라인의 한 점. chainage는 시점 기준 누가거리(m)."""
x: float
y: float
z: float
chainage_m: float
@dataclass
class StructureCandidate:
"""관 매설 구조물 측점 후보."""
chainage_m: float
x: float
y: float
# "stream"=세류 교차, "spacing"=최대 간격 규칙 보충, "confirmed"=사용자 확정
reason: str
stream_name: str | None = None
@dataclass
class PlannedRoute:
"""계획 노선 파일에서 읽은 노선."""
vertices: list[RouteVertex]
epsg: int | None
name: str | None
source: Path
@property
def line(self) -> LineString:
return LineString([(vertex.x, vertex.y) for vertex in self.vertices])
def read_planned_route_csv(path: Path) -> PlannedRoute | None:
"""계획 노선 CSV를 읽어 정점 목록으로 바꾼다.
이름은 대소문자·한글 표기를 함께 받아 준다(B03이 여러 형식을 수용할 예정).
`sequence` 있으면 순서로 정렬하고, 없으면 파일에 적힌 순서를 그대로 쓴다.
"""
try:
with path.open("r", encoding="utf-8-sig", newline="") as file:
rows = list(csv.DictReader(file))
except (OSError, csv.Error, UnicodeDecodeError):
logger.warning("계획 노선 CSV를 읽지 못했습니다: %s", path)
return None
if not rows:
return None
epsg = _first_int(rows[0], _EPSG_KEYS)
name = _first_text(rows[0], ("route_name", "name", "노선명"))
parsed: list[tuple[float, float, float, float]] = [] # (정렬키, x, y, z)
for index, row in enumerate(rows):
x = _first_float(row, _X_KEYS)
y = _first_float(row, _Y_KEYS)
if x is None or y is None:
continue
order = _first_float(row, _ORDER_KEYS)
parsed.append(
(float(index) if order is None else order, x, y, _first_float(row, _Z_KEYS) or 0.0)
)
if len(parsed) < 2:
logger.warning("계획 노선 CSV에 좌표가 2점 미만입니다: %s", path)
return None
parsed.sort(key=lambda item: item[0])
vertices: list[RouteVertex] = []
cumulative = 0.0
previous: tuple[float, float] | None = None
for _, x, y, z in parsed:
if previous is not None:
cumulative += math.dist(previous, (x, y))
vertices.append(RouteVertex(x=x, y=y, z=z, chainage_m=cumulative))
previous = (x, y)
logger.info(
"계획 노선 %s: 정점 %d개, 연장 %.0fm, EPSG %s", path.name, len(vertices), cumulative, epsg
)
return PlannedRoute(vertices=vertices, epsg=epsg, name=name, source=path)
def find_planned_route_file(input_dir: Path) -> Path | None:
"""B03 입력 폴더에서 계획 노선 파일을 찾는다. 여러 개면 가장 최근 것."""
if not input_dir.exists():
return None
candidates = sorted(
input_dir.rglob("*.csv"), key=lambda item: item.stat().st_mtime, reverse=True
)
return candidates[0] if candidates else None
def build_route_vertices(points: list[dict[str, Any]]) -> list[RouteVertex]:
"""DB route_points 행을 누가거리가 채워진 정점 목록으로 바꾼다."""
vertices: list[RouteVertex] = []
cumulative = 0.0
previous: tuple[float, float] | None = None
for row in points:
x = float(row["x"])
y = float(row["y"])
z = float(row.get("z") or 0.0)
if previous is not None:
cumulative += math.dist(previous, (x, y))
chainage = row.get("chainage_m")
vertices.append(
RouteVertex(
x=x, y=y, z=z, chainage_m=float(chainage) if chainage is not None else cumulative
)
)
previous = (x, y)
return vertices
def interpolate_vertex(
vertices: list[RouteVertex], chainage_m: float
) -> tuple[float, float, float]:
"""누가거리 위치의 (x, y, z)를 선형 보간한다. 범위 밖은 끝점으로 당긴다."""
if not vertices:
return (0.0, 0.0, 0.0)
if chainage_m <= vertices[0].chainage_m:
return (vertices[0].x, vertices[0].y, vertices[0].z)
for previous, current in zip(vertices, vertices[1:]):
if chainage_m <= current.chainage_m:
span = current.chainage_m - previous.chainage_m
ratio = 0.0 if span <= 0 else (chainage_m - previous.chainage_m) / span
return (
previous.x + (current.x - previous.x) * ratio,
previous.y + (current.y - previous.y) * ratio,
previous.z + (current.z - previous.z) * ratio,
)
last = vertices[-1]
return (last.x, last.y, last.z)
def is_uphill_at(vertices: list[RouteVertex], chainage_m: float, window_m: float = 20.0) -> bool:
"""해당 위치가 오르막(절토부)인지 종단 계획선의 국소 기울기 부호로 판정한다."""
_, _, back_z = interpolate_vertex(vertices, max(0.0, chainage_m - window_m))
_, _, forward_z = interpolate_vertex(vertices, chainage_m + window_m)
return forward_z >= back_z
def find_stream_crossings(
vertices: list[RouteVertex],
stream_features: list[dict[str, Any]],
) -> list[StructureCandidate]:
"""노선 평면 선형과 세류선의 교차 지점을 누가거리 순으로 찾는다."""
if len(vertices) < 2:
return []
route_line = LineString([(vertex.x, vertex.y) for vertex in vertices])
candidates: list[StructureCandidate] = []
for feature in stream_features:
geometry = feature.get("geometry")
if not geometry:
continue
try:
stream = shape(geometry)
except Exception: # noqa: BLE001 - 손상된 피처는 건너뛴다
continue
if stream.is_empty:
continue
intersection = route_line.intersection(stream)
if intersection.is_empty:
continue
name = _stream_name(feature)
for point in _collect_points(intersection):
candidates.append(
StructureCandidate(
chainage_m=route_line.project(point),
x=point.x,
y=point.y,
reason="stream",
stream_name=name,
)
)
candidates.sort(key=lambda item: item.chainage_m)
return candidates
def _stream_name(feature: dict[str, Any]) -> str | None:
properties = feature.get("properties") or {}
for key in ("명칭", "하천명", "NAME", "name"):
value = properties.get(key)
if value:
return str(value)
return None
def _collect_points(geometry: Any) -> list[Point]:
"""교차 결과(Point/MultiPoint/LineString 등)에서 대표 점들을 뽑는다."""
if geometry.geom_type == "Point":
return [geometry]
if geometry.geom_type in {"MultiPoint", "GeometryCollection"}:
points: list[Point] = []
for part in geometry.geoms:
points.extend(_collect_points(part))
return points
# 선분끼리 겹쳐 선으로 나온 경우는 중점을 대표로 쓴다.
if geometry.geom_type in {"LineString", "MultiLineString"}:
return [geometry.interpolate(0.5, normalized=True)]
return []
def _first_text(row: dict[str, Any], keys: tuple[str, ...]) -> str | None:
for key in keys:
value = row.get(key)
if value not in (None, ""):
return str(value).strip()
return None
def _first_float(row: dict[str, Any], keys: tuple[str, ...]) -> float | None:
text = _first_text(row, keys)
if text is None:
return None
try:
return float(text)
except ValueError:
return None
def _first_int(row: dict[str, Any], keys: tuple[str, ...]) -> int | None:
value = _first_float(row, keys)
return None if value is None else int(value)
+9 -2
View File
@@ -16,6 +16,10 @@ export const API_BASE_URL = "/api";
/** API 요청 타임아웃 (ms) */
export const API_TIMEOUT_MS = 30_000;
/** (ms).
* . */
export const API_ANALYSIS_TIMEOUT_MS = 60_000;
/** B03~B09 워크플로우에서 사용할 현재 프로젝트 UUID 저장 키 */
export const CURRENT_PROJECT_ID_KEY = "frd_current_project_id";
@@ -37,8 +41,8 @@ export const PROGRESS_UPDATE_INTERVAL_MS = 10_000;
/** B03 업로드 Service Worker 번들 경로 */
export const SERVICE_WORKER_PATH = "/assets/B03_FileInput_ServiceWorker.js";
/** 허용 확장자 (지형/포인트클라우드/도면) */
export const UPLOAD_ALLOWED_EXT = [".las", ".laz", ".tif", ".tfw", ".prj", ".dxf"] as const;
/** 허용 확장자 (계획노선/지형/포인트클라우드/도면) */
export const UPLOAD_ALLOWED_EXT = [".csv", ".las", ".laz", ".tif", ".tfw", ".prj", ".dxf"] as const;
/* -----------------------------------------------------------------------------
* 3. WebCAD / 3D
@@ -88,6 +92,8 @@ export const ROUTES = {
B09_WF6_ESTIMATION: "b09-wf6-estimation",
B10_PAYMENT: "b10-payment",
B11_STATUS: "b11-status",
// 대시보드에서 B그룹으로 처음 들어갈 때 3D·등고선을 미리 받아 두는 준비 화면.
B11_LOADING: "b11-loading",
} as const;
export type RouteKey = keyof typeof ROUTES;
@@ -109,6 +115,7 @@ export const PROTECTED_ROUTES: readonly RoutePath[] = [
ROUTES.B09_WF6_ESTIMATION,
ROUTES.B10_PAYMENT,
ROUTES.B11_STATUS,
ROUTES.B11_LOADING,
];
/* -----------------------------------------------------------------------------
+91 -1
View File
@@ -48,7 +48,7 @@ DB_POOL_MAX = int(os.getenv("DB_POOL_MAX", "20"))
UPLOAD_MAX_MB = int(os.getenv("UPLOAD_MAX_MB", str(30 * 1024)))
UPLOAD_MAX_FILES = int(os.getenv("UPLOAD_MAX_FILES", "5"))
UPLOAD_CHUNK_SIZE_BYTES = int(os.getenv("UPLOAD_CHUNK_SIZE_BYTES", str(1024 * 1024 * 1024)))
UPLOAD_ALLOWED_EXT = [".las", ".laz", ".tif", ".tfw", ".prj", ".dxf"]
UPLOAD_ALLOWED_EXT = [".csv", ".las", ".laz", ".tif", ".tfw", ".prj", ".dxf"]
CHUNK_TEMP_DIR = os.getenv("CHUNK_TEMP_DIR", "B03_FileInput/chunks_temp")
CHUNK_RETENTION_HOURS = int(os.getenv("CHUNK_RETENTION_HOURS", "24"))
MERGE_TIMEOUT_SECONDS = int(os.getenv("MERGE_TIMEOUT_SECONDS", "3600"))
@@ -232,6 +232,87 @@ SKELETON_MAIN_RIDGE_ACC_THRESHOLD_CELLS = int(
SKELETON_NODE_SPACING_M = float(os.getenv("SKELETON_NODE_SPACING_M", "10.0"))
# ─────────────────────────────────────────────────────────────────────────
# 5-3-1. 배수유역 격자 해석 파라미터 (B05 WF2 — 2026-07-31 전면 재설계)
#
# 도엽 등고선 TIN 보간 → 웅덩이 채움 → D8 물 방향 → 도로에서 상류 BFS(포인터 더블링)
# 순서로 유역을 정한다. 능선을 따로 탐지하지 않는다 — 도로로 물이 도달하는지 여부가
# 유일한 판정 기준이며, 그 경계가 곧 능선이다.
# 라이다 DEM은 노선 주변만 커버해 유역 산정에 부족하므로 쓰지 않는다(사용자 지시).
# ─────────────────────────────────────────────────────────────────────────
# 해석 격자 한 변(m). 작을수록 정밀하나 셀 수가 제곱으로 늘어난다.
DRAINAGE_GRID_SIZE_M = float(os.getenv("DRAINAGE_GRID_SIZE_M", "1.0"))
# 1차 배수유역 반경(m). 도로 교차점 상류로 이어진 세류망과 계획 노선을 각각 이 반경으로
# 버퍼해 합친 범위가 1차 영역이며, 그 bbox가 해석 격자다.
# 노선 버퍼가 필요한 이유: 세류 교차가 없는 구간의 도로도 격자 안에 있어야 그 구간 사면이
# 유역으로 잡힌다. 세류망 선정이 정확해진 뒤 다시 포함했다(2026-07-31 사용자 지시).
DRAINAGE_INITIAL_RADIUS_M = float(os.getenv("DRAINAGE_INITIAL_RADIUS_M", "100.0"))
# 활성 셀이 격자 최외곽에 닿았을 때 한 번에 넓히는 폭(m).
DRAINAGE_EXPAND_STEP_M = float(os.getenv("DRAINAGE_EXPAND_STEP_M", "200.0"))
# 확장 반복 상한. 경계 링이 전부 비활성이 되면 그 전에 스스로 멈춘다(안전핀).
DRAINAGE_MAX_EXPAND_ROUNDS = int(os.getenv("DRAINAGE_MAX_EXPAND_ROUNDS", "6"))
# 최외곽 적색 셀 주변을 한 회차에 넓히는 폭(m). 좁을수록 유역 경계가 정밀하나 회차가 늘어난다.
DRAINAGE_RED_EXPAND_BAND_M = float(os.getenv("DRAINAGE_RED_EXPAND_BAND_M", "50.0"))
# 적색 확장 반복 상한. 새로 추가한 셀에 적색이 없으면 그 전에 스스로 멈춘다(안전핀).
DRAINAGE_RED_EXPAND_MAX_ROUNDS = int(os.getenv("DRAINAGE_RED_EXPAND_MAX_ROUNDS", "20"))
# 격자 셀 수 권장 상한. 넘으면 **경고만** 남기고 그대로 계산한다 — 격자 크기 자동 조절은
# 하지 않는다(2026-07-31 사용자 지시). 느리면 위 DRAINAGE_GRID_SIZE_M을 직접 올린다.
DRAINAGE_MAX_GRID_CELLS = int(os.getenv("DRAINAGE_MAX_GRID_CELLS", "16000000"))
# 도로 폭(m). 이 폭으로 노선을 격자에 구워 D8 흐름이 도로를 대각선으로 건너뛰지 못하게 한다.
DRAINAGE_ROAD_WIDTH_M = float(os.getenv("DRAINAGE_ROAD_WIDTH_M", "4.0"))
# 계획선 최저점보다 이만큼 아래인 등고선은 상류 기여가 불가능하므로 보간에서 제외한다.
DRAINAGE_CONTOUR_MARGIN_M = float(os.getenv("DRAINAGE_CONTOUR_MARGIN_M", "10.0"))
# 이보다 짧은 등고선 파편은 노이즈로 보고 버린다. 봉우리 폐합 등고선은 이 값 이상이면 남는다.
DRAINAGE_CONTOUR_MIN_LENGTH_M = float(os.getenv("DRAINAGE_CONTOUR_MIN_LENGTH_M", "20.0"))
# 등고선 정점 재샘플 간격(m). 조밀할수록 TIN이 정확하나 Delaunay 비용이 커진다.
DRAINAGE_CONTOUR_RESAMPLE_M = float(os.getenv("DRAINAGE_CONTOUR_RESAMPLE_M", "5.0"))
# TIN 삼각망을 만들 때 격자 범위 밖으로 남길 여유(m). 도엽 전체 등고선을 다 물면 삼각망
# 비용만 커지고 결과는 같다. 여유가 0이면 격자 가장자리가 TIN 밖으로 나가 NaN이 된다.
DRAINAGE_CONTOUR_CLIP_MARGIN_M = float(os.getenv("DRAINAGE_CONTOUR_CLIP_MARGIN_M", "100.0"))
# 평탄면 해소용 미세 경사(m/셀). 채움 후 흐름 방향이 없는 셀에 출구 쪽 경사를 만들어 준다.
DRAINAGE_FLAT_EPSILON_M = float(os.getenv("DRAINAGE_FLAT_EPSILON_M", "0.001"))
# 관 매설 최대 간격(m). 이 간격을 넘으면 흐름 강도가 가장 큰 지점에 관을 보충한다.
DRAINAGE_PIPE_MAX_SPACING_M = float(os.getenv("DRAINAGE_PIPE_MAX_SPACING_M", "300.0"))
# 관끼리 이보다 가까우면 같은 계곡으로 보고 하나로 합친다.
DRAINAGE_PIPE_MIN_SPACING_M = float(os.getenv("DRAINAGE_PIPE_MIN_SPACING_M", "20.0"))
# 측구 흐름(도로 셀 → 담당 관) 판정용 종단 계획선 샘플 간격(m).
DRAINAGE_DITCH_SAMPLE_M = float(os.getenv("DRAINAGE_DITCH_SAMPLE_M", "1.0"))
# 유역 폴리곤 단순화 허용오차(m). 격자 계단 경계를 매끄럽게 줄여 응답 크기를 낮춘다.
DRAINAGE_POLYGON_SIMPLIFY_M = float(os.getenv("DRAINAGE_POLYGON_SIMPLIFY_M", "2.0"))
# 이 면적(㎡) 미만의 유역 조각은 버린다(격자 노이즈 제거).
DRAINAGE_MIN_BASIN_AREA_M2 = float(os.getenv("DRAINAGE_MIN_BASIN_AREA_M2", "100.0"))
# 격자 해석 결과 캐시 파일명. 프로젝트 저장소의 B04_wf1_Surface/drainage/ 아래에 놓인다.
DRAINAGE_CACHE_DIRNAME = "drainage"
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"))
# 화살표끼리 최소 이 간격을 두고 솎아낸다. 촘촘하면 도면이 지저분해진다.
DRAINAGE_ARROW_SPACING_M = float(os.getenv("DRAINAGE_ARROW_SPACING_M", "40.0"))
# 블록 안에서 화살표를 낼 수 있는 셀이 이 비율 미만이면 건너뛴다(가장자리 조각 방지).
DRAINAGE_ARROW_MIN_COVERAGE = float(os.getenv("DRAINAGE_ARROW_MIN_COVERAGE", "0.5"))
# 방향 일치도 하한(원형 평균 결과 길이 0~1). 블록 안 방향이 제각각이면 평균이 무의미하므로 버린다.
DRAINAGE_ARROW_MIN_AGREEMENT = float(os.getenv("DRAINAGE_ARROW_MIN_AGREEMENT", "0.7"))
# 단계별 검증 산출물은 같은 폴더에 `{번호}_{단계}.geojson` + `manifest.json`으로 쌓인다.
# 파일명 규칙은 B05_wf2_Route_Engine_Watershed_Export.STAGES가 유일한 정의처다.
# ─────────────────────────────────────────────────────────────────────────
# 5-4. 종횡단 생성 파라미터 (B06 WF3)
# ─────────────────────────────────────────────────────────────────────────
@@ -435,6 +516,15 @@ STORAGE_BASE_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "sto
MAP_SHEETS_DIRNAME = "map_sheets"
MAP_SHEETS_INDEX_FILENAME = "map_sheets_index.json"
# 배경 지도(위성·하이브리드·백지도) 확보 범위 = 계획노선이 걸치는 **기준 도엽**의 도곽.
# 1:5,000 도엽 1매는 약 2.2km × 2.3km다. 주변 도엽은 받지 않는다(2026-08-01 사용자 지시).
#
# 도엽 1매를 zoom 18로 받으면 한 변이 19타일(4,864px)이라 파일이 지나치게 커진다.
# 아래 한도 안에 들어오는 가장 선명한 zoom을 자동으로 고른다(브이월드 타일 눈금 그대로).
SURFACE_MAP_MAX_TILES_PER_SIDE = 16
SURFACE_MAP_MAX_ZOOM = 18
SURFACE_MAP_MIN_ZOOM = 14
# 브이월드 지도서비스 도엽 자동 다운로드 — 세션 만료 시 id/pw로 자동 재로그인 (.env)
VWORLD_LOGIN_ID = os.getenv("VWORLD_LOGIN_ID", "")
VWORLD_LOGIN_PW = os.getenv("VWORLD_LOGIN_PW", "")
+4
View File
@@ -31,7 +31,9 @@ from B04_wf1_Surface.B04_wf1_Surface_Router import router as b04_surface_router
from B04_wf1_Surface.B04_wf1_Surface_Router_Contour import router as b04_surface_contour_router
from B04_wf1_Surface.B04_wf1_Surface_Router_GIS import router as b04_surface_gis_router
from B04_wf1_Surface.B04_wf1_Surface_Router_GIS import tiles_router
from B04_wf1_Surface.B04_wf1_Surface_Router_Watershed import router as b04_watershed_router
from B05_wf2_Route.B05_wf2_Route_Router import router as b05_route_router
from B05_wf2_Route.B05_wf2_Route_Router_Drainage import router as b05_drainage_router
from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Router import router as b06_section_router
from B07_wf4_DesignDetail.B07_wf4_DesignDetail_Router import router as b07_design_router
from common_util.common_util_auth import require_company, verify_session
@@ -272,8 +274,10 @@ app.include_router(b03_file_input_router, dependencies=protected_with_company)
app.include_router(b04_surface_router, dependencies=protected_with_company)
app.include_router(b04_surface_contour_router, dependencies=protected_with_company)
app.include_router(b04_surface_gis_router, dependencies=protected_with_company)
app.include_router(b04_watershed_router, dependencies=protected_with_company)
app.include_router(tiles_router, dependencies=protected_with_company)
app.include_router(b05_route_router, dependencies=protected_with_company)
app.include_router(b05_drainage_router, dependencies=protected_with_company)
app.include_router(b06_section_router, dependencies=protected_with_company)
app.include_router(b07_design_router, dependencies=protected_with_company)
+2
View File
@@ -1,6 +1,8 @@
[tool.ruff]
target-version = "py313"
line-length = 100
# 폐기 엔진 보관 폴더는 이동 당시 원본 그대로 두기로 했으므로 린트/포맷 대상에서 뺀다.
extend-exclude = ["B05_wf2_Route/_legacy_watershed"]
[tool.ruff.lint]
select = ["E", "F", "I"]
+28 -7
View File
@@ -527,13 +527,13 @@ export const ui_locales = {
/* --- B03_FileInput 파일 입력 --- */
B03_File_Title: ["파일 입력", "File Input"],
B03_File_Subtitle: [
"지형·포인트클라우드·도면 파일을 업로드하세요.",
"Upload terrain, point cloud, and drawing files.",
"필수 계획노선과 지형·포인트클라우드 파일을 업로드하세요.",
"Upload the required planned route, terrain, and point cloud files.",
],
B03_File_Select_Label: ["입력 파일 선택", "Select input files"],
B03_File_Select_Hint: [
"LAS/LAZ 1개를 포함해 관련 PRJ, TFW, TIF 또는 도면 파일을 선택하세요.",
"Select exactly one LAS/LAZ file with related PRJ, TFW, TIF, or drawing files.",
"계획노선 CSV, LAS/LAZ 1개, PRJ, TFW를 선택하세요. TIF는 선택 사항입니다.",
"Select a planned-route CSV, one LAS/LAZ, PRJ, and TFW. TIF is optional.",
],
B03_File_Selected_Title: ["선택한 파일", "Selected files"],
B03_File_Selected_Empty: ["선택한 파일이 없습니다.", "No files selected."],
@@ -566,6 +566,9 @@ export const ui_locales = {
B03_File_Result_Path: ["저장 경로", "Stored path"],
B03_File_Group_Required: ["필수 파일", "Required files"],
B03_File_Group_Optional: ["선택 파일", "Optional files"],
B03_File_Group_Route: ["원청 계획노선 (필수)", "Client Planned Route (Required)"],
B03_File_Group_Terrain: ["지형 분석자료", "Terrain Analysis Files"],
B03_File_Slot_PlannedRoute: ["계획노선 좌표", "Planned Route Coordinates"],
B03_File_Slot_PointCloud: ["포인트클라우드", "Point Cloud"],
B03_File_Slot_Projection: ["좌표계 정의", "Projection"],
B03_File_Slot_RasterCoord: ["래스터 좌표", "Raster Coord 1"],
@@ -578,8 +581,8 @@ export const ui_locales = {
"A file for this slot is already selected.",
],
B03_File_Error_RequiredSlots: [
"필수 파일(LAS/LAZ, PRJ, TFW)을 모두 선택하세요.",
"Select all required files: LAS/LAZ, PRJ, and TFW.",
"필수 파일(계획노선 CSV, LAS/LAZ, PRJ, TFW)을 모두 선택하세요.",
"Select all required files: planned-route CSV, LAS/LAZ, PRJ, and TFW.",
],
B03_File_Error_SlotType: [
"선택한 파일 유형이 이 카드와 맞지 않습니다.",
@@ -659,7 +662,7 @@ export const ui_locales = {
B04_Surface_Map_Contour: ["등고선", "Contour lines"],
B04_Surface_Map_SheetContour: ["도엽 등고선", "Sheet contours"],
B04_Surface_Map_ContourLabel: ["등고 라벨", "Contour labels"],
B04_Surface_Map_SheetStream: ["세류(하천중심선)", "Stream centerline"],
B04_Surface_Map_SheetStream: ["세류(전체)", "Streams (all)"],
B04_Surface_Map_SheetElevPoint: ["표고점", "Spot elevation"],
B04_Surface_Map_SheetCutFill: ["성절토", "Cut/fill slope"],
B04_Surface_Map_SheetWall: ["옹벽석축", "Retaining wall"],
@@ -985,6 +988,24 @@ export const ui_locales = {
"결재·문서 생성 상태를 확인하고 결과물을 내려받으세요.",
"Check payment and document status, and download results.",
],
// 자료 준비 화면 (대시보드 → 작업 화면 진입 시 3D·등고선 선적재)
B11_Loading_Title: ["자료 준비 중", "Preparing data"],
B11_Loading_Subtitle: ["잠시만 기다려 주세요.", "This will take a moment."],
B11_Loading_Message: [
"작업 화면에서 바로 쓸 수 있도록 3D 지표면과 등고선을 준비합니다.",
"Loading the 3D surface and contours so the workspace opens instantly.",
],
B11_Loading_Start: ["자료를 준비하는 중…", "Preparing data…"],
B11_Loading_NoProject: [
"프로젝트가 선택되지 않았습니다. 대시보드에서 프로젝트를 먼저 고르세요.",
"No project selected. Choose a project on the dashboard first.",
],
B11_Loading_Failed: [
"자료를 준비하지 못했습니다. 분석 결과나 저장 경로에 문제가 있을 수 있습니다. 담당자에게 연락해 주세요.",
"Could not prepare the data. The analysis result or storage path may be broken. Please contact support.",
],
B11_Loading_Btn_Continue: ["그래도 화면으로 이동", "Continue anyway"],
B11_Loading_Btn_Dashboard: ["대시보드로", "Back to dashboard"],
B11_Status_Flow_Title: ["결재 진행 상태", "Payment Progress"],
B11_Status_Step_Request: ["발행 요청", "Invoice Requested"],
B11_Status_Step_Issue: ["세금계산서 발행", "Invoice Issued"],
+88
View File
@@ -0,0 +1,88 @@
/* 공통 프로그레스 서클 뷰포트 위에 얹는 원형 진행 표시.
테두리 두께·색은 공통 로딩 스피너(.ui-spinner) 같게 맞추고, 가운데에 진행률만 더한다. */
.ui-progress-circle {
--ui-progress-size: 72px;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-8);
pointer-events: none;
user-select: none;
}
/* 컨테이너 정중앙 오버레이 3D 뷰포트·지도·그래프 어디에나 같은 방식으로 얹는다.
컨테이너에 position: relative 있어야 한다. */
.ui-progress-circle--overlay {
position: absolute;
z-index: 1;
inset: 0;
justify-content: center;
}
.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: var(--color-mist-violet);
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;
}
+91
View File
@@ -0,0 +1,91 @@
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). 기본 72(공통 로딩 스피너와 같은 무게감). */
size?: number;
/** 서클 아래 안내 문구. */
label?: string;
/** 컨테이너 정중앙에 띄우는 오버레이로 만든다(컨테이너는 position: relative 여야 한다). */
overlay?: boolean;
}
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" + (options.overlay ? " ui-progress-circle--overlay" : "");
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(),
};
}