feat(D 예정노선): 등고선 = 줌별 벡터 조각 — 서버 조각 · 공용 창고 · 화면 고정 소스 (PLAN 615-1 · 615-2 · 요구 496)

- 서버: 새 common_util_contour_tiles — 조각(z8~15) = 전국 등고선 상자 읽기 · 줌 간격 거름 · 단순화 · 3857 · MVT(겹 contour · h · k) · 창고 storage/_shared/contour_tiles/v1_<지문>/z/x/y.pbf · 잠금 · .part 바꿈 · 빈 조각 저장
- 줌 간격: z8 1000 · z9 500(화면 꼭짓점 z13 이하 실측) · z10 200 · z11 100 · z12 50 · z13 25 · z14 10(+25) · z15 5 m
- 길: GET /api/projects/{id}/plan/contour/{z}/{x}/{y}.pbf
- 화면: contourTileSource(위성처럼 고정 · minzoom 8 · maxzoom 15) · 등고 겹 셋 = 벡터 조각 · 받는 동안 서클 · GL 이면 606 등고 도엽 조각 안 받음(옛 캔버스는 그대로) · 주곡선 간격 셈 걷음
- 시험: test_615_contour_tiles

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 09:06:48 +09:00
co-authored by Claude Opus 5.5
parent 2decb9e359
commit 8ce7ef6cbe
7 changed files with 342 additions and 31 deletions
@@ -9,6 +9,7 @@
`…/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)
- `GET …/plan/contour/{z}/{x}/{y}.pbf` — 등고선 줌별 벡터 조각(615 · 공용 창고)
범위 = 기준점 둘레(`SITE_RADIUS_M`) + 지점 + 그은 점(`extra` · 지도 보기 귀) 상자 + 여유 ∪ 받은 범위
(PLAN 591-2 · 기준점 = 처음 받는 기준일 뿐).
@@ -33,6 +34,7 @@ from fastapi import APIRouter, File, HTTPException, Request, Response, UploadFil
from pydantic import BaseModel
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from common_util import common_util_contour_tiles as contour_tiles
from common_util.common_util_crs import lonlat_to_tm, tm_epsg_for_lon
from common_util.common_util_http_cache import cached_file_response
from common_util.common_util_json import atomic_write_json
@@ -370,6 +372,20 @@ async def plan_sheet_tile(project_id: UUID, sheet_no: str, name: str, request: R
return cached_file_response(request, path, "application/geo+json")
@router.get("/{project_id}/plan/contour/{z}/{x}/{y}.pbf", response_model=None)
async def plan_contour_tile(project_id: UUID, z: int, x: int, y: int) -> Response:
"""등고선 벡터 조각(615) — 공용 창고 · 없으면 전국 파일에서 만듦 · 빈 조각 = 빈 MVT."""
try:
tile = await asyncio.to_thread(contour_tiles.ensure_tile, z, x, y)
except KeyError as error:
raise HTTPException(status_code=400, detail="조각 번호가 범위를 벗어났습니다.") from error
return Response(
content=tile,
media_type="application/x-protobuf",
headers={"Cache-Control": "private, max-age=86400"},
)
@router.get("/{project_id}/plan/layers/cadastral", response_model=None)
async def plan_layer_cadastral(project_id: UUID, request: Request) -> Response:
"""지적도 GeoJSON(브이월드 LX맵 · 참고용) — 받은 범위를 덮지 않으면 지금 받음."""
@@ -32,7 +32,6 @@ import { readByKey, writeByKey } from "../A00_Common/b_page_state";
import { fetchVWorldMeta, type VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import {
createNormalizer,
pickLevelStep,
type GeoJsonCollection,
type PreparedLayer,
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
@@ -146,7 +145,7 @@ export function createPlanLayers(opts: {
let areaTurn = 0;
/** 음영 GL 그림 — 그대로 · 위성 위 그늘(「곱하기」 꼴). */
let hillshade: { plain: GlImage; shade: GlImage } | null = null;
const gl = createGlBase(projectId, (why) => {
const gl = createGlBase(projectId, track, (why) => {
if (!glOn) return;
showToast(`그래픽 카드 그림을 못 써 옛 그림으로 — ${why} · ${NO_SATELLITE_NOTE}`, "info");
useGl(false);
@@ -158,6 +157,7 @@ export function createPlanLayers(opts: {
track,
wanted: () =>
meta ? LAYERS.flatMap((def) => (on[def.key] && SHEET_TILE_NAMES[def.key]) || []) : [],
gl: () => glOn,
onChange: (names) => {
for (const def of LAYERS)
if (meta && on[def.key] && SHEET_TILE_NAMES[def.key]?.some((name) => names.has(name)))
@@ -626,7 +626,7 @@ export function createPlanLayers(opts: {
roadPick: pickLayer,
});
/** GL 이면 캔버스 바탕 겹은 쉼 — 등고 주곡선 간격(화면 안 가닥 수)만 셈. */
/** GL 이면 캔버스 바탕 겹은 쉼 — 보이는 도엽 조각만 셈. */
const layers: PlanMapLayer[] = [
...canvasLayers.map((layer): PlanMapLayer => ({
...layer,
@@ -634,11 +634,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));
},
draw: (_, m) => void (meta && tiles.view(m, meta)),
},
];
+42 -20
View File
@@ -9,12 +9,14 @@
* 그은 선 · 재기 · 고르기 · 누름 = 위 캔버스 그대로(위에 겹침).
* 카메라 주인 = D03 화면(`ViewState` · 돌림) 하나 — 여기는 따라가기만 · 조작은 안 받음.
* 자료 = 지금 받는 GeoJSON 그대로(조각내기는 MapLibre 일꾼 스레드) · 색 = 받는 쪽이 지물에 붙인 `_color`.
* 등고선 = 줌별 벡터 조각(615 · 위성처럼 고정 소스 · 가는 선 = 모두 · 굵은 선 · 글 = `k` 1) · 받는 동안 서클.
* 그래픽 카드 · WebGL 이 없거나 잃으면 `onFail` — 부르는 쪽이 옛 캔버스로.
* 지도 세우기 · 자료 올리기 · 카메라 = 공용 부품 `ui_template_gl_map`(589-2) · 여기는 겹 꼴만.
* ========================================================================== */
import type { ExpressionSpecification, FilterSpecification, Map as MapLibreMap } from "maplibre-gl";
import {
contourTileSource,
createGlMap,
GL_FONT,
satelliteTileSource,
@@ -37,8 +39,6 @@ export interface GlBase {
/** 자료 담기(지도 없어도 · 켤 때 올림) — null = 비움. */
setData: (source: GlSource, data: GeoJsonCollection | GlImage | null) => void;
setVisible: (key: PlanLayerKey, on: boolean) => void;
/** 등고선 주곡선 간격(m) — 캔버스와 같은 「화면 안 가닥 수」 규칙. */
setContourStep: (step: number) => void;
/** 올린 임도망 고름(605-2) — 그 묶음 · 선만 굵게 강조 · null = 없앰. */
setRoadPick: (pick: RoadPick | null) => void;
/** 위성 밝기 상한(0~1) — 바로 반영 · 지도를 다시 세워도 유지. */
@@ -56,11 +56,8 @@ type GlSpec = GlMapSpec<PlanLayerKey>;
const HALO = "rgba(0, 0, 0, 0.55)";
const ROUND = { "line-join": "round", "line-cap": "round" } as const;
const COLOR: ExpressionSpecification = ["get", "_color"];
const LEVEL: ExpressionSpecification = ["to-number", ["get", "등고수치"], 0];
/** 주곡선 = 간격 배수(계곡선도 — 캔버스처럼 계곡선은 두 번 겹쳐 짙음). */
const minorFilter = (step: number): FilterSpecification =>
["==", ["%", LEVEL, step], 0] as unknown as FilterSpecification;
const INDEX = ["==", ["%", LEVEL, 25], 0] as unknown as FilterSpecification;
/** 등고 조각 굵은 선(서버가 줌마다 정함) — 가는 선은 모두라 굵은 선은 두 번 겹쳐 짙음(캔버스처럼). */
const BOLD = ["==", ["get", "k"], 1] as unknown as FilterSpecification;
/** 캔버스 점선(px) → MapLibre 점선(선 굵기 배수). */
const dash = (px: number[], width: number): number[] => px.map((v) => v / width);
@@ -127,7 +124,7 @@ function fillEdge(
}
/** 겹 차례(아래 → 위)와 꼴 — 캔버스 `D03_PlanAnalysis_UI_Layers` 의 색 · 굵기 · 투명도 · 차례 그대로. */
function layerSpecs(step: number, light: number, pick: RoadPick | null): GlSpec[] {
function layerSpecs(light: number, pick: RoadPick | null): GlSpec[] {
const contour = themeColor("--draw-map-contour", "#ffd68c");
const place = themeColor("--draw-map-place", "#f8fafc");
const landslideZone = themeColor("--draw-map-landslide-zone", "#e11d48");
@@ -183,7 +180,7 @@ function layerSpecs(step: number, light: number, pick: RoadPick | null): GlSpec[
id: "contour-minor",
type: "line",
source: "contour",
filter: minorFilter(step),
"source-layer": "contour",
layout: ROUND,
paint: { "line-color": contour, "line-width": 0.8, "line-opacity": 0.7 },
} as GlSpec,
@@ -192,7 +189,8 @@ function layerSpecs(step: number, light: number, pick: RoadPick | null): GlSpec[
id: "contour-index",
type: "line",
source: "contour",
filter: INDEX,
"source-layer": "contour",
filter: BOLD,
layout: ROUND,
paint: { "line-color": contour, "line-width": 1.6, "line-opacity": 0.7 },
} as GlSpec,
@@ -201,11 +199,12 @@ function layerSpecs(step: number, light: number, pick: RoadPick | null): GlSpec[
id: "contour-label",
type: "symbol",
source: "contour",
filter: INDEX,
"source-layer": "contour",
filter: BOLD,
layout: {
"symbol-placement": "line",
"symbol-spacing": 320,
"text-field": ["to-string", LEVEL],
"text-field": ["to-string", ["get", "h"]],
"text-font": [GL_FONT],
"text-size": 10,
"text-max-angle": 30,
@@ -349,24 +348,47 @@ const THEME_NAMES = [
/** 위성 채도(−1~1) — 조금 연하게. */
const SAT_SATURATION = -0.3;
export function createGlBase(projectId: string, onFail: (why: string) => void): GlBase {
let step = 5;
/** `track` = 받는 일을 진행 서클에 셈(등고 조각 받는 동안). */
export function createGlBase(
projectId: string,
track: <T>(job: Promise<T>) => Promise<T>,
onFail: (why: string) => void,
): GlBase {
let light = SAT_LIGHT_DEFAULT;
let pick: RoadPick | null = null;
const base = createGlMap<PlanLayerKey>({
specs: () => layerSpecs(step, light, pick),
fixed: { satellite: satelliteTileSource(projectId) },
specs: () => layerSpecs(light, pick),
fixed: { satellite: satelliteTileSource(projectId), contour: contourTileSource(projectId) },
themeNames: THEME_NAMES,
className: "d03-plan__gl",
onFail,
});
/** 등고 조각 받는 동안 서클 하나 — 다 받거나 지도가 쉬면(`idle`) · 지도를 버리면 끝. */
let settle: (() => void) | null = null;
const done = (): void => {
settle?.();
settle = null;
};
let watched: MapLibreMap | null = null;
function watch(map: MapLibreMap): void {
watched = map;
map.on("sourcedataloading", (event) => {
if (event.sourceId !== "contour" || settle) return;
void track(new Promise<void>((resolve) => (settle = resolve)));
});
map.on("sourcedata", (event) => {
if (event.sourceId === "contour" && map.isSourceLoaded("contour")) done();
});
map.on("idle", done);
}
return {
...base,
setContourStep(next) {
if (next === step) return;
step = next;
enable(parent) {
const on = base.enable(parent);
const map = base.map();
if (map?.getLayer("contour-minor")) map.setFilter("contour-minor", minorFilter(step));
if (!map) done();
else if (map !== watched) watch(map);
return on;
},
setRoadPick(next) {
pick = next;
@@ -6,6 +6,7 @@
* 보이는 도엽 12 장 이하 = 다 · 13~30 = 계곡선만(등고선 자리에 · 그 밖 선 겹 빔) · 넘음 = 선 겹 숨김.
* 받은 조각은 기억(최근 도엽 `KEEP_SHEETS` 장) — 다시 오면 즉시 · 받는 동안 서클(`track`).
* 지형 받기와 따로 — 보기만 옮겨도 조각은 받음 · 지형(받은 범위)은 안 받음(596-4 그대로).
* GL 이면 등고선은 안 받음(줌별 벡터 조각 · 615) — 옛 캔버스만 · GL 켬끔이 바뀌면 등고 겹을 다시 얹게 알림.
* ========================================================================== */
import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
@@ -56,6 +57,8 @@ export function createSheetTiles(opts: {
track: <T>(job: Promise<T>) => Promise<T>;
/** 지금 켠 겹의 조각 이름. */
wanted: () => string[];
/** GL 바탕인가(참 = 등고선 조각 안 받음). */
gl: () => boolean;
onChange: (names: Set<string>) => void;
}): SheetTiles {
const { projectId, track } = opts;
@@ -70,6 +73,7 @@ export function createSheetTiles(opts: {
let timer = 0;
let turn = 0;
let loading = false;
let glWas = opts.gl();
/** 바뀐 조각 이름 — 잠깐 모아 한 번에 알림(겹마다 합친 자료를 다시 얹는 일 줄임). */
const dirty = new Set<string>();
@@ -84,9 +88,11 @@ export function createSheetTiles(opts: {
}, CHANGE_MS);
}
/** 모드에 따른 실제 조각 — 계곡선만이면 등고선 = 계곡선 · 그 밖 = 없음. */
const source = (name: string): string | null =>
mode === "hidden" ? null : mode === "all" ? name : name === "등고선" ? "계곡선" : null;
/** 모드에 따른 실제 조각 — 계곡선만이면 등고선 = 계곡선 · 그 밖 = 없음 · GL 등고 = 없음. */
const source = (name: string): string | null => {
if (name === "등고선" && opts.gl()) return null;
return 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);
@@ -145,6 +151,10 @@ export function createSheetTiles(opts: {
return {
view: (m, meta) => {
if (glWas !== opts.gl()) {
glWas = !glWas;
changed(["등고선"]);
}
const { width, height } = m.view;
if (!width || !height) return;
// 그림 px 네 귀 → 작업 좌표 → 경위도(위성 틀 1차) · 돌린 지도 = 대각선만큼 넓힘
+159
View File
@@ -0,0 +1,159 @@
"""등고선 줌별 벡터 조각 — 웹 메르카토르 z/x/y MVT 를 공용 창고에(PLAN 615 · 요구 496 · 499).
조각 하나 = 전국 등고선(EPSG:5179)을 조각 상자(+ 여유 8 px)로 읽음 → 줌 간격 거름(SQL · 높이 색인)
→ 5179 에서 자르고 단순화(약 1 px) → 3857 → 조각 상자로 자름 → MVT(겹 `contour` · 속성 `h` 높이 ·
`k` 굵은 선 1/0 · 높이 하나 = 지물 하나). 512 px 조각 · 범위 4096.
창고 = `storage/_shared/contour_tiles/v<규칙 판>_<전국 파일 지문>/z/x/y.pbf` — 처음 볼 때
만들어 영구 · 빈 조각도 저장(다시 안 셈) · 잠금 · `.part` → 바꿈.
전국 파일 없는 PC = 있는 판 조각을 믿음 · 없으면 빈 조각.
화면 = `ui_template_gl_map.ts` `contourTileSource` ·
길 = `D03_PlanAnalysis_Router` `/plan/contour/{z}/{x}/{y}.pbf`.
"""
from __future__ import annotations
import hashlib
import math
from pathlib import Path
import numpy as np
from pyproj import Transformer
from common_util.common_util_terrain_store import NATIONAL_GPKG, _gpkg_stamp, _Lock
from config.config_system import STORAGE_BASE_DIR
TILES_ROOT = Path(STORAGE_BASE_DIR) / "_shared" / "contour_tiles"
RULE_VERSION = 1
MIN_ZOOM, MAX_ZOOM = 8, 15
LAYER = "contour"
EXTENT = 4096
TILE_PX = 512
BUFFER_PX = 8
# 줌별 담는 간격 · 굵은 선(m) — 화면 선 간격 약 6~12 px(분석 496 2장 「보통」)
STEP_M = {8: 1000, 9: 500, 10: 200, 11: 100, 12: 50, 13: 25, 14: 10, 15: 5}
BOLD_M = {8: 5000, 9: 2500, 10: 1000, 11: 500, 12: 250, 13: 25, 14: 25, 15: 25}
SIMPLIFY_PX = {14: 0.5, 15: 0.25} # 그 밖 1 px
# 국내 경위도(밖 = 빈 조각 · 요청 안 함 — 화면 소스 `bounds` 와 같음)
BOUNDS_LONLAT = (124.0, 33.0, 132.0, 39.0)
WORLD_M = 2 * math.pi * 6378137.0
# 단순화 허용치 — 메르카토르 m/px × 위도 36.5° 줄임(실제 땅 m)
GROUND_SCALE = math.cos(math.radians(36.5))
_TO_MERC = Transformer.from_crs("EPSG:5179", "EPSG:3857", always_xy=True)
_FROM_MERC = Transformer.from_crs("EPSG:3857", "EPSG:5179", always_xy=True)
def tile_bounds(z: int, x: int, y: int) -> tuple[float, float, float, float]:
"""조각 상자(EPSG:3857 m · 서 · 남 · 동 · 북)."""
size = WORLD_M / 2**z
west = -WORLD_M / 2 + x * size
north = WORLD_M / 2 - y * size
return west, north - size, west + size, north
def in_bounds(z: int, x: int, y: int) -> bool:
"""조각이 국내 경위도 상자에 닿나."""
lon0, lat0, lon1, lat1 = BOUNDS_LONLAT
n = 2**z
def tx(lon: float) -> int:
return math.floor((lon + 180) / 360 * n)
def ty(lat: float) -> int:
r = math.radians(lat)
return math.floor((1 - math.log(math.tan(r) + 1 / math.cos(r)) / math.pi) / 2 * n)
return tx(lon0) <= x <= tx(lon1) and ty(lat1) <= y <= ty(lat0)
def _folder() -> Path | None:
"""지금 판 창고 — 전국 파일 지문 · 없으면 있는 판 중 마지막 · 그것도 없으면 None."""
stamp = _gpkg_stamp()
if stamp:
tag = hashlib.sha1(stamp.encode()).hexdigest()[:8]
return TILES_ROOT / f"v{RULE_VERSION}_{tag}"
found = sorted(TILES_ROOT.glob(f"v{RULE_VERSION}_*"))
return found[-1] if found else None
def _empty() -> bytes:
import mapbox_vector_tile
return mapbox_vector_tile.encode([{"name": LAYER, "features": []}])
def build_tile(z: int, x: int, y: int) -> bytes:
"""조각 하나 만들기(창고 안 씀) — 전국 파일에서 읽어 MVT."""
import mapbox_vector_tile
import pyogrio
import shapely
if not (MIN_ZOOM <= z <= MAX_ZOOM) or not in_bounds(z, x, y) or not NATIONAL_GPKG.is_file():
return _empty()
west, south, east, north = tile_bounds(z, x, y)
pad = (east - west) * BUFFER_PX / TILE_PX
box = (west - pad, south - pad, east + pad, north + pad)
# 메르카토르 상자 둘레 → 5179 상자(가장자리 휨 감쌈)
t = np.linspace(0.0, 1.0, 9)
mx = np.concatenate(
[box[0] + (box[2] - box[0]) * t] * 2 + [np.full(9, box[0]), np.full(9, box[2])]
)
my = np.concatenate(
[np.full(9, box[1]), np.full(9, box[3])] + [box[1] + (box[3] - box[1]) * t] * 2
)
gx, gy = _FROM_MERC.transform(mx, my)
grow = 10.0
rect = (gx.min() - grow, gy.min() - grow, gx.max() + grow, gy.max() + grow)
step, bold = STEP_M[z], BOLD_M[z]
frame = pyogrio.read_dataframe(
NATIONAL_GPKG,
bbox=rect,
columns=["CTRLN_HG"],
where=f"CTRLN_HG % {step} = 0 OR CTRLN_HG % {bold} = 0",
)
if frame.empty:
return _empty()
tol = SIMPLIFY_PX.get(z, 1.0) * WORLD_M / (TILE_PX * 2**z) * GROUND_SCALE
geoms = shapely.simplify(shapely.clip_by_rect(frame.geometry.values, *rect), tol)
geoms = shapely.transform(
geoms, lambda xy: np.column_stack(_TO_MERC.transform(xy[:, 0], xy[:, 1]))
)
geoms = shapely.clip_by_rect(geoms, *box)
parts, owner = shapely.get_parts(geoms, return_index=True)
keep = shapely.get_num_points(parts) >= 2
parts, levels = parts[keep], frame["CTRLN_HG"].to_numpy(dtype=np.int64)[owner][keep]
features = [
{
"geometry": shapely.multilinestrings(parts[levels == level]),
"properties": {"h": int(level), "k": int(level % bold == 0)},
}
for level in np.unique(levels)
]
return mapbox_vector_tile.encode(
[{"name": LAYER, "features": features}],
default_options={"quantize_bounds": (west, south, east, north), "extents": EXTENT},
)
def ensure_tile(z: int, x: int, y: int) -> bytes:
"""조각 바이트 — 창고에 있으면 그대로 · 없으면 만들어 저장(잠금 · `.part` → 바꿈)."""
if not (MIN_ZOOM <= z <= MAX_ZOOM) or not (0 <= x < 2**z and 0 <= y < 2**z):
raise KeyError(f"{z}/{x}/{y}")
if not in_bounds(z, x, y):
return _empty()
folder = _folder()
if folder is None:
return _empty()
path = folder / str(z) / str(x) / f"{y}.pbf"
if path.is_file():
return path.read_bytes()
if not NATIONAL_GPKG.is_file():
return _empty() # 전국 파일 없는 PC = 남은 판에 없으면 빈 조각(저장 안 함)
with _Lock(path.with_suffix(".lock")):
if path.is_file():
return path.read_bytes()
data = build_tile(z, x, y)
part = path.with_suffix(".part")
part.write_bytes(data)
part.replace(path)
return data
@@ -0,0 +1,97 @@
"""등고선 줌별 벡터 조각(PLAN 615) — MVT 풀어 읽기(겹 `contour` · `h` · `k`) · 줌별 간격 ·
빈 조각(바다 · 국내 밖) = 빈 MVT · 처음만 만들고 둘째 = 파일 · 조각 번호 범위 · 화면 = 고정 벡터 소스.
./venv/Scripts/python.exe -m pytest resources/tester/test_615_contour_tiles.py -q
"""
import math
from pathlib import Path
import mapbox_vector_tile
import pytest
from B04_PreProcess.B04_PreProcess_Engine_MapSheet import sheet5k_center
from common_util import common_util_contour_tiles as ct
ROOT = Path(__file__).resolve().parents[2]
SHEET = "36901074" # 상주 용화 산지
needs_gpkg = pytest.mark.skipif(not ct.NATIONAL_GPKG.is_file(), reason="전국 등고선 파일 없음")
def _tile(lon: float, lat: float, z: int) -> tuple[int, int, int]:
n = 2**z
r = math.radians(lat)
y = (1 - math.log(math.tan(r) + 1 / math.cos(r)) / math.pi) / 2 * n
return z, int((lon + 180) / 360 * n), int(y)
def _features(data: bytes) -> list[dict]:
return mapbox_vector_tile.decode(data).get(ct.LAYER, {"features": []})["features"]
@needs_gpkg
@pytest.mark.parametrize("z", [12, 13, 14, 15])
def test_zoom_step_and_bold(z: int) -> None:
lat, lon = sheet5k_center(SHEET)
features = _features(ct.build_tile(*_tile(lon, lat, z)))
assert features, "산지 조각에 선이 있어야 함"
step, bold = ct.STEP_M[z], ct.BOLD_M[z]
levels = [f["properties"]["h"] for f in features]
assert len(levels) == len(set(levels)) # 높이 하나 = 지물 하나
for f in features:
h, k = f["properties"]["h"], f["properties"]["k"]
assert h % step == 0 or h % bold == 0
assert k == int(h % bold == 0)
assert f["geometry"]["type"] in ("LineString", "MultiLineString")
if z == 15:
assert any(h % 10 for h in levels) # 5 m 전부
if z == 14:
assert any(h % 25 == 0 and h % 10 for h in levels) # 10 m + 계곡선 25 m
def test_rules_dense_to_sparse() -> None:
zooms = range(ct.MIN_ZOOM, ct.MAX_ZOOM + 1)
steps = [ct.STEP_M[z] for z in zooms]
assert steps == sorted(steps, reverse=True) and steps[-1] == 5
assert all(ct.BOLD_M[z] % ct.STEP_M[z] == 0 or z == 14 for z in zooms)
def test_empty_tiles() -> None:
assert _features(ct.build_tile(*_tile(120.0, 30.0, 10))) == [] # 국내 밖
assert not ct.in_bounds(*_tile(120.0, 30.0, 10))
if ct.NATIONAL_GPKG.is_file():
assert _features(ct.build_tile(*_tile(131.0, 37.8, 13))) == [] # 동해 바다
def test_bad_tile_number() -> None:
for z, x, y in [(7, 0, 0), (16, 0, 0), (10, -1, 0), (10, 0, 1024)]:
with pytest.raises(KeyError):
ct.ensure_tile(z, x, y)
@needs_gpkg
def test_second_read_is_file(tmp_path, monkeypatch) -> None:
monkeypatch.setattr(ct, "TILES_ROOT", tmp_path)
lat, lon = sheet5k_center(SHEET)
z, x, y = _tile(lon, lat, 15)
first = ct.ensure_tile(z, x, y)
files = list(tmp_path.rglob("*.pbf"))
assert len(files) == 1 and files[0].parts[-3:] == (str(z), str(x), f"{y}.pbf")
assert files[0].parent.parent.parent.name.startswith(f"v{ct.RULE_VERSION}_")
assert not list(tmp_path.rglob("*.part")) and not list(tmp_path.rglob("*.lock"))
def boom(*_args):
raise AssertionError("둘째는 파일에서 읽어야 함")
monkeypatch.setattr(ct, "build_tile", boom)
assert ct.ensure_tile(z, x, y) == first
def test_screen_uses_fixed_vector_source() -> None:
gl = (ROOT / "ui_template" / "ui_template_gl_map.ts").read_text(encoding="utf-8")
assert "export function contourTileSource" in gl and '"vector"' in gl
assert "/plan/contour/{z}/{x}/{y}.pbf" in gl
base = (ROOT / "D03_PlanAnalysis" / "D03_PlanAnalysis_UI_MapGl.ts").read_text(encoding="utf-8")
assert "contour: contourTileSource(projectId)" in base
assert base.count('"source-layer": "contour"') == 3
+11
View File
@@ -65,6 +65,17 @@ export function satelliteTileSource(projectId: string): SourceSpecification {
};
}
/** 등고선 줌별 벡터 조각(PLAN 615 · 서버 공용 창고 z8~15 · 16 이상 = z15 늘려 씀) — 겹 `contour` · `h` 높이 · `k` 굵은 선. */
export function contourTileSource(projectId: string): SourceSpecification {
return {
type: "vector",
tiles: [`${location.origin}${API_BASE_URL}/projects/${projectId}/plan/contour/{z}/{x}/{y}.pbf`],
minzoom: 8,
maxzoom: 15,
bounds: [124, 33, 132, 39],
};
}
/** 느린 조작 잼(600-4) — `globalThis.__aisloPerf = true` 일 때만 `performance.measure(이름)` 으로 걸린 ms 를 남김(꺼져 있으면 그냥 실행). */
export function perfTime<T>(name: string, run: () => T): T {
if (!(globalThis as { __aisloPerf?: boolean }).__aisloPerf) return run();