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>
This commit is contained in:
@@ -205,6 +205,20 @@ export async function fetchCachedJson<T>(
|
||||
return JSON.parse(new TextDecoder().decode(bytes)) as T;
|
||||
}
|
||||
|
||||
/** 배수유역도 배경으로 쓰는 도엽 레이어(유일한 정의처 — 준비화면과 B05 패널이 함께 쓴다). */
|
||||
export const DRAINAGE_SHEET_LAYERS = ["도엽_등고선", "도엽_하천중심선"] as const;
|
||||
|
||||
/** 도엽 레이어(GeoJSON)를 보관함에서 먼저 찾는다.
|
||||
*
|
||||
* 서버는 프로젝트 주변만 잘라 좌표 자릿수를 줄인 표시용 사본을 ETag와 함께 내보낸다.
|
||||
* 분석용 원본과는 별개 파일이므로, 담아 두었다가 그대로 다시 써도 화면이 어긋나지 않는다. */
|
||||
export async function fetchCachedSheetLayer<T>(projectId: string, layer: string): Promise<T> {
|
||||
return fetchCachedJson<T>(
|
||||
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)),
|
||||
);
|
||||
// 위성사진은 <img>로 표시하므로 보관함이 아니라 브라우저 자체 캐시를 데워 둔다.
|
||||
await fetch(`${API_BASE_URL}/projects/${projectId}/vworld-map?layer_name=satellite`, {
|
||||
credentials: "include",
|
||||
}).catch(() => null);
|
||||
|
||||
report("준비 완료", 1);
|
||||
return confirmed.signature;
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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)]
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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():
|
||||
|
||||
@@ -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<GeoJsonCollection>(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}",
|
||||
|
||||
@@ -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<DrainageLayer, string> = {
|
||||
@@ -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<GeoJsonCollection>(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;
|
||||
|
||||
@@ -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", "")
|
||||
|
||||
Reference in New Issue
Block a user