- 도엽 병합본의 화면 표시용 사본 생성(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>
166 lines
6.2 KiB
Python
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
|