114 lines
4.6 KiB
Python
114 lines
4.6 KiB
Python
import math
|
|
from pathlib import Path
|
|
from typing import Any, Dict
|
|
import geopandas as gpd
|
|
import mapbox_vector_tile
|
|
from shapely.geometry import box
|
|
|
|
# 메모리 캐시: {project_id}_{layer_name}: GeoDataFrame
|
|
_gdf_cache: Dict[str, gpd.GeoDataFrame] = {}
|
|
|
|
def num2deg(xtile: int, ytile: int, zoom: int) -> tuple[float, float]:
|
|
"""Z/X/Y 타일 인덱스를 경도/위도(WGS84) 좌표로 변환합니다."""
|
|
n = 1.0 << zoom
|
|
lon_deg = xtile / n * 360.0 - 180.0
|
|
lat_rad = math.atan(math.sinh(math.pi * (1 - 2 * ytile / n)))
|
|
lat_deg = math.degrees(lat_rad)
|
|
return lon_deg, lat_deg
|
|
|
|
def get_tile_bbox(xtile: int, ytile: int, zoom: int) -> tuple[float, float, float, float]:
|
|
"""Z/X/Y 타일의 경위도 Bounding Box (min_lon, min_lat, max_lon, max_lat)를 구합니다."""
|
|
left, top = num2deg(xtile, ytile, zoom)
|
|
right, bottom = num2deg(xtile + 1, ytile + 1, zoom)
|
|
# 위도는 북쪽이 더 크므로 min_lat, max_lat 정렬
|
|
return min(left, right), min(top, bottom), max(left, right), max(top, bottom)
|
|
|
|
def load_geojson_cached(filepath: Path, cache_key: str) -> gpd.GeoDataFrame:
|
|
"""GeoJSON 파일을 한 번 읽으면 메모리에 GeoDataFrame으로 캐싱합니다."""
|
|
global _gdf_cache
|
|
if cache_key not in _gdf_cache:
|
|
if not filepath.exists():
|
|
raise FileNotFoundError(f"File not found: {filepath}")
|
|
gdf = gpd.read_file(filepath)
|
|
# 만약 CRS가 지정되어 있지 않다면 EPSG:4326으로 가정
|
|
if gdf.crs is None:
|
|
gdf.set_crs(epsg=4326, inplace=True)
|
|
# 경위도가 아닌 경우 EPSG:4326으로 재투영
|
|
if gdf.crs.to_epsg() != 4326:
|
|
gdf = gdf.to_crs(epsg=4326)
|
|
_gdf_cache[cache_key] = gdf
|
|
return _gdf_cache[cache_key]
|
|
|
|
def generate_mvt_tile(
|
|
filepath: Path,
|
|
cache_key: str,
|
|
z: int,
|
|
x: int,
|
|
y: int,
|
|
layer_name: str = "contour"
|
|
) -> bytes:
|
|
"""주어진 GeoJSON 파일에서 Z/X/Y 영역에 해당하는 데이터를 필터링하고 MVT 타일 바이너리로 인코딩합니다."""
|
|
gdf = load_geojson_cached(filepath, cache_key)
|
|
|
|
# 1. 타일 경계 구하기
|
|
min_lon, min_lat, max_lon, max_lat = get_tile_bbox(x, y, z)
|
|
tile_geom = box(min_lon, min_lat, max_lon, max_lat)
|
|
|
|
# 2. 해당 영역 내에 공간적으로 접하는(Intersects) 데이터 신속 조회
|
|
# GeoPandas의 공간 인덱스(sindex)를 이용한 대략적 바운딩 박스 필터링
|
|
possible_matches_index = list(gdf.sindex.intersection((min_lon, min_lat, max_lon, max_lat)))
|
|
possible_matches = gdf.iloc[possible_matches_index]
|
|
|
|
# 정확하게 겹치는 피처만 정밀 필터링 및 타일 경계로 클리핑(자르기)
|
|
clipped_gdf = possible_matches.clip(tile_geom)
|
|
|
|
if clipped_gdf.empty:
|
|
# 겹치는 데이터가 없으면 빈 MVT 반환
|
|
return mapbox_vector_tile.encode([])
|
|
|
|
# 3. GeoJSON 스타일 피처 목록 생성 (mapbox-vector-tile 인코더 형식에 정합)
|
|
features = []
|
|
|
|
# 타일 해상도 규격 (보통 4096)
|
|
extent = 4096
|
|
|
|
for idx, row in clipped_gdf.iterrows():
|
|
geom = row.geometry
|
|
if geom.is_empty:
|
|
continue
|
|
|
|
# 속성값 파싱 (숫자, 문자열 타입 보존)
|
|
properties = {}
|
|
for col in clipped_gdf.columns:
|
|
if col != 'geometry':
|
|
val = row[col]
|
|
# JSON/Protobuf 직렬화 가능한 단순 타입만 속성으로 삽입
|
|
if isinstance(val, (int, float, str, bool)):
|
|
properties[col] = val
|
|
else:
|
|
properties[col] = str(val) if val is not None else ""
|
|
|
|
# MVT 인코더 규격에 맞추기 위해 좌표를 WGS84 -> 타일 로컬 정수 좌표(0 ~ 4096)로 변환하는 함수 적용
|
|
# mapbox-vector-tile 패키지는 shapely geometry를 입력받아 내부적으로 투영 변환할 수 있습니다.
|
|
# 단, Geometry 자체를 MVT 타일 내부 좌표계로 직접 정규화하여 전달해야 합니다.
|
|
features.append({
|
|
"geometry": geom,
|
|
"properties": properties
|
|
})
|
|
|
|
# mapbox_vector_tile.encode는 타일 영역 정보(bounds)와 해상도(extent)를 주면 내부적으로 변환해 줍니다.
|
|
# bounds: (min_lon, min_lat, max_lon, max_lat)
|
|
tile_data = [{
|
|
"name": layer_name,
|
|
"features": features
|
|
}]
|
|
|
|
mvt_bytes = mapbox_vector_tile.encode(
|
|
tile_data,
|
|
quantize_bounds=(min_lon, min_lat, max_lon, max_lat),
|
|
y_coord_down=True, # MapLibre GL JS/Mapbox GL JS 스펙
|
|
extents=extent
|
|
)
|
|
|
|
return mvt_bytes
|