From 8ce7ef6cbe377a24f1a57174772e6aa7e08ce04a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 09:06:48 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(D=20=EC=98=88=EC=A0=95=EB=85=B8?= =?UTF-8?q?=EC=84=A0):=20=EB=93=B1=EA=B3=A0=EC=84=A0=20=3D=20=EC=A4=8C?= =?UTF-8?q?=EB=B3=84=20=EB=B2=A1=ED=84=B0=20=EC=A1=B0=EA=B0=81=20=E2=80=94?= =?UTF-8?q?=20=EC=84=9C=EB=B2=84=20=EC=A1=B0=EA=B0=81=20=C2=B7=20=EA=B3=B5?= =?UTF-8?q?=EC=9A=A9=20=EC=B0=BD=EA=B3=A0=20=C2=B7=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EA=B3=A0=EC=A0=95=20=EC=86=8C=EC=8A=A4=20(PLAN=20615-1=20?= =?UTF-8?q?=C2=B7=20615-2=20=C2=B7=20=EC=9A=94=EA=B5=AC=20496)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 서버: 새 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 --- D03_PlanAnalysis/D03_PlanAnalysis_Router.py | 16 ++ .../D03_PlanAnalysis_UI_Layers.ts | 12 +- D03_PlanAnalysis/D03_PlanAnalysis_UI_MapGl.ts | 62 ++++--- .../D03_PlanAnalysis_UI_SheetTiles.ts | 16 +- common_util/common_util_contour_tiles.py | 159 ++++++++++++++++++ resources/tester/test_615_contour_tiles.py | 97 +++++++++++ ui_template/ui_template_gl_map.ts | 11 ++ 7 files changed, 342 insertions(+), 31 deletions(-) create mode 100644 common_util/common_util_contour_tiles.py create mode 100644 resources/tester/test_615_contour_tiles.py diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Router.py b/D03_PlanAnalysis/D03_PlanAnalysis_Router.py index f303d8f13..a9b501632 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Router.py +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Router.py @@ -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맵 · 참고용) — 받은 범위를 덮지 않으면 지금 받음.""" diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts index 3825d1a3d..c760cc8a0 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts @@ -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)), }, ]; diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_MapGl.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_MapGl.ts index bcdf7b97a..ad78bfd08 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_MapGl.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_MapGl.ts @@ -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; 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: (job: Promise) => Promise, + onFail: (why: string) => void, +): GlBase { let light = SAT_LIGHT_DEFAULT; let pick: RoadPick | null = null; const base = createGlMap({ - 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((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; diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_SheetTiles.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_SheetTiles.ts index 2d20e522d..f239145a6 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_SheetTiles.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_SheetTiles.ts @@ -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: (job: Promise) => Promise; /** 지금 켠 겹의 조각 이름. */ wanted: () => string[]; + /** GL 바탕인가(참 = 등고선 조각 안 받음). */ + gl: () => boolean; onChange: (names: Set) => 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(); @@ -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차) · 돌린 지도 = 대각선만큼 넓힘 diff --git a/common_util/common_util_contour_tiles.py b/common_util/common_util_contour_tiles.py new file mode 100644 index 000000000..6d85198a8 --- /dev/null +++ b/common_util/common_util_contour_tiles.py @@ -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 diff --git a/resources/tester/test_615_contour_tiles.py b/resources/tester/test_615_contour_tiles.py new file mode 100644 index 000000000..6ad44dae9 --- /dev/null +++ b/resources/tester/test_615_contour_tiles.py @@ -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 diff --git a/ui_template/ui_template_gl_map.ts b/ui_template/ui_template_gl_map.ts index c3ec62d0b..bdf74c282 100644 --- a/ui_template/ui_template_gl_map.ts +++ b/ui_template/ui_template_gl_map.ts @@ -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(name: string, run: () => T): T { if (!(globalThis as { __aisloPerf?: boolean }).__aisloPerf) return run(); From a2ef417661681521efe37a9fa50ee99d56fca877 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 09:06:55 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(=EB=8F=84=EA=B5=AC):=20=EB=93=B1?= =?UTF-8?q?=EA=B3=A0=EC=84=A0=20=EB=B2=A1=ED=84=B0=20=EC=A1=B0=EA=B0=81=20?= =?UTF-8?q?=EB=AF=B8=EB=A6=AC=20=EB=A7=8C=EB=93=A4=EA=B8=B0=20=E2=80=94=20?= =?UTF-8?q?z8~12=20=C2=B7=20=EC=9D=B4=EC=96=B4=20=ED=95=98=EA=B8=B0=20?= =?UTF-8?q?=C2=B7=20=EC=9D=BC=EA=BE=BC=20=EB=91=98=20=C2=B7=20=EB=82=AE?= =?UTF-8?q?=EC=9D=80=20=EC=9A=B0=EC=84=A0=EC=88=9C=EC=9C=84=20(PLAN=20615-?= =?UTF-8?q?3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tools/precompute_contour_tiles.py — 조각 목록 = 612 전국 도엽 목록이 닿는 z/x/y(z8~12 2,923 조각) · 창고에 있으면 건너뜀 · --status 남은 수 · 크기 Co-Authored-By: Claude Opus 5.5 --- tools/precompute_contour_tiles.py | 103 ++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 tools/precompute_contour_tiles.py diff --git a/tools/precompute_contour_tiles.py b/tools/precompute_contour_tiles.py new file mode 100644 index 000000000..e45f30eeb --- /dev/null +++ b/tools/precompute_contour_tiles.py @@ -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())