This commit is contained in:
2026-07-15 18:33:33 +09:00
parent 6b1583103f
commit f533082537
201 changed files with 19776 additions and 563 deletions
@@ -163,3 +163,34 @@ export async function fetchSurfaceStatus(projectId: string): Promise<SurfaceStat
method: "GET",
});
}
export interface VWorldMeta {
x_min: number;
x_max: number;
y_min: number;
y_max: number;
width_meters: number;
height_meters: number;
center_x: number;
center_y: number;
lon_min: number;
lon_max: number;
lat_min: number;
lat_max: number;
}
export function getVWorldMapUrl(projectId: string, layerName: string): string {
return `${API_BASE_URL}/projects/${projectId}/vworld-map?layer_name=${layerName}`;
}
export async function fetchVWorldMeta(projectId: string, layerName: string): Promise<VWorldMeta> {
return requestJson<VWorldMeta>(`/projects/${projectId}/vworld-meta?layer_name=${layerName}`, {
method: "GET",
});
}
export async function fetchGisGeoJson(projectId: string, layer: string): Promise<any> {
return requestJson<any>(`/projects/${projectId}/geojson?layer=${encodeURIComponent(layer)}`, {
method: "GET",
});
}
+46
View File
@@ -84,6 +84,52 @@ def run_surface_analysis(
config["precompute"] = list(methods)
manifest = build_all_terrain_models(data, masks, models_dir, config, force=force)
# 3-2. VWorld 지도 및 국가 GIS 벡터 다운로드
_report(90, "download_maps", "VWorld 지도 및 GIS 벡터 데이터 다운로드 중")
try:
# project_root 내의 .prj 파일 탐색 (B03_FileInput/input 또는 root 내 존재할 수 있음)
prj_files = (
list(project_root.glob("**/B03_FileInput/input/*.prj"))
+ list(project_root.glob("*.prj"))
+ list(project_root.glob("**/*.prj"))
)
prj_path = prj_files[0] if prj_files else project_root / "result.prj"
bounds_dict_for_download = {
"x": [float(bounds[0, 0]), float(bounds[0, 1])],
"y": [float(bounds[1, 0]), float(bounds[1, 1])],
"z": [float(bounds[2, 0]), float(bounds[2, 1])],
}
from B04_wf1_Surface.B04_wf1_Surface_Engine_GisVector import download_all_gis_vectors
from B04_wf1_Surface.B04_wf1_Surface_Engine_VWorld import download_vworld_satellite_map
# VWorld 지도 및 GIS 데이터 저장 위치는 B04_wf1_Surface/processed에 보관.
layers = [
{"layer": "Satellite", "ext": "jpeg"},
{"layer": "Hybrid", "ext": "png"},
{"layer": "white", "ext": "png"},
]
for item in layers:
try:
download_vworld_satellite_map(
prj_path,
bounds_dict_for_download,
processed_dir,
layer_name=item["layer"],
ext=item["ext"],
)
except Exception:
pass
try:
download_all_gis_vectors(prj_path, bounds_dict_for_download, processed_dir)
except Exception:
pass
except Exception:
pass
_report(95, "saving", "결과 저장 중")
processed = {
@@ -0,0 +1,155 @@
"""국가 GIS 벡터 데이터 다운로드 엔진."""
from __future__ import annotations
import json
import urllib.parse
import urllib.request
from pathlib import Path
from pyproj import Transformer
# 설정 불러오기
try:
from config import config_system
VWORLD_API_KEY = getattr(
config_system, "VWORLD_API_KEY", "3DBD7306-7DBD-38BB-B292-267C5ED7AC6B"
)
PROJECT_ROOT = getattr(config_system, "PROJECT_ROOT", Path(__file__).resolve().parent.parent)
except ImportError:
VWORLD_API_KEY = "3DBD7306-7DBD-38BB-B292-267C5ED7AC6B"
PROJECT_ROOT = Path(__file__).resolve().parent.parent
from B04_wf1_Surface.B04_wf1_Surface_Engine_VWorld import get_epsg_from_prj
def request_vworld_wfs(typename: str, filename: str, bounds_wgs84: dict, output_dir: Path) -> None:
"""브이월드 WFS API를 사용하여 지정된 Bounds 영역 내의
공간 벡터 데이터를 GeoJSON으로 다운로드합니다.
"""
# 4326(위경도) bbox 파라미터 구성 (BBOX=minx,miny,maxx,maxy)
bbox_str = (
f"{bounds_wgs84['min_lon']},{bounds_wgs84['min_lat']},"
f"{bounds_wgs84['max_lon']},{bounds_wgs84['max_lat']}"
)
# WFS 파라미터 조립
params = {
"key": VWORLD_API_KEY,
"domain": "localhost",
"service": "WFS",
"version": "1.1.0",
"request": "GetFeature",
"typename": typename,
"bbox": bbox_str,
"output": "application/json", # GeoJSON 포맷 요청
"srsName": "EPSG:4326",
}
url = "http://api.vworld.kr/req/wfs?" + urllib.parse.urlencode(params)
headers = {"User-Agent": "Mozilla/5.0"}
req = urllib.request.Request(url, headers=headers)
try:
with urllib.request.urlopen(req, timeout=15) as res:
content = res.read()
data = json.loads(content.decode("utf-8"))
output_path = output_dir / filename
output_path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
except Exception:
pass
def download_all_gis_vectors(prj_path: Path, bounds_meter: dict, output_dir: Path) -> None:
"""지형의 로컬 미터단위 bounds 정보를 위경도로 변환 후 5대 국가 GIS 데이터를 다운로드합니다."""
output_dir.mkdir(parents=True, exist_ok=True)
# 1. vworld_white_meta.json이 생성되어 있다면 직접 위경도 경계 획득하여 pyproj 축왜곡 방지
meta_path = prj_path.parent / "vworld_white_meta.json"
if not meta_path.exists():
meta_path = prj_path.parent / "vworld_satellite_meta.json"
if meta_path.exists():
try:
with open(meta_path, "r", encoding="utf-8") as f:
meta_data = json.load(f)
bounds_wgs84 = {
"min_lon": meta_data["lon_min"] - 0.010,
"max_lon": meta_data["lon_max"] + 0.010,
"min_lat": meta_data["lat_min"] - 0.010,
"max_lat": meta_data["lat_max"] + 0.010,
}
except Exception:
# 폴백용 pyproj 변환
src_epsg = "EPSG:5186"
if prj_path.exists():
src_epsg = get_epsg_from_prj(prj_path.read_text(encoding="utf-8", errors="ignore"))
transformer = Transformer.from_crs(src_epsg, "EPSG:4326", always_xy=True)
x_min, x_max = bounds_meter["x"][0], bounds_meter["x"][1]
y_min, y_max = bounds_meter["y"][0], bounds_meter["y"][1]
lon_min, lat_min = transformer.transform(x_min, y_min)
lon_max, lat_max = transformer.transform(x_max, y_max)
bounds_wgs84 = {
"min_lon": min(lon_min, lon_max) - 0.002,
"max_lon": max(lon_min, lon_max) + 0.002,
"min_lat": min(lat_min, lat_max) - 0.002,
"max_lat": max(lat_min, lat_max) + 0.002,
}
else:
# 폴백용 pyproj 변환
src_epsg = "EPSG:5186"
if prj_path.exists():
src_epsg = get_epsg_from_prj(prj_path.read_text(encoding="utf-8", errors="ignore"))
transformer = Transformer.from_crs(src_epsg, "EPSG:4326", always_xy=True)
x_min, x_max = bounds_meter["x"][0], bounds_meter["x"][1]
y_min, y_max = bounds_meter["y"][0], bounds_meter["y"][1]
lon_min, lat_min = transformer.transform(x_min, y_min)
lon_max, lat_max = transformer.transform(x_max, y_max)
bounds_wgs84 = {
"min_lon": min(lon_min, lon_max) - 0.010,
"max_lon": max(lon_min, lon_max) + 0.010,
"min_lat": min(lat_min, lat_max) - 0.010,
"max_lat": max(lat_min, lat_max) + 0.010,
}
# 5대 레이어 다운로드
request_vworld_wfs(
"lt_c_landinfobasemap", "연속지적도_bounds.geojson", bounds_wgs84, output_dir
)
request_vworld_wfs("lt_c_uq111", "용도지역도_bounds.geojson", bounds_wgs84, output_dir)
request_vworld_wfs("lt_c_adsigg", "행정구역_시군구_bounds.geojson", bounds_wgs84, output_dir)
request_vworld_wfs("lt_c_adri", "행정구역_읍면동_bounds.geojson", bounds_wgs84, output_dir)
request_vworld_wfs("lt_c_gimshydro", "수계망_물줄기_bounds.geojson", bounds_wgs84, output_dir)
request_vworld_wfs(
"lt_c_kfdrssigugrade", "산사태위험등급_bounds.geojson", bounds_wgs84, output_dir
)
# 6. gpkg 등고선(national_contours.gpkg) 데이터에서
# 기준 영역 크롭하여 등고선_bounds.geojson 생성
gpkg_path = PROJECT_ROOT / "source" / "grobal_contours" / "national_contours.gpkg"
if gpkg_path.exists():
try:
import geopandas as gpd
from shapely.geometry import box
t_to_5179 = Transformer.from_crs("EPSG:4326", "EPSG:5179", always_xy=True)
min_x_5179, min_y_5179 = t_to_5179.transform(
bounds_wgs84["min_lon"], bounds_wgs84["min_lat"]
)
max_x_5179, max_y_5179 = t_to_5179.transform(
bounds_wgs84["max_lon"], bounds_wgs84["max_lat"]
)
bbox = box(min_x_5179, min_y_5179, max_x_5179, max_y_5179)
gdf = gpd.read_file(gpkg_path, bbox=bbox)
if not gdf.empty:
gdf_wgs84 = gdf.to_crs("EPSG:4326")
geojson_out = output_dir / "등고선_bounds.geojson"
gdf_wgs84.to_file(geojson_out, driver="GeoJSON")
except Exception:
pass
@@ -0,0 +1,113 @@
"""MVT 벡터 타일 생성 유틸리티."""
from __future__ import annotations
import math
from pathlib import Path
from typing import Any
try:
import geopandas as gpd
from shapely.geometry import box
except ImportError:
gpd = None
box = None
try:
import mapbox_vector_tile
except ImportError:
mapbox_vector_tile = None
# 메모리 캐시: {project_id}_{layer_name}: GeoDataFrame
_gdf_cache: dict[str, Any] = {}
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)
return min(left, right), min(top, bottom), max(left, right), max(top, bottom)
def load_geojson_cached(filepath: Path, cache_key: str) -> Any:
"""GeoJSON 파일을 한 번 읽으면 메모리에 GeoDataFrame으로 캐싱합니다."""
global _gdf_cache
if cache_key not in _gdf_cache:
if not filepath.exists():
raise FileNotFoundError(f"File not found: {filepath}")
if gpd is None:
raise ImportError("geopandas가 설치되어 있지 않습니다.")
gdf = gpd.read_file(filepath)
if gdf.crs is None:
gdf.set_crs(epsg=4326, inplace=True)
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 타일 바이너리로 인코딩합니다.
"""
if mapbox_vector_tile is None:
# mapbox_vector_tile 패키지가 없을 경우 빈 바이트 리턴하여 다운 방지
return b""
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) 데이터 신속 조회
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:
return mapbox_vector_tile.encode([])
# 3. GeoJSON 스타일 피처 목록 생성
features = []
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]
if isinstance(val, (int, float, str, bool)):
properties[col] = val
else:
properties[col] = str(val) if val is not None else ""
features.append({"geometry": geom, "properties": properties})
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,
extents=extent,
)
return mvt_bytes
@@ -0,0 +1,199 @@
"""VWorld 위성 맵 및 타일 다운로드 엔진."""
from __future__ import annotations
import json
import math
import urllib.parse
import urllib.request
from io import BytesIO
from pathlib import Path
from PIL import Image
from pyproj import Transformer
# 설정 불러오기
try:
from config import config_system
VWORLD_API_KEY = getattr(
config_system, "VWORLD_API_KEY", "3DBD7306-7DBD-38BB-B292-267C5ED7AC6B"
)
except ImportError:
VWORLD_API_KEY = "3DBD7306-7DBD-38BB-B292-267C5ED7AC6B"
def get_epsg_from_prj(prj_content: str) -> str:
"""PRJ 파일 텍스트 내용을 해석하여 적합한 EPSG 좌표계 코드를 추정합니다.
기본 한반도 주요 좌표계 매핑:
- Korea Central Belt (중부원점 20만, 60만) -> EPSG:5186 (Korea 2000 Central)
- Korea East Belt (동부원점) -> EPSG:5187
- Korea West Belt (서부원점) -> EPSG:5185
- WGS84 UTM 52N -> EPSG:32652
"""
prj_upper = prj_content.upper()
if "EAST" in prj_upper:
return "EPSG:5187"
elif "WEST" in prj_upper:
return "EPSG:5185"
elif "CENTRAL" in prj_upper:
return "EPSG:5186"
elif "UTM" in prj_upper and "52N" in prj_upper:
return "EPSG:32652"
# 디폴트는 한국 중부 2000 좌표계 적용
return "EPSG:5186"
def latlon_to_tile(lat: float, lon: float, zoom: int) -> tuple[int, int]:
"""위도, 경도를 OSM/VWorld 표준 지도 타일 좌표 (X, Y)로 변환합니다."""
lat_rad = math.radians(lat)
n = 2.0**zoom
xtile = int((lon + 180.0) / 360.0 * n)
ytile = int((1.0 - math.log(math.tan(lat_rad) + (1 / math.cos(lat_rad))) / math.pi) / 2.0 * n)
return xtile, ytile
def tile_to_latlon(xtile: int, ytile: int, zoom: int) -> tuple[float, float]:
"""OSM/VWorld 표준 타일 좌표 (X, Y)를 위도, 경도 좌표로 역변환합니다."""
n = 2.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 lat_deg, lon_deg
def download_vworld_satellite_map(
prj_path: Path, bounds: dict, output_dir: Path, layer_name: str = "Satellite", ext: str = "jpeg"
) -> Path | None:
"""PRJ 좌표 정보와 지형 범위(bounds)를 바탕으로,
해당 임도 산림 지역을 감싸는 VWorld 지도 타일을 다운로드하여 하나의 PNG 텍스처로 병합합니다.
"""
output_dir.mkdir(parents=True, exist_ok=True)
file_name = f"vworld_{layer_name.lower()}.png"
map_png_path = output_dir / file_name
meta_json_path = output_dir / f"vworld_{layer_name.lower()}_meta.json"
# 1. PRJ 내용 분석
if not prj_path.exists():
return None
prj_content = prj_path.read_text(encoding="utf-8", errors="ignore")
src_epsg = get_epsg_from_prj(prj_content)
# 2. 중심점 및 경계 좌표 위경도 변환
# bounds: {'x': [x_min, x_max], 'y': [y_min, y_max], 'z': [z_min, z_max]}
x_min, x_max = bounds["x"][0], bounds["x"][1]
y_min, y_max = bounds["y"][0], bounds["y"][1]
# 좌표 변환기 생성 (지정 좌표계 -> 경위도 EPSG:4326)
try:
transformer = Transformer.from_crs(src_epsg, "EPSG:4326", always_xy=True)
except Exception:
# 실패 시 강제로 Korea Central -> WGS84 생성
transformer = Transformer.from_crs("EPSG:5186", "EPSG:4326", always_xy=True)
lon_min, lat_min = transformer.transform(x_min, y_min)
lon_max, lat_max = transformer.transform(x_max, y_max)
# 3. 지도 타일 크기 결정 (ZOOM 18 초고해상도 적용)
zoom = 18
# 영역을 포괄하는 좌상단 타일, 우하단 타일 인덱스 산출
x1, y1 = latlon_to_tile(lat_max, lon_min, zoom)
x2, y2 = latlon_to_tile(lat_min, lon_max, zoom)
# 타일 경계 마진 패딩
x_start = min(x1, x2) - 1
x_end = max(x1, x2) + 1
y_start = min(y1, y2) - 1
y_end = max(y1, y2) + 1
tile_w = x_end - x_start + 1
tile_h = y_end - y_start + 1
# 너무 많은 타일을 다운로드하여 IP 차단되는 것을 방지
if tile_w > 15:
tile_w = 15
x_end = x_start + 14
if tile_h > 15:
tile_h = 15
y_end = y_start + 14
# 4. 개별 타일 다운로드 및 이미지 병합
map_img = Image.new("RGBA", (tile_w * 256, tile_h * 256))
headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"}
for ty in range(y_start, y_end + 1):
for tx in range(x_start, x_end + 1):
try:
normalized_layer = layer_name.lower()
if normalized_layer == "satellite":
sat_url = f"http://api.vworld.kr/req/wmts/1.0.0/{VWORLD_API_KEY}/Satellite/{zoom}/{ty}/{tx}.jpeg"
sat_req = urllib.request.Request(sat_url, headers=headers)
with urllib.request.urlopen(sat_req, timeout=5) as sat_res:
sat_data = sat_res.read()
base_img = Image.open(BytesIO(sat_data)).convert("RGBA")
elif normalized_layer == "hybrid":
hy_url = f"http://api.vworld.kr/req/wmts/1.0.0/{VWORLD_API_KEY}/Hybrid/{zoom}/{ty}/{tx}.png"
hy_req = urllib.request.Request(hy_url, headers=headers)
with urllib.request.urlopen(hy_req, timeout=5) as hy_res:
hy_data = hy_res.read()
base_img = Image.open(BytesIO(hy_data)).convert("RGBA")
elif normalized_layer == "white":
white_url = f"http://api.vworld.kr/req/wmts/1.0.0/{VWORLD_API_KEY}/white/{zoom}/{ty}/{tx}.png"
white_req = urllib.request.Request(white_url, headers=headers)
with urllib.request.urlopen(white_req, timeout=5) as white_res:
white_data = white_res.read()
base_img = Image.open(BytesIO(white_data)).convert("RGBA")
else:
layer_url = f"http://api.vworld.kr/req/wmts/1.0.0/{VWORLD_API_KEY}/{layer_name}/{zoom}/{ty}/{tx}.{ext}"
layer_req = urllib.request.Request(layer_url, headers=headers)
with urllib.request.urlopen(layer_req, timeout=5) as layer_res:
layer_data = layer_res.read()
base_img = Image.open(BytesIO(layer_data)).convert("RGBA")
# 병합 좌표 계산
px = (tx - x_start) * 256
py = (ty - y_start) * 256
map_img.paste(base_img, (px, py))
except Exception:
# 다운로드 실패 시 회색 격자 텍스처로 대체
fallback_tile = Image.new("RGBA", (256, 256), (200, 200, 200, 255))
px = (tx - x_start) * 256
py = (ty - y_start) * 256
map_img.paste(fallback_tile, (px, py))
# 5. 병합된 이미지의 실제 위경도 바운더리 계산
lat_top, lon_left = tile_to_latlon(x_start, y_start, zoom)
lat_bottom, lon_right = tile_to_latlon(x_end + 1, y_end + 1, zoom)
# 위경도를 다시 원본 Local 3D 미터 좌표계로 변환
try:
rev_transformer = Transformer.from_crs("EPSG:4326", src_epsg, always_xy=True)
except Exception:
rev_transformer = Transformer.from_crs("EPSG:4326", "EPSG:5186", always_xy=True)
img_x_min, img_y_max = rev_transformer.transform(lon_left, lat_top)
img_x_max, img_y_min = rev_transformer.transform(lon_right, lat_bottom)
# 6. 이미지 저장 및 메타데이터 작성
map_img.save(map_png_path, "PNG")
meta = {
"x_min": img_x_min,
"x_max": img_x_max,
"y_min": img_y_min,
"y_max": img_y_max,
"width_meters": img_x_max - img_x_min,
"height_meters": img_y_max - img_y_min,
"center_x": (img_x_min + img_x_max) / 2.0,
"center_y": (img_y_min + img_y_max) / 2.0,
"lon_min": lon_left,
"lon_max": lon_right,
"lat_min": lat_bottom,
"lat_max": lat_top,
}
meta_json_path.write_text(json.dumps(meta, indent=2), encoding="utf-8")
return map_png_path
+200 -1
View File
@@ -9,7 +9,7 @@ from uuid import UUID
import aiomysql
import numpy as np
from fastapi import APIRouter
from fastapi import APIRouter, HTTPException, Response
from fastapi.responses import FileResponse, JSONResponse
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
@@ -637,3 +637,202 @@ async def get_surface_model_contour(
status_code=500,
content={"status": "error", "message": "등고선 파일 조회 중 오류가 발생했습니다."},
)
# VWorld 메타 API
@router.get("/{project_id}/vworld-meta", response_model=None)
async def get_vworld_meta(
project_id: UUID, layer_name: str = "satellite"
) -> dict[str, Any] | JSONResponse:
"""VWorld 위성 맵 이미지 매핑 좌표 메타데이터를 반환합니다."""
pool = get_db_pool()
try:
async with pool.acquire() as connection:
stored_path = await get_project_storage_relative_path(connection, project_id)
project_root = Path(resolve_stored_project_path(stored_path))
target_dir = project_root / "B04_wf1_Surface" / "processed"
target_layer = "white" if layer_name.lower() in ["gray", "white"] else layer_name.lower()
meta_name = f"vworld_{target_layer}_meta.json"
meta_path = target_dir / meta_name
if not meta_path.exists() and target_layer == "satellite":
meta_path = target_dir / "vworld_meta.json"
if not meta_path.exists():
return JSONResponse(
status_code=404,
content={
"status": "error",
"message": f"VWorld {layer_name} 메타데이터를 찾을 수 없습니다.",
},
)
return json.loads(meta_path.read_text(encoding="utf-8"))
except Exception as exc:
return JSONResponse(status_code=500, content={"status": "error", "message": str(exc)})
# VWorld 맵 API
@router.get("/{project_id}/vworld-map", response_model=None)
async def get_vworld_map(
project_id: UUID, layer_name: str = "satellite"
) -> FileResponse | JSONResponse:
"""배경 지도 레이어 PNG 이미지를 반환합니다."""
pool = get_db_pool()
try:
async with pool.acquire() as connection:
stored_path = await get_project_storage_relative_path(connection, project_id)
project_root = Path(resolve_stored_project_path(stored_path))
target_dir = project_root / "B04_wf1_Surface" / "processed"
target_layer = layer_name.lower()
if target_layer in ["gray", "white"]:
target_layer = "white"
map_name = f"vworld_{target_layer}.png"
map_path = target_dir / map_name
if not map_path.exists() and target_layer == "satellite":
map_path = target_dir / "vworld_map.png"
if not map_path.exists():
return JSONResponse(
status_code=404,
content={
"status": "error",
"message": f"VWorld {layer_name} 지도가 존재하지 않습니다.",
},
)
return FileResponse(map_path, media_type="image/png")
except Exception as exc:
return JSONResponse(status_code=500, content={"status": "error", "message": str(exc)})
# GeoJSON 조회 API
@router.get("/{project_id}/geojson", response_model=None)
async def get_project_geojson(project_id: UUID, layer: str) -> dict[str, Any] | JSONResponse:
"""저장된 프로젝트의 특정 GeoJSON 레이어 데이터를 반환합니다."""
pool = get_db_pool()
try:
async with pool.acquire() as connection:
stored_path = await get_project_storage_relative_path(connection, project_id)
project_root = Path(resolve_stored_project_path(stored_path))
target_dir = project_root / "B04_wf1_Surface" / "processed"
layer_mapping = {
"지적도": "연속지적도_bounds.geojson",
"용도지역": "용도지역도_bounds.geojson",
"행정구역_시군구": "행정구역_시군구_bounds.geojson",
"행정구역_읍면동": "행정구역_읍면동_bounds.geojson",
"수계망": "수계망_물줄기_bounds.geojson",
"등고선": "등고선_bounds.geojson",
"산사태": "산사태위험등급_bounds.geojson",
}
filename = layer_mapping.get(layer)
if not filename:
return JSONResponse(
status_code=400,
content={"status": "error", "message": "유효하지 않은 레이어명입니다."},
)
filepath = target_dir / filename
if not filepath.exists():
return JSONResponse(
status_code=404,
content={
"status": "error",
"message": f"요청한 레이어({layer}) 파일이 존재하지 않습니다.",
},
)
if layer == "등고선":
simplified_filepath = target_dir / "등고선_bounds_simplified.geojson"
if simplified_filepath.exists():
try:
return json.loads(simplified_filepath.read_text(encoding="utf-8"))
except Exception:
pass
try:
import geopandas as gpd
gdf = gpd.read_file(filepath)
gdf["geometry"] = gdf["geometry"].simplify(
tolerance=0.00003, preserve_topology=True
)
simplified_filepath.write_text(gdf.to_json(), encoding="utf-8")
return json.loads(simplified_filepath.read_text(encoding="utf-8"))
except Exception as e:
logger.warning("등고선 단순화 처리 실패 (원본 전송): %s", e)
return json.loads(filepath.read_text(encoding="utf-8"))
except Exception as exc:
return JSONResponse(status_code=500, content={"status": "error", "message": str(exc)})
# 별도 tiles_router 정의 (prefix 없음)
tiles_router = APIRouter(tags=["B04 MVT Tiles"])
@tiles_router.get("/tiles/{project_id}/{layer}/{z}/{x}/{y}.pbf", response_model=None)
async def get_vector_tile(project_id: UUID, layer: str, z: int, x: int, y: int) -> Response:
"""프로젝트의 특정 레이어에 대한 정밀 벡터 타일(MVT) 조각을 동적으로 렌더링하여 반환합니다."""
pool = get_db_pool()
try:
async with pool.acquire() as connection:
stored_path = await get_project_storage_relative_path(connection, project_id)
project_root = Path(resolve_stored_project_path(stored_path))
target_dir = project_root / "B04_wf1_Surface" / "processed"
layer_mapping = {
"지적도": "연속지적도_bounds.geojson",
"용도지역": "용도지역도_bounds.geojson",
"행정구역_시군구": "행정구역_시군구_bounds.geojson",
"행정구역_읍면동": "행정구역_읍면동_bounds.geojson",
"수계망": "수계망_물줄기_bounds.geojson",
"등고선": "등고선_bounds.geojson",
"산사태": "산사태위험등급_bounds.geojson",
"임도노선": "임도노선.geojson",
}
filename = layer_mapping.get(layer)
if not filename:
raise HTTPException(status_code=400, detail="유효하지 않은 레이어명입니다.")
filepath = target_dir / filename
if layer == "임도노선" and not filepath.exists():
from config.config_system import PROJECT_ROOT
road_shp_candidates = list(PROJECT_ROOT.glob("samples/**/*_Polyline.shp"))
if road_shp_candidates:
try:
import geopandas as gpd
gdf = gpd.read_file(road_shp_candidates[0])
gdf = gdf.to_crs(epsg=4326)
filepath.write_text(gdf.to_json(), encoding="utf-8")
except Exception:
pass
if not filepath.exists():
import mapbox_vector_tile
empty_tile = mapbox_vector_tile.encode([]) if mapbox_vector_tile else b""
return Response(content=empty_tile, media_type="application/x-protobuf")
cache_key = f"{project_id}_{layer}"
from B04_wf1_Surface.B04_wf1_Surface_Engine_MvtHelper import generate_mvt_tile
mvt_bytes = generate_mvt_tile(filepath, cache_key, z, x, y, layer_name=layer)
return Response(
content=mvt_bytes,
media_type="application/x-protobuf",
headers={"Content-Encoding": "identity"},
)
except Exception:
import mapbox_vector_tile
empty_tile = mapbox_vector_tile.encode([]) if mapbox_vector_tile else b""
return Response(content=empty_tile, media_type="application/x-protobuf")
+36 -3
View File
@@ -11,8 +11,9 @@
* ui_template_locale에 () (frontend.md §3).
* ========================================================================== */
import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
import { CURRENT_PROJECT_ID_KEY, ROUTES, type RoutePath } from "@config/config_frontend";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import { navigateTo } from "../A00_Common/router";
import {
createButton,
createTag,
@@ -32,7 +33,7 @@ import {
type SurfaceModelSummary,
type SurfaceStatusResponse,
} from "./B04_wf1_Surface_Api_Fetch";
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
import { createWorkflowLayout, type WorkflowStage } from "@ui/ui_template_workflow_layout";
import { createSurfacePointCloudViewer } from "./B04_wf1_Surface_UI_Viewer";
import { createSurfaceTerrainViewer } from "./B04_wf1_Surface_UI_TerrainViewer";
import "./B04_wf1_Surface_UI_Style.css";
@@ -91,7 +92,7 @@ function buildInfoLine(label: string, value: unknown): HTMLElement {
return row;
}
export function renderB04Surface(root: HTMLElement): void {
export async function renderB04Surface(root: HTMLElement): Promise<void> {
let selectedInputFile: SurfaceInputFileSummary | null = null;
let inputFiles: SurfaceInputFileSummary[] = [];
@@ -190,12 +191,44 @@ export function renderB04Surface(root: HTMLElement): void {
bottom.append(statsSection, modelsSection);
workspace.append(topbar, viewer.root, terrainViewer.root, bottom);
const workflowRoutes = [
ROUTES.B03_FILE_INPUT,
ROUTES.B04_WF1_SURFACE,
ROUTES.B05_WF2_ROUTE,
ROUTES.B06_WF3_PROFILE_CROSS,
ROUTES.B07_WF4_DESIGN_DETAIL,
ROUTES.B08_WF5_QUANTITY,
ROUTES.B09_WF6_ESTIMATION,
];
let workflowStages: WorkflowStage[] = [];
const layoutProjectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
if (layoutProjectId) {
try {
const res = await fetch(`/api/projects/${layoutProjectId}/workflow-state`);
if (res.ok) {
const data = await res.json();
workflowStages = data.workflow_state?.stages ?? data.stages ?? [];
}
} catch {
/* 실패 시 빈 배열 → activeStep 기준 폴백으로 이동 허용 */
}
}
const layout = createWorkflowLayout({
title: L("B04_Surface_Title"),
steps: workflowSteps(),
activeStep: 1,
leftPanel: panel,
mainContent: workspace,
stages: workflowStages,
routes: workflowRoutes,
onStepClick: (_stepIndex, route) => {
if (layoutProjectId) {
localStorage.setItem(CURRENT_PROJECT_ID_KEY, layoutProjectId);
navigateTo(route as RoutePath);
}
},
});
function getProjectId(): string | null {
@@ -3,11 +3,13 @@ import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { PLYLoader } from "three/examples/jsm/loaders/PLYLoader.js";
import type { SurfaceModelSummary } from "./B04_wf1_Surface_Api_Fetch";
import { API_BASE_URL } from "@config/config_frontend";
import { fetchVWorldMeta, getVWorldMapUrl, fetchGisGeoJson } from "./B04_wf1_Surface_Api_Fetch";
export interface SurfaceTerrainViewer {
root: HTMLElement;
render: (projectId: string, models: readonly SurfaceModelSummary[]) => void;
updateBgMap: (projectId: string, bgLayer: string) => void;
updateGisLayer: (projectId: string, gisLayer: string) => void;
dispose: () => void;
}
@@ -263,6 +265,46 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
scaleBar.append(scaleLabel);
viewerArea.append(scaleBar);
// Elevation bounds legend bar overlay (I-403)
const legendBar = document.createElement("div");
legendBar.style.position = "absolute";
legendBar.style.top = "16px";
legendBar.style.right = "16px";
legendBar.style.background = "rgba(255, 255, 255, 0.9)";
legendBar.style.border = "1px solid #cbd5e1";
legendBar.style.borderRadius = "6px";
legendBar.style.padding = "8px";
legendBar.style.width = "50px";
legendBar.style.display = "none"; // hidden until contours are loaded
legendBar.style.flexDirection = "column";
legendBar.style.alignItems = "center";
legendBar.style.zIndex = "10";
legendBar.style.boxShadow = "0 2px 6px rgba(0,0,0,0.08)";
legendBar.style.pointerEvents = "none";
const maxValSpan = document.createElement("span");
maxValSpan.style.fontSize = "10px";
maxValSpan.style.fontWeight = "bold";
maxValSpan.style.color = "#b91c1c";
maxValSpan.style.marginBottom = "4px";
const gradientDiv = document.createElement("div");
gradientDiv.style.width = "12px";
gradientDiv.style.height = "120px";
gradientDiv.style.background =
"linear-gradient(to bottom, #d60000 0%, #ff5100 25%, #e6a100 50%, #228b22 75%, #3a85ff 100%)";
gradientDiv.style.borderRadius = "2px";
gradientDiv.style.border = "1px solid #94a3b8";
const minValSpan = document.createElement("span");
minValSpan.style.fontSize = "10px";
minValSpan.style.fontWeight = "bold";
minValSpan.style.color = "#1d4ed8";
minValSpan.style.marginTop = "4px";
legendBar.append(maxValSpan, gradientDiv, minValSpan);
viewerArea.append(legendBar);
// Three.js context variables
let currentProjectId = "";
let currentModelsList: readonly SurfaceModelSummary[] = [];
@@ -324,6 +366,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
}
labelElements.forEach((el) => el.remove());
labelElements.length = 0;
legendBar.style.display = "none";
}
const getFitParams = (object: THREE.Object3D) => {
@@ -376,8 +419,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
const match = currentModelsList.find((m) => {
const typeMatches = m.model_type.toLowerCase() === activeMethod.toLowerCase();
const pathContainsFilter =
m.model_file_path &&
m.model_file_path.toLowerCase().includes(activeFilter.toLowerCase());
m.model_file_path && m.model_file_path.toLowerCase().includes(activeFilter.toLowerCase());
return typeMatches && pathContainsFilter;
});
@@ -471,7 +513,13 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
});
};
let minH = Infinity;
let maxH = -Infinity;
data.contours.forEach((c: any) => {
if (c.level < minH) minH = c.level;
if (c.level > maxH) maxH = c.level;
const points = transform(c.coordinates);
if (points.length < 2) return;
@@ -531,8 +579,18 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
labelElements.push(labelDiv);
}
});
if (minH !== Infinity && maxH !== -Infinity) {
const nearestMin10 = Math.round(minH / 10) * 10;
const nearestMax10 = Math.round(maxH / 10) * 10;
maxValSpan.textContent = `${nearestMax10}m`;
minValSpan.textContent = `${nearestMin10}m`;
legendBar.style.display = "flex";
} else {
legendBar.style.display = "none";
}
} catch (e) {
// Contour loading failed or silent fail
legendBar.style.display = "none";
}
}
@@ -652,6 +710,12 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
currentModelsList = models;
updateSelectedModel();
},
updateBgMap(projectId, bgLayer) {
// 포인트클라우드 뷰어와 인터페이스 조화를 위해 빈 껍데기 함수 정의
},
updateGisLayer(projectId, gisLayer) {
// 포인트클라우드 뷰어와 인터페이스 조화를 위해 빈 껍데기 함수 정의
},
dispose() {
cancelAnimationFrame(animationFrameId);
resizeObserver.disconnect();
+255 -2
View File
@@ -1,7 +1,12 @@
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { RENDER_OPTIONS } from "@config/config_frontend";
import type { SurfacePointCloudSampleResponse } from "./B04_wf1_Surface_Api_Fetch";
import {
fetchVWorldMeta,
getVWorldMapUrl,
fetchGisGeoJson,
type SurfacePointCloudSampleResponse,
} from "./B04_wf1_Surface_Api_Fetch";
export interface SurfacePointCloudViewer {
root: HTMLElement;
@@ -9,6 +14,8 @@ export interface SurfacePointCloudViewer {
optionsGroup: HTMLElement;
statusSpan: HTMLElement;
render: (data: SurfacePointCloudSampleResponse | null) => void;
updateBgMap: (projectId: string, bgLayer: string) => void;
updateGisLayer: (projectId: string, gisLayer: string) => void;
dispose: () => void;
}
@@ -85,7 +92,53 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer {
densityInput.value = "10";
densityLabel.append(densityInput);
optionsDiv.append(sizeLabel, densityLabel);
const bgLabel = document.createElement("label");
bgLabel.textContent = "배경 지도 ";
bgLabel.style.display = "flex";
bgLabel.style.flexDirection = "column";
bgLabel.style.gap = "var(--spacing-4)";
bgLabel.style.marginTop = "var(--spacing-8)";
const bgSelect = document.createElement("select");
bgSelect.className = "b04-surface__select";
const bgOptions = [
{ value: "none", label: "없음" },
{ value: "satellite", label: "위성사진" },
{ value: "hybrid", label: "하이브리드 지도" },
{ value: "white", label: "백지도" },
];
bgOptions.forEach((opt) => {
const o = document.createElement("option");
o.value = opt.value;
o.textContent = opt.label;
bgSelect.append(o);
});
bgLabel.append(bgSelect);
const gisLabel = document.createElement("label");
gisLabel.textContent = "국가 GIS 레이어 ";
gisLabel.style.display = "flex";
gisLabel.style.flexDirection = "column";
gisLabel.style.gap = "var(--spacing-4)";
gisLabel.style.marginTop = "var(--spacing-8)";
const gisSelect = document.createElement("select");
gisSelect.className = "b04-surface__select";
const gisOptions = [
{ value: "none", label: "없음" },
{ value: "지적도", label: "연속지적도" },
{ value: "수계망", label: "수계망" },
{ value: "산사태", label: "산사태위험등급" },
{ value: "행정구역_시군구", label: "시군구 경계" },
{ value: "행정구역_읍면동", label: "읍면동 경계" },
];
gisOptions.forEach((opt) => {
const o = document.createElement("option");
o.value = opt.value;
o.textContent = opt.label;
gisSelect.append(o);
});
gisLabel.append(gisSelect);
optionsDiv.append(sizeLabel, densityLabel, bgLabel, gisLabel);
const optionsGroup = document.createElement("section");
optionsGroup.className = "b04-surface__group";
@@ -151,6 +204,8 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer {
scene.add(axes);
let pointsObject: THREE.Points | null = null;
let bgPlane: THREE.Mesh | null = null;
let gisObjects: THREE.Object3D[] = [];
let animationFrame = 0;
let currentData: SurfacePointCloudSampleResponse | null = null;
@@ -343,15 +398,204 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer {
statusSpan.textContent = ` ${renderPoints.length.toLocaleString()}개 점 표시 중 [높이범위: ~${nearestMin10}m ... ${nearestMax10}m~]`;
}
function clearBgMap(): void {
if (bgPlane) {
bgPlane.geometry.dispose();
if (Array.isArray(bgPlane.material)) {
bgPlane.material.forEach((mat) => mat.dispose());
} else {
bgPlane.material.dispose();
}
scene.remove(bgPlane);
bgPlane = null;
}
}
function clearGisLayers(): void {
gisObjects.forEach((obj) => {
if (obj instanceof THREE.Line) {
obj.geometry.dispose();
if (Array.isArray(obj.material)) {
obj.material.forEach((mat) => mat.dispose());
} else {
obj.material.dispose();
}
}
scene.remove(obj);
});
gisObjects = [];
}
function updateBgMap(projectId: string, bgLayer: string): void {
clearBgMap();
if (bgLayer === "none" || !currentData) return;
const bounds = currentData.bounds;
const xMin = bounds.x_min ?? 0;
const xMax = bounds.x_max ?? 0;
const yMin = bounds.y_min ?? 0;
const yMax = bounds.y_max ?? 0;
const zMin = bounds.z_min ?? 0;
const zMax = bounds.z_max ?? 0;
const xMid = (xMin + xMax) / 2;
const yMid = (yMin + yMax) / 2;
const zMid = (zMin + zMax) / 2;
const xSpan = Math.max(xMax - xMin, 1e-9);
const ySpan = Math.max(yMax - yMin, 1e-9);
const zSpan = Math.max(zMax - zMin, 1e-9);
const scale = 180 / Math.max(xSpan, ySpan, zSpan);
const targetLayer = bgLayer === "gray" ? "white" : bgLayer;
fetchVWorldMeta(projectId, targetLayer)
.then((meta) => {
const planeW = meta.width_meters * scale;
const planeH = meta.height_meters * scale;
const planeGeo = new THREE.PlaneGeometry(planeW, planeH);
const textureLoader = new THREE.TextureLoader();
textureLoader.load(getVWorldMapUrl(projectId, targetLayer), (texture) => {
const planeMat = new THREE.MeshBasicMaterial({
map: texture,
side: THREE.DoubleSide,
transparent: true,
opacity: 0.85,
});
bgPlane = new THREE.Mesh(planeGeo, planeMat);
bgPlane.rotation.x = -Math.PI / 2;
const planeCenterX = (meta.center_x - xMid) * scale;
const planeCenterY = -(meta.center_y - yMid) * scale;
const planeCenterZ = (zMin - zMid) * scale - 0.5;
bgPlane.position.set(planeCenterX, planeCenterZ, planeCenterY);
scene.add(bgPlane);
});
})
.catch((e) => console.log("VWorld 배경 로드 실패:", e.message));
}
function updateGisLayer(projectId: string, gisLayer: string): void {
clearGisLayers();
if (gisLayer === "none" || !currentData) return;
const bounds = currentData.bounds;
const xMin = bounds.x_min ?? 0;
const xMax = bounds.x_max ?? 0;
const yMin = bounds.y_min ?? 0;
const yMax = bounds.y_max ?? 0;
const zMin = bounds.z_min ?? 0;
const zMax = bounds.z_max ?? 0;
const xMid = (xMin + xMax) / 2;
const yMid = (yMin + yMax) / 2;
const zMid = (zMin + zMax) / 2;
const xSpan = Math.max(xMax - xMin, 1e-9);
const ySpan = Math.max(yMax - yMin, 1e-9);
const zSpan = Math.max(zMax - zMin, 1e-9);
const scale = 180 / Math.max(xSpan, ySpan, zSpan);
Promise.all([fetchGisGeoJson(projectId, gisLayer), fetchVWorldMeta(projectId, "white")])
.then(([geoData, meta]) => {
const lonRange = meta.lon_max - meta.lon_min;
const latRange = meta.lat_max - meta.lat_min;
const xRange = meta.x_max - meta.x_min;
const yRange = meta.y_max - meta.y_min;
const toLocal = (lon: number, lat: number): [number, number] => {
const localX_m = meta.x_min + ((lon - meta.lon_min) / lonRange) * xRange;
const localY_m = meta.y_min + ((lat - meta.lat_min) / latRange) * yRange;
return [(localX_m - xMid) * scale, -(localY_m - yMid) * scale];
};
const features = geoData.features || [];
const colors: Record<string, number> = {
지적도: 0xff5500,
수계망: 0x00aaff,
산사태: 0xff0055,
행정구역_시군구: 0x333333,
행정구역_읍면동: 0x666666,
};
const layerColor = colors[gisLayer] || 0x00aa00;
const groundZ = (zMin - zMid) * scale + 0.1;
features.forEach((feat: any) => {
const geom = feat.geometry;
if (!geom) return;
const drawRing = (ring: number[][]) => {
const pts: THREE.Vector3[] = ring.map((pt) => {
const [lx, ly] = toLocal(pt[0], pt[1]);
return new THREE.Vector3(lx, groundZ, ly);
});
if (pts.length === 0) return;
const lineGeo = new THREE.BufferGeometry().setFromPoints(pts);
const lineMat = new THREE.LineBasicMaterial({ color: layerColor });
const line = new THREE.Line(lineGeo, lineMat);
scene.add(line);
gisObjects.push(line);
};
const drawCoordinates = (coords: any[], type: string) => {
if (type === "Polygon") {
coords.forEach((ring: number[][]) => drawRing(ring));
} else if (type === "MultiPolygon") {
coords.forEach((poly: any[]) => drawCoordinates(poly, "Polygon"));
} else if (type === "LineString") {
drawRing(coords as number[][]);
} else if (type === "MultiLineString") {
(coords as number[][][]).forEach((line) => drawRing(line));
}
};
drawCoordinates(geom.coordinates, geom.type);
});
})
.catch((err) => console.error("GIS 벡터 로드 실패:", err));
}
const getProjectIdLocal = () => {
return localStorage.getItem("current_project_id");
};
bgSelect.addEventListener("change", () => {
const projId = getProjectIdLocal();
if (projId) {
updateBgMap(projId, bgSelect.value);
}
});
gisSelect.addEventListener("change", () => {
const projId = getProjectIdLocal();
if (projId) {
updateGisLayer(projId, gisSelect.value);
}
});
function render(data: SurfacePointCloudSampleResponse | null): void {
currentData = data;
clearPoints();
clearBgMap();
clearGisLayers();
if (!data) {
statusSpan.textContent = "데이터가 존재하지 않습니다.";
return;
}
renderPointCloudInternal(data);
setCameraView("top");
const projId = getProjectIdLocal();
if (projId) {
if (bgSelect.value !== "none") {
updateBgMap(projId, bgSelect.value);
}
if (gisSelect.value !== "none") {
updateGisLayer(projId, gisSelect.value);
}
}
}
// Event Listeners
@@ -377,6 +621,11 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer {
if (valSpan) valSpan.textContent = `${val * 10}%`;
if (currentData) {
renderPointCloudInternal(currentData);
const projId = getProjectIdLocal();
if (projId) {
if (bgSelect.value !== "none") updateBgMap(projId, bgSelect.value);
if (gisSelect.value !== "none") updateGisLayer(projId, gisSelect.value);
}
}
});
@@ -388,9 +637,13 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer {
optionsGroup,
statusSpan,
render,
updateBgMap,
updateGisLayer,
dispose: () => {
cancelAnimationFrame(animationFrame);
clearPoints();
clearBgMap();
clearGisLayers();
controls.dispose();
renderer.dispose();
},