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:
2026-08-01 11:47:29 +09:00
co-authored by Claude Opus 5
parent 96561de120
commit a9caf872b2
10 changed files with 437 additions and 35 deletions
+26
View File
@@ -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;
}
+27 -21
View File
@@ -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
+24 -8
View File
@@ -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;
+14
View File
@@ -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", "")