Files
Aislo/B04_wf1_Surface/B04_wf1_Surface_Engine_SheetView.py
T
eomsangdonandClaude Opus 5 a9caf872b2 perf(B04/B05): 도엽 표시본 도입 + 배경 범위·도엽 기준을 계획노선으로 변경
- 도엽 병합본의 화면 표시용 사본 생성(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) <noreply@anthropic.com>
2026-08-01 11:47:29 +09:00

166 lines
6.2 KiB
Python

# 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