diff --git a/B04_wf1_Surface/B04_wf1_Surface_Engine.py b/B04_wf1_Surface/B04_wf1_Surface_Engine.py index 9d97ca5f..097ee188 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Engine.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Engine.py @@ -309,10 +309,9 @@ def run_surface_analysis( (bounds_dict_for_download["y"][0] + bounds_dict_for_download["y"][1]) / 2.0, ) step_started = time.monotonic() - sheet_result = ensure_sheets( - get_project_map_sheets_dir(project_root), - neighbors_3x3(latlon_to_sheet5k(center_lat, center_lon)), - ) + sheet_grid = neighbors_3x3(latlon_to_sheet5k(center_lat, center_lon)) + sheet_store = get_project_map_sheets_dir(project_root) + sheet_result = ensure_sheets(sheet_store, sheet_grid) if sheet_result["failed"]: logger.warning("B04 수치지형도 도엽 일부 확보 실패: %s", sheet_result["failed"]) logger.info( @@ -320,6 +319,24 @@ def run_surface_analysis( len(sheet_result["available"]), time.monotonic() - step_started, ) + + # 확보된 도엽을 레이어별 병합 GeoJSON으로 산출 (기존 산출물 있으면 스킵) + if sheet_result["available"] and ( + rebuild or not any(processed_dir.glob("도엽_*.geojson")) + ): + from B04_wf1_Surface.B04_wf1_Surface_Engine_SheetParser import ( + parse_and_merge_sheets, + ) + + step_started = time.monotonic() + merged = parse_and_merge_sheets( + sheet_store, list(sheet_result["available"]), processed_dir + ) + logger.info( + "B04 도엽 레이어 병합 완료: %s (%.1fs)", + {k: v["count"] for k, v in merged.items()}, + time.monotonic() - step_started, + ) except Exception as exc: logger.warning("B04 수치지형도 도엽 확보 실패: %s", exc) except Exception as exc: diff --git a/B04_wf1_Surface/B04_wf1_Surface_Engine_SheetParser.py b/B04_wf1_Surface/B04_wf1_Surface_Engine_SheetParser.py new file mode 100644 index 00000000..3152b100 --- /dev/null +++ b/B04_wf1_Surface/B04_wf1_Surface_Engine_SheetParser.py @@ -0,0 +1,126 @@ +# B04_wf1_Surface_Engine_SheetParser.py +# 1:5,000 수치지형도 도엽 zip 파서 + 9매 병합 → 레이어별 GeoJSON 산출. +# +# 레이어 코드 (보유 도엽 2매 실측 확정, 2026-07-26): +# N3L_E0020000 하천중심선(구분: 세류/소하천/지방하천), N3P_F0020000 표고점(수치), +# N3L_F0030000 성/절토 비탈면, N3L_F0040000 옹벽·석축, N3P_E0042326 유수방향, +# N3P_H0020000 기준점(삼각점/통합기준점, 표고), N3P_H0040000 지명(계곡·부락 등) +# 벼랑바위·너덜바위·산산맥은 표본 도엽에 미출현 — 코드 확정 시 SHEET_LAYERS에 추가. +# +# 좌표계: zip 내부 PRJ는 오라벨 사례가 있어 map_sheets_index.json의 교정 crs를 사용한다. + +from __future__ import annotations + +import logging +import tempfile +import zipfile +from pathlib import Path + +import geopandas as gpd +import pandas as pd + +from .B04_wf1_Surface_Engine_SheetStore import _load_index, get_sheet_path + +logger = logging.getLogger(__name__) + +# 레이어 키 → (지오메트리 접두사, 지형지물 코드) +SHEET_LAYERS: dict[str, tuple[str, str]] = { + "하천중심선": ("N3L", "E0020000"), + "표고점": ("N3P", "F0020000"), + "성절토": ("N3L", "F0030000"), + "옹벽석축": ("N3L", "F0040000"), + "유수방향": ("N3P", "E0042326"), + "기준점": ("N3P", "H0020000"), + "지명": ("N3P", "H0040000"), +} + +# 산출 파일 접두사 (기존 processed/의 국가 GIS geojson과 구분) +_OUTPUT_PREFIX = "도엽" + + +def _read_layer_from_zip( + zip_path: Path, prefix: str, code: str, crs: str +) -> gpd.GeoDataFrame | None: + """도엽 zip에서 단일 레이어 SHP를 읽어 WGS84 GeoDataFrame으로 반환. 없으면 None.""" + member_base = f"{prefix}_{code}" + with zipfile.ZipFile(zip_path) as zf: + names = zf.namelist() + wanted = { + n + for n in names + if Path(n).stem.upper() == member_base + and Path(n).suffix.lower() in (".shp", ".shx", ".dbf", ".prj", ".cpg") + } + if not any(n.lower().endswith(".shp") for n in wanted): + return None + with tempfile.TemporaryDirectory() as td: + for n in wanted: + (Path(td) / Path(n).name).write_bytes(zf.read(n)) + gdf = gpd.read_file(Path(td) / f"{member_base}.shp") + + if gdf.empty: + return None + # 내부 PRJ 오라벨 대비: 인덱스의 교정 crs로 강제 지정 + gdf = gdf.set_crs(crs, allow_override=True) + return gdf.to_crs("EPSG:4326") + + +def parse_and_merge_sheets( + store_dir: str | Path, + sheet_nos: list[str], + output_dir: str | Path, + layers: list[str] | None = None, +) -> dict: + """도엽 여러 매를 레이어별로 병합해 GeoJSON으로 저장한다. + + - 도곽 경계 중복 지물은 UFID 기준 dedup (UFID 없으면 지오메트리 WKB 기준). + - 산출: {output_dir}/도엽_{레이어}.geojson (EPSG:4326) + - 반환: {레이어: {"count": n, "file": 경로}} (지물 0건 레이어는 제외) + """ + store = Path(store_dir) + out = Path(output_dir) + out.mkdir(parents=True, exist_ok=True) + index = _load_index(store)["sheets"] + target_layers = layers or list(SHEET_LAYERS) + + result: dict[str, dict] = {} + for layer_key in target_layers: + if layer_key not in SHEET_LAYERS: + logger.warning("도엽 파서: 미정의 레이어 요청 무시: %s", layer_key) + continue + prefix, code = SHEET_LAYERS[layer_key] + + parts: list[gpd.GeoDataFrame] = [] + for sheet_no in sheet_nos: + sheet_no = str(sheet_no) + zip_path = get_sheet_path(store, sheet_no) + entry = index.get(sheet_no) + if zip_path is None or entry is None: + logger.warning("도엽 파서: 영구저장소에 없는 도엽 스킵: %s", sheet_no) + continue + try: + gdf = _read_layer_from_zip(zip_path, prefix, code, entry["crs"]) + except Exception as exc: + logger.warning("도엽 파서: %s %s 읽기 실패: %s", sheet_no, layer_key, exc) + continue + if gdf is None: + continue + gdf["도엽번호"] = sheet_no + parts.append(gdf) + + if not parts: + continue + + merged = gpd.GeoDataFrame(pd.concat(parts, ignore_index=True), crs="EPSG:4326") + # 도곽 경계에 걸친 지물은 인접 도엽에 중복 수록됨 → UFID 우선 dedup + if "UFID" in merged.columns and merged["UFID"].notna().any(): + merged = merged.drop_duplicates(subset="UFID", keep="first") + else: + merged = merged.loc[~merged.geometry.to_wkb().duplicated(keep="first")] + + out_path = out / f"{_OUTPUT_PREFIX}_{layer_key}.geojson" + merged.to_file(out_path, driver="GeoJSON") + result[layer_key] = {"count": int(len(merged)), "file": str(out_path)} + logger.info("도엽 병합 완료: %s %d건 → %s", layer_key, len(merged), out_path.name) + + return result diff --git a/B04_wf1_Surface/B04_wf1_Surface_Router_GIS.py b/B04_wf1_Surface/B04_wf1_Surface_Router_GIS.py index 975b41a1..41abb019 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Router_GIS.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Router_GIS.py @@ -16,6 +16,11 @@ logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/projects", tags=["B04 Surface GIS"]) tiles_router = APIRouter(tags=["B04 MVT Tiles"]) +# 수치지형도 도엽 병합 레이어 (SheetParser 산출과 1:1 — 정의는 SHEET_LAYERS가 유일) +from B04_wf1_Surface.B04_wf1_Surface_Engine_SheetParser import SHEET_LAYERS # noqa: E402 + +_SHEET_GEOJSON_FILES = {f"도엽_{k}": f"도엽_{k}.geojson" for k in SHEET_LAYERS} + # VWorld 메타 API @router.get("/{project_id}/vworld-meta", response_model=None) @@ -105,6 +110,7 @@ async def get_project_geojson(project_id: UUID, layer: str) -> dict[str, Any] | "수계망": "수계망_물줄기_bounds.geojson", "등고선": "등고선_bounds.geojson", "산사태": "산사태위험등급_bounds.geojson", + **_SHEET_GEOJSON_FILES, } filename = layer_mapping.get(layer) @@ -180,6 +186,7 @@ async def get_vector_tile(project_id: UUID, layer: str, z: int, x: int, y: int) "등고선": "등고선_bounds.geojson", "산사태": "산사태위험등급_bounds.geojson", "임도노선": "임도노선.geojson", + **_SHEET_GEOJSON_FILES, } filename = layer_mapping.get(layer) diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts index ead045a2..b20d87c2 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts @@ -35,6 +35,11 @@ const GIS_LAYERS = [ "행정구역_시군구", "행정구역_읍면동", "등고선", + "도엽_하천중심선", + "도엽_표고점", + "도엽_성절토", + "도엽_옹벽석축", + "도엽_유수방향", ] as const; type BackgroundLayer = (typeof BACKGROUND_LAYERS)[number]; type GisLayer = (typeof GIS_LAYERS)[number]; @@ -46,6 +51,11 @@ const GIS_LAYER_COLORS: Record = { 행정구역_시군구: "#7c3aed", 행정구역_읍면동: "#22c55e", 등고선: "#a16207", + 도엽_하천중심선: "#2563eb", + 도엽_표고점: "#334155", + 도엽_성절토: "#f43f5e", + 도엽_옹벽석축: "#0f766e", + 도엽_유수방향: "#0891b2", }; function L(key: keyof typeof ui_locales): string { @@ -169,6 +179,11 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { 행정구역_시군구: L("B04_Surface_Map_Sigungu"), 행정구역_읍면동: L("B04_Surface_Map_Eupmyeondong"), 등고선: L("B04_Surface_Map_Contour"), + 도엽_하천중심선: L("B04_Surface_Map_SheetStream"), + 도엽_표고점: 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( @@ -269,6 +284,26 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { context.stroke(); } + function drawPoint( + context: CanvasRenderingContext2D, + coordinates: unknown, + width: number, + height: number, + ): void { + if ( + !Array.isArray(coordinates) || + typeof coordinates[0] !== "number" || + typeof coordinates[1] !== "number" + ) { + return; + } + const [x, y] = toCanvasPoint(coordinates[0], coordinates[1], width, height); + context.beginPath(); + context.arc(x, y, 2, 0, Math.PI * 2); + context.fillStyle = context.strokeStyle; + context.fill(); + } + function drawGeometry( context: CanvasRenderingContext2D, geometry: GeoJsonGeometry, @@ -277,7 +312,11 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { ): void { const coordinates = geometry.coordinates; if (!Array.isArray(coordinates)) return; - if (geometry.type === "LineString") { + if (geometry.type === "Point") { + drawPoint(context, coordinates, width, height); + } else if (geometry.type === "MultiPoint") { + coordinates.forEach((point) => drawPoint(context, point, width, height)); + } 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)); diff --git a/ui_template/ui_template_locale.ts b/ui_template/ui_template_locale.ts index 71b39a65..a1ec2d08 100644 --- a/ui_template/ui_template_locale.ts +++ b/ui_template/ui_template_locale.ts @@ -657,6 +657,11 @@ export const ui_locales = { B04_Surface_Map_Sigungu: ["시군구", "District boundary"], B04_Surface_Map_Eupmyeondong: ["읍면동", "Town boundary"], B04_Surface_Map_Contour: ["등고선", "Contour lines"], + B04_Surface_Map_SheetStream: ["세류(하천중심선)", "Stream centerline"], + B04_Surface_Map_SheetElevPoint: ["표고점", "Spot elevation"], + B04_Surface_Map_SheetCutFill: ["성절토", "Cut/fill slope"], + B04_Surface_Map_SheetWall: ["옹벽석축", "Retaining wall"], + B04_Surface_Map_SheetFlowDir: ["유수방향", "Flow direction"], B04_Surface_Map_Reset: ["보기 초기화", "Reset view"], B04_Surface_Map_ImageAlt: ["VWorld 배경 지도", "VWorld basemap"], B04_Surface_Map_Empty: [