This commit is contained in:
2026-07-26 14:06:04 +09:00
parent 9350e1d4c9
commit 0a1913eb6c
7 changed files with 113 additions and 61 deletions
+2 -1
View File
@@ -48,4 +48,5 @@ CORS_ORIGINS=http://localhost:5173,http://localhost:8000
APP_PUBLIC_BASE_URL=http://localhost:5173
# Gitea 토큰 66a40de16156431db0dfbf86149f8bafcec4832f
# 국토정보플랫폼 API키 : C7111C0D1C2D353F37EDE33145E2A286DF8D8EC6CA
# 국토정보플랫폼 API키 : C7111C0D1C2D353F37EDE33145E2A286DF8D8EC6CA
# 브이월드 API키 : 3DBD7306-7DBD-38BB-B292-267C5ED7AC6B
+2 -2
View File
@@ -47,8 +47,8 @@ const routeTable: Partial<Record<RoutePath, () => Promise<PageRenderer>>> = {
(await import("../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page")).renderB06ProfileCross,
[ROUTES.B07_WF4_DESIGN_DETAIL]: async () =>
(await import("../B07_wf4_DesignDetail/B07_wf4_DesignDetail_UI_Page")).renderB07DesignDetail,
[ROUTES.B08_WF5_QUANTITY]: async () =>
(await import("../B08_wf5_Quantity/B08_wf5_Quantity_UI_Page")).renderB08Quantity,
// B08_WF5_QUANTITY: 재작성 예정으로 기존 구현을 0_old_260726_codex.zip에 백업 후 제거.
// 등록을 빼두면 renderPlaceholder가 "준비 중" 화면을 띄운다. 새 페이지 완성 시 여기에 다시 추가.
[ROUTES.B09_WF6_ESTIMATION]: async () =>
(await import("../B09_wf6_Estimation/B09_wf6_Estimation_UI_Page")).renderB09Estimation,
[ROUTES.B10_PAYMENT]: async () =>
+7 -1
View File
@@ -268,7 +268,13 @@ def run_surface_analysis(
except Exception as exc:
logger.warning("B04 VWorld %s 지도 다운로드 실패: %s", item["layer"], exc)
if rebuild or not any(processed_dir.glob("*_bounds.geojson")):
# 등고선은 국가 gpkg 추가 시점이 전처리보다 늦을 수 있으므로 개별로 존재 여부를 확인한다.
contour_geojson = processed_dir / "등고선_bounds.geojson"
if (
rebuild
or not any(processed_dir.glob("*_bounds.geojson"))
or not contour_geojson.exists()
):
try:
step_started = time.monotonic()
download_all_gis_vectors(prj_path, bounds_dict_for_download, processed_dir)
@@ -3,6 +3,7 @@
from __future__ import annotations
import json
import logging
import urllib.parse
import urllib.request
from pathlib import Path
@@ -23,6 +24,8 @@ except ImportError:
from B04_wf1_Surface.B04_wf1_Surface_Engine_VWorld import get_epsg_from_prj
logger = logging.getLogger(__name__)
def request_vworld_wfs(typename: str, filename: str, bounds_wgs84: dict, output_dir: Path) -> None:
"""브이월드 WFS API를 사용하여 지정된 Bounds 영역 내의
@@ -63,42 +66,80 @@ def request_vworld_wfs(typename: str, filename: str, bounds_wgs84: dict, output_
pass
def read_bounds_wgs84_from_meta(meta_dir: Path) -> dict | None:
"""VWorld 메타 JSON에서 여유폭 0.010도를 더한 위경도 경계를 읽어옵니다."""
meta_path = meta_dir / "vworld_white_meta.json"
if not meta_path.exists():
meta_path = meta_dir / "vworld_satellite_meta.json"
if not meta_path.exists():
return None
try:
with open(meta_path, "r", encoding="utf-8") as f:
meta_data = json.load(f)
return {
"min_lon": meta_data["lon_min"] - 0.010,
"max_lon": meta_data["lon_max"] + 0.010,
"min_lat": meta_data["lat_min"] - 0.010,
"max_lat": meta_data["lat_max"] + 0.010,
}
except Exception as exc:
logger.warning("VWorld 메타 경계 읽기 실패: %s (%s)", meta_path, exc)
return None
def crop_national_contours(bounds_wgs84: dict, output_dir: Path) -> bool:
"""국가 등고선 gpkg에서 지정 위경도 영역을 잘라 등고선_bounds.geojson을 생성합니다."""
gpkg_path = PROJECT_ROOT / "resources" / "grobal_contours" / "national_contours.gpkg"
if not gpkg_path.exists():
logger.warning("국가 등고선 gpkg가 없습니다: %s", gpkg_path)
return False
try:
import geopandas as gpd
from shapely.geometry import box
# gpkg는 EPSG:5179(KGD2002 / Unified CS) 좌표계이므로 bbox도 같은 계로 변환한다.
t_to_5179 = Transformer.from_crs("EPSG:4326", "EPSG:5179", always_xy=True)
min_x_5179, min_y_5179 = t_to_5179.transform(
bounds_wgs84["min_lon"], bounds_wgs84["min_lat"]
)
max_x_5179, max_y_5179 = t_to_5179.transform(
bounds_wgs84["max_lon"], bounds_wgs84["max_lat"]
)
bbox = box(min_x_5179, min_y_5179, max_x_5179, max_y_5179)
gdf = gpd.read_file(gpkg_path, bbox=bbox)
if gdf.empty:
logger.warning("국가 등고선 크롭 결과가 비어 있습니다: bbox=%s", bounds_wgs84)
return False
# bbox 필터는 교차한 도엽 전체 라인을 반환하므로 실제 경계로 잘라 전송량을 줄인다.
gdf = gdf.clip(bbox)
gdf = gdf[~gdf.geometry.is_empty & gdf.geometry.notna()]
if gdf.empty:
logger.warning("국가 등고선 클리핑 결과가 비어 있습니다: bbox=%s", bounds_wgs84)
return False
output_dir.mkdir(parents=True, exist_ok=True)
gdf_wgs84 = gdf.to_crs("EPSG:4326")
geojson_out = output_dir / "등고선_bounds.geojson"
gdf_wgs84.to_file(geojson_out, driver="GeoJSON")
logger.info("국가 등고선 크롭 완료: %d개 라인 -> %s", len(gdf_wgs84), geojson_out)
return True
except Exception as exc:
logger.warning("국가 등고선 크롭 실패: %s", exc)
return False
def download_all_gis_vectors(prj_path: Path, bounds_meter: dict, output_dir: Path) -> None:
"""지형의 로컬 미터단위 bounds 정보를 위경도로 변환 후 5대 국가 GIS 데이터를 다운로드합니다."""
output_dir.mkdir(parents=True, exist_ok=True)
# 1. vworld_white_meta.json이 생성되어 있다면 직접 위경도 경계 획득하여 pyproj 축왜곡 방지
meta_path = prj_path.parent / "vworld_white_meta.json"
if not meta_path.exists():
meta_path = prj_path.parent / "vworld_satellite_meta.json"
bounds_wgs84 = read_bounds_wgs84_from_meta(prj_path.parent)
if meta_path.exists():
try:
with open(meta_path, "r", encoding="utf-8") as f:
meta_data = json.load(f)
bounds_wgs84 = {
"min_lon": meta_data["lon_min"] - 0.010,
"max_lon": meta_data["lon_max"] + 0.010,
"min_lat": meta_data["lat_min"] - 0.010,
"max_lat": meta_data["lat_max"] + 0.010,
}
except Exception:
# 폴백용 pyproj 변환
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)
x_min, x_max = bounds_meter["x"][0], bounds_meter["x"][1]
y_min, y_max = bounds_meter["y"][0], bounds_meter["y"][1]
lon_min, lat_min = transformer.transform(x_min, y_min)
lon_max, lat_max = transformer.transform(x_max, y_max)
bounds_wgs84 = {
"min_lon": min(lon_min, lon_max) - 0.002,
"max_lon": max(lon_min, lon_max) + 0.002,
"min_lat": min(lat_min, lat_max) - 0.002,
"max_lat": max(lat_min, lat_max) + 0.002,
}
else:
if bounds_wgs84 is None:
# 폴백용 pyproj 변환
src_epsg = "EPSG:5186"
if prj_path.exists():
@@ -129,27 +170,4 @@ def download_all_gis_vectors(prj_path: Path, bounds_meter: dict, output_dir: Pat
# 6. gpkg 등고선(national_contours.gpkg) 데이터에서
# 기준 영역 크롭하여 등고선_bounds.geojson 생성
gpkg_path = PROJECT_ROOT / "resources" / "grobal_contours" / "national_contours.gpkg"
if gpkg_path.exists():
try:
import geopandas as gpd
from shapely.geometry import box
t_to_5179 = Transformer.from_crs("EPSG:4326", "EPSG:5179", always_xy=True)
min_x_5179, min_y_5179 = t_to_5179.transform(
bounds_wgs84["min_lon"], bounds_wgs84["min_lat"]
)
max_x_5179, max_y_5179 = t_to_5179.transform(
bounds_wgs84["max_lon"], bounds_wgs84["max_lat"]
)
bbox = box(min_x_5179, min_y_5179, max_x_5179, max_y_5179)
gdf = gpd.read_file(gpkg_path, bbox=bbox)
if not gdf.empty:
gdf_wgs84 = gdf.to_crs("EPSG:4326")
geojson_out = output_dir / "등고선_bounds.geojson"
gdf_wgs84.to_file(geojson_out, driver="GeoJSON")
except Exception:
pass
crop_national_contours(bounds_wgs84, output_dir)
@@ -1,3 +1,4 @@
import asyncio
import json
import logging
from pathlib import Path
@@ -114,6 +115,18 @@ async def get_project_geojson(project_id: UUID, layer: str) -> dict[str, Any] |
)
filepath = target_dir / filename
# 등고선은 전처리 이후 국가 gpkg가 추가된 경우를 대비해 요청 시점에 크롭 생성한다.
if layer == "등고선" and not filepath.exists():
from B04_wf1_Surface.B04_wf1_Surface_Engine_GisVector import (
crop_national_contours,
read_bounds_wgs84_from_meta,
)
bounds_wgs84 = read_bounds_wgs84_from_meta(target_dir)
if bounds_wgs84 is not None:
await asyncio.to_thread(crop_national_contours, bounds_wgs84, target_dir)
if not filepath.exists():
return JSONResponse(
status_code=404,
@@ -28,7 +28,14 @@ type GeoJsonCollection = {
};
const BACKGROUND_LAYERS = ["white", "satellite", "hybrid"] as const;
const GIS_LAYERS = ["지적도", "수계망", "산사태", "행정구역_시군구", "행정구역_읍면동"] as const;
const GIS_LAYERS = [
"지적도",
"수계망",
"산사태",
"행정구역_시군구",
"행정구역_읍면동",
"등고선",
] as const;
type BackgroundLayer = (typeof BACKGROUND_LAYERS)[number];
type GisLayer = (typeof GIS_LAYERS)[number];
@@ -38,6 +45,7 @@ const GIS_LAYER_COLORS: Record<GisLayer, string> = {
: "#ef4444",
_시군구: "#7c3aed",
_읍면동: "#22c55e",
: "#a16207",
};
function L(key: keyof typeof ui_locales): string {
@@ -160,6 +168,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
산사태: L("B04_Surface_Map_Landslide"),
행정구역_시군구: L("B04_Surface_Map_Sigungu"),
행정구역_읍면동: L("B04_Surface_Map_Eupmyeondong"),
등고선: L("B04_Surface_Map_Contour"),
};
GIS_LAYERS.forEach((layer) => {
gisButtons.append(
@@ -309,9 +318,13 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
if (!context) return;
context.setTransform(dpr, 0, 0, dpr, 0, 0);
context.clearRect(0, 0, width, height);
context.lineWidth = 1.5;
GIS_LAYERS.forEach((layer) => {
// 등고선은 선 수가 많아 가장 아래에 얇게 깔아 다른 레이어 판독을 방해하지 않게 한다.
const drawOrder = [...GIS_LAYERS].sort((a, b) =>
a === "등고선" ? -1 : b === "등고선" ? 1 : 0,
);
drawOrder.forEach((layer) => {
if (!activeGisLayers.has(layer)) return;
context.lineWidth = layer === "등고선" ? 0.7 : 1.5;
context.strokeStyle = GIS_LAYER_COLORS[layer];
geoJsonLayers.get(layer)?.features?.forEach((feature) => {
if (feature.geometry) drawGeometry(context, feature.geometry, width, height);
+1
View File
@@ -656,6 +656,7 @@ export const ui_locales = {
B04_Surface_Map_Landslide: ["산사태위험등급", "Landslide risk"],
B04_Surface_Map_Sigungu: ["시군구", "District boundary"],
B04_Surface_Map_Eupmyeondong: ["읍면동", "Town boundary"],
B04_Surface_Map_Contour: ["등고선", "Contour lines"],
B04_Surface_Map_Reset: ["보기 초기화", "Reset view"],
B04_Surface_Map_ImageAlt: ["VWorld 배경 지도", "VWorld basemap"],
B04_Surface_Map_Empty: [