point.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. from fastapi import APIRouter, Request, Depends, Query, HTTPException, status,Path
  4. from common.security import valid_access_token
  5. from fastapi.responses import JSONResponse,Response
  6. from fastapi.responses import StreamingResponse
  7. from sqlalchemy.orm import Session
  8. from sqlalchemy import and_, or_,text,literal
  9. from sqlalchemy.sql import func
  10. from sqlalchemy.future import select
  11. from common.auth_user import *
  12. from pydantic import BaseModel
  13. from typing import Any, Dict
  14. # import contextily as ctx
  15. # import geopandas as gpd
  16. # from matplotlib import pyplot as plt
  17. import io
  18. from database import get_db
  19. from typing import List
  20. from models import *
  21. from utils import *
  22. from utils.ry_system_util import *
  23. from utils.video_util import *
  24. from collections import defaultdict
  25. import traceback
  26. from concurrent.futures import ThreadPoolExecutor, as_completed
  27. from multiprocessing import Pool, cpu_count
  28. import json
  29. import time
  30. import math
  31. router = APIRouter()
  32. @router.post("/get_info")
  33. @router.get("/get_info")
  34. async def get_infos(
  35. body = Depends(remove_xss_json),
  36. # zoom_level: float = Query(..., description="Zoom level for clustering"),
  37. # latitude_min: float = Query(..., description="Minimum latitude"),
  38. # latitude_max: float = Query(..., description="Maximum latitude"),
  39. # longitude_min: float = Query(..., description="Minimum longitude"),
  40. # longitude_max: float = Query(..., description="Maximum longitude"),
  41. # dict_value: str = Query(None),
  42. # option:str = Query(None),
  43. db: Session = Depends(get_db)
  44. ):
  45. try:
  46. # 根据缩放级别动态调整分组粒度
  47. zoom_level = float(body['zoom_level'])
  48. zoom_levels = {
  49. 3: 10000, # 全国范围
  50. 4: 5000,
  51. 5: 2500,
  52. 6: 1250,
  53. 7: 825,
  54. 8: 412.5,
  55. 9: 256.25,
  56. 10: 178.125,
  57. 11: 69.0625,
  58. 12: 29.53125,
  59. 13: 13.765625,
  60. 14: 5.8828125,
  61. 15: 2.44140625,
  62. 16: 1.220703125,
  63. 17: 0.6103515625,
  64. 18: 0.30517578125
  65. }
  66. distance_threshold=zoom_levels[int(zoom_level-1)]
  67. # distance_threshold = 100000 / (2.2 ** zoom_level) # 例如:每缩放一级,距离阈值减半
  68. dict_value= body['dict_value'].split(',')
  69. latitude_min = float(body['latitude_min'])
  70. latitude_max = float(body['latitude_max'])
  71. longitude_min = float(body['longitude_min'])
  72. longitude_max = float(body['longitude_max'])
  73. option = body['option'].split(',')
  74. print("1",time.time())
  75. iszjcj = ''
  76. pac = ''
  77. if 'iszjcj'in body:
  78. iszjcj = body['iszjcj']
  79. pac = body['pac']
  80. videos = get_videos(db,dict_value,latitude_min,latitude_max,longitude_min,longitude_max,iszjcj)
  81. infos = get_points(db,option,latitude_min,latitude_max,longitude_min,longitude_max,iszjcj,pac)
  82. # 动态分组逻辑
  83. groups = group_points(videos+infos, distance_threshold)
  84. print("4",time.time())
  85. return {"code": 200,
  86. "msg": "操作成功",
  87. "data": groups}
  88. except Exception as e:
  89. # 处理异常
  90. traceback.print_exc()
  91. raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
  92. @router.post("/get_details")
  93. @router.get("/get_details")
  94. async def get_details(
  95. body = Depends(remove_xss_json),
  96. # center_latitude: float = Query(..., description="网格中心点的纬度"),
  97. # center_longitude: float = Query(..., description="网格中心点的经度"),
  98. # zoom_level: float = Query(..., description="缩放级别"),
  99. db: Session = Depends(get_db)
  100. ):
  101. try:
  102. # 计算网格大小
  103. zoom_level = float(body['zoom_level'])
  104. zoom_levels = {
  105. 3: 10000, # 全国范围
  106. 4: 5000,
  107. 5: 2500,
  108. 6: 1250,
  109. 7: 825,
  110. 8: 412.5,
  111. 9: 256.25,
  112. 10: 178.125,
  113. 11: 69.0625,
  114. 12: 29.53125,
  115. 13: 13.765625,
  116. 14: 5.8828125,
  117. 15: 2.44140625,
  118. 16: 1.220703125,
  119. 17: 0.6103515625,
  120. 18: 0.30517578125
  121. }
  122. distance_threshold=zoom_levels[int(zoom_level-1)]
  123. # distance_threshold = 1000 / (1.5 ** zoom_level) # 例如:每缩放一级,距离阈值减半
  124. grid_size = calculate_grid_size(distance_threshold) # 地球半径为6371公里
  125. center_latitude = float(body['latitude'])
  126. center_longitude = float(body['longitude'])
  127. dict_value = body['dict_value'].split(',')
  128. option = body['option'].split(',')
  129. # 计算网格的经纬度范围
  130. latitude_min, latitude_max, longitude_min, longitude_max = get_grid_bounds_from_center(center_latitude, center_longitude, grid_size)
  131. videos = get_videos(db,dict_value,latitude_min,latitude_max,longitude_min,longitude_max)
  132. infos = get_points(db,option,latitude_min,latitude_max,longitude_min,longitude_max)
  133. return {"code": 200,
  134. "msg": "操作成功",
  135. "data": videos+infos }#{"videos":videos,"points":infos}}
  136. except Exception as e:
  137. # 处理异常
  138. traceback.print_exc()
  139. raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
  140. def calculate_grid_size(distance_threshold):
  141. # 假设地球半径为6371公里,将距离阈值转换为经纬度的差值
  142. # 这里假设纬度变化对距离的影响较小,仅根据经度计算网格大小
  143. earth_radius = 6371 # 地球半径,单位为公里
  144. grid_size = distance_threshold / earth_radius
  145. return grid_size
  146. def get_grid_key(latitude, longitude, grid_size):
  147. # 根据经纬度和网格大小计算网格键
  148. return (math.floor(latitude / grid_size), math.floor(longitude / grid_size))
  149. def get_grid_bounds_from_center(center_latitude, center_longitude, grid_size):
  150. half_grid_size = grid_size / 2
  151. min_latitude = center_latitude - half_grid_size
  152. max_latitude = center_latitude + half_grid_size
  153. min_longitude = center_longitude - half_grid_size
  154. max_longitude = center_longitude + half_grid_size
  155. return min_latitude, max_latitude, min_longitude, max_longitude
  156. def calculate_distance(point1, point2):
  157. # 使用 Haversine 公式计算两点之间的距离
  158. from math import radians, sin, cos, sqrt, atan2
  159. R = 6371 # 地球半径(公里)
  160. lat1, lon1 = radians(point1.latitude), radians(point1.longitude)
  161. lat2, lon2 = radians(point2.latitude), radians(point2.longitude)
  162. dlat = lat2 - lat1
  163. dlon = lon2 - lon1
  164. a = sin(dlat / 2) ** 2 + cos(lat1) * cos(lat2) * sin(dlon / 2) ** 2
  165. c = 2 * atan2(sqrt(a), sqrt(1 - a))
  166. return R * c
  167. def group_points(points, distance_threshold):
  168. grid_size = calculate_grid_size(distance_threshold)
  169. grid = defaultdict(lambda:{"count":0}) #,"list":[]
  170. groups = []
  171. tmp = defaultdict(list)
  172. for point in points:
  173. grid_key = get_grid_key(float(point.latitude), float(point.longitude), grid_size)
  174. lovalue = str(point.latitude)+str(point.longitude)
  175. if lovalue not in tmp['%s-%s'%grid_key]:
  176. tmp['%s-%s'%grid_key].append(lovalue)
  177. grid['%s-%s'%grid_key]['count']+=1
  178. if grid['%s-%s'%grid_key]['count']>1 and len(tmp['%s-%s'%grid_key])<3:
  179. grid['%s-%s'%grid_key]['dataType'] = ''
  180. grid['%s-%s'%grid_key]['id'] = ""
  181. if len(tmp['%s-%s'%grid_key])<2:
  182. grid['%s-%s'%grid_key]['name'] = '多数据点位'
  183. grid['%s-%s' % grid_key]['type'] ='1'
  184. else:
  185. grid['%s-%s' % grid_key]['name'] = '聚合点位'
  186. grid['%s-%s' % grid_key]['type'] = '3'
  187. # grid['%s-%s'%grid_key]['latitude'] = float(point.latitude) #(grid_key[0] + 0.5) * grid_size
  188. # grid['%s-%s'%grid_key]['longitude'] = float(point.longitude) #(grid_key[1] + 0.5) * grid_size
  189. elif grid['%s-%s'%grid_key]['count']==1:
  190. if point.dataType=='video':
  191. grid['%s-%s' % grid_key]['id'] = point.gbIndexCode
  192. else:
  193. grid['%s-%s' % grid_key]['id'] = point.id
  194. grid['%s-%s'%grid_key]['dataType'] = point.dataType
  195. grid['%s-%s'%grid_key]['infoType'] = point.infoType
  196. grid['%s-%s'%grid_key]['name'] = point.name
  197. grid['%s-%s'%grid_key]['type'] ='2'
  198. grid['%s-%s'%grid_key]['latitude'] = float(point.latitude)
  199. grid['%s-%s'%grid_key]['longitude'] = float(point.longitude)
  200. groups = list(grid.values())
  201. return groups
  202. def get_videos(db:Session,dict_value,latitude_min,latitude_max,longitude_min,longitude_max,iszjcj):
  203. que = True
  204. if len(dict_value)>0:
  205. videolist = []
  206. for value in dict_value:
  207. tag_info = get_dict_data_info(db, 'video_type', value)
  208. if tag_info:
  209. if tag_info.dict_label == '全量视频':
  210. break
  211. else:
  212. videolist += [i.video_code for i in tag_get_video_tag_list(db, value)]
  213. else:
  214. que = TPVideoInfo.gbIndexCode.in_(videolist)
  215. if iszjcj!='':
  216. pass
  217. # 查询分组
  218. query = (
  219. select(
  220. TPVideoInfo.gbIndexCode,
  221. TPVideoInfo.latitude,
  222. TPVideoInfo.longitude,
  223. TPVideoInfo.name,
  224. TPVideoInfo.status,
  225. literal('video').label("dataType"),
  226. literal('video').label("infoType")
  227. )
  228. .select_from(TPVideoInfo).where(
  229. and_(
  230. TPVideoInfo.latitude >= latitude_min,
  231. TPVideoInfo.latitude <= latitude_max,
  232. TPVideoInfo.longitude >= longitude_min,
  233. TPVideoInfo.longitude <= longitude_max,
  234. TPVideoInfo.longitude > 0,
  235. TPVideoInfo.latitude > 0, que
  236. )
  237. )
  238. .order_by(TPVideoInfo.status.asc())
  239. )
  240. result = db.execute(query)
  241. videos = result.fetchall()
  242. return videos
  243. def get_points(db:Session,option,latitude_min,latitude_max,longitude_min,longitude_max,iszjcj='',pac=''):
  244. # 使用参数化查询避免 SQL 注入
  245. if isinstance(option, list):
  246. option = tuple(option)
  247. if iszjcj=='zj':
  248. zd = ',T2.name as pacname,T2.pac,T2.parent_pac'
  249. pac = pac[:6]
  250. zjcjtable = f"""select * from tp_geojson_data_qx where pac like '{pac}%'"""
  251. que = f' JOIN ({zjcjtable}) T2 on ST_Intersects(T2.geometry, ST_SRID(POINT(A.longitude, A.latitude), 4326))'
  252. elif iszjcj=='cj':
  253. pac = pac[:9]
  254. zjcjtable = f"""select * from tp_geojson_data_zj where pac like '{pac}%'"""
  255. que = f'LEFT JOIN ({zjcjtable}) T2 on ST_Intersects(T2.geometry, ST_SRID(POINT(A.longitude, A.latitude), 4326))'
  256. else:
  257. que=''
  258. query = text(f"""
  259. SELECT
  260. A.`name`,A.`id`,A.dataType,A.longitude,A.latitude,A.infoType
  261. FROM (
  262. SELECT
  263. *,
  264. ROW_NUMBER() OVER (PARTITION BY longitude, latitude, `name`
  265. ORDER BY longitude, latitude, `name`) AS rn
  266. FROM
  267. `point_data`
  268. WHERE
  269. longitude > 0
  270. AND latitude BETWEEN :latitude_min AND :latitude_max
  271. AND longitude BETWEEN :longitude_min AND :longitude_max
  272. AND dataType IN :option
  273. ) AS A {que}
  274. WHERE rn = 1
  275. """)
  276. # 执行查询并传递参数
  277. result = db.execute(query, {
  278. 'latitude_min': latitude_min,
  279. 'latitude_max': latitude_max,
  280. 'longitude_min': longitude_min,
  281. 'longitude_max': longitude_max,
  282. 'option': option
  283. })
  284. infos = result.fetchall()
  285. return infos
  286. @router.post("/get_geojson")
  287. async def get_geojson(
  288. body = Depends(remove_xss_json),
  289. db: Session = Depends(get_db)
  290. ):
  291. try:
  292. # 根据缩放级别动态调整分组粒度
  293. latitude_min = float(body['latitude_min'])
  294. latitude_max = float(body['latitude_max'])
  295. longitude_min = float(body['longitude_min'])
  296. longitude_max = float(body['longitude_max'])
  297. if latitude_min<-90 or latitude_max>90 :
  298. return JSONResponse(status_code=500, content={"code": 500, "msg": "Latitude must be within [-90.000000, 90.000000]"})
  299. if longitude_min<-180 or longitude_max>180 :
  300. return JSONResponse(status_code=500, content={"code": 500, "msg": "Longitude must be within [-180.000000, 180.000000]"})
  301. table_name = 'tp_geojson_data_zj'
  302. option = body['option']
  303. if 'cj' == option:
  304. table_name = 'tp_geojson_data_cj_sq'
  305. sql = f"""SELECT id,
  306. name,
  307. pac,
  308. ST_AsGeoJSON(geometry) AS geojson,
  309. properties
  310. FROM {table_name}
  311. WHERE ST_Intersects(
  312. geometry,
  313. ST_GeomFromText(
  314. 'POLYGON(({latitude_min} {longitude_min},
  315. {latitude_max} {longitude_min},
  316. {latitude_max} {longitude_max},
  317. {latitude_min} {longitude_max},
  318. {latitude_min} {longitude_min}))',
  319. 4326
  320. )
  321. );"""
  322. result = db.execute(sql)
  323. features = result.fetchall()
  324. return {"code": 200,
  325. "msg": "操作成功","type":"FeatureCollection",
  326. "features": features}
  327. except Exception as e:
  328. # 处理异常
  329. traceback.print_exc()
  330. raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
  331. @router.post("/get_geojson_new")
  332. async def get_geojson(
  333. body = Depends(remove_xss_json),
  334. db: Session = Depends(get_db)
  335. ):
  336. try:
  337. # 根据缩放级别动态调整分组粒度
  338. pac = body['area_code']
  339. table_name = 'tp_geojson_data_zj'
  340. option = body['option']
  341. if 'cj' == option:
  342. table_name = 'tp_geojson_data_cj_sq'
  343. pac = pac[:9]
  344. else:
  345. pac = pac[:6]
  346. sql = f"""SELECT
  347. ST_AsGeoJSON(geometry) AS geometry,
  348. properties
  349. FROM {table_name}
  350. WHERE parent_pac = '{pac}';"""
  351. def gen():
  352. # 1. 写头
  353. yield '{"type":"FeatureCollection","features":['
  354. first = True
  355. # 2. 逐行流式
  356. for geom, prop_json in db.execute(sql): # 迭代器,不 fetchall
  357. if not first:
  358. yield ","
  359. feature = {
  360. "geometry": json.loads(geom),
  361. "properties": {
  362. "PAC": json.loads(prop_json)['PAC'],
  363. "NAME": json.loads(prop_json)['NAME']
  364. }
  365. }
  366. yield json.dumps(feature, ensure_ascii=False)
  367. first = False
  368. # 3. 写尾
  369. yield "]}"
  370. return StreamingResponse(gen(), media_type="application/json",
  371. headers={"Content-Disposition": "attachment; filename=data.geojson"})
  372. except Exception as e:
  373. # 处理异常
  374. traceback.print_exc()
  375. raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
  376. # @router.post("/get_map_img")
  377. # async def get_map_img(
  378. # body: Dict[str, Any] = Depends(remove_xss_json),
  379. # db: Session = Depends(get_db),
  380. # ):
  381. # """
  382. # 输入:
  383. # {
  384. # "latitude_min": 27.0,
  385. # "latitude_max": 30.0,
  386. # "longitude_min": 118.0,
  387. # "longitude_max": 121.0,
  388. # "option": "zj" // "zj" 或 "cj"
  389. # }
  390. # 返回:PNG 图片
  391. # """
  392. # try:
  393. # # 1. 参数提取与合法性校验
  394. # lat_min = float(body["latitude_min"])
  395. # lat_max = float(body["latitude_max"])
  396. # lon_min = float(body["longitude_min"])
  397. # lon_max = float(body["longitude_max"])
  398. # option = body.get("option", "zj")
  399. #
  400. # if not (-90 <= lat_min <= 90 and -90 <= lat_max <= 90):
  401. # raise ValueError("Latitude must be within [-90, 90]")
  402. # if not (-180 <= lon_min <= 180 and -180 <= lon_max <= 180):
  403. # raise ValueError("Longitude must be within [-180, 180]")
  404. #
  405. # table_name = "tp_geojson_data_zj" if option != "cj" else "tp_geojson_data_cj_sq"
  406. #
  407. # # 2. 构造 SQL
  408. # sql = text(
  409. # f"""
  410. # SELECT id, name, pac, ST_AsGeoJSON(geometry) AS geojson, properties
  411. # FROM {table_name}
  412. # WHERE ST_Intersects(
  413. # geometry,
  414. # ST_GeomFromText(
  415. # 'POLYGON(({lon_min} {lat_min},
  416. # {lon_max} {lat_min},
  417. # {lon_max} {lat_max},
  418. # {lon_min} {lat_max},
  419. # {lon_min} {lat_min}))',
  420. # 4326
  421. # )
  422. # );
  423. # """
  424. # )
  425. #
  426. # rows = db.execute(sql).fetchall()
  427. # if not rows:
  428. # raise HTTPException(
  429. # status_code=status.HTTP_404_NOT_FOUND,
  430. # detail="No data within the given bbox."
  431. # )
  432. #
  433. # # 3. 组装 GeoDataFrame
  434. # features = [
  435. # {**json.loads(r.geojson), "properties": json.loads(r.properties)}
  436. # for r in rows
  437. # ]
  438. # gdf = gpd.GeoDataFrame.from_features(features, crs="EPSG:4326")
  439. #
  440. # # 4. 绘图
  441. # fig, ax = plt.subplots(figsize=(6, 6), dpi=150)
  442. # gdf.to_crs(epsg=3857).plot(ax=ax, alpha=0.5, edgecolor="black")
  443. # ctx.add_basemap(ax, source=ctx.providers.Stamen.TonerLite, crs=gdf.to_crs(epsg=3857).crs)
  444. # ax.set_axis_off()
  445. # plt.tight_layout(pad=0)
  446. #
  447. # # 5. 保存成字节流
  448. # buf = io.BytesIO()
  449. # fig.savefig(buf, format="png")
  450. # buf.seek(0)
  451. # plt.close(fig)
  452. #
  453. # # 6. 返回图片
  454. # return StreamingResponse(buf, media_type="image/png")
  455. #
  456. # except Exception as e:
  457. # raise HTTPException(
  458. # status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
  459. # detail=str(e)
  460. # )
  461. # TILE_EXTENT = 4096 # 标准 MVT 精度
  462. # # ---------- 纯 Python 坐标转换 ----------
  463. # def lonlat2xy(lon: float, lat: float) -> tuple[float, float]:
  464. # """4326 -> 3857"""
  465. # x = lon * 20037508.34 / 180
  466. # y = math.log(math.tan((90 + lat) * math.pi / 360)) / (math.pi / 180)
  467. # y = y * 20037508.34 / 180
  468. # return x, y
  469. #
  470. # import mercantile
  471. # from mapbox_vector_tile import encode
  472. # from shapely.geometry import shape
  473. # @router.get("/tile/{option}/{z}/{x}/{y}.pbf")
  474. # async def get_tile(
  475. # option: str = Path(..., regex="^(zj|cj)$"),
  476. # z: int = Path(..., ge=0, le=22),
  477. # x: int = Path(..., ge=0),
  478. # y: int = Path(..., ge=0),
  479. # db: Session = Depends(get_db),
  480. # ):
  481. # """
  482. # 根据 Slippy Map 标准 XYZ 返回 MVT 二进制。
  483. # 前端 layer.url = '/tile/zj/{z}/{x}/{y}.pbf'
  484. # """
  485. # table = "tp_geojson_data_zj" if option == "zj" else "tp_geojson_data_cj_sq"
  486. #
  487. # # 1. 计算瓦片 bbox (4326)
  488. # tile_bounds = mercantile.bounds(mercantile.Tile(x, y, z))
  489. # xmin, ymin, xmax, ymax = tile_bounds.west, tile_bounds.south, tile_bounds.east, tile_bounds.north
  490. # print(xmin, ymin, xmax, ymax)
  491. # # 2. 查相交要素
  492. # sql = text(
  493. # f"""
  494. # SELECT id, name, pac, properties,
  495. # ST_AsGeoJSON(geometry) AS geojson
  496. # FROM {table}
  497. # WHERE ST_Intersects(
  498. # geometry,
  499. # ST_GeomFromText(
  500. # 'POLYGON((
  501. # {ymin} {xmin},
  502. # {ymin} {xmax},
  503. # {ymax} {xmax},
  504. # {ymax} {xmin},
  505. # {ymin} {xmin}))',
  506. # 4326
  507. # )
  508. # );
  509. # """
  510. # )
  511. # rows = db.execute(sql).fetchall()
  512. # if not rows:
  513. # raise HTTPException(status_code=204)
  514. #
  515. # # 3. 构造 MVT features
  516. # features: List[Dict[str, Any]] = []
  517. # bounds_3857 = mercantile.xy_bounds(mercantile.Tile(x, y, z))
  518. # bx, by, bw, bh = bounds_3857.left, bounds_3857.bottom, \
  519. # bounds_3857.right - bounds_3857.left, \
  520. # bounds_3857.top - bounds_3857.bottom
  521. #
  522. # for r in rows:
  523. # # 直接用原始 GeoJSON,不做任何坐标变换
  524. # geo = json.loads(r.geojson)
  525. # features.append({
  526. # "geometry": geo, # 必须是 4326 坐标
  527. # "properties": json.loads(r.properties),
  528. # })
  529. #
  530. # # 4. 生成 MVT
  531. # mvt_bytes = encode([
  532. # {
  533. # "name": "layer",
  534. # "features": features,
  535. # "extent": TILE_EXTENT,
  536. # }
  537. # ])
  538. #
  539. # return Response(content=mvt_bytes, media_type="application/x-protobuf")