perf(509-1 · 509-3 · 509-6 도엽 등고선 범위 · 화면 밖 건너뜀): 노선 편집 = 노선 띠(저장 노선 bbox + 300 + 여유 300 m)로 자른 도엽 · 배수유역도 = 세부 유역 ∪ 줌아웃 한계 + 300 m 로 따로 자른 도엽(서버 _derived/sheet_<범위> · 사본 지문 + 범위 열쇠) · 띠가 범위 밖인 초안 = 통째 사본 · 미리 받기도 범위본 · 공용 선 그리기 = 같은 쪽 화면 밖 구간 끊음(돌린 화면 = 반 대각선 넓힘) — 요구 317

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-21 20:41:54 +09:00
co-authored by Claude Opus 5.5
parent 23fd2f6f8d
commit 3bc710a636
13 changed files with 1009 additions and 38 deletions
@@ -0,0 +1,244 @@
"""도엽 표시본 잘라 두기 — 평면도 = 노선 띠 · 배수유역도 = 유역 + 여유(509-1 · 509-6 · 서버 단독 ③).
도엽 사본은 약 6.7×8.3 km 라 노선 편집 · 배수유역도가 통째로 받으면 그릴 꼭짓점의 9 할이
화면에 안 드는 헛일이다(요구 316). 화면마다 **보는 범위**만 잘라 보낸다 — 형상은 그대로 ·
범위 밖만 사라짐. 분석(배수유역 · 관 자리)은 원본 도엽을 읽으므로 이 사본과 상관없다.
- 범위(사업지 m · 바깥으로 `SNAP_M` 눈금에 맞춤 → 노선을 조금 옮겨도 같은 열쇠)
- `route` = 저장 노선 bbox + 띠 `ROUTE_BAND_M` + 여유 `ROUTE_SPARE_M` — 편집이 이 밖으로
나가면 화면이 통째 사본을 받는다(`clip_lonlat` 으로 판정).
- `drainage` = 세부 유역(노선 교차 관으로 모이는 유역) ∪ 줌아웃 한계(노선 bbox ×2)
+ `DRAIN_SPARE_M`.
- 파생 저장본 = `_derived/sheet_<범위>/<열쇠>.json`(GeoJSON 그대로 · 열쇠 = 사본 지문 + 범위)
· 지워도 다시 셈 · 범위를 못 정하면 사본 그대로.
- 경위도 ↔ m 는 화면과 같은 가정(위성 메타의 m 사각형 = 경위도 사각형 · `metricToScreen`).
"""
from __future__ import annotations
import json
import logging
import math
import os
import tempfile
from pathlib import Path
from typing import Any
from shapely import clip_by_rect
from shapely.geometry import mapping, shape
from common_util.common_util_derived_store import (
derived_dir,
derived_key,
file_stamp,
prune_derived,
)
from common_util.common_util_json import LONLAT_DIGITS, round_floats
from common_util.common_util_route_geometry import (
expected_route_csv_path,
planned_route_initial_path,
planned_route_working_path,
read_planned_route_csv,
)
from config.config_system import DRAINAGE_CACHE_DIRNAME, DRAINAGE_DETAIL_FILENAME
logger = logging.getLogger(__name__)
SCOPES = ("route", "drainage")
CLIP_VERSION = 1
ROUTE_BAND_M = 300.0 # = B05 `CONTOUR_BAND_M`(등고선을 보일 노선 둘레 띠)
ROUTE_SPARE_M = 300.0 # 띠 밖 여유 — 노선을 이만큼 옮겨도 다시 안 받음
DRAIN_SPARE_M = 300.0 # 유역 · 줌아웃 한계 밖 여유 — 화면 가로세로 비 · 테두리
ZOOM_OUT_ROUTE_FACTOR = 2.0 # = B05 `ZOOM_OUT_ROUTE_FACTOR`(노선 범위 몇 배까지 멀리 보나)
SNAP_M = 50.0
Rect = tuple[float, float, float, float]
def _read_meta(processed: Path) -> dict[str, Any] | None:
for name in ("vworld_satellite_meta.json", "vworld_meta.json"):
try:
meta = json.loads((processed / name).read_text(encoding="utf-8"))
except (OSError, ValueError):
continue
keys = ("x_min", "y_min", "width_meters", "height_meters")
if all(
meta.get(k) is not None for k in keys + ("lon_min", "lon_max", "lat_min", "lat_max")
):
return meta
return None
def _to_lonlat(meta: dict[str, Any], x: float, y: float) -> tuple[float, float]:
lon = meta["lon_min"] + (x - meta["x_min"]) / (meta["width_meters"] or 1) * (
meta["lon_max"] - meta["lon_min"]
)
lat = meta["lat_min"] + (y - meta["y_min"]) / (meta["height_meters"] or 1) * (
meta["lat_max"] - meta["lat_min"]
)
return lon, lat
def _to_metric(meta: dict[str, Any], lon: float, lat: float) -> tuple[float, float]:
x = (
meta["x_min"]
+ (lon - meta["lon_min"])
/ ((meta["lon_max"] - meta["lon_min"]) or 1)
* (meta["width_meters"])
)
y = (
meta["y_min"]
+ (lat - meta["lat_min"])
/ ((meta["lat_max"] - meta["lat_min"]) or 1)
* (meta["height_meters"])
)
return x, y
def _bounds(points: list[tuple[float, float]]) -> Rect | None:
if not points:
return None
xs = [p[0] for p in points]
ys = [p[1] for p in points]
return min(xs), min(ys), max(xs), max(ys)
def _grow(rect: Rect, dx: float, dy: float | None = None) -> Rect:
dy = dx if dy is None else dy
return rect[0] - dx, rect[1] - dy, rect[2] + dx, rect[3] + dy
def _union(a: Rect, b: Rect | None) -> Rect:
if b is None:
return a
return min(a[0], b[0]), min(a[1], b[1]), max(a[2], b[2]), max(a[3], b[3])
def _route_points(project_root: Path) -> list[tuple[float, float]]:
"""저장 노선(`read_route_plan` planned 와 같은 차례 — 수정본 · 초기 · 예상노선)."""
for path in (
planned_route_working_path(project_root),
planned_route_initial_path(project_root),
expected_route_csv_path(project_root),
):
route = read_planned_route_csv(path) if path.is_file() else None
if route is not None and len(route.vertices) >= 2:
return [(float(v.x), float(v.y)) for v in route.vertices]
return []
def _basin_points(project_root: Path, meta: dict[str, Any]) -> list[tuple[float, float]]:
"""세부 유역 꼭짓점(m) — 관마다 모이는 유역(B05 배수유역도가 그리는 것)."""
path = project_root / "B04_PreProcess" / DRAINAGE_CACHE_DIRNAME / DRAINAGE_DETAIL_FILENAME
try:
features = json.loads(path.read_text(encoding="utf-8")).get("features") or []
except (OSError, ValueError):
return []
points: list[tuple[float, float]] = []
for feature in features:
try:
lon0, lat0, lon1, lat1 = shape(feature["geometry"]).bounds
except Exception: # 깨진 피처 하나가 범위를 막지 않음
continue
points += [_to_metric(meta, lon0, lat0), _to_metric(meta, lon1, lat1)]
return points
def clip_rect_m(project_root: Path, scope: str, meta: dict[str, Any]) -> Rect | None:
"""범위 사각형(사업지 m · 눈금 맞춤). 노선이 없으면(또는 배수유역도에 유역이 없으면) None."""
route = _bounds(_route_points(project_root))
if route is None:
return None
if scope == "route":
rect = _grow(route, ROUTE_BAND_M + ROUTE_SPARE_M)
else:
basins = _bounds(_basin_points(project_root, meta))
if basins is None:
return None
half = (ZOOM_OUT_ROUTE_FACTOR - 1) / 2
zoom_out = _grow(route, (route[2] - route[0]) * half, (route[3] - route[1]) * half)
rect = _grow(_union(zoom_out, basins), DRAIN_SPARE_M)
steps = [round(value / SNAP_M, 6) for value in rect] # 경위도 되셈의 끝자리 흔들림 걷음
return (
math.floor(steps[0]) * SNAP_M,
math.floor(steps[1]) * SNAP_M,
math.ceil(steps[2]) * SNAP_M,
math.ceil(steps[3]) * SNAP_M,
)
def _coord_bounds(coords: Any) -> Rect:
"""GeoJSON 좌표(중첩 목록)의 bbox — shapely 를 만들기 전 거르기."""
stack = [coords]
x0 = y0 = math.inf
x1 = y1 = -math.inf
while stack:
item = stack.pop()
if item and isinstance(item[0], (int, float)):
x, y = item[0], item[1]
x0, y0, x1, y1 = min(x0, x), min(y0, y), max(x1, x), max(y1, y)
else:
stack.extend(item)
return x0, y0, x1, y1
def clip_collection(collection: dict[str, Any], rect: Rect) -> dict[str, Any]:
"""사각형(경위도) 밖을 걷은 컬렉션. 안에 다 든 피처는 그대로 · 걸친 것만 잘라 냄."""
west, south, east, north = rect
features = []
for feature in collection.get("features") or []:
geometry = feature.get("geometry")
if not geometry or not geometry.get("coordinates"):
continue
x0, y0, x1, y1 = _coord_bounds(geometry["coordinates"])
if x1 < west or x0 > east or y1 < south or y0 > north:
continue
if not (x0 >= west and x1 <= east and y0 >= south and y1 <= north):
cut = clip_by_rect(shape(geometry), *rect) # 점은 bbox 로 이미 갈림
if cut.is_empty:
continue
geometry = round_floats(mapping(cut), LONLAT_DIGITS)
features.append({**feature, "geometry": geometry})
return {**collection, "features": features, "clip_lonlat": [round(v, 7) for v in rect]}
def _write_compact(path: Path, value: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
handle, temporary = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".tmp")
try:
with os.fdopen(handle, "w", encoding="utf-8") as file:
json.dump(value, file, ensure_ascii=False, separators=(",", ":"))
os.replace(temporary, path)
except BaseException:
Path(temporary).unlink(missing_ok=True)
raise
def clipped_sheet(project_root: Path, source: Path, scope: str) -> Path:
"""`scope` 범위로 자른 표시본 경로. 범위를 못 정하거나 실패하면 `source` 그대로."""
try:
meta = _read_meta(project_root / "B04_PreProcess" / "processed")
rect_m = clip_rect_m(project_root, scope, meta) if meta and scope in SCOPES else None
if rect_m is None or meta is None:
return source
west, south = _to_lonlat(meta, rect_m[0], rect_m[1])
east, north = _to_lonlat(meta, rect_m[2], rect_m[3])
kind = f"sheet_{scope}"
key = derived_key(CLIP_VERSION, source.name, file_stamp(source), rect_m)
target = derived_dir(project_root, kind) / f"{key}.json"
if target.is_file():
return target
collection = json.loads(source.read_text(encoding="utf-8"))
_write_compact(target, clip_collection(collection, (west, south, east, north)))
prune_derived(project_root, kind, keep=4) # 레이어 둘 × 두 벌
logger.info(
"도엽 %s 범위 사본: %s (%.1fMB → %.1fMB)",
scope,
source.name,
source.stat().st_size / 1e6,
target.stat().st_size / 1e6,
)
return target
except Exception as exc: # 자르기 실패가 화면을 막으면 안 됨 — 통째 사본
logger.warning("도엽 %s 범위 사본 실패 (%s): %s", scope, source.name, exc)
return source
+8 -6
View File
@@ -18,6 +18,8 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/projects", tags=["B04 Surface GIS"])
tiles_router = APIRouter(tags=["B04 MVT Tiles"])
from B04_PreProcess.B04_PreProcess_Engine_SheetClip import clipped_sheet # noqa: E402
# 수치지형도 도엽 병합 레이어 (SheetParser 산출과 1:1 — 정의는 SHEET_LAYERS가 유일)
from B04_PreProcess.B04_PreProcess_Engine_SheetParser import SHEET_LAYERS # noqa: E402
@@ -96,12 +98,13 @@ async def get_vworld_map(
# GeoJSON 조회 API
@router.get("/{project_id}/geojson", response_model=None)
async def get_project_geojson(
project_id: UUID, layer: str, request: Request
project_id: UUID, layer: str, request: Request, scope: str | None = None
) -> dict[str, Any] | Response | JSONResponse:
"""저장된 프로젝트의 특정 GeoJSON 레이어 데이터를 반환합니다.
도엽 레이어는 분석용 원본 대신 **표시용 사본**(프로젝트 주변만 잘라 좌표를 줄인 것)을
파일 그대로 내보낸다. 원본을 매번 읽어 재직렬화하면 등고선 한 장에 2초가 든다.
`scope`(route · drainage)를 주면 그 화면이 보는 범위만 자른 사본을 준다(509-1 · 509-6).
"""
pool = get_db_pool()
try:
@@ -153,11 +156,10 @@ async def get_project_geojson(
# 재직렬화만 건너뛰어도 요청당 2초가 사라지고, ETag로 두 번째부터는 304가 된다.
# 다만 **자릿수만 줄인 사본**을 한 번 만들어 그것을 보낸다(2026-09-06) — 형상은
# 그대로고 뜻 없는 자리만 사라진다.
return cached_file_response(
request,
await asyncio.to_thread(_display_copy, filepath),
"application/geo+json",
)
display = await asyncio.to_thread(_display_copy, filepath)
if scope:
display = await asyncio.to_thread(clipped_sheet, project_root, display, scope)
return cached_file_response(request, display, "application/geo+json")
if layer == "등고선":
simplified_filepath = target_dir / "등고선_bounds_simplified.geojson"
+59 -13
View File
@@ -273,7 +273,7 @@ export function drawPreparedFeature(
): void {
const feature = layer.features[index];
if (!feature || feature.kind !== "line") return;
drawLineParts(context, feature, affineOf(view));
drawLineParts(context, feature, affineOf(view), view, Infinity); // 몇 가닥뿐 — 안 거름
}
/** 점과 선분 사이 거리(px). */
@@ -358,9 +358,19 @@ function drawLineParts(
context: CanvasRenderingContext2D,
feature: PreparedFeature,
affine: Affine,
view: ViewState,
pad: number,
): void {
// affine.ax = 정규화 1.0당 화면 px — 종횡비 보정 좌표계의 거리를 px로 바꾸는 계수.
const tolerance = LOD_PX / affine.ax;
// 화면 밖 구간 건너뛰기(509-3) — 두 끝이 **같은 쪽** 화면 밖이면 그 구간은 안 보임 → 선을 끊음.
// 한쪽이라도 화면 쪽이거나 서로 다른 쪽(모서리를 질러 갈 수 있음)이면 그대로 그림.
const left = -CULL_MARGIN - pad;
const top = -CULL_MARGIN - pad;
const right = view.width + CULL_MARGIN + pad;
const bottom = view.height + CULL_MARGIN + pad;
const sideOf = (x: number, y: number): number =>
(x < left ? 1 : x > right ? 2 : 0) | (y < top ? 4 : y > bottom ? 8 : 0);
for (const part of feature.parts) {
const coords = part.coords;
if (coords.length < 4) continue;
@@ -368,19 +378,51 @@ function drawLineParts(
// 현재 줌에서 화면 오차 LOD_PX 미만인 정점만 생략 (끝점은 weight=∞라 항상 유지).
// 정점 사이 보간은 하지 않는다 — 원본 데이터의 형상 그대로 표시 (2026-07-28 사용자 지시).
context.beginPath();
let started = false;
let kept = 0;
let moves = 0;
let fromFirst = false;
let penDown = false;
let firstX = 0;
let firstY = 0;
let firstSide = 0;
let prevX = 0;
let prevY = 0;
let prevSide = 0;
for (let i = 0; i < coords.length; i += 2) {
if (weights && weights[i / 2] < tolerance) continue;
const x = coords[i] * affine.ax + affine.bx;
const y = coords[i + 1] * affine.ay + affine.by;
if (started) context.lineTo(x, y);
else {
context.moveTo(x, y);
started = true;
const side = sideOf(x, y);
if (kept === 0) {
firstX = x;
firstY = y;
firstSide = side;
} else if (side & prevSide) {
penDown = false;
} else {
if (!penDown) {
context.moveTo(prevX, prevY);
if (moves === 0) fromFirst = kept === 1;
moves += 1;
}
context.lineTo(x, y);
penDown = true;
}
kept += 1;
prevX = x;
prevY = y;
prevSide = side;
}
if (part.closed && kept > 2) {
// 한 줄로 이어졌으면 closePath(모서리 이음 그대로) · 끊겼으면 닫는 구간만 따로.
if (moves === 1 && fromFirst && penDown) context.closePath();
else if (!(prevSide & firstSide)) {
if (!penDown) context.moveTo(prevX, prevY);
context.lineTo(firstX, firstY);
moves += 1;
}
}
if (!started) continue;
if (part.closed) context.closePath();
if (moves === 0) continue;
context.stroke();
}
}
@@ -424,8 +466,8 @@ const CULL_MARGIN = 32;
const LABEL_GAP_PX = 10;
const LABEL_ROW_PX = 14;
function isVisible(feature: PreparedFeature, affine: Affine, view: ViewState): boolean {
const margin = CULL_MARGIN;
function isVisible(feature: PreparedFeature, affine: Affine, view: ViewState, pad = 0): boolean {
const margin = CULL_MARGIN + pad;
const minX = feature.minX * affine.ax + affine.bx;
const maxX = feature.maxX * affine.ax + affine.bx;
const minY = feature.minY * affine.ay + affine.by;
@@ -506,21 +548,25 @@ export function pickLevelStep(
*
* `everyM` 을 주면 **그 배수의 표고만** 그린다. 1m 간격 LAS 등고선을 멀리서 다 그리면 화면이
* 선으로 뭉개져 지형이 안 읽힌다 — 확대에 따라 성긴 등고선부터 내보이려는 것이다. 안 주면
* 전부 그리므로 기존 화면(B04 지도·배수유역도)의 표기는 그대로다. */
* 전부 그리므로 기존 화면(B04 지도·배수유역도)의 표기는 그대로다.
*
* `padPx` = 화면 밖 판정을 이만큼 넓힘 — 그림을 돌린 화면(B05 노선 편집)은 모서리가 화면 사각형
* 밖에서 들어오므로 반 대각선만큼 준다. */
export function drawPreparedLayer(
context: CanvasRenderingContext2D,
layer: PreparedLayer,
view: ViewState,
marker: MarkerKind,
everyM?: number,
padPx = 0,
): void {
const affine = affineOf(view);
const step = everyM !== undefined && everyM > 0 ? everyM : 0;
for (const feature of layer.features) {
if (step && feature.labelValue !== null && feature.labelValue % step !== 0) continue;
if (!isVisible(feature, affine, view)) continue;
if (!isVisible(feature, affine, view, padPx)) continue;
if (feature.kind === "point") drawPointParts(context, feature, affine, marker);
else drawLineParts(context, feature, affine);
else drawLineParts(context, feature, affine, view, padPx);
}
}