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:
2026-10-30 22:14:11 +09:00
co-authored by Claude Opus 5.5
parent a3f4576802
commit bc1d5165ff
7 changed files with 646 additions and 17 deletions
+15
View File
@@ -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}
+28 -14
View File
@@ -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
+121
View File
@@ -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