This commit is contained in:
2026-07-18 11:48:57 +09:00
parent f847ea700f
commit 516c78ec7e
11 changed files with 527 additions and 283 deletions
+1
View File
@@ -34,6 +34,7 @@ dist/
storage/
0_old/
docs/
graphify-out/
# 로그 파일
*.log
@@ -14,7 +14,7 @@ from scipy.interpolate import RBFInterpolator, RectBivariateSpline
from skimage import measure
# 등고선 캐시 형식/추출 규칙이 바뀔 때 증가시킨다.
CONTOUR_EXTRACTOR_VERSION = 3
CONTOUR_EXTRACTOR_VERSION = 4
def extract_contours_from_grid(
@@ -189,17 +189,32 @@ def _tin_face_coverage_mask(
if not len(boundary_edges):
return np.zeros(xx.shape, dtype=bool)
from shapely import get_parts, intersects_xy, linestrings, polygonize, union_all
from shapely import get_parts, linestrings, polygonize
import rasterio.features
import affine
boundary_lines = linestrings(vertices[boundary_edges, :2])
polygons = list(get_parts(polygonize(boundary_lines)))
if not polygons:
return np.zeros(xx.shape, dtype=bool)
coverage = union_all(polygons)
xx_flat = np.asarray(xx, dtype=np.float64).ravel()
yy_flat = np.asarray(yy, dtype=np.float64).ravel()
res_flat = np.asarray(intersects_xy(coverage, xx_flat, yy_flat), dtype=bool)
return res_flat.reshape(xx.shape)
x_coords = xx[0, :]
y_coords = yy[:, 0]
dx = float(x_coords[1] - x_coords[0]) if len(x_coords) > 1 else 1.0
dy = float(y_coords[1] - y_coords[0]) if len(y_coords) > 1 else 1.0
transform = affine.Affine(dx, 0.0, x_coords[0] - dx / 2.0,
0.0, dy, y_coords[0] - dy / 2.0)
mask = rasterio.features.rasterize(
polygons,
out_shape=xx.shape,
transform=transform,
fill=0,
default_value=1,
dtype='uint8'
)
return mask.astype(bool)
def _grid_axes(x_min: float, x_max: float, y_min: float, y_max: float, target_grid_m: float):
@@ -210,6 +225,26 @@ def _grid_axes(x_min: float, x_max: float, y_min: float, y_max: float, target_gr
return x_coords, y_coords
def adaptive_contour_grid_resolution(
model_npz_path: Path, representation: str, minimum_meters: float = 1.0
) -> float:
"""모델 범위가 넓어도 온디맨드 격자가 10만 셀을 넘지 않게 조정한다."""
with np.load(model_npz_path) as data:
if representation == "regular_grid":
x_values, y_values = data["x"], data["y"]
elif representation == "triangular_mesh":
x_values, y_values = data["vertices"][:, 0], data["vertices"][:, 1]
elif representation == "bspline_surface":
x_values, y_values = data["control_x"], data["control_y"]
elif representation == "local_rbf_height_field":
x_values, y_values = data["centers_xy"][:, 0], data["centers_xy"][:, 1]
else:
x_values, y_values = data["points"][:, 0], data["points"][:, 1]
width = float(np.ptp(x_values))
height = float(np.ptp(y_values))
return max(minimum_meters, float(np.sqrt(width * height / 100_000)))
def extract_contours(
model_npz_path: Path,
representation: str,
@@ -270,7 +305,7 @@ def extract_contours(
control_z,
kx=min(degree, len(control_y) - 1),
ky=min(degree, len(control_x) - 1),
s=float(len(control_x) * len(control_y)) * 0.01,
s=0,
)
x_coords, y_coords = _grid_axes(
float(control_x[0]),
@@ -6,6 +6,7 @@
"""
import json
import logging
import threading
import time
from pathlib import Path
@@ -33,6 +34,7 @@ from common_util.common_util_json import atomic_write_json
# 진행률 콜백: (overall_percent, detail_message)
ProgressReporter = Callable[[int, str], None]
logger = logging.getLogger(__name__)
# 같은 프로세스에서 동일 프로젝트 계산 요청이 겹치면 두 번째 요청을 즉시 취소.
_ACTIVE_TERRAIN_BUILDS: set[str] = set()
@@ -54,7 +56,7 @@ def _cache_contours(
metadata: dict[str, Any],
) -> None:
"""빌드 완료 직후 기본 간격 등고선을 사전 추출·캐싱한다 (원본 + 스무딩)."""
interval = float(config.get("contour_interval_meters", 5.0))
interval = float(config.get("contour_interval_meters", 1.0))
target_grid_m = float(config.get("contour_grid_resolution_meters", 1.0))
model_path = output_dir / f"{stem}.npz"
@@ -106,6 +108,37 @@ def _cache_contours(
)
def _cache_contours_safely(
output_dir: Path,
stem: str,
filter_key: str,
method: str,
representation: str,
config: dict[str, Any],
bounds_info: dict[str, Any],
metadata: dict[str, Any],
) -> None:
"""등고선 사전 캐시 실패를 기록하되 모델 빌드는 유지한다."""
try:
_cache_contours(
output_dir,
stem,
filter_key,
method,
representation,
config,
bounds_info,
metadata,
)
except Exception as exc:
logger.warning(
"등고선 사전 캐시 실패: filter=%s, method=%s, error=%s",
filter_key,
method,
exc,
)
_REPRESENTATIONS = {
"meshfree": "meshfree_surfels",
"dtm": "regular_grid",
@@ -152,7 +185,11 @@ def _build_all_terrain_models(
output_dir.mkdir(parents=True, exist_ok=True)
manifest_path = output_dir / "manifest.json"
filters = tuple(key for key in config["source_filters"] if key in ground_masks)
methods = tuple(key for key in config["precompute"] if key in BUILDERS)
methods_list = [key for key in config["precompute"] if key in BUILDERS]
if "dtm" in methods_list:
methods_list.remove("dtm")
methods_list.insert(0, "dtm")
methods = tuple(methods_list)
signature = config_signature(config)
bounds = np.asarray(structured_data["bounds"], dtype=np.float64)
xyz = structured_data["xyz"]
@@ -213,6 +250,19 @@ def _build_all_terrain_models(
)
filter_entry["methods"][method] = entry
_write_json_file(manifest_path, manifest)
interval = float(config.get("contour_interval_meters", 1.0))
contour_path = output_dir / f"contour_{filter_key}_{method}_{interval}m.json"
if not contour_path.exists():
_cache_contours_safely(
output_dir,
stem,
filter_key,
method,
entry.get("representation", _REPRESENTATIONS[method]),
config,
manifest.get("bounds", {}),
entry,
)
done_units += 1
_report(f"{filter_key}-{method} 캐시 재사용")
continue
@@ -258,19 +308,16 @@ def _build_all_terrain_models(
metadata["smooth"] = {"status": "failed", "error": str(smooth_exc)}
filter_entry["methods"][method] = metadata
try:
_cache_contours(
output_dir,
stem,
filter_key,
method,
metadata.get("representation", "regular_grid"),
config,
manifest.get("bounds", {}),
metadata,
)
except Exception:
pass # 등고선 사전 캐시는 실패해도 모델 빌드를 무효화하지 않는다.
_cache_contours_safely(
output_dir,
stem,
filter_key,
method,
metadata.get("representation", "regular_grid"),
config,
manifest.get("bounds", {}),
metadata,
)
except Exception as exc:
failures += 1
filter_entry["methods"][method] = {
-156
View File
@@ -3,7 +3,6 @@
import asyncio
import json
import logging
import math
from pathlib import Path
from typing import Any
from uuid import UUID
@@ -19,10 +18,6 @@ from B04_wf1_Surface.B04_wf1_Surface_Engine import (
cache_ground_points,
run_surface_analysis,
)
from B04_wf1_Surface.B04_wf1_Surface_Engine_Contour import (
CONTOUR_EXTRACTOR_VERSION,
extract_contours,
)
from B04_wf1_Surface.B04_wf1_Surface_Repository import (
clear_confirmed_surface_models,
confirm_surface_model,
@@ -43,7 +38,6 @@ from B04_wf1_Surface.B04_wf1_Surface_Schema import (
SurfaceModelSummary,
SurfacePointCloudSampleResponse,
)
from common_util.common_util_atomic import atomic_write_bytes
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_workflow_state import (
@@ -57,13 +51,6 @@ from config.config_db import get_db_pool
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/projects", tags=["B04 Surface Analysis"])
POINT_CLOUD_SAMPLE_LIMIT = 500_000
MODEL_REPRESENTATIONS = {
"meshfree": "meshfree_surfels",
"dtm": "regular_grid",
"tin": "triangular_mesh",
"nurbs": "bspline_surface",
"implicit": "local_rbf_height_field",
}
# 분석 진행률 파일: B04 산출 폴더 아래에 원자적으로 기록/조회한다.
PROGRESS_FILE_RELATIVE = ("B04_wf1_Surface", "processed", "progress.json")
@@ -517,14 +504,12 @@ async def get_surface_model_preview(
content={"status": "error", "message": "해당 모델을 찾을 수 없습니다."},
)
model_type, model_file_path = row[0], row[1]
project_root = Path(resolve_stored_project_path(stored_path))
if not model_file_path:
return JSONResponse(
status_code=404,
content={"status": "error", "message": "모델 파일 경로가 없습니다."},
)
model_path = project_root / model_file_path
models_dir = model_path.parent
stem = model_path.stem
@@ -561,144 +546,3 @@ async def get_surface_model_preview(
status_code=500,
content={"status": "error", "message": "프리뷰 파일 조회 중 오류가 발생했습니다."},
)
@router.get("/{project_id}/surface/models/{model_id}/contour", response_model=None)
async def get_surface_model_contour(
project_id: UUID,
model_id: int,
interval: float = 5.0,
smooth: bool = False,
) -> FileResponse | JSONResponse:
"""지표면 모델의 등고선 JSON 파일을 반환한다."""
pool = get_db_pool()
try:
async with pool.acquire() as connection:
stored_path = await get_project_storage_relative_path(connection, project_id)
async with connection.cursor() as cursor:
await cursor.execute(
"""
SELECT model_type, model_file_path
FROM surface_models
WHERE id = %s AND project_id = %s
""",
(model_id, str(project_id)),
)
row = await cursor.fetchone()
if not row:
return JSONResponse(
status_code=404,
content={"status": "error", "message": "해당 모델을 찾을 수 없습니다."},
)
model_type, model_file_path = row[0], row[1]
project_root = Path(resolve_stored_project_path(stored_path))
if not model_file_path:
return JSONResponse(
status_code=404,
content={"status": "error", "message": "모델 파일 경로가 없습니다."},
)
model_path = project_root / model_file_path
models_dir = model_path.parent
structured_path = project_root / "B04_wf1_Surface" / "processed" / "structured.npz"
stem = model_path.stem
parts = stem.split("_")
if len(parts) >= 2:
method = parts[0]
filter_key = "_".join(parts[1:])
else:
method = model_type
filter_key = "csf"
if smooth and method in ("dtm", "tin"):
contour_filename = f"contour_{filter_key}_{method}_smooth_{interval}m.json"
else:
contour_filename = f"contour_{filter_key}_{method}_{interval}m.json"
contour_path = models_dir / contour_filename
if not contour_path.is_file():
if not math.isfinite(interval) or interval < 0.5:
return JSONResponse(
status_code=400,
content={"status": "error", "message": "등고선 간격은 0.5m 이상이어야 합니다."},
)
contour_model_path = (
models_dir / f"{stem}_smooth.npz"
if smooth and method in ("dtm", "tin")
else model_path
)
representation = (
"regular_grid"
if smooth and method == "dtm"
else "triangular_mesh"
if smooth and method == "tin"
else MODEL_REPRESENTATIONS.get(method)
)
if not contour_model_path.is_file() or representation is None:
return JSONResponse(
status_code=404,
content={
"status": "error",
"message": "등고선 생성에 필요한 모델 파일이 없습니다.",
},
)
contours = await asyncio.to_thread(
extract_contours,
contour_model_path,
representation,
interval,
1.0,
None,
)
with np.load(contour_model_path) as model_data:
if "bounds" in model_data:
model_bounds = np.asarray(model_data["bounds"], dtype=float)
bounds_payload = {
"x": model_bounds[0].tolist(),
"y": model_bounds[1].tolist(),
"z": model_bounds[2].tolist(),
}
else:
with np.load(structured_path) as structured:
model_bounds = np.asarray(structured["bounds"], dtype=float)
bounds_payload = {
"x": model_bounds[0].tolist(),
"y": model_bounds[1].tolist(),
"z": model_bounds[2].tolist(),
}
payload = {
"extractor_version": CONTOUR_EXTRACTOR_VERSION,
"project_id": str(project_id),
"source_filter": filter_key,
"method": method,
"interval": interval,
"bounds": bounds_payload,
"contours": contours,
}
atomic_write_bytes(
contour_path,
json.dumps(payload, ensure_ascii=False).encode("utf-8"),
)
if not contour_path.is_file():
return JSONResponse(
status_code=404,
content={
"status": "error",
"message": "등고선 파일이 생성되지 않았거나 존재하지 않습니다.",
},
)
return FileResponse(contour_path, media_type="application/json", filename=contour_filename)
except Exception:
logger.exception(
"지표면 모델 등고선 조회 실패: project_id=%s, model_id=%s", project_id, model_id
)
return JSONResponse(
status_code=500,
content={"status": "error", "message": "등고선 파일 조회 중 오류가 발생했습니다."},
)
@@ -0,0 +1,194 @@
"""B04 지표면 모델 등고선 FastAPI 라우터 (700줄 규정에 따라 본 라우터에서 분리)."""
import asyncio
import json
import logging
import math
import re
import time
from pathlib import Path
from uuid import UUID
import numpy as np
from fastapi import APIRouter
from fastapi.responses import FileResponse, JSONResponse
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from B04_wf1_Surface.B04_wf1_Surface_Engine_Contour import (
CONTOUR_EXTRACTOR_VERSION,
adaptive_contour_grid_resolution,
extract_contours,
)
from common_util.common_util_atomic import atomic_write_bytes
from common_util.common_util_storage import resolve_stored_project_path
from config.config_db import get_db_pool
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/projects", tags=["B04 Surface Contour"])
MODEL_REPRESENTATIONS = {
"meshfree": "meshfree_surfels",
"dtm": "regular_grid",
"tin": "triangular_mesh",
"nurbs": "bspline_surface",
"implicit": "local_rbf_height_field",
}
def _is_contour_cache_current(contour_path: Path) -> bool:
"""캐시 JSON 선두의 extractor_version이 현재 추출기 버전과 일치하는지 검사한다."""
try:
with contour_path.open("rb") as cache_file:
head = cache_file.read(256).decode("utf-8", errors="ignore")
except OSError:
return False
match = re.search(r'"extractor_version"\s*:\s*(\d+)', head)
return bool(match) and int(match.group(1)) == CONTOUR_EXTRACTOR_VERSION
@router.get("/{project_id}/surface/models/{model_id}/contour", response_model=None)
async def get_surface_model_contour(
project_id: UUID,
model_id: int,
interval: float = 1.0,
smooth: bool = False,
recalculate: bool = False,
) -> FileResponse | JSONResponse:
"""지표면 모델의 등고선 JSON 파일을 반환한다."""
pool = get_db_pool()
try:
async with pool.acquire() as connection:
stored_path = await get_project_storage_relative_path(connection, project_id)
async with connection.cursor() as cursor:
await cursor.execute(
"""
SELECT model_type, model_file_path
FROM surface_models
WHERE id = %s AND project_id = %s
""",
(model_id, str(project_id)),
)
row = await cursor.fetchone()
if not row:
return JSONResponse(
status_code=404,
content={"status": "error", "message": "해당 모델을 찾을 수 없습니다."},
)
model_type, model_file_path = row[0], row[1]
project_root = Path(resolve_stored_project_path(stored_path))
if not model_file_path:
return JSONResponse(
status_code=404,
content={"status": "error", "message": "모델 파일 경로가 없습니다."},
)
model_path = project_root / model_file_path
models_dir = model_path.parent
structured_path = project_root / "B04_wf1_Surface" / "processed" / "structured.npz"
stem = model_path.stem
parts = stem.split("_")
if len(parts) >= 2:
method = parts[0]
filter_key = "_".join(parts[1:])
else:
method = model_type
filter_key = "csf"
if smooth and method in ("dtm", "tin"):
contour_filename = f"contour_{filter_key}_{method}_smooth_{interval}m.json"
else:
contour_filename = f"contour_{filter_key}_{method}_{interval}m.json"
contour_path = models_dir / contour_filename
if recalculate or not _is_contour_cache_current(contour_path):
if not math.isfinite(interval) or interval < 0.5:
return JSONResponse(
status_code=400,
content={"status": "error", "message": "등고선 간격은 0.5m 이상이어야 합니다."},
)
contour_model_path = (
models_dir / f"{stem}_smooth.npz"
if smooth and method in ("dtm", "tin")
else model_path
)
representation = (
"regular_grid"
if smooth and method == "dtm"
else "triangular_mesh"
if smooth and method == "tin"
else MODEL_REPRESENTATIONS.get(method)
)
if not contour_model_path.is_file() or representation is None:
return JSONResponse(
status_code=404,
content={
"status": "error",
"message": "등고선 생성에 필요한 모델 파일이 없습니다.",
},
)
generation_started = time.monotonic()
contours = await asyncio.to_thread(
extract_contours,
contour_model_path,
representation,
interval,
adaptive_contour_grid_resolution(contour_model_path, representation),
None,
)
logger.info(
"등고선 온디맨드 계산: filter=%s, method=%s, smooth=%s, "
"interval=%.1f, duration=%.1fs",
filter_key,
method,
smooth,
interval,
time.monotonic() - generation_started,
)
with np.load(contour_model_path) as model_data:
if "bounds" in model_data:
model_bounds = np.asarray(model_data["bounds"], dtype=float)
bounds_payload = {
"x": model_bounds[0].tolist(),
"y": model_bounds[1].tolist(),
"z": model_bounds[2].tolist(),
}
else:
with np.load(structured_path) as structured:
model_bounds = np.asarray(structured["bounds"], dtype=float)
bounds_payload = {
"x": model_bounds[0].tolist(),
"y": model_bounds[1].tolist(),
"z": model_bounds[2].tolist(),
}
payload = {
"extractor_version": CONTOUR_EXTRACTOR_VERSION,
"project_id": str(project_id),
"source_filter": filter_key,
"method": method,
"interval": interval,
"bounds": bounds_payload,
"contours": contours,
}
atomic_write_bytes(
contour_path,
json.dumps(payload, ensure_ascii=False).encode("utf-8"),
)
if not contour_path.is_file():
return JSONResponse(
status_code=404,
content={
"status": "error",
"message": "등고선 파일이 생성되지 않았거나 존재하지 않습니다.",
},
)
return FileResponse(contour_path, media_type="application/json", filename=contour_filename)
except Exception:
logger.exception(
"지표면 모델 등고선 조회 실패: project_id=%s, model_id=%s", project_id, model_id
)
return JSONResponse(
status_code=500,
content={"status": "error", "message": "등고선 파일 조회 중 오류가 발생했습니다."},
)
+142 -76
View File
@@ -27,17 +27,23 @@ type GeoJsonCollection = {
features?: GeoJsonFeature[];
};
const BACKGROUND_LAYERS = ["white", "satellite", "hybrid"] as const;
const GIS_LAYERS = ["지적도", "수계망", "산사태", "행정구역_시군구", "행정구역_읍면동"] as const;
type BackgroundLayer = (typeof BACKGROUND_LAYERS)[number];
type GisLayer = (typeof GIS_LAYERS)[number];
const GIS_LAYER_COLORS: Record<GisLayer, string> = {
: "#f97316",
: "#0ea5e9",
: "#ef4444",
_시군구: "#7c3aed",
_읍면동: "#22c55e",
};
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
function makeOption(value: string, label: string): HTMLOptionElement {
const option = document.createElement("option");
option.value = value;
option.textContent = label;
return option;
}
export function createSurfaceMapViewer(): SurfaceMapViewer {
const root = document.createElement("section");
root.className = "b04-map";
@@ -49,44 +55,38 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
const controls = document.createElement("div");
controls.className = "b04-map__controls";
const backgroundLabel = document.createElement("label");
backgroundLabel.textContent = L("B04_Surface_Map_Background");
const backgroundSelect = document.createElement("select");
backgroundSelect.append(
makeOption("none", L("B04_Surface_Map_None")),
makeOption("satellite", L("B04_Surface_Map_Satellite")),
makeOption("hybrid", L("B04_Surface_Map_Hybrid")),
makeOption("white", L("B04_Surface_Map_White")),
);
backgroundSelect.value = "satellite";
backgroundLabel.append(backgroundSelect);
const backgroundGroup = document.createElement("div");
backgroundGroup.className = "b04-map__control-group";
const backgroundTitle = document.createElement("span");
backgroundTitle.textContent = L("B04_Surface_Map_Background");
const backgroundButtons = document.createElement("div");
backgroundButtons.className = "b04-map__layer-buttons";
backgroundGroup.append(backgroundTitle, backgroundButtons);
const gisLabel = document.createElement("label");
gisLabel.textContent = L("B04_Surface_Map_GisLayer");
const gisSelect = document.createElement("select");
gisSelect.append(
makeOption("none", L("B04_Surface_Map_None")),
makeOption("지적도", L("B04_Surface_Map_Cadastral")),
makeOption("수계망", L("B04_Surface_Map_Water")),
makeOption("산사태", L("B04_Surface_Map_Landslide")),
makeOption("행정구역_시군구", L("B04_Surface_Map_Sigungu")),
makeOption("행정구역_읍면동", L("B04_Surface_Map_Eupmyeondong")),
);
gisSelect.value = "지적도";
gisLabel.append(gisSelect);
const gisGroup = document.createElement("div");
gisGroup.className = "b04-map__control-group";
const gisTitle = document.createElement("span");
gisTitle.textContent = L("B04_Surface_Map_GisLayer");
const gisButtons = document.createElement("div");
gisButtons.className = "b04-map__layer-buttons";
gisGroup.append(gisTitle, gisButtons);
const resetButton = document.createElement("button");
resetButton.type = "button";
resetButton.textContent = L("B04_Surface_Map_Reset");
controls.append(backgroundLabel, gisLabel, resetButton);
controls.append(backgroundGroup, gisGroup, resetButton);
header.append(title, controls);
const viewport = document.createElement("div");
viewport.className = "b04-map__viewport";
const image = document.createElement("img");
image.className = "b04-map__image";
image.alt = L("B04_Surface_Map_ImageAlt");
image.draggable = false;
const backgroundImages = new Map<BackgroundLayer, HTMLImageElement>();
BACKGROUND_LAYERS.forEach((layer) => {
const image = document.createElement("img");
image.className = "b04-map__image";
image.alt = L("B04_Surface_Map_ImageAlt");
image.draggable = false;
backgroundImages.set(layer, image);
});
const canvas = document.createElement("canvas");
canvas.className = "b04-map__canvas";
const empty = document.createElement("p");
@@ -98,21 +98,87 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
scaleBar.className = "b04-map__scale";
const scaleText = document.createElement("span");
scaleBar.append(scaleText);
viewport.append(image, canvas, empty, status, scaleBar);
viewport.append(
...BACKGROUND_LAYERS.map((layer) => backgroundImages.get(layer)!),
canvas,
empty,
status,
scaleBar,
);
root.append(header, viewport);
let currentProjectId: string | null = null;
let referenceBounds: SurfaceBounds | null = null;
let meta: VWorldMeta | null = null;
let geoJson: GeoJsonCollection | null = null;
const geoJsonLayers = new Map<GisLayer, GeoJsonCollection>();
const activeBackgrounds = new Set<BackgroundLayer>(BACKGROUND_LAYERS);
const activeGisLayers = new Set<GisLayer>(GIS_LAYERS);
let scale = 1;
let offsetX = 0;
let offsetY = 0;
let dragStart: { x: number; y: number; offsetX: number; offsetY: number } | null = null;
let loadSequence = 0;
function makeLayerButton<T extends string>(
label: string,
activeLayers: Set<T>,
layer: T,
color?: string,
): HTMLButtonElement {
const button = document.createElement("button");
button.type = "button";
button.className = "b04-map__layer-button is-active";
button.textContent = label;
button.setAttribute("aria-pressed", "true");
if (color) {
button.classList.add("b04-map__layer-button--gis");
button.style.setProperty("--b04-layer-color", color);
}
button.addEventListener("click", () => {
if (activeLayers.has(layer)) activeLayers.delete(layer);
else activeLayers.add(layer);
const isActive = activeLayers.has(layer);
button.classList.toggle("is-active", isActive);
button.setAttribute("aria-pressed", String(isActive));
syncLayerVisibility();
});
return button;
}
const backgroundLabels: Record<BackgroundLayer, string> = {
white: L("B04_Surface_Map_White"),
satellite: L("B04_Surface_Map_Satellite"),
hybrid: L("B04_Surface_Map_Hybrid"),
};
BACKGROUND_LAYERS.forEach((layer) => {
backgroundButtons.append(makeLayerButton(backgroundLabels[layer], activeBackgrounds, layer));
});
const gisLabels: Record<GisLayer, string> = {
지적도: L("B04_Surface_Map_Cadastral"),
수계망: L("B04_Surface_Map_Water"),
산사태: L("B04_Surface_Map_Landslide"),
행정구역_시군구: L("B04_Surface_Map_Sigungu"),
행정구역_읍면동: L("B04_Surface_Map_Eupmyeondong"),
};
GIS_LAYERS.forEach((layer) => {
gisButtons.append(
makeLayerButton(gisLabels[layer], activeGisLayers, layer, GIS_LAYER_COLORS[layer]),
);
});
function updateImageTransform(): void {
image.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${scale})`;
backgroundImages.forEach((image) => {
image.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${scale})`;
});
}
function syncLayerVisibility(): void {
backgroundImages.forEach((image, layer) => {
image.hidden = !activeBackgrounds.has(layer);
});
empty.hidden = activeBackgrounds.size > 0 || activeGisLayers.size > 0;
drawVectorLayer();
}
function fitReferenceBounds(): void {
@@ -176,7 +242,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
ring: unknown,
width: number,
height: number,
fill: boolean,
closed: boolean,
): void {
if (!Array.isArray(ring) || ring.length === 0) return;
const points = ring.filter(
@@ -190,13 +256,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
if (index === 0) context.moveTo(x, y);
else context.lineTo(x, y);
});
if (fill) {
context.closePath();
context.save();
context.globalAlpha = 0.16;
context.fill();
context.restore();
}
if (closed) context.closePath();
context.stroke();
}
@@ -249,12 +309,13 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
if (!context) return;
context.setTransform(dpr, 0, 0, dpr, 0, 0);
context.clearRect(0, 0, width, height);
const styles = getComputedStyle(root);
context.strokeStyle = styles.getPropertyValue("--b04-map-vector").trim();
context.fillStyle = styles.getPropertyValue("--b04-map-vector").trim();
context.lineWidth = 1.5;
geoJson?.features?.forEach((feature) => {
if (feature.geometry) drawGeometry(context, feature.geometry, width, height);
GIS_LAYERS.forEach((layer) => {
if (!activeGisLayers.has(layer)) return;
context.strokeStyle = GIS_LAYER_COLORS[layer];
geoJsonLayers.get(layer)?.features?.forEach((feature) => {
if (feature.geometry) drawGeometry(context, feature.geometry, width, height);
});
});
updateImageTransform();
drawScaleBar(width, height);
@@ -262,44 +323,49 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
async function loadLayers(): Promise<void> {
if (!currentProjectId) return;
const projectId = currentProjectId;
const sequence = ++loadSequence;
const background = backgroundSelect.value;
const gisLayer = gisSelect.value;
empty.hidden = background !== "none" || gisLayer !== "none";
image.hidden = background === "none";
image.removeAttribute("src");
backgroundImages.forEach((image) => image.removeAttribute("src"));
meta = null;
geoJson = null;
geoJsonLayers.clear();
resetView();
if (background === "none" && gisLayer === "none") {
status.textContent = "";
return;
}
status.textContent = L("B04_Surface_Map_Loading");
const mapLayer = background === "none" ? "white" : background;
try {
const [nextMeta, nextGeoJson] = await Promise.all([
fetchVWorldMeta(currentProjectId, mapLayer),
gisLayer === "none" ? Promise.resolve(null) : fetchGisGeoJson(currentProjectId, gisLayer),
]);
const nextMeta = await fetchVWorldMeta(projectId, "satellite");
const loadedLayers = await Promise.all(
GIS_LAYERS.map(async (layer) => {
try {
const data = (await fetchGisGeoJson(projectId, layer)) as GeoJsonCollection;
return [layer, data] as const;
} catch {
return [layer, null] as const;
}
}),
);
if (sequence !== loadSequence) return;
meta = nextMeta;
geoJson = nextGeoJson as GeoJsonCollection | null;
if (background !== "none") {
image.src = `${getVWorldMapUrl(currentProjectId, mapLayer)}&_t=${Date.now()}`;
}
status.textContent = geoJson?.features
? L("B04_Surface_Map_Features").replace("{count}", geoJson.features.length.toLocaleString())
: "";
loadedLayers.forEach(([layer, data]) => {
if (data) geoJsonLayers.set(layer, data);
});
BACKGROUND_LAYERS.forEach((layer) => {
backgroundImages.get(layer)!.src = `${getVWorldMapUrl(projectId, layer)}&_t=${Date.now()}`;
});
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();
} catch (error) {
if (sequence !== loadSequence) return;
status.textContent = error instanceof Error ? error.message : L("B04_Surface_Map_LoadFailed");
}
}
backgroundSelect.addEventListener("change", () => void loadLayers());
gisSelect.addEventListener("change", () => void loadLayers());
resetButton.addEventListener("click", resetView);
viewport.addEventListener(
"wheel",
+30 -3
View File
@@ -497,7 +497,8 @@
.b04-map__header,
.b04-map__controls,
.b04-map__controls label {
.b04-map__control-group,
.b04-map__layer-buttons {
display: flex;
align-items: center;
}
@@ -517,13 +518,17 @@
flex-wrap: wrap;
}
.b04-map__controls label {
.b04-map__control-group {
gap: var(--spacing-8);
color: var(--color-text-secondary);
font-size: var(--text-caption);
}
.b04-map__controls select,
.b04-map__layer-buttons {
gap: var(--spacing-4);
flex-wrap: wrap;
}
.b04-map__controls button {
min-height: 34px;
padding: 0 var(--spacing-12);
@@ -537,6 +542,27 @@
cursor: pointer;
}
.b04-map__controls .b04-map__layer-button {
padding: var(--spacing-8);
opacity: 0.55;
}
.b04-map__layer-button.is-active {
opacity: 1;
box-shadow: inset 0 0 0 1px currentColor;
}
.b04-map__layer-button--gis {
border-color: var(--color-border);
color: var(--color-text-secondary);
}
.b04-map__layer-button--gis.is-active {
border-color: var(--b04-layer-color);
color: var(--b04-layer-color);
box-shadow: inset 0 0 0 1px var(--b04-layer-color);
}
.b04-map__viewport {
position: relative;
width: 100%;
@@ -565,6 +591,7 @@
object-fit: contain;
transform-origin: center;
user-select: none;
pointer-events: none;
}
.b04-map__canvas {
@@ -328,7 +328,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
if (activeMethod === "meshfree") {
new PLYLoader().load(
previewUrl,
(geometry) => {
async (geometry) => {
geometry.computeBoundingSphere();
const material = new THREE.PointsMaterial({
size: 0.35,
@@ -340,8 +340,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
terrainMesh = points;
scene.add(points);
fitCamera(points);
statusSpan.textContent = `${activeFilter.toUpperCase()} · ${activeMethod.toUpperCase()} 표시 중`;
void loadContourLines(modelId, isSmooth);
await loadSelectedContours(modelId, isSmooth);
},
undefined,
() => {
@@ -351,7 +350,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
} else {
new GLTFLoader().load(
previewUrl,
(gltf) => {
async (gltf) => {
gltf.scene.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.material.side = THREE.DoubleSide;
@@ -362,8 +361,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
terrainMesh = gltf.scene;
scene.add(gltf.scene);
fitCamera(gltf.scene);
statusSpan.textContent = `${activeFilter.toUpperCase()} · ${activeMethod.toUpperCase()} 표시 중`;
void loadContourLines(modelId, isSmooth);
await loadSelectedContours(modelId, isSmooth);
},
undefined,
() => {
@@ -376,14 +374,27 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
}
}
async function loadContourLines(modelId: number, isSmooth: boolean): Promise<boolean> {
async function loadContourLines(
modelId: number,
isSmooth: boolean,
recalculate = false,
): Promise<boolean> {
const interval = parseFloat(intervalInput.value) || 1.0;
const contourUrl = `${API_BASE_URL}/projects/${currentProjectId}/surface/models/${modelId}/contour?interval=${interval}&smooth=${isSmooth}`;
const projectId = currentProjectId;
const contourUrl = `${API_BASE_URL}/projects/${projectId}/surface/models/${modelId}/contour?interval=${interval}&smooth=${isSmooth}&recalculate=${recalculate}`;
try {
const res = await fetch(contourUrl);
const res = await fetch(contourUrl, { cache: "no-store" });
if (!res.ok) throw new Error("등고선 조회 실패");
const data = await res.json();
if (
currentProjectId !== projectId ||
currentModelId !== modelId ||
currentModelSmooth !== isSmooth ||
(parseFloat(intervalInput.value) || 1.0) !== interval
) {
return false;
}
clearContours();
@@ -486,6 +497,27 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
}
}
async function loadSelectedContours(
modelId: number,
isSmooth: boolean,
recalculate = false,
): Promise<void> {
const projectId = currentProjectId;
const interval = parseFloat(intervalInput.value) || 1.0;
statusSpan.textContent = `등고선 ${interval}m 계산 중...`;
const loaded = await loadContourLines(modelId, isSmooth, recalculate);
if (
currentProjectId === projectId &&
currentModelId === modelId &&
currentModelSmooth === isSmooth &&
(parseFloat(intervalInput.value) || 1.0) === interval
) {
statusSpan.textContent = loaded
? `${activeFilter.toUpperCase()} · ${activeMethod.toUpperCase()} · 등고선 ${interval}m`
: "등고선 계산 또는 조회에 실패했습니다.";
}
}
// Animation render loop
let animationFrameId = 0;
let hasConnected = false;
@@ -559,11 +591,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
const interval = Number(intervalInput.value);
if (!Number.isFinite(interval) || interval < 0.5 || currentModelId === null) return;
intervalSubmit.disabled = true;
statusSpan.textContent = `등고선 ${interval}m 계산 중...`;
const loaded = await loadContourLines(currentModelId, currentModelSmooth);
statusSpan.textContent = loaded
? `${activeFilter.toUpperCase()} · ${activeMethod.toUpperCase()} · 등고선 ${interval}m`
: "등고선 계산 또는 조회에 실패했습니다.";
await loadSelectedContours(currentModelId, currentModelSmooth, true);
intervalSubmit.disabled = false;
});
+1 -1
View File
@@ -145,7 +145,7 @@ SURFACE_SMOOTHING_TIN_TAUBIN_LAMBDA = float(os.getenv("SURFACE_SMOOTHING_TIN_TAU
SURFACE_SMOOTHING_TIN_TAUBIN_MU = float(os.getenv("SURFACE_SMOOTHING_TIN_TAUBIN_MU", "-0.53"))
# 등고선 파라미터
SURFACE_CONTOUR_INTERVAL_M = float(os.getenv("SURFACE_CONTOUR_INTERVAL_M", "5.0"))
SURFACE_CONTOUR_INTERVAL_M = float(os.getenv("SURFACE_CONTOUR_INTERVAL_M", "1.0"))
SURFACE_CONTOUR_GRID_RESOLUTION_M = float(os.getenv("SURFACE_CONTOUR_GRID_RESOLUTION_M", "1.0"))
+10 -10
View File
@@ -110,8 +110,8 @@
"semantic_hash": ""
},
"docs/wiki/log.md": {
"mtime": 1784276174.9796534,
"ast_hash": "a9bc801087f68a8cf53b4ccd025587ea",
"mtime": 1784281064.938371,
"ast_hash": "d0a674d4bde81eea7e8bd2edf76d6287",
"semantic_hash": ""
},
"docs/wiki/pages/A01_Home/A01_components.md": {
@@ -255,13 +255,13 @@
"semantic_hash": ""
},
"docs/wiki/pages/B04_wf1_Surface/B04_api.md": {
"mtime": 1784276164.8003862,
"ast_hash": "1ca8565f179fb4643bd4997cb6f1bc46",
"mtime": 1784281082.294356,
"ast_hash": "688e57fa31f595d8a2eb50fe1534bae8",
"semantic_hash": ""
},
"docs/wiki/pages/B04_wf1_Surface/B04_backend.md": {
"mtime": 1784276156.6492395,
"ast_hash": "f3f818ea467831d6f60136ece8e3bb9b",
"mtime": 1784281075.9390342,
"ast_hash": "179006cfa5579947f12703de677b9178",
"semantic_hash": ""
},
"docs/wiki/pages/B04_wf1_Surface/B04_db.md": {
@@ -275,8 +275,8 @@
"semantic_hash": ""
},
"docs/wiki/pages/B04_wf1_Surface/B04_frontend.md": {
"mtime": 1784276170.2440593,
"ast_hash": "514a6d8a8e8a3b0093bbbad7df764651",
"mtime": 1784278834.8752944,
"ast_hash": "bb89b16cc5a0d9763cb26c174a537f54",
"semantic_hash": ""
},
"docs/wiki/pages/B05_wf2_Route/B05_api.md": {
@@ -340,8 +340,8 @@
"semantic_hash": ""
},
"docs/wiki/pages/B08_wf5_Quantity/B08_frontend.md": {
"mtime": 1784259186.086623,
"ast_hash": "d7dfc0b92c6297f7fa95444c2916461e",
"mtime": 1784280350.6155877,
"ast_hash": "82f0c3c8358a8f6b16395623d73f049f",
"semantic_hash": ""
},
"docs/wiki/pages/B09_wf6_Estimation/B09_frontend.md": {
+2
View File
@@ -28,6 +28,7 @@ from B01_Dashboard.B01_Dashboard_Router import router as b01_dashboard_router
from B02_ProjRegister.B02_ProjRegister_Router import router as b02_proj_register_router
from B03_FileInput.B03_FileInput_Router import router as b03_file_input_router
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 B05_wf2_Route.B05_wf2_Route_Router import router as b05_route_router
@@ -246,6 +247,7 @@ protected = [Depends(verify_session)]
protected_with_company = [Depends(verify_session), Depends(require_company)]
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(tiles_router, dependencies=protected_with_company)
app.include_router(b05_route_router, dependencies=protected_with_company)