From a9caf872b2e53c2cb67cb7a8a676d6931a982dfd Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 1 Aug 2026 11:47:29 +0900 Subject: [PATCH] =?UTF-8?q?perf(B04/B05):=20=EB=8F=84=EC=97=BD=20=ED=91=9C?= =?UTF-8?q?=EC=8B=9C=EB=B3=B8=20=EB=8F=84=EC=9E=85=20+=20=EB=B0=B0?= =?UTF-8?q?=EA=B2=BD=20=EB=B2=94=EC=9C=84=C2=B7=EB=8F=84=EC=97=BD=20?= =?UTF-8?q?=EA=B8=B0=EC=A4=80=EC=9D=84=20=EA=B3=84=ED=9A=8D=EB=85=B8?= =?UTF-8?q?=EC=84=A0=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 도엽 병합본의 화면 표시용 사본 생성(B04_wf1_Surface_Engine_SheetView): 프로젝트 + 500m로 잘라내고 좌표를 소수 7자리로 줄여 등고선 37.3MB -> 0.97MB. 분석용 원본은 그대로 둔다(배수유역은 상류까지 넓은 범위가 필요). - /geojson의 도엽 레이어를 파일 그대로 + ETag로 전송 — 매 요청 2.0초 재직렬화 제거(0.008초). - /vworld-map도 ETag 전송으로 바꾸고, 프론트가 붙이던 `&_t=` 시간꼬리표 제거. - 도엽 레이어를 브라우저 보관함 경유로 조회하고 준비화면에서 미리 담는다. - 배경 지도 범위 = 라이다 범위 + 계획노선 범위 + 여유 300m(SURFACE_MAP_MARGIN_M). - 도엽 기준 좌표 = 계획노선 시점·종점(없으면 라이다 중심). 같은 도엽이면 9매, 이웃 도엽에 걸치면 12매. 표본 프로젝트는 기존 9매와 동일(회귀 없음). - 선정에서 빠진 도엽 zip 정리(prune_sheets) — 표본에서 30매 중 21매가 다른 지역 잔재. Co-Authored-By: Claude Opus 5 (1M context) --- A00_Common/b_asset_cache.ts | 26 +++ B04_wf1_Surface/B04_wf1_Surface_Engine.py | 48 ++--- .../B04_wf1_Surface_Engine_Extent.py | 113 ++++++++++++ .../B04_wf1_Surface_Engine_MapSheet.py | 17 ++ .../B04_wf1_Surface_Engine_SheetStore.py | 36 ++++ .../B04_wf1_Surface_Engine_SheetView.py | 165 ++++++++++++++++++ B04_wf1_Surface/B04_wf1_Surface_Router_GIS.py | 32 +++- .../B04_wf1_Surface_UI_MapViewer.ts | 11 +- .../B05_wf2_Route_UI_Drainage_Panel.ts | 10 +- config/config_system.py | 14 ++ 10 files changed, 437 insertions(+), 35 deletions(-) create mode 100644 B04_wf1_Surface/B04_wf1_Surface_Engine_Extent.py create mode 100644 B04_wf1_Surface/B04_wf1_Surface_Engine_SheetView.py diff --git a/A00_Common/b_asset_cache.ts b/A00_Common/b_asset_cache.ts index 44905989..487e6bce 100644 --- a/A00_Common/b_asset_cache.ts +++ b/A00_Common/b_asset_cache.ts @@ -205,6 +205,20 @@ export async function fetchCachedJson( return JSON.parse(new TextDecoder().decode(bytes)) as T; } +/** 배수유역도 배경으로 쓰는 도엽 레이어(유일한 정의처 — 준비화면과 B05 패널이 함께 쓴다). */ +export const DRAINAGE_SHEET_LAYERS = ["도엽_등고선", "도엽_하천중심선"] as const; + +/** 도엽 레이어(GeoJSON)를 보관함에서 먼저 찾는다. + * + * 서버는 프로젝트 주변만 잘라 좌표 자릿수를 줄인 표시용 사본을 ETag와 함께 내보낸다. + * 분석용 원본과는 별개 파일이므로, 담아 두었다가 그대로 다시 써도 화면이 어긋나지 않는다. */ +export async function fetchCachedSheetLayer(projectId: string, layer: string): Promise { + return fetchCachedJson( + projectId, + `${API_BASE_URL}/projects/${projectId}/geojson?layer=${encodeURIComponent(layer)}`, + ); +} + /* ── 준비 화면 연동 ──────────────────────────────────────────────────────── * 무엇을 이미 준비했는지, 준비가 끝나면 어느 화면으로 보낼지를 탭 단위로 기억한다. * 대시보드로 나갔다가 같은 프로젝트로 돌아오면 준비 화면을 건너뛴다(2026-08-01 사용자 지시). @@ -294,6 +308,18 @@ export async function preloadSurfaceAssets( `${base}/contour?interval=${interval}&smooth=${smooth}&recalculate=false`, { onProgress: (ratio) => report("등고선을 준비하는 중…", ratio) }, ); + + // 배수유역도 배경(도엽 표시본·위성사진)도 함께 담는다 — 없으면 B05가 진입할 때마다 받는다. + // 이 자료가 없어도 화면은 뜨므로 실패해도 준비를 멈추지 않는다. + report("배경 지도를 준비하는 중…", null); + await Promise.all( + DRAINAGE_SHEET_LAYERS.map((layer) => fetchCachedSheetLayer(projectId, layer).catch(() => null)), + ); + // 위성사진은 로 표시하므로 보관함이 아니라 브라우저 자체 캐시를 데워 둔다. + await fetch(`${API_BASE_URL}/projects/${projectId}/vworld-map?layer_name=satellite`, { + credentials: "include", + }).catch(() => null); + report("준비 완료", 1); return confirmed.signature; } diff --git a/B04_wf1_Surface/B04_wf1_Surface_Engine.py b/B04_wf1_Surface/B04_wf1_Surface_Engine.py index 097ee188..2626e4ec 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Engine.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Engine.py @@ -232,14 +232,24 @@ def run_surface_analysis( ) prj_path = prj_candidates[0] if prj_candidates else project_root / "result.prj" - bounds_dict_for_download = { + from B04_wf1_Surface.B04_wf1_Surface_Engine_Extent import download_extent + from B04_wf1_Surface.B04_wf1_Surface_Engine_GisVector import download_all_gis_vectors + from B04_wf1_Surface.B04_wf1_Surface_Engine_VWorld import ( + download_vworld_satellite_map, + get_epsg_from_prj, + ) + + las_bounds_dict = { "x": [float(bounds[0, 0]), float(bounds[0, 1])], "y": [float(bounds[1, 0]), float(bounds[1, 1])], "z": [float(bounds[2, 0]), float(bounds[2, 1])], } - - from B04_wf1_Surface.B04_wf1_Surface_Engine_GisVector import download_all_gis_vectors - from B04_wf1_Surface.B04_wf1_Surface_Engine_VWorld import download_vworld_satellite_map + project_epsg = "EPSG:5186" + if prj_path.exists(): + project_epsg = get_epsg_from_prj(prj_path.read_text(encoding="utf-8", errors="ignore")) + # 배경 지도는 라이다 범위와 계획노선을 합친 범위로 받는다 — 노선이 라이다 범위를 + # 벗어나도 배경이 잘리지 않게 한다(2026-08-01 사용자 지시). + bounds_dict_for_download = download_extent(project_root, las_bounds_dict, project_epsg) # VWorld 지도 및 GIS 데이터 저장 위치는 B04_wf1_Surface/processed에 보관. layers = [ @@ -284,32 +294,26 @@ def run_surface_analysis( except Exception as exc: logger.warning("B04 국가 GIS 벡터 다운로드 실패: %s", exc) - # 3-3. 1:5,000 수치지형도 도엽 3x3(9매) 확보 → 프로젝트 영구저장소 + # 3-3. 1:5,000 수치지형도 도엽 확보 → 프로젝트 영구저장소 + # 기준은 계획노선 시점·종점 (같은 도엽이면 9매, 이웃 도엽에 걸치면 12매). # (실패해도 분석은 계속 — 폴백은 수동 다운로드 + 인제스트) _report(92, "download_maps", "수치지형도 도엽 확보 중") try: - from pyproj import Transformer - - from B04_wf1_Surface.B04_wf1_Surface_Engine_MapSheet import ( - latlon_to_sheet5k, - neighbors_3x3, - ) + from B04_wf1_Surface.B04_wf1_Surface_Engine_Extent import sheet_reference_points_wgs84 + from B04_wf1_Surface.B04_wf1_Surface_Engine_MapSheet import neighbors_for_points from B04_wf1_Surface.B04_wf1_Surface_Engine_SheetStore import ( ensure_sheets, get_project_map_sheets_dir, + prune_sheets, ) - from B04_wf1_Surface.B04_wf1_Surface_Engine_VWorld import get_epsg_from_prj - 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) - center_lon, center_lat = transformer.transform( - (bounds_dict_for_download["x"][0] + bounds_dict_for_download["x"][1]) / 2.0, - (bounds_dict_for_download["y"][0] + bounds_dict_for_download["y"][1]) / 2.0, - ) step_started = time.monotonic() - sheet_grid = neighbors_3x3(latlon_to_sheet5k(center_lat, center_lon)) + # 도엽 기준은 계획노선 시점·종점 — 노선이 두 도엽에 걸치면 양쪽 주변까지 확보한다. + # 계획노선이 없으면 라이다 범위 중심으로 되돌아간다(2026-08-01 사용자 지시). + reference_points = sheet_reference_points_wgs84( + project_root, las_bounds_dict, project_epsg + ) + sheet_grid = neighbors_for_points(reference_points) sheet_store = get_project_map_sheets_dir(project_root) sheet_result = ensure_sheets(sheet_store, sheet_grid) if sheet_result["failed"]: @@ -319,6 +323,8 @@ def run_surface_analysis( len(sheet_result["available"]), time.monotonic() - step_started, ) + # 선정에서 빠진 zip은 쓰이지 않으므로 정리한다(다른 지역 잔재 포함). + prune_sheets(sheet_store, sheet_grid) # 확보된 도엽을 레이어별 병합 GeoJSON으로 산출 (기존 산출물 있으면 스킵) if sheet_result["available"] and ( diff --git a/B04_wf1_Surface/B04_wf1_Surface_Engine_Extent.py b/B04_wf1_Surface/B04_wf1_Surface_Engine_Extent.py new file mode 100644 index 00000000..8a5b768b --- /dev/null +++ b/B04_wf1_Surface/B04_wf1_Surface_Engine_Extent.py @@ -0,0 +1,113 @@ +# B04_wf1_Surface_Engine_Extent.py +# 전처리에서 내려받을 범위와 기준 좌표를 정한다. +# +# 배경(2026-08-01 사용자 지시): 배경 지도·수치지형도 도엽의 기준을 라이다 범위 한가운데로 +# 잡으면, 계획노선이 라이다 범위를 벗어날 때 배경과 도엽이 노선을 덮지 못한다. +# 계획노선(B03 CSV)의 시점·종점을 기준으로 삼고, 없을 때만 라이다 범위로 되돌린다. + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Any + +from config.config_system import SURFACE_MAP_MARGIN_M + +logger = logging.getLogger(__name__) + +_ROUTE_CSV_GLOB = "B03_FileInput/input/csv/*.csv" + + +def read_planned_route(project_root: Path) -> dict[str, Any] | None: + """B03 계획노선 CSV를 읽어 좌표계·범위·시점·종점을 돌려준다. 없거나 형식이 어긋나면 None.""" + from B03_FileInput.B03_FileInput_Engine_Analyze import analyze_planned_route_csv + + for csv_path in sorted(project_root.glob(_ROUTE_CSV_GLOB)): + try: + return analyze_planned_route_csv(csv_path) + except (OSError, ValueError) as exc: + logger.warning("B04 계획노선 CSV 해석 실패: %s (%s)", csv_path.name, exc) + return None + + +def _to_target_crs( + points: list[tuple[float, float]], source_epsg: int | None, target_epsg: str +) -> list[tuple[float, float]]: + """계획노선 좌표를 라이다 좌표계로 옮긴다. 좌표계가 같거나 알 수 없으면 그대로 쓴다.""" + if source_epsg is None: + return points + source = f"EPSG:{source_epsg}" + if source.upper() == target_epsg.upper(): + return points + from pyproj import Transformer + + transformer = Transformer.from_crs(source, target_epsg, always_xy=True) + return [transformer.transform(x, y) for x, y in points] + + +def download_extent( + project_root: Path, + las_bounds: dict[str, list[float]], + target_epsg: str, + margin_m: float = SURFACE_MAP_MARGIN_M, +) -> dict[str, list[float]]: + """배경 지도를 내려받을 범위. 라이다 범위와 계획노선을 합친 뒤 여유폭만큼 넓힌다. + + las_bounds/반환값 모두 {"x": [최소, 최대], "y": [...], "z": [...]} 꼴(라이다 좌표계). + """ + x_min, x_max = float(las_bounds["x"][0]), float(las_bounds["x"][1]) + y_min, y_max = float(las_bounds["y"][0]), float(las_bounds["y"][1]) + + route = read_planned_route(project_root) + if route: + bounds = route["bounds"] + corners = [ + (float(bounds["x_min"]), float(bounds["y_min"])), + (float(bounds["x_max"]), float(bounds["y_max"])), + ] + try: + moved = _to_target_crs(corners, route.get("epsg"), target_epsg) + except Exception as exc: + logger.warning("B04 계획노선 좌표 변환 실패 — 라이다 범위만 사용 (%s)", exc) + moved = [] + for x, y in moved: + x_min, x_max = min(x_min, x), max(x_max, x) + y_min, y_max = min(y_min, y), max(y_max, y) + + return { + "x": [x_min - margin_m, x_max + margin_m], + "y": [y_min - margin_m, y_max + margin_m], + "z": list(las_bounds.get("z", [0.0, 0.0])), + } + + +def sheet_reference_points_wgs84( + project_root: Path, + las_bounds: dict[str, list[float]], + target_epsg: str, +) -> list[tuple[float, float]]: + """도엽 선정 기준 좌표(위도, 경도) 목록. + + ① 계획노선 시점·종점 → ② 라이다 범위 중심(폴백). + 시점과 종점이 서로 다른 도엽에 걸치면 호출측이 두 도엽의 주변을 모두 확보한다. + """ + from pyproj import Transformer + + to_wgs84 = Transformer.from_crs(target_epsg, "EPSG:4326", always_xy=True) + + route = read_planned_route(project_root) + if route: + ends = [ + (float(route["start_point"][0]), float(route["start_point"][1])), + (float(route["end_point"][0]), float(route["end_point"][1])), + ] + try: + moved = _to_target_crs(ends, route.get("epsg"), target_epsg) + return [(lat, lon) for lon, lat in (to_wgs84.transform(x, y) for x, y in moved)] + except Exception as exc: + logger.warning("B04 계획노선 기준 좌표 산출 실패 — 라이다 중심 사용 (%s)", exc) + + center_x = (float(las_bounds["x"][0]) + float(las_bounds["x"][1])) / 2.0 + center_y = (float(las_bounds["y"][0]) + float(las_bounds["y"][1])) / 2.0 + lon, lat = to_wgs84.transform(center_x, center_y) + return [(lat, lon)] diff --git a/B04_wf1_Surface/B04_wf1_Surface_Engine_MapSheet.py b/B04_wf1_Surface/B04_wf1_Surface_Engine_MapSheet.py index e1b0c142..892b25b1 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Engine_MapSheet.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Engine_MapSheet.py @@ -88,3 +88,20 @@ def neighbors_3x3(sheet_no: str) -> list[str]: latlon_to_sheet5k(lat_c + dr * SHEET5K_SIZE_DEG, lon_c + dc * SHEET5K_SIZE_DEG) ) return result + + +def neighbors_for_points(points: list[tuple[float, float]]) -> list[str]: + """기준 좌표들이 속한 도엽 + 각각의 주변 8매를 합친 목록(중복 제거, 순서 유지). + + 계획노선 시점·종점이 같은 도엽이면 9매, 이웃한 두 도엽에 걸치면 12매가 된다 + (3×3 두 벌이 한 줄을 공유하므로 3×4). 도엽 하나가 늘 때마다 병합 산출물도 늘어나므로 + 기준 좌표는 노선의 양 끝만 쓴다(2026-08-01 사용자 지시). + """ + ordered: list[str] = [] + seen: set[str] = set() + for lat, lon in points: + for sheet_no in neighbors_3x3(latlon_to_sheet5k(lat, lon)): + if sheet_no not in seen: + seen.add(sheet_no) + ordered.append(sheet_no) + return ordered diff --git a/B04_wf1_Surface/B04_wf1_Surface_Engine_SheetStore.py b/B04_wf1_Surface/B04_wf1_Surface_Engine_SheetStore.py index 8766e65d..f513143e 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Engine_SheetStore.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Engine_SheetStore.py @@ -18,6 +18,7 @@ import base64 import datetime import http.cookiejar import json +import logging import re import shutil import tempfile @@ -36,6 +37,8 @@ from config.config_system import ( from .B04_wf1_Surface_Engine_MapSheet import latlon_to_sheet5k, sheet5k_to_bounds +logger = logging.getLogger(__name__) + # 도곽선 레이어 코드 (수치지형도 v2.0 도엽본) _SHEET_FRAME_CODE = "A0010000" _SHEET_NO_RE = re.compile(r"(\d{8})") @@ -227,6 +230,39 @@ def get_sheet_path(store_dir: str | Path, sheet_no: str) -> Path | None: return path if path.exists() else None +def prune_sheets(store_dir: str | Path, keep_sheet_nos: list[str]) -> list[str]: + """선정 도엽에 없는 zip을 지우고 지운 도엽번호를 돌려준다. + + 도엽 기준이 바뀌거나 다른 지역 파일이 섞여 들어오면 쓰지 않는 zip이 계속 쌓인다 + (표본 프로젝트에서 30매 중 21매가 다른 지역 잔재였다, 2026-08-01). + 병합에는 선정 도엽만 쓰이므로 산출물은 그대로다. + """ + store = Path(store_dir) + if not store.is_dir(): + return [] + keep = {str(sheet_no) for sheet_no in keep_sheet_nos} + index = _load_index(store) + removed: list[str] = [] + + for zip_path in sorted(store.glob("*.zip")): + match = _SHEET_NO_RE.fullmatch(zip_path.stem) + if not match or match.group(1) in keep: + continue + sheet_no = match.group(1) + try: + zip_path.unlink() + except OSError as exc: + logger.warning("도엽 정리: %s 삭제 실패 (%s)", zip_path.name, exc) + continue + index["sheets"].pop(sheet_no, None) + removed.append(sheet_no) + + if removed: + _save_index(store, index) + logger.info("도엽 정리: 미사용 %d매 삭제 (%s)", len(removed), ", ".join(removed)) + return removed + + def missing_sheets(store_dir: str | Path, sheet_nos: list[str]) -> list[str]: """요청 도엽 중 영구저장소에 없는 번호 목록.""" store = Path(store_dir) diff --git a/B04_wf1_Surface/B04_wf1_Surface_Engine_SheetView.py b/B04_wf1_Surface/B04_wf1_Surface_Engine_SheetView.py new file mode 100644 index 00000000..2db00f54 --- /dev/null +++ b/B04_wf1_Surface/B04_wf1_Surface_Engine_SheetView.py @@ -0,0 +1,165 @@ +# B04_wf1_Surface_Engine_SheetView.py +# 도엽 병합본의 화면 표시용 사본 생성. +# +# 배경(2026-08-01 실측): 9매 병합 등고선(도엽_등고선.geojson)은 37.3MB다. 도엽 zip 원본은 +# 9매 합쳐 8.9MB지만, 등고선 레이어만 뽑아 비압축 텍스트로 펼치면서 커진다 +# (정점 815,471개 × 좌표 소수 14자리). 화면은 프로젝트 주변만 보므로 그만큼 보낼 이유가 없다. +# +# 분석용 원본은 건드리지 않는다 — 배수유역 계산은 상류 유역까지 필요해 넓은 범위를 쓴다. +# 여기서는 프로젝트 주변만 잘라 좌표 자릿수를 줄인 별도 사본을 만들어 전송에만 쓴다. + +from __future__ import annotations + +import json +import logging +import math +from pathlib import Path +from typing import Any + +from config.config_system import ( + SHEET_VIEW_COORD_DECIMALS, + SHEET_VIEW_MARGIN_M, + SHEET_VIEW_SUFFIX, +) + +logger = logging.getLogger(__name__) + +_METERS_PER_LAT_DEGREE = 111_320.0 + + +def view_path_for(source_path: Path) -> Path: + """원본 geojson 경로에 대응하는 표시용 사본 경로.""" + return source_path.with_name(f"{source_path.stem}{SHEET_VIEW_SUFFIX}{source_path.suffix}") + + +def _crop_box(processed_dir: Path) -> tuple[float, float, float, float] | None: + """표시 범위(경도/위도 최소·최대). VWorld 메타의 프로젝트 범위 + 여유폭.""" + meta_path = processed_dir / "vworld_white_meta.json" + if not meta_path.is_file(): + meta_path = processed_dir / "vworld_satellite_meta.json" + if not meta_path.is_file(): + return None + try: + meta = json.loads(meta_path.read_text(encoding="utf-8")) + lon_min = float(meta["lon_min"]) + lon_max = float(meta["lon_max"]) + lat_min = float(meta["lat_min"]) + lat_max = float(meta["lat_max"]) + except (OSError, ValueError, KeyError) as exc: + logger.warning("도엽 표시본: 메타 범위 읽기 실패 (%s)", exc) + return None + + lat_margin = SHEET_VIEW_MARGIN_M / _METERS_PER_LAT_DEGREE + center_lat_rad = math.radians((lat_min + lat_max) / 2.0) + lon_scale = max(math.cos(center_lat_rad), 0.1) + lon_margin = SHEET_VIEW_MARGIN_M / (_METERS_PER_LAT_DEGREE * lon_scale) + return ( + lon_min - lon_margin, + lat_min - lat_margin, + lon_max + lon_margin, + lat_max + lat_margin, + ) + + +def _round_coordinates(value: Any) -> Any: + """좌표 배열을 재귀적으로 훑어 소수 자릿수를 줄인다(고도값 포함). + + shapely는 좌표를 튜플로 돌려주므로 list/tuple 둘 다 받는다. + """ + if isinstance(value, (list, tuple)): + if value and isinstance(value[0], (int, float)): + return [round(float(number), SHEET_VIEW_COORD_DECIMALS) for number in value] + return [_round_coordinates(item) for item in value] + return value + + +def _feature_bbox(coordinates: Any) -> tuple[float, float, float, float] | None: + """지오메트리 좌표 전체를 훑어 bbox를 구한다.""" + lons: list[float] = [] + lats: list[float] = [] + + def walk(node: Any) -> None: + if isinstance(node, list): + if node and isinstance(node[0], (int, float)): + lons.append(float(node[0])) + lats.append(float(node[1])) + return + for item in node: + walk(item) + + walk(coordinates) + if not lons: + return None + return (min(lons), min(lats), max(lons), max(lats)) + + +def build_sheet_view(source_path: Path, processed_dir: Path) -> Path | None: + """도엽 병합본에서 표시용 사본을 만들어 경로를 반환한다. + + - 표시 범위(프로젝트 + 여유폭)와 겹치는 지물만 남긴다(경계에서 자르지 않고 통째로 유지). + - 좌표 소수 자릿수를 줄이고 공백 없는 JSON으로 기록한다. + - 이미 최신 사본이 있으면 그대로 돌려준다(원본보다 오래되면 다시 만든다). + - 표시 범위를 알 수 없으면 None — 호출측이 원본을 그대로 보내면 된다. + """ + view_path = view_path_for(source_path) + if view_path.is_file() and view_path.stat().st_mtime >= source_path.stat().st_mtime: + return view_path + + box = _crop_box(processed_dir) + if box is None: + return None + min_lon, min_lat, max_lon, max_lat = box + + try: + data = json.loads(source_path.read_text(encoding="utf-8")) + except (OSError, ValueError) as exc: + logger.warning("도엽 표시본: 원본 읽기 실패 %s (%s)", source_path.name, exc) + return None + + from shapely.geometry import box, mapping, shape + + crop_area = box(min_lon, min_lat, max_lon, max_lat) + kept: list[dict[str, Any]] = [] + for feature in data.get("features") or []: + geometry = feature.get("geometry") or {} + coordinates = geometry.get("coordinates") + if coordinates is None: + continue + bbox = _feature_bbox(coordinates) + if bbox is None: + continue + if bbox[0] > max_lon or bbox[2] < min_lon or bbox[1] > max_lat or bbox[3] < min_lat: + continue + # 등고선 한 가닥은 도엽 끝까지 이어진다. 겹친다고 통째로 남기면 화면 밖 구간까지 + # 보내게 되므로 표시 범위에서 잘라낸다(고도 등 속성은 그대로 유지). + try: + clipped = shape(geometry).intersection(crop_area) + except Exception: + continue + if clipped.is_empty: + continue + clipped_geometry = mapping(clipped) + feature["geometry"] = { + "type": clipped_geometry["type"], + "coordinates": _round_coordinates(clipped_geometry["coordinates"]), + } + kept.append(feature) + + payload = {"type": "FeatureCollection", "features": kept} + if data.get("crs"): + payload["crs"] = data["crs"] + + tmp_path = view_path.with_suffix(".tmp") + tmp_path.write_text( + json.dumps(payload, ensure_ascii=False, separators=(",", ":")), encoding="utf-8" + ) + tmp_path.replace(view_path) + logger.info( + "도엽 표시본 생성: %s %d건 → %s (%.1fMB → %.1fMB)", + source_path.name, + len(kept), + view_path.name, + source_path.stat().st_size / 1e6, + view_path.stat().st_size / 1e6, + ) + return view_path diff --git a/B04_wf1_Surface/B04_wf1_Surface_Router_GIS.py b/B04_wf1_Surface/B04_wf1_Surface_Router_GIS.py index 48b7b73e..60baa091 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Router_GIS.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Router_GIS.py @@ -5,10 +5,12 @@ from pathlib import Path from typing import Any from uuid import UUID -from fastapi import APIRouter, HTTPException, Response -from fastapi.responses import FileResponse, JSONResponse +from fastapi import APIRouter, HTTPException, Request, Response +from fastapi.responses import JSONResponse from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path +from B04_wf1_Surface.B04_wf1_Surface_Engine_SheetView import build_sheet_view +from common_util.common_util_http_cache import cached_file_response from common_util.common_util_storage import resolve_stored_project_path from config.config_db import get_db_pool @@ -58,9 +60,9 @@ async def get_vworld_meta( # VWorld 맵 API @router.get("/{project_id}/vworld-map", response_model=None) async def get_vworld_map( - project_id: UUID, layer_name: str = "satellite" -) -> FileResponse | JSONResponse: - """배경 지도 레이어 PNG 이미지를 반환합니다.""" + project_id: UUID, request: Request, layer_name: str = "satellite" +) -> Response | JSONResponse: + """배경 지도 레이어 PNG 이미지를 반환합니다(ETag — 바뀌지 않았으면 304).""" pool = get_db_pool() try: async with pool.acquire() as connection: @@ -86,15 +88,21 @@ async def get_vworld_map( "message": f"VWorld {layer_name} 지도가 존재하지 않습니다.", }, ) - return FileResponse(map_path, media_type="image/png") + return cached_file_response(request, map_path, "image/png") except Exception as exc: return JSONResponse(status_code=500, content={"status": "error", "message": str(exc)}) # GeoJSON 조회 API @router.get("/{project_id}/geojson", response_model=None) -async def get_project_geojson(project_id: UUID, layer: str) -> dict[str, Any] | JSONResponse: - """저장된 프로젝트의 특정 GeoJSON 레이어 데이터를 반환합니다.""" +async def get_project_geojson( + project_id: UUID, layer: str, request: Request +) -> dict[str, Any] | Response | JSONResponse: + """저장된 프로젝트의 특정 GeoJSON 레이어 데이터를 반환합니다. + + 도엽 레이어는 분석용 원본 대신 **표시용 사본**(프로젝트 주변만 잘라 좌표를 줄인 것)을 + 파일 그대로 내보낸다. 원본을 매번 읽어 재직렬화하면 등고선 한 장에 2초가 든다. + """ pool = get_db_pool() try: async with pool.acquire() as connection: @@ -140,6 +148,14 @@ async def get_project_geojson(project_id: UUID, layer: str) -> dict[str, Any] | }, ) + if layer in _SHEET_GEOJSON_FILES: + view_path = await asyncio.to_thread(build_sheet_view, filepath, target_dir) + return cached_file_response( + request, + view_path or filepath, + "application/geo+json", + ) + if layer == "등고선": simplified_filepath = target_dir / "등고선_bounds_simplified.geojson" if simplified_filepath.exists(): diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts index 30499e4e..4a1788a8 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts @@ -1,5 +1,6 @@ import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { createProgressCircle } from "@ui/ui_template_progress"; +import { fetchCachedSheetLayer } from "../A00_Common/b_asset_cache"; import { fetchGisGeoJson, fetchVWorldMeta, @@ -385,7 +386,12 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { const loadedLayers = await Promise.all( GIS_LAYERS.map(async (layer) => { try { - const data = (await fetchGisGeoJson(projectId, layer)) as GeoJsonCollection; + // 도엽 레이어는 표시용 사본이라 보관함에 담아 두고 새로고침 때 그대로 쓴다. + const data = ( + layer.startsWith("도엽_") + ? await fetchCachedSheetLayer(projectId, layer) + : await fetchGisGeoJson(projectId, layer) + ) as GeoJsonCollection; return [layer, data] as const; } catch { return [layer, null] as const; @@ -408,7 +414,8 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { preparedLayers.set(layer, prepareLayer(data, normalizer!, CONTOUR_LABEL_KEYS[layer])); }); BACKGROUND_LAYERS.forEach((layer) => { - backgroundImages.get(layer)!.src = `${getVWorldMapUrl(projectId, layer)}&_t=${Date.now()}`; + // 주소에 시각을 붙이면 브라우저가 매번 다시 받는다. 서버가 ETag를 주므로 그대로 쓴다. + backgroundImages.get(layer)!.src = getVWorldMapUrl(projectId, layer); }); status.textContent = L("B04_Surface_Map_Features").replace( "{count}", diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts index 226e7682..bdaf06b6 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts @@ -1,6 +1,6 @@ import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; +import { DRAINAGE_SHEET_LAYERS, fetchCachedSheetLayer } from "../A00_Common/b_asset_cache"; import { - fetchGisGeoJson, fetchVWorldMeta, getVWorldMapUrl, type VWorldMeta, @@ -40,7 +40,7 @@ import { createProgressCircle } from "@ui/ui_template_progress"; /** 배수유역 산정의 근거가 되는 도엽 레이어. 3D는 쓰지 않는다(사용자 지시). * 표고점은 유효 데이터가 적어 산정에서 제외했으므로 배경에도 띄우지 않는다(2026-07-31). */ -const DRAINAGE_LAYERS = ["도엽_등고선", "도엽_하천중심선"] as const; +const DRAINAGE_LAYERS = DRAINAGE_SHEET_LAYERS; type DrainageLayer = (typeof DRAINAGE_LAYERS)[number]; const LAYER_COLORS: Record = { @@ -522,7 +522,8 @@ export function createDrainagePanel(): DrainagePanel { const loaded = await Promise.all( DRAINAGE_LAYERS.map(async (layer) => { try { - const data = (await fetchGisGeoJson(activeProjectId, layer)) as GeoJsonCollection; + // 도엽 레이어는 표시용 사본이라 보관함에 담아 두고 새로고침 때 그대로 쓴다. + const data = await fetchCachedSheetLayer(activeProjectId, layer); return [layer, data] as const; } catch { return [layer, null] as const; @@ -538,7 +539,8 @@ export function createDrainagePanel(): DrainagePanel { featureCount += data.features?.length ?? 0; preparedLayers.set(layer, prepareLayer(data, normalizer!)); }); - backgroundImage.src = `${getVWorldMapUrl(activeProjectId, "satellite")}&_t=${Date.now()}`; + // 주소에 시각을 붙이면 브라우저가 매번 다시 받는다. 서버가 ETag를 주므로 그대로 쓴다. + backgroundImage.src = getVWorldMapUrl(activeProjectId, "satellite"); if (routePoints.length > 1) routeLayer = prepareMetricPolyline(routePoints, nextMeta); pipeEditor.setContext(nextMeta, routePoints); status.hidden = featureCount > 0; diff --git a/config/config_system.py b/config/config_system.py index acba027c..d94fc375 100644 --- a/config/config_system.py +++ b/config/config_system.py @@ -516,6 +516,20 @@ STORAGE_BASE_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "sto MAP_SHEETS_DIRNAME = "map_sheets" MAP_SHEETS_INDEX_FILENAME = "map_sheets_index.json" +# 도엽 병합본(도엽_*.geojson)의 화면 표시용 사본 — 분석용 원본은 그대로 두고, +# 브라우저로 보낼 때만 프로젝트 주변으로 잘라 좌표 자릿수를 줄인 사본을 쓴다. +# (9매 병합 등고선 원본 37MB → 표시용 1MB 안팎, 2026-08-01) +SHEET_VIEW_SUFFIX = "_view" +SHEET_VIEW_MARGIN_M = 500.0 +# 소수 7자리 ≈ 1cm — 1:5,000 도엽 표시 정밀도에 충분하다. +SHEET_VIEW_COORD_DECIMALS = 7 + +# 배경 지도(위성·하이브리드·백지도) 내려받을 범위의 여유폭. +# 라이다 범위와 계획노선을 합친 뒤 이만큼 넓혀서 받는다 — 계획노선이 라이다 범위를 벗어나도 +# 배경이 잘리지 않게 한다. 브이월드 위성사진은 더 높은 해상도를 주지 않으므로 도엽만큼 +# 넓게 받을 필요는 없다(2026-08-01 사용자 지시). +SURFACE_MAP_MARGIN_M = 300.0 + # 브이월드 지도서비스 도엽 자동 다운로드 — 세션 만료 시 id/pw로 자동 재로그인 (.env) VWORLD_LOGIN_ID = os.getenv("VWORLD_LOGIN_ID", "") VWORLD_LOGIN_PW = os.getenv("VWORLD_LOGIN_PW", "")