260726_2
This commit is contained in:
@@ -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
|
||||
@@ -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 () =>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: [
|
||||
|
||||
Reference in New Issue
Block a user