merge: feature/B07-상세설계-기능-개발 → main (B04/B05 지도·배수유역 개선, B11 자료 준비 화면, 3D 조작감·성능 개선)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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(
|
||||
|
||||
@@ -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]
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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"));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
+11
-3
@@ -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);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
@@ -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", "")
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user