Merge remote-tracking branch 'origin/sub_laptop_1' into sub_laptop_8
This commit is contained in:
@@ -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)),
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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차) · 돌린 지도 = 대각선만큼 넓힘
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,103 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""등고선 벡터 조각 미리 만들기(PLAN 615-3) — 줌아웃 조각(z8~12)을 공용 창고에 미리.
|
||||
|
||||
실행: ./venv/Scripts/python.exe tools/precompute_contour_tiles.py --workers 2
|
||||
--zooms 8-12 줌 범위 · --status 남은 수 · 크기만
|
||||
|
||||
- 조각 목록 = 전국 등고선 도엽 목록(612 `_precompute_sheets.json`)의 도엽 상자가 닿는 z/x/y ·
|
||||
낮은 줌부터.
|
||||
- 셈 = `common_util_contour_tiles.ensure_tile`(창고에 있으면 건너뜀 = 이어 하기 · 잠금 ·
|
||||
`.part` → 바꿈).
|
||||
- 낮은 우선순위(BELOW_NORMAL) · 일꾼 여럿.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import math
|
||||
import sys
|
||||
import time
|
||||
from concurrent.futures import ProcessPoolExecutor, as_completed
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from B04_PreProcess.B04_PreProcess_Engine_MapSheet import sheet5k_to_bounds # noqa: E402
|
||||
from common_util import common_util_contour_tiles as ct # noqa: E402
|
||||
from tools.precompute_terrain import below_normal, sheet_index # noqa: E402
|
||||
|
||||
|
||||
def tiles_for(sheets: list[str], zooms: range) -> list[tuple[int, int, int]]:
|
||||
"""도엽 상자가 닿는 조각(낮은 줌부터 · 같은 줌은 x · y 차례)."""
|
||||
found: set[tuple[int, int, int]] = set()
|
||||
for no in sheets:
|
||||
lon0, lat0, lon1, lat1 = sheet5k_to_bounds(no)
|
||||
for z in zooms:
|
||||
n = 2**z
|
||||
|
||||
def tx(lon: float) -> int:
|
||||
return min(n - 1, math.floor((lon + 180) / 360 * n))
|
||||
|
||||
def ty(lat: float) -> int:
|
||||
r = math.radians(lat)
|
||||
y = (1 - math.log(math.tan(r) + 1 / math.cos(r)) / math.pi) / 2 * n
|
||||
return min(n - 1, math.floor(y))
|
||||
|
||||
for x in range(tx(lon0), tx(lon1) + 1):
|
||||
for y in range(ty(lat1), ty(lat0) + 1):
|
||||
found.add((z, x, y))
|
||||
return sorted(found)
|
||||
|
||||
|
||||
def _path(folder: Path, tile: tuple[int, int, int]) -> Path:
|
||||
z, x, y = tile
|
||||
return folder / str(z) / str(x) / f"{y}.pbf"
|
||||
|
||||
|
||||
def build(tile: tuple[int, int, int]) -> tuple[tuple[int, int, int], int, float]:
|
||||
started = time.monotonic()
|
||||
size = len(ct.ensure_tile(*tile))
|
||||
return tile, size, round(time.monotonic() - started, 2)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="등고선 벡터 조각 미리 만들기(PLAN 615-3)")
|
||||
parser.add_argument("--zooms", default="8-12")
|
||||
parser.add_argument("--workers", type=int, default=2)
|
||||
parser.add_argument("--status", action="store_true")
|
||||
args = parser.parse_args(argv)
|
||||
lo, _, hi = args.zooms.partition("-")
|
||||
zooms = range(int(lo), int(hi or lo) + 1)
|
||||
if zooms.start < ct.MIN_ZOOM or zooms.stop - 1 > ct.MAX_ZOOM:
|
||||
parser.error(f"줌 = {ct.MIN_ZOOM}~{ct.MAX_ZOOM}")
|
||||
|
||||
below_normal()
|
||||
folder = ct._folder()
|
||||
if folder is None or not ct.NATIONAL_GPKG.is_file():
|
||||
raise SystemExit(f"전국 등고선 파일 없음: {ct.NATIONAL_GPKG}")
|
||||
tiles = [t for t in tiles_for(list(sheet_index()), zooms) if ct.in_bounds(*t)]
|
||||
todo = [t for t in tiles if not _path(folder, t).is_file()]
|
||||
print(f"조각 {len(tiles)} · 남음 {len(todo)} · 창고 {folder} · 일꾼 {args.workers}", flush=True)
|
||||
if not args.status and todo:
|
||||
started = time.monotonic()
|
||||
with ProcessPoolExecutor(args.workers, initializer=below_normal) as pool:
|
||||
futures = [pool.submit(build, t) for t in todo]
|
||||
for n, future in enumerate(as_completed(futures), 1):
|
||||
(z, x, y), size, seconds = future.result()
|
||||
if n % 50 == 0 or n == len(todo):
|
||||
elapsed = time.monotonic() - started
|
||||
print(
|
||||
f"{n}/{len(todo)} z{z}/{x}/{y} {size} B {seconds} s · {elapsed:.0f} s",
|
||||
flush=True,
|
||||
)
|
||||
for z in zooms:
|
||||
paths = [_path(folder, t) for t in tiles if t[0] == z]
|
||||
have = [p.stat().st_size for p in paths if p.is_file()]
|
||||
print(f"z{z} 조각 {len(paths)} · 있음 {len(have)} · {sum(have) / 1e6:.1f} MB", flush=True)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user