feat(D 예정노선): 도엽 조각 — 공용 창고 storage/_shared/sheets · 보이는 도엽만 받기 (PLAN 606-1 · 606-2 · 요구 477)
- 새 D03_PlanAnalysis_SheetTiles.py — 도엽 도곽으로 자른 선 겹 조각(등고 · 계곡 = 전국 등고선 · 세류 · 물 면 · 도로 · 건물 · 지명 = 도엽 zip) · zip 은 프로젝트 · 공용 지형 창고에서 복사(원본 안 지움) 뒤 받기 · 빈 도엽 = 빈 조각 · 받기 실패 저장 안 함 · B04 함수 부르기만 - 라우터 길 둘 — 보이는 도엽 목록(bbox) · 조각 하나 - 새 UI_SheetTiles.ts — 보기 멈춘 뒤 보이는 도엽 · 없는 조각만 하나씩 · 12 장 이하 다 · 13~30 계곡선만 · 넘음 숨김 · 서클 - UI_Layers — 도엽 선 겹 원천을 조각으로 · 시험 606 새 · 475 옛 겹 길 단언 바꿈 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -196,6 +196,21 @@ export const fetchPlanCadastral = (projectId: string): Promise<GeoJsonCollection
|
||||
export const fetchPlanSheetLayer = (projectId: string, key: string): Promise<GeoJsonCollection> =>
|
||||
request(`${base(projectId)}/layers/sheet?key=${encodeURIComponent(key)}`);
|
||||
|
||||
/** 보이는 도엽(606-2) — 지도 보기 경위도 상자 → 수 · 번호(가운데부터 · 30 장 넘음 = 빈 목록). */
|
||||
export const fetchPlanSheetTiles = (
|
||||
projectId: string,
|
||||
bbox: [number, number, number, number],
|
||||
): Promise<{ count: number; sheets: string[] }> =>
|
||||
request(`${base(projectId)}/sheet-tiles?bbox=${bbox.map((v) => v.toFixed(5)).join(",")}`);
|
||||
|
||||
/** 도엽 조각 하나(606-1) — 공용 창고 · 없으면 서버가 만듦. */
|
||||
export const fetchPlanSheetTile = (
|
||||
projectId: string,
|
||||
sheet: string,
|
||||
name: string,
|
||||
): Promise<GeoJsonCollection> =>
|
||||
request(`${base(projectId)}/sheet-tiles/${sheet}/${encodeURIComponent(name)}`);
|
||||
|
||||
export const fetchPlanRaster = (
|
||||
projectId: string,
|
||||
kind: "slope" | "hillshade" | "landslide",
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
`…/zones` · `…/soils` · `…/bands` · `…/lines?kind=` · `…/zero-order` ·
|
||||
`…/forest-roads`(GET · POST 올리기 · `/bundles` 목록 · DELETE `/<번호>`) ·
|
||||
`…/landslide-zones` — 지도 겹 (PLAN 569-1 · 581-1 · 581-2 · 584-1 · 584-3 · 605-1)
|
||||
- `GET …/plan/sheet-tiles?bbox=` · `…/sheet-tiles/{도엽}/{겹}` — 보이는 도엽 · 도엽 조각(606)
|
||||
|
||||
범위 = 기준점 둘레(`SITE_RADIUS_M`) + 지점 + 그은 점(`extra` · 지도 보기 귀) 상자 + 여유 ∪ 받은 범위
|
||||
(PLAN 591-2 · 기준점 = 처음 받는 기준일 뿐).
|
||||
@@ -42,6 +43,7 @@ from D03_PlanAnalysis import D03_PlanAnalysis_Landslide as landslide
|
||||
from D03_PlanAnalysis import D03_PlanAnalysis_LandslideZone as landslide_zone
|
||||
from D03_PlanAnalysis import D03_PlanAnalysis_Layers as layers
|
||||
from D03_PlanAnalysis import D03_PlanAnalysis_Network as network
|
||||
from D03_PlanAnalysis import D03_PlanAnalysis_SheetTiles as sheet_tiles
|
||||
from D03_PlanAnalysis import D03_PlanAnalysis_Store as store
|
||||
from D03_PlanAnalysis import D03_PlanAnalysis_Terrain as terrain
|
||||
from D03_PlanAnalysis import D03_PlanAnalysis_ZeroOrder as zero_order
|
||||
@@ -346,6 +348,31 @@ async def plan_layer_sheet(project_id: UUID, key: str, request: Request) -> Resp
|
||||
return cached_file_response(request, clipped, "application/geo+json")
|
||||
|
||||
|
||||
@router.get("/{project_id}/plan/sheet-tiles", response_model=None)
|
||||
async def plan_sheet_tiles(project_id: UUID, bbox: str) -> dict[str, Any]:
|
||||
"""보이는 도엽(606-2) — 지도 보기 경위도 상자 → 수 · 번호(가운데부터 · 30 장 넘음 = 없음)."""
|
||||
try:
|
||||
lon0, lat0, lon1, lat1 = (float(v) for v in bbox.split(","))
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=400, detail="bbox = 경도,위도,경도,위도") from error
|
||||
if not (120 < lon0 < 135 and 120 < lon1 < 135 and 30 < lat0 < 45 and 30 < lat1 < 45):
|
||||
raise HTTPException(status_code=400, detail="국내 경위도가 아닙니다.")
|
||||
count, sheets = sheet_tiles.sheets_in_box((lon0, lat0, lon1, lat1))
|
||||
return {"count": count, "sheets": sheets}
|
||||
|
||||
|
||||
@router.get("/{project_id}/plan/sheet-tiles/{sheet_no}/{name}", response_model=None)
|
||||
async def plan_sheet_tile(project_id: UUID, sheet_no: str, name: str, request: Request) -> Response:
|
||||
"""도엽 조각 하나(606-1) — 공용 창고 · 없으면 만듦(등고 = 전국 파일 · 그 밖 = 도엽 zip)."""
|
||||
try:
|
||||
path = await asyncio.to_thread(sheet_tiles.ensure_tile, sheet_no, name)
|
||||
except KeyError as error:
|
||||
raise HTTPException(status_code=400, detail=f"없는 도엽 조각: {error}") from error
|
||||
except RuntimeError as error:
|
||||
raise HTTPException(status_code=502, detail=str(error)) from error
|
||||
return cached_file_response(request, path, "application/geo+json")
|
||||
|
||||
|
||||
@router.get("/{project_id}/plan/layers/cadastral", response_model=None)
|
||||
async def plan_layer_cadastral(project_id: UUID, request: Request) -> Response:
|
||||
"""지적도 GeoJSON(브이월드 LX맵 · 참고용) — 받은 범위를 덮지 않으면 지금 받음."""
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
"""도엽 조각 — 수치지형도 선 겹을 도엽(1:5,000) 한 장씩 공용 창고에(PLAN 606 · 요구 477).
|
||||
|
||||
자리 = `storage/_shared/sheets/<도엽>/`(모든 작업 폴더가 보는 정션 · 시놀로지로 데스크탑에도)
|
||||
— 도엽 zip + 색인(그 도엽 하나 · B04 받기 함수에 이 폴더를 창고로 줌) + `layers/<겹>_<판>.geojson`.
|
||||
조각 하나 = 도엽 도곽으로 자른 경위도 GeoJSON(자릿수 7) — 이웃 조각과 겹침 없음.
|
||||
- 등고선 · 계곡선(25 m 배수) = 전국 등고선 파일에서 자름 · 그 도엽에 없으면(섬 등) 도엽 zip 등고선.
|
||||
- 세류(하천중심선) · 물 면 셋 · 도로중심선 · 건물 · 지명 = 도엽 zip.
|
||||
도엽 zip = 창고에 없으면 프로젝트 `map_sheets` · 공용 지형 창고(607)에서 복사(원본 안 지움) · 그래도
|
||||
없으면 브이월드 받기. 받기 실패(로그인 · 망)는 저장 안 함(다음에 다시) · 검색 0(빈 도엽) = 빈 조각.
|
||||
B04 함수는 부르기만(`prune_sheets` 안 부름 · 요구 491).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
from pyproj import Transformer
|
||||
|
||||
from B04_PreProcess.B04_PreProcess_Engine_MapSheet import (
|
||||
SHEET5K_SIZE_DEG,
|
||||
latlon_to_sheet5k,
|
||||
sheet5k_to_bounds,
|
||||
)
|
||||
from common_util.common_util_json import LONLAT_DIGITS, round_floats
|
||||
from common_util.common_util_terrain_store import NATIONAL_GPKG, TERRAIN_ROOT, _Lock
|
||||
from config.config_system import STORAGE_BASE_DIR
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SHEETS_ROOT = Path(STORAGE_BASE_DIR) / "_shared" / "sheets"
|
||||
TILE_VERSION = 1
|
||||
CONTOUR, INDEX = "등고선", "계곡선"
|
||||
INDEX_STEP_M = 25
|
||||
ZIP_LAYERS = ("하천중심선", "도로중심선", "건물", "지명", "실폭하천", "수계면E003", "수계면E005")
|
||||
TILE_NAMES = (CONTOUR, INDEX, *ZIP_LAYERS)
|
||||
# 보이는 도엽 수 — 이하 = 다 · 넘고 INDEX_MAX 이하 = 계곡선만 · 넘음 = 선 겹 숨김(목록 안 줌)
|
||||
FULL_MAX, INDEX_MAX = 12, 30
|
||||
SHEET_NO = re.compile(r"^\d{8}$")
|
||||
_EMPTY = {"type": "FeatureCollection", "features": []}
|
||||
|
||||
Box = tuple[float, float, float, float]
|
||||
|
||||
|
||||
def sheets_in_box(bbox: Box) -> tuple[int, list[str]]:
|
||||
"""경위도 상자에 닿는 도엽 (수 · 번호 — 가운데부터). 수가 `INDEX_MAX` 넘으면 번호 없음."""
|
||||
lon0, lat0, lon1, lat1 = bbox
|
||||
step = SHEET5K_SIZE_DEG
|
||||
i0, i1 = math.floor(min(lat0, lat1) / step), math.floor(max(lat0, lat1) / step)
|
||||
j0, j1 = math.floor(min(lon0, lon1) / step), math.floor(max(lon0, lon1) / step)
|
||||
count = (i1 - i0 + 1) * (j1 - j0 + 1)
|
||||
if count > INDEX_MAX:
|
||||
return count, []
|
||||
mid_lat, mid_lon = (lat0 + lat1) / 2, (lon0 + lon1) / 2
|
||||
cells = [(i, j) for i in range(i0, i1 + 1) for j in range(j0, j1 + 1)]
|
||||
cells.sort(
|
||||
key=lambda c: ((c[0] + 0.5) * step - mid_lat) ** 2 + ((c[1] + 0.5) * step - mid_lon) ** 2
|
||||
)
|
||||
return count, [latlon_to_sheet5k((i + 0.5) * step, (j + 0.5) * step) for i, j in cells]
|
||||
|
||||
|
||||
def _gpkg_tag() -> str | None:
|
||||
try:
|
||||
stat = NATIONAL_GPKG.stat()
|
||||
except OSError:
|
||||
return None
|
||||
return hashlib.sha1(f"{stat.st_size}:{stat.st_mtime_ns}".encode()).hexdigest()[:8]
|
||||
|
||||
|
||||
def _tile_file(folder: Path, name: str) -> Path | None:
|
||||
"""지금 판의 조각 파일 자리 — 등고 둘은 전국 파일 지문을 이름에(바뀌면 다시) ·
|
||||
전국 파일 없는 PC 는 있는 조각을 믿음 · 그것도 없으면 None(zip 길)."""
|
||||
layers = folder / "layers"
|
||||
if name not in (CONTOUR, INDEX):
|
||||
return layers / f"{name}_v{TILE_VERSION}.geojson"
|
||||
tag = _gpkg_tag()
|
||||
if tag:
|
||||
return layers / f"{name}_v{TILE_VERSION}_{tag}.geojson"
|
||||
found = sorted(layers.glob(f"{name}_v{TILE_VERSION}_*.geojson"))
|
||||
return found[-1] if found else layers / f"{name}_v{TILE_VERSION}_zip.geojson"
|
||||
|
||||
|
||||
def _write(path: Path, collection: dict[str, Any]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
part = path.with_suffix(".part")
|
||||
text = json.dumps(
|
||||
round_floats(collection, LONLAT_DIGITS), ensure_ascii=False, separators=(",", ":")
|
||||
)
|
||||
part.write_text(text, encoding="utf-8")
|
||||
part.replace(path)
|
||||
|
||||
|
||||
def _clipped(frame: Any, rect: Box) -> dict[str, Any]:
|
||||
"""경위도 GeoDataFrame → 도곽으로 자른 FeatureCollection(빈 형상 뺌 · 날짜는 글로)."""
|
||||
import shapely
|
||||
|
||||
frame = frame.copy()
|
||||
frame["geometry"] = shapely.clip_by_rect(frame.geometry.values, *rect)
|
||||
frame = frame[~frame.geometry.is_empty]
|
||||
if frame.empty:
|
||||
return dict(_EMPTY)
|
||||
return json.loads(frame.to_json(drop_id=True, default=str))
|
||||
|
||||
|
||||
def _project_zips(sheet_no: str) -> list[Path]:
|
||||
"""옮겨 담을 후보 — 공용 지형 창고(607) · 프로젝트 `map_sheets`(새것부터)."""
|
||||
found = list(
|
||||
Path(STORAGE_BASE_DIR).glob(f"*/*/*/B04_PreProcess/processed/map_sheets/{sheet_no}.zip")
|
||||
)
|
||||
found.sort(key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
terrain = TERRAIN_ROOT / sheet_no / f"{sheet_no}.zip"
|
||||
return ([terrain] if terrain.is_file() else []) + found
|
||||
|
||||
|
||||
def ensure_zip(sheet_no: str) -> Path | None:
|
||||
"""창고 도엽 zip — 없으면 옮겨 담기 → 받기. 빈 도엽(검색 0) = None · 받기 실패 = 오류."""
|
||||
from B04_PreProcess.B04_PreProcess_Engine_SheetStore import (
|
||||
ensure_sheets,
|
||||
get_sheet_path,
|
||||
ingest_zip,
|
||||
)
|
||||
|
||||
folder = SHEETS_ROOT / sheet_no
|
||||
path = get_sheet_path(folder, sheet_no)
|
||||
if path is not None:
|
||||
return path
|
||||
with _Lock(folder / ".zip.lock"):
|
||||
path = get_sheet_path(folder, sheet_no)
|
||||
if path is not None:
|
||||
return path
|
||||
for source in _project_zips(sheet_no):
|
||||
try:
|
||||
ingest_zip(folder, source, sheet_no)
|
||||
logger.info("도엽 조각 zip 옮겨 담음 %s ← %s", sheet_no, source)
|
||||
return get_sheet_path(folder, sheet_no)
|
||||
except Exception as error: # noqa: BLE001 — 깨진 사본이면 다음 후보
|
||||
logger.warning("도엽 zip 옮겨 담기 실패 %s (%s): %s", sheet_no, source, error)
|
||||
result = ensure_sheets(folder, [sheet_no])
|
||||
if sheet_no in result["available"]:
|
||||
return Path(result["available"][sheet_no])
|
||||
reason = result["failed"].get(sheet_no, "")
|
||||
if reason.startswith("LookupError"):
|
||||
return None
|
||||
raise RuntimeError(f"도엽 {sheet_no} 받기 실패: {reason}")
|
||||
|
||||
|
||||
def _zip_layer(sheet_no: str, name: str) -> Any | None:
|
||||
"""창고 zip 의 겹 하나 → 경위도 GeoDataFrame(없음 = None)."""
|
||||
from B04_PreProcess.B04_PreProcess_Engine_SheetParser import SHEET_LAYERS, _read_layer_from_zip
|
||||
from B04_PreProcess.B04_PreProcess_Engine_SheetStore import _load_index
|
||||
|
||||
path = ensure_zip(sheet_no)
|
||||
if path is None:
|
||||
return None
|
||||
entry = _load_index(SHEETS_ROOT / sheet_no)["sheets"][sheet_no]
|
||||
prefix, code = SHEET_LAYERS[name]
|
||||
return _read_layer_from_zip(path, prefix, code, entry["crs"])
|
||||
|
||||
|
||||
def _gpkg_contours(rect: Box) -> Any | None:
|
||||
"""전국 등고선에서 도곽 상자에 닿는 선 → 경위도 GeoDataFrame(`등고수치`) · 없음 = None."""
|
||||
if not NATIONAL_GPKG.is_file():
|
||||
return None
|
||||
import pyogrio
|
||||
|
||||
lon0, lat0, lon1, lat1 = rect
|
||||
t = np.linspace(0.0, 1.0, 11)
|
||||
lon = np.concatenate(
|
||||
[lon0 + (lon1 - lon0) * t, lon0 + (lon1 - lon0) * t, np.full(11, lon0), np.full(11, lon1)]
|
||||
)
|
||||
lat = np.concatenate(
|
||||
[np.full(11, lat0), np.full(11, lat1), lat0 + (lat1 - lat0) * t, lat0 + (lat1 - lat0) * t]
|
||||
)
|
||||
x, y = Transformer.from_crs("EPSG:4326", "EPSG:5179", always_xy=True).transform(lon, lat)
|
||||
frame = pyogrio.read_dataframe(
|
||||
NATIONAL_GPKG,
|
||||
bbox=(x.min() - 5, y.min() - 5, x.max() + 5, y.max() + 5),
|
||||
columns=["CTRLN_HG"],
|
||||
)
|
||||
if frame.empty:
|
||||
return None
|
||||
frame = frame.rename(columns={"CTRLN_HG": "등고수치"}).to_crs("EPSG:4326")
|
||||
return frame[["등고수치", "geometry"]]
|
||||
|
||||
|
||||
def _build_contours(sheet_no: str, rect: Box) -> None:
|
||||
frame = _gpkg_contours(rect)
|
||||
if frame is None:
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Grid import ELEVATION_KEYS
|
||||
|
||||
frame = _zip_layer(sheet_no, CONTOUR)
|
||||
column = next((k for k in ELEVATION_KEYS if frame is not None and k in frame.columns), None)
|
||||
frame = (
|
||||
None
|
||||
if column is None
|
||||
else frame.rename(columns={column: "등고수치"})[["등고수치", "geometry"]]
|
||||
)
|
||||
full = dict(_EMPTY) if frame is None else _clipped(frame, rect)
|
||||
index = {
|
||||
**full,
|
||||
"features": [
|
||||
f
|
||||
for f in full["features"]
|
||||
if float(f["properties"].get("등고수치") or 0) % INDEX_STEP_M == 0
|
||||
],
|
||||
}
|
||||
folder = SHEETS_ROOT / sheet_no
|
||||
_write(_tile_file(folder, INDEX), index) # 계곡선 먼저 — 등고선 파일이 있으면 둘 다 있음
|
||||
_write(_tile_file(folder, CONTOUR), full)
|
||||
|
||||
|
||||
def ensure_tile(sheet_no: str, name: str) -> Path:
|
||||
"""도엽 조각 파일 — 있으면 그대로 · 없으면 만듦(받기 실패 = RuntimeError · 저장 안 함)."""
|
||||
if not SHEET_NO.match(sheet_no) or name not in TILE_NAMES:
|
||||
raise KeyError(f"{sheet_no}/{name}")
|
||||
folder = SHEETS_ROOT / sheet_no
|
||||
path = _tile_file(folder, name)
|
||||
if path.is_file():
|
||||
return path
|
||||
with _Lock(folder / f".{'contour' if name in (CONTOUR, INDEX) else name}.lock"):
|
||||
path = _tile_file(folder, name)
|
||||
if path.is_file():
|
||||
return path
|
||||
lon0, lat0, lon1, lat1 = sheet5k_to_bounds(sheet_no)
|
||||
rect = (lon0, lat0, lon1, lat1)
|
||||
if name in (CONTOUR, INDEX):
|
||||
_build_contours(sheet_no, rect)
|
||||
path = _tile_file(folder, name)
|
||||
else:
|
||||
frame = _zip_layer(sheet_no, name)
|
||||
_write(path, dict(_EMPTY) if frame is None else _clipped(frame, rect))
|
||||
return path
|
||||
|
||||
|
||||
def collect_project_zips() -> dict[str, int]:
|
||||
"""한 번 — 프로젝트마다 받아 둔 도엽 zip 을 공용 창고로 복사(원본 안 지움 · 있으면 건너뜀)."""
|
||||
nos = {
|
||||
p.stem
|
||||
for p in Path(STORAGE_BASE_DIR).glob("*/*/*/B04_PreProcess/processed/map_sheets/*.zip")
|
||||
}
|
||||
moved = failed = 0
|
||||
for no in sorted(n for n in nos if SHEET_NO.match(n)):
|
||||
try:
|
||||
moved += ensure_zip(no) is not None
|
||||
except Exception as error: # noqa: BLE001
|
||||
failed += 1
|
||||
logger.warning("도엽 zip 모으기 실패 %s: %s", no, error)
|
||||
return {"sheets": len(nos), "stored": moved, "failed": failed}
|
||||
@@ -11,7 +11,7 @@
|
||||
* 계류 100 m · 도로 300 m 띠 · 건물 · 지명).
|
||||
* 처음 켬 = 위성 · 등고선 · 세류 · 고른 값 = 화면 취향(`aislo:pref:d03.layers`) 기억.
|
||||
* 받기 = 켤 때 · 받은 지형 범위 지문(`load(have)` · 페이지가 지형면을 다 읽은 뒤 넘김)이 바뀌면 차례 +1 · 켠 겹 모두 다시 ·
|
||||
* 옛 차례 답은 버림(475) · 범위는 서버가 받은 범위로 정함(등고 · 세류 · 도엽 겹 = 서버가 자름) · 지형을 안 받았으면 단추를 막음 ·
|
||||
* 옛 차례 답은 버림(475) · 도엽 선 겹(등고 · 세류 · 물 면 · 도로 · 건물 · 지명) = 보이는 도엽 조각(606 · `UI_SheetTiles`) · 지형을 안 받았으면 단추를 막음 ·
|
||||
* 받기 실패 = 그 겹만 꺼짐 + 알림 · 받는 일은 페이지 서클에 셈(`track`).
|
||||
* 그리기 차례(아래 → 위) = 위성(GL 조각만 · 옛 캔버스 = 위성 없음 · 590-2) → 음영기복 → 급경사 → 산사태위험지도 → 산림입지도 → 보호구역 → 띠 → 지적 → 물 면 →
|
||||
* 건물 → 등고선 → 도로 → 임도 짐작 → 올린 임도망도 → 세류 → 선 겹 → 산사태취약지역 → 지명 → 그은 선.
|
||||
@@ -47,7 +47,6 @@ import {
|
||||
fetchPlanLandslideZones,
|
||||
fetchPlanLines,
|
||||
fetchPlanRaster,
|
||||
fetchPlanSheetLayer,
|
||||
fetchPlanSoils,
|
||||
fetchPlanZeroOrder,
|
||||
fetchPlanZones,
|
||||
@@ -75,6 +74,11 @@ import { layerCounts, legendRows, type Prepared } from "./D03_PlanAnalysis_UI_La
|
||||
import { createCanvasLayers } from "./D03_PlanAnalysis_UI_LayerPaint";
|
||||
import { createGlBase, GL_FONT, type GlImage } from "./D03_PlanAnalysis_UI_MapGl";
|
||||
import { createSatLightRow, readSatLight } from "./D03_PlanAnalysis_UI_SatLight";
|
||||
import {
|
||||
createSheetTiles,
|
||||
SHEET_TILE_NAMES,
|
||||
type SheetTiles,
|
||||
} from "./D03_PlanAnalysis_UI_SheetTiles";
|
||||
import {
|
||||
openSlopeBands,
|
||||
paintSlope,
|
||||
@@ -122,6 +126,7 @@ export interface PlanLayers {
|
||||
/** 겹이 받은 범위(마지막 `load` 값) · 차례 번호(475 검증). */
|
||||
area: [number, number, number, number] | null;
|
||||
turn: number;
|
||||
tiles: ReturnType<SheetTiles["state"]>;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -147,6 +152,18 @@ export function createPlanLayers(opts: {
|
||||
useGl(false);
|
||||
});
|
||||
let glOn = readByKey(CANVAS_PREF_KEY) !== "1";
|
||||
/** 도엽 조각(606) — 조각이 오면 그 겹만 합친 자료로 다시 얹음. */
|
||||
const tiles = createSheetTiles({
|
||||
projectId,
|
||||
track,
|
||||
wanted: () =>
|
||||
meta ? LAYERS.flatMap((def) => (on[def.key] && SHEET_TILE_NAMES[def.key]) || []) : [],
|
||||
onChange: (names) => {
|
||||
for (const def of LAYERS)
|
||||
if (meta && on[def.key] && SHEET_TILE_NAMES[def.key]?.some((name) => names.has(name)))
|
||||
void fetchLayer(def.key, meta, areaTurn).then(() => (sync(), map.redrawBase()));
|
||||
},
|
||||
});
|
||||
|
||||
const buttons = new Map<PlanLayerKey, HTMLButtonElement>();
|
||||
const groups = GROUPS.map((group) =>
|
||||
@@ -437,15 +454,12 @@ export function createPlanLayers(opts: {
|
||||
features: (collection.features ?? []).filter((feature) => test(feature.properties ?? {})),
|
||||
});
|
||||
if (key === "contour") {
|
||||
// 받은 범위로 서버가 자름 — 범위가 늘면 `load` 가 다시 받게 함
|
||||
const [collection] = await Promise.all([
|
||||
fetchPlanSheetLayer(projectId, "등고선"),
|
||||
glFont("400", "0123456789-"),
|
||||
]);
|
||||
await glFont("400", "0123456789-");
|
||||
const collection = tiles.collection("등고선");
|
||||
got.contour = indexLayerLod(prepare(collection, ["등고수치"]));
|
||||
put("contour", collection);
|
||||
} else if (key === "stream") {
|
||||
const collection = await fetchPlanSheetLayer(projectId, "하천중심선");
|
||||
const collection = tiles.collection("하천중심선");
|
||||
got.stream = [
|
||||
prepare(pick(collection, (props) => props["구분"] === "세류")),
|
||||
prepare(pick(collection, (props) => props["구분"] !== "세류")),
|
||||
@@ -456,7 +470,7 @@ export function createPlanLayers(opts: {
|
||||
got.cadastral = prepare(collection);
|
||||
put("cadastral", collection);
|
||||
} else if (key === "road" || key === "forestRoad") {
|
||||
const collection = await fetchPlanSheetLayer(projectId, "도로중심선");
|
||||
const collection = tiles.collection("도로중심선");
|
||||
got.road = {
|
||||
all: prepare(collection),
|
||||
small: prepare(
|
||||
@@ -500,14 +514,12 @@ export function createPlanLayers(opts: {
|
||||
if (key !== "hillshade") put(key, plain);
|
||||
else if (plain) shade = { plain, shade: glImage(raster, shadeImage(image))! };
|
||||
} else if (key === "water") {
|
||||
const parts = await Promise.all(
|
||||
WATER_KEYS.map((name) => fetchPlanSheetLayer(projectId, name)),
|
||||
);
|
||||
const parts = WATER_KEYS.map((name) => tiles.collection(name));
|
||||
const water = { features: parts.flatMap((part) => part.features ?? []) };
|
||||
got.water = prepare(water);
|
||||
put("water", water);
|
||||
} else if (key === "building") {
|
||||
const building = await fetchPlanSheetLayer(projectId, "건물");
|
||||
const building = tiles.collection("건물");
|
||||
got.building = prepare(building);
|
||||
put("building", building);
|
||||
} else if (key === "zones") {
|
||||
@@ -587,7 +599,7 @@ export function createPlanLayers(opts: {
|
||||
],
|
||||
});
|
||||
} else {
|
||||
const collection = await fetchPlanSheetLayer(projectId, "지명");
|
||||
const collection = tiles.collection("지명");
|
||||
got.place = (collection.features ?? []).flatMap((feature) => {
|
||||
const geometry = feature.geometry;
|
||||
const name = feature.properties?.["명칭"];
|
||||
@@ -623,6 +635,7 @@ export function createPlanLayers(opts: {
|
||||
{
|
||||
base: true,
|
||||
draw: (_, m) => {
|
||||
if (meta) tiles.view(m, meta);
|
||||
if (glOn && on.contour && data.contour)
|
||||
gl.setContourStep(pickLevelStep(data.contour, m.view, 5));
|
||||
},
|
||||
@@ -673,6 +686,7 @@ export function createPlanLayers(opts: {
|
||||
),
|
||||
area,
|
||||
turn: areaTurn,
|
||||
tiles: tiles.state(),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
/* =============================================================================
|
||||
* D03_PlanAnalysis_UI_SheetTiles.ts
|
||||
* D03 수치지형도 선 겹 = 도엽 조각(PLAN 606 · 요구 477) — 위성처럼 지도 보기에 보이는 도엽만.
|
||||
* 보기 멈춘 뒤(`VIEW_IDLE_MS`) 보이는 도엽 목록(서버 · 가운데부터) → 켠 겹의 없는 조각만 하나씩 받음
|
||||
* (등고 먼저 · 보조 겹 뒤) → 받을 때마다 `onChange`(겹이 합친 자료를 다시 얹음).
|
||||
* 보이는 도엽 12 장 이하 = 다 · 13~30 = 계곡선만(등고선 자리에 · 그 밖 선 겹 빔) · 넘음 = 선 겹 숨김.
|
||||
* 받은 조각은 기억(최근 도엽 `KEEP_SHEETS` 장) — 다시 오면 즉시 · 받는 동안 서클(`track`).
|
||||
* 지형 받기와 따로 — 보기만 옮겨도 조각은 받음 · 지형(받은 범위)은 안 받음(596-4 그대로).
|
||||
* ========================================================================== */
|
||||
|
||||
import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import type { GeoJsonCollection } from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
|
||||
import { fetchPlanSheetTile, fetchPlanSheetTiles } from "./D03_PlanAnalysis_Api";
|
||||
import { WATER_KEYS, type PlanLayerKey } from "./D03_PlanAnalysis_UI_LayerDefs";
|
||||
import type { ScreenMap } from "./D03_PlanAnalysis_UI_Map";
|
||||
|
||||
/** 겹 → 도엽 조각 이름(서버 `TILE_NAMES`). */
|
||||
export const SHEET_TILE_NAMES: Partial<Record<PlanLayerKey, string[]>> = {
|
||||
contour: ["등고선"],
|
||||
stream: ["하천중심선"],
|
||||
road: ["도로중심선"],
|
||||
forestRoad: ["도로중심선"],
|
||||
water: WATER_KEYS,
|
||||
building: ["건물"],
|
||||
place: ["지명"],
|
||||
};
|
||||
export const FULL_MAX = 12;
|
||||
export const INDEX_MAX = 30;
|
||||
const VIEW_IDLE_MS = 800;
|
||||
const KEEP_SHEETS = 24;
|
||||
const CHANGE_MS = 150;
|
||||
|
||||
export type SheetMode = "all" | "index" | "hidden";
|
||||
|
||||
export interface SheetTiles {
|
||||
/** 바탕 그릴 때마다 — 보기가 멈추면 보이는 도엽을 다시 셈. */
|
||||
view: (m: ScreenMap, meta: VWorldMeta) => void;
|
||||
/** 겹 조각 이름의 보이는 도엽 합(모드대로 · 없는 조각은 받기 시작). */
|
||||
collection: (name: string) => GeoJsonCollection;
|
||||
state: () => {
|
||||
mode: SheetMode;
|
||||
count: number;
|
||||
sheets: string[];
|
||||
cached: number;
|
||||
loading: boolean;
|
||||
failed: string[];
|
||||
/** 조각마다 받은 시간(ms · 마지막 20). */
|
||||
ms: Array<[string, number]>;
|
||||
};
|
||||
}
|
||||
|
||||
export function createSheetTiles(opts: {
|
||||
projectId: string;
|
||||
track: <T>(job: Promise<T>) => Promise<T>;
|
||||
/** 지금 켠 겹의 조각 이름. */
|
||||
wanted: () => string[];
|
||||
onChange: (names: Set<string>) => void;
|
||||
}): SheetTiles {
|
||||
const { projectId, track } = opts;
|
||||
const cache = new Map<string, GeoJsonCollection>();
|
||||
const failed = new Set<string>();
|
||||
const ms: Array<[string, number]> = [];
|
||||
let recent: string[] = [];
|
||||
let sheets: string[] = [];
|
||||
let count = 0;
|
||||
let mode: SheetMode = "all";
|
||||
let lastBox = "";
|
||||
let timer = 0;
|
||||
let turn = 0;
|
||||
let loading = false;
|
||||
|
||||
/** 바뀐 조각 이름 — 잠깐 모아 한 번에 알림(겹마다 합친 자료를 다시 얹는 일 줄임). */
|
||||
const dirty = new Set<string>();
|
||||
let flush = 0;
|
||||
function changed(names: string[]): void {
|
||||
for (const name of names) dirty.add(name);
|
||||
window.clearTimeout(flush);
|
||||
flush = window.setTimeout(() => {
|
||||
const names = new Set(dirty);
|
||||
dirty.clear();
|
||||
opts.onChange(names);
|
||||
}, CHANGE_MS);
|
||||
}
|
||||
|
||||
/** 모드에 따른 실제 조각 — 계곡선만이면 등고선 = 계곡선 · 그 밖 = 없음. */
|
||||
const source = (name: string): string | null =>
|
||||
mode === "hidden" ? null : mode === "all" ? name : name === "등고선" ? "계곡선" : null;
|
||||
|
||||
function nextJob(): [string, string] | null {
|
||||
const names = [...new Set(opts.wanted())].map(source).filter((n): n is string => !!n);
|
||||
// 등고 먼저 · 보조 겹(zip) 뒤
|
||||
names.sort(
|
||||
(a, b) =>
|
||||
Number(!a.endsWith("고선") && a !== "계곡선") -
|
||||
Number(!b.endsWith("고선") && b !== "계곡선"),
|
||||
);
|
||||
for (const name of names)
|
||||
for (const sheet of sheets) {
|
||||
const key = `${sheet}/${name}`;
|
||||
if (!cache.has(key) && !failed.has(key)) return [sheet, name];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 없는 조각을 하나씩 — 목록이 바뀌면(차례) 남은 것은 새 목록으로. */
|
||||
async function pull(): Promise<void> {
|
||||
if (loading) return;
|
||||
loading = true;
|
||||
try {
|
||||
for (let job = nextJob(); job; job = nextJob()) {
|
||||
const [sheet, name] = job;
|
||||
const key = `${sheet}/${name}`;
|
||||
const started = performance.now();
|
||||
try {
|
||||
cache.set(key, await fetchPlanSheetTile(projectId, sheet, name));
|
||||
} catch {
|
||||
failed.add(key); // 이번 화면에선 다시 안 받음(다음 목록 때 다시)
|
||||
}
|
||||
ms.push([key, Math.round(performance.now() - started)]);
|
||||
if (ms.length > 20) ms.shift();
|
||||
if (sheets.includes(sheet)) changed([name === "계곡선" ? "등고선" : name]);
|
||||
}
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
const kick = (): void => void (nextJob() && !loading && track(pull()));
|
||||
|
||||
async function list(bbox: [number, number, number, number]): Promise<void> {
|
||||
const mine = ++turn;
|
||||
const got = await fetchPlanSheetTiles(projectId, bbox).catch(() => null);
|
||||
if (!got || mine !== turn) return;
|
||||
const before = `${mode}|${sheets.join()}`;
|
||||
count = got.count;
|
||||
sheets = got.sheets;
|
||||
mode = count <= FULL_MAX ? "all" : count <= INDEX_MAX ? "index" : "hidden";
|
||||
failed.clear();
|
||||
recent = [...new Set([...sheets, ...recent])].slice(0, Math.max(KEEP_SHEETS, sheets.length));
|
||||
for (const key of cache.keys()) if (!recent.includes(key.split("/")[0])) cache.delete(key);
|
||||
if (`${mode}|${sheets.join()}` !== before) changed(Object.values(SHEET_TILE_NAMES).flat());
|
||||
kick();
|
||||
}
|
||||
|
||||
return {
|
||||
view: (m, meta) => {
|
||||
const { width, height } = m.view;
|
||||
if (!width || !height) return;
|
||||
// 그림 px 네 귀 → 작업 좌표 → 경위도(위성 틀 1차) · 돌린 지도 = 대각선만큼 넓힘
|
||||
const grow = m.uprightRad ? Math.SQRT2 : 1;
|
||||
const corners = [
|
||||
[0, 0],
|
||||
[width, 0],
|
||||
[0, height],
|
||||
[width, height],
|
||||
].map(([px, py]) =>
|
||||
m.toMetric(width / 2 + (px - width / 2) * grow, height / 2 + (py - height / 2) * grow),
|
||||
);
|
||||
const lon = (x: number) =>
|
||||
meta.lon_min +
|
||||
((x - meta.x_min) / (meta.width_meters || 1)) * (meta.lon_max - meta.lon_min);
|
||||
const lat = (y: number) =>
|
||||
meta.lat_min +
|
||||
((y - meta.y_min) / (meta.height_meters || 1)) * (meta.lat_max - meta.lat_min);
|
||||
const lons = corners.map(([x]) => lon(x));
|
||||
const lats = corners.map(([, y]) => lat(y));
|
||||
const bbox: [number, number, number, number] = [
|
||||
Math.min(...lons),
|
||||
Math.min(...lats),
|
||||
Math.max(...lons),
|
||||
Math.max(...lats),
|
||||
];
|
||||
const tag = bbox.map((v) => v.toFixed(4)).join();
|
||||
if (tag === lastBox) return;
|
||||
lastBox = tag;
|
||||
window.clearTimeout(timer);
|
||||
timer = window.setTimeout(() => void list(bbox), VIEW_IDLE_MS);
|
||||
},
|
||||
collection: (name) => {
|
||||
const src = source(name);
|
||||
if (src) kick();
|
||||
return {
|
||||
type: "FeatureCollection",
|
||||
features: src
|
||||
? sheets.flatMap((sheet) => cache.get(`${sheet}/${src}`)?.features ?? [])
|
||||
: [],
|
||||
} as GeoJsonCollection;
|
||||
},
|
||||
state: () => ({
|
||||
mode,
|
||||
count,
|
||||
sheets: [...sheets],
|
||||
cached: cache.size,
|
||||
loading,
|
||||
failed: [...failed],
|
||||
ms: [...ms],
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -96,9 +96,9 @@ def test_layers_turn_drops_old_answers() -> None:
|
||||
assert (
|
||||
"pending.get(slot) === job && pending.delete(slot)" in layers
|
||||
) # 옛 일이 새 기다림을 안 지움
|
||||
# 등고 · 세류 = 예정노선 겹 길(서버가 받은 범위로 자름) · 화면 상자 · 보관함 지문 걷음
|
||||
assert 'fetchPlanSheetLayer(projectId, "등고선")' in layers
|
||||
assert 'fetchPlanSheetLayer(projectId, "하천중심선")' in layers
|
||||
# 등고 · 세류 = 보이는 도엽 조각(606) · 옛 화면 상자 · 보관함 지문 걷음
|
||||
assert 'tiles.collection("등고선")' in layers
|
||||
assert 'tiles.collection("하천중심선")' in layers
|
||||
for gone in ("/geojson?layer=", "CONTOUR_PAD_M", "haveTag", "opts.have"):
|
||||
assert gone not in layers, gone
|
||||
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
"""도엽 조각(PLAN 606) — 보이는 도엽 수 · 차례 · 30 장 넘음 = 목록 없음 · 조각 = 도곽 안 ·
|
||||
처음만 만듦 · 계곡선 = 등고선 중 25 m 배수 · 빈 도엽 = 빈 조각 · 받기 실패는 저장 안 함 ·
|
||||
화면 = 보이는 도엽만(12 · 30 문턱) · 옛 받은 범위 겹 길 안 씀.
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_606_sheet_tiles.py -q
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from B04_PreProcess.B04_PreProcess_Engine_MapSheet import sheet5k_center, sheet5k_to_bounds
|
||||
from D03_PlanAnalysis import D03_PlanAnalysis_SheetTiles as st
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SHEET = "36901074"
|
||||
|
||||
|
||||
def _inside(collection, rect, eps=1e-6):
|
||||
lon0, lat0, lon1, lat1 = rect
|
||||
|
||||
def walk(c):
|
||||
if isinstance(c[0], (int, float)):
|
||||
assert lon0 - eps <= c[0] <= lon1 + eps and lat0 - eps <= c[1] <= lat1 + eps
|
||||
else:
|
||||
for part in c:
|
||||
walk(part)
|
||||
|
||||
for feature in collection["features"]:
|
||||
walk(feature["geometry"]["coordinates"])
|
||||
|
||||
|
||||
def test_sheets_in_box_count_order_cap() -> None:
|
||||
lat, lon = sheet5k_center(SHEET)
|
||||
assert st.sheets_in_box((lon - 0.001, lat - 0.001, lon + 0.001, lat + 0.001)) == (1, [SHEET])
|
||||
count, sheets = st.sheets_in_box((lon - 0.02, lat - 0.02, lon + 0.02, lat + 0.02))
|
||||
assert count == len(sheets) == 9 and sheets[0] == SHEET # 가운데부터
|
||||
count, sheets = st.sheets_in_box((lon - 0.1, lat - 0.1, lon + 0.1, lat + 0.1))
|
||||
assert count > st.INDEX_MAX and sheets == []
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def shelf(tmp_path, monkeypatch):
|
||||
found = sorted(ROOT.glob(f"storage/*/*/*/B04_PreProcess/processed/map_sheets/{SHEET}.zip"))
|
||||
found += sorted((ROOT / "storage" / "_shared" / "sheets" / SHEET).glob("*.zip"))
|
||||
if not found:
|
||||
pytest.skip("도엽 zip 없음")
|
||||
monkeypatch.setattr(st, "SHEETS_ROOT", tmp_path)
|
||||
monkeypatch.setattr(st, "_project_zips", lambda no: found[:1] if no == SHEET else [])
|
||||
return tmp_path
|
||||
|
||||
|
||||
def test_zip_tile_clipped_and_made_once(shelf) -> None:
|
||||
path = st.ensure_tile(SHEET, "하천중심선")
|
||||
collection = json.loads(path.read_text(encoding="utf-8"))
|
||||
assert collection["features"]
|
||||
_inside(collection, sheet5k_to_bounds(SHEET))
|
||||
stamp = path.stat().st_mtime_ns
|
||||
assert st.ensure_tile(SHEET, "하천중심선") == path and path.stat().st_mtime_ns == stamp
|
||||
assert (shelf / SHEET / f"{SHEET}.zip").is_file() # 옮겨 담음(원본은 그대로)
|
||||
|
||||
|
||||
def test_contour_and_index(shelf) -> None:
|
||||
full = json.loads(st.ensure_tile(SHEET, st.CONTOUR).read_text(encoding="utf-8"))
|
||||
index = json.loads(st.ensure_tile(SHEET, st.INDEX).read_text(encoding="utf-8"))
|
||||
levels = [f["properties"]["등고수치"] for f in full["features"]]
|
||||
assert levels and all(isinstance(v, (int, float)) for v in levels)
|
||||
assert index["features"] and all(
|
||||
f["properties"]["등고수치"] % 25 == 0 for f in index["features"]
|
||||
)
|
||||
assert len(index["features"]) == sum(1 for v in levels if v % 25 == 0)
|
||||
_inside(full, sheet5k_to_bounds(SHEET))
|
||||
|
||||
|
||||
def test_bad_name_rejected(shelf) -> None:
|
||||
with pytest.raises(KeyError):
|
||||
st.ensure_tile(SHEET, "../x")
|
||||
with pytest.raises(KeyError):
|
||||
st.ensure_tile("1234", "건물")
|
||||
|
||||
|
||||
def _no_download(monkeypatch, reason):
|
||||
import B04_PreProcess.B04_PreProcess_Engine_SheetStore as sheet_store
|
||||
|
||||
monkeypatch.setattr(
|
||||
sheet_store,
|
||||
"ensure_sheets",
|
||||
lambda folder, nos: {"available": {}, "failed": {nos[0]: reason}},
|
||||
)
|
||||
|
||||
|
||||
def test_empty_sheet_gives_empty_tile(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setattr(st, "SHEETS_ROOT", tmp_path)
|
||||
monkeypatch.setattr(st, "_project_zips", lambda no: [])
|
||||
_no_download(monkeypatch, "LookupError: 검색 결과 없음")
|
||||
path = st.ensure_tile("37507039", "건물")
|
||||
assert json.loads(path.read_text(encoding="utf-8"))["features"] == []
|
||||
|
||||
|
||||
def test_download_failure_not_saved(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setattr(st, "SHEETS_ROOT", tmp_path)
|
||||
monkeypatch.setattr(st, "_project_zips", lambda no: [])
|
||||
_no_download(monkeypatch, "PermissionError: 로그인")
|
||||
with pytest.raises(RuntimeError):
|
||||
st.ensure_tile("37507039", "건물")
|
||||
assert not list(tmp_path.rglob("*.geojson"))
|
||||
|
||||
|
||||
def _src(name: str) -> str:
|
||||
return (ROOT / "D03_PlanAnalysis" / name).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_screen_uses_visible_sheets() -> None:
|
||||
tiles = _src("D03_PlanAnalysis_UI_SheetTiles.ts")
|
||||
assert "export const FULL_MAX = 12;" in tiles and "export const INDEX_MAX = 30;" in tiles
|
||||
assert '"등고선" ? "계곡선" : null' in tiles # 13~30 = 계곡선만
|
||||
layers = _src("D03_PlanAnalysis_UI_Layers.ts")
|
||||
assert "fetchPlanSheetLayer" not in layers
|
||||
assert "tiles.view(m, meta)" in layers
|
||||
assert len(layers.splitlines()) <= 700
|
||||
Reference in New Issue
Block a user