From 0a1913eb6cd21b393ab9e8b069536679a764e796 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 26 Jul 2026 14:06:04 +0900 Subject: [PATCH] 260726_2 --- .env | 3 +- A00_Common/router.ts | 4 +- B04_wf1_Surface/B04_wf1_Surface_Engine.py | 8 +- .../B04_wf1_Surface_Engine_GisVector.py | 126 ++++++++++-------- B04_wf1_Surface/B04_wf1_Surface_Router_GIS.py | 13 ++ .../B04_wf1_Surface_UI_MapViewer.ts | 19 ++- ui_template/ui_template_locale.ts | 1 + 7 files changed, 113 insertions(+), 61 deletions(-) diff --git a/.env b/.env index fb28a5bb..2087204e 100644 --- a/.env +++ b/.env @@ -48,4 +48,5 @@ CORS_ORIGINS=http://localhost:5173,http://localhost:8000 APP_PUBLIC_BASE_URL=http://localhost:5173 # Gitea 토큰 66a40de16156431db0dfbf86149f8bafcec4832f -# 국토정보플랫폼 API키 : C7111C0D1C2D353F37EDE33145E2A286DF8D8EC6CA \ No newline at end of file +# 국토정보플랫폼 API키 : C7111C0D1C2D353F37EDE33145E2A286DF8D8EC6CA +# 브이월드 API키 : 3DBD7306-7DBD-38BB-B292-267C5ED7AC6B \ No newline at end of file diff --git a/A00_Common/router.ts b/A00_Common/router.ts index d58c7cad..3a2c83ef 100644 --- a/A00_Common/router.ts +++ b/A00_Common/router.ts @@ -47,8 +47,8 @@ const routeTable: Partial Promise>> = { (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 () => diff --git a/B04_wf1_Surface/B04_wf1_Surface_Engine.py b/B04_wf1_Surface/B04_wf1_Surface_Engine.py index 2b954538..9344bd77 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Engine.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Engine.py @@ -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) diff --git a/B04_wf1_Surface/B04_wf1_Surface_Engine_GisVector.py b/B04_wf1_Surface/B04_wf1_Surface_Engine_GisVector.py index 84141a78..454cb8b5 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Engine_GisVector.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Engine_GisVector.py @@ -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) diff --git a/B04_wf1_Surface/B04_wf1_Surface_Router_GIS.py b/B04_wf1_Surface/B04_wf1_Surface_Router_GIS.py index 642a1594..975b41a1 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Router_GIS.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Router_GIS.py @@ -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, diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts index 600d0467..ead045a2 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts @@ -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 = { 산사태: "#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); diff --git a/ui_template/ui_template_locale.ts b/ui_template/ui_template_locale.ts index 0cd2a650..71b39a65 100644 --- a/ui_template/ui_template_locale.ts +++ b/ui_template/ui_template_locale.ts @@ -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: [