From bc1d5165ffcd85191dcb3ceccf76e121598490cb Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 30 Oct 2026 22:14:11 +0900 Subject: [PATCH] =?UTF-8?q?feat(D=20=EC=98=88=EC=A0=95=EB=85=B8=EC=84=A0):?= =?UTF-8?q?=20=EB=8F=84=EC=97=BD=20=EC=A1=B0=EA=B0=81=20=E2=80=94=20?= =?UTF-8?q?=EA=B3=B5=EC=9A=A9=20=EC=B0=BD=EA=B3=A0=20storage/=5Fshared/she?= =?UTF-8?q?ets=20=C2=B7=20=EB=B3=B4=EC=9D=B4=EB=8A=94=20=EB=8F=84=EC=97=BD?= =?UTF-8?q?=EB=A7=8C=20=EB=B0=9B=EA=B8=B0=20(PLAN=20606-1=20=C2=B7=20606-2?= =?UTF-8?q?=20=C2=B7=20=EC=9A=94=EA=B5=AC=20477)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 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 --- D03_PlanAnalysis/D03_PlanAnalysis_Api.ts | 15 ++ D03_PlanAnalysis/D03_PlanAnalysis_Router.py | 27 ++ .../D03_PlanAnalysis_SheetTiles.py | 254 ++++++++++++++++++ .../D03_PlanAnalysis_UI_Layers.ts | 42 ++- .../D03_PlanAnalysis_UI_SheetTiles.ts | 198 ++++++++++++++ .../tester/test_475_plan_area_refresh.py | 6 +- resources/tester/test_606_sheet_tiles.py | 121 +++++++++ 7 files changed, 646 insertions(+), 17 deletions(-) create mode 100644 D03_PlanAnalysis/D03_PlanAnalysis_SheetTiles.py create mode 100644 D03_PlanAnalysis/D03_PlanAnalysis_UI_SheetTiles.ts create mode 100644 resources/tester/test_606_sheet_tiles.py diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts b/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts index 5c28a90e6..2228fe371 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts @@ -196,6 +196,21 @@ export const fetchPlanCadastral = (projectId: string): Promise => 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 => + request(`${base(projectId)}/sheet-tiles/${sheet}/${encodeURIComponent(name)}`); + export const fetchPlanRaster = ( projectId: string, kind: "slope" | "hillshade" | "landslide", diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Router.py b/D03_PlanAnalysis/D03_PlanAnalysis_Router.py index 6c43d161a..8d7386481 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Router.py +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Router.py @@ -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맵 · 참고용) — 받은 범위를 덮지 않으면 지금 받음.""" diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_SheetTiles.py b/D03_PlanAnalysis/D03_PlanAnalysis_SheetTiles.py new file mode 100644 index 000000000..cf48d9800 --- /dev/null +++ b/D03_PlanAnalysis/D03_PlanAnalysis_SheetTiles.py @@ -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} diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts index 9d765974d..3825d1a3d 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts @@ -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; }; } @@ -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(); 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(), }), }; } diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_SheetTiles.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_SheetTiles.ts new file mode 100644 index 000000000..cd08c363d --- /dev/null +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_SheetTiles.ts @@ -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> = { + 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: (job: Promise) => Promise; + /** 지금 켠 겹의 조각 이름. */ + wanted: () => string[]; + onChange: (names: Set) => void; +}): SheetTiles { + const { projectId, track } = opts; + const cache = new Map(); + const failed = new Set(); + 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(); + 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 { + 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 { + 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], + }), + }; +} diff --git a/resources/tester/test_475_plan_area_refresh.py b/resources/tester/test_475_plan_area_refresh.py index e1ec365d3..d8a5a9a39 100644 --- a/resources/tester/test_475_plan_area_refresh.py +++ b/resources/tester/test_475_plan_area_refresh.py @@ -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 diff --git a/resources/tester/test_606_sheet_tiles.py b/resources/tester/test_606_sheet_tiles.py new file mode 100644 index 000000000..eedcd7ba3 --- /dev/null +++ b/resources/tester/test_606_sheet_tiles.py @@ -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